From 0a36b1c6509098c4a1b75fc600c6da56c7cb4c91 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:52:15 +0200 Subject: [PATCH 01/41] fix: aggregate issuer debt for collateral checks --- crates/basis_server/src/acceptance/builder.rs | 13 + crates/basis_server/src/acceptance/mod.rs | 189 +++++++++++++- crates/basis_server/src/api.rs | 84 +++++++ crates/basis_server/src/lib.rs | 6 + crates/basis_server/src/main.rs | 13 + .../tests/acceptance_api_integration_tests.rs | 119 ++++++++- crates/basis_server/tests/cors_tests.rs | 13 + .../tests/http_api_integration_tests.rs | 13 + .../tests/redemption_api_integration_tests.rs | 13 + crates/basis_store/src/lib.rs | 79 +++++- crates/basis_store/src/persistence.rs | 152 +++++++++++ crates/basis_store/src/tests.rs | 238 ++++++++++++++++++ specs/acceptance_predicates.md | 50 +++- 13 files changed, 967 insertions(+), 15 deletions(-) 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..92fee8e 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -306,6 +306,7 @@ 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::StorageError(msg) => format!("Storage error: {}", msg), @@ -432,6 +433,7 @@ 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::StorageError(msg) => format!("Storage error: {}", msg), @@ -544,6 +546,7 @@ 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::StorageError(msg) => format!("Storage error: {}", msg), @@ -693,6 +696,7 @@ 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::StorageError(msg) => format!("Storage error: {}", msg), @@ -786,6 +790,7 @@ 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::StorageError(msg) => format!("Storage error: {}", msg), @@ -808,6 +813,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 +938,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 +1010,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 +1059,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); diff --git a/crates/basis_server/src/lib.rs b/crates/basis_server/src/lib.rs index 04a8a68..3db21f0 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -57,6 +57,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: diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index af9f274..94bafa6 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -329,6 +329,19 @@ async fn main() { let result = redemption_manager.tracker.get_issuer_notes(&issuer_pubkey); let _ = response_tx.send(result); } + TrackerCommand::GetProjectedIssuerGrossDebt { + issuer_pubkey, + candidate_recipient, + candidate_total_debt, + response_tx, + } => { + let result = redemption_manager.tracker.projected_issuer_gross_debt( + &issuer_pubkey, + candidate_recipient.as_ref(), + candidate_total_debt, + ); + let _ = response_tx.send(result); + } TrackerCommand::GetNotesByRecipient { recipient_pubkey, response_tx, diff --git a/crates/basis_server/tests/acceptance_api_integration_tests.rs b/crates/basis_server/tests/acceptance_api_integration_tests.rs index 909172c..ba29bd6 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(); @@ -734,11 +844,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(), diff --git a/crates/basis_server/tests/cors_tests.rs b/crates/basis_server/tests/cors_tests.rs index 0ae70ec..3594d6e 100644 --- a/crates/basis_server/tests/cors_tests.rs +++ b/crates/basis_server/tests/cors_tests.rs @@ -68,6 +68,19 @@ mod cors_tests { let result = redemption_manager.tracker.get_issuer_notes(&issuer_pubkey); let _ = response_tx.send(result); } + TrackerCommand::GetProjectedIssuerGrossDebt { + issuer_pubkey, + candidate_recipient, + candidate_total_debt, + response_tx, + } => { + let result = redemption_manager.tracker.projected_issuer_gross_debt( + &issuer_pubkey, + candidate_recipient.as_ref(), + candidate_total_debt, + ); + let _ = response_tx.send(result); + } TrackerCommand::GetNotesByRecipient { recipient_pubkey, response_tx, diff --git a/crates/basis_server/tests/http_api_integration_tests.rs b/crates/basis_server/tests/http_api_integration_tests.rs index db297be..ed819a4 100644 --- a/crates/basis_server/tests/http_api_integration_tests.rs +++ b/crates/basis_server/tests/http_api_integration_tests.rs @@ -68,6 +68,19 @@ mod http_api_tests { let result = redemption_manager.tracker.get_issuer_notes(&issuer_pubkey); let _ = response_tx.send(result); } + TrackerCommand::GetProjectedIssuerGrossDebt { + issuer_pubkey, + candidate_recipient, + candidate_total_debt, + response_tx, + } => { + let result = redemption_manager.tracker.projected_issuer_gross_debt( + &issuer_pubkey, + candidate_recipient.as_ref(), + candidate_total_debt, + ); + let _ = response_tx.send(result); + } TrackerCommand::GetNotesByRecipient { recipient_pubkey, response_tx, diff --git a/crates/basis_server/tests/redemption_api_integration_tests.rs b/crates/basis_server/tests/redemption_api_integration_tests.rs index 0ed36ed..99ce927 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -88,6 +88,19 @@ mod redemption_api_tests { let result = redemption_manager.tracker.get_issuer_notes(&issuer_pubkey); let _ = response_tx.send(result); } + TrackerCommand::GetProjectedIssuerGrossDebt { + issuer_pubkey, + candidate_recipient, + candidate_total_debt, + response_tx, + } => { + let result = redemption_manager.tracker.projected_issuer_gross_debt( + &issuer_pubkey, + candidate_recipient.as_ref(), + candidate_total_debt, + ); + let _ = response_tx.send(result); + } TrackerCommand::GetNotesByRecipient { recipient_pubkey, response_tx, diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index 9c0c136..cdf22aa 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -281,6 +281,7 @@ pub enum NoteError { AmountOverflow, FutureTimestamp, PastTimestamp, + DebtRegression, RedemptionTooEarly, InsufficientCollateral, StorageError(String), @@ -551,12 +552,18 @@ 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. + if let Some(existing_note) = self + .storage + .get_note(issuer_pubkey, ¬e.recipient_pubkey)? + { 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 @@ -863,12 +870,18 @@ 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) { + // Preserve the same cumulative-debt invariant as `add_note` for internal + // updates (for example, redemption completion). + if let Some(existing_note) = self + .storage + .get_note(issuer_pubkey, ¬e.recipient_pubkey)? + { if note.timestamp <= existing_note.timestamp { return Err(NoteError::PastTimestamp); } + if note.amount_collected < existing_note.amount_collected { + return Err(NoteError::DebtRegression); + } } // Prepare AVL tree key: hash(issuer_pubkey || receiver_pubkey) @@ -1134,6 +1147,60 @@ impl TrackerStateManager { self.storage.get_issuer_notes(issuer_pubkey) } + /// Calculate a conservative issuer-wide debt snapshot for an acceptance check. + /// + /// 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, + candidate_recipient: Option<&PubKey>, + candidate_total_debt: u64, + ) -> Result { + // Secondary indices are query accelerators, not liability authority: + // store_note writes the primary record before its indices. Scan the + // primary partition strictly so an interrupted index update cannot hide + // debt from a collateralization decision. + let notes = 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); + } + } + + if candidate_recipient == Some(¬e.recipient_pubkey) { + edge_debt = edge_debt.max(candidate_total_debt); + replaced_candidate_edge = true; + } + + total = total + .checked_add(edge_debt) + .ok_or(NoteError::AmountOverflow)?; + } + + if candidate_recipient.is_none() || !replaced_candidate_edge { + total = total + .checked_add(candidate_total_debt) + .ok_or(NoteError::AmountOverflow)?; + } + + Ok(total) + } + /// Get all notes for a specific recipient pub fn get_recipient_notes( &self, diff --git a/crates/basis_store/src/persistence.rs b/crates/basis_store/src/persistence.rs index f6b2929..0173660 100644 --- a/crates/basis_store/src/persistence.rs +++ b/crates/basis_store/src/persistence.rs @@ -190,6 +190,45 @@ impl NoteStorage { }) } + #[cfg(test)] + pub(crate) fn remove_issuer_index_for_test( + &self, + issuer_pubkey: &PubKey, + ) -> Result<(), NoteError> { + self.issuer_index.remove(issuer_pubkey).map_err(|e| { + NoteError::StorageError(format!("Failed to remove issuer index in test: {}", e)) + })?; + Ok(()) + } + + #[cfg(test)] + pub(crate) fn remove_primary_note_for_test( + &self, + issuer_pubkey: &PubKey, + recipient_pubkey: &PubKey, + ) -> Result<(), NoteError> { + let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); + self.notes_partition.remove(key.to_bytes()).map_err(|e| { + NoteError::StorageError(format!("Failed to remove note in test: {}", e)) + })?; + Ok(()) + } + + #[cfg(test)] + pub(crate) fn corrupt_primary_note_for_test( + &self, + issuer_pubkey: &PubKey, + recipient_pubkey: &PubKey, + ) -> Result<(), NoteError> { + let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); + self.notes_partition + .insert(key.to_bytes(), &[0u8]) + .map_err(|e| { + NoteError::StorageError(format!("Failed to corrupt note in test: {}", e)) + })?; + Ok(()) + } + /// Serialize a list of note keys to bytes fn serialize_note_keys(keys: &[NoteKey]) -> Vec { let mut bytes = Vec::new(); @@ -224,6 +263,64 @@ impl NoteStorage { Ok(keys) } + fn deserialize_note_keys_strict(bytes: &[u8]) -> Result, NoteError> { + if bytes.len() < 4 { + return Err(NoteError::StorageError( + "Invalid note key list format".to_string(), + )); + } + + let count = u32::from_be_bytes(bytes[0..4].try_into().unwrap()) as usize; + let expected_len = 4usize + .checked_add( + count.checked_mul(32).ok_or_else(|| { + NoteError::StorageError("Note key count overflow".to_string()) + })?, + ) + .ok_or_else(|| NoteError::StorageError("Note key list length overflow".to_string()))?; + + if bytes.len() != expected_len { + return Err(NoteError::StorageError( + "Invalid note key list format".to_string(), + )); + } + + let mut keys = Vec::with_capacity(count); + for chunk in bytes[4..].chunks_exact(32) { + let key_bytes: [u8; 32] = chunk.try_into().unwrap(); + keys.push(NoteKey::from_bytes(&key_bytes)); + } + Ok(keys) + } + + fn deserialize_note_value(value_bytes: &[u8]) -> Result<(PubKey, IouNote), NoteError> { + const STORED_NOTE_LEN: usize = 33 + 8 + 8 + 8 + 65 + 33; + + if value_bytes.len() != STORED_NOTE_LEN { + return Err(NoteError::StorageError( + "Invalid stored note format".to_string(), + )); + } + + 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(); + + Ok(( + issuer_pubkey, + IouNote { + recipient_pubkey, + amount_collected, + amount_redeemed, + timestamp, + signature, + }, + )) + } + /// Add a note key to an index partition fn add_to_index( index: &fjall::Partition, @@ -522,6 +619,61 @@ impl NoteStorage { } } + /// Read an issuer's liabilities from the primary note partition. + /// + /// This intentionally does not rely on the secondary issuer index for + /// completeness: the primary record is written before the index. Existing + /// index entries are still validated so stale or corrupt references fail + /// closed instead of being interpreted as zero debt. + pub fn get_issuer_notes_strict( + &self, + issuer_pubkey: &PubKey, + ) -> Result, NoteError> { + let mut notes = Vec::new(); + let mut primary_keys = std::collections::HashSet::new(); + + 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)) + })?; + let (stored_issuer, note) = Self::deserialize_note_value(value_bytes.as_ref())?; + let expected_key = + NoteKey::from_keys(&stored_issuer, ¬e.recipient_pubkey).to_bytes(); + + if stored_key.as_ref() != expected_key.as_slice() { + return Err(NoteError::StorageError( + "Stored note key does not match note contents".to_string(), + )); + } + + if &stored_issuer == issuer_pubkey { + primary_keys.insert(expected_key); + notes.push(note); + } + } + + match self.issuer_index.get(issuer_pubkey) { + Ok(Some(bytes)) => { + for indexed_key in Self::deserialize_note_keys_strict(bytes.as_ref())? { + if !primary_keys.contains(&indexed_key.to_bytes()) { + return Err(NoteError::StorageError( + "Issuer index references a missing or mismatched note".to_string(), + )); + } + } + } + Ok(None) => {} + Err(e) => { + return Err(NoteError::StorageError(format!( + "Failed to read issuer index: {}", + e + ))) + } + } + + Ok(notes) + } + /// Get all notes for a specific recipient (uses recipient index for O(1) lookup) pub fn get_recipient_notes( &self, diff --git a/crates/basis_store/src/tests.rs b/crates/basis_store/src/tests.rs index 64c0a64..c8ef866 100644 --- a/crates/basis_store/src/tests.rs +++ b/crates/basis_store/src/tests.rs @@ -689,4 +689,242 @@ 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, "tx123", 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, "tx123", 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 internal_note_update_rejects_cumulative_debt_regression() { + 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(); + + assert!(matches!( + manager.update_note(&issuer, &create_note(&issuer_secret, &recipient, 40, 2)), + Err(crate::NoteError::DebtRegression) + )); + } + + #[test] + fn projected_issuer_gross_debt_uses_primary_notes_when_index_is_missing() { + 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_issuer_index_for_test(&issuer) + .unwrap(); + + assert_eq!( + manager + .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) + .unwrap(), + 110 + ); + } + + #[test] + fn projected_issuer_gross_debt_rejects_stale_index_entry() { + 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_primary_note_for_test(&issuer, &recipient_b) + .unwrap(); + + assert!(manager + .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) + .is_err()); + } + + #[test] + fn projected_issuer_gross_debt_rejects_corrupt_primary_note() { + 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_primary_note_for_test(&issuer, &recipient_b) + .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()); + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 60, 1)) + .unwrap(); + } + + let manager = TrackerStateManager::new(temp_dir.path()); + assert_eq!( + manager + .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) + .unwrap(), + 110 + ); + } } 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 From 13d333879c6681255fc06a520ecc2c4043baaf60 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:50:04 +0200 Subject: [PATCH 02/41] security: fail closed across tracker authority boundaries --- crates/basis_app/src/ui.rs | 40 +-- crates/basis_cli/src/api.rs | 98 +---- crates/basis_cli/src/commands/note.rs | 27 +- crates/basis_cli/src/commands/reserve.rs | 14 +- crates/basis_cli/src/commands/transaction.rs | 174 +++++---- crates/basis_server/src/api.rs | 336 +++++------------- crates/basis_server/src/config.rs | 99 +++++- .../basis_server/src/create_reserve_tests.rs | 75 ++-- crates/basis_server/src/models.rs | 5 - crates/basis_server/src/redemption_build.rs | 151 ++++---- .../basis_server/src/tracker_box_updater.rs | 186 +++++++++- .../tests/redemption_api_integration_tests.rs | 66 ++-- crates/basis_store/src/contract_compiler.rs | 16 +- crates/basis_store/src/ergo_scanner.rs | 34 +- crates/basis_store/tests/fix_note_state.rs | 67 ---- docs/AGENT_INTERFACE.md | 9 +- docs/BUILD_AND_CREATE_RESERVE.md | 29 +- openapi.yaml | 25 +- specs/client/offchain_redemption_signing.md | 8 +- specs/ergo_node_practices.md | 16 +- specs/security_boundary_remediation.md | 58 +++ specs/server/basis_server_spec.md | 25 +- specs/server/redemption_state_spec.md | 61 ++-- 23 files changed, 795 insertions(+), 824 deletions(-) delete mode 100644 crates/basis_store/tests/fix_note_state.rs create mode 100644 specs/security_boundary_remediation.md diff --git a/crates/basis_app/src/ui.rs b/crates/basis_app/src/ui.rs index 7961838..e0eb256 100644 --- a/crates/basis_app/src/ui.rs +++ b/crates/basis_app/src/ui.rs @@ -1125,7 +1125,6 @@ async fn tracker_assisted_redeem( issuer_signature: hex::encode(issuer_sig), emergency: false, tracker_box_id: None, - change_address: None, }) .await .map_err(|e| format!("tracker build failed: {}", e))?; @@ -1174,13 +1173,7 @@ async fn tracker_assisted_redeem( 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, - ) + .redemption_submit(tx_json) .await .map_err(|e| format!("submit failed: {}", e)) } @@ -1239,31 +1232,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); diff --git a/crates/basis_cli/src/api.rs b/crates/basis_cli/src/api.rs index 75163ea..d9ad48c 100644 --- a/crates/basis_cli/src/api.rs +++ b/crates/basis_cli/src/api.rs @@ -126,12 +126,6 @@ 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 { @@ -452,6 +446,7 @@ pub struct RedemptionPreparationResponse { // Tracker-assisted 2-phase redemption build/submit (POST /redemption/build, /redemption/submit). #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct RedemptionBuildRequest { pub issuer_pubkey: String, pub recipient_pubkey: String, @@ -463,8 +458,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)] @@ -490,12 +483,9 @@ pub struct RedemptionBuildResponse { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] 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)] @@ -672,26 +662,11 @@ impl TrackerClient { } /// 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 { + /// Returns the node-accepted transaction id. Settlement state is reconciled separately from + /// confirmed active-chain evidence; this request carries no caller-asserted accounting data. + pub async fn redemption_submit(&self, signed_tx: serde_json::Value) -> 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 request = RedemptionSubmitRequest { signed_tx }; let response = match ureq::post(&url).send_json(serde_json::to_value(request)?) { Ok(resp) => resp, Err(ureq::Error::Status(code, resp)) => { @@ -703,7 +678,7 @@ impl TrackerClient { Err(e) => return Err(anyhow::anyhow!("redemption submit request failed: {}", e)), }; - if response.status() == 200 { + if response.status() == 200 || response.status() == 202 { let api_response: ApiResponse = response.into_json()?; if api_response.success { Ok(api_response.data.unwrap().tx_id) @@ -782,27 +757,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( @@ -1165,44 +1119,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()?; diff --git a/crates/basis_cli/src/commands/note.rs b/crates/basis_cli/src/commands/note.rs index 62fe6a5..f1bd027 100644 --- a/crates/basis_cli/src/commands/note.rs +++ b/crates/basis_cli/src/commands/note.rs @@ -477,6 +477,15 @@ pub async fn get_note( } /// Redeem a note, either via server-side signing or (default) local signing. +fn reject_legacy_server_sign(server_sign: bool) -> Result<()> { + if server_sign { + anyhow::bail!( + "Legacy server-sign redemption is retired because node acceptance did not prove settlement; use the local signing flow" + ); + } + Ok(()) +} + pub async fn redeem_note( account_manager: &AccountManager, client: &TrackerClient, @@ -484,6 +493,8 @@ pub async fn redeem_note( amount: u64, server_sign: bool, ) -> Result { + reject_legacy_server_sign(server_sign)?; + let current_account = account_manager .get_current() .ok_or_else(|| anyhow::anyhow!("No current account selected"))?; @@ -562,8 +573,8 @@ pub async fn redeem_note( } 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. + // Schnorr signature. Confirmed-chain reconciliation, not this broadcast path, + // owns the later settlement-state transition. let tx_id = execute_local_redemption( client, account_manager, @@ -629,3 +640,15 @@ 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_legacy_server_sign; + + #[test] + fn legacy_server_sign_fails_before_network_or_state_changes() { + let error = reject_legacy_server_sign(true).unwrap_err().to_string(); + assert!(error.contains("retired")); + assert!(reject_legacy_server_sign(false).is_ok()); + } +} diff --git a/crates/basis_cli/src/commands/reserve.rs b/crates/basis_cli/src/commands/reserve.rs index 2a5cc37..7139766 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, diff --git a/crates/basis_cli/src/commands/transaction.rs b/crates/basis_cli/src/commands/transaction.rs index 1b994a0..678d004 100644 --- a/crates/basis_cli/src/commands/transaction.rs +++ b/crates/basis_cli/src/commands/transaction.rs @@ -1,4 +1,4 @@ -use crate::api::{CompleteRedemptionRequest, TrackerClient}; +use crate::api::TrackerClient; use crate::output::progress; use anyhow::Result; use clap::Subcommand; @@ -251,13 +251,40 @@ struct RedemptionBuildResult { 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, +} + +fn require_local_signing(local_sign: bool) -> Result<()> { + if !local_sign { + anyhow::bail!( + "Unsigned node-wallet artifacts are retired because they exported a private dlog key; use --local-sign or the assisted signer" + ); + } + Ok(()) +} + +fn ensure_secret_free_artifact(value: &serde_json::Value) -> Result<()> { + fn contains_forbidden_field(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Object(fields) => fields.iter().any(|(name, child)| { + matches!( + name.as_str(), + "secrets" | "private_key" | "privateKey" | "mnemonic" | "seed" + ) || contains_forbidden_field(child) + }), + serde_json::Value::Array(values) => values.iter().any(contains_forbidden_field), + _ => false, + } + } + + if contains_forbidden_field(value) { + anyhow::bail!("transaction artifact contains a forbidden secret-bearing field"); + } + Ok(()) } /// Build the unsigned redemption transaction JSON and collect all metadata needed to either @@ -395,13 +422,8 @@ async fn build_redemption_tx( 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"); + // Private keys are never requested while building a transaction artifact. + // The local signer resolves its witness only inside the in-memory signing boundary. // Get tracker lookup proof for context var #8 from server progress!("🔍 Retrieving tracker lookup proof from server..."); @@ -687,11 +709,9 @@ async fn build_redemption_tx( "inputsRaw": inputs_raw, "dataInputsRaw": [ tracker_box_binary - ], - "secrets": { - "dlog": [recipient_private_key] - } + ] }); + ensure_secret_free_artifact(&transaction_json)?; Ok(RedemptionBuildResult { transaction_json, @@ -709,13 +729,11 @@ async fn build_redemption_tx( 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, }) } @@ -793,7 +811,6 @@ pub async fn execute_local_redemption( .await?; let tx_id = sign_and_broadcast_local(SignLocalParams { - client, issuer_pubkey, recipient_pubkey, amount, @@ -818,32 +835,13 @@ pub async fn execute_local_redemption( 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."); - } + // A node-accepted transaction is intentionally not promoted to settled here. + // The confirmed-chain reconciler owns the local settlement transition. progress!("✅ Redemption broadcast with LOCAL proveDlog signatures."); progress!("📋 Transaction ID: {}", tx_id); @@ -865,6 +863,8 @@ pub async fn generate_redemption_transaction( recipient_secret: Option, fee_secret: Option, ) -> Result { + require_local_signing(local_sign)?; + if local_sign { let tx_id = execute_local_redemption( client, @@ -1093,7 +1093,6 @@ pub async fn redeem_tracker_assisted( 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))?; @@ -1136,9 +1135,7 @@ pub async fn redeem_tracker_assisted( .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_secret = resolve_dlog_secret(&recipient_secret, "recipient")?; let receiver_sk = SecretKey::dlog_from_bytes(&receiver_secret) .ok_or_else(|| anyhow::anyhow!("invalid recipient dlog secret"))?; @@ -1166,13 +1163,7 @@ pub async fn redeem_tracker_assisted( progress!("📡 Submitting via tracker (POST /redemption/submit)..."); let tx_id = client - .redemption_submit( - tx_json, - issuer_pubkey, - recipient_pubkey, - amount, - build.new_already_redeemed, - ) + .redemption_submit(tx_json) .await .map_err(|e| anyhow::anyhow!("submit failed: {}", e))?; progress!("✅ Redemption broadcast. Transaction ID: {}", tx_id); @@ -1348,7 +1339,6 @@ fn ergo_tree_to_p2pk_address(ergo_tree_hex: &str) -> Result { /// 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, @@ -1375,39 +1365,17 @@ struct SignLocalParams<'a> { 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 - ) - })? - } - }; +fn resolve_dlog_secret(provided: &Option, label: &str) -> Result<[u8; 32]> { + let hexstr = provided.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "{} signing witness is required locally; private keys are never exported from a node wallet", + label + ) + })?; let bytes = hex::decode(hexstr.trim()) .map_err(|e| anyhow::anyhow!("{} secret is not valid hex: {}", label, e))?; if bytes.len() != 32 { @@ -1585,15 +1553,8 @@ async fn sign_and_broadcast_local(p: SignLocalParams<'_>) -> Result { 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_secret = resolve_dlog_secret(&p.recipient_secret, "recipient")?; + let fee_secret = resolve_dlog_secret(&p.fee_secret, "fee-payer")?; 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) @@ -1642,10 +1603,6 @@ async fn sign_and_broadcast_local(p: SignLocalParams<'_>) -> Result { .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) @@ -1901,4 +1858,37 @@ mod tests { let tree = address_to_ergo_tree(&address).unwrap(); assert_eq!(tree, format!("0008cd{}", pubkey)); } + + #[test] + fn non_local_generation_is_rejected_before_secret_export() { + let error = require_local_signing(false).unwrap_err().to_string(); + assert!(error.contains("retired")); + assert!(require_local_signing(true).is_ok()); + } + + #[test] + fn transaction_artifact_rejects_secret_bearing_fields() { + let sentinel = "sentinel-private-key-do-not-export"; + let artifact = serde_json::json!({ + "tx": {"inputs": [], "dataInputs": [], "outputs": []}, + "secrets": {"dlog": [sentinel]} + }); + assert!(ensure_secret_free_artifact(&artifact).is_err()); + + let safe = serde_json::json!({ + "tx": {"inputs": [], "dataInputs": [], "outputs": []}, + "inputsRaw": [], + "dataInputsRaw": [] + }); + assert!(ensure_secret_free_artifact(&safe).is_ok()); + assert!(!safe.to_string().contains(sentinel)); + } + + #[test] + fn missing_witness_never_falls_back_to_node_wallet_export() { + let error = resolve_dlog_secret(&None, "fee-payer") + .unwrap_err() + .to_string(); + assert!(error.contains("never exported")); + } } diff --git a/crates/basis_server/src/api.rs b/crates/basis_server/src/api.rs index 92fee8e..ae1eff6 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -1590,12 +1590,26 @@ pub async fn get_key_status( ) } +fn legacy_settlement_disabled() -> bool { + true +} + // Initiate redemption process #[axum::debug_handler] pub async fn initiate_redemption( State(state): State, Json(payload): Json, ) -> (StatusCode, Json>) { + if legacy_settlement_disabled() { + return ( + StatusCode::GONE, + Json(crate::models::error_response( + "Legacy server-sign redemption is retired; use a locally reviewed signing flow and confirmed-chain reconciliation" + .to_string(), + )), + ); + } + tracing::debug!("Initiating redemption: {:?}", payload); // Convert recipient public key to P2PK address @@ -1998,118 +2012,24 @@ pub async fn initiate_redemption( } } -// Complete redemption process by removing the note from tracker state +/// 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( State(_state): State, - Json(payload): Json, + 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(), - )), - ) - } - } + ( + StatusCode::GONE, + Json(crate::models::error_response( + "Direct redemption completion is retired; settlement state is advanced only by the confirmed-chain reconciler" + .to_string(), + )), + ) } // Get tracker lookup proof for context var #8 @@ -3504,21 +3424,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_known_legacy_reserve_contract() { + 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 @@ -3611,146 +3528,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_known_legacy_reserve_contract() { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(crate::models::error_response(e)), + ); + } let reserve_contract_address = config.basis_reserve_contract_p2s(); @@ -3767,6 +3577,28 @@ 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); + } + + #[test] + fn legacy_settlement_stays_disabled() { + assert!(legacy_settlement_disabled()); + } +} + /// Get the current tracker state: local digest, confirmed on-chain digest, and /// any in-flight pending update transaction. #[axum::debug_handler] diff --git a/crates/basis_server/src/config.rs b/crates/basis_server/src/config.rs index a68f5ba..e6b17a0 100644 --- a/crates/basis_server/src/config.rs +++ b/crates/basis_server/src/config.rs @@ -48,7 +48,7 @@ impl ServerConfig { } /// Ergo blockchain configuration -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct ErgoConfig { /// Ergo node configuration pub node: NodeConfig, @@ -63,6 +63,24 @@ pub struct ErgoConfig { 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("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 { @@ -132,6 +150,20 @@ impl AppConfig { &self.ergo.basis_reserve_contract_p2s } + /// Reject the known historical strict-insert contract when a caller is + /// about to construct insert-or-update reserve state. + pub fn reject_known_legacy_reserve_contract(&self) -> Result<(), String> { + let legacy = basis_store::contract_compiler::get_basis_reserve_contract_p2s() + .map_err(|e| format!("cannot resolve historical reserve contract identity: {e}"))?; + if self.basis_reserve_contract_p2s() == legacy { + return Err( + "configured reserve contract is the retired strict-insert generation; configure an explicitly reviewed insert-or-update P2S before building reserve transactions" + .to_string(), + ); + } + Ok(()) + } + /// 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 { @@ -328,6 +360,71 @@ impl AppConfig { mod tests { use super::*; + #[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()), + }, + 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 known_strict_insert_contract_fails_closed() { + 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, + }, + transaction: TransactionConfig { + fee: 1_000_000, + change_address: None, + }, + acceptance: AcceptanceConfig::empty(), + }; + + let error = config.reject_known_legacy_reserve_contract().unwrap_err(); + assert!(error.contains("retired strict-insert")); + + config.ergo.basis_reserve_contract_p2s = "explicitly-reviewed-new-generation".to_string(); + assert!(config.reject_known_legacy_reserve_contract().is_ok()); + } + #[test] fn test_tracker_public_key_hex_format() { let config = AppConfig { diff --git a/crates/basis_server/src/create_reserve_tests.rs b/crates/basis_server/src/create_reserve_tests.rs index f248ade..55c2720 100644 --- a/crates/basis_server/src/create_reserve_tests.rs +++ b/crates/basis_server/src/create_reserve_tests.rs @@ -26,6 +26,10 @@ 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()); @@ -67,7 +71,7 @@ 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(), ), @@ -142,36 +146,45 @@ 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::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; + + assert!(!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(), + "0703e8c3e4877e2f7b79e0e407421a81a1619ea64e37e5e4e77454d1e361e6f80b12" + ); + assert!(reserve_response.fee > 0); + } + + #[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("retired strict-insert")); } #[tokio::test] diff --git a/crates/basis_server/src/models.rs b/crates/basis_server/src/models.rs index bddfcea..674bd9c 100644 --- a/crates/basis_server/src/models.rs +++ b/crates/basis_server/src/models.rs @@ -390,11 +390,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..fcd582a 100644 --- a/crates/basis_server/src/redemption_build.rs +++ b/crates/basis_server/src/redemption_build.rs @@ -49,6 +49,7 @@ const FEE_CONTRACT_TREE: &str = "1005040004000e36100204a00b08cd0279be667ef9dcbba // ----- request / response models ----- #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub struct RedemptionBuildRequest { pub issuer_pubkey: String, pub recipient_pubkey: String, @@ -63,9 +64,6 @@ pub struct RedemptionBuildRequest { /// 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)] @@ -100,16 +98,9 @@ pub struct RedemptionBuildResponse { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] 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)] @@ -287,6 +278,22 @@ fn select_fee_inputs( None } +/// Derive fee-input change from the exact script whose inputs the tracker signs. +/// Mixed-script funding is rejected because one shared change output would have +/// ambiguous ownership even if global value conservation still held. +fn fee_change_address(fee_boxes: &[NodeBox]) -> Result { + let first = fee_boxes + .first() + .ok_or_else(|| "no selected fee inputs".to_string())?; + if fee_boxes + .iter() + .any(|fee_box| fee_box.ergo_tree != first.ergo_tree) + { + return Err("selected fee inputs do not share one owner script".to_string()); + } + ergo_tree_to_p2pk_address(&first.ergo_tree) +} + type ApiResult = Result>)>; fn api_err(status: StatusCode, msg: impl Into) -> ApiResult { @@ -311,6 +318,10 @@ async fn build_redemption_inner( state: &AppState, payload: &RedemptionBuildRequest, ) -> ApiResult { + if let Err(e) = state.config.reject_known_legacy_reserve_contract() { + return api_err(StatusCode::SERVICE_UNAVAILABLE, e); + } + // Validate public keys. let issuer_pubkey_bytes = match hex::decode(&payload.issuer_pubkey) { Ok(b) if b.len() == 33 => b, @@ -736,12 +747,15 @@ async fn build_redemption_inner( } }; - // 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()), + // Change belongs to the authenticated owner of every selected fee input. + let change_address = match fee_change_address(&fee_boxes) { + Ok(address) => address, + Err(e) => { + return api_err( + StatusCode::INTERNAL_SERVER_ERROR, + format!("fee change: {e}"), + ) + } }; // Build the reserve R5 register (updated reserve state as SAvlTree constant). @@ -1023,39 +1037,14 @@ async fn build_redemption_inner( /// 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). +/// Node acceptance is not active-chain confirmation. This endpoint deliberately +/// accepts no accounting metadata and performs no local settlement mutation; +/// the confirmed-chain reconciler owns that transition. #[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, @@ -1067,34 +1056,8 @@ pub async fn submit_redemption( } }; - // 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, + StatusCode::ACCEPTED, Json(success_response(RedemptionSubmitResponse { tx_id })), ) } @@ -1182,6 +1145,50 @@ mod tests { assert!(select_fee_inputs(&[], 1_000_000, "zz").is_none()); } + #[test] + fn build_request_rejects_caller_selected_change() { + let payload = serde_json::json!({ + "issuer_pubkey": "02".repeat(33), + "recipient_pubkey": "03".repeat(33), + "amount": 1, + "timestamp": 1, + "issuer_signature": "00".repeat(65), + "change_address": "attacker-selected" + }); + + assert!(serde_json::from_value::(payload).is_err()); + } + + #[test] + fn fee_change_is_bound_to_one_input_owner_script() { + let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; + let mut first = wallet_box("a", 600_000, false); + first.ergo_tree = format!("0008cd{pubkey}"); + let mut second = wallet_box("b", 600_000, false); + second.ergo_tree = first.ergo_tree.clone(); + + assert_eq!( + fee_change_address(&[first.clone(), second.clone()]).unwrap(), + pubkey_to_address(pubkey).unwrap() + ); + + second.ergo_tree = format!("0008cd{}", "02".repeat(33)); + assert!(fee_change_address(&[first, second]).is_err()); + } + + #[test] + fn submit_request_rejects_unverified_accounting_metadata() { + let payload = serde_json::json!({ + "signed_tx": {"inputs": [], "dataInputs": [], "outputs": []}, + "issuer_pubkey": "02".repeat(33), + "recipient_pubkey": "03".repeat(33), + "redeemed_amount": 1, + "new_already_redeemed": 1 + }); + + assert!(serde_json::from_value::(payload).is_err()); + } + fn r5_box(r5: Option<&str>) -> NodeBox { let mut registers = std::collections::HashMap::new(); if let Some(v) = r5 { diff --git a/crates/basis_server/src/tracker_box_updater.rs b/crates/basis_server/src/tracker_box_updater.rs index c41f9ba..9ebe8db 100644 --- a/crates/basis_server/src/tracker_box_updater.rs +++ b/crates/basis_server/src/tracker_box_updater.rs @@ -168,7 +168,7 @@ impl SharedTrackerState { } /// Configuration for the tracker box updater -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct TrackerBoxUpdateConfig { pub node_url: String, pub api_key: Option, @@ -178,6 +178,22 @@ pub struct TrackerBoxUpdateConfig { pub tracker_secret_key: Option<[u8; 32]>, } +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("change_address", &self.change_address) + .field( + "tracker_secret_key", + &self.tracker_secret_key.as_ref().map(|_| ""), + ) + .finish() + } +} + impl Default for TrackerBoxUpdateConfig { fn default() -> Self { Self { @@ -191,6 +207,152 @@ impl Default for TrackerBoxUpdateConfig { } } +#[cfg(test)] +mod secret_redaction_tests { + use super::{TrackerBoxUpdateConfig, TrackerBoxUpdater}; + use std::io::{self, Write}; + use std::sync::{Arc, Mutex}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + #[derive(Clone, Default)] + struct SharedWriter(Arc>>); + + struct BufferWriter(Arc>>); + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedWriter { + type Writer = BufferWriter; + + fn make_writer(&'a self) -> Self::Writer { + BufferWriter(Arc::clone(&self.0)) + } + } + + impl Write for BufferWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0 + .lock() + .expect("log buffer lock") + .extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + async fn one_error_response(body: &'static str) -> (String, tokio::task::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind loopback listener"); + let address = listener.local_addr().expect("loopback address"); + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept request"); + let mut request = Vec::new(); + let mut buffer = [0u8; 4096]; + loop { + let count = stream.read(&mut buffer).await.expect("read request"); + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + if let Some(header_end) = + request.windows(4).position(|window| window == b"\r\n\r\n") + { + 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; + } + } + } + + let response = format!( + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write response"); + request + }); + (format!("http://{address}"), task) + } + + #[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); + } + + #[tokio::test(flavor = "current_thread")] + async fn node_signing_error_and_logs_do_not_echo_secret_bearing_bodies() { + let tracker_sentinel = "sentinel-tracker-private-key-do-not-log"; + let api_sentinel = "sentinel-updater-api-key-do-not-log"; + let response_sentinel = "sentinel-node-response-do-not-log"; + let (node_url, server) = one_error_response(response_sentinel).await; + let config = TrackerBoxUpdateConfig { + node_url, + api_key: Some(api_sentinel.to_string()), + ..TrackerBoxUpdateConfig::default() + }; + let unsigned_tx = serde_json::json!({ + "tx": {"inputs": [], "dataInputs": [], "outputs": []}, + "secrets": {"dlog": [tracker_sentinel]} + }); + + let writer = SharedWriter::default(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_writer(writer.clone()) + .finish(); + let dispatch = tracing::Dispatch::new(subscriber); + let guard = tracing::dispatcher::set_default(&dispatch); + let error = TrackerBoxUpdater::sign_transaction(&config, unsigned_tx) + .await + .expect_err("loopback node must reject signing") + .to_string(); + drop(guard); + + let request = server.await.expect("loopback server task"); + assert!(request + .windows(tracker_sentinel.len()) + .any(|window| window == tracker_sentinel.as_bytes())); + assert!(request + .windows(api_sentinel.len()) + .any(|window| window == api_sentinel.as_bytes())); + + let logs = String::from_utf8(writer.0.lock().expect("log buffer lock").clone()) + .expect("UTF-8 logs"); + for sentinel in [tracker_sentinel, api_sentinel, response_sentinel] { + assert!(!error.contains(sentinel)); + assert!(!logs.contains(sentinel)); + } + assert!(logs.contains("Node signing request completed")); + } +} + /// Error type for tracker box updater operations #[derive(Debug, thiserror::Error)] pub enum TrackerBoxUpdaterError { @@ -739,7 +901,7 @@ impl TrackerBoxUpdater { config: &TrackerBoxUpdateConfig, unsigned_tx: serde_json::Value, ) -> Result { - info!("Signing unsigned transaction: {}", unsigned_tx); + info!("Requesting node signature for tracker-box update"); let client = reqwest::Client::new(); let url = format!( @@ -759,15 +921,12 @@ impl TrackerBoxUpdater { let status = response.status(); let body_text = response.text().await.unwrap_or_default(); - info!( - "/wallet/transaction/sign response: status={}, body={}", - status, body_text - ); + info!(status = %status, "Node signing request completed"); if !status.is_success() { return Err(TrackerBoxUpdaterError::SigningFailed(format!( - "HTTP {}: {}", - status, body_text + "HTTP {}", + status ))); } @@ -780,7 +939,7 @@ impl TrackerBoxUpdater { config: &TrackerBoxUpdateConfig, signed_tx: &serde_json::Value, ) -> 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('/')); @@ -797,15 +956,12 @@ impl TrackerBoxUpdater { 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"); if !status.is_success() { return Err(TrackerBoxUpdaterError::BroadcastFailed(format!( - "HTTP {}: {}", - status, body_text + "HTTP {}", + status ))); } diff --git a/crates/basis_server/tests/redemption_api_integration_tests.rs b/crates/basis_server/tests/redemption_api_integration_tests.rs index 99ce927..125e3f0 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -2,7 +2,7 @@ //! //! This module tests the redemption-related HTTP endpoints: //! - POST /redeem: Initiate redemption -//! - POST /redeem/complete: Complete redemption +//! - POST /redeem/complete: Retired completion tombstone //! - GET /proof/redemption: Get redemption proof //! - POST /redemption/prepare: Prepare redemption data //! - POST /tracker/signature: Request tracker signature @@ -356,8 +356,7 @@ mod redemption_api_tests { // ============================================================================ #[tokio::test] - async fn test_redeem_invalid_hex_pubkey() { - // Test that invalid hex encoding for recipient pubkey returns 400 + async fn test_redeem_legacy_route_is_gone_for_invalid_hex_payload() { let state = create_mock_app_state().await; let request = RedeemRequest { @@ -375,7 +374,7 @@ mod redemption_api_tests { let response = initiate_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_eq!(response.0, StatusCode::GONE); let body = &response.1; assert!(!body.success); assert!(body.error.is_some()); @@ -383,8 +382,7 @@ mod redemption_api_tests { } #[tokio::test] - async fn test_redeem_invalid_pubkey_length() { - // Test that wrong-length public key returns 400 + async fn test_redeem_legacy_route_is_gone_for_wrong_length_payload() { let state = create_mock_app_state().await; // 32 bytes instead of 33 @@ -405,19 +403,14 @@ mod redemption_api_tests { 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; - assert!( - !body.success || body.data.is_none(), - "Expected failure or no data, got: {:?}", - body - ); + assert_eq!(response.0, StatusCode::GONE); + assert!(!body.success); + assert!(body.data.is_none()); } #[tokio::test] - async fn test_redeem_note_not_found() { - // Test redemption for a note that doesn't exist in the tracker + async fn test_redeem_legacy_route_is_gone_without_state_lookup() { let state = create_mock_app_state().await; let request = RedeemRequest { @@ -436,16 +429,14 @@ mod redemption_api_tests { 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); + assert_eq!(response.0, StatusCode::GONE); 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 + async fn test_redeem_legacy_route_is_gone_for_emergency_payload() { let state = create_mock_app_state().await; let request = RedeemRequest { @@ -461,24 +452,17 @@ mod redemption_api_tests { 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 + assert_eq!(response.0, StatusCode::GONE); 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 - ); - } + assert!(!body.success); + assert!(body + .error + .as_deref() + .unwrap_or_default() + .contains("retired")); } // ============================================================================ @@ -486,8 +470,7 @@ mod redemption_api_tests { // ============================================================================ #[tokio::test] - async fn test_complete_redemption_invalid_hex() { - // Test that invalid hex encoding returns 400 + async fn test_complete_redemption_is_gone_for_invalid_hex_payload() { let state = create_mock_app_state().await; let request = CompleteRedemptionRequest { @@ -502,15 +485,14 @@ mod redemption_api_tests { let response = complete_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_eq!(response.0, StatusCode::GONE); 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 + async fn test_complete_redemption_is_gone_for_wrong_length_payload() { let state = create_mock_app_state().await; // 32 bytes instead of 33 @@ -528,15 +510,14 @@ mod redemption_api_tests { let response = complete_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_eq!(response.0, StatusCode::GONE); 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 + async fn test_complete_redemption_is_gone_without_state_lookup() { let state = create_mock_app_state().await; let request = CompleteRedemptionRequest { @@ -552,8 +533,7 @@ mod redemption_api_tests { 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); + assert_eq!(response.0, StatusCode::GONE); let body = &response.1; assert!(!body.success); assert!(body.error.is_some()); diff --git a/crates/basis_store/src/contract_compiler.rs b/crates/basis_store/src/contract_compiler.rs index d201242..0a61eab 100644 --- a/crates/basis_store/src/contract_compiler.rs +++ b/crates/basis_store/src/contract_compiler.rs @@ -12,9 +12,13 @@ pub enum CompilerError { ErgoLibUnavailable(String), } -/// Get the Basis reserve contract P2S address +/// 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()) } @@ -33,8 +37,8 @@ mod tests { use ergo_lib::ergotree_ir::serialization::SigmaSerializable; #[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 +68,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 +105,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..7c19821 100644 --- a/crates/basis_store/src/ergo_scanner.rs +++ b/crates/basis_store/src/ergo_scanner.rs @@ -107,7 +107,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 +121,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 { @@ -150,8 +162,7 @@ impl ServerState { // 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); @@ -169,8 +180,8 @@ impl ServerState { // 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"); } @@ -832,6 +843,19 @@ mod tests { use super::*; use std::collections::HashMap; + #[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() { // Create a mock scan box with a public key that has the 0x07 prefix 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/docs/AGENT_INTERFACE.md b/docs/AGENT_INTERFACE.md index b526b22..2ff4f76 100644 --- a/docs/AGENT_INTERFACE.md +++ b/docs/AGENT_INTERFACE.md @@ -159,10 +159,11 @@ $ 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). +- `note redeem --json` → `{amount, server_sign: false, tx_id}`; `--server-sign` + is retired and fails before network or persistence effects. +- `transaction generate-redemption --json` → `{tx_id}` with `--local-sign`. + The historical non-local artifact mode is retired because transaction + artifacts must never contain exported private keys. - `transaction redeem-assisted --json` → `{tx_id}`. - `test test-redemption --json` → `{issuer_pubkey, recipient_pubkey, redemption_amount, output_file, transaction}`. diff --git a/docs/BUILD_AND_CREATE_RESERVE.md b/docs/BUILD_AND_CREATE_RESERVE.md index 2a1188d..f71072f 100644 --- a/docs/BUILD_AND_CREATE_RESERVE.md +++ b/docs/BUILD_AND_CREATE_RESERVE.md @@ -4,7 +4,7 @@ ```bash # Navigate to the project root -cd /home/kushti/chaincash/basis-tracker +cd basis-tracker # Build the entire workspace (this will build basis-cli) cargo build --release @@ -29,6 +29,10 @@ The built binary will be available at: ## Step 3: Create the Reserve for Alice +The server refuses to build against the known historical strict-insert P2S. +Configure an insert-or-update contract identity promoted from reviewed source +and parity evidence before following this step. + ```bash # Create a reserve using the specified NFT ID ./target/release/basis_cli reserve create \ @@ -121,22 +125,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/openapi.yaml b/openapi.yaml index 15e8824..8895218 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -280,8 +280,11 @@ paths: /redeem: post: - summary: Initiate redemption process - description: Initiate the redemption of an IOU note from a reserve + summary: Retired server-sign redemption route + description: | + Compatibility tombstone. Server-side redemption is retired because a + node-accepted transaction is not confirmed settlement evidence. Use a + locally reviewed signing flow and confirmed-chain reconciliation. operationId: initiateRedemption tags: - Redemption @@ -292,20 +295,8 @@ paths: 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: Legacy server-sign redemption is retired content: application/json: schema: @@ -1174,4 +1165,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/client/offchain_redemption_signing.md b/specs/client/offchain_redemption_signing.md index 6fa781a..2bb19f5 100644 --- a/specs/client/offchain_redemption_signing.md +++ b/specs/client/offchain_redemption_signing.md @@ -161,8 +161,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 +176,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/ergo_node_practices.md b/specs/ergo_node_practices.md index a209a19..48b8b16 100644 --- a/specs/ergo_node_practices.md +++ b/specs/ergo_node_practices.md @@ -112,18 +112,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/security_boundary_remediation.md b/specs/security_boundary_remediation.md new file mode 100644 index 0000000..85522e3 --- /dev/null +++ b/specs/security_boundary_remediation.md @@ -0,0 +1,58 @@ +# Tracker Security Boundaries + +This change makes wallet authority, signing authority, and settlement evidence +explicit. It intentionally breaks legacy conveniences that allowed a remote +caller to exercise tracker-owned capabilities or to assert settlement state. + +## Enforced invariants + +1. The HTTP service never forwards a reserve-creation request to the configured + node wallet. `/reserves/create` remains a payload builder; the reserve owner + reviews, signs, and submits that payload with their own wallet. +2. Change from tracker-signed fee inputs is paid only to the common P2PK script + of those inputs. A request cannot choose the change address, and mixed-owner + fee inputs are rejected. +3. Node API credentials and tracker signing material are redacted from `Debug` + output. Signing and broadcast logs contain status and identifiers only, not + request or response bodies. +4. A transaction artifact never contains private-key, mnemonic, seed, or + `secrets` fields. The historical node-wallet artifact path is retired; local + signing keeps the witness inside the signing boundary, and a missing witness + never falls back to exporting it from the node wallet. +5. Node acceptance is not settlement confirmation. `/redemption/submit` + accepts only the signed transaction and does not mutate note or reserve-tree + accounting. `/redeem/complete` is a `410 Gone` tombstone. +6. The legacy server-sign redemption path is disabled before any network, + signing, broadcast, or persistence effect. +7. Builders reject the known historical strict-insert reserve P2S while they + emit insert-or-update AVL state. A new contract identity must be promoted + from reviewed source and parity evidence as a separate change. + +## Compatibility changes + +| Surface | New behavior | +| --- | --- | +| `POST /reserves/submit` | Returns `410 Gone`; no node-wallet request is made. | +| `reserve create --submit` | Returns an error; omit the flag and sign the payload in the owner wallet. | +| Assisted build `change_address` | Removed and rejected as an unknown field. | +| `POST /redemption/submit` | Accepts `{ "signed_tx": ... }`, returns `202 Accepted`, and performs no settlement mutation. | +| `POST /redeem/complete` | Returns `410 Gone`. | +| `note redeem --server-sign` | Returns an error before effects. | +| Non-local `transaction generate-redemption` | Returns an error; use `--local-sign` or the assisted signer. | +| Reserve P2S `3PQnJ92K...` | Reserve payload/redemption builders return `503 Service Unavailable`; no successor is constructed against the incompatible generation. | + +## Settlement hand-off + +The confirmed-chain reconciler is the sole intended producer of settled local +state. It must authenticate the expected reserve successor, bind it to a block +on the selected active chain, apply the configured confirmation policy, and +support deterministic rollback before advancing note and reserve-tree state. +Until that reconciler is available, a submitted transaction remains only +node-accepted and must not be represented as settled. + +## Regression boundary + +Tests use sentinel strings and local data only. They prove that retired +handlers fail closed, caller accounting fields are rejected, fee change is +owner-bound, and `Debug`/artifact surfaces omit sentinel secrets. They do not +claim target-node admission, confirmation, reorg safety, or deployment parity. diff --git a/specs/server/basis_server_spec.md b/specs/server/basis_server_spec.md index 42262cd..383391a 100644 --- a/specs/server/basis_server_spec.md +++ b/specs/server/basis_server_spec.md @@ -44,8 +44,8 @@ The server uses an actor-like pattern with a dedicated tracker thread that proce - `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 /redeem` - Retired legacy server-sign path (`410 Gone`) +- `POST /redeem/complete` - Retired caller-asserted completion path (`410 Gone`) - `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 @@ -56,7 +56,7 @@ The server uses an actor-like pattern with a dedicated tracker thread that proce - `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 +- `POST /reserves/submit` - Retired node-wallet proxy (`410 Gone`) ### Event Tracking @@ -196,6 +196,10 @@ The server now implements real cryptographic functionality using the Ergo node's The server provides an endpoint to generate reserve creation payloads for Ergo node's `/wallet/payment/send` API: +The endpoint returns `503 Service Unavailable` when the configured P2S is the +known historical strict-insert generation. A builder that emits insert-or-update +AVL state must not construct a reserve against that incompatible contract. + - `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 @@ -206,7 +210,7 @@ The server provides an endpoint to generate reserve creation payloads for Ergo n - `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) + - `token_id`: NFT ID from request (snake_case in the tracker response; the owner wallet adapter maps its own API format) - `amount`: Always 1 for NFTs - `registers`: Map of register values - `R4`: Owner public key from request (GroupElement) @@ -215,15 +219,10 @@ The server provides an endpoint to generate reserve creation payloads for Ergo n - `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. +- `POST /reserves/submit` is retained only as a `410 Gone` compatibility + tombstone. The reserve owner reviews, signs, and submits the payload returned + by `/reserves/create`; the tracker never exercises its node wallet for a + remote caller. ### Debt Transfer Support diff --git a/specs/server/redemption_state_spec.md b/specs/server/redemption_state_spec.md index b34b0a0..c0497b8 100644 --- a/specs/server/redemption_state_spec.md +++ b/specs/server/redemption_state_spec.md @@ -451,60 +451,49 @@ 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: +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. -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. +### POST /redeem/complete (retired) -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 (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 configured `3PQnJ92K...` P2S is the historical strict-insert generation, +while the builder emits insert-or-update state. Reserve creation and redemption +building therefore fail closed for that known legacy identity. A replacement P2S +must be compiled from the reviewed insert-or-update source and promoted together +with its exact source/build identity and parity fixtures; compatibility must not be +inferred from an address prefix. ## Integration with Blockchain Scanner @@ -521,7 +510,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. From c8b89c69bdf2ee06c11aaf344dd94f3fb4524932 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:37:11 +0200 Subject: [PATCH 03/41] fix(mcp): pass reserve submit policy explicitly --- crates/basis_mcp/src/server.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/basis_mcp/src/server.rs b/crates/basis_mcp/src/server.rs index a4ab641..8fb6ab9 100644 --- a/crates/basis_mcp/src/server.rs +++ b/crates/basis_mcp/src/server.rs @@ -419,6 +419,7 @@ impl BasisMcp { params.nft_id, None, params.amount, + false, ) .await, ) From 52a6cc028d82370049ff70250472eb93333e24b7 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:56:59 +0200 Subject: [PATCH 04/41] security: retire legacy redemption and bind exact fee boxes --- crates/basis_cli/src/api.rs | 118 ---- crates/basis_cli/src/commands/note.rs | 175 +----- crates/basis_cli/src/commands/transaction.rs | 294 +++++++-- crates/basis_cli/src/interactive.rs | 2 +- crates/basis_mcp/src/server.rs | 15 +- crates/basis_server/src/api.rs | 578 +----------------- crates/basis_server/src/lib.rs | 15 - crates/basis_server/src/main.rs | 30 - crates/basis_server/src/redemption_build.rs | 249 +++++++- crates/basis_server/tests/cors_tests.rs | 22 - .../tests/http_api_integration_tests.rs | 22 - .../tests/redemption_api_integration_tests.rs | 22 - docs/AGENT_INTERFACE.md | 11 +- specs/CLI_TOOLS_ANALYSIS.md | 2 +- specs/agent_integration.md | 20 +- specs/security_boundary_remediation.md | 12 +- specs/spec.md | 2 +- 17 files changed, 535 insertions(+), 1054 deletions(-) diff --git a/crates/basis_cli/src/api.rs b/crates/basis_cli/src/api.rs index d9ad48c..be159d1 100644 --- a/crates/basis_cli/src/api.rs +++ b/crates/basis_cli/src/api.rs @@ -44,60 +44,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, @@ -322,70 +268,6 @@ 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 pub async fn request_tracker_signature( diff --git a/crates/basis_cli/src/commands/note.rs b/crates/basis_cli/src/commands/note.rs index f1bd027..91d6f12 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: redemption requires the reviewed transaction flow 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()?; } } @@ -476,127 +448,19 @@ pub async fn get_note( client.get_note(issuer, recipient).await } -/// Redeem a note, either via server-side signing or (default) local signing. -fn reject_legacy_server_sign(server_sign: bool) -> Result<()> { - if server_sign { - anyhow::bail!( - "Legacy server-sign redemption is retired because node acceptance did not prove settlement; use the local signing flow" - ); - } - Ok(()) -} +const NOTE_REDEMPTION_RETIRED: &str = + "note redeem is retired; use the reviewed transaction redemption flow with explicit local witnesses and confirmed-chain reconciliation"; -pub async fn redeem_note( - account_manager: &AccountManager, - client: &TrackerClient, - issuer: &str, - amount: u64, - server_sign: bool, -) -> Result { - reject_legacy_server_sign(server_sign)?; - - 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); - - 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. Confirmed-chain reconciliation, not this broadcast path, - // owns the later settlement-state transition. - 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) { @@ -643,12 +507,11 @@ fn blake2b256_hash(data: &[u8]) -> [u8; 32] { #[cfg(test)] mod security_boundary_tests { - use super::reject_legacy_server_sign; + use super::reject_retired_note_redemption; #[test] - fn legacy_server_sign_fails_before_network_or_state_changes() { - let error = reject_legacy_server_sign(true).unwrap_err().to_string(); + fn note_redemption_is_an_unconditional_pre_effect_tombstone() { + let error = reject_retired_note_redemption().unwrap_err().to_string(); assert!(error.contains("retired")); - assert!(reject_legacy_server_sign(false).is_ok()); } } diff --git a/crates/basis_cli/src/commands/transaction.rs b/crates/basis_cli/src/commands/transaction.rs index 678d004..f377948 100644 --- a/crates/basis_cli/src/commands/transaction.rs +++ b/crates/basis_cli/src/commands/transaction.rs @@ -102,12 +102,12 @@ pub enum TransactionCommands { /// 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. + /// Recipient (receiver) dlog secret as 32-byte hex. Required for local signing; if + /// omitted, the command fails before network access or transaction construction. #[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. + /// Fee-payer dlog secret as 32-byte hex. Required for local signing; if omitted, the + /// command fails before network access or transaction construction. #[arg(long)] fee_secret: Option, }, @@ -126,7 +126,7 @@ pub enum TransactionCommands { #[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. + /// proveDlog(receiver). If omitted, the command fails before network access. #[arg(long)] recipient_secret: Option, }, @@ -538,7 +538,7 @@ async fn build_redemption_tx( None }; - let (fee_inputs, fee_input_total) = select_fee_inputs( + let (fee_input_claims, _) = select_fee_inputs( &wallet_boxes, TRANSACTION_FEE, &reserve_box_id, @@ -550,6 +550,33 @@ async fn build_redemption_tx( TRANSACTION_FEE, TRANSACTION_FEE ))?; + // The wallet-list JSON is selection metadata only. Bind ID, value, script, + // and assets to the exact Sigma bytes later supplied to the prover. + let mut fee_inputs = Vec::with_capacity(fee_input_claims.len()); + for claim in &fee_input_claims { + let binary = client + .get_box_binary(&claim.box_id, NODE_URL, Some(API_KEY)) + .await + .map_err(|e| { + anyhow::anyhow!("Failed to get binary for fee input {}: {}", claim.box_id, e) + })?; + fee_inputs.push(verify_fee_input_claim(claim, binary)?); + } + let fee_input_total = authoritative_fee_input_total(&fee_inputs)?; + if fee_input_total < TRANSACTION_FEE { + anyhow::bail!( + "Exact fee inputs provide {} nanoERG, below required {} nanoERG", + fee_input_total, + TRANSACTION_FEE + ); + } + let common_fee_tree = common_fee_input_tree(&fee_inputs)?; + if let Some(expected_tree) = target_tree.as_deref() { + if !decoded_hex_eq(expected_tree, common_fee_tree) { + anyhow::bail!("Exact fee-input owner script does not match the requested change owner"); + } + } + progress!( "✅ Selected {} fee input box(es) totaling {} nanoERG", fee_inputs.len(), @@ -558,10 +585,8 @@ async fn build_redemption_tx( 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() + ergo_tree_to_p2pk_address(common_fee_tree)? }; progress!("📦 Preparing box IDs for transaction..."); @@ -618,17 +643,7 @@ async fn build_redemption_tx( "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); + fee_input_binaries.push(fee_box.binary.clone()); } let mut inputs = vec![json!({ @@ -753,6 +768,15 @@ pub async fn execute_local_redemption( recipient_secret: Option, fee_secret: Option, ) -> Result { + // Resolve both witnesses before any network request, proof request, build, or + // broadcast. There is no node-wallet key-export fallback. + let recipient_secret = resolve_dlog_secret(&recipient_secret, "recipient")?; + let fee_secret = resolve_dlog_secret(&fee_secret, "fee-payer")?; + let recipient_witness = SecretKey::dlog_from_bytes(&recipient_secret) + .ok_or_else(|| anyhow::anyhow!("invalid recipient dlog secret"))?; + let fee_witness = SecretKey::dlog_from_bytes(&fee_secret) + .ok_or_else(|| anyhow::anyhow!("invalid fee-payer dlog secret"))?; + progress!("🔍 Retrieving note information..."); let note = client .get_note(issuer_pubkey, recipient_pubkey) @@ -835,8 +859,8 @@ pub async fn execute_local_redemption( fee_input_count: build.fee_input_count, fee_input_total: build.fee_input_total, change_address: &build.change_address, - recipient_secret, - fee_secret, + recipient_witness, + fee_witness, }) .await?; @@ -1046,6 +1070,12 @@ pub async fn redeem_tracker_assisted( use ergo_lib::chain::transaction::Transaction; use ergo_lib::ergo_chain_types::{Header, PreHeader}; + // The local reserve-input witness is mandatory and is resolved before any + // tracker request or transaction construction. + let receiver_secret = resolve_dlog_secret(&recipient_secret, "recipient")?; + let receiver_sk = SecretKey::dlog_from_bytes(&receiver_secret) + .ok_or_else(|| anyhow::anyhow!("invalid recipient dlog secret"))?; + let issuer_pk: [u8; 33] = hex::decode(issuer_pubkey) .map_err(|e| anyhow::anyhow!("issuer hex: {}", e))? .try_into() @@ -1134,11 +1164,6 @@ pub async fn redeem_tracker_assisted( .try_into() .map_err(|_| anyhow::anyhow!("headers array"))?; - // Recipient (receiver) secret for the reserve input's proveDlog(recipient). - let receiver_secret = resolve_dlog_secret(&recipient_secret, "recipient")?; - 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( @@ -1245,6 +1270,117 @@ fn select_fee_inputs( None } +#[derive(Debug, Clone)] +struct VerifiedFeeInput { + box_id: String, + value: u64, + ergo_tree: String, + assets: Vec, + binary: String, +} + +fn decoded_hex_eq(left: &str, right: &str) -> bool { + match (hex::decode(left), hex::decode(right)) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + +fn fee_assets_match(claimed: &[crate::api::Token], canonical: &[crate::api::Token]) -> bool { + claimed.len() == canonical.len() + && claimed.iter().zip(canonical).all(|(left, right)| { + decoded_hex_eq(&left.token_id, &right.token_id) && left.amount == right.amount + }) +} + +/// Parse the exact Sigma bytes used by the signer and reject any disagreement +/// with the wallet-list JSON that was used only to select candidates. +fn verify_fee_input_claim( + claimed: &crate::api::ErgoBoxDetails, + binary: String, +) -> Result { + let bytes = hex::decode(&binary) + .map_err(|e| anyhow::anyhow!("fee box {} binary hex: {}", claimed.box_id, e))?; + let ergo_box = ErgoBox::sigma_parse_bytes(&bytes) + .map_err(|e| anyhow::anyhow!("fee box {} Sigma parse: {:?}", claimed.box_id, e))?; + let box_id = ergo_box.box_id().to_string(); + let value = *ergo_box.value.as_u64(); + let ergo_tree = + hex::encode(ergo_box.ergo_tree.sigma_serialize_bytes().map_err(|e| { + anyhow::anyhow!("fee box {} script serialize: {:?}", claimed.box_id, e) + })?); + let assets: Vec = ergo_box + .tokens + .as_ref() + .map(|tokens| { + tokens + .iter() + .map(|token| crate::api::Token { + token_id: hex::encode(token.token_id.as_ref()), + amount: *token.amount.as_u64(), + }) + .collect() + }) + .unwrap_or_default(); + + if !decoded_hex_eq(&claimed.box_id, &box_id) { + anyhow::bail!( + "fee box id mismatch: wallet list claimed {}, exact Sigma box is {}", + claimed.box_id, + box_id + ); + } + if claimed.value != value { + anyhow::bail!( + "fee box {} value mismatch: wallet list claimed {}, exact Sigma box is {}", + box_id, + claimed.value, + value + ); + } + if !decoded_hex_eq(&claimed.ergo_tree, &ergo_tree) { + anyhow::bail!( + "fee box {} script mismatch between wallet list and exact Sigma box", + box_id + ); + } + if !fee_assets_match(&claimed.assets, &assets) { + anyhow::bail!( + "fee box {} assets mismatch between wallet list and exact Sigma box", + box_id + ); + } + + Ok(VerifiedFeeInput { + box_id, + value, + ergo_tree, + assets, + binary, + }) +} + +fn authoritative_fee_input_total(fee_inputs: &[VerifiedFeeInput]) -> Result { + fee_inputs.iter().try_fold(0u64, |total, box_| { + total + .checked_add(box_.value) + .ok_or_else(|| anyhow::anyhow!("fee input value overflow")) + }) +} + +fn common_fee_input_tree(fee_inputs: &[VerifiedFeeInput]) -> Result<&str> { + let first = fee_inputs + .first() + .ok_or_else(|| anyhow::anyhow!("no verified fee inputs"))?; + if fee_inputs + .iter() + .any(|fee_input| !decoded_hex_eq(&fee_input.ergo_tree, &first.ergo_tree)) + { + anyhow::bail!("exact fee inputs do not share one owner script"); + } + Ok(&first.ergo_tree) +} + /// Build serialized SAvlTree constant matching Scala's /// `ValueSerializer.serialize(AvlTreeConstant(tree))`. /// Format: 0x64 || 33-byte digest || flags || VLQ key_length || VLQ value_length @@ -1365,8 +1501,8 @@ struct SignLocalParams<'a> { fee_input_count: usize, fee_input_total: u64, change_address: &'a str, - recipient_secret: Option, - fee_secret: Option, + recipient_witness: SecretKey, + fee_witness: SecretKey, } fn resolve_dlog_secret(provided: &Option, label: &str) -> Result<[u8; 32]> { @@ -1552,14 +1688,6 @@ async fn sign_and_broadcast_local(p: SignLocalParams<'_>) -> Result { 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, "recipient")?; - let fee_secret = resolve_dlog_secret(&p.fee_secret, "fee-payer")?; - 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 @@ -1578,7 +1706,7 @@ async fn sign_and_broadcast_local(p: SignLocalParams<'_>) -> Result { &pre_header, &headers, fee_idx, - &fee_sk, + &p.fee_witness, ) .map_err(|e| anyhow::anyhow!("fee input {} signing failed: {:?}", fee_idx, e))?, ); @@ -1591,7 +1719,7 @@ async fn sign_and_broadcast_local(p: SignLocalParams<'_>) -> Result { &pre_header, &headers, 0, - &recipient_sk, + &p.recipient_witness, ) .map_err(|e| anyhow::anyhow!("reserve input signing failed: {:?}", e))?; @@ -1678,6 +1806,9 @@ async fn sign_and_broadcast_local(p: SignLocalParams<'_>) -> Result { #[cfg(test)] mod tests { use super::*; + use ergo_lib::ergotree_ir::chain::ergo_box::{box_value::BoxValue, NonMandatoryRegisters}; + use ergo_lib::ergotree_ir::chain::tx_id::TxId; + use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; use serde_json::{json, Map, Value}; /// A valid P2PK (proveDlog) ergoTree as returned by the node for a wallet address. @@ -1686,6 +1817,37 @@ mod tests { const GENERATOR_GE: &str = "070279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + fn exact_fee_claim( + pubkey: &str, + value: u64, + index: u16, + ) -> (crate::api::ErgoBoxDetails, String) { + let tree_hex = format!("0008cd{pubkey}"); + let tree = ErgoTree::sigma_parse_bytes(&hex::decode(&tree_hex).unwrap()).unwrap(); + let ergo_box = ErgoBox::new( + BoxValue::try_from(value).unwrap(), + tree, + None, + NonMandatoryRegisters::empty(), + 100, + TxId::zero(), + index, + ) + .unwrap(); + let binary = hex::encode(ergo_box.sigma_serialize_bytes().unwrap()); + let claim = crate::api::ErgoBoxDetails { + box_id: ergo_box.box_id().to_string(), + value, + ergo_tree: tree_hex, + assets: Vec::new(), + additional_registers: Default::default(), + creation_height: 100, + transaction_id: TxId::zero().to_string(), + index, + }; + (claim, binary) + } + /// 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"]; @@ -1891,4 +2053,58 @@ mod tests { .to_string(); assert!(error.contains("never exported")); } + + #[test] + fn exact_fee_input_rejects_id_mismatch() { + let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; + let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); + claim.box_id = "00".repeat(32); + + let error = verify_fee_input_claim(&claim, binary) + .unwrap_err() + .to_string(); + assert!(error.contains("id mismatch")); + } + + #[test] + fn exact_fee_input_rejects_script_mismatch() { + let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; + let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); + claim.ergo_tree = format!( + "0008cd{}", + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + ); + + let error = verify_fee_input_claim(&claim, binary) + .unwrap_err() + .to_string(); + assert!(error.contains("script mismatch")); + } + + #[test] + fn exact_fee_input_rejects_value_mismatch() { + let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; + let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); + claim.value += 1; + + let error = verify_fee_input_claim(&claim, binary) + .unwrap_err() + .to_string(); + assert!(error.contains("value mismatch")); + } + + #[test] + fn exact_fee_input_rejects_asset_mismatch() { + let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; + let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); + claim.assets.push(crate::api::Token { + token_id: "aa".repeat(32), + amount: 1, + }); + + let error = verify_fee_input_claim(&claim, binary) + .unwrap_err() + .to_string(); + assert!(error.contains("assets mismatch")); + } } 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_mcp/src/server.rs b/crates/basis_mcp/src/server.rs index 8fb6ab9..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. diff --git a/crates/basis_server/src/api.rs b/crates/basis_server/src/api.rs index ae1eff6..dfb2ab1 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -1590,426 +1590,19 @@ pub async fn get_key_status( ) } -fn legacy_settlement_disabled() -> bool { - true -} - -// Initiate redemption process +/// Retired server-sign redemption endpoint. #[axum::debug_handler] pub async fn initiate_redemption( - State(state): State, - Json(payload): Json, + State(_state): State, + Json(_payload): Json, ) -> (StatusCode, Json>) { - if legacy_settlement_disabled() { - return ( - StatusCode::GONE, - Json(crate::models::error_response( - "Legacy server-sign redemption is retired; use a locally reviewed signing flow and confirmed-chain reconciliation" - .to_string(), - )), - ); - } - - 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(); - - // 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(), - )), - ); - } - }; - - // 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() - } - }; - - let normalized_actual_key = basis_store::normalize_public_key(&actual_owner_key); - let original_reserve_key = &reserve.owner_pubkey; - - // 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); - - // 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; - - 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; - } - } - - 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 - ); - } - - // 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(), - )), - ) - } - } + ( + StatusCode::GONE, + Json(crate::models::error_response( + "Legacy server-sign redemption is retired; use the reviewed transaction flow with explicit local witnesses and confirmed-chain reconciliation" + .to_string(), + )), + ) } /// Legacy direct-completion endpoint. @@ -2590,152 +2183,6 @@ pub async fn request_tracker_signature( ) } -/// 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] @@ -3592,11 +3039,6 @@ mod security_boundary_tests { let (status, _) = submit_reserve_transaction(Json(payload)).await; assert_eq!(status, StatusCode::GONE); } - - #[test] - fn legacy_settlement_stays_disabled() { - assert!(legacy_settlement_disabled()); - } } /// Get the current tracker state: local digest, confirmed on-chain digest, and diff --git a/crates/basis_server/src/lib.rs b/crates/basis_server/src/lib.rs index 3db21f0..7fafa38 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -86,21 +86,6 @@ 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, diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 94bafa6..7f9bfe5 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -371,36 +371,6 @@ async fn main() { .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, - ); - - // 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); diff --git a/crates/basis_server/src/redemption_build.rs b/crates/basis_server/src/redemption_build.rs index fcd582a..214dd62 100644 --- a/crates/basis_server/src/redemption_build.rs +++ b/crates/basis_server/src/redemption_build.rs @@ -114,7 +114,6 @@ pub struct RedemptionSubmitResponse { struct NodeAsset { #[serde(alias = "tokenId")] token_id: String, - #[allow(dead_code)] amount: u64, } @@ -131,6 +130,16 @@ struct NodeBox { additional_registers: HashMap, } +#[derive(Debug, Clone)] +struct VerifiedFeeBox { + box_id: String, + value: u64, + ergo_tree: String, + assets: Vec, + binary: String, + ergo_box: ErgoBox, +} + 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]. @@ -278,10 +287,97 @@ fn select_fee_inputs( None } +fn decoded_hex_eq(left: &str, right: &str) -> bool { + match (hex::decode(left), hex::decode(right)) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + +fn assets_match(claimed: &[NodeAsset], canonical: &[NodeAsset]) -> bool { + claimed.len() == canonical.len() + && claimed.iter().zip(canonical).all(|(left, right)| { + decoded_hex_eq(&left.token_id, &right.token_id) && left.amount == right.amount + }) +} + +/// Parse the exact Sigma bytes later supplied to the prover and reject any +/// disagreement with the wallet-list JSON that was used only for selection. +fn verify_fee_box_claim(claimed: &NodeBox, binary: String) -> Result { + let bytes = + hex::decode(&binary).map_err(|e| format!("fee box {} binary hex: {e}", claimed.box_id))?; + let ergo_box = ErgoBox::sigma_parse_bytes(&bytes) + .map_err(|e| format!("fee box {} Sigma parse: {e:?}", claimed.box_id))?; + + let box_id = ergo_box.box_id().to_string(); + let value = *ergo_box.value.as_u64(); + let ergo_tree = hex::encode( + ergo_box + .ergo_tree + .sigma_serialize_bytes() + .map_err(|e| format!("fee box {} script serialize: {e:?}", claimed.box_id))?, + ); + let assets: Vec = ergo_box + .tokens + .as_ref() + .map(|tokens| { + tokens + .iter() + .map(|token| NodeAsset { + token_id: hex::encode(token.token_id.as_ref()), + amount: *token.amount.as_u64(), + }) + .collect() + }) + .unwrap_or_default(); + + if !decoded_hex_eq(&claimed.box_id, &box_id) { + return Err(format!( + "fee box id mismatch: wallet list claimed {}, exact Sigma box is {}", + claimed.box_id, box_id + )); + } + if claimed.value != value { + return Err(format!( + "fee box {} value mismatch: wallet list claimed {}, exact Sigma box is {}", + box_id, claimed.value, value + )); + } + if !decoded_hex_eq(&claimed.ergo_tree, &ergo_tree) { + return Err(format!( + "fee box {} script mismatch between wallet list and exact Sigma box", + box_id + )); + } + if !assets_match(&claimed.assets, &assets) { + return Err(format!( + "fee box {} assets mismatch between wallet list and exact Sigma box", + box_id + )); + } + + Ok(VerifiedFeeBox { + box_id, + value, + ergo_tree, + assets, + binary, + ergo_box, + }) +} + +fn authoritative_fee_total(fee_boxes: &[VerifiedFeeBox]) -> Result { + fee_boxes.iter().try_fold(0u64, |total, box_| { + total + .checked_add(box_.value) + .ok_or_else(|| "fee input value overflow".to_string()) + }) +} + /// Derive fee-input change from the exact script whose inputs the tracker signs. /// Mixed-script funding is rejected because one shared change output would have /// ambiguous ownership even if global value conservation still held. -fn fee_change_address(fee_boxes: &[NodeBox]) -> Result { +fn fee_change_address(fee_boxes: &[VerifiedFeeBox]) -> Result { let first = fee_boxes .first() .ok_or_else(|| "no selected fee inputs".to_string())?; @@ -737,7 +833,7 @@ async fn build_redemption_inner( } }; let fee = state.config.transaction_fee(); - let (fee_boxes, fee_total) = match select_fee_inputs(&wallet_boxes, fee, &reserve_box_id) { + let (fee_box_claims, _) = match select_fee_inputs(&wallet_boxes, fee, &reserve_box_id) { Some(v) => v, None => { return api_err( @@ -747,6 +843,43 @@ async fn build_redemption_inner( } }; + // `/wallet/boxes/unspent` is selection metadata only. Fetch and parse the + // exact bytes that will be supplied to the prover, then bind every + // transaction-relevant field to those bytes. + let mut fee_boxes = Vec::with_capacity(fee_box_claims.len()); + for claim in &fee_box_claims { + let binary = match node.box_binary(&claim.box_id).await { + Ok(binary) => binary, + Err(e) => { + return api_err( + StatusCode::INTERNAL_SERVER_ERROR, + format!("fee bin {}: {e}", claim.box_id), + ) + } + }; + let verified = match verify_fee_box_claim(claim, binary) { + Ok(verified) => verified, + Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, e), + }; + fee_boxes.push(verified); + } + let fee_total = match authoritative_fee_total(&fee_boxes) { + Ok(total) if total >= fee => total, + Ok(total) => { + return api_err( + StatusCode::INTERNAL_SERVER_ERROR, + format!("exact fee inputs provide {total} nanoERG, below required {fee} nanoERG"), + ) + } + Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, e), + }; + if fee_boxes.iter().any(|fee_box| !fee_box.assets.is_empty()) { + return api_err( + StatusCode::INTERNAL_SERVER_ERROR, + "exact fee inputs unexpectedly contain tokens", + ); + } + // Change belongs to the authenticated owner of every selected fee input. let change_address = match fee_change_address(&fee_boxes) { Ok(address) => address, @@ -892,19 +1025,6 @@ async fn build_redemption_inner( ) } }; - 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) => { @@ -931,10 +1051,10 @@ async fn build_redemption_inner( }; 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 fee_eboxes: Vec = fee_boxes + .iter() + .map(|fee_box| fee_box.ergo_box.clone()) + .collect(); let mut input_boxes = vec![reserve_ebox]; input_boxes.extend(fee_eboxes); @@ -1013,7 +1133,7 @@ async fn build_redemption_inner( }; let mut input_box_binaries = vec![reserve_box_binary]; - input_box_binaries.extend(fee_binaries); + input_box_binaries.extend(fee_boxes.iter().map(|fee_box| fee_box.binary.clone())); Ok(RedemptionBuildResponse { unsigned_tx, @@ -1065,6 +1185,9 @@ pub async fn submit_redemption( #[cfg(test)] mod tests { use super::*; + use ergo_lib::ergotree_ir::chain::ergo_box::{box_value::BoxValue, NonMandatoryRegisters}; + use ergo_lib::ergotree_ir::chain::tx_id::TxId; + use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; fn wallet_box(id: &str, value: u64, with_token: bool) -> NodeBox { let assets = if with_token { @@ -1084,6 +1207,30 @@ mod tests { } } + fn exact_fee_claim(pubkey: &str, value: u64, index: u16) -> (NodeBox, String) { + let tree_hex = format!("0008cd{pubkey}"); + let tree = ErgoTree::sigma_parse_bytes(&hex::decode(&tree_hex).unwrap()).unwrap(); + let ergo_box = ErgoBox::new( + BoxValue::try_from(value).unwrap(), + tree, + None, + NonMandatoryRegisters::empty(), + 100, + TxId::zero(), + index, + ) + .unwrap(); + let binary = hex::encode(ergo_box.sigma_serialize_bytes().unwrap()); + let claim = NodeBox { + box_id: ergo_box.box_id().to_string(), + value, + ergo_tree: tree_hex, + assets: Vec::new(), + additional_registers: Default::default(), + }; + (claim, binary) + } + #[test] fn select_fee_inputs_picks_exact_single_box() { let boxes = vec![ @@ -1162,18 +1309,66 @@ mod tests { #[test] fn fee_change_is_bound_to_one_input_owner_script() { let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; - let mut first = wallet_box("a", 600_000, false); - first.ergo_tree = format!("0008cd{pubkey}"); - let mut second = wallet_box("b", 600_000, false); - second.ergo_tree = first.ergo_tree.clone(); + let other_pubkey = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + let (first_claim, first_binary) = exact_fee_claim(pubkey, 600_000, 0); + let (second_claim, second_binary) = exact_fee_claim(pubkey, 600_000, 1); + let first = verify_fee_box_claim(&first_claim, first_binary).unwrap(); + let second = verify_fee_box_claim(&second_claim, second_binary).unwrap(); assert_eq!( fee_change_address(&[first.clone(), second.clone()]).unwrap(), pubkey_to_address(pubkey).unwrap() ); - second.ergo_tree = format!("0008cd{}", "02".repeat(33)); - assert!(fee_change_address(&[first, second]).is_err()); + let (other_claim, other_binary) = exact_fee_claim(other_pubkey, 600_000, 2); + let other = verify_fee_box_claim(&other_claim, other_binary).unwrap(); + assert!(fee_change_address(&[first, other]).is_err()); + } + + #[test] + fn exact_fee_box_rejects_id_mismatch() { + let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; + let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); + claim.box_id = "00".repeat(32); + + let error = verify_fee_box_claim(&claim, binary).unwrap_err(); + assert!(error.contains("id mismatch")); + } + + #[test] + fn exact_fee_box_rejects_script_mismatch() { + let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; + let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); + claim.ergo_tree = format!( + "0008cd{}", + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + ); + + let error = verify_fee_box_claim(&claim, binary).unwrap_err(); + assert!(error.contains("script mismatch")); + } + + #[test] + fn exact_fee_box_rejects_value_mismatch() { + let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; + let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); + claim.value += 1; + + let error = verify_fee_box_claim(&claim, binary).unwrap_err(); + assert!(error.contains("value mismatch")); + } + + #[test] + fn exact_fee_box_rejects_asset_mismatch() { + let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; + let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); + claim.assets.push(NodeAsset { + token_id: "aa".repeat(32), + amount: 1, + }); + + let error = verify_fee_box_claim(&claim, binary).unwrap_err(); + assert!(error.contains("assets mismatch")); } #[test] diff --git a/crates/basis_server/tests/cors_tests.rs b/crates/basis_server/tests/cors_tests.rs index 3594d6e..0758704 100644 --- a/crates/basis_server/tests/cors_tests.rs +++ b/crates/basis_server/tests/cors_tests.rs @@ -101,28 +101,6 @@ mod cors_tests { .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 } => { // For testing purposes, return an empty list let result = Ok(Vec::new()); diff --git a/crates/basis_server/tests/http_api_integration_tests.rs b/crates/basis_server/tests/http_api_integration_tests.rs index ed819a4..83535c2 100644 --- a/crates/basis_server/tests/http_api_integration_tests.rs +++ b/crates/basis_server/tests/http_api_integration_tests.rs @@ -101,28 +101,6 @@ mod http_api_tests { .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 } => { // For testing purposes, return an empty list let result = Ok(Vec::new()); diff --git a/crates/basis_server/tests/redemption_api_integration_tests.rs b/crates/basis_server/tests/redemption_api_integration_tests.rs index 125e3f0..d8f9395 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -121,28 +121,6 @@ mod redemption_api_tests { .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); diff --git a/docs/AGENT_INTERFACE.md b/docs/AGENT_INTERFACE.md index 2ff4f76..ba46376 100644 --- a/docs/AGENT_INTERFACE.md +++ b/docs/AGENT_INTERFACE.md @@ -159,8 +159,8 @@ $ 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: false, tx_id}`; `--server-sign` - is retired and fails before network or persistence effects. +- `note redeem --json` is a compatibility tombstone and fails before account, + network, construction, signing, broadcast, or persistence effects. - `transaction generate-redemption --json` → `{tx_id}` with `--local-sign`. The historical non-local artifact mode is retired because transaction artifacts must never contain exported private keys. @@ -213,7 +213,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`) | @@ -226,8 +226,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/specs/CLI_TOOLS_ANALYSIS.md b/specs/CLI_TOOLS_ANALYSIS.md index e525c03..12b636b 100644 --- a/specs/CLI_TOOLS_ANALYSIS.md +++ b/specs/CLI_TOOLS_ANALYSIS.md @@ -149,7 +149,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/agent_integration.md b/specs/agent_integration.md index b5fd37e..7ac6fc8 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,12 @@ 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. Use the separately reviewed + transaction flow with explicit local witnesses. +4. Treat node acceptance as pending until confirmed-chain reconciliation. ### Create a reserve 1. `reserve_create {nft_id, amount}` (owner = current account) — returns the unsigned @@ -187,10 +186,11 @@ 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**: the separately reviewed + transaction redemption flow (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. + `note_redeem` itself is retired. - **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/security_boundary_remediation.md b/specs/security_boundary_remediation.md index 85522e3..8ff8740 100644 --- a/specs/security_boundary_remediation.md +++ b/specs/security_boundary_remediation.md @@ -10,8 +10,9 @@ caller to exercise tracker-owned capabilities or to assert settlement state. node wallet. `/reserves/create` remains a payload builder; the reserve owner reviews, signs, and submits that payload with their own wallet. 2. Change from tracker-signed fee inputs is paid only to the common P2PK script - of those inputs. A request cannot choose the change address, and mixed-owner - fee inputs are rejected. + derived from the exact Sigma-serialized boxes used by the prover. Wallet-list + JSON is selection metadata only; mismatched IDs, values, scripts, or assets + are rejected, as are mixed-owner fee inputs. 3. Node API credentials and tracker signing material are redacted from `Debug` output. Signing and broadcast logs contain status and identifiers only, not request or response bodies. @@ -22,8 +23,9 @@ caller to exercise tracker-owned capabilities or to assert settlement state. 5. Node acceptance is not settlement confirmation. `/redemption/submit` accepts only the signed transaction and does not mutate note or reserve-tree accounting. `/redeem/complete` is a `410 Gone` tombstone. -6. The legacy server-sign redemption path is disabled before any network, - signing, broadcast, or persistence effect. +6. Legacy `POST /redeem`, CLI `note redeem`, and MCP `note_redeem` are + unconditional tombstones before account, network, construction, signing, + broadcast, or persistence effects. 7. Builders reject the known historical strict-insert reserve P2S while they emit insert-or-update AVL state. A new contract identity must be promoted from reviewed source and parity evidence as a separate change. @@ -37,7 +39,7 @@ caller to exercise tracker-owned capabilities or to assert settlement state. | Assisted build `change_address` | Removed and rejected as an unknown field. | | `POST /redemption/submit` | Accepts `{ "signed_tx": ... }`, returns `202 Accepted`, and performs no settlement mutation. | | `POST /redeem/complete` | Returns `410 Gone`. | -| `note redeem --server-sign` | Returns an error before effects. | +| `note redeem` / MCP `note_redeem` | Return an error before effects; no boolean reactivation path remains. | | Non-local `transaction generate-redemption` | Returns an error; use `--local-sign` or the assisted signer. | | Reserve P2S `3PQnJ92K...` | Reserve payload/redemption builders return `503 Service Unavailable`; no successor is constructed against the incompatible generation. | 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 From 794cdbeb5772e1a050bdff07ec1d723cc43c7a0b Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:45:58 +0200 Subject: [PATCH 05/41] fix: persist bounded authoritative note state --- Cargo.lock | 33 + crates/basis_server/src/api.rs | 20 + crates/basis_server/src/main.rs | 9 +- crates/basis_store/Cargo.toml | 1 + crates/basis_store/src/lib.rs | 433 ++++--- crates/basis_store/src/persistence.rs | 1019 ++++++++--------- crates/basis_store/src/redemption.rs | 61 +- crates/basis_store/src/tests.rs | 435 ++++++- crates/basis_store/src/tracker_scanner.rs | 23 +- .../basis_store/src/tracker_scanner_test.rs | 12 +- crates/basis_trees/src/avl_tree.rs | 92 +- specs/trees/note_state_snapshot.md | 69 ++ 12 files changed, 1376 insertions(+), 831 deletions(-) create mode 100644 specs/trees/note_state_snapshot.md diff --git a/Cargo.lock b/Cargo.lock index dd94494..237d8bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,6 +407,7 @@ dependencies = [ "ergotree-interpreter", "ergotree-ir", "fjall", + "fs2", "generic-array", "hex", "num-bigint", @@ -1332,6 +1333,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 +4119,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 +4144,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/crates/basis_server/src/api.rs b/crates/basis_server/src/api.rs index dfb2ab1..834cbe7 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -309,7 +309,11 @@ pub async fn create_note( 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::StorageError(msg) => format!("Storage error: {}", msg), + NoteError::StorageOutcomeUnknown(_) => { + "Storage outcome unknown; restart and reconcile".to_string() + } NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -436,7 +440,11 @@ pub async fn get_notes_by_issuer( 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::StorageError(msg) => format!("Storage error: {}", msg), + NoteError::StorageOutcomeUnknown(_) => { + "Storage outcome unknown; restart and reconcile".to_string() + } NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -549,7 +557,11 @@ pub async fn get_notes_by_recipient( 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::StorageError(msg) => format!("Storage error: {}", msg), + NoteError::StorageOutcomeUnknown(_) => { + "Storage outcome unknown; restart and reconcile".to_string() + } NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -699,7 +711,11 @@ pub async fn get_note_by_issuer_and_recipient( 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::StorageError(msg) => format!("Storage error: {}", msg), + NoteError::StorageOutcomeUnknown(_) => { + "Storage outcome unknown; restart and reconcile".to_string() + } NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -793,7 +809,11 @@ pub async fn get_all_notes( 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::StorageError(msg) => format!("Storage error: {}", msg), + NoteError::StorageOutcomeUnknown(_) => { + "Storage outcome unknown; restart and reconcile".to_string() + } NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 7f9bfe5..67e4a0a 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -171,7 +171,6 @@ async fn main() { tracker_scanner_config, metadata_storage, tracker_storage, - &data_dir, ); // Process tracker boxes directly, no scan registration required @@ -243,19 +242,13 @@ 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))); - // Clone the data directory for the tracker thread before the async move. let data_dir_for_tracker_thread = data_dir.clone(); // 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; + use basis_store::{RedemptionManager, TrackerStateManager}; tracing::debug!("Tracker thread started"); let tracker = TrackerStateManager::new(&data_dir_for_tracker_thread); diff --git a/crates/basis_store/Cargo.toml b/crates/basis_store/Cargo.toml index ee7fcab..76eecf1 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 } diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index cdf22aa..f43a5e4 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -44,7 +44,10 @@ 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}, +}; /// Public key type (Secp256k1) pub type PubKey = [u8; 33]; @@ -284,7 +287,14 @@ pub enum NoteError { 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), 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), UnsupportedOperation, } @@ -303,58 +313,63 @@ pub struct TrackerStateManager { reserve_avl_state: basis_trees::BasisAvlTree, /// Per-note confirmation records, keyed by note key (32 bytes). confirmations: std::collections::HashMap, + poisoned: AtomicBool, } impl TrackerStateManager { + fn ensure_healthy(&self) -> Result<(), NoteError> { + if self.poisoned.load(Ordering::SeqCst) { + Err(NoteError::StorageOutcomeUnknown( + "Tracker state manager is quarantined after an indeterminate durable write; restart and reconcile before reuse" + .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(_)) + ) { + self.poisoned.store(true, Ordering::SeqCst); + } + result + } + /// Create a new tracker state manager with the configured storage location. pub fn new(data_dir: impl AsRef) -> Self { + Self::try_new(data_dir) + .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) -> 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)?; + 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); - } - }; + let avl_state = basis_trees::BasisAvlTree::new().map_err(|e| { + NoteError::StorageError(format!("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 reserve_avl_state = basis_trees::BasisAvlTree::new().map_err(|e| { + NoteError::StorageError(format!("Failed to initialize reserve AVL tree: {:?}", e)) + })?; // Rebuild AVL tree from all stored notes to ensure consistency after restart let mut manager = Self { @@ -367,65 +382,96 @@ impl TrackerStateManager { storage, reserve_avl_state, confirmations: std::collections::HashMap::new(), + poisoned: AtomicBool::new(false), }; - 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(); 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.poisoned.store(true, Ordering::SeqCst); + return Err(error); + } + }; + + 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) + ); - let mut notes_with_issuer = self - .storage - .get_all_notes_with_issuer() - .map_err(|e| NoteError::StorageError(format!("Failed to get all notes: {:?}", e)))?; + Ok(()) + } - if notes_with_issuer.is_empty() { - tracing::info!("No stored notes found, AVL tree remains empty"); - return Ok(()); - } + 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)) + })?; - // 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); + 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) } /// Create a new tracker state manager with temporary storage (used in tests only) @@ -528,11 +574,15 @@ impl TrackerStateManager { storage, reserve_avl_state, confirmations: std::collections::HashMap::new(), + poisoned: AtomicBool::new(false), }; // 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 + ); } manager.rebuild_confirmations(); @@ -542,6 +592,8 @@ 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()?; + // Validate that timestamp is not in the future let current_time = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -554,10 +606,10 @@ impl TrackerStateManager { // A note is a cumulative debt record. Both its timestamp and totalDebt // must be monotone for a given issuer-recipient edge. - if let Some(existing_note) = self - .storage - .get_note(issuer_pubkey, ¬e.recipient_pubkey)? - { + let existing_note = self.quarantine_on_storage_failure( + self.storage.get_note(issuer_pubkey, ¬e.recipient_pubkey), + )?; + if let Some(existing_note) = &existing_note { if note.timestamp <= existing_note.timestamp { return Err(NoteError::PastTimestamp); } @@ -572,6 +624,14 @@ impl TrackerStateManager { NoteError::InvalidSignature })?; + // 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(); @@ -580,28 +640,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.poisoned.store(true, Ordering::SeqCst); + 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.poisoned.store(true, Ordering::SeqCst); + 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. @@ -642,6 +712,12 @@ impl TrackerStateManager { /// the current local value and clear any pending in-flight state (pending /// transactions do not survive a restart). pub fn rebuild_confirmations(&mut self) { + if let Err(e) = self.ensure_healthy() { + panic!( + "Cannot rebuild confirmations on quarantined tracker: {:?}", + e + ); + } self.confirmations.clear(); let notes = match self.storage.get_all_notes_with_issuer() { @@ -686,12 +762,21 @@ impl TrackerStateManager { issuer_pubkey: &PubKey, recipient_pubkey: &PubKey, ) -> Option { + if let Err(e) = self.ensure_healthy() { + panic!("Cannot read confirmation from quarantined tracker: {:?}", e); + } let key = Self::confirmation_key(issuer_pubkey, recipient_pubkey); self.confirmations.get(&key).cloned() } /// Get a snapshot of all confirmation records keyed by note key. pub fn all_confirmations(&self) -> std::collections::HashMap { + if let Err(e) = self.ensure_healthy() { + panic!( + "Cannot read confirmations from quarantined tracker: {:?}", + e + ); + } self.confirmations.clone() } @@ -704,6 +789,7 @@ impl TrackerStateManager { tx_id: &str, submitted_height: u64, ) -> Result { + self.ensure_healthy()?; let _ = (digest, submitted_height); let notes = self.storage.get_all_notes_with_issuer()?; let mut count = 0usize; @@ -738,6 +824,7 @@ impl TrackerStateManager { /// 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 { + self.ensure_healthy()?; let keys: Vec = self.confirmations.keys().copied().collect(); let mut count = 0usize; @@ -776,6 +863,7 @@ impl TrackerStateManager { /// the status from the local value versus the confirmed value. Returns the /// number of notes reverted. pub fn revert_pending_notes(&mut self) -> Result { + self.ensure_healthy()?; let notes = self.storage.get_all_notes_with_issuer()?; let mut count = 0usize; @@ -817,6 +905,7 @@ impl TrackerStateManager { box_id: &str, height: u64, ) -> Result { + self.ensure_healthy()?; if confirmed_digest != &self.current_state.avl_root_digest { return Ok(0); } @@ -857,54 +946,60 @@ 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`. + pub(crate) fn record_redemption_progress( + &mut self, + issuer_pubkey: &PubKey, + recipient_pubkey: &PubKey, + redeemed_amount: u64, + ) -> Result { + self.ensure_healthy()?; + let mut note = self + .quarantine_on_storage_failure(self.storage.get_note(issuer_pubkey, recipient_pubkey))? + .ok_or_else(|| NoteError::StorageError("Note not found".to_string()))?; + if note.verify_signature(issuer_pubkey).is_err() { + self.poisoned.store(true, Ordering::SeqCst); + return Err(NoteError::InvalidSignature); } - // Preserve the same cumulative-debt invariant as `add_note` for internal - // updates (for example, redemption completion). - if let Some(existing_note) = self - .storage - .get_note(issuer_pubkey, ¬e.recipient_pubkey)? - { - if note.timestamp <= existing_note.timestamp { - return Err(NoteError::PastTimestamp); - } - if note.amount_collected < existing_note.amount_collected { - return Err(NoteError::DebtRegression); + 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.poisoned.store(true, Ordering::SeqCst); + return Err(error); } + }; + if committed_total != note.amount_collected { + self.poisoned.store(true, Ordering::SeqCst); + 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 @@ -914,6 +1009,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(); @@ -941,6 +1037,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(); @@ -964,6 +1061,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(); @@ -994,6 +1092,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(); @@ -1058,6 +1157,7 @@ impl TrackerStateManager { timestamp: u64, new_already_redeemed: u64, ) -> Result<(Vec, Vec), NoteError> { + self.ensure_healthy()?; 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) @@ -1079,6 +1179,12 @@ impl TrackerStateManager { /// 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 { + if let Err(e) = self.ensure_healthy() { + panic!( + "Cannot read reserve state from quarantined tracker: {:?}", + e + ); + } self.reserve_avl_state.root_digest().to_vec() } @@ -1092,6 +1198,7 @@ impl TrackerStateManager { timestamp: u64, already_redeemed: u64, ) -> Result<(), NoteError> { + self.ensure_healthy()?; let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); let key_bytes = key.to_bytes(); let mut value_bytes = Vec::with_capacity(16); @@ -1114,6 +1221,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(); @@ -1137,14 +1245,15 @@ impl TrackerStateManager { issuer_pubkey: &PubKey, recipient_pubkey: &PubKey, ) -> Result { - self.storage - .get_note(issuer_pubkey, recipient_pubkey)? + 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())) } /// 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) + self.ensure_healthy()?; + self.quarantine_on_storage_failure(self.storage.get_issuer_notes(issuer_pubkey)) } /// Calculate a conservative issuer-wide debt snapshot for an acceptance check. @@ -1161,11 +1270,11 @@ impl TrackerStateManager { candidate_recipient: Option<&PubKey>, candidate_total_debt: u64, ) -> Result { - // Secondary indices are query accelerators, not liability authority: - // store_note writes the primary record before its indices. Scan the - // primary partition strictly so an interrupted index update cannot hide - // debt from a collateralization decision. - let notes = self.storage.get_issuer_notes_strict(issuer_pubkey)?; + 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; @@ -1206,7 +1315,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 @@ -1214,18 +1324,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 @@ -1240,6 +1355,9 @@ impl TrackerStateManager { /// Get the current tracker state pub fn get_state(&self) -> &TrackerState { + if let Err(e) = self.ensure_healthy() { + panic!("Cannot publish state from quarantined tracker: {:?}", e); + } &self.current_state } } @@ -1251,6 +1369,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(); diff --git a/crates/basis_store/src/persistence.rs b/crates/basis_store/src/persistence.rs index 0173660..fb674c6 100644 --- a/crates/basis_store/src/persistence.rs +++ b/crates/basis_store/src/persistence.rs @@ -1,26 +1,46 @@ -//! 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, }; -use fjall::{Config, PartitionCreateOptions}; -use std::path::Path; +use fjall::{Config, Keyspace, PartitionCreateOptions, PersistMode}; +use fs2::FileExt; +#[cfg(test)] +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{ + fs::{File, OpenOptions}, + path::Path, + sync::Mutex, +}; + +const NOTE_STATE_MAGIC: &[u8; 4] = b"BNS1"; +const NOTE_STATE_KEY: &[u8] = b"note_state_v1"; +const NOTE_SCHEMA_KEY: &[u8] = b"note_schema_v1"; +const NOTE_STATE_HEADER_LEN: usize = 4 + 4 + 33; +const NOTE_RECORD_LEN: usize = 33 + 8 + 8 + 8 + 65 + 33; +const MAX_NOTE_COUNT: usize = 50_000; + +#[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 +/// 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, } /// Database storage for scanner metadata @@ -153,7 +173,28 @@ impl ScannerMetadataStorage { impl NoteStorage { /// Open or create a new note storage database with extra indices - pub fn open>(path: P) -> Result { + pub(crate) fn open>(path: P) -> 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 +205,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,288 +225,398 @@ 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), + }; + storage.ensure_state_initialized()?; + Ok(storage) } - #[cfg(test)] - pub(crate) fn remove_issuer_index_for_test( - &self, - issuer_pubkey: &PubKey, - ) -> Result<(), NoteError> { - self.issuer_index.remove(issuer_pubkey).map_err(|e| { - NoteError::StorageError(format!("Failed to remove issuer index in test: {}", e)) - })?; - Ok(()) + fn ensure_state_initialized(&self) -> 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)))?; + + match (schema, state) { + (Some(schema), Some(_)) => { + 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().map(|_| ()) + } + (Some(_), None) => Err(NoteError::StorageError( + "Note schema exists without authoritative state".to_string(), + )), + (None, Some(_)) => { + // Recover an initialization interrupted after the authoritative + // empty state was synced but before the schema marker was synced. + self.read_state_partition_strict()?; + 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!( + "Note schema initialization durability is unknown: {}", + e + )) + }) + } + (None, None) => { + 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(), + }; + 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.keyspace.persist(PersistMode::SyncData).map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Empty note state durability 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!( + "Note schema initialization durability is unknown: {}", + e + )) + }) + } + } } #[cfg(test)] - pub(crate) fn remove_primary_note_for_test( - &self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - ) -> Result<(), NoteError> { - let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); - self.notes_partition.remove(key.to_bytes()).map_err(|e| { - NoteError::StorageError(format!("Failed to remove note in test: {}", e)) - })?; - Ok(()) + 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 corrupt_primary_note_for_test( - &self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - ) -> Result<(), NoteError> { - let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); + pub(crate) fn corrupt_state_for_test(&self) -> Result<(), NoteError> { self.notes_partition - .insert(key.to_bytes(), &[0u8]) - .map_err(|e| { - NoteError::StorageError(format!("Failed to corrupt note in test: {}", e)) - })?; - Ok(()) + .insert(NOTE_STATE_KEY, [0u8]) + .map_err(|e| NoteError::StorageError(e.to_string())) } - /// 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()); + #[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 + bytes[amount_last_byte] ^= 1; + self.notes_partition + .insert(NOTE_STATE_KEY, bytes) + .map_err(|e| NoteError::StorageError(e.to_string())) } - /// Deserialize a list of note keys from bytes - fn deserialize_note_keys(bytes: &[u8]) -> Result, NoteError> { - if bytes.len() < 4 { - return Ok(Vec::new()); - } - 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 { + #[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( - "Invalid note key list format".to_string(), + "Note state header is truncated".to_string(), )); } - let mut offset = 4; - 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; - } - Ok(keys) + 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())) } - fn deserialize_note_keys_strict(bytes: &[u8]) -> Result, NoteError> { - if bytes.len() < 4 { + #[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); + } + + fn deserialize_note_state(value_bytes: &[u8]) -> Result { + if value_bytes.len() < NOTE_STATE_HEADER_LEN || &value_bytes[..4] != NOTE_STATE_MAGIC { return Err(NoteError::StorageError( - "Invalid note key list format".to_string(), + "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 expected_len = 4usize - .checked_add( - count.checked_mul(32).ok_or_else(|| { - NoteError::StorageError("Note key count overflow".to_string()) - })?, - ) - .ok_or_else(|| NoteError::StorageError("Note key list length overflow".to_string()))?; - - 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( - "Invalid note key list format".to_string(), + "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 keys = Vec::with_capacity(count); - for chunk in bytes[4..].chunks_exact(32) { - let key_bytes: [u8; 32] = chunk.try_into().unwrap(); - keys.push(NoteKey::from_bytes(&key_bytes)); + let mut avl_root_digest = [0u8; 33]; + avl_root_digest.copy_from_slice(&value_bytes[8..41]); + 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 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) - } - fn deserialize_note_value(value_bytes: &[u8]) -> Result<(PubKey, IouNote), NoteError> { - const STORED_NOTE_LEN: usize = 33 + 8 + 8 + 8 + 65 + 33; + Ok(StoredNoteState { + avl_root_digest, + notes, + }) + } - if value_bytes.len() != STORED_NOTE_LEN { + fn serialize_note_state(state: &StoredNoteState) -> Result, NoteError> { + if state.notes.len() > MAX_NOTE_COUNT { return Err(NoteError::StorageError( - "Invalid stored note format".to_string(), + "Note count exceeds configured bound".to_string(), )); } - - 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(); - - Ok(( - issuer_pubkey, - IouNote { - recipient_pubkey, - amount_collected, - amount_redeemed, - timestamp, - signature, - }, - )) + 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 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); + for (issuer_pubkey, note) in &state.notes { + bytes.extend_from_slice(issuer_pubkey); + bytes.extend_from_slice(¬e.amount_collected.to_be_bytes()); + bytes.extend_from_slice(¬e.amount_redeemed.to_be_bytes()); + bytes.extend_from_slice(¬e.timestamp.to_be_bytes()); + bytes.extend_from_slice(¬e.signature); + bytes.extend_from_slice(¬e.recipient_pubkey); + } + Ok(bytes) } - /// Add a note key to an index partition - fn add_to_index( - index: &fjall::Partition, - pubkey: &PubKey, - note_key: &NoteKey, + /// 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 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(), - }; - - // 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)))?; + 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() == MAX_NOTE_COUNT { + return Err(NoteError::StorageError( + "Note count exceeds configured bound".to_string(), + )); + } + 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(()) } - /// 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(()), - }; + 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 key_bytes = note_key.to_bytes(); - keys.retain(|k| k.to_bytes() != key_bytes); + self.read_state_partition_strict() + } - if keys.is_empty() { - index.remove(pubkey_bytes).map_err(|e| { - NoteError::StorageError(format!("Failed to remove index entry: {}", e)) + 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)) })?; - } else { - let serialized = Self::serialize_note_keys(&keys); - index - .insert(pubkey_bytes, &serialized) - .map_err(|e| NoteError::StorageError(format!("Failed to update index: {}", 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())?); } - - Ok(()) + state.ok_or_else(|| { + NoteError::StorageError("Authoritative note state is missing".to_string()) + }) } - /// 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); - + #[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(&key_bytes, &value_bytes) - .map_err(|e| NoteError::StorageError(format!("Failed to insert note: {}", e)))?; - - // 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)?; - + .insert(NOTE_STATE_KEY, Self::serialize_note_state(&state)?) + .map_err(|e| NoteError::StorageError(e.to_string()))?; Ok(()) } + #[cfg(test)] + pub(crate) fn note_row_count_for_test(&self) -> Result { + self.read_state_strict().map(|state| state.notes.len()) + } + /// 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); - 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(), - )); - } - - let mut offset = 0; - let _stored_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(); - - let note = IouNote { - recipient_pubkey, - amount_collected, - amount_redeemed, - timestamp, - signature, - }; - - Ok(Some(note)) - } - Ok(None) => Ok(None), - Err(e) => Err(NoteError::StorageError(format!( - "Failed to get note: {}", - e - ))), - } + 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. @@ -481,27 +634,6 @@ impl NoteStorage { 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 - ))), - } - } - /// Retrieve all confirmation records. pub fn get_all_confirmations(&self) -> Result, NoteError> { let mut results = Vec::new(); @@ -522,184 +654,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) - } - - fn get_notes_by_keys_with_issuer( - &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(_) => {} - } - } - Ok(notes) - } - - /// Get all notes for a specific issuer (uses issuer index for O(1) lookup) + /// Get all notes for a specific issuer from the strict primary snapshot. 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 - ))), - } + self.read_state_strict().map(|state| { + state + .notes + .into_iter() + .filter_map(|(issuer, note)| (issuer == *issuer_pubkey).then_some(note)) + .collect() + }) } /// Read an issuer's liabilities from the primary note partition. /// - /// This intentionally does not rely on the secondary issuer index for - /// completeness: the primary record is written before the index. Existing - /// index entries are still validated so stale or corrupt references fail - /// closed instead of being interpreted as zero debt. + /// The versioned primary note rows are the only liability authority. pub fn get_issuer_notes_strict( &self, issuer_pubkey: &PubKey, ) -> Result, NoteError> { - let mut notes = Vec::new(); - let mut primary_keys = std::collections::HashSet::new(); - - 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)) - })?; - let (stored_issuer, note) = Self::deserialize_note_value(value_bytes.as_ref())?; - let expected_key = - NoteKey::from_keys(&stored_issuer, ¬e.recipient_pubkey).to_bytes(); - - if stored_key.as_ref() != expected_key.as_slice() { + 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 note key does not match note contents".to_string(), + "Stored redeemed amount exceeds cumulative debt".to_string(), )); } - - if &stored_issuer == issuer_pubkey { - primary_keys.insert(expected_key); - notes.push(note); - } - } - - match self.issuer_index.get(issuer_pubkey) { - Ok(Some(bytes)) => { - for indexed_key in Self::deserialize_note_keys_strict(bytes.as_ref())? { - if !primary_keys.contains(&indexed_key.to_bytes()) { - return Err(NoteError::StorageError( - "Issuer index references a missing or mismatched note".to_string(), - )); - } - } - } - Ok(None) => {} - Err(e) => { - return Err(NoteError::StorageError(format!( - "Failed to read issuer index: {}", - e - ))) - } } - Ok(notes) } - /// Get all notes for a specific recipient (uses recipient index for O(1) lookup) + /// 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 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 @@ -707,156 +716,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/redemption.rs b/crates/basis_store/src/redemption.rs index 245c53d..4b04a1f 100644 --- a/crates/basis_store/src/redemption.rs +++ b/crates/basis_store/src/redemption.rs @@ -348,35 +348,25 @@ impl RedemptionManager { redeemed_amount: u64, new_already_redeemed: Option, ) -> Result<(), RedemptionError> { - // Get the current note - let mut note = self + // Read the signed payment timestamp before recording settlement progress. + // Settlement bookkeeping must never rewrite a signed field without a new + // issuer signature. + let 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) + let updated_note = self + .tracker + .record_redemption_progress(issuer_pubkey, recipient_pubkey, redeemed_amount) .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); + let tree_value = new_already_redeemed.unwrap_or(updated_note.amount_redeemed); self.tracker .update_already_redeemed( issuer_pubkey, @@ -967,12 +957,10 @@ mod tests { 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. + /// Regression test: settlement bookkeeping must preserve the issuer-signed + /// note timestamp while keeping the reserve AVL value in sync. #[test] - fn test_complete_redemption_syncs_reserve_tree_with_pre_refresh_timestamp() { + fn test_complete_redemption_preserves_signed_timestamp_and_syncs_reserve_tree() { let tracker = TrackerStateManager::new_with_temp_storage(); let mut redemption_manager = RedemptionManager::new(tracker); @@ -1021,10 +1009,11 @@ mod tests { .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" - ); + assert_eq!(updated.timestamp, payment_timestamp); + assert_eq!(updated.signature, signature); + updated + .verify_signature(&issuer_pubkey) + .expect("settlement must preserve the issuer signature"); // Reserve tree must contain (key, payment_timestamp || cumulative_redeemed) with the // PRE-refresh timestamp — compare against an independently built reference tree. @@ -1043,9 +1032,7 @@ mod tests { "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; + // A second local settlement update accumulates without rewriting signed fields. redemption_manager .complete_redemption(&issuer_pubkey, &recipient_pubkey, 50_000_000, None) .expect("second completion"); @@ -1054,14 +1041,14 @@ mod tests { .update_already_redeemed( &issuer_pubkey, &recipient_pubkey, - first_refresh_ts, + payment_timestamp, 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" + "second settlement update must accumulate without rewriting signed fields" ); } @@ -1095,11 +1082,10 @@ mod tests { 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, + amount_redeemed: 0, timestamp: payment_timestamp, signature, }; @@ -1108,6 +1094,13 @@ mod tests { .add_note(&issuer_pubkey, ¬e) .expect("add note"); + // Simulate 100M of tracker-derived settlement progress from an earlier + // reserve. Caller-supplied note creation is not allowed to inject it. + redemption_manager + .tracker + .record_redemption_progress(&issuer_pubkey, &recipient_pubkey, 100_000_000) + .expect("record prior settlement state"); + // On-chain build against a fresh (empty) reserve tree used cumulative 100M // (0 + 100M redeemed now), while the note accumulates to 200M. redemption_manager diff --git a/crates/basis_store/src/tests.rs b/crates/basis_store/src/tests.rs index c8ef866..d8ce645 100644 --- a/crates/basis_store/src/tests.rs +++ b/crates/basis_store/src/tests.rs @@ -782,7 +782,7 @@ mod confirmation_state_tests { } #[test] - fn internal_note_update_rejects_cumulative_debt_regression() { + 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); @@ -792,14 +792,75 @@ mod confirmation_state_tests { .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.update_note(&issuer, &create_note(&issuer_secret, &recipient, 40, 2)), - Err(crate::NoteError::DebtRegression) + manager.record_redemption_progress(&issuer, &recipient, 1), + Err(crate::NoteError::StorageError(message)) + if message.contains("Debt record not found") + )); + assert!(matches!( + manager.lookup_note(&issuer, &recipient), + Err(crate::NoteError::StorageOutcomeUnknown(_)) )); } #[test] - fn projected_issuer_gross_debt_uses_primary_notes_when_index_is_missing() { + fn settlement_progress_quarantines_on_tampered_signed_note() { + 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::InvalidSignature) + )); + 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); @@ -809,11 +870,6 @@ mod confirmation_state_tests { manager .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 60, 1)) .unwrap(); - manager - .storage - .remove_issuer_index_for_test(&issuer) - .unwrap(); - assert_eq!( manager .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) @@ -823,7 +879,7 @@ mod confirmation_state_tests { } #[test] - fn projected_issuer_gross_debt_rejects_stale_index_entry() { + 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); @@ -833,10 +889,7 @@ mod confirmation_state_tests { manager .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 60, 1)) .unwrap(); - manager - .storage - .remove_primary_note_for_test(&issuer, &recipient_b) - .unwrap(); + manager.storage.remove_state_for_test().unwrap(); assert!(manager .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) @@ -844,7 +897,7 @@ mod confirmation_state_tests { } #[test] - fn projected_issuer_gross_debt_rejects_corrupt_primary_note() { + 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); @@ -854,10 +907,7 @@ mod confirmation_state_tests { manager .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 60, 1)) .unwrap(); - manager - .storage - .corrupt_primary_note_for_test(&issuer, &recipient_b) - .unwrap(); + manager.storage.corrupt_state_for_test().unwrap(); assert!(manager .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) @@ -927,4 +977,351 @@ mod confirmation_state_tests { 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()); + 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()); + 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()); + 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()); + 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_signature_validation_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::InvalidSignature) + )); + 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::InvalidSignature) + )); + assert!(matches!( + manager.get_all_notes(), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[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 mut manager = TrackerStateManager::new(temp_dir.path()); + 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!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { manager.get_state() })) + .is_err() + ); + + drop(manager); + + let reopened = TrackerStateManager::try_new(temp_dir.path()).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 storage_allows_only_one_writer_per_path() { + let temp_dir = tempfile::tempdir().unwrap(); + let first = TrackerStateManager::try_new(temp_dir.path()).unwrap(); + assert!(matches!( + TrackerStateManager::try_new(temp_dir.path()), + Err(crate::NoteError::StorageError(message)) + if message.contains("active writer") + )); + drop(first); + assert!(TrackerStateManager::try_new(temp_dir.path()).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()), + 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..45a8ced 100644 --- a/crates/basis_store/src/tracker_scanner.rs +++ b/crates/basis_store/src/tracker_scanner.rs @@ -1,7 +1,6 @@ //! 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::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::Mutex; @@ -15,7 +14,7 @@ use reqwest::Client; use crate::{ ergo_scanner::{IndexedErgoBox, ScanBox}, persistence::{ScannerMetadataStorage, TrackerStorage}, - TrackerBoxInfo, TrackerStateManager, + TrackerBoxInfo, }; #[derive(Error, Debug)] @@ -71,28 +70,13 @@ 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 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 { @@ -476,7 +460,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, @@ -488,9 +471,7 @@ pub fn create_tracker_server_state( config, inner: Arc::new(Mutex::new(inner)), client: Client::new(), - tracker_state: TrackerStateManager::new(&data_dir), 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..d02920e 100644 --- a/crates/basis_store/src/tracker_scanner_test.rs +++ b/crates/basis_store/src/tracker_scanner_test.rs @@ -75,8 +75,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 +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); // Create a mock ScanBox let mut registers = HashMap::new(); @@ -212,8 +210,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 +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 missing R5 register (required) let mut registers = HashMap::new(); 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/specs/trees/note_state_snapshot.md b/specs/trees/note_state_snapshot.md new file mode 100644 index 0000000..d8d42ab --- /dev/null +++ b/specs/trees/note_state_snapshot.md @@ -0,0 +1,69 @@ +# Authoritative Note State Snapshot + +## Persistence invariant + +The tracker persists one versioned `BNS1` value in the `iou_notes` partition. +That value contains: + +1. the expected 33-byte AVL root digest; +2. the ordered live set of issuer-recipient notes; and +3. 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. + +## 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. + +## 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. + +## Legacy data + +A database containing 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. Only the internal, +checked settlement transition may advance it; 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. From 474bbb1ed39e9417d684626e5a1ae229d53b32f3 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:10:17 +0200 Subject: [PATCH 06/41] fix: close tracker state journal quarantine gaps --- config/basis.toml.example | 3 + crates/basis_server/src/api.rs | 35 ++ crates/basis_server/src/config.rs | 15 +- .../basis_server/src/create_reserve_tests.rs | 1 + crates/basis_server/src/lib.rs | 7 + crates/basis_server/src/main.rs | 104 +++-- crates/basis_server/src/redemption_build.rs | 13 + .../basis_server/src/tracker_box_updater.rs | 109 ++++- .../tests/acceptance_api_integration_tests.rs | 3 + crates/basis_server/tests/cors_tests.rs | 11 + .../tests/http_api_integration_tests.rs | 11 + .../tests/redemption_api_integration_tests.rs | 11 + crates/basis_store/src/lib.rs | 264 ++++++++++-- crates/basis_store/src/persistence.rs | 395 ++++++++++++++++-- crates/basis_store/src/redemption.rs | 3 +- crates/basis_store/src/tests.rs | 304 +++++++++++++- docs/CONFIGURATION.md | 15 + specs/server/tracker_box_update_spec.md | 35 +- specs/trees/note_state_snapshot.md | 48 ++- 19 files changed, 1229 insertions(+), 158 deletions(-) diff --git a/config/basis.toml.example b/config/basis.toml.example index 4e51103..b26092e 100644 --- a/config/basis.toml.example +++ b/config/basis.toml.example @@ -12,6 +12,9 @@ basis_reserve_contract_p2s = "3PQnJ92Krn6NeM1GdMSmNayw34Nuud7UKMoKSTRUTucsNybh99 # 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 # 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) diff --git a/crates/basis_server/src/api.rs b/crates/basis_server/src/api.rs index 834cbe7..3cb3ac8 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -310,6 +310,13 @@ pub async fn create_note( 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() @@ -441,6 +448,13 @@ pub async fn get_notes_by_issuer( 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() @@ -558,6 +572,13 @@ pub async fn get_notes_by_recipient( 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() @@ -712,6 +733,13 @@ pub async fn get_note_by_issuer_and_recipient( 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() @@ -810,6 +838,13 @@ pub async fn get_all_notes( 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() diff --git a/crates/basis_server/src/config.rs b/crates/basis_server/src/config.rs index e6b17a0..525c484 100644 --- a/crates/basis_server/src/config.rs +++ b/crates/basis_server/src/config.rs @@ -56,6 +56,10 @@ 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, /// 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) @@ -121,6 +125,7 @@ impl AppConfig { .set_default("ergo.tracker_public_key", "")? // Tracker secret key (optional - for local signing) .set_default("ergo.tracker_secret_key", "")? + .set_default("ergo.allow_fresh_tracker_generation", false)? // Acceptance predicate configuration (optional) .set_default("acceptance.default", "reject")? .set_default("acceptance.predicates", Vec::::new())? @@ -167,7 +172,14 @@ impl AppConfig { /// 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), } } @@ -444,6 +456,7 @@ mod tests { }, basis_reserve_contract_p2s: "test".to_string(), tracker_nft_id: None, + allow_fresh_tracker_generation: 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 55c2720..18d1afa 100644 --- a/crates/basis_server/src/create_reserve_tests.rs +++ b/crates/basis_server/src/create_reserve_tests.rs @@ -75,6 +75,7 @@ mod create_reserve_tests { tracker_nft_id: Some( "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b".to_string(), ), + allow_fresh_tracker_generation: false, tracker_public_key: Some( "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), ), diff --git a/crates/basis_server/src/lib.rs b/crates/basis_server/src/lib.rs index 7fafa38..d40bc0d 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -156,4 +156,11 @@ pub enum TrackerCommand { height: u64, response_tx: tokio::sync::oneshot::Sender>, }, + /// Validate or durably anchor the first observed root for the configured + /// tracker NFT before the updater may publish a successor commitment. + ValidateObservedGeneration { + tracker_nft_id: [u8; 32], + observed_root: [u8; 33], + response_tx: tokio::sync::oneshot::Sender>, + }, } diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 67e4a0a..89fd04b 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -45,6 +45,7 @@ async fn main() { }, basis_reserve_contract_p2s: "3PQnJ92Krn6NeM1GdMSmNayw34Nuud7UKMoKSTRUTucsNybh99K1HEfjZqyvP7cPag1yBkDv3ruMAgb2NsVKq3tAygjHz7mKDzHK6CJGhD3WfNViD7DoViqbgsXrzvs6Kt8Wyzb48uGqJAFQFWes6ZPKELqUZowy8xtVCS5w1VwnyaeRiWpEyUVGaEHw3qWo5DcVxzmMAP8XXhVTw1rYYrUxsyGPNaBxQkkkTVD9L3bmw77EfeAJgJ1hLxghykNofHscHtMtES4v5FSfqke3Huun81S7gNoraEnsR6Dy6YnQgrBswwCZhyGc89YeNFQn1TCFh5Hct3nKGrd1bV5zoCw67Q9fKtoaCtvcPQ2GDWycGKNRNgyAnPEa8WbHbTEVcjAN25aBwhnY5LFGqYxnUAjhpfkTPJ4FJWRijSqMESzpyrmhTLZdivmn4YSwcchVZr7bHGbfncEDwqPKefdoxNnVPxuVdmeqQXL3aDL7TaqWgExzz1UPXHw3UiKYTUkNgQKCN4WV3LHqc9PecoisL77ydVbSCxPapaX2zTf26F8bGK3hsTVBZnMkt93SJP5GmPgZU5FT9NkFh4okjXK9ce2wmA4MV93ySyYnUKGwTRFJWwE7G1MYqBqTY3ESkn8PJHqVuL4cgtuV2GEPagKt19befRAuUV3FaLGVPJMzpKdANd7hKGZRcy3DnPfT1Q9dyFD4VpdBgFRXJWaaDqYjL7ni4nJcKKam9P395wRRnjGWhTV4hv3KoxC8Xk2CZAUjhkTzvuNHxQrLsWjyrKWJqZgs2uZxoAEHEobDegYWiTcnFCPU9EeJxZLSjysDFninqpQvA66Yt1SvJnSZm49RKsaoR98UJVScdiQfNZE76zTYBioXGatdRz7QVkXDzDPjPMu9Hhepc2XbHqo3ia8tszHptbnSzm2R3PC7iu2Tnhu3QT".to_string(), tracker_nft_id: None, + allow_fresh_tracker_generation: false, tracker_public_key: None, tracker_secret_key: None, }, @@ -58,11 +59,18 @@ async fn main() { } }; - // 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 - } + // 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"); @@ -242,20 +250,41 @@ async fn main() { // Create channel for communicating with tracker thread let (tx, mut rx) = tokio::sync::mpsc::channel::(100); - // Clone the data directory for the tracker thread before the async move. + 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 + }, + }; + + // 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_state_for_tracker = shared_tracker_state_for_updater.clone(); // Also pass shared state for updater tokio::task::spawn_blocking(move || { - use basis_store::{RedemptionManager, TrackerStateManager}; + 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 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 = tracker.get_state().avl_root_digest; shared_state_for_tracker.set_avl_root_digest(initial_root); + let _ = init_tx.send(Ok(initial_root)); tracing::info!( "Tracker thread initialized with AVL root digest: {}", hex::encode(&initial_root) @@ -263,35 +292,6 @@ async fn main() { let mut redemption_manager = RedemptionManager::new(tracker); - // 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:?}"), - } - } - } - } - while let Some(cmd) = rx.blocking_recv() { tracing::debug!("Tracker thread received command: {:?}", cmd); match cmd { @@ -468,10 +468,34 @@ async fn main() { .reconcile_with_confirmed_digest(&digest, &box_id, height); let _ = response_tx.send(result); } + TrackerCommand::ValidateObservedGeneration { + tracker_nft_id, + observed_root, + response_tx, + } => { + let result = redemption_manager + .tracker + .validate_observed_generation(&tracker_nft_id, observed_root); + let _ = response_tx.send(result); + } } } }); + match init_rx.await { + Ok(Ok(_)) => {} + 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..."); diff --git a/crates/basis_server/src/redemption_build.rs b/crates/basis_server/src/redemption_build.rs index 214dd62..4a97f59 100644 --- a/crates/basis_server/src/redemption_build.rs +++ b/crates/basis_server/src/redemption_build.rs @@ -1189,6 +1189,19 @@ mod tests { use ergo_lib::ergotree_ir::chain::tx_id::TxId; use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; + #[test] + fn submit_request_rejects_unverified_accounting_metadata() { + let payload = serde_json::json!({ + "signed_tx": {"inputs": [], "dataInputs": [], "outputs": []}, + "issuer_pubkey": "02".repeat(33), + "recipient_pubkey": "03".repeat(33), + "redeemed_amount": 1, + "new_already_redeemed": 1 + }); + + assert!(serde_json::from_value::(payload).is_err()); + } + fn wallet_box(id: &str, value: u64, with_token: bool) -> NodeBox { let assets = if with_token { vec![NodeAsset { diff --git a/crates/basis_server/src/tracker_box_updater.rs b/crates/basis_server/src/tracker_box_updater.rs index 9ebe8db..4ca400d 100644 --- a/crates/basis_server/src/tracker_box_updater.rs +++ b/crates/basis_server/src/tracker_box_updater.rs @@ -48,6 +48,7 @@ pub struct SharedTrackerState { pub tracker_nft_id: Arc>>, pub confirmed: Arc>, pub pending: Arc>, + publication_health: basis_store::PublicationHealth, } impl SharedTrackerState { @@ -61,6 +62,7 @@ impl SharedTrackerState { tracker_nft_id: Arc::new(RwLock::new(None)), confirmed: Arc::new(RwLock::new(ConfirmedState::default())), pending: Arc::new(RwLock::new(PendingState::default())), + publication_health: basis_store::PublicationHealth::new(), } } @@ -72,6 +74,7 @@ impl SharedTrackerState { tracker_nft_id: Arc::new(RwLock::new(None)), confirmed: Arc::new(RwLock::new(ConfirmedState::default())), pending: Arc::new(RwLock::new(PendingState::default())), + publication_health: basis_store::PublicationHealth::new(), } } @@ -107,6 +110,25 @@ impl SharedTrackerState { } } + /// 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() + } + + /// Return the cached root only while the owning manager is healthy. + pub fn publication_digest(&self) -> Option<[u8; 33]> { + self.is_publication_healthy() + .then(|| self.get_avl_root_digest()) + } + pub fn get_tracker_pubkey(&self) -> [u8; 33] { if let Ok(pubkey_lock) = self.tracker_pubkey.read() { *pubkey_lock @@ -456,6 +478,14 @@ impl TrackerBoxUpdater { } } + // 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; + } + if let Some((ref tx_id, expected_digest)) = pending_tx { match Self::check_transaction_confirmation(&config, tx_id).await { Ok(true) => { @@ -508,7 +538,13 @@ impl TrackerBoxUpdater { } }; - let current_digest = shared_state.get_avl_root_digest(); + let current_digest = match shared_state.publication_digest() { + Some(digest) => digest, + None => { + error!("Tracker state is quarantined; refusing commitment publication"); + continue; + } + }; let tracker_pubkey = shared_state.get_tracker_pubkey(); if current_digest == [0u8; 33] { @@ -527,6 +563,7 @@ impl TrackerBoxUpdater { // Refresh the confirmed box id in shared state from the live node. shared_state.set_tracker_box_id(tracker_box.box_id.clone()); + let mut generation_validated = false; 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 { @@ -542,6 +579,46 @@ impl TrackerBoxUpdater { tracker_box.creation_height as u64, ); + // The first observed root must match the explicitly + // approved bootstrap root for this NFT. Every update is + // blocked until the state manager durably validates or + // anchors that generation. + let tracker_nft_bytes: [u8; 32] = match hex::decode(&tracker_nft_id) + .ok() + .and_then(|bytes| bytes.try_into().ok()) + { + Some(bytes) => bytes, + None => { + shared_state.quarantine_publication(); + error!("Configured tracker NFT is not exactly 32 bytes"); + continue; + } + }; + let generation_valid = if let Some(ref tx) = cmd_tx { + let (rtx, rrx) = tokio::sync::oneshot::channel(); + if tx + .send(crate::TrackerCommand::ValidateObservedGeneration { + tracker_nft_id: tracker_nft_bytes, + observed_root: onchain_digest_arr, + response_tx: rtx, + }) + .await + .is_err() + { + false + } else { + matches!(rrx.await, Ok(Ok(()))) + } + } else { + false + }; + if !generation_valid { + shared_state.quarantine_publication(); + error!("Tracker generation validation failed; refusing commitment publication"); + continue; + } + generation_validated = true; + // Reconcile per-note confirmation records with the // observed on-chain digest (handles restarts where the // local tree already matches the on-chain commitment). @@ -567,6 +644,14 @@ impl TrackerBoxUpdater { } } + if !generation_validated { + shared_state.quarantine_publication(); + error!( + "Tracker box has no valid R5 generation root; refusing commitment publication" + ); + 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). @@ -1204,3 +1289,25 @@ fn change_address_to_ergo_tree(address_str: &str) -> Result { + let result = redemption_manager + .tracker + .validate_observed_generation(&tracker_nft_id, observed_root); + let _ = response_tx.send(result); + } } } }); @@ -239,6 +249,7 @@ mod cors_tests { tracker_nft_id: Some( "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b".to_string(), ), + allow_fresh_tracker_generation: 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 83535c2..662a7ad 100644 --- a/crates/basis_server/tests/http_api_integration_tests.rs +++ b/crates/basis_server/tests/http_api_integration_tests.rs @@ -218,6 +218,16 @@ mod http_api_tests { let digest = redemption_manager.tracker.reserve_state_digest(); let _ = response_tx.send(digest); } + TrackerCommand::ValidateObservedGeneration { + tracker_nft_id, + observed_root, + response_tx, + } => { + let result = redemption_manager + .tracker + .validate_observed_generation(&tracker_nft_id, observed_root); + let _ = response_tx.send(result); + } } } }); @@ -239,6 +249,7 @@ mod http_api_tests { tracker_nft_id: Some( "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b".to_string(), ), + allow_fresh_tracker_generation: false, tracker_public_key: Some( "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), ), diff --git a/crates/basis_server/tests/redemption_api_integration_tests.rs b/crates/basis_server/tests/redemption_api_integration_tests.rs index d8f9395..87157fe 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -232,6 +232,16 @@ mod redemption_api_tests { .reconcile_with_confirmed_digest(&digest, &box_id, height); let _ = response_tx.send(result); } + TrackerCommand::ValidateObservedGeneration { + tracker_nft_id, + observed_root, + response_tx, + } => { + let result = redemption_manager + .tracker + .validate_observed_generation(&tracker_nft_id, observed_root); + let _ = response_tx.send(result); + } } } }); @@ -253,6 +263,7 @@ mod redemption_api_tests { tracker_nft_id: Some( "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b".to_string(), ), + allow_fresh_tracker_generation: false, tracker_public_key: Some( "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), ), diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index f43a5e4..87a967a 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -46,7 +46,10 @@ use basis_core::traits::SignatureVerifier; use secp256k1; use std::{ path::Path, - sync::atomic::{AtomicBool, Ordering}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, }; /// Public key type (Secp256k1) @@ -290,6 +293,18 @@ pub enum NoteError { /// 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 @@ -298,6 +313,47 @@ pub enum NoteError { 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 @@ -314,9 +370,15 @@ pub struct TrackerStateManager { /// 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) { Err(NoteError::StorageOutcomeUnknown( @@ -338,15 +400,43 @@ impl TrackerStateManager { | Err(NoteError::StorageError(_)) | Err(NoteError::StorageOutcomeUnknown(_)) | Err(NoteError::MigrationRequired(_)) + | Err(NoteError::GenerationMismatch(_)) + | Err(NoteError::GenerationBindingRequired(_)) ) { - self.poisoned.store(true, Ordering::SeqCst); + 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 { - Self::try_new(data_dir) + 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)) } @@ -355,12 +445,25 @@ impl TrackerStateManager { /// 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) -> Result { + 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..."); tracing::debug!("Opening note storage..."); let storage_path = data_dir.as_ref().join("notes"); - let storage = persistence::NoteStorage::open(&storage_path)?; + let storage = persistence::NoteStorage::open(&storage_path, generation)?; tracing::debug!("Note storage opened successfully at: {:?}", storage_path); let avl_state = basis_trees::BasisAvlTree::new().map_err(|e| { @@ -383,13 +486,14 @@ impl TrackerStateManager { reserve_avl_state, confirmations: std::collections::HashMap::new(), poisoned: AtomicBool::new(false), + publication_health, }; 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"); Ok(manager) @@ -410,7 +514,7 @@ impl TrackerStateManager { let rebuilt_tree = match self.build_validated_avl_tree() { Ok(tree) => tree, Err(error) => { - self.poisoned.store(true, Ordering::SeqCst); + self.poison(); return Err(error); } }; @@ -474,6 +578,55 @@ impl TrackerStateManager { 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) pub fn new_with_temp_storage() -> Self { tracing::debug!("Creating TrackerStateManager (test version with temporary storage)..."); @@ -494,7 +647,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 @@ -521,7 +678,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: {:?}", @@ -575,6 +732,7 @@ impl TrackerStateManager { 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. @@ -584,7 +742,9 @@ impl TrackerStateManager { e ); } - manager.rebuild_confirmations(); + if let Err(e) = manager.rebuild_confirmations() { + panic!("Failed to rebuild confirmations in test instance: {:?}", e); + } manager } @@ -593,6 +753,7 @@ impl TrackerStateManager { /// 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() @@ -606,9 +767,16 @@ impl TrackerStateManager { // A note is a cumulative debt record. Both its timestamp and totalDebt // must be monotone for a given issuer-recipient edge. - let existing_note = self.quarantine_on_storage_failure( - self.storage.get_note(issuer_pubkey, ¬e.recipient_pubkey), - )?; + 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); @@ -624,6 +792,13 @@ 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. @@ -646,12 +821,12 @@ impl TrackerStateManager { let mut candidate = match self.avl_state.try_clone() { Ok(candidate) => candidate, Err(error) => { - self.poisoned.store(true, Ordering::SeqCst); + self.poison(); return Err(NoteError::StorageError(error.to_string())); } }; if let Err(error) = candidate.update(key_bytes.clone(), value_bytes) { - self.poisoned.store(true, Ordering::SeqCst); + self.poison(); return Err(NoteError::StorageError(error.to_string())); } @@ -711,26 +886,13 @@ impl TrackerStateManager { /// 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) { - if let Err(e) = self.ensure_healthy() { - panic!( - "Cannot rebuild confirmations on quarantined tracker: {:?}", - e - ); - } - 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(); + 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 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); @@ -747,13 +909,16 @@ impl TrackerStateManager { 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. @@ -790,8 +955,8 @@ impl TrackerStateManager { submitted_height: u64, ) -> Result { self.ensure_healthy()?; + let notes = self.validate_complete_snapshot_against_live()?.notes; let _ = (digest, submitted_height); - let notes = self.storage.get_all_notes_with_issuer()?; let mut count = 0usize; for (issuer_pubkey, note) in ¬es { @@ -825,6 +990,7 @@ impl TrackerStateManager { /// number of notes transitioned to `Confirmed`. pub fn confirm_pending_notes(&mut self, box_id: &str, height: u64) -> Result { self.ensure_healthy()?; + self.validate_complete_snapshot_against_live()?; let keys: Vec = self.confirmations.keys().copied().collect(); let mut count = 0usize; @@ -864,7 +1030,7 @@ impl TrackerStateManager { /// number of notes reverted. pub fn revert_pending_notes(&mut self) -> Result { self.ensure_healthy()?; - let notes = self.storage.get_all_notes_with_issuer()?; + let notes = self.validate_complete_snapshot_against_live()?.notes; let mut count = 0usize; for (issuer_pubkey, note) in ¬es { @@ -906,11 +1072,10 @@ impl TrackerStateManager { 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 { @@ -952,6 +1117,7 @@ impl TrackerStateManager { /// 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, @@ -959,11 +1125,19 @@ impl TrackerStateManager { redeemed_amount: u64, ) -> Result { self.ensure_healthy()?; + let target_key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey).to_bytes(); let mut note = self - .quarantine_on_storage_failure(self.storage.get_note(issuer_pubkey, recipient_pubkey))? + .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.poisoned.store(true, Ordering::SeqCst); + self.poison(); return Err(NoteError::InvalidSignature); } @@ -973,12 +1147,12 @@ impl TrackerStateManager { // 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.poisoned.store(true, Ordering::SeqCst); + self.poison(); return Err(error); } }; if committed_total != note.amount_collected { - self.poisoned.store(true, Ordering::SeqCst); + self.poison(); return Err(NoteError::StorageError( "Stored note does not match the live AVL commitment".to_string(), )); @@ -1191,7 +1365,8 @@ impl TrackerStateManager { /// 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( + #[cfg(test)] + pub(crate) fn update_already_redeemed( &mut self, issuer_pubkey: &PubKey, recipient_pubkey: &PubKey, @@ -1199,6 +1374,7 @@ impl TrackerStateManager { already_redeemed: u64, ) -> Result<(), NoteError> { self.ensure_healthy()?; + self.validate_complete_snapshot_against_live()?; let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); let key_bytes = key.to_bytes(); let mut value_bytes = Vec::with_capacity(16); diff --git a/crates/basis_store/src/persistence.rs b/crates/basis_store/src/persistence.rs index fb674c6..8a321d7 100644 --- a/crates/basis_store/src/persistence.rs +++ b/crates/basis_store/src/persistence.rs @@ -1,25 +1,32 @@ //! 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, FreshGenerationApproval, IouNote, + NoteConfirmation, NoteError, NoteKey, PubKey, TrackerBoxInfo, TrackerGenerationConfig, }; use fjall::{Config, Keyspace, PartitionCreateOptions, PersistMode}; use fs2::FileExt; #[cfg(test)] -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::{ fs::{File, OpenOptions}, path::Path, sync::Mutex, }; -const NOTE_STATE_MAGIC: &[u8; 4] = b"BNS1"; -const NOTE_STATE_KEY: &[u8] = b"note_state_v1"; -const NOTE_SCHEMA_KEY: &[u8] = b"note_schema_v1"; -const NOTE_STATE_HEADER_LEN: usize = 4 + 4 + 33; +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 GENERATION_MAGIC: &[u8; 4] = b"BNG1"; +const SNAPSHOT_CHECKSUM_DOMAIN: &[u8] = b"basis-note-state-snapshot-v2"; +const GENERATION_CHECKSUM_DOMAIN: &[u8] = b"basis-tracker-generation-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; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct StoredNoteState { @@ -27,6 +34,13 @@ pub(crate) struct StoredNoteState { pub notes: Vec<(PubKey, IouNote)>, } +#[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. /// /// One `iou_notes` value contains the complete ordered live-note set and the AVL @@ -41,6 +55,8 @@ pub(crate) struct NoteStorage { _writer_file_lock: File, #[cfg(test)] fail_next_persist: AtomicBool, + #[cfg(test)] + capacity_limit: AtomicUsize, } /// Database storage for scanner metadata @@ -172,8 +188,22 @@ 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(crate) 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)) @@ -240,12 +270,17 @@ impl NoteStorage { _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()?; + storage.ensure_state_initialized(generation)?; Ok(storage) } - fn ensure_state_initialized(&self) -> Result<(), NoteError> { + fn ensure_state_initialized( + &self, + generation: TrackerGenerationConfig, + ) -> Result<(), NoteError> { let schema = self .schema_partition .get(NOTE_SCHEMA_KEY) @@ -263,7 +298,8 @@ impl NoteStorage { .to_string(), )); } - self.read_state_strict().map(|_| ()) + let state = self.read_state_strict()?; + self.ensure_generation_manifest(generation, &state) } (Some(_), None) => Err(NoteError::StorageError( "Note schema exists without authoritative state".to_string(), @@ -285,9 +321,17 @@ impl NoteStorage { "Note schema initialization durability is unknown: {}", e )) - }) + })?; + let state = self.read_state_strict()?; + self.ensure_generation_manifest(generation, &state) } (None, None) => { + 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)) })? { @@ -330,11 +374,67 @@ impl NoteStorage { "Note schema initialization durability is unknown: {}", e )) - }) + })?; + self.ensure_generation_manifest(generation, &empty) } } } + fn ensure_generation_manifest( + &self, + generation: TrackerGenerationConfig, + state: &StoredNoteState, + ) -> Result<(), NoteError> { + let stored = self + .schema_partition + .get(NOTE_GENERATION_KEY) + .map_err(|e| NoteError::StorageError(format!("Failed to read generation: {}", e)))?; + + if let Some(bytes) = stored { + let manifest = Self::deserialize_generation(bytes.as_ref())?; + 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(), + )); + } + return Ok(()); + } + + if generation.fresh_generation != FreshGenerationApproval::Approve { + return Err(NoteError::GenerationBindingRequired( + "Tracker generation manifest is missing; explicit fresh-generation approval is required" + .to_string(), + )); + } + if !state.notes.is_empty() { + return Err(NoteError::MigrationRequired( + "A non-empty note snapshot without a generation manifest requires explicit migration" + .to_string(), + )); + } + + let manifest = StoredGeneration { + tracker_nft_id: generation.tracker_nft_id, + bootstrap_root: state.avl_root_digest, + anchor_root: None, + }; + 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.keyspace.persist(PersistMode::SyncData).map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Tracker generation binding durability is unknown: {}", + e + )) + }) + } + #[cfg(test)] pub(crate) fn remove_state_for_test(&self) -> Result<(), NoteError> { self.notes_partition @@ -369,6 +469,25 @@ impl NoteStorage { .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 @@ -400,7 +519,74 @@ impl NoteStorage { 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); + } + + #[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(), @@ -429,6 +615,17 @@ impl NoteStorage { 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( + "Authoritative note snapshot checksum mismatch".to_string(), + )); + } let mut notes = Vec::with_capacity(count); let mut seen_keys = std::collections::HashSet::with_capacity(count); let mut offset = NOTE_STATE_HEADER_LEN; @@ -473,9 +670,9 @@ impl NoteStorage { fn serialize_note_state(state: &StoredNoteState) -> Result, NoteError> { if state.notes.len() > MAX_NOTE_COUNT { - return Err(NoteError::StorageError( - "Note count exceeds configured bound".to_string(), - )); + 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()))?; @@ -490,21 +687,100 @@ impl NoteStorage { })?, ) .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); - for (issuer_pubkey, note) in &state.notes { - bytes.extend_from_slice(issuer_pubkey); - bytes.extend_from_slice(¬e.amount_collected.to_be_bytes()); - bytes.extend_from_slice(¬e.amount_redeemed.to_be_bytes()); - bytes.extend_from_slice(¬e.timestamp.to_be_bytes()); - bytes.extend_from_slice(¬e.signature); - bytes.extend_from_slice(¬e.recipient_pubkey); - } + 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, + }) + } + /// Store an IOU note with its issuer public key pub(crate) fn store_note( &self, @@ -524,10 +800,10 @@ impl NoteStorage { { *stored_note = note.clone(); } else { - if state.notes.len() == MAX_NOTE_COUNT { - return Err(NoteError::StorageError( - "Note count exceeds configured bound".to_string(), - )); + if state.notes.len() >= self.capacity_limit() { + return Err(NoteError::CapacityExceeded { + limit: self.capacity_limit(), + }); } state.notes.push((*issuer_pubkey, note.clone())); } @@ -557,6 +833,69 @@ impl NoteStorage { Ok(()) } + pub(crate) fn ensure_capacity_for_validated_state( + &self, + 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(()) + } + + 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(), + )); + } + + 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 + )) + }) + } + pub(crate) fn read_state_strict(&self) -> Result { let schema = self .schema_partition diff --git a/crates/basis_store/src/redemption.rs b/crates/basis_store/src/redemption.rs index 4b04a1f..632266b 100644 --- a/crates/basis_store/src/redemption.rs +++ b/crates/basis_store/src/redemption.rs @@ -341,7 +341,8 @@ impl RedemptionManager { /// `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( + #[cfg(test)] + pub(crate) fn complete_redemption( &mut self, issuer_pubkey: &PubKey, recipient_pubkey: &PubKey, diff --git a/crates/basis_store/src/tests.rs b/crates/basis_store/src/tests.rs index d8ce645..e2b1f2c 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, 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"); @@ -661,7 +671,7 @@ mod confirmation_state_tests { let digest = manager.get_state().avl_root_digest; manager.mark_notes_pending(digest, "tx123", 100).unwrap(); - manager.rebuild_confirmations(); + manager.rebuild_confirmations().unwrap(); let confirmation = manager.get_confirmation(&issuer, &recipient).unwrap(); assert_eq!(confirmation.status, NoteConfirmationStatus::LocalOnly); @@ -829,7 +839,7 @@ mod confirmation_state_tests { assert!(matches!( manager.record_redemption_progress(&issuer, &recipient, 1), Err(crate::NoteError::StorageError(message)) - if message.contains("Debt record not found") + if message.contains("live root") )); assert!(matches!( manager.lookup_note(&issuer, &recipient), @@ -838,7 +848,7 @@ mod confirmation_state_tests { } #[test] - fn settlement_progress_quarantines_on_tampered_signed_note() { + fn settlement_progress_quarantines_on_tampered_snapshot() { let mut manager = make_manager(); let issuer_secret = [1u8; 32]; let issuer = issuer_pubkey(&issuer_secret); @@ -851,7 +861,8 @@ mod confirmation_state_tests { assert!(matches!( manager.record_redemption_progress(&issuer, &recipient, 1), - Err(crate::NoteError::InvalidSignature) + Err(crate::NoteError::StorageError(message)) + if message.contains("checksum") )); assert!(matches!( manager.lookup_note(&issuer, &recipient), @@ -963,13 +974,17 @@ mod confirmation_state_tests { let recipient_c = [3u8; 33]; { - let mut manager = TrackerStateManager::new(temp_dir.path()); + 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()); + let manager = + TrackerStateManager::new(temp_dir.path(), generation(FreshGenerationApproval::Deny)); assert_eq!( manager .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) @@ -987,7 +1002,10 @@ mod confirmation_state_tests { let recipient_c = [3u8; 33]; let root_before_restart = { - let mut manager = TrackerStateManager::new(temp_dir.path()); + let mut manager = TrackerStateManager::new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ); manager .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 60, 2)) .unwrap(); @@ -997,7 +1015,8 @@ mod confirmation_state_tests { manager.get_state().avl_root_digest }; - let manager = TrackerStateManager::new(temp_dir.path()); + let manager = + TrackerStateManager::new(temp_dir.path(), generation(FreshGenerationApproval::Deny)); assert_eq!(manager.get_state().avl_root_digest, root_before_restart); } @@ -1124,7 +1143,10 @@ mod confirmation_state_tests { let recipient = [2u8; 33]; let root_before_restart = { - let mut manager = TrackerStateManager::new(temp_dir.path()); + let mut manager = TrackerStateManager::new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ); for version in 1..=25u64 { manager .add_note( @@ -1137,7 +1159,8 @@ mod confirmation_state_tests { manager.get_state().avl_root_digest }; - let manager = TrackerStateManager::new(temp_dir.path()); + 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); @@ -1177,7 +1200,7 @@ mod confirmation_state_tests { } #[test] - fn same_length_debt_tampering_fails_signature_validation_and_quarantines() { + 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); @@ -1191,7 +1214,8 @@ mod confirmation_state_tests { assert!(matches!( manager.rebuild_avl_tree(), - Err(crate::NoteError::InvalidSignature) + Err(crate::NoteError::StorageError(message)) + if message.contains("checksum") )); assert_eq!(manager.avl_state.root_digest(), root_before); assert!(matches!( @@ -1214,7 +1238,8 @@ mod confirmation_state_tests { assert!(matches!( manager.projected_issuer_gross_debt(&issuer, Some(&recipient), 100), - Err(crate::NoteError::InvalidSignature) + Err(crate::NoteError::StorageError(message)) + if message.contains("checksum") )); assert!(matches!( manager.get_all_notes(), @@ -1222,6 +1247,220 @@ mod confirmation_state_tests { )); } + #[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(_)) + )); + } + + #[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 declared_note_count_above_bound_fails_closed() { let mut manager = make_manager(); @@ -1249,7 +1488,13 @@ mod confirmation_state_tests { let recipient_b = [2u8; 33]; let recipient_c = [3u8; 33]; - let mut manager = TrackerStateManager::new(temp_dir.path()); + 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(); @@ -1263,6 +1508,7 @@ mod confirmation_state_tests { 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() @@ -1270,7 +1516,11 @@ mod confirmation_state_tests { drop(manager); - let reopened = TrackerStateManager::try_new(temp_dir.path()).unwrap(); + 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, @@ -1282,14 +1532,25 @@ mod confirmation_state_tests { #[test] fn storage_allows_only_one_writer_per_path() { let temp_dir = tempfile::tempdir().unwrap(); - let first = TrackerStateManager::try_new(temp_dir.path()).unwrap(); + let first = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); assert!(matches!( - TrackerStateManager::try_new(temp_dir.path()), + 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()).is_ok()); + assert!(TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny) + ) + .is_ok()); } #[test] @@ -1309,7 +1570,10 @@ mod confirmation_state_tests { } assert!(matches!( - TrackerStateManager::try_new(temp_dir.path()), + TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny) + ), Err(crate::NoteError::MigrationRequired(message)) if message.contains("explicit") )); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a54d1d7..b7b838d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -26,6 +26,14 @@ database_url = "sqlite:data/basis.db" # Legacy field, kept for compatibility (c `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 @@ -41,6 +49,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" @@ -97,6 +109,8 @@ 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_URL="http://your-node:9053" ``` @@ -144,6 +158,7 @@ database_url = "sqlite:data/basis.db" basis_reserve_contract_p2s = "" start_height = 0 tracker_nft_id = "" +allow_fresh_tracker_generation = false [ergo.node] url = "http://159.89.116.15:11088" diff --git a/specs/server/tracker_box_update_spec.md b/specs/server/tracker_box_update_spec.md index 26025a6..6cf6d80 100644 --- a/specs/server/tracker_box_update_spec.md +++ b/specs/server/tracker_box_update_spec.md @@ -168,25 +168,26 @@ 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**: +9. **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**: +10. **Submit Transaction**: - POST the unsigned transaction to `/wallet/transaction/sign` to obtain a signed transaction - POST the signed transaction to `/transactions` to broadcast it - Log the transaction ID on successful broadcast and mark it as pending confirmation -10. **Error Handling**: +11. **Error Handling**: - If any step fails, log an appropriate ERROR message - Continue with the scheduled interval regardless of failures @@ -464,9 +465,9 @@ 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. **Generation Validation Command**: Validate the configured tracker NFT and first observed R5 against the durable generation manifest before publication +3. **AVL Tree Operations**: Each admitted note update produces a validated durable snapshot before the shared root changes +4. **State Consistency**: A one-way health gate removes every cached root from the publication path after manager quarantine ## Logging Specifications @@ -566,10 +567,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 +582,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/trees/note_state_snapshot.md b/specs/trees/note_state_snapshot.md index d8d42ab..d59d6d3 100644 --- a/specs/trees/note_state_snapshot.md +++ b/specs/trees/note_state_snapshot.md @@ -2,12 +2,14 @@ ## Persistence invariant -The tracker persists one versioned `BNS1` value in the `iou_notes` partition. +The tracker persists one versioned `BNS2` value in the `iou_notes` partition. That value contains: 1. the expected 33-byte AVL root digest; -2. the ordered live set of issuer-recipient notes; and -3. each note's tracker-derived redeemed progress. +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 @@ -20,6 +22,17 @@ 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. @@ -32,6 +45,10 @@ 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. + ## Recovery Startup parses the entire authoritative value with exact length and count @@ -45,9 +62,25 @@ 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 legacy per-note rows is returned as +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: @@ -60,8 +93,11 @@ Operators must choose one of two separately reviewed procedures: `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. Only the internal, -checked settlement transition may advance it; signed fields remain unchanged. +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 From bc7bf2caacb9f3261c982a42446fa559a9c2081a Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:24:12 +0200 Subject: [PATCH 07/41] fix: fence tracker state publication Fail closed on partial generation state, expose validated roots only through the tracker actor, and hold a monotonic lease across signing and broadcast. Port the overlapping public tombstones from containment commit 2c85486 without importing its unrelated CLI and documentation changes. --- crates/basis_server/src/api.rs | 134 +++++--- crates/basis_server/src/lib.rs | 60 +++- crates/basis_server/src/main.rs | 292 +++++++++++++++++- crates/basis_server/src/redemption_build.rs | 34 +- .../basis_server/src/tracker_box_updater.rs | 262 ++++++++++------ crates/basis_server/tests/cors_tests.rs | 41 ++- .../tests/http_api_integration_tests.rs | 69 ++++- .../tests/redemption_api_integration_tests.rs | 41 ++- .../tests/tracker_box_updater_integration.rs | 23 +- crates/basis_store/src/lib.rs | 148 +++++---- crates/basis_store/src/persistence.rs | 187 +++++------ crates/basis_store/src/redemption.rs | 12 +- crates/basis_store/src/tests.rs | 167 ++++++++++ 13 files changed, 1105 insertions(+), 365 deletions(-) diff --git a/crates/basis_server/src/api.rs b/crates/basis_server/src/api.rs index 3cb3ac8..3c7312c 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -321,6 +321,12 @@ pub async fn create_note( 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::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -459,6 +465,12 @@ pub async fn get_notes_by_issuer( 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::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -583,6 +595,12 @@ pub async fn get_notes_by_recipient( 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::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -744,6 +762,12 @@ pub async fn get_note_by_issuer_and_recipient( 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::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -849,6 +873,12 @@ pub async fn get_all_notes( 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::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -1759,13 +1789,8 @@ pub async fn get_tracker_proof( } }; - // 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 + // Request the lookup proof and its exact validated BNS2-backed root from + // the owning tracker actor in one serialized command. let (response_tx, response_rx) = tokio::sync::oneshot::channel(); if let Err(e) = state @@ -1788,7 +1813,8 @@ pub async fn get_tracker_proof( // Wait for response from tracker thread match response_rx.await { - Ok(Ok(proof)) => { + Ok(Ok((proof, tracker_state))) => { + let tracker_state_digest = hex::encode(tracker_state.avl_root_digest); // Extract total debt from proof value let total_debt = if proof.value.len() == 8 { let mut bytes = [0u8; 8]; @@ -1943,7 +1969,7 @@ pub async fn get_reserve_proof( // Wait for response from tracker thread match response_rx.await { - Ok(Ok(proof)) => { + Ok(Ok((proof, lookup_root))) => { // 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]; @@ -2007,6 +2033,15 @@ pub async fn get_reserve_proof( } }; + if lookup_root != insert_proof.2 { + return ( + StatusCode::CONFLICT, + Json(crate::models::error_response( + "Reserve state changed while generating proofs; retry".to_string(), + )), + ); + } + let proof_data = crate::models::ReserveProofData { key: hex::encode(&proof.key), value: hex::encode(&proof.value), @@ -2475,17 +2510,7 @@ pub async fn prepare_redemption( ); } - // 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 + // Generate a proof together with the exact validated actor-owned root. let (proof_response_tx, proof_response_rx) = tokio::sync::oneshot::channel(); let issuer_pubkey_bytes = match hex::decode(&payload.issuer_pubkey) { @@ -2554,11 +2579,11 @@ pub async fn prepare_redemption( } // 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) - } + let (proof_result, tracker_state_digest) = match proof_response_rx.await { + Ok(Ok((note_proof, tracker_state))) => ( + hex::encode(¬e_proof.avl_proof), + hex::encode(tracker_state.avl_root_digest), + ), Ok(Err(e)) => { tracing::error!("Failed to generate proof: {:?}", e); return ( @@ -2671,17 +2696,7 @@ pub async fn get_redemption_proof( } } - // 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 + // Generate a proof together with the exact validated actor-owned root. let (proof_response_tx, proof_response_rx) = tokio::sync::oneshot::channel(); let issuer_pubkey_bytes = match hex::decode(issuer_pubkey) { @@ -2750,11 +2765,11 @@ pub async fn get_redemption_proof( } // 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) - } + let (proof_result, tracker_state_digest) = match proof_response_rx.await { + Ok(Ok((note_proof, tracker_state))) => ( + hex::encode(¬e_proof.avl_proof), + hex::encode(tracker_state.avl_root_digest), + ), Ok(Err(e)) => { tracing::error!("Failed to generate proof: {:?}", e); return ( @@ -3104,14 +3119,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, diff --git a/crates/basis_server/src/lib.rs b/crates/basis_server/src/lib.rs index d40bc0d..9451c6d 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -44,6 +44,15 @@ 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], +} + // Commands that can be sent to the tracker thread #[derive(Debug)] pub enum TrackerCommand { @@ -89,21 +98,25 @@ pub enum TrackerCommand { GenerateProof { issuer_pubkey: basis_store::PubKey, recipient_pubkey: basis_store::PubKey, - response_tx: - tokio::sync::oneshot::Sender>, + response_tx: tokio::sync::oneshot::Sender< + Result<(basis_store::NoteProof, basis_store::TrackerState), basis_store::NoteError>, + >, }, GetTrackerLookupProof { issuer_pubkey: basis_store::PubKey, recipient_pubkey: basis_store::PubKey, response_tx: tokio::sync::oneshot::Sender< - Result, + Result< + (basis_store::TrackerLookupProof, basis_store::TrackerState), + basis_store::NoteError, + >, >, }, GetReserveLookupProof { issuer_pubkey: basis_store::PubKey, recipient_pubkey: basis_store::PubKey, response_tx: tokio::sync::oneshot::Sender< - Result, + Result<(basis_store::ReserveLookupProof, Vec), basis_store::NoteError>, >, }, GetReserveInsertProof { @@ -111,12 +124,18 @@ pub enum TrackerCommand { recipient_pubkey: basis_store::PubKey, timestamp: u64, new_already_redeemed: u64, - response_tx: - tokio::sync::oneshot::Sender, Vec), basis_store::NoteError>>, + response_tx: tokio::sync::oneshot::Sender< + Result<(Vec, Vec, Vec), basis_store::NoteError>, + >, }, /// Get the current reserve AVL tree root digest (33 bytes). GetReserveStateDigest { - response_tx: tokio::sync::oneshot::Sender>, + response_tx: tokio::sync::oneshot::Sender, basis_store::NoteError>>, + }, + /// Get the current BNS2-backed tracker state through its owning actor. + GetValidatedState { + response_tx: + tokio::sync::oneshot::Sender>, }, /// Get the confirmation record for a single note. GetConfirmation { @@ -129,7 +148,10 @@ 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. @@ -163,4 +185,26 @@ pub enum TrackerCommand { observed_root: [u8; 33], response_tx: tokio::sync::oneshot::Sender>, }, + /// 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>, + }, + /// Bind a submitted transaction to the exact leased digest and release the + /// actor fence. + CompletePublication { + lease: PublicationLease, + tx_id: String, + submitted_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>, + }, } diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 89fd04b..97cf0d6 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -4,8 +4,8 @@ use axum::{ }; use basis_server::{ api::*, build_redemption, reserve_api::*, store::EventStore, submit_redemption, AppConfig, - AppState, ErgoConfig, EventType, ServerConfig, SharedTrackerState, TrackerBoxUpdateConfig, - TrackerBoxUpdater, TrackerCommand, TrackerEvent, TransactionConfig, + AppState, ErgoConfig, EventType, PublicationLease, ServerConfig, SharedTrackerState, + TrackerBoxUpdateConfig, TrackerBoxUpdater, TrackerCommand, TrackerEvent, TransactionConfig, }; use basis_store::{ ergo_scanner::{start_scanner, NodeConfig, ReserveEvent, ServerState}, @@ -16,6 +16,76 @@ use tokio::sync::Mutex; use tower_http::cors::{Any, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +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::GenerateProof { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetTrackerLookupProof { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetReserveLookupProof { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetReserveInsertProof { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetReserveStateDigest { 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::MarkNotesPending { response_tx, .. } + | TrackerCommand::ConfirmPendingNotes { response_tx, .. } + | TrackerCommand::RevertPendingNotes { response_tx } + | TrackerCommand::ReconcileWithConfirmedDigest { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::ValidateObservedGeneration { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::BeginPublication { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::CompletePublication { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationLeaseMismatch)); + } + TrackerCommand::AbortPublication { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationLeaseMismatch)); + } + } +} + #[tokio::main] async fn main() { tracing::info!("Starting basis server..."); @@ -282,8 +352,14 @@ async fn main() { return; } }; - let initial_root = tracker.get_state().avl_root_digest; - shared_state_for_tracker.set_avl_root_digest(initial_root); + 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 _ = init_tx.send(Ok(initial_root)); tracing::info!( "Tracker thread initialized with AVL root digest: {}", @@ -291,9 +367,53 @@ async fn main() { ); let mut redemption_manager = RedemptionManager::new(tracker); + let mut active_publication: Option = None; + let mut next_publication_id = 1u64; while let Some(cmd) = rx.blocking_recv() { tracing::debug!("Tracker thread received command: {:?}", cmd); + + if let Some(active_lease) = active_publication { + match cmd { + TrackerCommand::CompletePublication { + lease, + tx_id, + submitted_height, + response_tx, + } if lease == active_lease => { + let result = + redemption_manager + .tracker + .validated_state() + .and_then(|state| { + if state.avl_root_digest != lease.digest { + return Err( + basis_store::NoteError::PublicationLeaseMismatch, + ); + } + redemption_manager.tracker.mark_notes_pending( + lease.digest, + &tx_id, + submitted_height, + ) + }); + if result.is_err() { + shared_state_for_tracker.quarantine_publication(); + } + active_publication = None; + let _ = response_tx.send(result); + } + TrackerCommand::AbortPublication { lease, response_tx } + if lease == active_lease => + { + active_publication = None; + let _ = response_tx.send(Ok(())); + } + other => reject_while_publication_is_fenced(other), + } + continue; + } + match cmd { TrackerCommand::AddNote { issuer_pubkey, @@ -306,9 +426,6 @@ async fn main() { // 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 } @@ -375,7 +492,13 @@ async fn main() { } => { let result = redemption_manager .tracker - .generate_proof(&issuer_pubkey, &recipient_pubkey); + .generate_proof(&issuer_pubkey, &recipient_pubkey) + .and_then(|proof| { + redemption_manager + .tracker + .validated_state() + .map(|state| (proof, state)) + }); let _ = response_tx.send(result); } TrackerCommand::GetTrackerLookupProof { @@ -385,7 +508,13 @@ async fn main() { } => { let result = redemption_manager .tracker - .generate_tracker_lookup_proof(&issuer_pubkey, &recipient_pubkey); + .generate_tracker_lookup_proof(&issuer_pubkey, &recipient_pubkey) + .and_then(|proof| { + redemption_manager + .tracker + .validated_state() + .map(|state| (proof, state)) + }); let _ = response_tx.send(result); } TrackerCommand::GetReserveLookupProof { @@ -395,7 +524,13 @@ async fn main() { } => { let result = redemption_manager .tracker - .generate_reserve_lookup_proof(&issuer_pubkey, &recipient_pubkey); + .generate_reserve_lookup_proof(&issuer_pubkey, &recipient_pubkey) + .and_then(|proof| { + redemption_manager + .tracker + .reserve_state_digest() + .map(|root| (proof, root)) + }); let _ = response_tx.send(result); } TrackerCommand::GetReserveInsertProof { @@ -405,18 +540,29 @@ async fn main() { new_already_redeemed, response_tx, } => { - let result = redemption_manager.tracker.generate_reserve_insert_proof( - &issuer_pubkey, - &recipient_pubkey, - timestamp, - new_already_redeemed, - ); + let result = redemption_manager + .tracker + .generate_reserve_insert_proof( + &issuer_pubkey, + &recipient_pubkey, + timestamp, + new_already_redeemed, + ) + .and_then(|(proof, updated_root)| { + redemption_manager + .tracker + .reserve_state_digest() + .map(|current_root| (proof, updated_root, current_root)) + }); 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(redemption_manager.tracker.validated_state()); + } TrackerCommand::GetConfirmation { issuer_pubkey, recipient_pubkey, @@ -428,7 +574,11 @@ async fn main() { let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.all_confirmations()); + let result = redemption_manager + .tracker + .validated_state() + .map(|_| redemption_manager.tracker.all_confirmations()); + let _ = response_tx.send(result); } TrackerCommand::MarkNotesPending { digest, @@ -478,6 +628,53 @@ async fn main() { .validate_observed_generation(&tracker_nft_id, observed_root); let _ = response_tx.send(result); } + TrackerCommand::BeginPublication { + tracker_nft_id, + observed_root, + box_id, + height, + response_tx, + } => { + let result = redemption_manager + .tracker + .validate_observed_generation(&tracker_nft_id, observed_root) + .and_then(|_| { + redemption_manager.tracker.reconcile_with_confirmed_digest( + &observed_root, + &box_id, + height, + )?; + redemption_manager.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::CompletePublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::PublicationLeaseMismatch)); + } + TrackerCommand::AbortPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::PublicationLeaseMismatch)); + } } } }); @@ -857,6 +1054,67 @@ async fn main() { }; } +#[cfg(test)] +mod publication_fence_tests { + use super::*; + + #[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)) + )); + + let (proof_tx, proof_rx) = tokio::sync::oneshot::channel(); + reject_while_publication_is_fenced(TrackerCommand::GenerateProof { + issuer_pubkey: [2u8; 33], + recipient_pubkey: [3u8; 33], + response_tx: proof_tx, + }); + assert!(matches!( + proof_rx.await, + Ok(Err(basis_store::NoteError::PublicationInProgress)) + )); + } + + #[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)) + )); + } +} + /// Background task that continuously scans the blockchain for reserve events #[allow(dead_code)] async fn background_scanner_task(state: AppState, config: AppConfig) { diff --git a/crates/basis_server/src/redemption_build.rs b/crates/basis_server/src/redemption_build.rs index 4a97f59..126b31b 100644 --- a/crates/basis_server/src/redemption_build.rs +++ b/crates/basis_server/src/redemption_build.rs @@ -483,7 +483,7 @@ async fn build_redemption_inner( // 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 = { + let (reserve_box_id, selected_reserve_digest) = { // 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(); @@ -499,10 +499,17 @@ async fn build_redemption_inner( ); } match rx.await { - Ok(d) => hex::encode(d), + Ok(Ok(d)) => d, + Ok(Err(e)) => { + return api_err( + StatusCode::SERVICE_UNAVAILABLE, + format!("tracker reserve state unavailable: {e:?}"), + ) + } Err(_) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "tracker unavailable"), } }; + let tracker_reserve_digest_hex = hex::encode(&tracker_reserve_digest); let scanner = state.ergo_scanner.lock().await; let reserves = match scanner.reserve_storage().get_all_reserves() { @@ -552,7 +559,7 @@ async fn build_redemption_inner( 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 => { + Some(d) if d == tracker_reserve_digest_hex => { found = Some(box_id.clone()); break; } @@ -561,7 +568,7 @@ async fn build_redemption_inner( "reserve {} R5 {} != tracker tree {}; skipping", box_id, &d[..16.min(d.len())], - &tracker_reserve_digest[..16.min(tracker_reserve_digest.len())] + &tracker_reserve_digest_hex[..16.min(tracker_reserve_digest_hex.len())] ); } None => { @@ -574,13 +581,14 @@ async fn build_redemption_inner( } } match found { - Some(id) => id, + Some(id) => (id, tracker_reserve_digest), 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())], + &tracker_reserve_digest_hex + [..16.min(tracker_reserve_digest_hex.len())], payload.issuer_pubkey ), ) @@ -638,7 +646,7 @@ async fn build_redemption_inner( ); } match rx.await { - Ok(Ok(proof)) => { + Ok(Ok((proof, _state))) => { let total_debt = if proof.value.len() == 8 { let mut b = [0u8; 8]; b.copy_from_slice(&proof.value); @@ -683,8 +691,8 @@ async fn build_redemption_inner( "tracker thread unavailable", ); } - let lookup = match rx.await { - Ok(Ok(p)) => p, + let (lookup, lookup_root) = match rx.await { + Ok(Ok(result)) => result, Ok(Err(e)) => { return api_err( StatusCode::INTERNAL_SERVER_ERROR, @@ -725,7 +733,7 @@ async fn build_redemption_inner( "tracker thread unavailable", ); } - let (insert_bytes, new_digest) = match irx.await { + let (insert_bytes, new_digest, insert_current_root) = match irx.await { Ok(Ok(v)) => v, Ok(Err(e)) => { return api_err( @@ -735,6 +743,12 @@ async fn build_redemption_inner( } Err(_) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "tracker unavailable"), }; + if lookup_root != insert_current_root || lookup_root != selected_reserve_digest { + return api_err( + StatusCode::CONFLICT, + "reserve state changed while building proofs; retry from a fresh snapshot", + ); + } ( lookup.proof, insert_bytes, diff --git a/crates/basis_server/src/tracker_box_updater.rs b/crates/basis_server/src/tracker_box_updater.rs index 4ca400d..9b877f4 100644 --- a/crates/basis_server/src/tracker_box_updater.rs +++ b/crates/basis_server/src/tracker_box_updater.rs @@ -42,7 +42,6 @@ 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>>, @@ -56,7 +55,6 @@ 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)), @@ -68,7 +66,6 @@ impl SharedTrackerState { 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)), @@ -78,12 +75,6 @@ impl SharedTrackerState { } } - pub fn set_avl_root_digest(&self, digest: [u8; 33]) { - if let Ok(mut root_lock) = self.avl_root_digest.write() { - *root_lock = digest; - } - } - pub fn set_tracker_pubkey(&self, pubkey: [u8; 33]) { if let Ok(mut pubkey_lock) = self.tracker_pubkey.write() { *pubkey_lock = pubkey; @@ -102,14 +93,6 @@ 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() @@ -123,12 +106,6 @@ impl SharedTrackerState { self.publication_health.is_healthy() } - /// Return the cached root only while the owning manager is healthy. - pub fn publication_digest(&self) -> Option<[u8; 33]> { - self.is_publication_healthy() - .then(|| self.get_avl_root_digest()) - } - pub fn get_tracker_pubkey(&self) -> [u8; 33] { if let Ok(pubkey_lock) = self.tracker_pubkey.read() { *pubkey_lock @@ -394,8 +371,8 @@ pub enum TrackerBoxUpdaterError { InsufficientFeeInputs { available: u64, required: u64 }, #[error("Failed to sign transaction: {0}")] SigningFailed(String), - #[error("Failed to broadcast transaction: {0}")] - BroadcastFailed(String), + #[error("Broadcast outcome is unknown; tracker publication remains fenced: {0}")] + BroadcastOutcomeUnknown(String), } /// Ergo box as returned by the blockchain API @@ -538,20 +515,8 @@ impl TrackerBoxUpdater { } }; - let current_digest = match shared_state.publication_digest() { - Some(digest) => digest, - None => { - error!("Tracker state is quarantined; refusing commitment publication"); - continue; - } - }; 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) => { @@ -563,7 +528,7 @@ impl TrackerBoxUpdater { // Refresh the confirmed box id in shared state from the live node. shared_state.set_tracker_box_id(tracker_box.box_id.clone()); - let mut generation_validated = false; + let mut publication_lease = None; 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 { @@ -579,10 +544,10 @@ impl TrackerBoxUpdater { tracker_box.creation_height as u64, ); - // The first observed root must match the explicitly - // approved bootstrap root for this NFT. Every update is - // blocked until the state manager durably validates or - // anchors that generation. + // The actor validates/reconciles the observed generation + // and then remains fenced until this exact external + // publication attempt is completed or explicitly + // aborted. let tracker_nft_bytes: [u8; 32] = match hex::decode(&tracker_nft_id) .ok() .and_then(|bytes| bytes.try_into().ok()) @@ -594,63 +559,72 @@ impl TrackerBoxUpdater { continue; } }; - let generation_valid = if let Some(ref tx) = cmd_tx { + let lease = if let Some(ref tx) = cmd_tx { let (rtx, rrx) = tokio::sync::oneshot::channel(); if tx - .send(crate::TrackerCommand::ValidateObservedGeneration { + .send(crate::TrackerCommand::BeginPublication { tracker_nft_id: tracker_nft_bytes, observed_root: onchain_digest_arr, + box_id: tracker_box.box_id.clone(), + height: tracker_box.creation_height as u64, response_tx: rtx, }) .await .is_err() { - false + None } else { - matches!(rrx.await, Ok(Ok(()))) + match rrx.await { + Ok(Ok(lease)) => Some(lease), + _ => None, + } } } else { - false + None }; - if !generation_valid { + let lease = match lease { + Some(lease) => lease, + None => { + shared_state.quarantine_publication(); + error!("Tracker actor refused the publication fence"); + continue; + } + }; + if lease.digest == [0u8; 33] { shared_state.quarantine_publication(); - error!("Tracker generation validation failed; refusing commitment publication"); + error!("Tracker actor returned an uninitialized publication digest"); continue; } - generation_validated = true; - - // 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; - } + let current_digest = lease.digest; + publication_lease = Some(lease); if onchain_digest == current_digest.as_slice() { info!("On-chain tracker box already has current AVL root digest"); last_submitted_digest = Some(current_digest); + if !Self::abort_publication(&cmd_tx, lease).await { + shared_state.quarantine_publication(); + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "tracker actor did not release a no-op publication fence" + .to_string(), + )); + } continue; } } } } - if !generation_validated { - shared_state.quarantine_publication(); - error!( + let publication_lease = match publication_lease { + Some(lease) => lease, + None => { + shared_state.quarantine_publication(); + error!( "Tracker box has no valid R5 generation root; refusing commitment publication" ); - continue; - } + continue; + } + }; + let current_digest = publication_lease.digest; // If a previous submission for a different digest is still pending // and never confirmed, skip submitting a new one (the confirmation @@ -658,6 +632,13 @@ impl TrackerBoxUpdater { if let Some(last) = last_submitted_digest { if last == current_digest { info!("AVL root digest unchanged, skipping redundant update"); + if !Self::abort_publication(&cmd_tx, publication_lease).await { + shared_state.quarantine_publication(); + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "tracker actor did not release a redundant publication fence" + .to_string(), + )); + } continue; } } @@ -678,29 +659,83 @@ impl TrackerBoxUpdater { ); 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; + if !Self::complete_publication( + &cmd_tx, + publication_lease, + tx_id.clone(), + submitted_height, + ) + .await + { + shared_state.quarantine_publication(); + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "broadcast succeeded but actor receipt was not durably accepted" + .to_string(), + )); } + shared_state.set_pending(current_digest, tx_id.clone(), submitted_height); + pending_tx = Some((tx_id, current_digest)); } Err(e) => { error!("Failed to submit tracker box update: {}", e); + if matches!(e, TrackerBoxUpdaterError::BroadcastOutcomeUnknown(_)) { + shared_state.quarantine_publication(); + return Err(e); + } + if !Self::abort_publication(&cmd_tx, publication_lease).await { + shared_state.quarantine_publication(); + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "tracker actor did not release a failed publication fence".to_string(), + )); + } } } } } + async fn abort_publication( + cmd_tx: &Option>, + lease: crate::PublicationLease, + ) -> bool { + let Some(tx) = cmd_tx else { + return false; + }; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + if tx + .send(crate::TrackerCommand::AbortPublication { lease, response_tx }) + .await + .is_err() + { + return false; + } + matches!(response_rx.await, Ok(Ok(()))) + } + + async fn complete_publication( + cmd_tx: &Option>, + lease: crate::PublicationLease, + tx_id: String, + submitted_height: u64, + ) -> bool { + let Some(tx) = cmd_tx else { + return false; + }; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + if tx + .send(crate::TrackerCommand::CompletePublication { + lease, + tx_id, + submitted_height, + response_tx, + }) + .await + .is_err() + { + return false; + } + matches!(response_rx.await, Ok(Ok(_))) + } + /// 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( @@ -1037,21 +1072,32 @@ impl TrackerBoxUpdater { let response = request .send() .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!(status = %status, "Transaction broadcast request completed"); + Self::parse_broadcast_response(status, &body_text) + } + + fn parse_broadcast_response( + status: reqwest::StatusCode, + body_text: &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 {}", + 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 @@ -1063,7 +1109,7 @@ impl TrackerBoxUpdater { .or_else(|| body["txId"].as_str()) .map(|s| s.to_string()) .ok_or_else(|| { - TrackerBoxUpdaterError::BroadcastFailed("Missing tx id".to_string()) + TrackerBoxUpdaterError::BroadcastOutcomeUnknown("Missing tx id".to_string()) }), } } @@ -1292,22 +1338,36 @@ fn change_address_to_ergo_tree(address_str: &str) -> Result { // Mock reserve insert proof - let _ = response_tx.send(Ok((vec![1, 2, 3, 4], vec![5, 6, 7, 8]))); + let result = + redemption_manager + .tracker + .reserve_state_digest() + .map(|current_root| { + (vec![1, 2, 3, 4], current_root.clone(), current_root) + }); + let _ = response_tx.send(result); } TrackerCommand::GetNotesByRecipientWithIssuer { recipient_pubkey: _, @@ -174,7 +192,8 @@ mod cors_tests { let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.all_confirmations()); + let _ = + response_tx.send(Ok(redemption_manager.tracker.all_confirmations())); } TrackerCommand::MarkNotesPending { digest, @@ -207,6 +226,9 @@ mod cors_tests { let digest = redemption_manager.tracker.reserve_state_digest(); let _ = response_tx.send(digest); } + TrackerCommand::GetValidatedState { response_tx } => { + let _ = response_tx.send(redemption_manager.tracker.validated_state()); + } TrackerCommand::ReconcileWithConfirmedDigest { digest, box_id, @@ -228,6 +250,15 @@ mod cors_tests { .validate_observed_generation(&tracker_nft_id, observed_root); let _ = response_tx.send(result); } + TrackerCommand::BeginPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); + } + TrackerCommand::CompletePublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); + } + TrackerCommand::AbortPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); + } } } }); diff --git a/crates/basis_server/tests/http_api_integration_tests.rs b/crates/basis_server/tests/http_api_integration_tests.rs index 662a7ad..f3e98bf 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, }; @@ -117,7 +118,10 @@ mod http_api_tests { avl_proof: vec![1, 2, 3, 4], // Mock proof data operations: vec![], }; - let result = Ok(mock_proof); + let result = redemption_manager + .tracker + .validated_state() + .map(|state| (mock_proof, state)); let _ = response_tx.send(result); } TrackerCommand::GetTrackerLookupProof { @@ -131,7 +135,11 @@ mod http_api_tests { value: vec![0u8; 8], proof: vec![1, 2, 3, 4], }; - let _ = response_tx.send(Ok(mock_proof)); + let result = redemption_manager + .tracker + .validated_state() + .map(|state| (mock_proof, state)); + let _ = response_tx.send(result); } TrackerCommand::GetReserveLookupProof { issuer_pubkey: _, @@ -144,7 +152,11 @@ mod http_api_tests { value: vec![0u8; 8], proof: Some(vec![1, 2, 3, 4]), }; - let _ = response_tx.send(Ok(mock_proof)); + let result = redemption_manager + .tracker + .reserve_state_digest() + .map(|root| (mock_proof, root)); + let _ = response_tx.send(result); } TrackerCommand::GetReserveInsertProof { issuer_pubkey: _, @@ -154,7 +166,14 @@ mod http_api_tests { response_tx, } => { // Mock reserve insert proof - let _ = response_tx.send(Ok((vec![1, 2, 3, 4], vec![5, 6, 7, 8]))); + let result = + redemption_manager + .tracker + .reserve_state_digest() + .map(|current_root| { + (vec![1, 2, 3, 4], current_root.clone(), current_root) + }); + let _ = response_tx.send(result); } TrackerCommand::GetNotesByRecipientWithIssuer { recipient_pubkey: _, @@ -174,7 +193,8 @@ mod http_api_tests { let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.all_confirmations()); + let _ = + response_tx.send(Ok(redemption_manager.tracker.all_confirmations())); } TrackerCommand::MarkNotesPending { digest, @@ -218,6 +238,9 @@ mod http_api_tests { let digest = redemption_manager.tracker.reserve_state_digest(); let _ = response_tx.send(digest); } + TrackerCommand::GetValidatedState { response_tx } => { + let _ = response_tx.send(redemption_manager.tracker.validated_state()); + } TrackerCommand::ValidateObservedGeneration { tracker_nft_id, observed_root, @@ -228,6 +251,15 @@ mod http_api_tests { .validate_observed_generation(&tracker_nft_id, observed_root); let _ = response_tx.send(result); } + TrackerCommand::BeginPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); + } + TrackerCommand::CompletePublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); + } + TrackerCommand::AbortPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); + } } } }); @@ -480,9 +512,15 @@ 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); } @@ -490,7 +528,7 @@ mod http_api_tests { 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)); @@ -514,6 +552,21 @@ mod http_api_tests { assert_eq!(data.submitted_height, Some(150)); } + #[tokio::test] + async fn reserve_wallet_proxy_is_a_gone_tombstone() { + let state = create_mock_app_state().await; + let payload = ReserveCreationResponse { + requests: Vec::new(), + fee: 1_000_000, + change_address: "not-forwarded".to_string(), + }; + + let response = + submit_reserve_transaction(axum::extract::State(state), 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/redemption_api_integration_tests.rs b/crates/basis_server/tests/redemption_api_integration_tests.rs index 87157fe..e751612 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -135,7 +135,10 @@ mod redemption_api_tests { avl_proof: vec![1, 2, 3, 4], operations: vec![], }; - let result = Ok(mock_proof); + let result = redemption_manager + .tracker + .validated_state() + .map(|state| (mock_proof, state)); let _ = response_tx.send(result); } TrackerCommand::GetTrackerLookupProof { @@ -148,7 +151,11 @@ mod redemption_api_tests { value: vec![0u8; 8], proof: vec![1, 2, 3, 4], }; - let _ = response_tx.send(Ok(mock_proof)); + let result = redemption_manager + .tracker + .validated_state() + .map(|state| (mock_proof, state)); + let _ = response_tx.send(result); } TrackerCommand::GetReserveLookupProof { issuer_pubkey: _, @@ -160,7 +167,11 @@ mod redemption_api_tests { value: vec![0u8; 8], proof: Some(vec![1, 2, 3, 4]), }; - let _ = response_tx.send(Ok(mock_proof)); + let result = redemption_manager + .tracker + .reserve_state_digest() + .map(|root| (mock_proof, root)); + let _ = response_tx.send(result); } TrackerCommand::GetReserveInsertProof { issuer_pubkey: _, @@ -169,7 +180,14 @@ mod redemption_api_tests { new_already_redeemed: _, response_tx, } => { - let _ = response_tx.send(Ok((vec![1, 2, 3, 4], vec![5, 6, 7, 8]))); + let result = + redemption_manager + .tracker + .reserve_state_digest() + .map(|current_root| { + (vec![1, 2, 3, 4], current_root.clone(), current_root) + }); + let _ = response_tx.send(result); } TrackerCommand::GetNotesByRecipientWithIssuer { recipient_pubkey: _, @@ -188,7 +206,8 @@ mod redemption_api_tests { let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.all_confirmations()); + let _ = + response_tx.send(Ok(redemption_manager.tracker.all_confirmations())); } TrackerCommand::MarkNotesPending { digest, @@ -221,6 +240,9 @@ mod redemption_api_tests { let digest = redemption_manager.tracker.reserve_state_digest(); let _ = response_tx.send(digest); } + TrackerCommand::GetValidatedState { response_tx } => { + let _ = response_tx.send(redemption_manager.tracker.validated_state()); + } TrackerCommand::ReconcileWithConfirmedDigest { digest, box_id, @@ -242,6 +264,15 @@ mod redemption_api_tests { .validate_observed_generation(&tracker_nft_id, observed_root); let _ = response_tx.send(result); } + TrackerCommand::BeginPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); + } + TrackerCommand::CompletePublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); + } + TrackerCommand::AbortPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); + } } } }); diff --git a/crates/basis_server/tests/tracker_box_updater_integration.rs b/crates/basis_server/tests/tracker_box_updater_integration.rs index 1b0cd03..2c5bfa7 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] diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index 87a967a..8c7e351 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -310,6 +310,10 @@ pub enum NoteError { /// 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, UnsupportedOperation, } @@ -844,7 +848,7 @@ impl TrackerStateManager { // 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); + self.recompute_confirmation_status(&key32, note.amount_collected)?; Ok(()) } @@ -858,26 +862,30 @@ 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); - - let confirmed = entry.confirmed_total_debt; - let pending = entry.pending_total_debt; + .get(key) + .cloned() + .unwrap_or_else(NoteConfirmation::local_only); - 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. @@ -962,16 +970,19 @@ impl TrackerStateManager { 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; } } @@ -1002,14 +1013,16 @@ impl TrackerStateManager { .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); + if let Some(mut updated) = self.confirmations.get(&key).cloned() { + updated.confirmed_total_debt = updated.pending_total_debt; + 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; } } @@ -1042,16 +1055,18 @@ 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; } } @@ -1081,21 +1096,24 @@ impl TrackerStateManager { 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; } } @@ -1212,6 +1230,7 @@ impl TrackerStateManager { recipient_pubkey: &PubKey, ) -> Result { self.ensure_healthy()?; + self.validate_complete_snapshot_against_live()?; let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); let key_bytes = key.to_bytes(); @@ -1267,6 +1286,7 @@ impl TrackerStateManager { recipient_pubkey: &PubKey, ) -> Result { self.ensure_healthy()?; + self.validate_complete_snapshot_against_live()?; let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); let key_bytes = key.to_bytes(); @@ -1332,6 +1352,7 @@ impl TrackerStateManager { new_already_redeemed: u64, ) -> Result<(Vec, Vec), NoteError> { self.ensure_healthy()?; + self.validate_complete_snapshot_against_live()?; 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) @@ -1352,14 +1373,10 @@ impl TrackerStateManager { /// 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 { - if let Err(e) = self.ensure_healthy() { - panic!( - "Cannot read reserve state from quarantined tracker: {:?}", - e - ); - } - self.reserve_avl_state.root_digest().to_vec() + pub fn reserve_state_digest(&self) -> Result, NoteError> { + self.ensure_healthy()?; + self.validate_complete_snapshot_against_live()?; + Ok(self.reserve_avl_state.root_digest().to_vec()) } /// Update the already_redeemed amount in the reserve AVL tree. @@ -1398,6 +1415,7 @@ impl TrackerStateManager { recipient_pubkey: &PubKey, ) -> Result { self.ensure_healthy()?; + self.validate_complete_snapshot_against_live()?; let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); let key_bytes = key.to_bytes(); @@ -1529,10 +1547,24 @@ 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(e) = self.ensure_healthy() { - panic!("Cannot publish state from quarantined tracker: {:?}", e); + if let Err(error) = self.validated_state() { + panic!("Cannot expose invalid tracker state: {error:?}"); } &self.current_state } diff --git a/crates/basis_store/src/persistence.rs b/crates/basis_store/src/persistence.rs index 8a321d7..08ae0ee 100644 --- a/crates/basis_store/src/persistence.rs +++ b/crates/basis_store/src/persistence.rs @@ -290,42 +290,27 @@ impl NoteStorage { .get(NOTE_STATE_KEY) .map_err(|e| NoteError::StorageError(format!("Failed to read note state: {}", e)))?; - match (schema, state) { - (Some(schema), Some(_)) => { + // 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)))?; + + 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(), )); } - let state = self.read_state_strict()?; - self.ensure_generation_manifest(generation, &state) + self.read_state_strict()?; + self.validate_generation_manifest(generation, generation_bytes.as_ref()) } - (Some(_), None) => Err(NoteError::StorageError( - "Note schema exists without authoritative state".to_string(), - )), - (None, Some(_)) => { - // Recover an initialization interrupted after the authoritative - // empty state was synced but before the schema marker was synced. - self.read_state_partition_strict()?; - 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!( - "Note schema initialization durability is unknown: {}", - e - )) - })?; - let state = self.read_state_strict()?; - self.ensure_generation_manifest(generation, &state) - } - (None, None) => { + (None, None, None) => { if generation.fresh_generation != FreshGenerationApproval::Approve { return Err(NoteError::GenerationBindingRequired( "A new data directory requires explicit fresh tracker generation approval" @@ -347,6 +332,24 @@ impl NoteStorage { 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| { @@ -355,12 +358,6 @@ impl NoteStorage { e )) })?; - self.keyspace.persist(PersistMode::SyncData).map_err(|e| { - NoteError::StorageOutcomeUnknown(format!( - "Empty note state durability is unknown: {}", - e - )) - })?; self.schema_partition .insert(NOTE_SCHEMA_KEY, NOTE_STATE_MAGIC) .map_err(|e| { @@ -371,68 +368,44 @@ impl NoteStorage { })?; self.keyspace.persist(PersistMode::SyncData).map_err(|e| { NoteError::StorageOutcomeUnknown(format!( - "Note schema initialization durability is unknown: {}", + "Tracker generation initialization durability is unknown: {}", e )) - })?; - self.ensure_generation_manifest(generation, &empty) + }) } + (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 ensure_generation_manifest( + fn validate_generation_manifest( &self, generation: TrackerGenerationConfig, - state: &StoredNoteState, + stored: &[u8], ) -> Result<(), NoteError> { - let stored = self - .schema_partition - .get(NOTE_GENERATION_KEY) - .map_err(|e| NoteError::StorageError(format!("Failed to read generation: {}", e)))?; - - if let Some(bytes) = stored { - let manifest = Self::deserialize_generation(bytes.as_ref())?; - 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(), - )); - } - return Ok(()); - } - - if generation.fresh_generation != FreshGenerationApproval::Approve { - return Err(NoteError::GenerationBindingRequired( - "Tracker generation manifest is missing; explicit fresh-generation approval is required" - .to_string(), - )); - } - if !state.notes.is_empty() { - return Err(NoteError::MigrationRequired( - "A non-empty note snapshot without a generation manifest requires explicit migration" + 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(), )); } - - let manifest = StoredGeneration { - tracker_nft_id: generation.tracker_nft_id, - bootstrap_root: state.avl_root_digest, - anchor_root: None, - }; - 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.keyspace.persist(PersistMode::SyncData).map_err(|e| { - NoteError::StorageOutcomeUnknown(format!( - "Tracker generation binding durability is unknown: {}", - e - )) - }) + Ok(()) } #[cfg(test)] @@ -442,6 +415,27 @@ impl NoteStorage { .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 corrupt_state_for_test(&self) -> Result<(), NoteError> { self.notes_partition @@ -964,13 +958,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(()) + .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. diff --git a/crates/basis_store/src/redemption.rs b/crates/basis_store/src/redemption.rs index 632266b..29f00f0 100644 --- a/crates/basis_store/src/redemption.rs +++ b/crates/basis_store/src/redemption.rs @@ -1028,8 +1028,8 @@ mod tests { ) .expect("reference update"); assert_eq!( - redemption_manager.tracker.reserve_state_digest(), - reference.reserve_state_digest(), + redemption_manager.tracker.reserve_state_digest().unwrap(), + reference.reserve_state_digest().unwrap(), "reserve tree must use pre-refresh timestamp and cumulative amount" ); @@ -1047,8 +1047,8 @@ mod tests { ) .expect("reference update 2"); assert_eq!( - redemption_manager.tracker.reserve_state_digest(), - reference2.reserve_state_digest(), + redemption_manager.tracker.reserve_state_digest().unwrap(), + reference2.reserve_state_digest().unwrap(), "second settlement update must accumulate without rewriting signed fields" ); } @@ -1132,8 +1132,8 @@ mod tests { ) .expect("reference update"); assert_eq!( - redemption_manager.tracker.reserve_state_digest(), - reference.reserve_state_digest(), + redemption_manager.tracker.reserve_state_digest().unwrap(), + reference.reserve_state_digest().unwrap(), "reserve tree must use the explicit on-chain cumulative value" ); } diff --git a/crates/basis_store/src/tests.rs b/crates/basis_store/src/tests.rs index e2b1f2c..25e8e5c 100644 --- a/crates/basis_store/src/tests.rs +++ b/crates/basis_store/src/tests.rs @@ -1399,6 +1399,114 @@ mod confirmation_state_tests { )); } + 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()), + ) + } + + #[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(); @@ -1461,6 +1569,42 @@ mod confirmation_state_tests { assert!(!manager.is_healthy()); } + #[test] + fn proof_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.generate_proof(&issuer, &recipient), + 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.reserve_state_digest(), + 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(); @@ -1529,6 +1673,29 @@ mod confirmation_state_tests { 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(); From b5c790312aebe4ac87171309db483c93ca907318 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:17:33 +0200 Subject: [PATCH 08/41] fix: persist tracker publication receipts across restart --- crates/basis_server/src/api.rs | 5 + crates/basis_server/src/config.rs | 2 + crates/basis_server/src/lib.rs | 45 +-- crates/basis_server/src/main.rs | 125 ++++----- crates/basis_server/src/redemption_build.rs | 13 - .../basis_server/src/tracker_box_updater.rs | 263 +++++++++++++----- crates/basis_server/tests/cors_tests.rs | 51 +--- .../tests/http_api_integration_tests.rs | 56 +--- .../tests/redemption_api_integration_tests.rs | 51 +--- crates/basis_store/src/lib.rs | 162 ++++++++--- crates/basis_store/src/persistence.rs | 169 ++++++++++- crates/basis_store/src/tests.rs | 180 +++++++++++- specs/trees/note_state_snapshot.md | 22 ++ 13 files changed, 769 insertions(+), 375 deletions(-) diff --git a/crates/basis_server/src/api.rs b/crates/basis_server/src/api.rs index 3c7312c..5548b0e 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -327,6 +327,7 @@ pub async fn create_note( NoteError::PublicationLeaseMismatch => { "Tracker publication lease mismatch".to_string() } + NoteError::InvalidTransactionId => "Invalid transaction id".to_string(), NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -471,6 +472,7 @@ pub async fn get_notes_by_issuer( NoteError::PublicationLeaseMismatch => { "Tracker publication lease mismatch".to_string() } + NoteError::InvalidTransactionId => "Invalid transaction id".to_string(), NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -601,6 +603,7 @@ pub async fn get_notes_by_recipient( NoteError::PublicationLeaseMismatch => { "Tracker publication lease mismatch".to_string() } + NoteError::InvalidTransactionId => "Invalid transaction id".to_string(), NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -768,6 +771,7 @@ pub async fn get_note_by_issuer_and_recipient( NoteError::PublicationLeaseMismatch => { "Tracker publication lease mismatch".to_string() } + NoteError::InvalidTransactionId => "Invalid transaction id".to_string(), NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -879,6 +883,7 @@ pub async fn get_all_notes( NoteError::PublicationLeaseMismatch => { "Tracker publication lease mismatch".to_string() } + NoteError::InvalidTransactionId => "Invalid transaction id".to_string(), NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( diff --git a/crates/basis_server/src/config.rs b/crates/basis_server/src/config.rs index 525c484..24c934e 100644 --- a/crates/basis_server/src/config.rs +++ b/crates/basis_server/src/config.rs @@ -392,6 +392,7 @@ mod tests { tracker_nft_id: None, tracker_public_key: None, tracker_secret_key: Some(tracker_sentinel.clone()), + allow_fresh_tracker_generation: false, }, transaction: TransactionConfig { fee: 1_000_000, @@ -422,6 +423,7 @@ mod tests { tracker_nft_id: None, tracker_public_key: None, tracker_secret_key: None, + allow_fresh_tracker_generation: false, }, transaction: TransactionConfig { fee: 1_000_000, diff --git a/crates/basis_server/src/lib.rs b/crates/basis_server/src/lib.rs index 9451c6d..e0c082b 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -154,37 +154,6 @@ pub enum TrackerCommand { >, >, }, - /// Mark all currently-local notes as pending for an in-flight update tx. - MarkNotesPending { - digest: [u8; 33], - 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, - response_tx: tokio::sync::oneshot::Sender>, - }, - /// Revert all pending notes back to local state (update tx dropped/rejected). - RevertPendingNotes { - 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>, - }, - /// Validate or durably anchor the first observed root for the configured - /// tracker NFT before the updater may publish a successor commitment. - ValidateObservedGeneration { - tracker_nft_id: [u8; 32], - observed_root: [u8; 33], - response_tx: tokio::sync::oneshot::Sender>, - }, /// Validate and reconcile an observed tracker generation, then freeze the /// actor until the external publication attempt is resolved. BeginPublication { @@ -194,14 +163,22 @@ pub enum TrackerCommand { height: u64, response_tx: tokio::sync::oneshot::Sender>, }, - /// Bind a submitted transaction to the exact leased digest and release the - /// actor fence. - CompletePublication { + /// 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 the durable attempt after active-chain confirmation and release + /// the actor fence. + ConfirmPublication { + tx_id: String, + 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, diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 97cf0d6..55c6522 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -65,19 +65,11 @@ fn reject_while_publication_is_fenced(command: TrackerCommand) { TrackerCommand::GetAllConfirmations { response_tx } => { let _ = response_tx.send(Err(NoteError::PublicationInProgress)); } - TrackerCommand::MarkNotesPending { response_tx, .. } - | TrackerCommand::ConfirmPendingNotes { response_tx, .. } - | TrackerCommand::RevertPendingNotes { response_tx } - | TrackerCommand::ReconcileWithConfirmedDigest { response_tx, .. } => { - let _ = response_tx.send(Err(NoteError::PublicationInProgress)); - } - TrackerCommand::ValidateObservedGeneration { response_tx, .. } => { - let _ = response_tx.send(Err(NoteError::PublicationInProgress)); - } TrackerCommand::BeginPublication { response_tx, .. } => { let _ = response_tx.send(Err(NoteError::PublicationInProgress)); } - TrackerCommand::CompletePublication { response_tx, .. } => { + TrackerCommand::RecordPublicationAttempt { response_tx, .. } + | TrackerCommand::ConfirmPublication { response_tx, .. } => { let _ = response_tx.send(Err(NoteError::PublicationLeaseMismatch)); } TrackerCommand::AbortPublication { response_tx, .. } => { @@ -360,14 +352,26 @@ async fn main() { return; } }; - let _ = init_tx.send(Ok(initial_root)); + 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 _ = 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 active_publication: Option = None; + let mut active_publication: Option = + initial_pending.as_ref().map(|pending| PublicationLease { + id: 0, + digest: pending.digest(), + }); let mut next_publication_id = 1u64; while let Some(cmd) = rx.blocking_recv() { @@ -375,7 +379,7 @@ async fn main() { if let Some(active_lease) = active_publication { match cmd { - TrackerCommand::CompletePublication { + TrackerCommand::RecordPublicationAttempt { lease, tx_id, submitted_height, @@ -400,14 +404,42 @@ async fn main() { if result.is_err() { shared_state_for_tracker.quarantine_publication(); } - active_publication = None; + let _ = response_tx.send(result); + } + TrackerCommand::ConfirmPublication { + tx_id, + box_id, + height, + response_tx, + } => { + let result = redemption_manager + .tracker + .confirm_pending_publication(&tx_id, &box_id, height); + if result.is_ok() { + active_publication = None; + } else { + shared_state_for_tracker.quarantine_publication(); + } let _ = response_tx.send(result); } TrackerCommand::AbortPublication { lease, response_tx } if lease == active_lease => { - active_publication = None; - let _ = response_tx.send(Ok(())); + let result = + redemption_manager + .tracker + .pending_publication() + .and_then(|pending| { + if pending.is_some() { + Err(basis_store::NoteError::PublicationInProgress) + } else { + Ok(()) + } + }); + if result.is_ok() { + active_publication = None; + } + let _ = response_tx.send(result); } other => reject_while_publication_is_fenced(other), } @@ -580,54 +612,6 @@ async fn main() { .map(|_| redemption_manager.tracker.all_confirmations()); let _ = response_tx.send(result); } - 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::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::ValidateObservedGeneration { - tracker_nft_id, - observed_root, - response_tx, - } => { - let result = redemption_manager - .tracker - .validate_observed_generation(&tracker_nft_id, observed_root); - let _ = response_tx.send(result); - } TrackerCommand::BeginPublication { tracker_nft_id, observed_root, @@ -669,7 +653,8 @@ async fn main() { } } } - TrackerCommand::CompletePublication { response_tx, .. } => { + TrackerCommand::RecordPublicationAttempt { response_tx, .. } + | TrackerCommand::ConfirmPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::PublicationLeaseMismatch)); } TrackerCommand::AbortPublication { response_tx, .. } => { @@ -680,7 +665,15 @@ async fn main() { }); match init_rx.await { - Ok(Ok(_)) => {} + Ok(Ok((_root, pending))) => { + if let Some(pending) = pending { + shared_tracker_state_for_updater.set_pending( + pending.digest(), + pending.tx_id().to_string(), + pending.submitted_height(), + ); + } + } Ok(Err(error)) => { shared_tracker_state_for_updater.quarantine_publication(); tracing::error!(error, "Tracker state initialization failed closed"); diff --git a/crates/basis_server/src/redemption_build.rs b/crates/basis_server/src/redemption_build.rs index 126b31b..e297696 100644 --- a/crates/basis_server/src/redemption_build.rs +++ b/crates/basis_server/src/redemption_build.rs @@ -1398,19 +1398,6 @@ mod tests { assert!(error.contains("assets mismatch")); } - #[test] - fn submit_request_rejects_unverified_accounting_metadata() { - let payload = serde_json::json!({ - "signed_tx": {"inputs": [], "dataInputs": [], "outputs": []}, - "issuer_pubkey": "02".repeat(33), - "recipient_pubkey": "03".repeat(33), - "redeemed_amount": 1, - "new_already_redeemed": 1 - }); - - assert!(serde_json::from_value::(payload).is_err()); - } - fn r5_box(r5: Option<&str>) -> NodeBox { let mut registers = std::collections::HashMap::new(); if let Some(v) = r5 { diff --git a/crates/basis_server/src/tracker_box_updater.rs b/crates/basis_server/src/tracker_box_updater.rs index 9b877f4..f9658a2 100644 --- a/crates/basis_server/src/tracker_box_updater.rs +++ b/crates/basis_server/src/tracker_box_updater.rs @@ -4,6 +4,7 @@ //! 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 ergo_lib::chain::transaction::Transaction; use std::sync::{Arc, RwLock}; use tokio::time::{interval, Duration}; use tracing::{error, info, warn}; @@ -428,6 +429,11 @@ fn vlq_encode(mut value: usize) -> Vec { /// Tracker box updater service pub struct TrackerBoxUpdater; +struct PreparedTrackerUpdate { + signed_tx: serde_json::Value, + tx_id: String, +} + impl TrackerBoxUpdater { /// Start the tracker box updater service as an async background task pub async fn start( @@ -439,7 +445,17 @@ impl TrackerBoxUpdater { 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; + let pending = shared_state.get_pending(); + let mut pending_tx = match (pending.tx_id, pending.digest, pending.submitted_height) { + (Some(tx_id), Some(digest), Some(_)) => Some((tx_id, digest)), + (None, None, None) => None, + _ => { + shared_state.quarantine_publication(); + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "incomplete durable publication receipt at updater startup".to_string(), + )); + } + }; info!( "Tracker box updater started with {}s interval", @@ -467,28 +483,28 @@ impl TrackerBoxUpdater { 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); // Look up the confirming box to record its height/id. let (box_id, height) = Self::fetch_tracker_box_summary(&config, tx_id) .await .unwrap_or_else(|_| (tx_id.clone(), 0)); - - shared_state.set_confirmed(expected_digest, box_id.clone(), height); + if !Self::confirm_publication( + &cmd_tx, + tx_id.clone(), + box_id.clone(), + height, + ) + .await + { + shared_state.quarantine_publication(); + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "confirmed publication was not durably reconciled".to_string(), + )); + } + last_submitted_digest = Some(expected_digest); + shared_state.set_confirmed(expected_digest, box_id, 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; - } } Ok(false) => { info!( @@ -643,7 +659,7 @@ impl TrackerBoxUpdater { } } - match Self::submit_tracker_update( + let prepared = match Self::prepare_tracker_update( &tracker_nft_id, &config, &tracker_box, @@ -652,42 +668,66 @@ 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; - if !Self::complete_publication( - &cmd_tx, - publication_lease, - tx_id.clone(), - submitted_height, - ) - .await - { - shared_state.quarantine_publication(); - return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( - "broadcast succeeded but actor receipt was not durably accepted" - .to_string(), - )); - } - shared_state.set_pending(current_digest, tx_id.clone(), submitted_height); - pending_tx = Some((tx_id, current_digest)); - } + Ok(prepared) => prepared, Err(e) => { - error!("Failed to submit tracker box update: {}", e); - if matches!(e, TrackerBoxUpdaterError::BroadcastOutcomeUnknown(_)) { - shared_state.quarantine_publication(); - return 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 a failed publication fence".to_string(), + "tracker actor did not release an unbroadcast publication fence" + .to_string(), )); } + continue; + } + }; + + let submitted_height = Self::get_node_height(&config) + .await + .map(|height| height as u64) + .unwrap_or(tracker_box.creation_height as u64); + 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(), + )); + } + + shared_state.set_pending(current_digest, prepared.tx_id.clone(), submitted_height); + pending_tx = Some((prepared.tx_id.clone(), current_digest)); + + match Self::broadcast_transaction( + &config, + &prepared.signed_tx, + &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); } } } @@ -711,7 +751,7 @@ impl TrackerBoxUpdater { matches!(response_rx.await, Ok(Ok(()))) } - async fn complete_publication( + async fn record_publication_attempt( cmd_tx: &Option>, lease: crate::PublicationLease, tx_id: String, @@ -722,7 +762,7 @@ impl TrackerBoxUpdater { }; let (response_tx, response_rx) = tokio::sync::oneshot::channel(); if tx - .send(crate::TrackerCommand::CompletePublication { + .send(crate::TrackerCommand::RecordPublicationAttempt { lease, tx_id, submitted_height, @@ -736,6 +776,31 @@ impl TrackerBoxUpdater { matches!(response_rx.await, Ok(Ok(_))) } + async fn confirm_publication( + cmd_tx: &Option>, + tx_id: String, + box_id: String, + height: u64, + ) -> bool { + let Some(tx) = cmd_tx else { + return false; + }; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + if tx + .send(crate::TrackerCommand::ConfirmPublication { + tx_id, + box_id, + height, + response_tx, + }) + .await + .is_err() + { + return false; + } + matches!(response_rx.await, Ok(Ok(_))) + } + /// 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( @@ -1058,6 +1123,7 @@ impl TrackerBoxUpdater { async fn broadcast_transaction( config: &TrackerBoxUpdateConfig, signed_tx: &serde_json::Value, + expected_tx_id: &str, ) -> Result { info!("Broadcasting signed tracker-box update transaction"); @@ -1078,12 +1144,13 @@ impl TrackerBoxUpdater { let body_text = response.text().await.unwrap_or_default(); info!(status = %status, "Transaction broadcast request completed"); - Self::parse_broadcast_response(status, &body_text) + 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 @@ -1100,28 +1167,62 @@ impl TrackerBoxUpdater { 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::BroadcastOutcomeUnknown("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()) + } + + fn signed_transaction_id( + signed_tx: &serde_json::Value, + ) -> Result { + 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(), + )); + } + Ok(tx_id) } /// Submit a tracker box update transaction via /wallet/transaction/sign - async fn submit_tracker_update( + async fn prepare_tracker_update( tracker_nft_id: &str, config: &TrackerBoxUpdateConfig, tracker_box: &ErgoBoxApi, tracker_pubkey: &[u8; 33], avl_root_digest: &[u8; 33], - ) -> Result { + ) -> Result { let mut r4_bytes = vec![0x07u8]; r4_bytes.extend_from_slice(tracker_pubkey); let r4_value = hex::encode(&r4_bytes); @@ -1255,9 +1356,8 @@ impl TrackerBoxUpdater { }); let signed_tx = Self::sign_transaction(config, unsigned_tx).await?; - let tx_id = Self::broadcast_transaction(config, &signed_tx).await?; - - Ok(tx_id) + let tx_id = Self::signed_transaction_id(&signed_tx)?; + Ok(PreparedTrackerUpdate { signed_tx, tx_id }) } /// Check if a transaction has been confirmed on-chain by querying the blockchain API @@ -1354,10 +1454,12 @@ mod publication_health_tests { #[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" + "node failed", + &expected, ), Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown(_)) )); @@ -1365,9 +1467,40 @@ mod publication_health_tests { #[test] fn success_without_transaction_id_has_unknown_outcome() { + let expected = "11".repeat(32); assert!(matches!( - TrackerBoxUpdater::parse_broadcast_response(reqwest::StatusCode::OK, "{}"), + 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(_)) + )); + } + } + + #[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 + ); + } } diff --git a/crates/basis_server/tests/cors_tests.rs b/crates/basis_server/tests/cors_tests.rs index 7f4bc30..31af94a 100644 --- a/crates/basis_server/tests/cors_tests.rs +++ b/crates/basis_server/tests/cors_tests.rs @@ -195,33 +195,6 @@ mod cors_tests { let _ = response_tx.send(Ok(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); @@ -229,31 +202,11 @@ mod cors_tests { TrackerCommand::GetValidatedState { response_tx } => { let _ = response_tx.send(redemption_manager.tracker.validated_state()); } - 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::ValidateObservedGeneration { - tracker_nft_id, - observed_root, - response_tx, - } => { - let result = redemption_manager - .tracker - .validate_observed_generation(&tracker_nft_id, observed_root); - let _ = response_tx.send(result); - } TrackerCommand::BeginPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } - TrackerCommand::CompletePublication { response_tx, .. } => { + TrackerCommand::RecordPublicationAttempt { response_tx, .. } + | TrackerCommand::ConfirmPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } TrackerCommand::AbortPublication { response_tx, .. } => { diff --git a/crates/basis_server/tests/http_api_integration_tests.rs b/crates/basis_server/tests/http_api_integration_tests.rs index f3e98bf..75d2840 100644 --- a/crates/basis_server/tests/http_api_integration_tests.rs +++ b/crates/basis_server/tests/http_api_integration_tests.rs @@ -196,44 +196,6 @@ mod http_api_tests { let _ = response_tx.send(Ok(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::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); @@ -241,20 +203,11 @@ mod http_api_tests { TrackerCommand::GetValidatedState { response_tx } => { let _ = response_tx.send(redemption_manager.tracker.validated_state()); } - TrackerCommand::ValidateObservedGeneration { - tracker_nft_id, - observed_root, - response_tx, - } => { - let result = redemption_manager - .tracker - .validate_observed_generation(&tracker_nft_id, observed_root); - let _ = response_tx.send(result); - } TrackerCommand::BeginPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } - TrackerCommand::CompletePublication { response_tx, .. } => { + TrackerCommand::RecordPublicationAttempt { response_tx, .. } + | TrackerCommand::ConfirmPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } TrackerCommand::AbortPublication { response_tx, .. } => { @@ -554,16 +507,13 @@ mod http_api_tests { #[tokio::test] async fn reserve_wallet_proxy_is_a_gone_tombstone() { - let state = create_mock_app_state().await; let payload = ReserveCreationResponse { requests: Vec::new(), fee: 1_000_000, change_address: "not-forwarded".to_string(), }; - let response = - submit_reserve_transaction(axum::extract::State(state), axum::extract::Json(payload)) - .await; + let response = submit_reserve_transaction(axum::extract::Json(payload)).await; assert_eq!(response.0, StatusCode::GONE); } diff --git a/crates/basis_server/tests/redemption_api_integration_tests.rs b/crates/basis_server/tests/redemption_api_integration_tests.rs index e751612..939402a 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -209,33 +209,6 @@ mod redemption_api_tests { let _ = response_tx.send(Ok(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); @@ -243,31 +216,11 @@ mod redemption_api_tests { TrackerCommand::GetValidatedState { response_tx } => { let _ = response_tx.send(redemption_manager.tracker.validated_state()); } - 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::ValidateObservedGeneration { - tracker_nft_id, - observed_root, - response_tx, - } => { - let result = redemption_manager - .tracker - .validate_observed_generation(&tracker_nft_id, observed_root); - let _ = response_tx.send(result); - } TrackerCommand::BeginPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } - TrackerCommand::CompletePublication { response_tx, .. } => { + TrackerCommand::RecordPublicationAttempt { response_tx, .. } + | TrackerCommand::ConfirmPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } TrackerCommand::AbortPublication { response_tx, .. } => { diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index 8c7e351..cfc909d 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -166,6 +166,29 @@ impl Default for NoteConfirmation { /// 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 { @@ -314,6 +337,8 @@ pub enum NoteError { 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, } @@ -890,14 +915,15 @@ impl TrackerStateManager { /// 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). + /// 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()); @@ -906,14 +932,21 @@ impl TrackerStateManager { 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 { + 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 if Some(local_value) == record.confirmed_total_debt { NoteConfirmationStatus::Confirmed } else { + record.pending_total_debt = None; + record.pending_tx_id = None; NoteConfirmationStatus::LocalOnly }; @@ -963,8 +996,18 @@ impl TrackerStateManager { submitted_height: u64, ) -> Result { self.ensure_healthy()?; - let notes = self.validate_complete_snapshot_against_live()?.notes; - let _ = (digest, submitted_height); + 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 { @@ -996,36 +1039,78 @@ 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 { + #[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. + pub fn confirm_pending_publication( + &mut self, + tx_id: &str, + box_id: &str, + height: u64, + ) -> Result { self.ensure_healthy()?; - self.validate_complete_snapshot_against_live()?; - let keys: Vec = self.confirmations.keys().copied().collect(); + let snapshot = self.validate_complete_snapshot_against_live()?; + let pending = self + .quarantine_on_storage_failure(self.storage.pending_publication())? + .ok_or(NoteError::PublicationLeaseMismatch)?; + if !pending.tx_id.eq_ignore_ascii_case(tx_id) { + return Err(NoteError::PublicationLeaseMismatch); + } + if pending.digest != snapshot.avl_root_digest { + return Err(NoteError::PublicationLeaseMismatch); + } 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(mut updated) = self.confirmations.get(&key).cloned() { - updated.confirmed_total_debt = updated.pending_total_debt; - 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; - } - } + .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_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.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!( @@ -1034,6 +1119,8 @@ impl TrackerStateManager { box_id, height ); + let clear_result = self.storage.clear_pending_publication(tx_id); + self.quarantine_on_storage_failure(clear_result)?; Ok(count) } @@ -1041,7 +1128,8 @@ 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 { + #[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; @@ -1073,6 +1161,12 @@ impl TrackerStateManager { } 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) } diff --git a/crates/basis_store/src/persistence.rs b/crates/basis_store/src/persistence.rs index 08ae0ee..3601e5c 100644 --- a/crates/basis_store/src/persistence.rs +++ b/crates/basis_store/src/persistence.rs @@ -2,7 +2,8 @@ use crate::{ blake2b256_hash, reserve_tracker::ExtendedReserveInfo, FreshGenerationApproval, IouNote, - NoteConfirmation, NoteError, NoteKey, PubKey, TrackerBoxInfo, TrackerGenerationConfig, + NoteConfirmation, NoteError, NoteKey, PendingTrackerPublication, PubKey, TrackerBoxInfo, + TrackerGenerationConfig, }; use fjall::{Config, Keyspace, PartitionCreateOptions, PersistMode}; use fs2::FileExt; @@ -19,14 +20,19 @@ 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 GENERATION_MAGIC: &[u8; 4] = b"BNG1"; +const PENDING_PUBLICATION_MAGIC: &[u8; 4] = b"BPA1"; 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 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; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct StoredNoteState { @@ -298,6 +304,18 @@ impl NoteStorage { .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())?; + } match (schema, state, stored_generation) { (Some(schema), Some(_), Some(generation_bytes)) => { @@ -311,6 +329,12 @@ impl NoteStorage { 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" @@ -436,6 +460,19 @@ impl NoteStorage { .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 @@ -775,6 +812,136 @@ impl NoteStorage { }) } + 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) + } + + 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, + }) + } + + 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() + } + + 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 + )) + })?; + 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 + )) + }) + } + /// Store an IOU note with its issuer public key pub(crate) fn store_note( &self, diff --git a/crates/basis_store/src/tests.rs b/crates/basis_store/src/tests.rs index 25e8e5c..547625e 100644 --- a/crates/basis_store/src/tests.rs +++ b/crates/basis_store/src/tests.rs @@ -567,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] @@ -586,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(); @@ -608,7 +701,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(); @@ -660,7 +755,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); @@ -669,14 +764,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().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] @@ -754,7 +851,9 @@ mod confirmation_state_tests { .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) .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(); assert_eq!( @@ -776,7 +875,9 @@ mod confirmation_state_tests { .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) .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(); assert!(matches!( @@ -1425,6 +1526,63 @@ mod confirmation_state_tests { ) } + 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()) + } + + #[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(); diff --git a/specs/trees/note_state_snapshot.md b/specs/trees/note_state_snapshot.md index d59d6d3..06c1f70 100644 --- a/specs/trees/note_state_snapshot.md +++ b/specs/trees/note_state_snapshot.md @@ -49,6 +49,28 @@ 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 From 6db2ba156332258d0dacd9c224a1213c53e53df2 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:48:05 +0200 Subject: [PATCH 09/41] fix: harden tracker publication recovery --- crates/basis_server/src/main.rs | 326 +++++++++++++----- .../basis_server/src/tracker_box_updater.rs | 136 +++++++- crates/basis_store/src/tests.rs | 81 +++++ 3 files changed, 453 insertions(+), 90 deletions(-) diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 55c6522..51578f9 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -78,6 +78,103 @@ fn reject_while_publication_is_fenced(command: TrackerCommand) { } } +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, + redemption_manager: &mut basis_store::RedemptionManager, + shared_state: &SharedTrackerState, +) -> Option { + match command { + TrackerCommand::RecordPublicationAttempt { + lease, + tx_id, + submitted_height, + response_tx, + } if lease == active_lease => { + let result = redemption_manager + .tracker + .validated_state() + .and_then(|state| { + if state.avl_root_digest != lease.digest { + return Err(basis_store::NoteError::PublicationLeaseMismatch); + } + redemption_manager.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 { + tx_id, + box_id, + height, + response_tx, + } => { + let result = redemption_manager + .tracker + .confirm_pending_publication(&tx_id, &box_id, height); + 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::AbortPublication { lease, response_tx } if lease == active_lease => { + let result = redemption_manager + .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..."); @@ -360,6 +457,8 @@ async fn main() { return; } }; + 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: {}", @@ -367,82 +466,18 @@ async fn main() { ); let mut redemption_manager = RedemptionManager::new(tracker); - let mut active_publication: Option = - initial_pending.as_ref().map(|pending| PublicationLease { - id: 0, - digest: pending.digest(), - }); let mut next_publication_id = 1u64; while let Some(cmd) = rx.blocking_recv() { tracing::debug!("Tracker thread received command: {:?}", cmd); if let Some(active_lease) = active_publication { - match cmd { - TrackerCommand::RecordPublicationAttempt { - lease, - tx_id, - submitted_height, - response_tx, - } if lease == active_lease => { - let result = - redemption_manager - .tracker - .validated_state() - .and_then(|state| { - if state.avl_root_digest != lease.digest { - return Err( - basis_store::NoteError::PublicationLeaseMismatch, - ); - } - redemption_manager.tracker.mark_notes_pending( - lease.digest, - &tx_id, - submitted_height, - ) - }); - if result.is_err() { - shared_state_for_tracker.quarantine_publication(); - } - let _ = response_tx.send(result); - } - TrackerCommand::ConfirmPublication { - tx_id, - box_id, - height, - response_tx, - } => { - let result = redemption_manager - .tracker - .confirm_pending_publication(&tx_id, &box_id, height); - if result.is_ok() { - active_publication = None; - } else { - shared_state_for_tracker.quarantine_publication(); - } - let _ = response_tx.send(result); - } - TrackerCommand::AbortPublication { lease, response_tx } - if lease == active_lease => - { - let result = - redemption_manager - .tracker - .pending_publication() - .and_then(|pending| { - if pending.is_some() { - Err(basis_store::NoteError::PublicationInProgress) - } else { - Ok(()) - } - }); - if result.is_ok() { - active_publication = None; - } - let _ = response_tx.send(result); - } - other => reject_while_publication_is_fenced(other), - } + active_publication = handle_command_while_publication_is_fenced( + active_lease, + cmd, + &mut redemption_manager, + &shared_state_for_tracker, + ); continue; } @@ -665,15 +700,7 @@ async fn main() { }); match init_rx.await { - Ok(Ok((_root, pending))) => { - if let Some(pending) = pending { - shared_tracker_state_for_updater.set_pending( - pending.digest(), - pending.tx_id().to_string(), - pending.submitted_height(), - ); - } - } + Ok(Ok((_root, _pending))) => {} Ok(Err(error)) => { shared_tracker_state_for_updater.quarantine_publication(); tracing::error!(error, "Tracker state initialization failed closed"); @@ -1051,6 +1078,21 @@ async fn main() { 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(); @@ -1106,6 +1148,132 @@ mod publication_fence_tests { 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)); + + let mut redemption_manager = basis_store::RedemptionManager::new(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 redemption_manager, + &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 redemption_manager, + &restarted_shared, + ); + assert!(matches!( + add_rx.await, + Ok(Err(basis_store::NoteError::PublicationInProgress)) + )); + + let (wrong_tx, wrong_rx) = tokio::sync::oneshot::channel(); + active = handle_command_while_publication_is_fenced( + active.unwrap(), + TrackerCommand::ConfirmPublication { + tx_id: "22".repeat(32), + box_id: "wrong-box".to_string(), + height: 200, + response_tx: wrong_tx, + }, + &mut redemption_manager, + &restarted_shared, + ); + assert!(matches!( + wrong_rx.await, + Ok(Err(basis_store::NoteError::PublicationLeaseMismatch)) + )); + assert!(active.is_some()); + assert!(restarted_shared.is_publication_healthy()); + assert_eq!( + redemption_manager + .tracker + .pending_publication() + .unwrap() + .unwrap() + .tx_id(), + tx_id + ); + + let (confirm_tx, confirm_rx) = tokio::sync::oneshot::channel(); + active = handle_command_while_publication_is_fenced( + active.unwrap(), + TrackerCommand::ConfirmPublication { + tx_id: tx_id.clone(), + box_id: "confirmed-box".to_string(), + height: 201, + response_tx: confirm_tx, + }, + &mut redemption_manager, + &restarted_shared, + ); + assert!(matches!(confirm_rx.await, Ok(Ok(_)))); + assert!(active.is_none()); + assert!(redemption_manager + .tracker + .pending_publication() + .unwrap() + .is_none()); + + // The updater clears its shared pending cache only after the actor has + // durably accepted the exact confirmation. + restarted_shared.clear_pending(); + let updater_pending = restarted_shared.get_pending(); + assert!(updater_pending.tx_id.is_none()); + assert!(updater_pending.digest.is_none()); + assert!(updater_pending.submitted_height.is_none()); + } } /// Background task that continuously scans the blockchain for reserve events diff --git a/crates/basis_server/src/tracker_box_updater.rs b/crates/basis_server/src/tracker_box_updater.rs index f9658a2..9353fcf 100644 --- a/crates/basis_server/src/tracker_box_updater.rs +++ b/crates/basis_server/src/tracker_box_updater.rs @@ -435,6 +435,22 @@ struct PreparedTrackerUpdate { } impl TrackerBoxUpdater { + pub(crate) 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(), + )) + } + } + } + /// Start the tracker box updater service as an async background task pub async fn start( config: TrackerBoxUpdateConfig, @@ -445,17 +461,7 @@ impl TrackerBoxUpdater { let mut ticker = interval(Duration::from_secs(config.update_interval_seconds)); let mut last_submitted_digest: Option<[u8; 33]> = None; - let pending = shared_state.get_pending(); - let mut pending_tx = match (pending.tx_id, pending.digest, pending.submitted_height) { - (Some(tx_id), Some(digest), Some(_)) => Some((tx_id, digest)), - (None, None, None) => None, - _ => { - shared_state.quarantine_publication(); - return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( - "incomplete durable publication receipt at updater startup".to_string(), - )); - } - }; + let mut pending_tx = Self::restored_pending_transaction(&shared_state)?; info!( "Tracker box updater started with {}s interval", @@ -1195,6 +1201,23 @@ impl TrackerBoxUpdater { 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!( @@ -1212,6 +1235,11 @@ impl TrackerBoxUpdater { "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) } @@ -1440,6 +1468,34 @@ fn change_address_to_ergo_tree(address_str: &str) -> Result 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(); From e1f1bc7b563b5ea22ba6bf18d15fb20301fcb814 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:16:56 +0200 Subject: [PATCH 10/41] test: prove durable publication restart wiring --- crates/basis_server/src/main.rs | 12 ++++++++++++ crates/basis_server/src/tracker_box_updater.rs | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 51578f9..79b9437 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -1185,6 +1185,10 @@ mod publication_fence_tests { 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 redemption_manager = basis_store::RedemptionManager::new(reopened); @@ -1245,6 +1249,10 @@ mod publication_fence_tests { .tx_id(), tx_id ); + assert_eq!( + TrackerBoxUpdater::restored_pending_transaction(&restarted_shared).unwrap(), + Some((tx_id.clone(), digest)) + ); let (confirm_tx, confirm_rx) = tokio::sync::oneshot::channel(); active = handle_command_while_publication_is_fenced( @@ -1273,6 +1281,10 @@ mod publication_fence_tests { assert!(updater_pending.tx_id.is_none()); assert!(updater_pending.digest.is_none()); assert!(updater_pending.submitted_height.is_none()); + assert_eq!( + TrackerBoxUpdater::restored_pending_transaction(&restarted_shared).unwrap(), + None + ); } } diff --git a/crates/basis_server/src/tracker_box_updater.rs b/crates/basis_server/src/tracker_box_updater.rs index 9353fcf..2738cd9 100644 --- a/crates/basis_server/src/tracker_box_updater.rs +++ b/crates/basis_server/src/tracker_box_updater.rs @@ -435,7 +435,8 @@ struct PreparedTrackerUpdate { } impl TrackerBoxUpdater { - pub(crate) fn restored_pending_transaction( + #[doc(hidden)] + pub fn restored_pending_transaction( shared_state: &SharedTrackerState, ) -> Result, TrackerBoxUpdaterError> { let pending = shared_state.get_pending(); From fb31aba12b6b4a39403b5f7cc839257cb2bd7403 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:36:08 +0200 Subject: [PATCH 11/41] feat: pin inert Basis v2 runtime identity --- Cargo.lock | 1 + config/basis.toml.example | 5 +- crates/basis_core/src/basis_v2.rs | 446 ++++++++++++++++++ crates/basis_core/src/lib.rs | 1 + crates/basis_server/src/api.rs | 4 +- crates/basis_server/src/config.rs | 69 ++- .../basis_server/src/create_reserve_tests.rs | 40 +- crates/basis_server/src/main.rs | 11 +- crates/basis_server/src/redemption_build.rs | 2 +- crates/basis_store/Cargo.toml | 1 + .../basis_store/contracts/basis-token-v2.p2s | 1 + .../contracts/basis-v2-provenance.json | 26 + crates/basis_store/contracts/basis-v2.p2s | 1 + crates/basis_store/src/contract_compiler.rs | 125 +++++ docs/BUILD_AND_CREATE_RESERVE.md | 9 +- docs/BUILD_INSTALL.md | 5 +- docs/CONFIGURATION.md | 6 +- specs/basis_v2_runtime.md | 95 ++++ specs/security_boundary_remediation.md | 9 +- specs/server/basis_server_spec.md | 14 +- specs/server/redemption_state_spec.md | 14 +- 21 files changed, 822 insertions(+), 63 deletions(-) create mode 100644 crates/basis_core/src/basis_v2.rs create mode 100644 crates/basis_store/contracts/basis-token-v2.p2s create mode 100644 crates/basis_store/contracts/basis-v2-provenance.json create mode 100644 crates/basis_store/contracts/basis-v2.p2s create mode 100644 specs/basis_v2_runtime.md diff --git a/Cargo.lock b/Cargo.lock index 237d8bf..35f8b1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,6 +417,7 @@ dependencies = [ "secp256k1", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "tokio", diff --git a/config/basis.toml.example b/config/basis.toml.example index b26092e..7416629 100644 --- a/config/basis.toml.example +++ b/config/basis.toml.example @@ -8,7 +8,10 @@ data_dir = "data" database_url = "sqlite:data/basis.db" [ergo] -basis_reserve_contract_p2s = "3PQnJ92Krn6NeM1GdMSmNayw34Nuud7UKMoKSTRUTucsNybh99K1HEfjZqyvP7cPag1yBkDv3ruMAgb2NsVKq3tAygjHz7mKDzHK6CJGhD3WfNViD7DoViqbgsXrzvs6Kt8Wyzb48uGqJAFQFWes6ZPKELqUZowy8xtVCS5w1VwnyaeRiWpEyUVGaEHw3qWo5DcVxzmMAP8XXhVTw1rYYrUxsyGPNaBxQkkkTVD9L3bmw77EfeAJgJ1hLxghykNofHscHtMtES4v5FSfqke3Huun81S7gNoraEnsR6Dy6YnQgrBswwCZhyGc89YeNFQn1TCFh5Hct3nKGrd1bV5zoCw67Q9fKtoaCtvcPQ2GDWycGKNRNgyAnPEa8WbHbTEVcjAN25aBwhnY5LFGqYxnUAjhpfkTPJ4FJWRijSqMESzpyrmhTLZdivmn4YSwcchVZr7bHGbfncEDwqPKefdoxNnVPxuVdmeqQXL3aDL7TaqWgExzz1UPXHw3UiKYTUkNgQKCN4WV3LHqc9PecoisL77ydVbSCxPapaX2zTf26F8bGK3hsTVBZnMkt93SJP5GmPgZU5FT9NkFh4okjXK9ce2wmA4MV93ySyYnUKGwTRFJWwE7G1MYqBqTY3ESkn8PJHqVuL4cgtuV2GEPagKt19befRAuUV3FaLGVPJMzpKdANd7hKGZRcy3DnPfT1Q9dyFD4VpdBgFRXJWaaDqYjL7ni4nJcKKam9P395wRRnjGWhTV4hv3KoxC8Xk2CZAUjhkTzvuNHxQrLsWjyrKWJqZgs2uZxoAEHEobDegYWiTcnFCPU9EeJxZLSjysDFninqpQvA66Yt1SvJnSZm49RKsaoR98UJVScdiQfNZE76zTYBioXGatdRz7QVkXDzDPjPMu9Hhepc2XbHqo3ia8tszHptbnSzm2R3PC7iu2Tnhu3QT" +# The historical reserve identity is the temporary compatibility default. All +# reserve construction is disabled; this is not a legacy-safety endorsement. +# The exact audited Basis v2 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" diff --git a/crates/basis_core/src/basis_v2.rs b/crates/basis_core/src/basis_v2.rs new file mode 100644 index 0000000..3ad81ed --- /dev/null +++ b/crates/basis_core/src/basis_v2.rs @@ -0,0 +1,446 @@ +//! 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 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, +} + +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. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ClaimDomainV2 { + pub reserve_nft_id: [u8; 32], + pub tracker_nft_id: [u8; 32], + pub owner_pubkey: PubKey, + pub receiver_pubkey: PubKey, + pub 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, + }) + } + + /// 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. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimV2 { + pub domain: ClaimDomainV2, + pub total_debt: u64, + pub timestamp: u64, + pub signature: Signature, +} + +impl ClaimV2 { + pub fn sign( + domain: ClaimDomainV2, + total_debt: u64, + timestamp: u64, + owner_secret: &[u8; 32], + ) -> Result { + let message = domain.signing_message(total_debt, timestamp)?; + let signature = schnorr_sign(&message, owner_secret, &domain.owner_pubkey)?; + Ok(Self { + domain, + total_debt, + timestamp, + 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()?; + SchnorrVerifier + .verify_signature(&self.signature, &message, &self.domain.owner_pubkey) + .map_err(BasisV2Error::from) + } +} + +/// Fixed 24-byte value committed by reserve R5 in ABI v2. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RedeemedStateV2 { + pub timestamp: u64, + pub total_debt: u64, + pub 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 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::*; + use secp256k1::{PublicKey, Secp256k1, SecretKey}; + + 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 erg_domain() -> ClaimDomainV2 { + ClaimDomainV2::erg([1u8; 32], [2u8; 32], key(1).1, key(2).1).unwrap() + } + + #[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); + } + } + + #[test] + fn signed_claim_is_bound_to_its_exact_domain() { + let (owner_secret, owner) = key(1); + let domain = ClaimDomainV2::erg([1u8; 32], [2u8; 32], owner, key(2).1).unwrap(); + let claim = ClaimV2::sign(domain, 100, 10, &owner_secret).unwrap(); + claim.verify().unwrap(); + + let mut wrong = claim.clone(); + wrong.domain.reserve_nft_id = [9u8; 32]; + assert_eq!(wrong.verify(), Err(BasisV2Error::InvalidSignature)); + } + + #[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) + ); + } + + #[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_server/src/api.rs b/crates/basis_server/src/api.rs index 5548b0e..f2b2ca4 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -2950,7 +2950,7 @@ pub async fn create_reserve_payload( // second file/env view here could validate one P2S and build against another. let config = state.config.clone(); - if let Err(e) = config.reject_known_legacy_reserve_contract() { + if let Err(e) = config.reject_unsupported_reserve_builder() { return ( StatusCode::SERVICE_UNAVAILABLE, Json(crate::models::error_response(e)), @@ -3077,7 +3077,7 @@ pub async fn get_basis_reserve_contract_p2s( let config = state.config.clone(); - if let Err(e) = config.reject_known_legacy_reserve_contract() { + if let Err(e) = config.reject_unsupported_reserve_builder() { return ( StatusCode::SERVICE_UNAVAILABLE, Json(crate::models::error_response(e)), diff --git a/crates/basis_server/src/config.rs b/crates/basis_server/src/config.rs index 24c934e..913f64a 100644 --- a/crates/basis_server/src/config.rs +++ b/crates/basis_server/src/config.rs @@ -107,6 +107,8 @@ impl AppConfig { /// Load configuration from default locations pub fn load() -> Result { + let legacy_read_only_p2s = basis_store::contract_compiler::get_basis_reserve_contract_p2s() + .map_err(|e| config::ConfigError::Message(e.to_string()))?; let config = config::Config::builder() // Default configuration .set_default("server.host", "0.0.0.0")? @@ -119,6 +121,7 @@ impl AppConfig { .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 + .set_default("ergo.basis_reserve_contract_p2s", legacy_read_only_p2s)? // Transaction configuration defaults .set_default("transaction.fee", 1000000)? // 0.001 ERG // Tracker public key (optional) @@ -155,18 +158,44 @@ impl AppConfig { &self.ergo.basis_reserve_contract_p2s } - /// Reject the known historical strict-insert contract when a caller is - /// about to construct insert-or-update reserve state. - pub fn reject_known_legacy_reserve_contract(&self) -> Result<(), String> { + /// Require the exact ChainCash-reviewed Basis v2 ERG contract identity. + 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; every construction route remains a + /// tombstone and this does not attest safety of legacy 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 reserve contract identity: {e}"))?; - if self.basis_reserve_contract_p2s() == legacy { + .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( - "configured reserve contract is the retired strict-insert generation; configure an explicitly reviewed insert-or-update P2S before building reserve transactions" + "Basis v2 contract identity is recognized, but v2 scanner and BNS2/BRS2 state are not installed; runtime activation is disabled" .to_string(), ); } - Ok(()) + 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) @@ -408,7 +437,7 @@ mod tests { } #[test] - fn known_strict_insert_contract_fails_closed() { + fn validates_only_the_exact_basis_v2_contract() { let mut config = AppConfig { server: ServerConfig { host: "127.0.0.1".to_string(), @@ -432,11 +461,27 @@ mod tests { acceptance: AcceptanceConfig::empty(), }; - let error = config.reject_known_legacy_reserve_contract().unwrap_err(); - assert!(error.contains("retired strict-insert")); + 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 = "explicitly-reviewed-new-generation".to_string(); - assert!(config.reject_known_legacy_reserve_contract().is_ok()); + 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] diff --git a/crates/basis_server/src/create_reserve_tests.rs b/crates/basis_server/src/create_reserve_tests.rs index 18d1afa..abcd9fe 100644 --- a/crates/basis_server/src/create_reserve_tests.rs +++ b/crates/basis_server/src/create_reserve_tests.rs @@ -5,9 +5,7 @@ mod create_reserve_tests { use tokio::sync::Mutex; use crate::{ - api::create_reserve_payload, - models::{CreateReserveRequest, ReserveCreationResponse}, - AppState, TrackerCommand, + api::create_reserve_payload, models::CreateReserveRequest, AppState, TrackerCommand, }; use basis_store::ergo_scanner::{NodeConfig, ServerState}; @@ -133,8 +131,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(), @@ -147,24 +149,14 @@ mod create_reserve_tests { let (status, response_json) = result; - 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; - - assert!(!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(), - "0703e8c3e4877e2f7b79e0e407421a81a1619ea64e37e5e4e77454d1e361e6f80b12" - ); - assert!(reserve_response.fee > 0); + 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] @@ -185,7 +177,7 @@ mod create_reserve_tests { .error .as_deref() .unwrap_or_default() - .contains("retired strict-insert")); + .contains("identity check failed")); } #[tokio::test] diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 79b9437..7d37bde 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -202,14 +202,16 @@ async fn main() { scan_name: Some("Basis Reserve Scanner".to_string()), api_key: None, }, - basis_reserve_contract_p2s: "3PQnJ92Krn6NeM1GdMSmNayw34Nuud7UKMoKSTRUTucsNybh99K1HEfjZqyvP7cPag1yBkDv3ruMAgb2NsVKq3tAygjHz7mKDzHK6CJGhD3WfNViD7DoViqbgsXrzvs6Kt8Wyzb48uGqJAFQFWes6ZPKELqUZowy8xtVCS5w1VwnyaeRiWpEyUVGaEHw3qWo5DcVxzmMAP8XXhVTw1rYYrUxsyGPNaBxQkkkTVD9L3bmw77EfeAJgJ1hLxghykNofHscHtMtES4v5FSfqke3Huun81S7gNoraEnsR6Dy6YnQgrBswwCZhyGc89YeNFQn1TCFh5Hct3nKGrd1bV5zoCw67Q9fKtoaCtvcPQ2GDWycGKNRNgyAnPEa8WbHbTEVcjAN25aBwhnY5LFGqYxnUAjhpfkTPJ4FJWRijSqMESzpyrmhTLZdivmn4YSwcchVZr7bHGbfncEDwqPKefdoxNnVPxuVdmeqQXL3aDL7TaqWgExzz1UPXHw3UiKYTUkNgQKCN4WV3LHqc9PecoisL77ydVbSCxPapaX2zTf26F8bGK3hsTVBZnMkt93SJP5GmPgZU5FT9NkFh4okjXK9ce2wmA4MV93ySyYnUKGwTRFJWwE7G1MYqBqTY3ESkn8PJHqVuL4cgtuV2GEPagKt19befRAuUV3FaLGVPJMzpKdANd7hKGZRcy3DnPfT1Q9dyFD4VpdBgFRXJWaaDqYjL7ni4nJcKKam9P395wRRnjGWhTV4hv3KoxC8Xk2CZAUjhkTzvuNHxQrLsWjyrKWJqZgs2uZxoAEHEobDegYWiTcnFCPU9EeJxZLSjysDFninqpQvA66Yt1SvJnSZm49RKsaoR98UJVScdiQfNZE76zTYBioXGatdRz7QVkXDzDPjPMu9Hhepc2XbHqo3ia8tszHptbnSzm2R3PC7iu2Tnhu3QT".to_string(), + basis_reserve_contract_p2s: + basis_store::contract_compiler::get_basis_reserve_contract_p2s() + .expect("historical read-only contract identity must decode"), tracker_nft_id: None, allow_fresh_tracker_generation: false, tracker_public_key: None, tracker_secret_key: None, }, transaction: TransactionConfig { - fee: 1000000, // 0.001 ERG + fee: 1000000, // 0.001 ERG change_address: None, // Will be derived from tracker public key }, acceptance: basis_server::acceptance::config::AcceptanceConfig::empty(), @@ -218,6 +220,11 @@ async fn main() { } }; + 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() diff --git a/crates/basis_server/src/redemption_build.rs b/crates/basis_server/src/redemption_build.rs index e297696..70566a4 100644 --- a/crates/basis_server/src/redemption_build.rs +++ b/crates/basis_server/src/redemption_build.rs @@ -414,7 +414,7 @@ async fn build_redemption_inner( state: &AppState, payload: &RedemptionBuildRequest, ) -> ApiResult { - if let Err(e) = state.config.reject_known_legacy_reserve_contract() { + if let Err(e) = state.config.reject_unsupported_reserve_builder() { return api_err(StatusCode::SERVICE_UNAVAILABLE, e); } diff --git a/crates/basis_store/Cargo.toml b/crates/basis_store/Cargo.toml index 76eecf1..7c52752 100644 --- a/crates/basis_store/Cargo.toml +++ b/crates/basis_store/Cargo.toml @@ -44,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..b4a720a --- /dev/null +++ b/crates/basis_store/contracts/basis-v2-provenance.json @@ -0,0 +1,26 @@ +{ + "abi_generation": 2, + "network": "ergo-mainnet", + "chaincash_base_commit": "78475e30362571acf56e4e38276a9d6c0a84ce0c", + "contract_source_commit": "9a274396d5f78f7be5ed76bacee5329c42570317", + "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/contract_compiler.rs b/crates/basis_store/src/contract_compiler.rs index 0a61eab..f5a3f03 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 reviewed +/// ChainCash 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,6 +27,55 @@ 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(), + } + } +} + +/// 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. @@ -35,6 +101,65 @@ 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["contract_source_commit"], + "9a274396d5f78f7be5ed76bacee5329c42570317" + ); + 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 historical_contract_identity_is_a_valid_p2s() { diff --git a/docs/BUILD_AND_CREATE_RESERVE.md b/docs/BUILD_AND_CREATE_RESERVE.md index f71072f..8b7f28b 100644 --- a/docs/BUILD_AND_CREATE_RESERVE.md +++ b/docs/BUILD_AND_CREATE_RESERVE.md @@ -29,9 +29,12 @@ The built binary will be available at: ## Step 3: Create the Reserve for Alice -The server refuses to build against the known historical strict-insert P2S. -Configure an insert-or-update contract identity promoted from reviewed source -and parity evidence before following this step. +> **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 diff --git a/docs/BUILD_INSTALL.md b/docs/BUILD_INSTALL.md index 617559e..71d94cc 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**: @@ -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 b7b838d..fcbd1bf 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -39,7 +39,9 @@ different NFT, missing or corrupt manifest, or non-matching first root fails clo ```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 @@ -155,7 +157,7 @@ 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 diff --git a/specs/basis_v2_runtime.md b/specs/basis_v2_runtime.md new file mode 100644 index 0000000..4c44893 --- /dev/null +++ b/specs/basis_v2_runtime.md @@ -0,0 +1,95 @@ +# 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. + +## Activation boundary + +This foundation recognizes the exact v2 identity but deliberately rejects its +activation at server startup. The current scanner and state store are v1-shaped +and must not interpret v2 registers. The historical strict-insert identity is +retained only as a temporary compatibility mode, while every construction +endpoint stays disabled; this does not establish legacy acceptance safety. V2 +activation requires its scanner, BNS2/BRS2 state, and a builder +that supplies all of the following as one coherent manifest: + +- 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, startup rejects the exact v2 P2S and reserve creation, +P2S distribution, and redemption build endpoints fail closed. This prevents a +v1-shaped scanner or transaction from being presented as a v2 operation. + +## Coexistence and migration + +V1 and v2 records must use separate storage namespaces and APIs. 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 v1 state. diff --git a/specs/security_boundary_remediation.md b/specs/security_boundary_remediation.md index 8ff8740..679b594 100644 --- a/specs/security_boundary_remediation.md +++ b/specs/security_boundary_remediation.md @@ -26,9 +26,10 @@ caller to exercise tracker-owned capabilities or to assert settlement state. 6. Legacy `POST /redeem`, CLI `note redeem`, and MCP `note_redeem` are unconditional tombstones before account, network, construction, signing, broadcast, or persistence effects. -7. Builders reject the known historical strict-insert reserve P2S while they - emit insert-or-update AVL state. A new contract identity must be promoted - from reviewed source and parity evidence as a separate change. +7. The exact committed Basis v2 ERG ErgoTree is recognized, but startup rejects + its activation until the v2 scanner and BNS2/BRS2 state are installed. The + historical identity remains only as a compatibility mode; reserve creation, + P2S distribution, and redemption builders remain disabled. ## Compatibility changes @@ -41,7 +42,7 @@ caller to exercise tracker-owned capabilities or to assert settlement state. | `POST /redeem/complete` | Returns `410 Gone`. | | `note redeem` / MCP `note_redeem` | Return an error before effects; no boolean reactivation path remains. | | Non-local `transaction generate-redemption` | Returns an error; use `--local-sign` or the assisted signer. | -| Reserve P2S `3PQnJ92K...` | Reserve payload/redemption builders return `503 Service Unavailable`; no successor is constructed against the incompatible generation. | +| Reserve P2S / builders | Historical identity: temporary compatibility only, not a safety endorsement. Exact v2 identity: recognized but startup-disabled. Unknown identity: rejected. All construction/P2S distribution routes return `503 Service Unavailable`. | ## Settlement hand-off diff --git a/specs/server/basis_server_spec.md b/specs/server/basis_server_spec.md index 383391a..7a2eaa7 100644 --- a/specs/server/basis_server_spec.md +++ b/specs/server/basis_server_spec.md @@ -196,16 +196,22 @@ The server now implements real cryptographic functionality using the Ergo node's The server provides an endpoint to generate reserve creation payloads for Ergo node's `/wallet/payment/send` API: -The endpoint returns `503 Service Unavailable` when the configured P2S is the -known historical strict-insert generation. A builder that emits insert-or-update -AVL state must not construct a reserve against that incompatible contract. +The endpoint currently returns `503 Service Unavailable` for every +configuration. Startup retains the historical identity only for compatibility +and rejects activation of the recognized, byte-exact v2 ERG tree +until its scanner and BNS2/BRS2 stores exist. The payload code remains +unreachable until a v2 builder supplies R4-R9, fixed 32/24 reserve state, a +policy-derived emergency height, predecessor lineage, and a genuine singleton. +See `specs/basis_v2_runtime.md`. - `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: +- Once the v2 builder is installed, it will return a reviewed owner-wallet + intent rather than exercising the node wallet. The historical response shape + below is not active: - `requests`: Array of payment requests - `address`: Reserve contract P2S address (hardcoded in configuration) - `value`: ERG amount from request diff --git a/specs/server/redemption_state_spec.md b/specs/server/redemption_state_spec.md index c0497b8..ec73eb7 100644 --- a/specs/server/redemption_state_spec.md +++ b/specs/server/redemption_state_spec.md @@ -488,12 +488,14 @@ semantics: - `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. -The configured `3PQnJ92K...` P2S is the historical strict-insert generation, -while the builder emits insert-or-update state. Reserve creation and redemption -building therefore fail closed for that known legacy identity. A replacement P2S -must be compiled from the reviewed insert-or-update source and promoted together -with its exact source/build identity and parity fixtures; compatibility must not be -inferred from an address prefix. +The runtime now pins the full byte-exact Basis v2 ERG and token ErgoTrees from +the reviewed ChainCash source 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 From 70d14104bb70eb2e3e844f7817253d73449f3933 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:55:36 +0200 Subject: [PATCH 12/41] fix: reconcile tracker publications on confirmed chain --- config/basis.toml.example | 10 + crates/basis_server/src/config.rs | 42 + .../basis_server/src/create_reserve_tests.rs | 4 + crates/basis_server/src/lib.rs | 10 +- crates/basis_server/src/main.rs | 145 +- .../basis_server/src/tracker_box_updater.rs | 1624 ++++++-- .../tests/acceptance_api_integration_tests.rs | 12 + crates/basis_server/tests/cors_tests.rs | 7 +- .../tests/http_api_integration_tests.rs | 16 +- .../tests/redemption_api_integration_tests.rs | 7 +- .../tests/tracker_box_updater_integration.rs | 2 +- .../basis_store/src/chain_reconciliation.rs | 3482 +++++++++++++++++ crates/basis_store/src/lib.rs | 448 ++- crates/basis_store/src/persistence.rs | 142 +- crates/basis_store/src/tests.rs | 247 +- specs/confirmed_chain_reconciliation.md | 151 + 16 files changed, 5987 insertions(+), 362 deletions(-) create mode 100644 crates/basis_store/src/chain_reconciliation.rs create mode 100644 specs/confirmed_chain_reconciliation.md diff --git a/config/basis.toml.example b/config/basis.toml.example index b26092e..1a3b4b6 100644 --- a/config/basis.toml.example +++ b/config/basis.toml.example @@ -15,6 +15,16 @@ 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) diff --git a/crates/basis_server/src/config.rs b/crates/basis_server/src/config.rs index 24c934e..45ab4ab 100644 --- a/crates/basis_server/src/config.rs +++ b/crates/basis_server/src/config.rs @@ -60,6 +60,20 @@ pub struct ErgoConfig { /// 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) @@ -76,6 +90,22 @@ impl std::fmt::Debug for ErgoConfig { &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", @@ -393,6 +423,10 @@ mod tests { 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, @@ -424,6 +458,10 @@ mod tests { 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, @@ -459,6 +497,10 @@ 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 18d1afa..c12b001 100644 --- a/crates/basis_server/src/create_reserve_tests.rs +++ b/crates/basis_server/src/create_reserve_tests.rs @@ -76,6 +76,10 @@ mod create_reserve_tests { "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/src/lib.rs b/crates/basis_server/src/lib.rs index e0c082b..60943f7 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -174,9 +174,13 @@ pub enum TrackerCommand { /// Promote the durable attempt after active-chain confirmation and release /// the actor fence. ConfirmPublication { - tx_id: String, - box_id: String, - height: u64, + effect: basis_store::chain_reconciliation::ValidatedChainEffect, + response_tx: tokio::sync::oneshot::Sender>, + }, + /// 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>, }, /// Release an actor fence after a no-op or failed publication attempt. diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 79b9437..a3ebee8 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -72,6 +72,9 @@ fn reject_while_publication_is_fenced(command: TrackerCommand) { | 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)); } @@ -128,14 +131,12 @@ fn handle_command_while_publication_is_fenced( Some(active_lease) } TrackerCommand::ConfirmPublication { - tx_id, - box_id, - height, + effect, response_tx, } => { let result = redemption_manager .tracker - .confirm_pending_publication(&tx_id, &box_id, height); + .confirm_validated_publication(&effect); let confirmed = result.is_ok(); if result.as_ref().is_err_and(|error| { !matches!(error, basis_store::NoteError::PublicationLeaseMismatch) @@ -149,6 +150,19 @@ fn handle_command_while_publication_is_fenced( Some(active_lease) } } + TrackerCommand::RollbackPublication { + rollback, + response_tx, + } => { + let result = redemption_manager + .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 = redemption_manager .tracker @@ -205,6 +219,10 @@ async fn main() { basis_reserve_contract_p2s: "3PQnJ92Krn6NeM1GdMSmNayw34Nuud7UKMoKSTRUTucsNybh99K1HEfjZqyvP7cPag1yBkDv3ruMAgb2NsVKq3tAygjHz7mKDzHK6CJGhD3WfNViD7DoViqbgsXrzvs6Kt8Wyzb48uGqJAFQFWes6ZPKELqUZowy8xtVCS5w1VwnyaeRiWpEyUVGaEHw3qWo5DcVxzmMAP8XXhVTw1rYYrUxsyGPNaBxQkkkTVD9L3bmw77EfeAJgJ1hLxghykNofHscHtMtES4v5FSfqke3Huun81S7gNoraEnsR6Dy6YnQgrBswwCZhyGc89YeNFQn1TCFh5Hct3nKGrd1bV5zoCw67Q9fKtoaCtvcPQ2GDWycGKNRNgyAnPEa8WbHbTEVcjAN25aBwhnY5LFGqYxnUAjhpfkTPJ4FJWRijSqMESzpyrmhTLZdivmn4YSwcchVZr7bHGbfncEDwqPKefdoxNnVPxuVdmeqQXL3aDL7TaqWgExzz1UPXHw3UiKYTUkNgQKCN4WV3LHqc9PecoisL77ydVbSCxPapaX2zTf26F8bGK3hsTVBZnMkt93SJP5GmPgZU5FT9NkFh4okjXK9ce2wmA4MV93ySyYnUKGwTRFJWwE7G1MYqBqTY3ESkn8PJHqVuL4cgtuV2GEPagKt19befRAuUV3FaLGVPJMzpKdANd7hKGZRcy3DnPfT1Q9dyFD4VpdBgFRXJWaaDqYjL7ni4nJcKKam9P395wRRnjGWhTV4hv3KoxC8Xk2CZAUjhkTzvuNHxQrLsWjyrKWJqZgs2uZxoAEHEobDegYWiTcnFCPU9EeJxZLSjysDFninqpQvA66Yt1SvJnSZm49RKsaoR98UJVScdiQfNZE76zTYBioXGatdRz7QVkXDzDPjPMu9Hhepc2XbHqo3ia8tszHptbnSzm2R3PC7iu2Tnhu3QT".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: None, tracker_secret_key: None, }, @@ -457,6 +475,24 @@ async fn main() { 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()))); @@ -650,21 +686,14 @@ async fn main() { TrackerCommand::BeginPublication { tracker_nft_id, observed_root, - box_id, - height, + box_id: _, + height: _, response_tx, } => { let result = redemption_manager .tracker .validate_observed_generation(&tracker_nft_id, observed_root) - .and_then(|_| { - redemption_manager.tracker.reconcile_with_confirmed_digest( - &observed_root, - &box_id, - height, - )?; - redemption_manager.tracker.validated_state() - }) + .and_then(|_| redemption_manager.tracker.validated_state()) .and_then(|state| { next_publication_id .checked_add(1) @@ -688,10 +717,35 @@ async fn main() { } } } - TrackerCommand::RecordPublicationAttempt { response_tx, .. } - | TrackerCommand::ConfirmPublication { response_tx, .. } => { + TrackerCommand::RecordPublicationAttempt { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::PublicationLeaseMismatch)); } + TrackerCommand::ConfirmPublication { + effect, + response_tx, + } => { + let result = redemption_manager + .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::RollbackPublication { + rollback, + response_tx, + } => { + let result = redemption_manager + .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)); } @@ -742,6 +796,15 @@ async fn main() { 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); @@ -1222,21 +1285,20 @@ mod publication_fence_tests { Ok(Err(basis_store::NoteError::PublicationInProgress)) )); - let (wrong_tx, wrong_rx) = tokio::sync::oneshot::channel(); + let (abort_tx, abort_rx) = tokio::sync::oneshot::channel(); + let restored_lease = active.unwrap(); active = handle_command_while_publication_is_fenced( - active.unwrap(), - TrackerCommand::ConfirmPublication { - tx_id: "22".repeat(32), - box_id: "wrong-box".to_string(), - height: 200, - response_tx: wrong_tx, + restored_lease, + TrackerCommand::AbortPublication { + lease: restored_lease, + response_tx: abort_tx, }, &mut redemption_manager, &restarted_shared, ); assert!(matches!( - wrong_rx.await, - Ok(Err(basis_store::NoteError::PublicationLeaseMismatch)) + abort_rx.await, + Ok(Err(basis_store::NoteError::PublicationInProgress)) )); assert!(active.is_some()); assert!(restarted_shared.is_publication_healthy()); @@ -1253,38 +1315,7 @@ mod publication_fence_tests { TrackerBoxUpdater::restored_pending_transaction(&restarted_shared).unwrap(), Some((tx_id.clone(), digest)) ); - - let (confirm_tx, confirm_rx) = tokio::sync::oneshot::channel(); - active = handle_command_while_publication_is_fenced( - active.unwrap(), - TrackerCommand::ConfirmPublication { - tx_id: tx_id.clone(), - box_id: "confirmed-box".to_string(), - height: 201, - response_tx: confirm_tx, - }, - &mut redemption_manager, - &restarted_shared, - ); - assert!(matches!(confirm_rx.await, Ok(Ok(_)))); - assert!(active.is_none()); - assert!(redemption_manager - .tracker - .pending_publication() - .unwrap() - .is_none()); - - // The updater clears its shared pending cache only after the actor has - // durably accepted the exact confirmation. - restarted_shared.clear_pending(); - let updater_pending = restarted_shared.get_pending(); - assert!(updater_pending.tx_id.is_none()); - assert!(updater_pending.digest.is_none()); - assert!(updater_pending.submitted_height.is_none()); - assert_eq!( - TrackerBoxUpdater::restored_pending_transaction(&restarted_shared).unwrap(), - None - ); + assert!(active.is_some()); } } diff --git a/crates/basis_server/src/tracker_box_updater.rs b/crates/basis_server/src/tracker_box_updater.rs index 2738cd9..a682742 100644 --- a/crates/basis_server/src/tracker_box_updater.rs +++ b/crates/basis_server/src/tracker_box_updater.rs @@ -4,8 +4,18 @@ //! 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 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::transaction::Transaction; -use std::sync::{Arc, RwLock}; +use std::{ + path::PathBuf, + sync::{Arc, RwLock}, +}; use tokio::time::{interval, Duration}; use tracing::{error, info, warn}; @@ -27,6 +37,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. @@ -48,6 +64,8 @@ pub struct SharedTrackerState { pub tracker_nft_id: Arc>>, pub confirmed: Arc>, pub pending: Arc>, + historical_confirmation: Arc>>, + confirmation_history_present: Arc>, publication_health: basis_store::PublicationHealth, } @@ -61,6 +79,8 @@ impl SharedTrackerState { 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(), } } @@ -72,6 +92,8 @@ impl SharedTrackerState { 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(), } } @@ -148,11 +170,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(); } } @@ -165,6 +204,41 @@ impl SharedTrackerState { pub fn get_confirmed(&self) -> ConfirmedState { 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 @@ -176,6 +250,22 @@ pub struct TrackerBoxUpdateConfig { 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 { @@ -185,6 +275,13 @@ impl std::fmt::Debug for TrackerBoxUpdateConfig { .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("change_address", &self.change_address) .field( "tracker_secret_key", @@ -203,6 +300,13 @@ impl Default for TrackerBoxUpdateConfig { 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, } } } @@ -374,6 +478,10 @@ pub enum TrackerBoxUpdaterError { SigningFailed(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 @@ -386,10 +494,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, @@ -430,8 +540,20 @@ fn vlq_encode(mut value: usize) -> Vec { pub struct TrackerBoxUpdater; struct PreparedTrackerUpdate { - signed_tx: serde_json::Value, + signed_bytes: Vec, tx_id: String, + intent: ReconciliationIntent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NodeTip { + id: String, + height: u64, +} + +enum TransactionObservation { + Pending, + Accepted(ValidatedChainEffect), } impl TrackerBoxUpdater { @@ -452,6 +574,18 @@ impl TrackerBoxUpdater { } } + 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, @@ -459,10 +593,68 @@ impl TrackerBoxUpdater { 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 policy = ReconciliationPolicy::new( + config.min_successor_depth, + config.max_evidence_age_ms, + reorg_monitor_depth, + ); + let restored_pending = Self::restored_pending_transaction(&shared_state)?; + let historical_confirmation = shared_state.get_historical_confirmation(); + let bootstrap = Self::journal_bootstrap_policy( + shared_state.has_confirmation_history(), + 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 = Self::restored_pending_transaction(&shared_state)?; + Self::validate_startup_join( + &journal, + restored_pending.as_ref(), + historical_confirmation.as_ref(), + )?; info!( "Tracker box updater started with {}s interval", @@ -486,47 +678,126 @@ impl TrackerBoxUpdater { continue; } - 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); - - // 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 - .unwrap_or_else(|_| (tx_id.clone(), 0)); - if !Self::confirm_publication( - &cmd_tx, - tx_id.clone(), - box_id.clone(), - height, - ) - .await - { - shared_state.quarantine_publication(); - return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( - "confirmed publication was not durably reconciled".to_string(), - )); - } - last_submitted_digest = Some(expected_digest); - shared_state.set_confirmed(expected_digest, box_id, height); - shared_state.clear_pending(); - pending_tx = None; + { + shared_state.quarantine_publication(); + return Err(error); } - Ok(false) => { - info!( - "Transaction {} still pending, waiting for next cycle...", - tx_id - ); - continue; + 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); } - Err(e) => { - error!( - "Failed to check transaction {} status: {}. Will retry.", - tx_id, e - ); + continue; + } + RecoveryAction::RestoreRetired(effect) => { + if let Err(error) = + Self::restore_retired_publication(&cmd_tx, &shared_state, &journal, &effect) + .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 + { + if Self::is_retryable_chain_observation_error(&error) { + warn!(%error, tx_id = %accepted.tx_id(), "Unable to revalidate accepted chain anchor; retaining fail-closed state"); continue; } + 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), + } + 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) => { + warn!(tx_id = %intent.tx_id(), %error, "Confirmed-chain evidence unavailable or invalid; retaining the fence"); + } + } + 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(), + )); + } } } @@ -548,122 +819,43 @@ 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()); - let mut publication_lease = None; - 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, - ); - - // The actor validates/reconciles the observed generation - // and then remains fenced until this exact external - // publication attempt is completed or explicitly - // aborted. - let tracker_nft_bytes: [u8; 32] = match hex::decode(&tracker_nft_id) - .ok() - .and_then(|bytes| bytes.try_into().ok()) - { - Some(bytes) => bytes, - None => { - shared_state.quarantine_publication(); - error!("Configured tracker NFT is not exactly 32 bytes"); - continue; - } - }; - let lease = if let Some(ref tx) = cmd_tx { - let (rtx, rrx) = tokio::sync::oneshot::channel(); - if tx - .send(crate::TrackerCommand::BeginPublication { - tracker_nft_id: tracker_nft_bytes, - observed_root: onchain_digest_arr, - box_id: tracker_box.box_id.clone(), - height: tracker_box.creation_height as u64, - response_tx: rtx, - }) - .await - .is_err() - { - None - } else { - match rrx.await { - Ok(Ok(lease)) => Some(lease), - _ => None, - } - } - } else { - None - }; - let lease = match lease { - Some(lease) => lease, - None => { - shared_state.quarantine_publication(); - error!("Tracker actor refused the publication fence"); - continue; - } - }; - if lease.digest == [0u8; 33] { - shared_state.quarantine_publication(); - error!("Tracker actor returned an uninitialized publication digest"); - continue; - } - let current_digest = lease.digest; - publication_lease = Some(lease); - - if onchain_digest == current_digest.as_slice() { - info!("On-chain tracker box already has current AVL root digest"); - last_submitted_digest = Some(current_digest); - if !Self::abort_publication(&cmd_tx, lease).await { - shared_state.quarantine_publication(); - return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( - "tracker actor did not release a no-op publication fence" - .to_string(), - )); - } - continue; - } - } - } - } - - let publication_lease = match publication_lease { - Some(lease) => lease, - None => { - shared_state.quarantine_publication(); - error!( - "Tracker box has no valid R5 generation root; refusing commitment publication" - ); - 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 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"); - if !Self::abort_publication(&cmd_tx, publication_lease).await { - shared_state.quarantine_publication(); - return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( - "tracker actor did not release a redundant publication fence" - .to_string(), - )); - } - continue; + 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; } let prepared = match Self::prepare_tracker_update( @@ -689,10 +881,7 @@ impl TrackerBoxUpdater { } }; - let submitted_height = Self::get_node_height(&config) - .await - .map(|height| height as u64) - .unwrap_or(tracker_box.creation_height as u64); + let submitted_height = Self::fetch_tip(&config, &client).await?.height; if !Self::record_publication_attempt( &cmd_tx, publication_lease, @@ -707,13 +896,16 @@ impl TrackerBoxUpdater { .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); - pending_tx = Some((prepared.tx_id.clone(), current_digest)); - + journal.arm_submission(prepared.intent.intent_id())?; match Self::broadcast_transaction( &config, - &prepared.signed_tx, + &client, + &prepared.signed_bytes, &prepared.tx_id, ) .await @@ -740,6 +932,554 @@ impl TrackerBoxUpdater { } } + fn node_client( + config: &TrackerBoxUpdateConfig, + ) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(config.request_timeout_seconds)) + .build() + .map_err(|error| TrackerBoxUpdaterError::HttpError(error.to_string())) + } + + #[doc(hidden)] + pub async fn probe_transaction_observation( + config: &TrackerBoxUpdateConfig, + tx_id: &str, + ) -> 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(()) + } + + fn validate_startup_join( + journal: &ReconciliationJournal, + restored_pending: Option<&(String, [u8; 33])>, + historical_confirmation: Option<&basis_store::ConfirmedProjectionAnchor>, + ) -> Result<(), TrackerBoxUpdaterError> { + journal + .validate_tracker_startup_join(restored_pending, historical_confirmation) + .map_err(|error| { + TrackerBoxUpdaterError::BroadcastOutcomeUnknown(format!( + "tracker startup reconciliation join failed: {error}" + )) + }) + } + + 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(), + )) + } + } + + fn unix_time_ms() -> Result { + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| TrackerBoxUpdaterError::HttpError(error.to_string()))? + .as_millis(); + u64::try_from(millis).map_err(|_| { + TrackerBoxUpdaterError::HttpError("system time exceeds u64 milliseconds".to_string()) + }) + } + + fn is_retryable_chain_observation_error(error: &TrackerBoxUpdaterError) -> bool { + match error { + TrackerBoxUpdaterError::HttpError(_) => true, + TrackerBoxUpdaterError::Reconciliation(error) => !matches!( + error, + ReconciliationError::TicketInProgress + | ReconciliationError::DuplicateTransactionConflict + | ReconciliationError::NoTicket + | ReconciliationError::IntentMismatch + | ReconciliationError::InvalidPhase + | ReconciliationError::Journal(_) + | ReconciliationError::JournalBindingRequired + | ReconciliationError::JournalBindingMismatch + | ReconciliationError::AccountingProjectionMismatch + | ReconciliationError::OutcomeUnknown(_) + ), + _ => false, + } + } + + async fn get_node_bytes( + config: &TrackerBoxUpdateConfig, + client: &reqwest::Client, + path_and_query: &str, + missing_is_pending: bool, + ) -> Result>, TrackerBoxUpdaterError> { + let url = format!( + "{}{}", + config.node_url.trim_end_matches('/'), + path_and_query + ); + let mut request = client.get(url); + if let Some(api_key) = &config.api_key { + request = request.header("api_key", api_key); + } + let response = request + .send() + .await + .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() { + return Err(TrackerBoxUpdaterError::HttpError(format!( + "node returned HTTP {} for {}", + response.status(), + path_and_query.split('?').next().unwrap_or("request") + ))); + } + response + .bytes() + .await + .map(|bytes| Some(bytes.to_vec())) + .map_err(|error| TrackerBoxUpdaterError::HttpError(error.to_string())) + } + + async fn fetch_tip( + config: &TrackerBoxUpdateConfig, + client: &reqwest::Client, + ) -> Result { + let bytes = Self::get_node_bytes(config, client, "/info", false) + .await? + .ok_or_else(|| TrackerBoxUpdaterError::HttpError("missing /info body".to_string()))?; + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|error| TrackerBoxUpdaterError::HttpError(error.to_string()))?; + let height = value + .get("fullHeight") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| { + TrackerBoxUpdaterError::HttpError("/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::HttpError( + "/info does not expose one coherent full-chain tip id".to_string(), + )); + } + Ok(NodeTip { + id: ids.into_iter().next().unwrap_or_default(), + height, + }) + } + + async fn collect_selected_chain( + config: &TrackerBoxUpdateConfig, + client: &reqwest::Client, + inclusion_height: u64, + ) -> Result { + let before = Self::fetch_tip(config, client).await?; + let to_height = before + .height + .checked_add(1) + .ok_or_else(|| TrackerBoxUpdaterError::HttpError("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::HttpError("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()?, + )?) + } + + async fn collect_selected_window( + config: &TrackerBoxUpdateConfig, + client: &reqwest::Client, + 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::HttpError("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::HttpError("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()?, + )?) + } + + async fn observe_transaction( + config: &TrackerBoxUpdateConfig, + client: &reqwest::Client, + 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::HttpError(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::HttpError(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::HttpError("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::HttpError("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::HttpError("empty chain slice".to_string()) + })?; + let block_id = first_header + .get("id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + TrackerBoxUpdaterError::HttpError("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::HttpError("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::HttpError("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, + Self::unix_time_ms()?, + )?)) + } + + 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(()) + } + + 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(()) + } + + 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) + } + + async fn revalidate_accepted_anchor( + config: &TrackerBoxUpdateConfig, + client: &reqwest::Client, + 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 + } + + 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) + } + + 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()?; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + tx.send(crate::TrackerCommand::BeginPublication { + tracker_nft_id, + observed_root, + box_id, + height, + response_tx, + }) + .await + .ok()?; + response_rx.await.ok()?.ok() + } + async fn abort_publication( cmd_tx: &Option>, lease: crate::PublicationLease, @@ -785,9 +1525,7 @@ impl TrackerBoxUpdater { async fn confirm_publication( cmd_tx: &Option>, - tx_id: String, - box_id: String, - height: u64, + effect: ValidatedChainEffect, ) -> bool { let Some(tx) = cmd_tx else { return false; @@ -795,9 +1533,7 @@ impl TrackerBoxUpdater { let (response_tx, response_rx) = tokio::sync::oneshot::channel(); if tx .send(crate::TrackerCommand::ConfirmPublication { - tx_id, - box_id, - height, + effect, response_tx, }) .await @@ -808,58 +1544,25 @@ impl TrackerBoxUpdater { matches!(response_rx.await, Ok(Ok(_))) } - /// 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( - 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); - } - - let response = request - .send() - .await - .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; - - if !response.status().is_success() { - return Err(TrackerBoxUpdaterError::HttpError(format!( - "HTTP {}", - response.status() - ))); - } - - let body: serde_json::Value = response - .json() + async fn rollback_publication( + cmd_tx: &Option>, + rollback: ValidatedRollback, + ) -> bool { + let Some(tx) = cmd_tx else { + return false; + }; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + if tx + .send(crate::TrackerCommand::RollbackPublication { + rollback, + response_tx, + }) .await - .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; - - // 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)); - } + .is_err() + { + return false; } - - Ok((tx_id.to_string(), 0)) + matches!(response_rx.await, Ok(Ok(_))) } /// Find the tracker box on chain using the tracker NFT ID @@ -867,7 +1570,7 @@ impl TrackerBoxUpdater { config: &TrackerBoxUpdateConfig, tracker_nft_id: &str, ) -> Result { - let client = reqwest::Client::new(); + let client = Self::node_client(config)?; let url = format!( "{}/blockchain/box/unspent/byTokenId/{}?limit=5", config.node_url.trim_end_matches('/'), @@ -919,7 +1622,7 @@ impl TrackerBoxUpdater { async fn get_wallet_boxes( config: &TrackerBoxUpdateConfig, ) -> Result, TrackerBoxUpdaterError> { - let client = reqwest::Client::new(); + let client = Self::node_client(config)?; let url = format!( "{}/wallet/boxes/unspent?minConfirmations=0&maxConfirmations=-1", config.node_url.trim_end_matches('/') @@ -1017,7 +1720,7 @@ impl TrackerBoxUpdater { config: &TrackerBoxUpdateConfig, box_id: &str, ) -> Result { - let client = reqwest::Client::new(); + let client = Self::node_client(config)?; let url = format!( "{}/utxo/byIdBinary/{}", config.node_url.trim_end_matches('/'), @@ -1055,7 +1758,7 @@ impl TrackerBoxUpdater { async fn get_node_height( config: &TrackerBoxUpdateConfig, ) -> Result { - let client = reqwest::Client::new(); + let client = Self::node_client(config)?; let url = format!("{}/info", config.node_url.trim_end_matches('/')); let mut request = client.get(&url); @@ -1095,7 +1798,7 @@ impl TrackerBoxUpdater { ) -> Result { info!("Requesting node signature for tracker-box update"); - let client = reqwest::Client::new(); + let client = Self::node_client(config)?; let url = format!( "{}/wallet/transaction/sign", config.node_url.trim_end_matches('/') @@ -1129,15 +1832,18 @@ impl TrackerBoxUpdater { /// Broadcast a signed transaction to the Ergo node's /transactions endpoint. async fn broadcast_transaction( config: &TrackerBoxUpdateConfig, - signed_tx: &serde_json::Value, + client: &reqwest::Client, + signed_bytes: &[u8], expected_tx_id: &str, ) -> Result { 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); } @@ -1255,6 +1961,11 @@ impl TrackerBoxUpdater { let mut r4_bytes = vec![0x07u8]; r4_bytes.extend_from_slice(tracker_pubkey); let r4_value = hex::encode(&r4_bytes); + if tracker_box.additional_registers.get("R4") != Some(&r4_value) { + return Err(TrackerBoxUpdaterError::SerializationError( + "tracker predecessor R4 does not match the configured receiver".to_string(), + )); + } let mut r5_bytes = vec![0x64u8]; r5_bytes.extend_from_slice(avl_root_digest); @@ -1264,7 +1975,6 @@ impl TrackerBoxUpdater { 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 { @@ -1325,18 +2035,22 @@ impl TrackerBoxUpdater { }) .collect(); - // 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()); - } else { - other_assets.push(asset.clone()); - } + let nft_occurrences = tracker_box + .assets + .iter() + .filter(|asset| asset.token_id == tracker_nft_id) + .count(); + if nft_occurrences != 1 + || !tracker_box + .assets + .first() + .is_some_and(|asset| asset.token_id == tracker_nft_id && asset.amount == 1) + { + return Err(TrackerBoxUpdaterError::SerializationError( + "tracker NFT is not a unique singleton at token index zero".to_string(), + )); } - output_assets.extend(other_assets); + let output_assets = tracker_box.assets.clone(); let mut outputs = vec![ serde_json::json!({ @@ -1386,42 +2100,31 @@ impl TrackerBoxUpdater { let signed_tx = Self::sign_transaction(config, unsigned_tx).await?; let tx_id = Self::signed_transaction_id(&signed_tx)?; - Ok(PreparedTrackerUpdate { signed_tx, tx_id }) - } - - /// 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 - ); - - let mut request = client.get(&url); - if let Some(ref 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()))?; - - 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 - ))) - } + 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, + }) } } @@ -1467,7 +2170,20 @@ fn change_address_to_ergo_tree(address_str: &str) -> Result (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) + } + + 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() + } + #[test] fn publication_quarantine_is_one_way() { let state = SharedTrackerState::new(); @@ -1509,6 +2280,24 @@ mod publication_health_tests { assert!(!state.is_publication_healthy()); } + #[test] + fn only_chain_observation_failures_are_retryable_after_anchor_revalidation() { + assert!(TrackerBoxUpdater::is_retryable_chain_observation_error( + &TrackerBoxUpdaterError::HttpError("node unavailable".to_string()) + )); + assert!(TrackerBoxUpdater::is_retryable_chain_observation_error( + &TrackerBoxUpdaterError::Reconciliation(ReconciliationError::StaleEvidence) + )); + assert!(!TrackerBoxUpdater::is_retryable_chain_observation_error( + &TrackerBoxUpdaterError::Reconciliation(ReconciliationError::OutcomeUnknown( + "journal persist".to_string(), + )) + )); + assert!(!TrackerBoxUpdater::is_retryable_chain_observation_error( + &TrackerBoxUpdaterError::BroadcastOutcomeUnknown("actor rejected".to_string()) + )); + } + #[test] fn updater_restores_a_complete_durable_publication_receipt() { let state = SharedTrackerState::new(); @@ -1618,4 +2407,291 @@ mod publication_health_tests { 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, shutdown_rx, None).await, + Err(TrackerBoxUpdaterError::InvalidConfiguration(_)) + )); + 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 + ); + } + } + + fn historical_confirmation() -> ConfirmedProjectionAnchor { + ConfirmedProjectionAnchor::from_parts( + "33".repeat(32), + "22".repeat(32), + "44".repeat(32), + 100, + 6, + "55".repeat(32), + [0x66; 33], + ) + } + + #[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() + ); + } + + #[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 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 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 78ce303..ace6343 100644 --- a/crates/basis_server/tests/acceptance_api_integration_tests.rs +++ b/crates/basis_server/tests/acceptance_api_integration_tests.rs @@ -808,6 +808,10 @@ async fn create_test_app_with_liability_state( 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, }, @@ -901,6 +905,10 @@ 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, }, @@ -995,6 +1003,10 @@ 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, }, diff --git a/crates/basis_server/tests/cors_tests.rs b/crates/basis_server/tests/cors_tests.rs index 31af94a..4d996e9 100644 --- a/crates/basis_server/tests/cors_tests.rs +++ b/crates/basis_server/tests/cors_tests.rs @@ -206,7 +206,8 @@ mod cors_tests { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } TrackerCommand::RecordPublicationAttempt { response_tx, .. } - | TrackerCommand::ConfirmPublication { response_tx, .. } => { + | TrackerCommand::ConfirmPublication { response_tx, .. } + | TrackerCommand::RollbackPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } TrackerCommand::AbortPublication { response_tx, .. } => { @@ -234,6 +235,10 @@ mod cors_tests { "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 75d2840..f0161d5 100644 --- a/crates/basis_server/tests/http_api_integration_tests.rs +++ b/crates/basis_server/tests/http_api_integration_tests.rs @@ -207,7 +207,8 @@ mod http_api_tests { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } TrackerCommand::RecordPublicationAttempt { response_tx, .. } - | TrackerCommand::ConfirmPublication { response_tx, .. } => { + | TrackerCommand::ConfirmPublication { response_tx, .. } + | TrackerCommand::RollbackPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } TrackerCommand::AbortPublication { response_tx, .. } => { @@ -235,6 +236,10 @@ mod http_api_tests { "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(), ), @@ -474,7 +479,14 @@ mod http_api_tests { let local_digest = response_rx.await.unwrap().unwrap().avl_root_digest; { let shared = state.shared_tracker_state.lock().await; - 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; diff --git a/crates/basis_server/tests/redemption_api_integration_tests.rs b/crates/basis_server/tests/redemption_api_integration_tests.rs index 939402a..d72f4ba 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -220,7 +220,8 @@ mod redemption_api_tests { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } TrackerCommand::RecordPublicationAttempt { response_tx, .. } - | TrackerCommand::ConfirmPublication { response_tx, .. } => { + | TrackerCommand::ConfirmPublication { response_tx, .. } + | TrackerCommand::RollbackPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } TrackerCommand::AbortPublication { response_tx, .. } => { @@ -248,6 +249,10 @@ mod redemption_api_tests { "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/tracker_box_updater_integration.rs b/crates/basis_server/tests/tracker_box_updater_integration.rs index 2c5bfa7..0e8f468 100644 --- a/crates/basis_server/tests/tracker_box_updater_integration.rs +++ b/crates/basis_server/tests/tracker_box_updater_integration.rs @@ -122,7 +122,7 @@ mod integration_tests { // Check a non-existent 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_store/src/chain_reconciliation.rs b/crates/basis_store/src/chain_reconciliation.rs new file mode 100644 index 0000000..1308aaa --- /dev/null +++ b/crates/basis_store/src/chain_reconciliation.rs @@ -0,0 +1,3482 @@ +//! 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; + +/// 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 `BlockTransactions.transactionsRoot` construction. For modern +/// blocks the witness bytes are appended to the transaction id in the same +/// Merkle leaf; they are not hashed, truncated, or inserted as separate +/// leaves. Version 1 commits only to transaction ids. +fn transaction_merkle_root( + transactions: &[Transaction], + block_version: u8, +) -> Result { + let leaves = transactions + .iter() + .map(|transaction| { + 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); + } + let mut leaf = transaction_id.to_vec(); + if block_version != 1 { + leaf.extend( + transaction + .inputs + .iter() + .flat_map(|input| input.spending_proof.proof.as_ref().iter().copied()), + ); + } + Ok(MerkleNode::from_bytes(leaf)) + }) + .collect::, ReconciliationError>>()?; + Ok(MerkleTree::new(leaves).root_hash_special()) +} + +/// Application finality policy. Depth counts successors, so the tip block has +/// depth zero. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReconciliationPolicy { + min_successor_depth: u64, + max_evidence_age_ms: u64, + reorg_monitor_depth: u64, +} + +impl ReconciliationPolicy { + pub fn new( + min_successor_depth: u64, + max_evidence_age_ms: u64, + reorg_monitor_depth: u64, + ) -> Self { + Self { + min_successor_depth, + max_evidence_age_ms, + reorg_monitor_depth, + } + } + + 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.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, +} + +#[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, +} + +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, + }) + } +} + +impl ValidatedChainEffect { + 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 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 { + 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, + }, + } +} + +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, + }); + } + Ok(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(), + }) +} + +/// 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, +} + +#[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, +} + +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, + }) + } +} + +impl ValidatedRollback { + 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 { + 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, + } +} + +pub fn validate_rollback( + accepted: &ValidatedChainEffect, + selected_chain: &ActiveChainProof, + policy: ReconciliationPolicy, + now_unix_ms: u64, +) -> Result { + 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); + } + Ok(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, + }) +} + +/// 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> { + 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, +} + +#[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, +} + +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, + }) + } +} + +/// 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 { + 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 { + return Ok(ReorgHorizonDecision::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, + })); + } + Ok(ReorgHorizonDecision::Retire(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, + })) +} + +#[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 { + 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>, + ) -> Result<(), ReconciliationError> { + let state = self.read_state()?; + let action = Self::recovery_action_from_state(&state)?; + 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 { + 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() + }) { + if !candidates.contains(&effect) { + candidates.push(effect); + } + } + 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 && !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)?; + if pending.intent.intent_id != effect.intent_id || pending.intent.tx_id != effect.tx_id + { + return Err(ReconciliationError::IntentMismatch); + } + 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); + } + state.accepted = Some(AcceptedAnchor { + 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)?; + 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)?; + 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.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 mut prior = 0u64; + for event in &state.history { + 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 + { + return Err(ReconciliationError::Journal( + "journal history is malformed".to_string(), + )); + } + prior = event.sequence; + } + if let Some(pending) = &state.pending { + pending.intent.validate()?; + if pending.phase == PendingPhase::AcceptanceReady && pending.accepted_effect.is_none() { + return Err(ReconciliationError::Journal( + "acceptance-ready ticket lacks evidence".to_string(), + )); + } + } + if let Some(accepted) = &state.accepted { + normalize_id(accepted.effect.intent_id.clone(), "accepted intent id")?; + } + 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 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("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) + } + + fn journal_binding() -> ReconciliationJournalBinding { + ReconciliationJournalBinding::tracker_v1([0xaa; 32]) + } + + fn open_test_journal(path: &Path) -> ReconciliationJournal { + ReconciliationJournal::open(path, journal_binding(), JournalBootstrap::FreshAllowed) + .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_frozen_reference_and_rejects_old_mutants() { + let (_, signed, _) = tracker_fixture(); + let transaction = parse_transaction(&signed).unwrap(); + let root_v2 = transaction_merkle_root(&[transaction.clone()], 2).unwrap(); + // Frozen from sigma-rust `ergo-chain-generation::transactions_root` + // for this exact transaction fixture. + assert_eq!( + hex::encode(root_v2.as_ref()), + "7950d92caa1b621ed4deed01f326883792f9e6505e99513daf737cc42431fa78" + ); + + let unsigned_id = blake2b256_hash(&transaction.bytes_to_sign().unwrap()); + assert_eq!(transaction.id().as_ref(), unsigned_id.as_slice()); + let root_v1 = transaction_merkle_root(&[transaction.clone()], 1).unwrap(); + assert_eq!( + root_v1, + MerkleTree::new(vec![MerkleNode::from_bytes(unsigned_id.to_vec())]).root_hash_special() + ); + + let witness = transaction + .inputs + .iter() + .flat_map(|input| input.spending_proof.proof.as_ref().iter().copied()) + .collect::>(); + let separated = MerkleTree::new(vec![ + MerkleNode::from_bytes(unsigned_id.to_vec()), + MerkleNode::from_bytes(blake2b256_hash(&witness)[1..].to_vec()), + ]) + .root_hash_special(); + let mut hashed_leaf = unsigned_id.to_vec(); + hashed_leaf.extend_from_slice(&blake2b256_hash(&witness)[1..]); + let hashed = MerkleTree::new(vec![MerkleNode::from_bytes(hashed_leaf)]).root_hash_special(); + let mut truncated_leaf = unsigned_id.to_vec(); + truncated_leaf.extend_from_slice(&witness[1..]); + let truncated = + MerkleTree::new(vec![MerkleNode::from_bytes(truncated_leaf)]).root_hash_special(); + assert_ne!(root_v2, separated); + assert_ne!(root_v2, hashed); + assert_ne!(root_v2, truncated); + } + + #[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); + 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)), + 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) + .is_ok()); + assert_eq!( + acceptance.validate_tracker_startup_join(None, None), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + assert!(acceptance + .validate_tracker_startup_join(None, Some(&projection)) + .is_ok()); + assert_eq!( + acceptance.validate_tracker_startup_join(Some(&("aa".repeat(32), pending.1)), None,), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + assert_eq!( + acceptance.validate_tracker_startup_join(Some(&(pending.0.clone(), [0x99; 33])), None,), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + + acceptance.mark_applied(&effect).unwrap(); + assert_eq!( + acceptance.validate_tracker_startup_join(None, None), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + assert!(acceptance + .validate_tracker_startup_join(None, Some(&projection)) + .is_ok()); + } + + #[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) + .is_ok()); + assert_eq!( + journal.validate_tracker_startup_join(None, None), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + assert_eq!( + journal.validate_tracker_startup_join(Some(&("aa".repeat(32), pending_b.1)), None,), + 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/lib.rs b/crates/basis_store/src/lib.rs index cfc909d..ae3447a 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -1,6 +1,7 @@ //! Core data structures for Basis tracker pub mod avl_tree; +pub mod chain_reconciliation; pub mod contract_compiler; #[cfg(test)] @@ -123,6 +124,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, } @@ -136,6 +152,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, } } @@ -143,17 +164,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() } } @@ -163,6 +208,86 @@ 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]; @@ -942,11 +1067,12 @@ impl TrackerStateManager { }); record.status = if durable_pending { NoteConfirmationStatus::Pending - } else if Some(local_value) == record.confirmed_total_debt { - NoteConfirmationStatus::Confirmed } 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 }; @@ -986,6 +1112,96 @@ impl TrackerStateManager { self.confirmations.clone() } + /// 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 /// `Pending`, recording the value that the in-flight update transaction will /// commit. Returns the number of notes transitioned to `Pending`. @@ -1064,23 +1280,78 @@ impl TrackerStateManager { /// Confirm exactly the durable publication receipt observed on the active /// chain. A different transaction cannot release the publication fence. - pub fn confirm_pending_publication( + #[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())? - .ok_or(NoteError::PublicationLeaseMismatch)?; - if !pending.tx_id.eq_ignore_ascii_case(tx_id) { - return Err(NoteError::PublicationLeaseMismatch); - } - if pending.digest != snapshot.avl_root_digest { + 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; // The publication receipt is persisted before the per-note advisory @@ -1100,12 +1371,22 @@ impl TrackerStateManager { || 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_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)?; @@ -1119,11 +1400,141 @@ 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) + } + /// Revert every `Pending` note back to its prior state (used when an update /// transaction is dropped or rejected). Clears pending metadata and recomputes /// the status from the local value versus the confirmed value. Returns the @@ -1174,7 +1585,8 @@ 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, diff --git a/crates/basis_store/src/persistence.rs b/crates/basis_store/src/persistence.rs index 3601e5c..98b3f1b 100644 --- a/crates/basis_store/src/persistence.rs +++ b/crates/basis_store/src/persistence.rs @@ -1,9 +1,9 @@ //! Persistence layer for a bounded, versioned IOU-note state snapshot. use crate::{ - blake2b256_hash, reserve_tracker::ExtendedReserveInfo, FreshGenerationApproval, IouNote, - NoteConfirmation, NoteError, NoteKey, PendingTrackerPublication, PubKey, TrackerBoxInfo, - TrackerGenerationConfig, + blake2b256_hash, reserve_tracker::ExtendedReserveInfo, ConfirmedProjectionAnchor, + FreshGenerationApproval, IouNote, NoteConfirmation, NoteError, NoteKey, + PendingTrackerPublication, PubKey, TrackerBoxInfo, TrackerGenerationConfig, }; use fjall::{Config, Keyspace, PartitionCreateOptions, PersistMode}; use fs2::FileExt; @@ -21,11 +21,14 @@ 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; @@ -33,6 +36,8 @@ 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 { @@ -864,6 +869,77 @@ impl NoteStorage { }) } + 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, + )) + } + pub(crate) fn pending_publication( &self, ) -> Result, NoteError> { @@ -942,6 +1018,66 @@ impl NoteStorage { }) } + 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() + } + + 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}" + )) + }) + } + + 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}" + )) + }) + } + /// Store an IOU note with its issuer public key pub(crate) fn store_note( &self, diff --git a/crates/basis_store/src/tests.rs b/crates/basis_store/src/tests.rs index 32af04f..ed937a1 100644 --- a/crates/basis_store/src/tests.rs +++ b/crates/basis_store/src/tests.rs @@ -691,6 +691,244 @@ 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 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(); @@ -782,8 +1020,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, }; diff --git a/specs/confirmed_chain_reconciliation.md b/specs/confirmed_chain_reconciliation.md new file mode 100644 index 0000000..22781fb --- /dev/null +++ b/specs/confirmed_chain_reconciliation.md @@ -0,0 +1,151 @@ +# 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 +spending-proof bytes of all inputs are appended to that same transaction-id +leaf. Witnesses are not separate leaves, hashed independently, or truncated. + +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. + +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. + +If the actor receipt exists at `AcceptanceReady`, its transaction id and root +must exactly match the journal. 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. + +## Integration dependency: bounded node responses + +This change is not a standalone service-resource-bounds closure. It must be +integrated with the bounded-node-request work rooted at commit `248929c` before +deployment. In this module, every reconciler evidence request flows through +`get_node_bytes`: `/info`, `/blocks/chainSlice`, +`/blockchain/transaction/byId`, `/blocks/{id}`, and +`/blockchain/box/byId`. Its `send` followed by `bytes` needs the shared bounded +body reader. The transaction-production side also has direct node response +reads after `send` in `find_tracker_box`, `get_wallet_boxes`, `get_box_binary`, +`get_node_height`, `sign_transaction`, and `broadcast_transaction`; those JSON, +text, and error-body reads must consume the same service-bounds abstraction. From 97c2640e6db1c25b1f7f9e6307051ef4e6f42a82 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:06:44 +0200 Subject: [PATCH 13/41] fix: harden Basis v2 runtime admission --- config/basis.toml.example | 2 +- crates/basis_core/src/basis_v2.rs | 366 ++++++++++++++++-- crates/basis_server/src/config.rs | 25 +- .../basis_server/src/create_reserve_tests.rs | 47 ++- crates/basis_server/src/main.rs | 47 +-- .../contracts/basis-v2-provenance.json | 3 + crates/basis_store/src/contract_compiler.rs | 9 + crates/basis_store/src/ergo_scanner.rs | 100 ++++- .../basis_store/src/reserve_tracking_test.rs | 18 +- specs/basis_v2_runtime.md | 10 +- specs/security_boundary_remediation.md | 5 +- specs/server/basis_server_spec.md | 5 +- 12 files changed, 536 insertions(+), 101 deletions(-) diff --git a/config/basis.toml.example b/config/basis.toml.example index 7416629..3eb352b 100644 --- a/config/basis.toml.example +++ b/config/basis.toml.example @@ -10,7 +10,7 @@ database_url = "sqlite:data/basis.db" [ergo] # The historical reserve identity is the temporary compatibility default. All # reserve construction is disabled; this is not a legacy-safety endorsement. -# The exact audited Basis v2 identity is embedded, but runtime activation fails +# The reviewed and 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 diff --git a/crates/basis_core/src/basis_v2.rs b/crates/basis_core/src/basis_v2.rs index 3ad81ed..0bea451 100644 --- a/crates/basis_core/src/basis_v2.rs +++ b/crates/basis_core/src/basis_v2.rs @@ -9,6 +9,7 @@ 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. @@ -52,6 +53,10 @@ pub enum BasisV2Error { 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 { @@ -68,13 +73,27 @@ pub enum ReserveAssetV2 { } /// 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 { - pub reserve_nft_id: [u8; 32], - pub tracker_nft_id: [u8; 32], - pub owner_pubkey: PubKey, - pub receiver_pubkey: PubKey, - pub asset: ReserveAssetV2, + reserve_nft_id: [u8; 32], + tracker_nft_id: [u8; 32], + owner_pubkey: PubKey, + receiver_pubkey: PubKey, + asset: ReserveAssetV2, } impl ClaimDomainV2 { @@ -134,6 +153,26 @@ impl ClaimDomainV2 { }) } + 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(); @@ -170,12 +209,15 @@ impl ClaimDomainV2 { } /// 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 { - pub domain: ClaimDomainV2, - pub total_debt: u64, - pub timestamp: u64, - pub signature: Signature, + domain: ClaimDomainV2, + total_debt: u64, + timestamp: u64, + signature: Signature, } impl ClaimV2 { @@ -185,8 +227,15 @@ impl ClaimV2 { 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, &domain.owner_pubkey)?; + let signature = schnorr_sign(&message, &owner_secret.secret_bytes(), &domain.owner_pubkey)?; Ok(Self { domain, total_debt, @@ -195,12 +244,63 @@ impl ClaimV2 { }) } + /// 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()?; + // 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 self.signature[33] & 0x80 != 0 { + return Err(BasisV2Error::NonCanonicalSignature); + } + let mut challenge = Blake2b::::new(); + challenge.update(&self.signature[..33]); + challenge.update(message); + challenge.update(self.domain.owner_pubkey); + if challenge.finalize()[0] & 0x80 != 0 { + return Err(BasisV2Error::NonCanonicalSignature); + } SchnorrVerifier .verify_signature(&self.signature, &message, &self.domain.owner_pubkey) .map_err(BasisV2Error::from) @@ -208,11 +308,16 @@ impl ClaimV2 { } /// 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 { - pub timestamp: u64, - pub total_debt: u64, - pub redeemed: u64, + timestamp: u64, + total_debt: u64, + redeemed: u64, } impl RedeemedStateV2 { @@ -238,6 +343,18 @@ impl RedeemedStateV2 { 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); @@ -303,8 +420,6 @@ fn decode_non_negative_long(bytes: &[u8]) -> Result { #[cfg(test)] mod tests { use super::*; - use secp256k1::{PublicKey, Secp256k1, SecretKey}; - fn key(secret: u8) -> ([u8; 32], PubKey) { let mut bytes = [0u8; 32]; bytes[31] = secret; @@ -313,6 +428,68 @@ mod tests { (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() } @@ -327,11 +504,11 @@ mod tests { "656c938601a973fe7dd8b5984b70430bf2c885b69a0d91268b0f5c4383a02d73" ); let token = ClaimDomainV2::token( - base.reserve_nft_id, + base.reserve_nft_id(), [5u8; 32], - base.tracker_nft_id, - base.owner_pubkey, - base.receiver_pubkey, + base.tracker_nft_id(), + base.owner_pubkey(), + base.receiver_pubkey(), ) .unwrap(); assert_eq!( @@ -347,44 +524,95 @@ mod tests { let mutations = [ ClaimDomainV2::erg( [3u8; 32], - base.tracker_nft_id, - base.owner_pubkey, - base.receiver_pubkey, + base.tracker_nft_id(), + base.owner_pubkey(), + base.receiver_pubkey(), ) .unwrap(), ClaimDomainV2::erg( - base.reserve_nft_id, + base.reserve_nft_id(), [4u8; 32], - base.owner_pubkey, - base.receiver_pubkey, + base.owner_pubkey(), + base.receiver_pubkey(), ) .unwrap(), ClaimDomainV2::erg( - base.reserve_nft_id, - base.tracker_nft_id, + base.reserve_nft_id(), + base.tracker_nft_id(), key(3).1, - base.receiver_pubkey, + base.receiver_pubkey(), ) .unwrap(), ClaimDomainV2::erg( - base.reserve_nft_id, - base.tracker_nft_id, - base.owner_pubkey, + base.reserve_nft_id(), + base.tracker_nft_id(), + base.owner_pubkey(), key(4).1, ) .unwrap(), ClaimDomainV2::token( - base.reserve_nft_id, + base.reserve_nft_id(), [5u8; 32], - base.tracker_nft_id, - base.owner_pubkey, - base.receiver_pubkey, + 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] @@ -394,9 +622,51 @@ mod tests { let claim = ClaimV2::sign(domain, 100, 10, &owner_secret).unwrap(); claim.verify().unwrap(); - let mut wrong = claim.clone(); - wrong.domain.reserve_nft_id = [9u8; 32]; - assert_eq!(wrong.verify(), Err(BasisV2Error::InvalidSignature)); + let wrong_domain = ClaimDomainV2::erg( + [9u8; 32], + domain.tracker_nft_id(), + domain.owner_pubkey(), + domain.receiver_pubkey(), + ) + .unwrap(); + assert!(matches!( + ClaimV2::from_signed( + wrong_domain, + claim.total_debt(), + claim.timestamp(), + *claim.signature(), + ), + Err(BasisV2Error::InvalidSignature | BasisV2Error::NonCanonicalSignature) + )); + } + + #[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] @@ -414,14 +684,30 @@ mod tests { 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(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!( diff --git a/crates/basis_server/src/config.rs b/crates/basis_server/src/config.rs index 913f64a..d410de0 100644 --- a/crates/basis_server/src/config.rs +++ b/crates/basis_server/src/config.rs @@ -141,6 +141,16 @@ impl AppConfig { config.try_deserialize() } + /// 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) @@ -170,8 +180,9 @@ impl AppConfig { /// 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; every construction route remains a - /// tombstone and this does not attest safety of legacy acceptance state. + /// temporarily for compatibility; the server HTTP construction and P2S + /// distribution routes 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() @@ -484,6 +495,16 @@ mod tests { .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 { diff --git a/crates/basis_server/src/create_reserve_tests.rs b/crates/basis_server/src/create_reserve_tests.rs index abcd9fe..db60f6f 100644 --- a/crates/basis_server/src/create_reserve_tests.rs +++ b/crates/basis_server/src/create_reserve_tests.rs @@ -5,7 +5,10 @@ mod create_reserve_tests { use tokio::sync::Mutex; use crate::{ - api::create_reserve_payload, models::CreateReserveRequest, AppState, TrackerCommand, + api::{create_reserve_payload, get_basis_reserve_contract_p2s}, + models::CreateReserveRequest, + redemption_build::{build_redemption, RedemptionBuildRequest}, + AppState, TrackerCommand, }; use basis_store::ergo_scanner::{NodeConfig, ServerState}; @@ -180,6 +183,48 @@ mod create_reserve_tests { .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 (p2s_status, p2s_response) = + get_basis_reserve_contract_p2s(State(state.clone())).await; + assert_eq!(p2s_status, StatusCode::SERVICE_UNAVAILABLE); + assert!(!p2s_response.success); + + let build_request = RedemptionBuildRequest { + issuer_pubkey: "02".to_string(), + recipient_pubkey: "03".to_string(), + amount: 1, + timestamp: 1, + issuer_signature: String::new(), + emergency: false, + tracker_box_id: None, + }; + let (build_status, build_response) = + build_redemption(State(state.clone()), Json(build_request)).await; + assert_eq!(build_status, StatusCode::SERVICE_UNAVAILABLE); + assert!(!build_response.success); + + let create_request = CreateReserveRequest { + nft_id: "12".repeat(32), + owner_pubkey: "03e8c3e4877e2f7b79e0e407421a81a1619ea64e37e5e4e77454d1e361e6f80b12" + .to_string(), + erg_amount: 1_000_000, + }; + let (create_status, create_response) = + create_reserve_payload(State(state), Json(create_request)).await; + assert_eq!(create_status, StatusCode::SERVICE_UNAVAILABLE); + assert!(!create_response.success); + } + } + #[tokio::test] async fn test_create_reserve_payload_invalid_pubkey() { let state = create_test_app_state(); diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 7d37bde..46cf2b4 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -4,8 +4,8 @@ use axum::{ }; use basis_server::{ api::*, build_redemption, reserve_api::*, store::EventStore, submit_redemption, AppConfig, - AppState, ErgoConfig, EventType, PublicationLease, ServerConfig, SharedTrackerState, - TrackerBoxUpdateConfig, TrackerBoxUpdater, TrackerCommand, TrackerEvent, TransactionConfig, + AppState, EventType, PublicationLease, SharedTrackerState, TrackerBoxUpdateConfig, + TrackerBoxUpdater, TrackerCommand, TrackerEvent, }; use basis_store::{ ergo_scanner::{start_scanner, NodeConfig, ReserveEvent, ServerState}, @@ -180,45 +180,10 @@ 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: - basis_store::contract_compiler::get_basis_reserve_contract_p2s() - .expect("historical read-only contract identity must decode"), - tracker_nft_id: None, - allow_fresh_tracker_generation: false, - 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); + }); if let Err(e) = config.validate_runtime_contract_mode() { tracing::error!("{}", e); diff --git a/crates/basis_store/contracts/basis-v2-provenance.json b/crates/basis_store/contracts/basis-v2-provenance.json index b4a720a..7ce93b2 100644 --- a/crates/basis_store/contracts/basis-v2-provenance.json +++ b/crates/basis_store/contracts/basis-v2-provenance.json @@ -1,8 +1,11 @@ { "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", diff --git a/crates/basis_store/src/contract_compiler.rs b/crates/basis_store/src/contract_compiler.rs index f5a3f03..729881c 100644 --- a/crates/basis_store/src/contract_compiler.rs +++ b/crates/basis_store/src/contract_compiler.rs @@ -120,10 +120,19 @@ mod tests { 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!( diff --git a/crates/basis_store/src/ergo_scanner.rs b/crates/basis_store/src/ergo_scanner.rs index 7c19821..25402fb 100644 --- a/crates/basis_store/src/ergo_scanner.rs +++ b/crates/basis_store/src/ergo_scanner.rs @@ -174,6 +174,16 @@ impl ServerState { /// Create a server state that uses real Ergo scanner pub fn new(config: NodeConfig, data_dir: impl AsRef) -> Result { + if let Some(configured) = config.reserve_contract_p2s.as_deref() { + 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(); @@ -446,7 +456,7 @@ impl ServerState { } /// Parse reserve box into ExtendedReserveInfo - pub fn parse_reserve_box( + pub(crate) fn parse_reserve_box( &self, scan_box: &ScanBox, ) -> Result { @@ -454,6 +464,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 @@ -843,6 +868,10 @@ mod tests { use super::*; use std::collections::HashMap; + fn historical_tree() -> String { + crate::contract_compiler::get_basis_reserve_ergo_tree_hex().unwrap() + } + #[test] fn node_config_debug_redacts_api_key() { let sentinel = "sentinel-node-api-key-do-not-log"; @@ -873,7 +902,7 @@ 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![], @@ -932,7 +961,7 @@ 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![], @@ -985,7 +1014,7 @@ 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![], @@ -1022,4 +1051,67 @@ mod tests { } } } + + #[test] + fn scanner_rejects_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(); + for configured in [exact_v2, "unknown-generation".to_string()] { + let config = NodeConfig { + reserve_contract_p2s: Some(configured), + ..NodeConfig::default() + }; + assert!(matches!( + ServerState::new(config, &root), + Err(ScannerError::Generic(message)) if message.contains("generation is unsupported") + )); + assert!(!root.exists()); + } + } + + #[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(NodeConfig::default(), &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/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/specs/basis_v2_runtime.md b/specs/basis_v2_runtime.md index 4c44893..02b0859 100644 --- a/specs/basis_v2_runtime.md +++ b/specs/basis_v2_runtime.md @@ -82,9 +82,13 @@ that supplies all of the following as one coherent manifest: - reserve-NFT-specific proof/root/box lineage and an idempotent confirmed-chain settlement record. -Until that join exists, startup rejects the exact v2 P2S and reserve creation, -P2S distribution, and redemption build endpoints fail closed. This prevents a -v1-shaped scanner or transaction from being presented as a v2 operation. +Until that join exists, normal server startup rejects the exact v2 P2S and the +reserve creation, P2S distribution, and HTTP redemption-build endpoints fail +closed. The v1 scanner constructor also rejects exact v2 and unknown configured +identities, and its internal parser checks the historical ErgoTree before +decoding registers. The older public library transaction builder remains a +separate legacy-quarantine dependency; this foundation does not claim that all +v1 code has been removed. ## Coexistence and migration diff --git a/specs/security_boundary_remediation.md b/specs/security_boundary_remediation.md index 679b594..87e5938 100644 --- a/specs/security_boundary_remediation.md +++ b/specs/security_boundary_remediation.md @@ -29,7 +29,8 @@ caller to exercise tracker-owned capabilities or to assert settlement state. 7. The exact committed Basis v2 ERG ErgoTree is recognized, but startup rejects its activation until the v2 scanner and BNS2/BRS2 state are installed. The historical identity remains only as a compatibility mode; reserve creation, - P2S distribution, and redemption builders remain disabled. + server P2S distribution and HTTP redemption builders remain disabled. The + older public library builder is a separate legacy-quarantine workstream. ## Compatibility changes @@ -42,7 +43,7 @@ caller to exercise tracker-owned capabilities or to assert settlement state. | `POST /redeem/complete` | Returns `410 Gone`. | | `note redeem` / MCP `note_redeem` | Return an error before effects; no boolean reactivation path remains. | | Non-local `transaction generate-redemption` | Returns an error; use `--local-sign` or the assisted signer. | -| Reserve P2S / builders | Historical identity: temporary compatibility only, not a safety endorsement. Exact v2 identity: recognized but startup-disabled. Unknown identity: rejected. All construction/P2S distribution routes return `503 Service Unavailable`. | +| Server reserve P2S / HTTP builders | Historical identity: temporary compatibility only, not a safety endorsement. Exact v2 identity: recognized but startup-disabled. Unknown identity: rejected. Server construction/P2S distribution routes return `503 Service Unavailable`; legacy library builders are not covered by this branch. | ## Settlement hand-off diff --git a/specs/server/basis_server_spec.md b/specs/server/basis_server_spec.md index 7a2eaa7..ba00f6a 100644 --- a/specs/server/basis_server_spec.md +++ b/specs/server/basis_server_spec.md @@ -196,8 +196,9 @@ The server now implements real cryptographic functionality using the Ergo node's The server provides an endpoint to generate reserve creation payloads for Ergo node's `/wallet/payment/send` API: -The endpoint currently returns `503 Service Unavailable` for every -configuration. Startup retains the historical identity only for compatibility +An otherwise valid request currently returns `503 Service Unavailable` for +every configured contract identity. Malformed payloads may be rejected earlier +with `400 Bad Request`. Startup retains the historical identity only for compatibility and rejects activation of the recognized, byte-exact v2 ERG tree until its scanner and BNS2/BRS2 stores exist. The payload code remains unreachable until a v2 builder supplies R4-R9, fixed 32/24 reserve state, a From d063d31153229894322ec1aac8e3a355f4c96a06 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:46:24 +0200 Subject: [PATCH 14/41] fix: close Basis v2 runtime admission gaps --- config/basis.toml.example | 9 +- crates/basis_core/src/basis_v2.rs | 59 ++++++---- crates/basis_server/src/config.rs | 2 +- .../basis_server/src/create_reserve_tests.rs | 97 +++++++++------- crates/basis_server/src/lib.rs | 32 ++++++ crates/basis_server/src/main.rs | 47 ++------ .../tests/acceptance_api_integration_tests.rs | 9 ++ crates/basis_server/tests/cors_tests.rs | 3 + .../tests/http_api_integration_tests.rs | 3 + .../tests/redemption_api_integration_tests.rs | 3 + crates/basis_store/src/contract_compiler.rs | 4 +- crates/basis_store/src/ergo_scanner.rs | 106 +++++++++++++++--- .../src/real_scanner_integration_tests.rs | 4 + .../src/simple_integration_tests.rs | 13 ++- specs/server/redemption_state_spec.md | 2 +- 15 files changed, 264 insertions(+), 129 deletions(-) diff --git a/config/basis.toml.example b/config/basis.toml.example index 3eb352b..3e23339 100644 --- a/config/basis.toml.example +++ b/config/basis.toml.example @@ -8,10 +8,11 @@ data_dir = "data" database_url = "sqlite:data/basis.db" [ergo] -# The historical reserve identity is the temporary compatibility default. All -# reserve construction is disabled; this is not a legacy-safety endorsement. -# The reviewed and pinned Basis v2 candidate identity is embedded, but runtime activation fails -# closed until the v2 scanner and BNS2/BRS2 stores are installed. +# The historical reserve identity is the temporary compatibility default. The +# server HTTP construction and P2S routes 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" diff --git a/crates/basis_core/src/basis_v2.rs b/crates/basis_core/src/basis_v2.rs index 0bea451..baab42d 100644 --- a/crates/basis_core/src/basis_v2.rs +++ b/crates/basis_core/src/basis_v2.rs @@ -494,6 +494,21 @@ mod tests { 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"); @@ -617,27 +632,29 @@ mod tests { #[test] fn signed_claim_is_bound_to_its_exact_domain() { - let (owner_secret, owner) = key(1); - let domain = ClaimDomainV2::erg([1u8; 32], [2u8; 32], owner, key(2).1).unwrap(); - let claim = ClaimV2::sign(domain, 100, 10, &owner_secret).unwrap(); - claim.verify().unwrap(); - - let wrong_domain = ClaimDomainV2::erg( - [9u8; 32], - domain.tracker_nft_id(), - domain.owner_pubkey(), - domain.receiver_pubkey(), - ) - .unwrap(); - assert!(matches!( - ClaimV2::from_signed( - wrong_domain, - claim.total_debt(), - claim.timestamp(), - *claim.signature(), - ), - Err(BasisV2Error::InvalidSignature | BasisV2Error::NonCanonicalSignature) - )); + 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] diff --git a/crates/basis_server/src/config.rs b/crates/basis_server/src/config.rs index d410de0..eb2e8da 100644 --- a/crates/basis_server/src/config.rs +++ b/crates/basis_server/src/config.rs @@ -168,7 +168,7 @@ impl AppConfig { &self.ergo.basis_reserve_contract_p2s } - /// Require the exact ChainCash-reviewed Basis v2 ERG contract identity. + /// 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(), diff --git a/crates/basis_server/src/create_reserve_tests.rs b/crates/basis_server/src/create_reserve_tests.rs index db60f6f..5fc8afc 100644 --- a/crates/basis_server/src/create_reserve_tests.rs +++ b/crates/basis_server/src/create_reserve_tests.rs @@ -1,16 +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, get_basis_reserve_contract_p2s}, - models::CreateReserveRequest, - redemption_build::{build_redemption, RedemptionBuildRequest}, + api::create_reserve_payload, models::CreateReserveRequest, reserve_construction_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 { @@ -37,6 +41,9 @@ mod create_reserve_tests { // 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() }; @@ -50,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 { @@ -193,35 +194,49 @@ mod create_reserve_tests { for configured in [exact_v2, legacy, "unknown-generation".to_string()] { let state = create_test_app_state_with_p2s(configured); - let (p2s_status, p2s_response) = - get_basis_reserve_contract_p2s(State(state.clone())).await; - assert_eq!(p2s_status, StatusCode::SERVICE_UNAVAILABLE); - assert!(!p2s_response.success); - - let build_request = RedemptionBuildRequest { - issuer_pubkey: "02".to_string(), - recipient_pubkey: "03".to_string(), - amount: 1, - timestamp: 1, - issuer_signature: String::new(), - emergency: false, - tracker_box_id: None, - }; - let (build_status, build_response) = - build_redemption(State(state.clone()), Json(build_request)).await; - assert_eq!(build_status, StatusCode::SERVICE_UNAVAILABLE); - assert!(!build_response.success); - - let create_request = CreateReserveRequest { - nft_id: "12".repeat(32), - owner_pubkey: "03e8c3e4877e2f7b79e0e407421a81a1619ea64e37e5e4e77454d1e361e6f80b12" - .to_string(), - erg_amount: 1_000_000, - }; - let (create_status, create_response) = - create_reserve_payload(State(state), Json(create_request)).await; - assert_eq!(create_status, StatusCode::SERVICE_UNAVAILABLE); - assert!(!create_response.success); + 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().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(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{path}"); + } } } diff --git a/crates/basis_server/src/lib.rs b/crates/basis_server/src/lib.rs index e0c082b..2317aca 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -12,6 +12,10 @@ pub mod tracker_box_updater; #[cfg(test)] mod create_reserve_tests; +use axum::{ + routing::{get, post}, + Router, +}; use tokio::sync::Mutex; // Re-export main types for external use @@ -53,6 +57,34 @@ pub struct PublicationLease { 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", "*")], + "", + ) +} + +/// 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( + "/redemption/build", + post(redemption_build::build_redemption).options(handle_options), + ) + .route( + "/config/reserve-contract-p2s", + get(api::get_basis_reserve_contract_p2s), + ) +} + // Commands that can be sent to the tracker thread #[derive(Debug)] pub enum TrackerCommand { diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 46cf2b4..20b1f03 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, EventType, PublicationLease, SharedTrackerState, TrackerBoxUpdateConfig, + api::*, handle_options, reserve_api::*, reserve_construction_routes, store::EventStore, + submit_redemption, 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, }; @@ -227,20 +228,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 { @@ -954,19 +945,11 @@ async fn main() { "/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), @@ -985,10 +968,7 @@ 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(reserve_construction_routes()) .with_state(app_state.clone()) .layer(tower_http::trace::TraceLayer::new_for_http()) .layer( @@ -1368,15 +1348,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/tests/acceptance_api_integration_tests.rs b/crates/basis_server/tests/acceptance_api_integration_tests.rs index 78ce303..07b77ab 100644 --- a/crates/basis_server/tests/acceptance_api_integration_tests.rs +++ b/crates/basis_server/tests/acceptance_api_integration_tests.rs @@ -823,6 +823,9 @@ async fn create_test_app_with_liability_state( 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, @@ -916,6 +919,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, @@ -1010,6 +1016,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/cors_tests.rs b/crates/basis_server/tests/cors_tests.rs index 31af94a..324602b 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() }; diff --git a/crates/basis_server/tests/http_api_integration_tests.rs b/crates/basis_server/tests/http_api_integration_tests.rs index 75d2840..fffd9c8 100644 --- a/crates/basis_server/tests/http_api_integration_tests.rs +++ b/crates/basis_server/tests/http_api_integration_tests.rs @@ -36,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( diff --git a/crates/basis_server/tests/redemption_api_integration_tests.rs b/crates/basis_server/tests/redemption_api_integration_tests.rs index 939402a..e6caac7 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -55,6 +55,9 @@ mod redemption_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( diff --git a/crates/basis_store/src/contract_compiler.rs b/crates/basis_store/src/contract_compiler.rs index 729881c..f748e33 100644 --- a/crates/basis_store/src/contract_compiler.rs +++ b/crates/basis_store/src/contract_compiler.rs @@ -9,8 +9,8 @@ use ergo_lib::ergotree_ir::serialization::SigmaSerializable; 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 reviewed -/// ChainCash receipt. It is evidence metadata, not an activation flag. +/// 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)] diff --git a/crates/basis_store/src/ergo_scanner.rs b/crates/basis_store/src/ergo_scanner.rs index 25402fb..dc9edb1 100644 --- a/crates/basis_store/src/ergo_scanner.rs +++ b/crates/basis_store/src/ergo_scanner.rs @@ -174,15 +174,19 @@ impl ServerState { /// Create a server state that uses real Ergo scanner pub fn new(config: NodeConfig, data_dir: impl AsRef) -> Result { - if let Some(configured) = config.reserve_contract_p2s.as_deref() { - 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 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(); @@ -872,6 +876,15 @@ mod tests { 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"; @@ -909,7 +922,7 @@ mod tests { }; // 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() @@ -968,7 +981,7 @@ mod tests { }; // 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() @@ -1021,7 +1034,7 @@ mod tests { }; // 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() @@ -1053,7 +1066,7 @@ mod tests { } #[test] - fn scanner_rejects_v2_and_unknown_generations_before_opening_storage() { + 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(), @@ -1067,19 +1080,78 @@ mod tests { crate::contract_compiler::BasisV2ContractKind::Erg, ) .unwrap(); - for configured in [exact_v2, "unknown-generation".to_string()] { + 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: Some(configured), + reserve_contract_p2s: configured, ..NodeConfig::default() }; assert!(matches!( ServerState::new(config, &root), - Err(ScannerError::Generic(message)) if message.contains("generation is unsupported") + 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(); @@ -1108,7 +1180,7 @@ mod tests { .as_nanos() )); let _ = std::fs::remove_dir_all(&root); - let state = ServerState::new(NodeConfig::default(), &root).unwrap(); + 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/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/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/specs/server/redemption_state_spec.md b/specs/server/redemption_state_spec.md index ec73eb7..7ed73ec 100644 --- a/specs/server/redemption_state_spec.md +++ b/specs/server/redemption_state_spec.md @@ -489,7 +489,7 @@ semantics: - Acceptance policies can include a `no_pending_refund` predicate to reject notes backed by a reserve with a non-zero R7 refund height. The runtime now pins the full byte-exact Basis v2 ERG and token ErgoTrees from -the reviewed ChainCash source receipt. Unknown identities and any one-byte +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 From 4974740a35b44cb6bb2057367d966580ca1463bf Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:13:00 +0200 Subject: [PATCH 15/41] fix: make nested environment config fail closed --- config/basis.toml.example | 5 +- crates/basis_server/src/config.rs | 181 +++++++++++++++++++++++++----- docs/BUILD_INSTALL.md | 2 +- docs/CONFIGURATION.md | 24 ++-- specs/server/basis_server_spec.md | 2 +- 5 files changed, 167 insertions(+), 47 deletions(-) diff --git a/config/basis.toml.example b/config/basis.toml.example index 3e23339..36ea514 100644 --- a/config/basis.toml.example +++ b/config/basis.toml.example @@ -9,8 +9,9 @@ database_url = "sqlite:data/basis.db" [ergo] # The historical reserve identity is the temporary compatibility default. The -# server HTTP construction and P2S routes are disabled; this is not a claim -# about legacy library prototypes and not a legacy-safety endorsement. +# `/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 diff --git a/crates/basis_server/src/config.rs b/crates/basis_server/src/config.rs index eb2e8da..28a4cf5 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 { @@ -96,51 +98,66 @@ 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() - } - - /// Load configuration from default locations - pub fn load() -> Result { + 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()))?; - 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 + .set_default("ergo.node.api_key", "")? .set_default("ergo.basis_reserve_contract_p2s", legacy_read_only_p2s)? - // Transaction configuration defaults - .set_default("transaction.fee", 1000000)? // 0.001 ERG - // Tracker public key (optional) + .set_default("transaction.fee", 1000000)? .set_default("ergo.tracker_public_key", "")? - // Tracker secret key (optional - for local signing) .set_default("ergo.tracker_secret_key", "")? - .set_default("ergo.allow_fresh_tracker_generation", false)? - // 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 { @@ -180,9 +197,10 @@ impl AppConfig { /// 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; the server HTTP construction and P2S - /// distribution routes remain tombstones. This does not attest safety of - /// legacy library builders or acceptance state. + /// 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() @@ -412,6 +430,107 @@ 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"; diff --git a/docs/BUILD_INSTALL.md b/docs/BUILD_INSTALL.md index 71d94cc..882eea5 100644 --- a/docs/BUILD_INSTALL.md +++ b/docs/BUILD_INSTALL.md @@ -202,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) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index fcbd1bf..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,7 +24,7 @@ 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 @@ -104,16 +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_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_URL="http://your-node:9053" +export BASIS_ERGO__ALLOW_FRESH_TRACKER_GENERATION="true" +export BASIS_ERGO__NODE__NODE_URL="http://your-node:9053" ``` ## Tracker Public Key Configuration diff --git a/specs/server/basis_server_spec.md b/specs/server/basis_server_spec.md index ba00f6a..c932ce4 100644 --- a/specs/server/basis_server_spec.md +++ b/specs/server/basis_server_spec.md @@ -259,7 +259,7 @@ 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. + 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 From 43c9c9c17bffdb0e52b4bc72be5b13af87367518 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:10:23 +0200 Subject: [PATCH 16/41] feat: add fixed Basis v2 AVL trees --- crates/basis_trees/src/fixed_avl.rs | 447 ++++++++++++++++++++++++++++ crates/basis_trees/src/lib.rs | 2 + 2 files changed, 449 insertions(+) create mode 100644 crates/basis_trees/src/fixed_avl.rs diff --git a/crates/basis_trees/src/fixed_avl.rs b/crates/basis_trees/src/fixed_avl.rs new file mode 100644 index 0000000..499b855 --- /dev/null +++ b/crates/basis_trees/src/fixed_avl.rs @@ -0,0 +1,447 @@ +//! 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. + +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 { + ergo_avltree_rust::batch_node::Node::Leaf(ergo_avltree_rust::batch_node::LeafNode { + hdr: ergo_avltree_rust::batch_node::NodeHeader { + visited: false, + is_new: false, + label: None, + key: Some(ergo_avltree_rust::operation::ADKey::from(vec![ + 0u8; + BASIS_V2_KEY_LENGTH + ])), + }, + value: ergo_avltree_rust::operation::ADValue::from(vec![]), + next_node_key: ergo_avltree_rust::operation::ADKey::from(vec![0u8; BASIS_V2_KEY_LENGTH]), + }) +} + +/// 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 + } +} + +/// Mandatory membership or non-membership lookup evidence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LookupWitness { + proof: Vec, + value: Option<[u8; VALUE_LEN]>, +} + +impl LookupWitness { + pub fn proof(&self) -> &[u8] { + &self.proof + } + + pub const fn value(&self) -> Option<[u8; VALUE_LEN]> { + self.value + } +} + +/// 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. +pub struct FixedAvlTree { + prover: BatchAVLProver, + ordered_entries: Vec<([u8; BASIS_V2_KEY_LENGTH], [u8; VALUE_LEN])>, + positions: HashMap<[u8; BASIS_V2_KEY_LENGTH], usize>, +} + +impl FixedAvlTree { + pub fn new() -> Result { + if VALUE_LEN != 8 && VALUE_LEN != 24 { + return Err(TreeError::InvalidState); + } + Ok(Self { + prover: Self::empty_prover(), + ordered_entries: Vec::new(), + positions: HashMap::new(), + }) + } + + pub 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) + } + + pub const fn shape() -> FixedTreeShape { + FixedTreeShape { + key_length: BASIS_V2_KEY_LENGTH, + value_length: VALUE_LEN, + insert_allowed: true, + update_allowed: true, + remove_allowed: false, + } + } + + pub fn len(&self) -> usize { + self.ordered_entries.len() + } + + pub fn is_empty(&self) -> bool { + self.ordered_entries.is_empty() + } + + pub fn get(&self, key: &[u8; BASIS_V2_KEY_LENGTH]) -> Option<[u8; VALUE_LEN]> { + self.positions + .get(key) + .map(|position| self.ordered_entries[*position].1) + } + + pub fn ordered_entries( + &self, + ) -> impl ExactSizeIterator { + self.ordered_entries.iter() + } + + pub 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) + } + + pub 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(()) + } + + pub 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. + pub 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. + pub 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. + pub fn verify_lookup( + starting_digest: &[u8; 33], + key: &[u8; BASIS_V2_KEY_LENGTH], + witness: &LookupWitness, + ) -> bool { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + Self::verify_lookup_inner(starting_digest, key, witness) + })) + .unwrap_or(false) + } + + fn verify_lookup_inner( + 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, + } + } + + 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(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exposes_the_exact_v2_shapes() { + assert_eq!( + FixedAvlTree::<8>::shape(), + FixedTreeShape { + key_length: 32, + value_length: 8, + insert_allowed: true, + update_allowed: true, + remove_allowed: false, + } + ); + assert_eq!(FixedAvlTree::<24>::shape().value_length(), 24); + assert!(matches!( + FixedAvlTree::<0>::new(), + Err(TreeError::InvalidState) + )); + assert!(matches!( + FixedAvlTree::<16>::new(), + Err(TreeError::InvalidState) + )); + } + + #[test] + fn insertion_order_is_explicit_and_replayable() { + let entries = [([1u8; 32], [10u8; 8]), ([2u8; 32], [20u8; 8])]; + let tree = FixedAvlTree::<8>::from_ordered_entries(entries).unwrap(); + let replay = FixedAvlTree::<8>::from_ordered_entries(entries).unwrap(); + assert_eq!(tree.root_digest().unwrap(), replay.root_digest().unwrap()); + assert_eq!(tree.ordered_entries().copied().collect::>(), entries); + } + + #[test] + fn insert_and_update_are_strict() { + let mut tree = FixedAvlTree::<8>::new().unwrap(); + 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 = FixedAvlTree::<24>::new().unwrap(); + 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!(FixedAvlTree::<24>::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!(FixedAvlTree::<24>::verify_lookup( + &digest, + &[9u8; 32], + &non_membership + )); + + let mut wrong = non_membership.clone(); + wrong.value = Some([0u8; 24]); + assert!(!FixedAvlTree::<24>::verify_lookup( + &digest, &[9u8; 32], &wrong + )); + + let mut wrong_proof = membership.clone(); + wrong_proof.proof[0] ^= 1; + assert!(!FixedAvlTree::<24>::verify_lookup( + &digest, + &[1u8; 32], + &wrong_proof + )); + assert!(!FixedAvlTree::<24>::verify_lookup( + &digest, + &[3u8; 32], + &membership + )); + let mut wrong_digest = digest; + wrong_digest[1] ^= 1; + assert!(!FixedAvlTree::<24>::verify_lookup( + &wrong_digest, + &[1u8; 32], + &membership + )); + } + + #[test] + fn transition_proof_is_deterministic_and_does_not_mutate() { + let mut tree = FixedAvlTree::<8>::new().unwrap(); + 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()); + } +} diff --git a/crates/basis_trees/src/lib.rs b/crates/basis_trees/src/lib.rs index 6731c39..6e41b9d 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,7 @@ pub mod avl_tree_tests; pub use avl_tree::BasisAvlTree; pub use errors::TreeError; +pub use fixed_avl::{FixedAvlTree, FixedTreeShape, LookupWitness, TransitionWitness}; pub use proofs::{MembershipProof, NonMembershipProof, StateProof}; pub use state::TrackerState; pub use storage::{NodeType, OperationType, TreeCheckpoint, TreeNode, TreeOperation, TreeStorage}; From 3282ef03a2b28e82d46e94106c31ef996dfccf93 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:00:11 +0200 Subject: [PATCH 17/41] fix: harden Basis v2 fixed AVL proofs --- .../examples/malformed_proof_abort_harness.rs | 91 ++++ crates/basis_trees/src/fixed_avl.rs | 424 +++++++++++++++--- crates/basis_trees/src/lib.rs | 5 +- .../fixtures/basis_v2_avl_scala_0403162.json | 45 ++ specs/trees/basis-v2-fixed-avl-receipt.md | 59 +++ .../src/batch_avl_verifier.rs | 144 ++++-- 6 files changed, 663 insertions(+), 105 deletions(-) create mode 100644 crates/basis_trees/examples/malformed_proof_abort_harness.rs create mode 100644 crates/basis_trees/tests/fixtures/basis_v2_avl_scala_0403162.json create mode 100644 specs/trees/basis-v2-fixed-avl-receipt.md 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/fixed_avl.rs b/crates/basis_trees/src/fixed_avl.rs index 499b855..cb3f5a6 100644 --- a/crates/basis_trees/src/fixed_avl.rs +++ b/crates/basis_trees/src/fixed_avl.rs @@ -4,6 +4,13 @@ //! 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; @@ -66,23 +73,13 @@ impl FixedTreeShape { } } -/// Mandatory membership or non-membership lookup evidence. +/// Internal membership or non-membership lookup evidence. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct LookupWitness { +struct LookupWitness { proof: Vec, value: Option<[u8; VALUE_LEN]>, } -impl LookupWitness { - pub fn proof(&self) -> &[u8] { - &self.proof - } - - pub const fn value(&self) -> Option<[u8; VALUE_LEN]> { - self.value - } -} - /// An insert-or-update proof and the root it produces without mutating state. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TransitionWitness { @@ -105,36 +102,33 @@ impl TransitionWitness { /// 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. -pub struct FixedAvlTree { +struct FixedAvlInner { prover: BatchAVLProver, ordered_entries: Vec<([u8; BASIS_V2_KEY_LENGTH], [u8; VALUE_LEN])>, positions: HashMap<[u8; BASIS_V2_KEY_LENGTH], usize>, } -impl FixedAvlTree { - pub fn new() -> Result { - if VALUE_LEN != 8 && VALUE_LEN != 24 { - return Err(TreeError::InvalidState); - } - Ok(Self { +impl FixedAvlInner { + fn new() -> Self { + Self { prover: Self::empty_prover(), ordered_entries: Vec::new(), positions: HashMap::new(), - }) + } } - pub fn from_ordered_entries(entries: I) -> Result + fn from_ordered_entries(entries: I) -> Result where I: IntoIterator, { - let mut tree = Self::new()?; + let mut tree = Self::new(); for (key, value) in entries { tree.insert(key, value)?; } Ok(tree) } - pub const fn shape() -> FixedTreeShape { + const fn shape() -> FixedTreeShape { FixedTreeShape { key_length: BASIS_V2_KEY_LENGTH, value_length: VALUE_LEN, @@ -144,34 +138,34 @@ impl FixedAvlTree { } } - pub fn len(&self) -> usize { + fn len(&self) -> usize { self.ordered_entries.len() } - pub fn is_empty(&self) -> bool { + fn is_empty(&self) -> bool { self.ordered_entries.is_empty() } - pub fn get(&self, key: &[u8; BASIS_V2_KEY_LENGTH]) -> Option<[u8; VALUE_LEN]> { + fn get(&self, key: &[u8; BASIS_V2_KEY_LENGTH]) -> Option<[u8; VALUE_LEN]> { self.positions .get(key) .map(|position| self.ordered_entries[*position].1) } - pub fn ordered_entries( + fn ordered_entries( &self, ) -> impl ExactSizeIterator { self.ordered_entries.iter() } - pub fn root_digest(&self) -> Result<[u8; 33], TreeError> { + 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) } - pub fn insert( + fn insert( &mut self, key: [u8; BASIS_V2_KEY_LENGTH], value: [u8; VALUE_LEN], @@ -186,7 +180,7 @@ impl FixedAvlTree { Ok(()) } - pub fn update( + fn update( &mut self, key: [u8; BASIS_V2_KEY_LENGTH], value: [u8; VALUE_LEN], @@ -198,7 +192,7 @@ impl FixedAvlTree { } /// Generate mandatory lookup evidence for either an existing or absent key. - pub fn lookup_witness( + fn lookup_witness( &mut self, key: [u8; BASIS_V2_KEY_LENGTH], ) -> Result, TreeError> { @@ -228,7 +222,7 @@ impl FixedAvlTree { /// /// The real tree is not mutated. Rebuilding the temporary prover uses the /// authoritative first-insertion order, never hash-map iteration order. - pub fn transition_witness( + fn transition_witness( &self, key: [u8; BASIS_V2_KEY_LENGTH], value: [u8; VALUE_LEN], @@ -264,18 +258,7 @@ impl FixedAvlTree { } /// Verify exactly one membership or non-membership lookup operation. - pub fn verify_lookup( - starting_digest: &[u8; 33], - key: &[u8; BASIS_V2_KEY_LENGTH], - witness: &LookupWitness, - ) -> bool { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - Self::verify_lookup_inner(starting_digest, key, witness) - })) - .unwrap_or(false) - } - - fn verify_lookup_inner( + fn verify_lookup( starting_digest: &[u8; 33], key: &[u8; BASIS_V2_KEY_LENGTH], witness: &LookupWitness, @@ -325,14 +308,174 @@ impl FixedAvlTree { } } +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, + ) + } + } + + 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!( - FixedAvlTree::<8>::shape(), + TrackerAvlTree::shape(), FixedTreeShape { key_length: 32, value_length: 8, @@ -341,29 +484,36 @@ mod tests { remove_allowed: false, } ); - assert_eq!(FixedAvlTree::<24>::shape().value_length(), 24); - assert!(matches!( - FixedAvlTree::<0>::new(), - Err(TreeError::InvalidState) - )); - assert!(matches!( - FixedAvlTree::<16>::new(), - Err(TreeError::InvalidState) - )); + 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 = FixedAvlTree::<8>::from_ordered_entries(entries).unwrap(); - let replay = FixedAvlTree::<8>::from_ordered_entries(entries).unwrap(); + 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 = FixedAvlTree::<8>::new().unwrap(); + let mut tree = TrackerAvlTree::new(); tree.insert([1u8; 32], [2u8; 8]).unwrap(); assert!(matches!( tree.insert([1u8; 32], [3u8; 8]), @@ -380,49 +530,47 @@ mod tests { #[test] fn membership_and_non_membership_both_have_verifiable_proofs() { - let mut tree = FixedAvlTree::<24>::new().unwrap(); + 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!(FixedAvlTree::<24>::verify_lookup( + 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!(FixedAvlTree::<24>::verify_lookup( + assert_eq!(non_membership.value(), None); + assert!(!non_membership.proof().is_empty()); + assert!(ReserveAvlTree::verify_lookup( &digest, &[9u8; 32], &non_membership )); let mut wrong = non_membership.clone(); - wrong.value = Some([0u8; 24]); - assert!(!FixedAvlTree::<24>::verify_lookup( - &digest, &[9u8; 32], &wrong - )); + wrong.inner.value = Some([0u8; 24]); + assert!(!ReserveAvlTree::verify_lookup(&digest, &[9u8; 32], &wrong)); let mut wrong_proof = membership.clone(); - wrong_proof.proof[0] ^= 1; - assert!(!FixedAvlTree::<24>::verify_lookup( + wrong_proof.inner.proof[0] ^= 1; + assert!(!ReserveAvlTree::verify_lookup( &digest, &[1u8; 32], &wrong_proof )); - assert!(!FixedAvlTree::<24>::verify_lookup( + assert!(!ReserveAvlTree::verify_lookup( &digest, &[3u8; 32], &membership )); let mut wrong_digest = digest; wrong_digest[1] ^= 1; - assert!(!FixedAvlTree::<24>::verify_lookup( + assert!(!ReserveAvlTree::verify_lookup( &wrong_digest, &[1u8; 32], &membership @@ -431,7 +579,7 @@ mod tests { #[test] fn transition_proof_is_deterministic_and_does_not_mutate() { - let mut tree = FixedAvlTree::<8>::new().unwrap(); + 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(); @@ -444,4 +592,134 @@ mod tests { tree.update([1u8; 32], [5u8; 8]).unwrap(); assert_eq!(tree.root_digest().unwrap(), witness.new_digest()); } + + #[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 6e41b9d..d0a2697 100644 --- a/crates/basis_trees/src/lib.rs +++ b/crates/basis_trees/src/lib.rs @@ -21,7 +21,10 @@ pub mod avl_tree_tests; pub use avl_tree::BasisAvlTree; pub use errors::TreeError; -pub use fixed_avl::{FixedAvlTree, FixedTreeShape, LookupWitness, TransitionWitness}; +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/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/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 From fa4bce14ba0c9db76a2cf81df74ca6f286d56f5c Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:58:06 +0200 Subject: [PATCH 18/41] Add bounded Basis v2 state snapshots --- crates/basis_store/src/basis_v2_state.rs | 1829 ++++++++++++++++++++++ crates/basis_store/src/lib.rs | 1 + specs/trees/basis-v2-state-snapshots.md | 110 ++ 3 files changed, 1940 insertions(+) create mode 100644 crates/basis_store/src/basis_v2_state.rs create mode 100644 specs/trees/basis-v2-state-snapshots.md 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..f93f12c --- /dev/null +++ b/crates/basis_store/src/basis_v2_state.rs @@ -0,0 +1,1829 @@ +//! 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, +} + +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()) + } + + /// 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, +} + +#[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 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()) + } + + 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) + .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> { + let len = partition + .len() + .map_err(|error| V2StateError::Storage(error.to_string()))?; + if len != 1 { + return Err(V2StateError::Corrupt(format!( + "authoritative partition must contain exactly one row, found {len}" + ))); + } + 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() + } + + 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(); + } + + #[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_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| { + 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_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 + ); + } + + #[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_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| { + 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_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/lib.rs b/crates/basis_store/src/lib.rs index cfc909d..10ec6a1 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -1,6 +1,7 @@ //! Core data structures for Basis tracker pub mod avl_tree; +pub mod basis_v2_state; pub mod contract_compiler; #[cfg(test)] diff --git a/specs/trees/basis-v2-state-snapshots.md b/specs/trees/basis-v2-state-snapshots.md new file mode 100644 index 0000000..0b5b5d4 --- /dev/null +++ b/specs/trees/basis-v2-state-snapshots.md @@ -0,0 +1,110 @@ +# 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. + +## 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. From e10ef6e1f1f22dc6a7a4bbd587fb19180dca189e Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:15:02 +0200 Subject: [PATCH 19/41] fix: close Basis v2 state recovery gaps --- crates/basis_store/src/basis_v2_state.rs | 141 +++++++++++++++++++++-- specs/trees/basis-v2-state-snapshots.md | 4 +- 2 files changed, 135 insertions(+), 10 deletions(-) diff --git a/crates/basis_store/src/basis_v2_state.rs b/crates/basis_store/src/basis_v2_state.rs index f93f12c..90884c1 100644 --- a/crates/basis_store/src/basis_v2_state.rs +++ b/crates/basis_store/src/basis_v2_state.rs @@ -363,6 +363,11 @@ impl ReserveRedeemedStoreV2 { 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) @@ -528,6 +533,7 @@ fn open_exact_partition( 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) @@ -591,13 +597,30 @@ fn read_only_snapshot( key: &[u8], maximum_len: usize, ) -> Result, V2StateError> { - let len = partition - .len() - .map_err(|error| V2StateError::Storage(error.to_string()))?; - if len != 1 { - return Err(V2StateError::Corrupt(format!( - "authoritative partition must contain exactly one row, found {len}" - ))); + // 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) @@ -1217,6 +1240,57 @@ mod tests { 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(); @@ -1398,12 +1472,21 @@ mod tests { } #[test] - fn tracker_restart_rejects_checksum_signature_root_order_duplicate_and_bounds_corruption() { + 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; @@ -1505,6 +1588,7 @@ mod tests { FreshV2StateApproval::Approve, ) .unwrap(); + assert!(first.is_empty().unwrap()); assert_eq!( ReserveRedeemedStoreV2::open(&first_path, first_binding, FreshV2StateApproval::Reject) .err() @@ -1563,6 +1647,29 @@ mod tests { .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] @@ -1757,11 +1864,19 @@ mod tests { } #[test] - fn reserve_restart_rejects_checksum_signature_state_root_order_and_legacy_corruption() { + 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; @@ -1770,6 +1885,14 @@ mod tests { }), 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; diff --git a/specs/trees/basis-v2-state-snapshots.md b/specs/trees/basis-v2-state-snapshots.md index 0b5b5d4..c320c77 100644 --- a/specs/trees/basis-v2-state-snapshots.md +++ b/specs/trees/basis-v2-state-snapshots.md @@ -14,7 +14,9 @@ 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. +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 From d0cf4ff55d8662b1472082f66ed0df6c837b0221 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:25:28 +0200 Subject: [PATCH 20/41] Quarantine legacy redemption builders --- .../basis_cli/tests/cli_integration_tests.rs | 42 +------------------ crates/basis_offchain/src/lib.rs | 13 +++++- crates/basis_server/src/main.rs | 25 ++++------- .../tests/api_integration_tests.rs | 32 +------------- crates/basis_server/tests/cors_tests.rs | 23 ++++------ .../tests/http_api_integration_tests.rs | 23 ++++------ .../tests/redemption_api_integration_tests.rs | 23 ++++------ crates/basis_store/src/lib.rs | 25 +++++++++-- crates/basis_store/src/test_helpers.rs | 4 +- specs/legacy_runtime_quarantine.md | 31 ++++++++++++++ 10 files changed, 102 insertions(+), 139 deletions(-) create mode 100644 specs/legacy_runtime_quarantine.md 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_offchain/src/lib.rs b/crates/basis_offchain/src/lib.rs index b3dd5b8..b059614 100644 --- a/crates/basis_offchain/src/lib.rs +++ b/crates/basis_offchain/src/lib.rs @@ -1,9 +1,18 @@ -//! Offchain logic for Basis tracker +//! Offchain logic for Basis tracker. +//! +//! The placeholder v1 transaction-builder surface is retained only as a +//! historical unit-test fixture. Production construction resumes with the +//! versioned v2 builder. +//! +//! ```compile_fail +//! use basis_offchain::transaction_builder::RedemptionTransactionBuilder; +//! ``` pub mod ergo_tx; pub mod schnorr; pub mod signing; -pub mod transaction_builder; +#[cfg(test)] +mod transaction_builder; #[cfg(test)] pub mod test_helpers; diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 20b1f03..39aface 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -389,8 +389,6 @@ async fn main() { // Spawn tracker thread (using tokio::task::spawn_blocking for CPU-bound work) tokio::task::spawn_blocking(move || { - use basis_store::RedemptionManager; - tracing::debug!("Tracker thread started"); let tracker = match basis_store::TrackerStateManager::try_new_with_publication_health( &data_dir_for_tracker_thread, @@ -451,7 +449,7 @@ 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() { @@ -466,7 +464,7 @@ async fn main() { 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::GetProjectedIssuerGrossDebt { @@ -486,18 +484,14 @@ async fn main() { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker - .get_recipient_notes(&recipient_pubkey); + let result = tracker.get_recipient_notes(&recipient_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 result = tracker.get_recipient_notes_with_issuer(&recipient_pubkey); let _ = response_tx.send(result); } TrackerCommand::GetNoteByIssuerAndRecipient { @@ -505,14 +499,13 @@ async fn main() { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker + let result = tracker .lookup_note(&issuer_pubkey, &recipient_pubkey) .map(Some); let _ = response_tx.send(result); } TrackerCommand::GetNotes { response_tx } => { - let result = redemption_manager.tracker.get_all_notes_with_issuer(); + let result = tracker.get_all_notes_with_issuer(); let _ = response_tx.send(result); } TrackerCommand::GenerateProof { @@ -587,7 +580,7 @@ async fn main() { let _ = response_tx.send(result); } TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = redemption_manager.tracker.reserve_state_digest(); + let digest = tracker.reserve_state_digest(); let _ = response_tx.send(digest); } TrackerCommand::GetValidatedState { response_tx } => { @@ -598,9 +591,7 @@ async fn main() { 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 } => { 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 324602b..6d9ee6a 100644 --- a/crates/basis_server/tests/cors_tests.rs +++ b/crates/basis_server/tests/cors_tests.rs @@ -47,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); @@ -61,14 +60,14 @@ 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::GetProjectedIssuerGrossDebt { @@ -88,9 +87,7 @@ mod cors_tests { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker - .get_recipient_notes(&recipient_pubkey); + let result = tracker.get_recipient_notes(&recipient_pubkey); let _ = response_tx.send(result); } TrackerCommand::GetNoteByIssuerAndRecipient { @@ -98,8 +95,7 @@ mod cors_tests { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker + let result = tracker .lookup_note(&issuer_pubkey, &recipient_pubkey) .map(Some); let _ = response_tx.send(result); @@ -189,9 +185,8 @@ 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 } => { @@ -199,7 +194,7 @@ mod cors_tests { response_tx.send(Ok(redemption_manager.tracker.all_confirmations())); } TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = redemption_manager.tracker.reserve_state_digest(); + let digest = tracker.reserve_state_digest(); let _ = response_tx.send(digest); } TrackerCommand::GetValidatedState { response_tx } => { diff --git a/crates/basis_server/tests/http_api_integration_tests.rs b/crates/basis_server/tests/http_api_integration_tests.rs index fffd9c8..2a7f78a 100644 --- a/crates/basis_server/tests/http_api_integration_tests.rs +++ b/crates/basis_server/tests/http_api_integration_tests.rs @@ -48,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); @@ -62,14 +61,14 @@ 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::GetProjectedIssuerGrossDebt { @@ -89,9 +88,7 @@ mod http_api_tests { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker - .get_recipient_notes(&recipient_pubkey); + let result = tracker.get_recipient_notes(&recipient_pubkey); let _ = response_tx.send(result); } TrackerCommand::GetNoteByIssuerAndRecipient { @@ -99,8 +96,7 @@ mod http_api_tests { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker + let result = tracker .lookup_note(&issuer_pubkey, &recipient_pubkey) .map(Some); let _ = response_tx.send(result); @@ -190,9 +186,8 @@ 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 } => { @@ -200,7 +195,7 @@ mod http_api_tests { response_tx.send(Ok(redemption_manager.tracker.all_confirmations())); } TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = redemption_manager.tracker.reserve_state_digest(); + let digest = tracker.reserve_state_digest(); let _ = response_tx.send(digest); } TrackerCommand::GetValidatedState { response_tx } => { diff --git a/crates/basis_server/tests/redemption_api_integration_tests.rs b/crates/basis_server/tests/redemption_api_integration_tests.rs index e6caac7..4ce8a6d 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -67,11 +67,10 @@ mod redemption_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); @@ -81,14 +80,14 @@ mod redemption_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::GetProjectedIssuerGrossDebt { @@ -108,9 +107,7 @@ mod redemption_api_tests { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker - .get_recipient_notes(&recipient_pubkey); + let result = tracker.get_recipient_notes(&recipient_pubkey); let _ = response_tx.send(result); } TrackerCommand::GetNoteByIssuerAndRecipient { @@ -118,8 +115,7 @@ mod redemption_api_tests { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker + let result = tracker .lookup_note(&issuer_pubkey, &recipient_pubkey) .map(Some); let _ = response_tx.send(result); @@ -203,9 +199,8 @@ mod redemption_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 } => { @@ -213,7 +208,7 @@ mod redemption_api_tests { response_tx.send(Ok(redemption_manager.tracker.all_confirmations())); } TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = redemption_manager.tracker.reserve_state_digest(); + let digest = tracker.reserve_state_digest(); let _ = response_tx.send(digest); } TrackerCommand::GetValidatedState { response_tx } => { diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index 10ec6a1..b80505a 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -1,4 +1,16 @@ -//! Core data structures for Basis tracker +//! Core data structures for Basis tracker. +//! +//! The retired v1 redemption manager and transaction builder are deliberately +//! test-only historical fixtures. They are not part of the production crate +//! API while the v2 BNS2/BRS2 builder is being implemented. +//! +//! ```compile_fail +//! use basis_store::RedemptionManager; +//! ``` +//! +//! ```compile_fail +//! use basis_store::transaction_builder::RedemptionTransactionBuilder; +//! ``` pub mod avl_tree; pub mod basis_v2_state; @@ -9,7 +21,8 @@ pub mod cross_validation_tests; pub mod cross_verification; pub mod ergo_scanner; pub mod persistence; -pub mod redemption; +#[cfg(test)] +mod redemption; #[cfg(test)] pub mod redemption_blockchain_tests; #[cfg(test)] @@ -23,7 +36,8 @@ pub mod schnorr_tests; pub mod simple_integration_tests; pub mod tests; pub mod tracker_scanner; -pub mod transaction_builder; +#[cfg(test)] +mod transaction_builder; // Test modules #[cfg(test)] @@ -1864,7 +1878,10 @@ pub use ergo_scanner::{ }; // Re-export redemption types -pub use redemption::{RedemptionData, RedemptionError, RedemptionManager, RedemptionRequest}; +#[cfg(test)] +pub(crate) use redemption::{ + RedemptionData, RedemptionError, RedemptionManager, RedemptionRequest, +}; // Re-export reqwest for use in dependent crates pub use reqwest; diff --git a/crates/basis_store/src/test_helpers.rs b/crates/basis_store/src/test_helpers.rs index 0d275a7..ac9b5a6 100644 --- a/crates/basis_store/src/test_helpers.rs +++ b/crates/basis_store/src/test_helpers.rs @@ -30,8 +30,8 @@ pub fn create_test_note(amount: u64, timestamp: u64) -> IouNote { } /// Create test transaction context following chaincash-rs patterns -pub fn create_test_tx_context() -> basis_offchain::transaction_builder::TxContext { - basis_offchain::transaction_builder::TxContext { +pub fn create_test_tx_context() -> crate::transaction_builder::TxContext { + crate::transaction_builder::TxContext { current_height: 1000, fee: 1000000, // 0.001 ERG - same as chaincash-rs SUGGESTED_TX_FEE change_address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), diff --git a/specs/legacy_runtime_quarantine.md b/specs/legacy_runtime_quarantine.md new file mode 100644 index 0000000..6908da4 --- /dev/null +++ b/specs/legacy_runtime_quarantine.md @@ -0,0 +1,31 @@ +# Legacy runtime quarantine + +The pre-v2 redemption builders are historical test fixtures, not supported +production APIs. + +## Enforced boundary + +- `basis_store::redemption` and `basis_store::transaction_builder` are compiled + only for the crate's unit tests. +- `basis_offchain::transaction_builder` is compiled only for the crate's unit + tests. +- `basis_store` does not export `RedemptionManager`, `RedemptionRequest`, or + the v1 `RedemptionTransactionBuilder` to downstream crates. +- The server actor owns `TrackerStateManager` directly; it cannot reach the + retired manager through a production dependency. + +The crate-level `compile_fail` examples are regression guards for the public +API boundary. Historical unit and property tests remain available so the old +behavior can still be examined without making it callable by an application. + +## Scope + +This quarantine does not approve a replacement contract generation and does +not make the assisted redemption route a v2 builder. The replacement runtime +must pin the reviewed v2 source, ErgoTree, P2S, claim domain, register schema, +and proof shapes before construction is enabled. Until that integration is +complete, the existing HTTP and CLI retirement guards remain authoritative. + +There is no automatic migration from legacy reserve state. Operators must +inventory any supported lineage and apply an explicitly reviewed retirement or +migration policy. From f62e493927f6ff835a3d940e79a2b1daba60049d Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:31:31 +0200 Subject: [PATCH 21/41] docs: scope legacy API quarantine --- specs/PRODUCTION_READINESS_AUDIT.md | 12 +- specs/legacy_runtime_quarantine.md | 15 +- specs/offchain/spec.md | 14 +- specs/server/tracker_box_update_spec.md | 23 +- .../FULL_FJALL_PERSISTENCE_IMPLEMENTATION.md | 12 +- specs/trees/RESOLVER_IMPLEMENTATION.md | 9 +- tests/end_to_end_flow.rs | 244 ------------------ 7 files changed, 59 insertions(+), 270 deletions(-) delete mode 100644 tests/end_to_end_flow.rs 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/legacy_runtime_quarantine.md b/specs/legacy_runtime_quarantine.md index 6908da4..58a7286 100644 --- a/specs/legacy_runtime_quarantine.md +++ b/specs/legacy_runtime_quarantine.md @@ -1,4 +1,4 @@ -# Legacy runtime quarantine +# Legacy library API quarantine The pre-v2 redemption builders are historical test fixtures, not supported production APIs. @@ -18,13 +18,14 @@ The crate-level `compile_fail` examples are regression guards for the public API boundary. Historical unit and property tests remain available so the old behavior can still be examined without making it callable by an application. -## Scope +## Scope and integration dependency -This quarantine does not approve a replacement contract generation and does -not make the assisted redemption route a v2 builder. The replacement runtime -must pin the reviewed v2 source, ErgoTree, P2S, claim domain, register schema, -and proof shapes before construction is enabled. Until that integration is -complete, the existing HTTP and CLI retirement guards remain authoritative. +This branch closes only the public Rust library surface described above. It +does not quarantine every server or client runtime path: the assisted +`/redemption/build` and `/redemption/submit` routes belong to the separate +generation-admission workstream. Before integration, those routes must either +be unconditional tombstones or accept only the exact reviewed v2 source, +ErgoTree, P2S, claim domain, register schema, and proof shapes. There is no automatic migration from legacy reserve state. Operators must inventory any supported lineage and apply an explicitly reviewed retirement or 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/server/tracker_box_update_spec.md b/specs/server/tracker_box_update_spec.md index 6cf6d80..ff77c42 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: partially superseded design reference.** The updater remains a +> migration input, but the v1 redemption builder and raw completion command +> shown below have been retired from production APIs. Actor-owned durable state, +> confirmed-chain reconciliation, and exact v2 generation admission are defined +> in separate current workstreams. This document is not deployment authority. ## Overview @@ -407,9 +413,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; @@ -446,7 +453,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 @@ -465,9 +473,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. **Generation Validation Command**: Validate the configured tracker NFT and first observed R5 against the durable generation manifest before publication -3. **AVL Tree Operations**: Each admitted note update produces a validated durable snapshot before the shared root changes -4. **State Consistency**: A one-way health gate removes every cached root from the publication path after manager quarantine +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 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/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 From bcd2e654f6a1e2d05b099a5d3d005cedf8599d0f Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:51:30 +0200 Subject: [PATCH 22/41] docs: complete legacy runtime quarantine guards --- crates/basis_store/src/lib.rs | 16 +++++++++++++ .../server/r6_register_implementation_spec.md | 18 ++++++++++---- specs/server/redemption_state_spec.md | 24 +++++++++++++------ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index b80505a..cda2e2d 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -9,6 +9,22 @@ //! ``` //! //! ```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; //! ``` 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 7ed73ec..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, From 81440a00b44b05d9445335f6c39997cbdd13252f Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:58:36 +0200 Subject: [PATCH 23/41] feat: expose exact Basis v2 proof verification --- crates/basis_core/src/basis_v2.rs | 55 ++++--- crates/basis_store/src/basis_v2_state.rs | 183 +++++++++++++++++++++++ crates/basis_trees/src/fixed_avl.rs | 121 +++++++++++++-- 3 files changed, 326 insertions(+), 33 deletions(-) diff --git a/crates/basis_core/src/basis_v2.rs b/crates/basis_core/src/basis_v2.rs index baab42d..c209518 100644 --- a/crates/basis_core/src/basis_v2.rs +++ b/crates/basis_core/src/basis_v2.rs @@ -285,28 +285,45 @@ impl ClaimV2 { pub fn verify(&self) -> Result<(), BasisV2Error> { let message = self.signing_message()?; - // 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 self.signature[33] & 0x80 != 0 { - return Err(BasisV2Error::NonCanonicalSignature); - } - let mut challenge = Blake2b::::new(); - challenge.update(&self.signature[..33]); - challenge.update(message); - challenge.update(self.domain.owner_pubkey); - if challenge.finalize()[0] & 0x80 != 0 { - return Err(BasisV2Error::NonCanonicalSignature); - } - SchnorrVerifier - .verify_signature(&self.signature, &message, &self.domain.owner_pubkey) - .map_err(BasisV2Error::from) + 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 diff --git a/crates/basis_store/src/basis_v2_state.rs b/crates/basis_store/src/basis_v2_state.rs index 90884c1..f6a82eb 100644 --- a/crates/basis_store/src/basis_v2_state.rs +++ b/crates/basis_store/src/basis_v2_state.rs @@ -146,6 +146,25 @@ pub struct TrackerClaimStoreV2 { 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, @@ -225,6 +244,39 @@ impl TrackerClaimStoreV2 { .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> { @@ -310,6 +362,41 @@ pub struct ReserveRedeemedStoreV2 { 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>( @@ -401,6 +488,49 @@ impl ReserveRedeemedStoreV2 { .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 } @@ -1230,6 +1360,59 @@ mod tests { 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); diff --git a/crates/basis_trees/src/fixed_avl.rs b/crates/basis_trees/src/fixed_avl.rs index cb3f5a6..650ebce 100644 --- a/crates/basis_trees/src/fixed_avl.rs +++ b/crates/basis_trees/src/fixed_avl.rs @@ -25,20 +25,12 @@ use std::collections::HashMap; pub const BASIS_V2_KEY_LENGTH: usize = 32; -fn tree_resolver(_digest: &[u8; 32]) -> ergo_avltree_rust::batch_node::Node { - ergo_avltree_rust::batch_node::Node::Leaf(ergo_avltree_rust::batch_node::LeafNode { - hdr: ergo_avltree_rust::batch_node::NodeHeader { - visited: false, - is_new: false, - label: None, - key: Some(ergo_avltree_rust::operation::ADKey::from(vec![ - 0u8; - BASIS_V2_KEY_LENGTH - ])), - }, - value: ergo_avltree_rust::operation::ADValue::from(vec![]), - next_node_key: ergo_avltree_rust::operation::ADKey::from(vec![0u8; BASIS_V2_KEY_LENGTH]), - }) +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. @@ -284,6 +276,46 @@ impl FixedAvlInner { } } + /// 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)), @@ -425,6 +457,39 @@ macro_rules! define_fixed_tree { &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 { @@ -551,6 +616,18 @@ mod tests { &[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]); @@ -591,6 +668,22 @@ mod tests { 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] From a5753432684f7922fd0763913a66b2454cd71fc1 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:00:24 +0200 Subject: [PATCH 24/41] feat: add dormant Basis v2 redemption manifests --- crates/basis_store/src/basis_v2_builder.rs | 1896 +++++++++++++++++ crates/basis_store/src/lib.rs | 1 + .../tests/fixtures/basis_v2_context_abi.json | 14 + 3 files changed, 1911 insertions(+) create mode 100644 crates/basis_store/src/basis_v2_builder.rs create mode 100644 crates/basis_store/tests/fixtures/basis_v2_context_abi.json 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..b59fcb3 --- /dev/null +++ b/crates/basis_store/src/basis_v2_builder.rs @@ -0,0 +1,1896 @@ +//! 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 }; +//! ``` + +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::ergotree_ir::chain::ergo_box::{ErgoBox, NonMandatoryRegisterId}; +use ergo_lib::ergotree_ir::mir::avl_tree_data::AvlTreeData; +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; + +/// 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("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, Deserialize)] +#[serde(deny_unknown_fields)] +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, +} + +impl V2RedemptionManifest { + 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", + )); + } + 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 + } +} + +/// 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(), + )?; + 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 { + 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")); + } + 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(), + }); + } + Ok(outputs) +} + +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) -> String { + let mut bytes = Vec::with_capacity(38); + bytes.push(0x64); + bytes.extend_from_slice(&root); + bytes.push(0x03); + bytes.push(32); + bytes.push(value_length as u8); + hex::encode(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> { + 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> { + let proof = hex::decode(encoded).map_err(|error| invariant(format!("{label} hex: {error}")))?; + validate_proof_size(&proof, label)?; + Ok(proof) +} + +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::{ADDigest, 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 ergo_lib::ergotree_ir::mir::avl_tree_data::{AvlTreeData, AvlTreeFlags}; + 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() + } + + 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) { + assert!(validate_v2_redemption_manifest(manifest, intent).is_err()); + } + + #[test] + fn older_signed_claim_remains_exact_when_tracker_commits_newer_total() { + let fixture = make_fixture(false, false); + validate_v2_redemption_manifest(&fixture.manifest, &fixture.intent).unwrap(); + 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); + + 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 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 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); + } + + #[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); + } +} diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index cda2e2d..b1f005a 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -29,6 +29,7 @@ //! ``` pub mod avl_tree; +pub mod basis_v2_builder; pub mod basis_v2_state; pub mod contract_compiler; 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 + } +} From fcadef1263ac77408b597e20e3b3eb63d7e5670e Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:45:20 +0200 Subject: [PATCH 25/41] feat: gate Basis v2 client admission --- crates/basis_cli/src/lib.rs | 1 + crates/basis_cli/src/v2_manifest.rs | 38 ++++++++ crates/basis_store/src/basis_v2_builder.rs | 102 ++++++++++++++++++--- 3 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 crates/basis_cli/src/v2_manifest.rs 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/v2_manifest.rs b/crates/basis_cli/src/v2_manifest.rs new file mode 100644 index 0000000..013fae4 --- /dev/null +++ b/crates/basis_cli/src/v2_manifest.rs @@ -0,0 +1,38 @@ +//! 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, 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)? +} diff --git a/crates/basis_store/src/basis_v2_builder.rs b/crates/basis_store/src/basis_v2_builder.rs index b59fcb3..ceb0f3e 100644 --- a/crates/basis_store/src/basis_v2_builder.rs +++ b/crates/basis_store/src/basis_v2_builder.rs @@ -23,8 +23,9 @@ 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::{ErgoBox, NonMandatoryRegisterId}; -use ergo_lib::ergotree_ir::mir::avl_tree_data::AvlTreeData; +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}; @@ -435,6 +436,22 @@ impl<'a> ValidatedV2RedemptionManifest<'a> { } } +/// 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)) +} + /// 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( @@ -1188,7 +1205,7 @@ fn expected_outputs_raw( 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 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); @@ -1396,14 +1413,18 @@ fn validate_avl_shape( Ok(()) } -fn serialize_fixed_avl_register(root: [u8; 33], value_length: u32) -> String { - let mut bytes = Vec::with_capacity(38); - bytes.push(0x64); - bytes.extend_from_slice(&root); - bytes.push(0x03); - bytes.push(32); - bytes.push(value_length as u8); - hex::encode(bytes) +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> { @@ -1452,14 +1473,13 @@ mod tests { use super::*; use crate::basis_v2_state::{FreshV2StateApproval, ReserveStoreBindingV2}; use basis_core::impls::schnorr_sign; - use ergo_lib::ergo_chain_types::{ADDigest, Digest32, EcPoint}; + 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 ergo_lib::ergotree_ir::mir::avl_tree_data::{AvlTreeData, AvlTreeFlags}; use secp256k1::{PublicKey, Secp256k1, SecretKey}; use tempfile::TempDir; @@ -1490,6 +1510,18 @@ mod tests { .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() } @@ -1671,13 +1703,28 @@ mod tests { } fn reject(manifest: &V2RedemptionManifest, intent: &V2SigningIntent) { - assert!(validate_v2_redemption_manifest(manifest, intent).is_err()); + 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!(result.is_err()); + assert_eq!( + callback_calls.get(), + 0, + "proof/signature callback ran for a rejected manifest" + ); } #[test] fn older_signed_claim_remains_exact_when_tracker_commits_newer_total() { let fixture = make_fixture(false, false); - validate_v2_redemption_manifest(&fixture.manifest, &fixture.intent).unwrap(); + 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) @@ -1711,6 +1758,25 @@ mod tests { 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 @@ -1762,6 +1828,10 @@ mod tests { 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); @@ -1878,6 +1948,10 @@ mod tests { 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] From 711091fcb54c5eb86912a4a616e18465691e5d13 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:46:00 +0200 Subject: [PATCH 26/41] refactor: remove legacy redemption runtime --- crates/basis_app/src/ui.rs | 547 +---- crates/basis_cli/src/api.rs | 297 +-- crates/basis_cli/src/commands/mod.rs | 2 - .../basis_cli/src/commands/test_redemption.rs | 334 --- crates/basis_cli/src/commands/transaction.rs | 2110 ----------------- crates/basis_cli/src/main.rs | 17 - crates/basis_offchain/src/lib.rs | 8 +- crates/basis_offchain/src/signing.rs | 285 --- .../basis_offchain/src/transaction_builder.rs | 123 - crates/basis_server/src/api.rs | 1246 +--------- .../basis_server/src/create_reserve_tests.rs | 103 +- crates/basis_server/src/lib.rs | 49 +- crates/basis_server/src/main.rs | 153 +- crates/basis_server/src/redemption_build.rs | 1395 +---------- crates/basis_server/tests/cors_tests.rs | 30 +- .../tests/http_api_integration_tests.rs | 30 +- .../tests/redemption_api_integration_tests.rs | 130 +- crates/basis_store/src/lib.rs | 22 +- crates/basis_store/src/property_tests.rs | 204 +- crates/basis_store/src/redemption.rs | 1328 ----------- .../src/redemption_blockchain_tests.rs | 1179 --------- .../src/redemption_simple_tests.rs | 252 -- crates/basis_store/src/test_helpers.rs | 279 --- crates/basis_store/src/transaction_builder.rs | 1048 -------- 24 files changed, 421 insertions(+), 10750 deletions(-) delete mode 100644 crates/basis_cli/src/commands/test_redemption.rs delete mode 100644 crates/basis_cli/src/commands/transaction.rs delete mode 100644 crates/basis_offchain/src/signing.rs delete mode 100644 crates/basis_offchain/src/transaction_builder.rs delete mode 100644 crates/basis_store/src/redemption.rs delete mode 100644 crates/basis_store/src/redemption_blockchain_tests.rs delete mode 100644 crates/basis_store/src/redemption_simple_tests.rs delete mode 100644 crates/basis_store/src/test_helpers.rs delete mode 100644 crates/basis_store/src/transaction_builder.rs diff --git a/crates/basis_app/src/ui.rs b/crates/basis_app/src/ui.rs index e0eb256..1ee5a26 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 @@ -24,6 +17,15 @@ pub const _MAGENTA: &str = "\x1b[35m"; pub const WHITE: &str = "\x1b[37m"; pub const GRAY: &str = "\x1b[90m"; +const V1_REDEMPTION_RETIRED: &str = + "Basis v1 redemption is retired; v2 stays disabled until confirmed-chain authority and the validated-manifest signing path are integrated"; + +fn reject_retired_v1_redemption_before_effects( + _effect: impl FnOnce() -> T, +) -> Result { + Err(V1_REDEMPTION_RETIRED) +} + pub async fn run(app: &mut App) -> Result<()> { clear_screen(); if app.intro_account.is_some() { @@ -906,278 +908,16 @@ async fn draw_create_note(app: &mut App) -> Result<()> { } 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); - } - } - + let message = reject_retired_v1_redemption_before_effects::<()>(|| { + panic!("retired redemption effect must never run") + }) + .err() + .expect("v1 redemption screen must be retired"); + app.set_notification(message.to_string(), 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, - }) - .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) - .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); @@ -1258,244 +998,16 @@ async fn draw_create_reserve(app: &mut App) -> Result<()> { } 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, - ); - } - + let message = reject_retired_v1_redemption_before_effects::<()>(|| { + panic!("retired transaction-generation effect must never run") + }) + .err() + .expect("v1 transaction-generation screen must be retired"); + app.set_notification(message.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(); @@ -2066,3 +1578,18 @@ async fn save_and_upload_policy(app: &mut App) -> Result<()> { } // Helper functions are now in crate::acceptance_policy module + +#[cfg(test)] +mod v1_redemption_tombstone_tests { + use super::*; + + #[test] + fn tui_rejects_v1_redemption_before_the_effect_callback() { + let calls = std::cell::Cell::new(0usize); + let result = reject_retired_v1_redemption_before_effects(|| { + calls.set(calls.get() + 1); + }); + assert_eq!(result, Err(V1_REDEMPTION_RETIRED)); + assert_eq!(calls.get(), 0); + } +} diff --git a/crates/basis_cli/src/api.rs b/crates/basis_cli/src/api.rs index be159d1..c8f7589 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, @@ -72,18 +79,7 @@ pub struct Asset { pub amount: u64, } -// 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, @@ -268,53 +264,20 @@ impl TrackerClient { } } - /// 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, @@ -326,7 +289,7 @@ 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 { @@ -359,23 +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)] -#[serde(deny_unknown_fields)] -pub struct RedemptionSubmitRequest { - pub signed_tx: serde_json::Value, -} - -#[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, @@ -385,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) @@ -407,173 +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. Settlement state is reconciled separately from - /// confirmed active-chain evidence; this request carries no caller-asserted accounting data. - pub async fn redemption_submit(&self, signed_tx: serde_json::Value) -> Result { - let url = format!("{}/redemption/submit", self.base_url); - let request = RedemptionSubmitRequest { signed_tx }; - 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 || response.status() == 202 { - 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 @@ -1063,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/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 f377948..0000000 --- a/crates/basis_cli/src/commands/transaction.rs +++ /dev/null @@ -1,2110 +0,0 @@ -use crate::api::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; if - /// omitted, the command fails before network access or transaction construction. - #[arg(long)] - recipient_secret: Option, - /// Fee-payer dlog secret as 32-byte hex. Required for local signing; if omitted, the - /// command fails before network access or transaction construction. - #[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, the command fails before network access. - #[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, - issuer_signature_len: usize, - tracker_signature_len: usize, - insert_proof_len: usize, - reserve_lookup_proof_len: Option, - tracker_lookup_proof_len: usize, -} - -fn require_local_signing(local_sign: bool) -> Result<()> { - if !local_sign { - anyhow::bail!( - "Unsigned node-wallet artifacts are retired because they exported a private dlog key; use --local-sign or the assisted signer" - ); - } - Ok(()) -} - -fn ensure_secret_free_artifact(value: &serde_json::Value) -> Result<()> { - fn contains_forbidden_field(value: &serde_json::Value) -> bool { - match value { - serde_json::Value::Object(fields) => fields.iter().any(|(name, child)| { - matches!( - name.as_str(), - "secrets" | "private_key" | "privateKey" | "mnemonic" | "seed" - ) || contains_forbidden_field(child) - }), - serde_json::Value::Array(values) => values.iter().any(contains_forbidden_field), - _ => false, - } - } - - if contains_forbidden_field(value) { - anyhow::bail!("transaction artifact contains a forbidden secret-bearing field"); - } - Ok(()) -} - -/// 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)?; - - // Private keys are never requested while building a transaction artifact. - // The local signer resolves its witness only inside the in-memory signing boundary. - - // 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_input_claims, _) = 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 - ))?; - - // The wallet-list JSON is selection metadata only. Bind ID, value, script, - // and assets to the exact Sigma bytes later supplied to the prover. - let mut fee_inputs = Vec::with_capacity(fee_input_claims.len()); - for claim in &fee_input_claims { - let binary = client - .get_box_binary(&claim.box_id, NODE_URL, Some(API_KEY)) - .await - .map_err(|e| { - anyhow::anyhow!("Failed to get binary for fee input {}: {}", claim.box_id, e) - })?; - fee_inputs.push(verify_fee_input_claim(claim, binary)?); - } - let fee_input_total = authoritative_fee_input_total(&fee_inputs)?; - if fee_input_total < TRANSACTION_FEE { - anyhow::bail!( - "Exact fee inputs provide {} nanoERG, below required {} nanoERG", - fee_input_total, - TRANSACTION_FEE - ); - } - let common_fee_tree = common_fee_input_tree(&fee_inputs)?; - if let Some(expected_tree) = target_tree.as_deref() { - if !decoded_hex_eq(expected_tree, common_fee_tree) { - anyhow::bail!("Exact fee-input owner script does not match the requested change owner"); - } - } - - 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 { - ergo_tree_to_p2pk_address(common_fee_tree)? - }; - - 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!({}) - })); - fee_input_binaries.push(fee_box.binary.clone()); - } - - 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 - ] - }); - ensure_secret_free_artifact(&transaction_json)?; - - 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, - 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(), - }) -} - -/// 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 { - // Resolve both witnesses before any network request, proof request, build, or - // broadcast. There is no node-wallet key-export fallback. - let recipient_secret = resolve_dlog_secret(&recipient_secret, "recipient")?; - let fee_secret = resolve_dlog_secret(&fee_secret, "fee-payer")?; - let recipient_witness = SecretKey::dlog_from_bytes(&recipient_secret) - .ok_or_else(|| anyhow::anyhow!("invalid recipient dlog secret"))?; - let fee_witness = SecretKey::dlog_from_bytes(&fee_secret) - .ok_or_else(|| anyhow::anyhow!("invalid fee-payer dlog secret"))?; - - 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 { - 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_witness, - fee_witness, - }) - .await?; - - // A node-accepted transaction is intentionally not promoted to settled here. - // The confirmed-chain reconciler owns the local settlement transition. - - 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 { - require_local_signing(local_sign)?; - - 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}; - - // The local reserve-input witness is mandatory and is resolved before any - // tracker request or transaction construction. - let receiver_secret = resolve_dlog_secret(&recipient_secret, "recipient")?; - let receiver_sk = SecretKey::dlog_from_bytes(&receiver_secret) - .ok_or_else(|| anyhow::anyhow!("invalid recipient dlog secret"))?; - - 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, - }) - .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"))?; - - // 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) - .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 -} - -#[derive(Debug, Clone)] -struct VerifiedFeeInput { - box_id: String, - value: u64, - ergo_tree: String, - assets: Vec, - binary: String, -} - -fn decoded_hex_eq(left: &str, right: &str) -> bool { - match (hex::decode(left), hex::decode(right)) { - (Ok(left), Ok(right)) => left == right, - _ => false, - } -} - -fn fee_assets_match(claimed: &[crate::api::Token], canonical: &[crate::api::Token]) -> bool { - claimed.len() == canonical.len() - && claimed.iter().zip(canonical).all(|(left, right)| { - decoded_hex_eq(&left.token_id, &right.token_id) && left.amount == right.amount - }) -} - -/// Parse the exact Sigma bytes used by the signer and reject any disagreement -/// with the wallet-list JSON that was used only to select candidates. -fn verify_fee_input_claim( - claimed: &crate::api::ErgoBoxDetails, - binary: String, -) -> Result { - let bytes = hex::decode(&binary) - .map_err(|e| anyhow::anyhow!("fee box {} binary hex: {}", claimed.box_id, e))?; - let ergo_box = ErgoBox::sigma_parse_bytes(&bytes) - .map_err(|e| anyhow::anyhow!("fee box {} Sigma parse: {:?}", claimed.box_id, e))?; - let box_id = ergo_box.box_id().to_string(); - let value = *ergo_box.value.as_u64(); - let ergo_tree = - hex::encode(ergo_box.ergo_tree.sigma_serialize_bytes().map_err(|e| { - anyhow::anyhow!("fee box {} script serialize: {:?}", claimed.box_id, e) - })?); - let assets: Vec = ergo_box - .tokens - .as_ref() - .map(|tokens| { - tokens - .iter() - .map(|token| crate::api::Token { - token_id: hex::encode(token.token_id.as_ref()), - amount: *token.amount.as_u64(), - }) - .collect() - }) - .unwrap_or_default(); - - if !decoded_hex_eq(&claimed.box_id, &box_id) { - anyhow::bail!( - "fee box id mismatch: wallet list claimed {}, exact Sigma box is {}", - claimed.box_id, - box_id - ); - } - if claimed.value != value { - anyhow::bail!( - "fee box {} value mismatch: wallet list claimed {}, exact Sigma box is {}", - box_id, - claimed.value, - value - ); - } - if !decoded_hex_eq(&claimed.ergo_tree, &ergo_tree) { - anyhow::bail!( - "fee box {} script mismatch between wallet list and exact Sigma box", - box_id - ); - } - if !fee_assets_match(&claimed.assets, &assets) { - anyhow::bail!( - "fee box {} assets mismatch between wallet list and exact Sigma box", - box_id - ); - } - - Ok(VerifiedFeeInput { - box_id, - value, - ergo_tree, - assets, - binary, - }) -} - -fn authoritative_fee_input_total(fee_inputs: &[VerifiedFeeInput]) -> Result { - fee_inputs.iter().try_fold(0u64, |total, box_| { - total - .checked_add(box_.value) - .ok_or_else(|| anyhow::anyhow!("fee input value overflow")) - }) -} - -fn common_fee_input_tree(fee_inputs: &[VerifiedFeeInput]) -> Result<&str> { - let first = fee_inputs - .first() - .ok_or_else(|| anyhow::anyhow!("no verified fee inputs"))?; - if fee_inputs - .iter() - .any(|fee_input| !decoded_hex_eq(&fee_input.ergo_tree, &first.ergo_tree)) - { - anyhow::bail!("exact fee inputs do not share one owner script"); - } - Ok(&first.ergo_tree) -} - -/// 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> { - 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_witness: SecretKey, - fee_witness: SecretKey, -} - -fn resolve_dlog_secret(provided: &Option, label: &str) -> Result<[u8; 32]> { - let hexstr = provided.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "{} signing witness is required locally; private keys are never exported from a node wallet", - label - ) - })?; - 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]; - - 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, - &p.fee_witness, - ) - .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, - &p.recipient_witness, - ) - .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 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 ergo_lib::ergotree_ir::chain::ergo_box::{box_value::BoxValue, NonMandatoryRegisters}; - use ergo_lib::ergotree_ir::chain::tx_id::TxId; - use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; - 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"; - - fn exact_fee_claim( - pubkey: &str, - value: u64, - index: u16, - ) -> (crate::api::ErgoBoxDetails, String) { - let tree_hex = format!("0008cd{pubkey}"); - let tree = ErgoTree::sigma_parse_bytes(&hex::decode(&tree_hex).unwrap()).unwrap(); - let ergo_box = ErgoBox::new( - BoxValue::try_from(value).unwrap(), - tree, - None, - NonMandatoryRegisters::empty(), - 100, - TxId::zero(), - index, - ) - .unwrap(); - let binary = hex::encode(ergo_box.sigma_serialize_bytes().unwrap()); - let claim = crate::api::ErgoBoxDetails { - box_id: ergo_box.box_id().to_string(), - value, - ergo_tree: tree_hex, - assets: Vec::new(), - additional_registers: Default::default(), - creation_height: 100, - transaction_id: TxId::zero().to_string(), - index, - }; - (claim, binary) - } - - /// 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)); - } - - #[test] - fn non_local_generation_is_rejected_before_secret_export() { - let error = require_local_signing(false).unwrap_err().to_string(); - assert!(error.contains("retired")); - assert!(require_local_signing(true).is_ok()); - } - - #[test] - fn transaction_artifact_rejects_secret_bearing_fields() { - let sentinel = "sentinel-private-key-do-not-export"; - let artifact = serde_json::json!({ - "tx": {"inputs": [], "dataInputs": [], "outputs": []}, - "secrets": {"dlog": [sentinel]} - }); - assert!(ensure_secret_free_artifact(&artifact).is_err()); - - let safe = serde_json::json!({ - "tx": {"inputs": [], "dataInputs": [], "outputs": []}, - "inputsRaw": [], - "dataInputsRaw": [] - }); - assert!(ensure_secret_free_artifact(&safe).is_ok()); - assert!(!safe.to_string().contains(sentinel)); - } - - #[test] - fn missing_witness_never_falls_back_to_node_wallet_export() { - let error = resolve_dlog_secret(&None, "fee-payer") - .unwrap_err() - .to_string(); - assert!(error.contains("never exported")); - } - - #[test] - fn exact_fee_input_rejects_id_mismatch() { - let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; - let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); - claim.box_id = "00".repeat(32); - - let error = verify_fee_input_claim(&claim, binary) - .unwrap_err() - .to_string(); - assert!(error.contains("id mismatch")); - } - - #[test] - fn exact_fee_input_rejects_script_mismatch() { - let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; - let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); - claim.ergo_tree = format!( - "0008cd{}", - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - ); - - let error = verify_fee_input_claim(&claim, binary) - .unwrap_err() - .to_string(); - assert!(error.contains("script mismatch")); - } - - #[test] - fn exact_fee_input_rejects_value_mismatch() { - let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; - let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); - claim.value += 1; - - let error = verify_fee_input_claim(&claim, binary) - .unwrap_err() - .to_string(); - assert!(error.contains("value mismatch")); - } - - #[test] - fn exact_fee_input_rejects_asset_mismatch() { - let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; - let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); - claim.assets.push(crate::api::Token { - token_id: "aa".repeat(32), - amount: 1, - }); - - let error = verify_fee_input_claim(&claim, binary) - .unwrap_err() - .to_string(); - assert!(error.contains("assets mismatch")); - } -} 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_offchain/src/lib.rs b/crates/basis_offchain/src/lib.rs index b059614..1291680 100644 --- a/crates/basis_offchain/src/lib.rs +++ b/crates/basis_offchain/src/lib.rs @@ -1,8 +1,7 @@ //! Offchain logic for Basis tracker. //! -//! The placeholder v1 transaction-builder surface is retained only as a -//! historical unit-test fixture. Production construction resumes with the -//! versioned v2 builder. +//! 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; @@ -10,9 +9,6 @@ pub mod ergo_tx; pub mod schnorr; -pub mod signing; -#[cfg(test)] -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/api.rs b/crates/basis_server/src/api.rs index f2b2ca4..9e18bdc 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -1,6 +1,5 @@ use axum::{extract::State, http::StatusCode, Json}; use std::collections::HashMap; -use std::time::{SystemTime, UNIX_EPOCH}; use crate::{ models::{ @@ -14,137 +13,7 @@ use crate::{ }, 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 { @@ -1715,1128 +1584,55 @@ pub async fn complete_redemption( ) } -// Get tracker lookup proof for context var #8 -// Following specs/server/redemption_transaction_format_spec.md - GET /tracker/proof +/// Retired v1 tracker-proof endpoint. #[axum::debug_handler] pub async fn get_tracker_proof( - State(state): State, - axum::extract::Query(params): axum::extract::Query>, + 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(), - )), - ); - } - }; - - // Request the lookup proof and its exact validated BNS2-backed root from - // the owning tracker actor in one serialized command. - 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, tracker_state))) => { - let tracker_state_digest = hex::encode(tracker_state.avl_root_digest); - // 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(), - )), - ) - } - } + crate::reject_retired_v1_redemption() } -// Get reserve lookup proof for context var #7 -// Following specs/server/redemption_transaction_format_spec.md - GET /reserve/proof +/// Retired v1 reserve-proof endpoint. #[axum::debug_handler] pub async fn get_reserve_proof( - State(state): State, - axum::extract::Query(params): axum::extract::Query>, + 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, lookup_root))) => { - // 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(), - )), - ); - } - }; - - if lookup_root != insert_proof.2 { - return ( - StatusCode::CONFLICT, - Json(crate::models::error_response( - "Reserve state changed while generating proofs; retry".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(), - )), - ) - } - } + crate::reject_retired_v1_redemption() } -// Request tracker signature for redemption -// Following specs/server/redemption_state_spec.md - POST /tracker/signature +/// Retired v1 tracker-signature endpoint. #[axum::debug_handler] pub async fn request_tracker_signature( - State(state): State, - Json(payload): Json, + State(_state): State, + Json(_payload): Json, ) -> (StatusCode, Json>) { - tracing::debug!("Requesting tracker signature for redemption: {:?}", payload); + crate::reject_retired_v1_redemption() +} - // 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)), - ) -} - -// Prepare redemption with all necessary data -// Following specs/server/redemption_state_spec.md - POST /redemption/prepare +/// Retired v1 redemption-preparation endpoint. #[axum::debug_handler] pub async fn prepare_redemption( - State(state): State, - Json(payload): Json, + 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), - )), - ); - } - - // Generate a proof together with the exact validated actor-owned root. - 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, tracker_state_digest) = match proof_response_rx.await { - Ok(Ok((note_proof, tracker_state))) => ( - hex::encode(¬e_proof.avl_proof), - hex::encode(tracker_state.avl_root_digest), - ), - 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)), - ) + crate::reject_retired_v1_redemption() } -// Enhanced proof endpoint specifically for redemption +/// Retired v1 aggregate redemption-proof endpoint. #[axum::debug_handler] pub async fn get_redemption_proof( - State(state): State, - axum::extract::Query(params): axum::extract::Query>, + 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(), - )), - ); - } - } - - // Generate a proof together with the exact validated actor-owned root. - 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, tracker_state_digest) = match proof_response_rx.await { - Ok(Ok((note_proof, tracker_state))) => ( - hex::encode(¬e_proof.avl_proof), - hex::encode(tracker_state.avl_root_digest), - ), - 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))) + crate::reject_retired_v1_redemption() } // Get the latest tracker box ID from the tracker storage diff --git a/crates/basis_server/src/create_reserve_tests.rs b/crates/basis_server/src/create_reserve_tests.rs index 5fc8afc..1af9f39 100644 --- a/crates/basis_server/src/create_reserve_tests.rs +++ b/crates/basis_server/src/create_reserve_tests.rs @@ -11,7 +11,7 @@ mod create_reserve_tests { use crate::{ api::create_reserve_payload, models::CreateReserveRequest, reserve_construction_routes, - AppState, TrackerCommand, + retired_v1_redemption_routes, AppState, TrackerCommand, }; use basis_store::ergo_scanner::{NodeConfig, ServerState}; use tower::ServiceExt; @@ -208,7 +208,9 @@ mod create_reserve_tests { "owner_pubkey": "03e8c3e4877e2f7b79e0e407421a81a1619ea64e37e5e4e77454d1e361e6f80b12", "erg_amount": 1_000_000 }); - let app = reserve_construction_routes().with_state(state); + let app = reserve_construction_routes() + .merge(retired_v1_redemption_routes()) + .with_state(state); let requests = [ (Method::GET, "/config/reserve-contract-p2s", Body::empty()), ( @@ -235,11 +237,106 @@ mod create_reserve_tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{path}"); + 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 2317aca..db92807 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -13,8 +13,9 @@ pub mod tracker_box_updater; mod create_reserve_tests; use axum::{ + http::StatusCode, routing::{get, post}, - Router, + Json, Router, }; use tokio::sync::Mutex; @@ -66,6 +67,16 @@ pub async fn handle_options() -> impl axum::response::IntoResponse { ) } +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. @@ -75,13 +86,45 @@ pub fn reserve_construction_routes() -> Router { "/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 { + 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( - "/config/reserve-contract-p2s", - get(api::get_basis_reserve_contract_p2s), + "/redemption/submit", + post(redemption_build::submit_redemption).options(handle_options), ) } diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 39aface..193dd8e 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -3,10 +3,10 @@ use axum::{ Router, }; use basis_server::{ - api::*, handle_options, reserve_api::*, reserve_construction_routes, store::EventStore, - submit_redemption, AppConfig, AppState, EventType, PublicationLease, SharedTrackerState, - TrackerBoxUpdateConfig, - TrackerBoxUpdater, TrackerCommand, TrackerEvent, + 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, ReserveEvent, ServerState}, @@ -99,7 +99,7 @@ fn restore_pending_publication( fn handle_command_while_publication_is_fenced( active_lease: PublicationLease, command: TrackerCommand, - redemption_manager: &mut basis_store::RedemptionManager, + tracker: &mut basis_store::TrackerStateManager, shared_state: &SharedTrackerState, ) -> Option { match command { @@ -109,19 +109,12 @@ fn handle_command_while_publication_is_fenced( submitted_height, response_tx, } if lease == active_lease => { - let result = redemption_manager - .tracker - .validated_state() - .and_then(|state| { - if state.avl_root_digest != lease.digest { - return Err(basis_store::NoteError::PublicationLeaseMismatch); - } - redemption_manager.tracker.mark_notes_pending( - lease.digest, - &tx_id, - submitted_height, - ) - }); + 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(); } @@ -134,9 +127,7 @@ fn handle_command_while_publication_is_fenced( height, response_tx, } => { - let result = redemption_manager - .tracker - .confirm_pending_publication(&tx_id, &box_id, height); + let result = tracker.confirm_pending_publication(&tx_id, &box_id, height); let confirmed = result.is_ok(); if result.as_ref().is_err_and(|error| { !matches!(error, basis_store::NoteError::PublicationLeaseMismatch) @@ -151,16 +142,13 @@ fn handle_command_while_publication_is_fenced( } } TrackerCommand::AbortPublication { lease, response_tx } if lease == active_lease => { - let result = redemption_manager - .tracker - .pending_publication() - .and_then(|pending| { - if pending.is_some() { - Err(basis_store::NoteError::PublicationInProgress) - } else { - Ok(()) - } - }); + 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 { @@ -390,7 +378,7 @@ async fn main() { // Spawn tracker thread (using tokio::task::spawn_blocking for CPU-bound work) tokio::task::spawn_blocking(move || { tracing::debug!("Tracker thread started"); - let tracker = match basis_store::TrackerStateManager::try_new_with_publication_health( + let mut tracker = match basis_store::TrackerStateManager::try_new_with_publication_health( &data_dir_for_tracker_thread, generation, shared_state_for_tracker.publication_health(), @@ -426,7 +414,6 @@ async fn main() { hex::encode(&initial_root) ); - let mut redemption_manager = RedemptionManager::new(tracker); let mut next_publication_id = 1u64; while let Some(cmd) = rx.blocking_recv() { @@ -436,7 +423,7 @@ async fn main() { active_publication = handle_command_while_publication_is_fenced( active_lease, cmd, - &mut redemption_manager, + &mut tracker, &shared_state_for_tracker, ); continue; @@ -473,7 +460,7 @@ async fn main() { candidate_total_debt, response_tx, } => { - let result = redemption_manager.tracker.projected_issuer_gross_debt( + let result = tracker.projected_issuer_gross_debt( &issuer_pubkey, candidate_recipient.as_ref(), candidate_total_debt, @@ -513,15 +500,9 @@ async fn main() { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker + let result = tracker .generate_proof(&issuer_pubkey, &recipient_pubkey) - .and_then(|proof| { - redemption_manager - .tracker - .validated_state() - .map(|state| (proof, state)) - }); + .and_then(|proof| tracker.validated_state().map(|state| (proof, state))); let _ = response_tx.send(result); } TrackerCommand::GetTrackerLookupProof { @@ -529,15 +510,9 @@ async fn main() { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker + let result = tracker .generate_tracker_lookup_proof(&issuer_pubkey, &recipient_pubkey) - .and_then(|proof| { - redemption_manager - .tracker - .validated_state() - .map(|state| (proof, state)) - }); + .and_then(|proof| tracker.validated_state().map(|state| (proof, state))); let _ = response_tx.send(result); } TrackerCommand::GetReserveLookupProof { @@ -545,15 +520,9 @@ async fn main() { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker + let result = tracker .generate_reserve_lookup_proof(&issuer_pubkey, &recipient_pubkey) - .and_then(|proof| { - redemption_manager - .tracker - .reserve_state_digest() - .map(|root| (proof, root)) - }); + .and_then(|proof| tracker.reserve_state_digest().map(|root| (proof, root))); let _ = response_tx.send(result); } TrackerCommand::GetReserveInsertProof { @@ -563,8 +532,7 @@ async fn main() { new_already_redeemed, response_tx, } => { - let result = redemption_manager - .tracker + let result = tracker .generate_reserve_insert_proof( &issuer_pubkey, &recipient_pubkey, @@ -572,8 +540,7 @@ async fn main() { new_already_redeemed, ) .and_then(|(proof, updated_root)| { - redemption_manager - .tracker + tracker .reserve_state_digest() .map(|current_root| (proof, updated_root, current_root)) }); @@ -584,7 +551,7 @@ async fn main() { let _ = response_tx.send(digest); } TrackerCommand::GetValidatedState { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.validated_state()); + let _ = response_tx.send(tracker.validated_state()); } TrackerCommand::GetConfirmation { issuer_pubkey, @@ -595,10 +562,9 @@ async fn main() { let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let result = redemption_manager - .tracker + let result = tracker .validated_state() - .map(|_| redemption_manager.tracker.all_confirmations()); + .map(|_| tracker.all_confirmations()); let _ = response_tx.send(result); } TrackerCommand::BeginPublication { @@ -608,16 +574,15 @@ async fn main() { height, response_tx, } => { - let result = redemption_manager - .tracker + let result = tracker .validate_observed_generation(&tracker_nft_id, observed_root) .and_then(|_| { - redemption_manager.tracker.reconcile_with_confirmed_digest( + tracker.reconcile_with_confirmed_digest( &observed_root, &box_id, height, )?; - redemption_manager.tracker.validated_state() + tracker.validated_state() }) .and_then(|state| { next_publication_id @@ -918,28 +883,8 @@ 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/submit", - post(submit_redemption).options(handle_options), - ) .route("/reserves", get(get_all_reserves)) .route( "/reserves/submit", @@ -959,6 +904,7 @@ 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)) + .merge(retired_v1_redemption_routes()) .merge(reserve_construction_routes()) .with_state(app_state.clone()) .layer(tower_http::trace::TraceLayer::new_for_http()) @@ -985,7 +931,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"); @@ -1133,7 +1079,7 @@ mod publication_fence_tests { Some((tx_id.clone(), digest)) ); - let mut redemption_manager = basis_store::RedemptionManager::new(reopened); + let mut tracker = reopened; let (state_tx, state_rx) = tokio::sync::oneshot::channel(); active = handle_command_while_publication_is_fenced( @@ -1141,7 +1087,7 @@ mod publication_fence_tests { TrackerCommand::GetValidatedState { response_tx: state_tx, }, - &mut redemption_manager, + &mut tracker, &restarted_shared, ); assert!(matches!( @@ -1157,7 +1103,7 @@ mod publication_fence_tests { note: note.clone(), response_tx: add_tx, }, - &mut redemption_manager, + &mut tracker, &restarted_shared, ); assert!(matches!( @@ -1174,7 +1120,7 @@ mod publication_fence_tests { height: 200, response_tx: wrong_tx, }, - &mut redemption_manager, + &mut tracker, &restarted_shared, ); assert!(matches!( @@ -1184,12 +1130,7 @@ mod publication_fence_tests { assert!(active.is_some()); assert!(restarted_shared.is_publication_healthy()); assert_eq!( - redemption_manager - .tracker - .pending_publication() - .unwrap() - .unwrap() - .tx_id(), + tracker.pending_publication().unwrap().unwrap().tx_id(), tx_id ); assert_eq!( @@ -1206,16 +1147,12 @@ mod publication_fence_tests { height: 201, response_tx: confirm_tx, }, - &mut redemption_manager, + &mut tracker, &restarted_shared, ); assert!(matches!(confirm_rx.await, Ok(Ok(_)))); assert!(active.is_none()); - assert!(redemption_manager - .tracker - .pending_publication() - .unwrap() - .is_none()); + assert!(tracker.pending_publication().unwrap().is_none()); // The updater clears its shared pending cache only after the actor has // durably accepted the exact confirmation. diff --git a/crates/basis_server/src/redemption_build.rs b/crates/basis_server/src/redemption_build.rs index 70566a4..5d4d349 100644 --- a/crates/basis_server/src/redemption_build.rs +++ b/crates/basis_server/src/redemption_build.rs @@ -1,84 +1,40 @@ -//! 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)] +//! This module deliberately contains no transaction construction, proof, +//! signing, node submission, broadcast, or state-mutation implementation. use axum::{extract::State, http::StatusCode, Json}; +use ergo_lib::ergo_chain_types::Header; 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"; +use serde_json::Value; -// ----- request / response models ----- +use crate::models::ApiResponse; +use crate::AppState; +/// Legacy request shape retained only so stale clients receive HTTP 410 rather +/// than reaching an alternate deserialization path. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] 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, } +/// Legacy response shape retained for source compatibility only. No production +/// function can construct or return a successful instance. #[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, @@ -90,10 +46,6 @@ pub struct RedemptionBuildResponse { 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, } @@ -108,1329 +60,50 @@ pub struct RedemptionSubmitResponse { pub tx_id: String, } -// ----- node client ----- - -#[derive(Debug, Clone, Deserialize)] -struct NodeAsset { - #[serde(alias = "tokenId")] - token_id: String, - 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, -} - -#[derive(Debug, Clone)] -struct VerifiedFeeBox { - box_id: String, - value: u64, - ergo_tree: String, - assets: Vec, - binary: String, - ergo_box: ErgoBox, -} - -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, -} - -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()), - } - } - - 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 -} - -fn decoded_hex_eq(left: &str, right: &str) -> bool { - match (hex::decode(left), hex::decode(right)) { - (Ok(left), Ok(right)) => left == right, - _ => false, - } -} - -fn assets_match(claimed: &[NodeAsset], canonical: &[NodeAsset]) -> bool { - claimed.len() == canonical.len() - && claimed.iter().zip(canonical).all(|(left, right)| { - decoded_hex_eq(&left.token_id, &right.token_id) && left.amount == right.amount - }) -} - -/// Parse the exact Sigma bytes later supplied to the prover and reject any -/// disagreement with the wallet-list JSON that was used only for selection. -fn verify_fee_box_claim(claimed: &NodeBox, binary: String) -> Result { - let bytes = - hex::decode(&binary).map_err(|e| format!("fee box {} binary hex: {e}", claimed.box_id))?; - let ergo_box = ErgoBox::sigma_parse_bytes(&bytes) - .map_err(|e| format!("fee box {} Sigma parse: {e:?}", claimed.box_id))?; - - let box_id = ergo_box.box_id().to_string(); - let value = *ergo_box.value.as_u64(); - let ergo_tree = hex::encode( - ergo_box - .ergo_tree - .sigma_serialize_bytes() - .map_err(|e| format!("fee box {} script serialize: {e:?}", claimed.box_id))?, - ); - let assets: Vec = ergo_box - .tokens - .as_ref() - .map(|tokens| { - tokens - .iter() - .map(|token| NodeAsset { - token_id: hex::encode(token.token_id.as_ref()), - amount: *token.amount.as_u64(), - }) - .collect() - }) - .unwrap_or_default(); - - if !decoded_hex_eq(&claimed.box_id, &box_id) { - return Err(format!( - "fee box id mismatch: wallet list claimed {}, exact Sigma box is {}", - claimed.box_id, box_id - )); - } - if claimed.value != value { - return Err(format!( - "fee box {} value mismatch: wallet list claimed {}, exact Sigma box is {}", - box_id, claimed.value, value - )); - } - if !decoded_hex_eq(&claimed.ergo_tree, &ergo_tree) { - return Err(format!( - "fee box {} script mismatch between wallet list and exact Sigma box", - box_id - )); - } - if !assets_match(&claimed.assets, &assets) { - return Err(format!( - "fee box {} assets mismatch between wallet list and exact Sigma box", - box_id - )); - } - - Ok(VerifiedFeeBox { - box_id, - value, - ergo_tree, - assets, - binary, - ergo_box, - }) -} - -fn authoritative_fee_total(fee_boxes: &[VerifiedFeeBox]) -> Result { - fee_boxes.iter().try_fold(0u64, |total, box_| { - total - .checked_add(box_.value) - .ok_or_else(|| "fee input value overflow".to_string()) - }) -} - -/// Derive fee-input change from the exact script whose inputs the tracker signs. -/// Mixed-script funding is rejected because one shared change output would have -/// ambiguous ownership even if global value conservation still held. -fn fee_change_address(fee_boxes: &[VerifiedFeeBox]) -> Result { - let first = fee_boxes - .first() - .ok_or_else(|| "no selected fee inputs".to_string())?; - if fee_boxes - .iter() - .any(|fee_box| fee_box.ergo_tree != first.ergo_tree) - { - return Err("selected fee inputs do not share one owner script".to_string()); - } - ergo_tree_to_p2pk_address(&first.ergo_tree) -} - -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, + 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, - } + crate::reject_retired_v1_redemption() } -async fn build_redemption_inner( - state: &AppState, - payload: &RedemptionBuildRequest, -) -> ApiResult { - if let Err(e) = state.config.reject_unsupported_reserve_builder() { - return api_err(StatusCode::SERVICE_UNAVAILABLE, e); - } - - // 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, selected_reserve_digest) = { - // 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(Ok(d)) => d, - Ok(Err(e)) => { - return api_err( - StatusCode::SERVICE_UNAVAILABLE, - format!("tracker reserve state unavailable: {e:?}"), - ) - } - Err(_) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "tracker unavailable"), - } - }; - let tracker_reserve_digest_hex = hex::encode(&tracker_reserve_digest); - - 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_hex => { - 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_hex[..16.min(tracker_reserve_digest_hex.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, tracker_reserve_digest), - 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_hex - [..16.min(tracker_reserve_digest_hex.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, _state))) => { - 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, lookup_root) = match rx.await { - Ok(Ok(result)) => result, - 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, insert_current_root) = 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"), - }; - if lookup_root != insert_current_root || lookup_root != selected_reserve_digest { - return api_err( - StatusCode::CONFLICT, - "reserve state changed while building proofs; retry from a fresh snapshot", - ); - } - ( - 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_box_claims, _) = 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"), - ) - } - }; - - // `/wallet/boxes/unspent` is selection metadata only. Fetch and parse the - // exact bytes that will be supplied to the prover, then bind every - // transaction-relevant field to those bytes. - let mut fee_boxes = Vec::with_capacity(fee_box_claims.len()); - for claim in &fee_box_claims { - let binary = match node.box_binary(&claim.box_id).await { - Ok(binary) => binary, - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("fee bin {}: {e}", claim.box_id), - ) - } - }; - let verified = match verify_fee_box_claim(claim, binary) { - Ok(verified) => verified, - Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, e), - }; - fee_boxes.push(verified); - } - let fee_total = match authoritative_fee_total(&fee_boxes) { - Ok(total) if total >= fee => total, - Ok(total) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("exact fee inputs provide {total} nanoERG, below required {fee} nanoERG"), - ) - } - Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, e), - }; - if fee_boxes.iter().any(|fee_box| !fee_box.assets.is_empty()) { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "exact fee inputs unexpectedly contain tokens", - ); - } - - // Change belongs to the authenticated owner of every selected fee input. - let change_address = match fee_change_address(&fee_boxes) { - Ok(address) => address, - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("fee change: {e}"), - ) - } - }; - - // 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 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 fee_eboxes: Vec = fee_boxes - .iter() - .map(|fee_box| fee_box.ergo_box.clone()) - .collect(); - - 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_boxes.iter().map(|fee_box| fee_box.binary.clone())); - - 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, - }) -} - -/// Broadcast a fully-signed redemption transaction to the Ergo node. -/// -/// Node acceptance is not active-chain confirmation. This endpoint deliberately -/// accepts no accounting metadata and performs no local settlement mutation; -/// the confirmed-chain reconciler owns that transition. +/// 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, + State(_state): State, + Json(_payload): Json, ) -> (StatusCode, Json>) { - 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}"))), - ) - } - }; - - ( - StatusCode::ACCEPTED, - Json(success_response(RedemptionSubmitResponse { tx_id })), - ) + crate::reject_retired_v1_redemption() } #[cfg(test)] mod tests { use super::*; - use ergo_lib::ergotree_ir::chain::ergo_box::{box_value::BoxValue, NonMandatoryRegisters}; - use ergo_lib::ergotree_ir::chain::tx_id::TxId; - use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; - - #[test] - fn submit_request_rejects_unverified_accounting_metadata() { - let payload = serde_json::json!({ - "signed_tx": {"inputs": [], "dataInputs": [], "outputs": []}, - "issuer_pubkey": "02".repeat(33), - "recipient_pubkey": "03".repeat(33), - "redeemed_amount": 1, - "new_already_redeemed": 1 - }); - - assert!(serde_json::from_value::(payload).is_err()); - } - - 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(), - } - } - - fn exact_fee_claim(pubkey: &str, value: u64, index: u16) -> (NodeBox, String) { - let tree_hex = format!("0008cd{pubkey}"); - let tree = ErgoTree::sigma_parse_bytes(&hex::decode(&tree_hex).unwrap()).unwrap(); - let ergo_box = ErgoBox::new( - BoxValue::try_from(value).unwrap(), - tree, - None, - NonMandatoryRegisters::empty(), - 100, - TxId::zero(), - index, - ) - .unwrap(); - let binary = hex::encode(ergo_box.sigma_serialize_bytes().unwrap()); - let claim = NodeBox { - box_id: ergo_box.box_id().to_string(), - value, - ergo_tree: tree_hex, - assets: Vec::new(), - additional_registers: Default::default(), - }; - (claim, binary) - } - - #[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()); - } #[test] fn build_request_rejects_caller_selected_change() { - let payload = serde_json::json!({ - "issuer_pubkey": "02".repeat(33), - "recipient_pubkey": "03".repeat(33), + let request = serde_json::json!({ + "issuer_pubkey": "02", + "recipient_pubkey": "03", "amount": 1, - "timestamp": 1, - "issuer_signature": "00".repeat(65), - "change_address": "attacker-selected" + "timestamp": 2, + "issuer_signature": "04", + "change_address": "caller-controlled" }); - - assert!(serde_json::from_value::(payload).is_err()); + assert!(serde_json::from_value::(request).is_err()); } #[test] - fn fee_change_is_bound_to_one_input_owner_script() { - let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; - let other_pubkey = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - let (first_claim, first_binary) = exact_fee_claim(pubkey, 600_000, 0); - let (second_claim, second_binary) = exact_fee_claim(pubkey, 600_000, 1); - let first = verify_fee_box_claim(&first_claim, first_binary).unwrap(); - let second = verify_fee_box_claim(&second_claim, second_binary).unwrap(); - - assert_eq!( - fee_change_address(&[first.clone(), second.clone()]).unwrap(), - pubkey_to_address(pubkey).unwrap() - ); - - let (other_claim, other_binary) = exact_fee_claim(other_pubkey, 600_000, 2); - let other = verify_fee_box_claim(&other_claim, other_binary).unwrap(); - assert!(fee_change_address(&[first, other]).is_err()); - } - - #[test] - fn exact_fee_box_rejects_id_mismatch() { - let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; - let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); - claim.box_id = "00".repeat(32); - - let error = verify_fee_box_claim(&claim, binary).unwrap_err(); - assert!(error.contains("id mismatch")); - } - - #[test] - fn exact_fee_box_rejects_script_mismatch() { - let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; - let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); - claim.ergo_tree = format!( - "0008cd{}", - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - ); - - let error = verify_fee_box_claim(&claim, binary).unwrap_err(); - assert!(error.contains("script mismatch")); - } - - #[test] - fn exact_fee_box_rejects_value_mismatch() { - let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; - let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); - claim.value += 1; - - let error = verify_fee_box_claim(&claim, binary).unwrap_err(); - assert!(error.contains("value mismatch")); - } - - #[test] - fn exact_fee_box_rejects_asset_mismatch() { - let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; - let (mut claim, binary) = exact_fee_claim(pubkey, 1_000_000, 0); - claim.assets.push(NodeAsset { - token_id: "aa".repeat(32), - amount: 1, + fn submit_request_rejects_unverified_accounting_metadata() { + let request = serde_json::json!({ + "signed_tx": {}, + "issuer_pubkey": "02", + "new_already_redeemed": 99 }); - - let error = verify_fee_box_claim(&claim, binary).unwrap_err(); - assert!(error.contains("assets mismatch")); - } - - 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()); + assert!(serde_json::from_value::(request).is_err()); } } diff --git a/crates/basis_server/tests/cors_tests.rs b/crates/basis_server/tests/cors_tests.rs index 6d9ee6a..bd92102 100644 --- a/crates/basis_server/tests/cors_tests.rs +++ b/crates/basis_server/tests/cors_tests.rs @@ -76,7 +76,7 @@ mod cors_tests { candidate_total_debt, response_tx, } => { - let result = redemption_manager.tracker.projected_issuer_gross_debt( + let result = tracker.projected_issuer_gross_debt( &issuer_pubkey, candidate_recipient.as_ref(), candidate_total_debt, @@ -116,10 +116,7 @@ mod cors_tests { avl_proof: vec![1, 2, 3, 4], // Mock proof data operations: vec![], }; - let result = redemption_manager - .tracker - .validated_state() - .map(|state| (mock_proof, state)); + let result = tracker.validated_state().map(|state| (mock_proof, state)); let _ = response_tx.send(result); } TrackerCommand::GetTrackerLookupProof { @@ -133,10 +130,7 @@ mod cors_tests { value: vec![0u8; 8], proof: vec![1, 2, 3, 4], }; - let result = redemption_manager - .tracker - .validated_state() - .map(|state| (mock_proof, state)); + let result = tracker.validated_state().map(|state| (mock_proof, state)); let _ = response_tx.send(result); } TrackerCommand::GetReserveLookupProof { @@ -150,8 +144,7 @@ mod cors_tests { value: vec![0u8; 8], proof: Some(vec![1, 2, 3, 4]), }; - let result = redemption_manager - .tracker + let result = tracker .reserve_state_digest() .map(|root| (mock_proof, root)); let _ = response_tx.send(result); @@ -164,13 +157,9 @@ mod cors_tests { response_tx, } => { // Mock reserve insert proof - let result = - redemption_manager - .tracker - .reserve_state_digest() - .map(|current_root| { - (vec![1, 2, 3, 4], current_root.clone(), current_root) - }); + let result = tracker.reserve_state_digest().map(|current_root| { + (vec![1, 2, 3, 4], current_root.clone(), current_root) + }); let _ = response_tx.send(result); } TrackerCommand::GetNotesByRecipientWithIssuer { @@ -190,15 +179,14 @@ mod cors_tests { let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = - response_tx.send(Ok(redemption_manager.tracker.all_confirmations())); + let _ = response_tx.send(Ok(tracker.all_confirmations())); } TrackerCommand::GetReserveStateDigest { response_tx } => { let digest = tracker.reserve_state_digest(); let _ = response_tx.send(digest); } TrackerCommand::GetValidatedState { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.validated_state()); + let _ = response_tx.send(tracker.validated_state()); } TrackerCommand::BeginPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); diff --git a/crates/basis_server/tests/http_api_integration_tests.rs b/crates/basis_server/tests/http_api_integration_tests.rs index 2a7f78a..197d0f6 100644 --- a/crates/basis_server/tests/http_api_integration_tests.rs +++ b/crates/basis_server/tests/http_api_integration_tests.rs @@ -77,7 +77,7 @@ mod http_api_tests { candidate_total_debt, response_tx, } => { - let result = redemption_manager.tracker.projected_issuer_gross_debt( + let result = tracker.projected_issuer_gross_debt( &issuer_pubkey, candidate_recipient.as_ref(), candidate_total_debt, @@ -117,10 +117,7 @@ mod http_api_tests { avl_proof: vec![1, 2, 3, 4], // Mock proof data operations: vec![], }; - let result = redemption_manager - .tracker - .validated_state() - .map(|state| (mock_proof, state)); + let result = tracker.validated_state().map(|state| (mock_proof, state)); let _ = response_tx.send(result); } TrackerCommand::GetTrackerLookupProof { @@ -134,10 +131,7 @@ mod http_api_tests { value: vec![0u8; 8], proof: vec![1, 2, 3, 4], }; - let result = redemption_manager - .tracker - .validated_state() - .map(|state| (mock_proof, state)); + let result = tracker.validated_state().map(|state| (mock_proof, state)); let _ = response_tx.send(result); } TrackerCommand::GetReserveLookupProof { @@ -151,8 +145,7 @@ mod http_api_tests { value: vec![0u8; 8], proof: Some(vec![1, 2, 3, 4]), }; - let result = redemption_manager - .tracker + let result = tracker .reserve_state_digest() .map(|root| (mock_proof, root)); let _ = response_tx.send(result); @@ -165,13 +158,9 @@ mod http_api_tests { response_tx, } => { // Mock reserve insert proof - let result = - redemption_manager - .tracker - .reserve_state_digest() - .map(|current_root| { - (vec![1, 2, 3, 4], current_root.clone(), current_root) - }); + let result = tracker.reserve_state_digest().map(|current_root| { + (vec![1, 2, 3, 4], current_root.clone(), current_root) + }); let _ = response_tx.send(result); } TrackerCommand::GetNotesByRecipientWithIssuer { @@ -191,15 +180,14 @@ mod http_api_tests { let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = - response_tx.send(Ok(redemption_manager.tracker.all_confirmations())); + let _ = response_tx.send(Ok(tracker.all_confirmations())); } TrackerCommand::GetReserveStateDigest { response_tx } => { let digest = tracker.reserve_state_digest(); let _ = response_tx.send(digest); } TrackerCommand::GetValidatedState { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.validated_state()); + let _ = response_tx.send(tracker.validated_state()); } TrackerCommand::BeginPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); diff --git a/crates/basis_server/tests/redemption_api_integration_tests.rs b/crates/basis_server/tests/redemption_api_integration_tests.rs index 4ce8a6d..24a646d 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -1,11 +1,11 @@ //! Integration tests for redemption API endpoints //! //! This module tests the redemption-related HTTP endpoints: -//! - POST /redeem: Initiate redemption +//! - POST /redeem: Retired initiation tombstone //! - POST /redeem/complete: Retired completion tombstone -//! - GET /proof/redemption: Get redemption proof -//! - POST /redemption/prepare: Prepare redemption data -//! - POST /tracker/signature: Request tracker signature +//! - GET /proof/redemption: Retired proof tombstone +//! - POST /redemption/prepare: Retired preparation tombstone +//! - POST /tracker/signature: Retired signing tombstone //! //! 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. @@ -24,7 +24,7 @@ mod redemption_api_tests { request_tracker_signature, }, models::{ - CompleteRedemptionRequest, RedeemRequest, RedemptionPreparationRequest, + ApiResponse, CompleteRedemptionRequest, RedeemRequest, RedemptionPreparationRequest, TrackerSignatureRequest, }, AppState, TrackerCommand, @@ -40,6 +40,16 @@ mod redemption_api_tests { /// directory" errors. Holding this lock while creating test storage avoids that. static STORAGE_INIT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn assert_v1_tombstone(status: StatusCode, body: &axum::Json>) { + assert_eq!(status, StatusCode::GONE); + assert!(!body.success); + assert!(body + .error + .as_deref() + .unwrap_or_default() + .contains("v1 redemption is retired")); + } + // ============================================================================ // Test helper: create mock app state with tracker thread handling redemption commands // ============================================================================ @@ -96,7 +106,7 @@ mod redemption_api_tests { candidate_total_debt, response_tx, } => { - let result = redemption_manager.tracker.projected_issuer_gross_debt( + let result = tracker.projected_issuer_gross_debt( &issuer_pubkey, candidate_recipient.as_ref(), candidate_total_debt, @@ -134,10 +144,7 @@ mod redemption_api_tests { avl_proof: vec![1, 2, 3, 4], operations: vec![], }; - let result = redemption_manager - .tracker - .validated_state() - .map(|state| (mock_proof, state)); + let result = tracker.validated_state().map(|state| (mock_proof, state)); let _ = response_tx.send(result); } TrackerCommand::GetTrackerLookupProof { @@ -150,10 +157,7 @@ mod redemption_api_tests { value: vec![0u8; 8], proof: vec![1, 2, 3, 4], }; - let result = redemption_manager - .tracker - .validated_state() - .map(|state| (mock_proof, state)); + let result = tracker.validated_state().map(|state| (mock_proof, state)); let _ = response_tx.send(result); } TrackerCommand::GetReserveLookupProof { @@ -166,8 +170,7 @@ mod redemption_api_tests { value: vec![0u8; 8], proof: Some(vec![1, 2, 3, 4]), }; - let result = redemption_manager - .tracker + let result = tracker .reserve_state_digest() .map(|root| (mock_proof, root)); let _ = response_tx.send(result); @@ -179,13 +182,9 @@ mod redemption_api_tests { new_already_redeemed: _, response_tx, } => { - let result = - redemption_manager - .tracker - .reserve_state_digest() - .map(|current_root| { - (vec![1, 2, 3, 4], current_root.clone(), current_root) - }); + let result = tracker.reserve_state_digest().map(|current_root| { + (vec![1, 2, 3, 4], current_root.clone(), current_root) + }); let _ = response_tx.send(result); } TrackerCommand::GetNotesByRecipientWithIssuer { @@ -204,15 +203,14 @@ mod redemption_api_tests { let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = - response_tx.send(Ok(redemption_manager.tracker.all_confirmations())); + let _ = response_tx.send(Ok(tracker.all_confirmations())); } TrackerCommand::GetReserveStateDigest { response_tx } => { let digest = tracker.reserve_state_digest(); let _ = response_tx.send(digest); } TrackerCommand::GetValidatedState { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.validated_state()); + let _ = response_tx.send(tracker.validated_state()); } TrackerCommand::BeginPublication { response_tx, .. } => { let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); @@ -529,10 +527,7 @@ mod redemption_api_tests { 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()); + assert_v1_tombstone(response.0, &response.1); } #[tokio::test] @@ -554,10 +549,7 @@ mod redemption_api_tests { 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()); + assert_v1_tombstone(response.0, &response.1); } // ============================================================================ @@ -582,10 +574,7 @@ mod redemption_api_tests { 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()); + assert_v1_tombstone(response.0, &response.1); } #[tokio::test] @@ -609,10 +598,7 @@ mod redemption_api_tests { 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()); + assert_v1_tombstone(response.0, &response.1); } #[tokio::test] @@ -634,21 +620,7 @@ mod redemption_api_tests { 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 - ); - } + assert_v1_tombstone(response.0, &response.1); } #[tokio::test] @@ -670,20 +642,7 @@ mod redemption_api_tests { 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 - ); - } + assert_v1_tombstone(response.0, &response.1); } // ============================================================================ @@ -706,10 +665,7 @@ mod redemption_api_tests { 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()); + assert_v1_tombstone(response.0, &response.1); } #[tokio::test] @@ -731,13 +687,7 @@ mod redemption_api_tests { 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()); + assert_v1_tombstone(response.0, &response.1); } #[tokio::test] @@ -757,21 +707,7 @@ mod redemption_api_tests { 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 - ); - } + assert_v1_tombstone(response.0, &response.1); } // ============================================================================ diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index b1f005a..05896b0 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -1,8 +1,8 @@ //! Core data structures for Basis tracker. //! -//! The retired v1 redemption manager and transaction builder are deliberately -//! test-only historical fixtures. They are not part of the production crate -//! API while the v2 BNS2/BRS2 builder is being implemented. +//! 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; @@ -38,12 +38,6 @@ pub mod cross_validation_tests; pub mod cross_verification; pub mod ergo_scanner; pub mod persistence; -#[cfg(test)] -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; @@ -53,8 +47,6 @@ pub mod schnorr_tests; pub mod simple_integration_tests; pub mod tests; pub mod tracker_scanner; -#[cfg(test)] -mod transaction_builder; // Test modules #[cfg(test)] @@ -68,8 +60,6 @@ pub mod real_scanner_integration_tests; #[cfg(test)] pub mod reserve_tracking_test; #[cfg(test)] -pub mod test_helpers; -#[cfg(test)] pub mod tracker_scanner_test; use basis_core; @@ -1894,11 +1884,5 @@ pub use ergo_scanner::{ ScannerError, ServerState, }; -// Re-export redemption types -#[cfg(test)] -pub(crate) use redemption::{ - RedemptionData, RedemptionError, RedemptionManager, RedemptionRequest, -}; - // Re-export reqwest for use in dependent crates pub use reqwest; 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/redemption.rs b/crates/basis_store/src/redemption.rs deleted file mode 100644 index 29f00f0..0000000 --- a/crates/basis_store/src/redemption.rs +++ /dev/null @@ -1,1328 +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). - #[cfg(test)] - pub(crate) fn complete_redemption( - &mut self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - redeemed_amount: u64, - new_already_redeemed: Option, - ) -> Result<(), RedemptionError> { - // Read the signed payment timestamp before recording settlement progress. - // Settlement bookkeeping must never rewrite a signed field without a new - // issuer signature. - let note = self - .tracker - .lookup_note(issuer_pubkey, recipient_pubkey) - .map_err(|_| RedemptionError::NoteNotFound)?; - let payment_timestamp = note.timestamp; - - let updated_note = self - .tracker - .record_redemption_progress(issuer_pubkey, recipient_pubkey, redeemed_amount) - .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(updated_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: settlement bookkeeping must preserve the issuer-signed - /// note timestamp while keeping the reserve AVL value in sync. - #[test] - fn test_complete_redemption_preserves_signed_timestamp_and_syncs_reserve_tree() { - 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_eq!(updated.timestamp, payment_timestamp); - assert_eq!(updated.signature, signature); - updated - .verify_signature(&issuer_pubkey) - .expect("settlement must preserve the issuer signature"); - - // 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().unwrap(), - reference.reserve_state_digest().unwrap(), - "reserve tree must use pre-refresh timestamp and cumulative amount" - ); - - // A second local settlement update accumulates without rewriting signed fields. - 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, - payment_timestamp, - redeemed + 50_000_000, - ) - .expect("reference update 2"); - assert_eq!( - redemption_manager.tracker.reserve_state_digest().unwrap(), - reference2.reserve_state_digest().unwrap(), - "second settlement update must accumulate without rewriting signed fields" - ); - } - - /// 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"); - - 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"); - - // Simulate 100M of tracker-derived settlement progress from an earlier - // reserve. Caller-supplied note creation is not allowed to inject it. - redemption_manager - .tracker - .record_redemption_progress(&issuer_pubkey, &recipient_pubkey, 100_000_000) - .expect("record prior settlement state"); - - // 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().unwrap(), - reference.reserve_state_digest().unwrap(), - "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/test_helpers.rs b/crates/basis_store/src/test_helpers.rs deleted file mode 100644 index ac9b5a6..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() -> crate::transaction_builder::TxContext { - crate::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/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")); - } -} From 12e0280643dfe12313bc71dacbb8462715bf2b45 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:03:29 +0200 Subject: [PATCH 27/41] refactor: remove global v1 redemption surfaces --- crates/basis_app/src/app.rs | 3 - crates/basis_app/src/ui.rs | 70 -- .../tests/legacy_navigation_surface_guard.rs | 23 + crates/basis_cli/src/api.rs | 2 +- crates/basis_cli/src/commands/note.rs | 7 +- crates/basis_cli/tests/local_sign_v3.rs | 44 - crates/basis_server/src/api.rs | 69 +- crates/basis_server/src/lib.rs | 75 +- crates/basis_server/src/main.rs | 82 -- crates/basis_server/src/models.rs | 217 ----- crates/basis_server/src/redemption_build.rs | 93 +- .../tests/api_avl_integration_tests.rs | 46 +- crates/basis_server/tests/cors_tests.rs | 61 -- .../tests/http_api_integration_tests.rs | 61 -- .../tests/legacy_command_surface_guard.rs | 78 ++ .../tests/redemption_api_integration_tests.rs | 892 ++---------------- crates/basis_store/src/basis_spec_tests.rs | 117 +-- crates/basis_store/src/lib.rs | 317 +------ crates/basis_store/src/tests.rs | 2 +- .../tests/legacy_global_surface_guard.rs | 24 + docs/AGENT_INTERFACE.md | 11 +- docs/Alice_Bob_Redemption_Test.md | 6 +- docs/OPENAPI.md | 248 +---- docs/TRACKER_BOX_SETUP.md | 5 +- openapi.yaml | 567 ++--------- specs/CLI_TOOLS_ANALYSIS.md | 3 + specs/REMAINING_ENHANCEMENTS.md | 4 +- specs/agent_integration.md | 14 +- specs/client/offchain_redemption_signing.md | 4 + specs/ergo_node_practices.md | 4 + specs/interactive_demo.md | 5 +- specs/legacy_runtime_quarantine.md | 77 +- specs/redemption_cli_spec.md | 252 +---- specs/redemption_execution_report.md | 4 + specs/security_boundary_remediation.md | 88 +- specs/server/basis_server_spec.md | 441 ++------- 36 files changed, 631 insertions(+), 3385 deletions(-) create mode 100644 crates/basis_app/tests/legacy_navigation_surface_guard.rs delete mode 100644 crates/basis_cli/tests/local_sign_v3.rs create mode 100644 crates/basis_server/tests/legacy_command_surface_guard.rs create mode 100644 crates/basis_store/tests/legacy_global_surface_guard.rs 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 1ee5a26..b192918 100644 --- a/crates/basis_app/src/ui.rs +++ b/crates/basis_app/src/ui.rs @@ -17,15 +17,6 @@ pub const _MAGENTA: &str = "\x1b[35m"; pub const WHITE: &str = "\x1b[37m"; pub const GRAY: &str = "\x1b[90m"; -const V1_REDEMPTION_RETIRED: &str = - "Basis v1 redemption is retired; v2 stays disabled until confirmed-chain authority and the validated-manifest signing path are integrated"; - -fn reject_retired_v1_redemption_before_effects( - _effect: impl FnOnce() -> T, -) -> Result { - Err(V1_REDEMPTION_RETIRED) -} - pub async fn run(app: &mut App) -> Result<()> { clear_screen(); if app.intro_account.is_some() { @@ -46,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?, } } @@ -612,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); @@ -656,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); @@ -746,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); @@ -907,17 +874,6 @@ async fn draw_create_note(app: &mut App) -> Result<()> { Ok(()) } -async fn draw_redeem_note(app: &mut App) -> Result<()> { - let message = reject_retired_v1_redemption_before_effects::<()>(|| { - panic!("retired redemption effect must never run") - }) - .err() - .expect("v1 redemption screen must be retired"); - app.set_notification(message.to_string(), true); - app.navigate_to(Screen::Notes); - Ok(()) -} - async fn draw_create_reserve(app: &mut App) -> Result<()> { println!("{} CREATE RESERVE{}", BOLD, RESET); println!("{} ──────────────{}\n", CYAN, RESET); @@ -997,17 +953,6 @@ async fn draw_create_reserve(app: &mut App) -> Result<()> { Ok(()) } -async fn draw_generate_transaction(app: &mut App) -> Result<()> { - let message = reject_retired_v1_redemption_before_effects::<()>(|| { - panic!("retired transaction-generation effect must never run") - }) - .err() - .expect("v1 transaction-generation screen must be retired"); - app.set_notification(message.to_string(), true); - app.navigate_to(Screen::Transactions); - Ok(()) -} - 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(); @@ -1578,18 +1523,3 @@ async fn save_and_upload_policy(app: &mut App) -> Result<()> { } // Helper functions are now in crate::acceptance_policy module - -#[cfg(test)] -mod v1_redemption_tombstone_tests { - use super::*; - - #[test] - fn tui_rejects_v1_redemption_before_the_effect_callback() { - let calls = std::cell::Cell::new(0usize); - let result = reject_retired_v1_redemption_before_effects(|| { - calls.set(calls.get() + 1); - }); - assert_eq!(result, Err(V1_REDEMPTION_RETIRED)); - assert_eq!(calls.get(), 0); - } -} 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 c8f7589..ec05361 100644 --- a/crates/basis_cli/src/api.rs +++ b/crates/basis_cli/src/api.rs @@ -599,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( diff --git a/crates/basis_cli/src/commands/note.rs b/crates/basis_cli/src/commands/note.rs index 91d6f12..d3920dc 100644 --- a/crates/basis_cli/src/commands/note.rs +++ b/crates/basis_cli/src/commands/note.rs @@ -103,7 +103,7 @@ pub enum NoteCommands { #[arg(long)] recipient: String, }, - /// Retired: redemption requires the reviewed transaction flow + /// Retired: v2 admission has no active proof, signer, or submit path Redeem { /// Issuer public key (hex) #[arg(long)] @@ -324,8 +324,7 @@ pub async fn create_demo_note(amount: u64, output: Option) -> Result Result<()> { anyhow::bail!(NOTE_REDEMPTION_RETIRED) 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_server/src/api.rs b/crates/basis_server/src/api.rs index 9e18bdc..5c2a489 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -3,13 +3,11 @@ use std::collections::HashMap; 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, }; @@ -1551,17 +1549,8 @@ pub async fn get_key_status( /// Retired server-sign redemption endpoint. #[axum::debug_handler] -pub async fn initiate_redemption( - State(_state): State, - Json(_payload): Json, -) -> (StatusCode, Json>) { - ( - StatusCode::GONE, - Json(crate::models::error_response( - "Legacy server-sign redemption is retired; use the reviewed transaction flow with explicit local witnesses and confirmed-chain reconciliation" - .to_string(), - )), - ) +pub async fn initiate_redemption() -> (StatusCode, Json>) { + crate::reject_retired_v1_redemption() } /// Legacy direct-completion endpoint. @@ -1571,67 +1560,37 @@ pub async fn initiate_redemption( /// route as an explicit tombstone so older clients fail closed instead of /// silently mutating tracker state. #[axum::debug_handler] -pub async fn complete_redemption( - State(_state): State, - Json(_payload): Json, -) -> (StatusCode, Json>) { - ( - StatusCode::GONE, - Json(crate::models::error_response( - "Direct redemption completion is retired; settlement state is advanced only by the confirmed-chain reconciler" - .to_string(), - )), - ) +pub async fn complete_redemption() -> (StatusCode, Json>) { + crate::reject_retired_v1_redemption() } /// Retired v1 tracker-proof endpoint. #[axum::debug_handler] -pub async fn get_tracker_proof( - State(_state): State, - axum::extract::Query(_params): axum::extract::Query>, -) -> ( - StatusCode, - Json>, -) { +pub async fn get_tracker_proof() -> (StatusCode, Json>) { crate::reject_retired_v1_redemption() } /// Retired v1 reserve-proof endpoint. #[axum::debug_handler] -pub async fn get_reserve_proof( - State(_state): State, - axum::extract::Query(_params): axum::extract::Query>, -) -> ( - StatusCode, - Json>, -) { +pub async fn get_reserve_proof() -> (StatusCode, Json>) { crate::reject_retired_v1_redemption() } /// Retired v1 tracker-signature endpoint. #[axum::debug_handler] -pub async fn request_tracker_signature( - State(_state): State, - Json(_payload): Json, -) -> (StatusCode, Json>) { +pub async fn request_tracker_signature() -> (StatusCode, Json>) { crate::reject_retired_v1_redemption() } /// Retired v1 redemption-preparation endpoint. #[axum::debug_handler] -pub async fn prepare_redemption( - State(_state): State, - Json(_payload): Json, -) -> (StatusCode, Json>) { +pub async fn prepare_redemption() -> (StatusCode, Json>) { crate::reject_retired_v1_redemption() } /// Retired v1 aggregate redemption-proof endpoint. #[axum::debug_handler] -pub async fn get_redemption_proof( - State(_state): State, - axum::extract::Query(_params): axum::extract::Query>, -) -> (StatusCode, Json>) { +pub async fn get_redemption_proof() -> (StatusCode, Json>) { crate::reject_retired_v1_redemption() } diff --git a/crates/basis_server/src/lib.rs b/crates/basis_server/src/lib.rs index db92807..9bc1277 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -1,4 +1,37 @@ //! 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; @@ -97,7 +130,10 @@ pub fn reserve_construction_routes() -> Router { /// 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 { +pub fn retired_v1_redemption_routes() -> Router +where + S: Clone + Send + Sync + 'static, +{ Router::new() .route( "/redeem", @@ -170,43 +206,6 @@ pub enum TrackerCommand { Result, basis_store::NoteError>, >, }, - GenerateProof { - issuer_pubkey: basis_store::PubKey, - recipient_pubkey: basis_store::PubKey, - response_tx: tokio::sync::oneshot::Sender< - Result<(basis_store::NoteProof, basis_store::TrackerState), basis_store::NoteError>, - >, - }, - GetTrackerLookupProof { - issuer_pubkey: basis_store::PubKey, - recipient_pubkey: basis_store::PubKey, - response_tx: tokio::sync::oneshot::Sender< - Result< - (basis_store::TrackerLookupProof, basis_store::TrackerState), - basis_store::NoteError, - >, - >, - }, - GetReserveLookupProof { - issuer_pubkey: basis_store::PubKey, - recipient_pubkey: basis_store::PubKey, - response_tx: tokio::sync::oneshot::Sender< - Result<(basis_store::ReserveLookupProof, Vec), basis_store::NoteError>, - >, - }, - GetReserveInsertProof { - issuer_pubkey: basis_store::PubKey, - recipient_pubkey: basis_store::PubKey, - timestamp: u64, - new_already_redeemed: u64, - response_tx: tokio::sync::oneshot::Sender< - Result<(Vec, Vec, Vec), basis_store::NoteError>, - >, - }, - /// Get the current reserve AVL tree root digest (33 bytes). - GetReserveStateDigest { - response_tx: tokio::sync::oneshot::Sender, basis_store::NoteError>>, - }, /// Get the current BNS2-backed tracker state through its owning actor. GetValidatedState { response_tx: diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 193dd8e..80d197e 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -42,21 +42,6 @@ fn reject_while_publication_is_fenced(command: TrackerCommand) { TrackerCommand::GetNotes { response_tx } => { let _ = response_tx.send(Err(NoteError::PublicationInProgress)); } - TrackerCommand::GenerateProof { response_tx, .. } => { - let _ = response_tx.send(Err(NoteError::PublicationInProgress)); - } - TrackerCommand::GetTrackerLookupProof { response_tx, .. } => { - let _ = response_tx.send(Err(NoteError::PublicationInProgress)); - } - TrackerCommand::GetReserveLookupProof { response_tx, .. } => { - let _ = response_tx.send(Err(NoteError::PublicationInProgress)); - } - TrackerCommand::GetReserveInsertProof { response_tx, .. } => { - let _ = response_tx.send(Err(NoteError::PublicationInProgress)); - } - TrackerCommand::GetReserveStateDigest { response_tx } => { - let _ = response_tx.send(Err(NoteError::PublicationInProgress)); - } TrackerCommand::GetValidatedState { response_tx } => { let _ = response_tx.send(Err(NoteError::PublicationInProgress)); } @@ -415,7 +400,6 @@ async fn main() { ); let mut next_publication_id = 1u64; - while let Some(cmd) = rx.blocking_recv() { tracing::debug!("Tracker thread received command: {:?}", cmd); @@ -495,61 +479,6 @@ async fn main() { let result = tracker.get_all_notes_with_issuer(); let _ = response_tx.send(result); } - TrackerCommand::GenerateProof { - issuer_pubkey, - recipient_pubkey, - response_tx, - } => { - let result = tracker - .generate_proof(&issuer_pubkey, &recipient_pubkey) - .and_then(|proof| tracker.validated_state().map(|state| (proof, state))); - let _ = response_tx.send(result); - } - TrackerCommand::GetTrackerLookupProof { - issuer_pubkey, - recipient_pubkey, - response_tx, - } => { - let result = tracker - .generate_tracker_lookup_proof(&issuer_pubkey, &recipient_pubkey) - .and_then(|proof| tracker.validated_state().map(|state| (proof, state))); - let _ = response_tx.send(result); - } - TrackerCommand::GetReserveLookupProof { - issuer_pubkey, - recipient_pubkey, - response_tx, - } => { - let result = tracker - .generate_reserve_lookup_proof(&issuer_pubkey, &recipient_pubkey) - .and_then(|proof| tracker.reserve_state_digest().map(|root| (proof, root))); - let _ = response_tx.send(result); - } - TrackerCommand::GetReserveInsertProof { - issuer_pubkey, - recipient_pubkey, - timestamp, - new_already_redeemed, - response_tx, - } => { - let result = tracker - .generate_reserve_insert_proof( - &issuer_pubkey, - &recipient_pubkey, - timestamp, - new_already_redeemed, - ) - .and_then(|(proof, updated_root)| { - tracker - .reserve_state_digest() - .map(|current_root| (proof, updated_root, current_root)) - }); - let _ = response_tx.send(result); - } - TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = tracker.reserve_state_digest(); - let _ = response_tx.send(digest); - } TrackerCommand::GetValidatedState { response_tx } => { let _ = response_tx.send(tracker.validated_state()); } @@ -1009,17 +938,6 @@ mod publication_fence_tests { state_rx.await, Ok(Err(basis_store::NoteError::PublicationInProgress)) )); - - let (proof_tx, proof_rx) = tokio::sync::oneshot::channel(); - reject_while_publication_is_fenced(TrackerCommand::GenerateProof { - issuer_pubkey: [2u8; 33], - recipient_pubkey: [3u8; 33], - response_tx: proof_tx, - }); - assert!(matches!( - proof_rx.await, - Ok(Err(basis_store::NoteError::PublicationInProgress)) - )); } #[tokio::test] diff --git a/crates/basis_server/src/models.rs b/crates/basis_server/src/models.rs index 674bd9c..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 { diff --git a/crates/basis_server/src/redemption_build.rs b/crates/basis_server/src/redemption_build.rs index 5d4d349..f85e91c 100644 --- a/crates/basis_server/src/redemption_build.rs +++ b/crates/basis_server/src/redemption_build.rs @@ -3,107 +3,20 @@ //! This module deliberately contains no transaction construction, proof, //! signing, node submission, broadcast, or state-mutation implementation. -use axum::{extract::State, http::StatusCode, Json}; -use ergo_lib::ergo_chain_types::Header; -use serde::{Deserialize, Serialize}; -use serde_json::Value; +use axum::{http::StatusCode, Json}; use crate::models::ApiResponse; -use crate::AppState; - -/// Legacy request shape retained only so stale clients receive HTTP 410 rather -/// than reaching an alternate deserialization path. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RedemptionBuildRequest { - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub amount: u64, - pub timestamp: u64, - pub issuer_signature: String, - #[serde(default)] - pub emergency: bool, - #[serde(default)] - pub tracker_box_id: Option, -} - -/// Legacy response shape retained for source compatibility only. No production -/// function can construct or return a successful instance. -#[derive(Debug, Serialize)] -pub struct RedemptionBuildResponse { - pub unsigned_tx: Value, - pub partial_tx: Value, - pub input_box_binaries: Vec, - pub data_box_binaries: Vec, - 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, - pub new_already_redeemed: u64, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RedemptionSubmitRequest { - pub signed_tx: Value, -} - -#[derive(Debug, Serialize)] -pub struct RedemptionSubmitResponse { - pub tx_id: String, -} /// 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>) { +pub async fn build_redemption() -> (StatusCode, Json>) { crate::reject_retired_v1_redemption() } /// 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>) { +pub async fn submit_redemption() -> (StatusCode, Json>) { crate::reject_retired_v1_redemption() } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn build_request_rejects_caller_selected_change() { - let request = serde_json::json!({ - "issuer_pubkey": "02", - "recipient_pubkey": "03", - "amount": 1, - "timestamp": 2, - "issuer_signature": "04", - "change_address": "caller-controlled" - }); - assert!(serde_json::from_value::(request).is_err()); - } - - #[test] - fn submit_request_rejects_unverified_accounting_metadata() { - let request = serde_json::json!({ - "signed_tx": {}, - "issuer_pubkey": "02", - "new_already_redeemed": 99 - }); - assert!(serde_json::from_value::(request).is_err()); - } -} 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/cors_tests.rs b/crates/basis_server/tests/cors_tests.rs index bd92102..4bf37fd 100644 --- a/crates/basis_server/tests/cors_tests.rs +++ b/crates/basis_server/tests/cors_tests.rs @@ -105,63 +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 = tracker.validated_state().map(|state| (mock_proof, state)); - 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 result = tracker.validated_state().map(|state| (mock_proof, state)); - let _ = response_tx.send(result); - } - 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 result = tracker - .reserve_state_digest() - .map(|root| (mock_proof, root)); - let _ = response_tx.send(result); - } - TrackerCommand::GetReserveInsertProof { - issuer_pubkey: _, - recipient_pubkey: _, - timestamp: _, - new_already_redeemed: _, - response_tx, - } => { - // Mock reserve insert proof - let result = tracker.reserve_state_digest().map(|current_root| { - (vec![1, 2, 3, 4], current_root.clone(), current_root) - }); - let _ = response_tx.send(result); - } TrackerCommand::GetNotesByRecipientWithIssuer { recipient_pubkey: _, response_tx, @@ -181,10 +124,6 @@ mod cors_tests { TrackerCommand::GetAllConfirmations { response_tx } => { let _ = response_tx.send(Ok(tracker.all_confirmations())); } - TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = tracker.reserve_state_digest(); - let _ = response_tx.send(digest); - } TrackerCommand::GetValidatedState { response_tx } => { let _ = response_tx.send(tracker.validated_state()); } diff --git a/crates/basis_server/tests/http_api_integration_tests.rs b/crates/basis_server/tests/http_api_integration_tests.rs index 197d0f6..2144078 100644 --- a/crates/basis_server/tests/http_api_integration_tests.rs +++ b/crates/basis_server/tests/http_api_integration_tests.rs @@ -106,63 +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 = tracker.validated_state().map(|state| (mock_proof, state)); - 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 result = tracker.validated_state().map(|state| (mock_proof, state)); - let _ = response_tx.send(result); - } - 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 result = tracker - .reserve_state_digest() - .map(|root| (mock_proof, root)); - let _ = response_tx.send(result); - } - TrackerCommand::GetReserveInsertProof { - issuer_pubkey: _, - recipient_pubkey: _, - timestamp: _, - new_already_redeemed: _, - response_tx, - } => { - // Mock reserve insert proof - let result = tracker.reserve_state_digest().map(|current_root| { - (vec![1, 2, 3, 4], current_root.clone(), current_root) - }); - let _ = response_tx.send(result); - } TrackerCommand::GetNotesByRecipientWithIssuer { recipient_pubkey: _, response_tx, @@ -182,10 +125,6 @@ mod http_api_tests { TrackerCommand::GetAllConfirmations { response_tx } => { let _ = response_tx.send(Ok(tracker.all_confirmations())); } - TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = tracker.reserve_state_digest(); - let _ = response_tx.send(digest); - } TrackerCommand::GetValidatedState { response_tx } => { let _ = response_tx.send(tracker.validated_state()); } 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..a00049f --- /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"); + 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 24a646d..f872a03 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -1,839 +1,65 @@ -//! Integration tests for redemption API endpoints -//! -//! This module tests the redemption-related HTTP endpoints: -//! - POST /redeem: Retired initiation tombstone -//! - POST /redeem/complete: Retired completion tombstone -//! - GET /proof/redemption: Retired proof tombstone -//! - POST /redemption/prepare: Retired preparation tombstone -//! - POST /tracker/signature: Retired signing tombstone -//! -//! 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::{ - ApiResponse, 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(()); - - fn assert_v1_tombstone(status: StatusCode, body: &axum::Json>) { - assert_eq!(status, StatusCode::GONE); - assert!(!body.success); - assert!(body - .error - .as_deref() - .unwrap_or_default() - .contains("v1 redemption is retired")); - } - - // ============================================================================ - // 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(), - 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( - 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::TrackerStateManager; - - tracing::debug!("Test tracker thread started"); - let mut tracker = TrackerStateManager::new_with_temp_storage(); - - 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 = tracker.add_note(&issuer_pubkey, ¬e); - let _ = response_tx.send(result); - } - TrackerCommand::GetNotesByIssuer { - issuer_pubkey, - response_tx, - } => { - let result = tracker.get_issuer_notes(&issuer_pubkey); - let _ = response_tx.send(result); - } - TrackerCommand::GetProjectedIssuerGrossDebt { - issuer_pubkey, - candidate_recipient, - candidate_total_debt, - response_tx, - } => { - let result = tracker.projected_issuer_gross_debt( - &issuer_pubkey, - candidate_recipient.as_ref(), - candidate_total_debt, - ); - let _ = response_tx.send(result); - } - TrackerCommand::GetNotesByRecipient { - recipient_pubkey, - response_tx, - } => { - let result = tracker.get_recipient_notes(&recipient_pubkey); - let _ = response_tx.send(result); - } - TrackerCommand::GetNoteByIssuerAndRecipient { - issuer_pubkey, - recipient_pubkey, - response_tx, - } => { - let result = tracker - .lookup_note(&issuer_pubkey, &recipient_pubkey) - .map(Some); - 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 = tracker.validated_state().map(|state| (mock_proof, state)); - 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 result = tracker.validated_state().map(|state| (mock_proof, state)); - let _ = response_tx.send(result); - } - 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 result = tracker - .reserve_state_digest() - .map(|root| (mock_proof, root)); - let _ = response_tx.send(result); - } - TrackerCommand::GetReserveInsertProof { - issuer_pubkey: _, - recipient_pubkey: _, - timestamp: _, - new_already_redeemed: _, - response_tx, - } => { - let result = tracker.reserve_state_digest().map(|current_root| { - (vec![1, 2, 3, 4], current_root.clone(), current_root) - }); - let _ = response_tx.send(result); - } - TrackerCommand::GetNotesByRecipientWithIssuer { - recipient_pubkey: _, - response_tx, - } => { - let _ = response_tx.send(Ok(Vec::new())); - } - TrackerCommand::GetConfirmation { - issuer_pubkey, - recipient_pubkey, - response_tx, - } => { - let result = - Ok(tracker.get_confirmation(&issuer_pubkey, &recipient_pubkey)); - let _ = response_tx.send(result); - } - TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = response_tx.send(Ok(tracker.all_confirmations())); - } - TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = tracker.reserve_state_digest(); - let _ = response_tx.send(digest); - } - TrackerCommand::GetValidatedState { response_tx } => { - let _ = response_tx.send(tracker.validated_state()); - } - TrackerCommand::BeginPublication { response_tx, .. } => { - let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); - } - TrackerCommand::RecordPublicationAttempt { response_tx, .. } - | TrackerCommand::ConfirmPublication { response_tx, .. } => { - let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); - } - TrackerCommand::AbortPublication { response_tx, .. } => { - let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); - } - } - } - }); - - // 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(), - ), - allow_fresh_tracker_generation: false, - 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_legacy_route_is_gone_for_invalid_hex_payload() { - 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::GONE); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - assert!(body.data.is_none()); - } - - #[tokio::test] - async fn test_redeem_legacy_route_is_gone_for_wrong_length_payload() { - 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; - - let body = &response.1; - assert_eq!(response.0, StatusCode::GONE); - assert!(!body.success); - assert!(body.data.is_none()); - } - - #[tokio::test] - async fn test_redeem_legacy_route_is_gone_without_state_lookup() { - 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; - - assert_eq!(response.0, StatusCode::GONE); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - #[tokio::test] - async fn test_redeem_legacy_route_is_gone_for_emergency_payload() { - 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 - }; - - let response = - initiate_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - - assert_eq!(response.0, StatusCode::GONE); - let body = &response.1; - assert!(!body.success); - assert!(body - .error - .as_deref() - .unwrap_or_default() - .contains("retired")); - } - - // ============================================================================ - // POST /redeem/complete tests - // ============================================================================ - - #[tokio::test] - async fn test_complete_redemption_is_gone_for_invalid_hex_payload() { - 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::GONE); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - #[tokio::test] - async fn test_complete_redemption_is_gone_for_wrong_length_payload() { - 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::GONE); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - #[tokio::test] - async fn test_complete_redemption_is_gone_without_state_lookup() { - 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; - - assert_eq!(response.0, StatusCode::GONE); - 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_v1_tombstone(response.0, &response.1); - } - - #[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(), - ); - - let response = - get_redemption_proof(axum::extract::State(state), axum::extract::Query(params)).await; - - assert_v1_tombstone(response.0, &response.1); - } - - // ============================================================================ - // 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_v1_tombstone(response.0, &response.1); - } - - #[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_v1_tombstone(response.0, &response.1); - } - - #[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; - - assert_v1_tombstone(response.0, &response.1); - } - - #[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; - - assert_v1_tombstone(response.0, &response.1); - } - - // ============================================================================ - // 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_v1_tombstone(response.0, &response.1); - } - - #[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; - - assert_v1_tombstone(response.0, &response.1); - } - - #[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; - - assert_v1_tombstone(response.0, &response.1); - } - - // ============================================================================ - // 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" + 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["error"] + .as_str() + .unwrap_or_default() + .contains("v1 redemption is retired"), + "route {uri}" ); - 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_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/lib.rs b/crates/basis_store/src/lib.rs index 05896b0..995faf3 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -27,6 +27,57 @@ //! ```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; @@ -246,41 +297,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 { @@ -416,8 +432,6 @@ 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, @@ -521,10 +535,6 @@ impl TrackerStateManager { NoteError::StorageError(format!("Failed to initialize AVL tree: {:?}", e)) })?; - let reserve_avl_state = basis_trees::BasisAvlTree::new().map_err(|e| { - NoteError::StorageError(format!("Failed to initialize reserve AVL tree: {:?}", e)) - })?; - // Rebuild AVL tree from all stored notes to ensure consistency after restart let mut manager = Self { avl_state, @@ -534,7 +544,6 @@ impl TrackerStateManager { last_update_timestamp: 0, }, storage, - reserve_avl_state, confirmations: std::collections::HashMap::new(), poisoned: AtomicBool::new(false), publication_health, @@ -759,18 +768,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, @@ -780,7 +777,6 @@ impl TrackerStateManager { last_update_timestamp: 0, }, storage, - reserve_avl_state, confirmations: std::collections::HashMap::new(), poisoned: AtomicBool::new(false), publication_health: PublicationHealth::new(), @@ -1338,217 +1334,6 @@ 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 { - self.ensure_healthy()?; - self.validate_complete_snapshot_against_live()?; - 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( - &self, - 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(); - - // 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)) - } - - /// 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 { - self.ensure_healthy()?; - self.validate_complete_snapshot_against_live()?; - 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), - }) - } - } - - /// Generate a reserve insert proof for context var #5 and return updated tree digest for R5. - /// - /// 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( - &self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - timestamp: u64, - new_already_redeemed: u64, - ) -> Result<(Vec, Vec), NoteError> { - self.ensure_healthy()?; - self.validate_complete_snapshot_against_live()?; - 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) -> Result, NoteError> { - self.ensure_healthy()?; - self.validate_complete_snapshot_against_live()?; - Ok(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 - #[cfg(test)] - pub(crate) fn update_already_redeemed( - &mut self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - timestamp: u64, - already_redeemed: u64, - ) -> Result<(), NoteError> { - self.ensure_healthy()?; - self.validate_complete_snapshot_against_live()?; - 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 { - self.ensure_healthy()?; - self.validate_complete_snapshot_against_live()?; - 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()); - - // Lookup the note to include in proof - let note = self.lookup_note(issuer_pubkey, recipient_pubkey)?; - - Ok(NoteProof { - note, - avl_proof, - operations: Vec::new(), - }) - } - /// Lookup a note by issuer and recipient pub fn lookup_note( &self, diff --git a/crates/basis_store/src/tests.rs b/crates/basis_store/src/tests.rs index 32af04f..d552c42 100644 --- a/crates/basis_store/src/tests.rs +++ b/crates/basis_store/src/tests.rs @@ -1838,7 +1838,7 @@ mod confirmation_state_tests { manager.storage.tamper_first_total_debt_for_test().unwrap(); assert!(matches!( - manager.reserve_state_digest(), + manager.validated_state(), Err(crate::NoteError::StorageError(message)) if message.contains("snapshot checksum") )); assert!(!manager.is_healthy()); 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/docs/AGENT_INTERFACE.md b/docs/AGENT_INTERFACE.md index ba46376..d874351 100644 --- a/docs/AGENT_INTERFACE.md +++ b/docs/AGENT_INTERFACE.md @@ -161,18 +161,15 @@ $ basis-cli reserve status --json - `reserve collateralization --json` → `{issuer_pubkey, ratio, status}`. - `note redeem --json` is a compatibility tombstone and fails before account, network, construction, signing, broadcast, or persistence effects. -- `transaction generate-redemption --json` → `{tx_id}` with `--local-sign`. - The historical non-local artifact mode is retired because transaction - artifacts must never contain exported private keys. -- `transaction redeem-assisted --json` → `{tx_id}`. -- `test test-redemption --json` → `{issuer_pubkey, recipient_pubkey, redemption_amount, output_file, transaction}`. +- 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. 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= The proof routes and redemption procedures below are retired and return +> `410 Gone`. This file is not current deployment or activation authority. --- diff --git a/openapi.yaml b/openapi.yaml index 8895218..68a2425 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -280,250 +280,143 @@ paths: /redeem: post: - summary: Retired server-sign redemption route - description: | - Compatibility tombstone. Server-side redemption is retired because a - node-accepted transaction is not confirmed settlement evidence. Use a - locally reviewed signing flow and confirmed-chain reconciliation. + 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: '410': - description: Legacy server-sign redemption is retired + 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: @@ -736,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 @@ -882,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' @@ -1115,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 diff --git a/specs/CLI_TOOLS_ANALYSIS.md b/specs/CLI_TOOLS_ANALYSIS.md index 12b636b..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`). 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/agent_integration.md b/specs/agent_integration.md index 7ac6fc8..aa31a64 100644 --- a/specs/agent_integration.md +++ b/specs/agent_integration.md @@ -137,9 +137,8 @@ basis_cli --json acceptance check --issuer <66-hex> --recipient <66-hex> --total ### Check receipts 1. `note_list {direction: "received"}` → pick notes with `outstanding > 0`. 2. `note_get {issuer, recipient: }` for details. -3. Do not invoke `note_redeem`; it is retired. Use the separately reviewed - transaction flow with explicit local witnesses. -4. Treat node acceptance as pending until confirmed-chain reconciliation. +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 @@ -186,11 +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**: the separately reviewed - transaction redemption flow (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. - `note_redeem` itself is retired. +- **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/client/offchain_redemption_signing.md b/specs/client/offchain_redemption_signing.md index 2bb19f5..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 diff --git a/specs/ergo_node_practices.md b/specs/ergo_node_practices.md index 48b8b16..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 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 index 58a7286..54cbb55 100644 --- a/specs/legacy_runtime_quarantine.md +++ b/specs/legacy_runtime_quarantine.md @@ -1,32 +1,53 @@ -# Legacy library API quarantine +# Legacy v1 runtime quarantine -The pre-v2 redemption builders are historical test fixtures, not supported -production APIs. +The pre-v2 redemption runtime is structurally absent from production APIs. +Historical specifications remain lineage material only. ## Enforced boundary -- `basis_store::redemption` and `basis_store::transaction_builder` are compiled - only for the crate's unit tests. -- `basis_offchain::transaction_builder` is compiled only for the crate's unit - tests. -- `basis_store` does not export `RedemptionManager`, `RedemptionRequest`, or - the v1 `RedemptionTransactionBuilder` to downstream crates. -- The server actor owns `TrackerStateManager` directly; it cannot reach the - retired manager through a production dependency. - -The crate-level `compile_fail` examples are regression guards for the public -API boundary. Historical unit and property tests remain available so the old -behavior can still be examined without making it callable by an application. - -## Scope and integration dependency - -This branch closes only the public Rust library surface described above. It -does not quarantine every server or client runtime path: the assisted -`/redemption/build` and `/redemption/submit` routes belong to the separate -generation-admission workstream. Before integration, those routes must either -be unconditional tombstones or accept only the exact reviewed v2 source, -ErgoTree, P2S, claim domain, register schema, and proof shapes. - -There is no automatic migration from legacy reserve state. Operators must -inventory any supported lineage and apply an explicitly reviewed retirement or -migration policy. +- `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/redemption_cli_spec.md b/specs/redemption_cli_spec.md index e1d2b0d..ea8768a 100644 --- a/specs/redemption_cli_spec.md +++ b/specs/redemption_cli_spec.md @@ -1,239 +1,23 @@ -# Specification for Basis CLI Redemption Transaction Generation +# Historical v1 CLI redemption specification -## Overview +> **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. -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. +## Current CLI boundary -## CLI Command Definition +- 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. -### Command Name -`basis-cli transaction generate-redemption` +The v2 client currently validates an exact `V2RedemptionManifest` before an +opaque callback. It has no concrete prover, signer, wallet adapter, submitter, +or broadcaster, and production manifest construction remains unavailable until +confirmed-chain authority is integrated. -### 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` +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/security_boundary_remediation.md b/specs/security_boundary_remediation.md index 87e5938..a58a232 100644 --- a/specs/security_boundary_remediation.md +++ b/specs/security_boundary_remediation.md @@ -1,62 +1,46 @@ # Tracker Security Boundaries -This change makes wallet authority, signing authority, and settlement evidence -explicit. It intentionally breaks legacy conveniences that allowed a remote -caller to exercise tracker-owned capabilities or to assert settlement state. +This specification records the current fail-closed wallet, signing, +construction, and settlement boundaries. ## Enforced invariants -1. The HTTP service never forwards a reserve-creation request to the configured - node wallet. `/reserves/create` remains a payload builder; the reserve owner - reviews, signs, and submits that payload with their own wallet. -2. Change from tracker-signed fee inputs is paid only to the common P2PK script - derived from the exact Sigma-serialized boxes used by the prover. Wallet-list - JSON is selection metadata only; mismatched IDs, values, scripts, or assets - are rejected, as are mixed-owner fee inputs. -3. Node API credentials and tracker signing material are redacted from `Debug` - output. Signing and broadcast logs contain status and identifiers only, not - request or response bodies. -4. A transaction artifact never contains private-key, mnemonic, seed, or - `secrets` fields. The historical node-wallet artifact path is retired; local - signing keeps the witness inside the signing boundary, and a missing witness - never falls back to exporting it from the node wallet. -5. Node acceptance is not settlement confirmation. `/redemption/submit` - accepts only the signed transaction and does not mutate note or reserve-tree - accounting. `/redeem/complete` is a `410 Gone` tombstone. -6. Legacy `POST /redeem`, CLI `note redeem`, and MCP `note_redeem` are - unconditional tombstones before account, network, construction, signing, - broadcast, or persistence effects. -7. The exact committed Basis v2 ERG ErgoTree is recognized, but startup rejects - its activation until the v2 scanner and BNS2/BRS2 state are installed. The - historical identity remains only as a compatibility mode; reserve creation, - server P2S distribution and HTTP redemption builders remain disabled. The - older public library builder is a separate legacy-quarantine workstream. - -## Compatibility changes - -| Surface | New behavior | +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. + +## HTTP compatibility changes + +| Surface | Current behavior | | --- | --- | -| `POST /reserves/submit` | Returns `410 Gone`; no node-wallet request is made. | -| `reserve create --submit` | Returns an error; omit the flag and sign the payload in the owner wallet. | -| Assisted build `change_address` | Removed and rejected as an unknown field. | -| `POST /redemption/submit` | Accepts `{ "signed_tx": ... }`, returns `202 Accepted`, and performs no settlement mutation. | -| `POST /redeem/complete` | Returns `410 Gone`. | -| `note redeem` / MCP `note_redeem` | Return an error before effects; no boolean reactivation path remains. | -| Non-local `transaction generate-redemption` | Returns an error; use `--local-sign` or the assisted signer. | -| Server reserve P2S / HTTP builders | Historical identity: temporary compatibility only, not a safety endorsement. Exact v2 identity: recognized but startup-disabled. Unknown identity: rejected. Server construction/P2S distribution routes return `503 Service Unavailable`; legacy library builders are not covered by this branch. | +| `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 -The confirmed-chain reconciler is the sole intended producer of settled local -state. It must authenticate the expected reserve successor, bind it to a block -on the selected active chain, apply the configured confirmation policy, and -support deterministic rollback before advancing note and reserve-tree state. -Until that reconciler is available, a submitted transaction remains only -node-accepted and must not be represented as settled. +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. -## Regression boundary - -Tests use sentinel strings and local data only. They prove that retired -handlers fail closed, caller accounting fields are rejected, fee change is -owner-bound, and `Debug`/artifact surfaces omit sentinel secrets. They do not -claim target-node admission, confirmation, reorg safety, or deployment parity. +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 c932ce4..ff9ba39 100644 --- a/specs/server/basis_server_spec.md +++ b/specs/server/basis_server_spec.md @@ -1,363 +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` - Retired legacy server-sign path (`410 Gone`) -- `POST /redeem/complete` - Retired caller-asserted completion path (`410 Gone`) -- `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` - Retired node-wallet proxy (`410 Gone`) - -### 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: - -An otherwise valid request currently returns `503 Service Unavailable` for -every configured contract identity. Malformed payloads may be rejected earlier -with `400 Bad Request`. Startup retains the historical identity only for compatibility -and rejects activation of the recognized, byte-exact v2 ERG tree -until its scanner and BNS2/BRS2 stores exist. The payload code remains -unreachable until a v2 builder supplies R4-R9, fixed 32/24 reserve state, a -policy-derived emergency height, predecessor lineage, and a genuine singleton. -See `specs/basis_v2_runtime.md`. - -- `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) - -- Once the v2 builder is installed, it will return a reviewed owner-wallet - intent rather than exercising the node wallet. The historical response shape - below is not active: - - `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 tracker response; the owner wallet adapter maps its own API format) - - `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` is retained only as a `410 Gone` compatibility - tombstone. The reserve owner reviews, signs, and submits the payload returned - by `/reserves/create`; the tracker never exercises its node wallet for a - remote caller. - -### 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. From 6ddc85f7dbdf54a831c0ccc85428025c41272afb Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:54:02 +0200 Subject: [PATCH 28/41] fix: bound event pagination and debt totals --- crates/basis_server/src/api.rs | 48 +++++++--- crates/basis_server/src/store.rs | 146 ++++++++++++++++++++++++++----- 2 files changed, 163 insertions(+), 31 deletions(-) diff --git a/crates/basis_server/src/api.rs b/crates/basis_server/src/api.rs index 5c2a489..72e623b 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -1,5 +1,8 @@ use axum::{extract::State, http::StatusCode, Json}; -use std::collections::HashMap; +use std::{ + collections::HashMap, + time::{SystemTime, UNIX_EPOCH}, +}; use crate::{ models::{ @@ -1321,20 +1324,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 @@ -1343,6 +1348,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 ( @@ -1375,7 +1390,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); @@ -1473,7 +1488,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 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()); + } +} From b016d247cac75c145e9e9c0f6f64a7238b335a4a Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:17:50 +0200 Subject: [PATCH 29/41] fix: bound tracker request work --- crates/basis_server/src/api.rs | 205 ++++++++------------------------ crates/basis_server/src/lib.rs | 139 ++++++++++++++++++++++ crates/basis_server/src/main.rs | 4 + 3 files changed, 195 insertions(+), 153 deletions(-) diff --git a/crates/basis_server/src/api.rs b/crates/basis_server/src/api.rs index 72e623b..8c79ddb 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -1,8 +1,5 @@ use axum::{extract::State, http::StatusCode, Json}; -use std::{ - collections::HashMap, - time::{SystemTime, UNIX_EPOCH}, -}; +use serde::Deserialize; use crate::{ models::{ @@ -111,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 {}", @@ -251,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 {}", @@ -396,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 {}", @@ -558,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 {}", @@ -671,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()); @@ -1445,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); @@ -2024,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 ( @@ -2065,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)) => { @@ -2158,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/lib.rs b/crates/basis_server/src/lib.rs index 9bc1277..0c770e2 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -52,6 +52,19 @@ use axum::{ }; 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::*; @@ -259,3 +272,129 @@ pub enum TrackerCommand { 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::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 80d197e..3a95a4b 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -401,6 +401,10 @@ async fn main() { 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); if let Some(active_lease) = active_publication { From c60a2014440442d6cfabcca91ef37fa16ea45ca9 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:21:57 +0200 Subject: [PATCH 30/41] fix: bound node HTTP responses --- crates/basis_server/src/bounded_http.rs | 228 ++++++++++++++++++++++++ crates/basis_server/src/lib.rs | 1 + 2 files changed, 229 insertions(+) create mode 100644 crates/basis_server/src/bounded_http.rs diff --git a/crates/basis_server/src/bounded_http.rs b/crates/basis_server/src/bounded_http.rs new file mode 100644 index 0000000..8190e3b --- /dev/null +++ b/crates/basis_server/src/bounded_http.rs @@ -0,0 +1,228 @@ +//! Bounded outbound HTTP client for Ergo node calls. + +use reqwest::{RequestBuilder, StatusCode}; +use serde::de::DeserializeOwned; +use std::sync::{Arc, LazyLock}; +use std::time::Duration; +use tokio::sync::Semaphore; + +pub const NODE_HTTP_TIMEOUT: Duration = Duration::from_secs(15); +pub const NODE_HTTP_MAX_BODY_BYTES: usize = 2 * 1024 * 1024; +pub const NODE_HTTP_MAX_IN_FLIGHT: usize = 16; + +#[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()) + } +} + +#[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) + } +} + +#[derive(Clone)] +pub struct BoundedHttpClient { + client: reqwest::Client, + permits: Arc, + timeout: Duration, + max_body_bytes: usize, +} + +impl BoundedHttpClient { + 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.client.get(url) + } + + pub fn post(&self, url: &str) -> RequestBuilder { + self.client.post(url) + } + + 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, + ) + }); + +pub fn node_http() -> Result<&'static BoundedHttpClient, BoundedHttpError> { + NODE_HTTP_CLIENT + .as_ref() + .map_err(|error| BoundedHttpError::ClientInitialization(error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + 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}") + } + + #[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 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 _occupied = client.permits.try_acquire().unwrap(); + + assert_eq!( + client + .execute(client.get("http://127.0.0.1:1")) + .await + .unwrap_err(), + BoundedHttpError::Overloaded + ); + } +} diff --git a/crates/basis_server/src/lib.rs b/crates/basis_server/src/lib.rs index 0c770e2..c75b8a5 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -35,6 +35,7 @@ pub mod acceptance; pub mod api; +mod bounded_http; pub mod config; pub mod models; pub mod redemption_build; From 88268df90d75cb2273a4710bd0607a00ef9cd6e3 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:24:10 +0200 Subject: [PATCH 31/41] fix: bound updater node calls --- .../basis_server/src/tracker_box_updater.rs | 77 ++++++++++--------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/crates/basis_server/src/tracker_box_updater.rs b/crates/basis_server/src/tracker_box_updater.rs index 2738cd9..7043836 100644 --- a/crates/basis_server/src/tracker_box_updater.rs +++ b/crates/basis_server/src/tracker_box_updater.rs @@ -814,7 +814,8 @@ impl TrackerBoxUpdater { config: &TrackerBoxUpdateConfig, tx_id: &str, ) -> Result<(String, u64), TrackerBoxUpdaterError> { - let client = reqwest::Client::new(); + let client = crate::bounded_http::node_http() + .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; let url = format!( "{}/blockchain/transaction/byId/{}", config.node_url.trim_end_matches('/'), @@ -826,8 +827,8 @@ 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()))?; @@ -840,7 +841,6 @@ impl TrackerBoxUpdater { let body: serde_json::Value = response .json() - .await .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; // The new tracker box is the first output of the update transaction. @@ -867,7 +867,8 @@ impl TrackerBoxUpdater { config: &TrackerBoxUpdateConfig, tracker_nft_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!( "{}/blockchain/box/unspent/byTokenId/{}?limit=5", config.node_url.trim_end_matches('/'), @@ -879,14 +880,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 {}: {}", status, body @@ -895,7 +896,6 @@ impl TrackerBoxUpdater { let boxes: Vec = response .json() - .await .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; if boxes.is_empty() { @@ -919,7 +919,8 @@ impl TrackerBoxUpdater { async fn get_wallet_boxes( config: &TrackerBoxUpdateConfig, ) -> Result, TrackerBoxUpdaterError> { - let client = reqwest::Client::new(); + 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('/') @@ -930,14 +931,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 wallet boxes: {}", status, body @@ -946,7 +947,6 @@ impl TrackerBoxUpdater { let entries: Vec = response .json() - .await .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; Ok(entries.into_iter().map(|e| e.box_details).collect()) @@ -1017,7 +1017,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('/'), @@ -1029,14 +1030,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 @@ -1045,7 +1046,6 @@ impl TrackerBoxUpdater { let binary: BoxBinaryResponse = response .json() - .await .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; Ok(binary.bytes) @@ -1055,7 +1055,8 @@ impl TrackerBoxUpdater { async fn get_node_height( config: &TrackerBoxUpdateConfig, ) -> Result { - let client = reqwest::Client::new(); + let client = crate::bounded_http::node_http() + .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; let url = format!("{}/info", config.node_url.trim_end_matches('/')); let mut request = client.get(&url); @@ -1063,14 +1064,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 node height: {}", status, body @@ -1079,7 +1080,6 @@ impl TrackerBoxUpdater { let body: serde_json::Value = response .json() - .await .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; body["fullHeight"] @@ -1095,7 +1095,8 @@ impl TrackerBoxUpdater { ) -> Result { info!("Requesting node signature for tracker-box update"); - let client = reqwest::Client::new(); + let client = crate::bounded_http::node_http() + .map_err(|e| TrackerBoxUpdaterError::SigningFailed(e.to_string()))?; let url = format!( "{}/wallet/transaction/sign", config.node_url.trim_end_matches('/') @@ -1106,13 +1107,12 @@ impl TrackerBoxUpdater { request = request.header("api_key", api_key); } - let response = request - .send() + let response = client + .execute(request) .await .map_err(|e| TrackerBoxUpdaterError::SigningFailed(e.to_string()))?; let status = response.status(); - let body_text = response.text().await.unwrap_or_default(); info!(status = %status, "Node signing request completed"); if !status.is_success() { @@ -1122,7 +1122,8 @@ impl TrackerBoxUpdater { ))); } - serde_json::from_str(&body_text) + response + .json() .map_err(|e| TrackerBoxUpdaterError::SigningFailed(format!("JSON parse error: {}", e))) } @@ -1134,7 +1135,8 @@ impl TrackerBoxUpdater { ) -> Result { info!("Broadcasting signed tracker-box update transaction"); - let client = reqwest::Client::new(); + let client = crate::bounded_http::node_http() + .map_err(|e| TrackerBoxUpdaterError::BroadcastOutcomeUnknown(e.to_string()))?; let url = format!("{}/transactions", config.node_url.trim_end_matches('/')); let mut request = client.post(&url).json(signed_tx); @@ -1142,14 +1144,14 @@ impl TrackerBoxUpdater { request = request.header("api_key", api_key); } - let response = request - .send() + let response = client + .execute(request) .await .map_err(|e| TrackerBoxUpdaterError::BroadcastOutcomeUnknown(e.to_string()))?; let status = response.status(); - let body_text = response.text().await.unwrap_or_default(); info!(status = %status, "Transaction broadcast request completed"); + let body_text = response.text_lossy(); Self::parse_broadcast_response(status, &body_text, expected_tx_id) } @@ -1394,7 +1396,8 @@ impl TrackerBoxUpdater { config: &TrackerBoxUpdateConfig, tx_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!( "{}/blockchain/transaction/byId/{}", config.node_url.trim_end_matches('/'), @@ -1406,8 +1409,8 @@ 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()))?; @@ -1415,7 +1418,7 @@ impl TrackerBoxUpdater { 200 => Ok(true), 404 => Ok(false), status => { - let body = response.text().await.unwrap_or_default(); + let body = response.text_lossy(); Err(TrackerBoxUpdaterError::HttpError(format!( "HTTP {} checking transaction: {}", status, body From 48c2329bf5a893cd5433b618888359eff921fa0d Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:27:12 +0200 Subject: [PATCH 32/41] docs: define service resource bounds --- specs/service_resource_bounds.md | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 specs/service_resource_bounds.md diff --git a/specs/service_resource_bounds.md b/specs/service_resource_bounds.md new file mode 100644 index 0000000..37926a6 --- /dev/null +++ b/specs/service_resource_bounds.md @@ -0,0 +1,47 @@ +# 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, and tracker-box updater use +one process-wide bounded client: + +- 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. + +The negative matrix covers a stalled loopback server, a saturated permit set, +and a chunked response that crosses the cap. 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. From 868b4ee4b3f1cf7b5d4096d81609fb4e490dffd6 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:16:18 +0200 Subject: [PATCH 33/41] fix: bound scanner node requests --- crates/basis_server/src/bounded_http.rs | 209 +++----------- crates/basis_store/src/ergo_scanner.rs | 268 ++++++++++++++++-- crates/basis_store/src/tracker_scanner.rs | 53 ++-- .../basis_store/src/tracker_scanner_test.rs | 43 ++- specs/service_resource_bounds.md | 9 +- 5 files changed, 363 insertions(+), 219 deletions(-) diff --git a/crates/basis_server/src/bounded_http.rs b/crates/basis_server/src/bounded_http.rs index 8190e3b..99061e4 100644 --- a/crates/basis_server/src/bounded_http.rs +++ b/crates/basis_server/src/bounded_http.rs @@ -1,173 +1,14 @@ -//! Bounded outbound HTTP client for Ergo node calls. +//! Re-export the one process-wide bounded Ergo node HTTP client. -use reqwest::{RequestBuilder, StatusCode}; -use serde::de::DeserializeOwned; -use std::sync::{Arc, LazyLock}; -use std::time::Duration; -use tokio::sync::Semaphore; +pub use basis_store::ergo_scanner::node_http; -pub const NODE_HTTP_TIMEOUT: Duration = Duration::from_secs(15); -pub const NODE_HTTP_MAX_BODY_BYTES: usize = 2 * 1024 * 1024; -pub const NODE_HTTP_MAX_IN_FLIGHT: usize = 16; - -#[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()) - } -} - -#[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) - } -} - -#[derive(Clone)] -pub struct BoundedHttpClient { - client: reqwest::Client, - permits: Arc, - timeout: Duration, - max_body_bytes: usize, -} - -impl BoundedHttpClient { - 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.client.get(url) - } - - pub fn post(&self, url: &str) -> RequestBuilder { - self.client.post(url) - } - - 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, - ) - }); - -pub fn node_http() -> Result<&'static BoundedHttpClient, BoundedHttpError> { - NODE_HTTP_CLIENT - .as_ref() - .map_err(|error| BoundedHttpError::ClientInitialization(error.to_string())) -} +#[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 { @@ -183,6 +24,24 @@ mod tests { 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]; @@ -200,6 +59,19 @@ mod tests { ); } + #[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(); @@ -215,7 +87,13 @@ mod tests { #[tokio::test] async fn concurrent_request_limit_rejects_without_queueing() { let client = BoundedHttpClient::new(Duration::from_secs(1), 64, 1).unwrap(); - let _occupied = client.permits.try_acquire().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 @@ -224,5 +102,6 @@ mod tests { .unwrap_err(), BoundedHttpError::Overloaded ); + assert!(first.await.unwrap().is_ok()); } } diff --git a/crates/basis_store/src/ergo_scanner.rs b/crates/basis_store/src/ergo_scanner.rs index dc9edb1..b841cca 100644 --- a/crates/basis_store/src/ergo_scanner.rs +++ b/crates/basis_store/src/ergo_scanner.rs @@ -3,16 +3,188 @@ use std::{ 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::{Client, RequestBuilder, StatusCode}; +use serde::de::DeserializeOwned; + +/// 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) + } +} + +/// 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())) +} /// Response from `POST /blockchain/box/unspent/byAddress` is a JSON array of IndexedErgoBox. pub(crate) type ByAddressResponse = Vec; @@ -155,10 +327,15 @@ pub struct ServerState { impl ServerState { /// 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 { @@ -169,7 +346,18 @@ impl ServerState { info!("No API key header added to HTTP request"); } - request + Ok(request) + } + + async fn execute_request( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + let client = node_http().map_err(|error| ScannerError::HttpError(error.to_string()))?; + client + .execute(request) + .await + .map_err(|error| ScannerError::HttpError(error.to_string())) } /// Create a server state that uses real Ergo scanner @@ -272,9 +460,9 @@ impl ServerState { let url = format!("{}/info", self.config.node_url); info!("Fetching current blockchain height from: {}", url); + let request = self.request_builder(reqwest::Method::GET, &url)?; let response = self - .request_builder(reqwest::Method::GET, &url) - .send() + .execute_request(request) .await .map_err(|e| ScannerError::HttpError(format!("Failed to connect to node: {}", e)))?; @@ -287,7 +475,6 @@ impl ServerState { let info: serde_json::Value = response .json() - .await .map_err(|e| ScannerError::JsonError(format!("Failed to parse node info: {}", e)))?; let height = info["fullHeight"].as_u64().ok_or_else(|| { @@ -337,9 +524,9 @@ impl ServerState { // Fetch from node let url = format!("{}/info", self.config.node_url); + let request = self.request_builder(reqwest::Method::GET, &url)?; let response = self - .request_builder(reqwest::Method::GET, &url) - .send() + .execute_request(request) .await .map_err(|e| ScannerError::HttpError(format!("Failed to connect to node: {}", e)))?; @@ -352,7 +539,6 @@ impl ServerState { let info: serde_json::Value = response .json() - .await .map_err(|e| ScannerError::JsonError(format!("Failed to parse node info: {}", e)))?; let height = info["fullHeight"].as_u64().ok_or_else(|| { @@ -381,32 +567,23 @@ impl ServerState { let url = format!("{}/blockchain/box/unspent/byAddress", self.config.node_url); info!("Fetching unspent reserve boxes from: {}", url); - 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 request = self + .request_builder(reqwest::Method::POST, &url)? + .json(reserve_contract_p2s); + let response = self.execute_request(request).await.map_err(|e| { + ScannerError::HttpError(format!("Failed to fetch reserve boxes: {}", e)) + })?; 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 - ); + error!("Failed to get reserve boxes with status {}", status); return Err(ScannerError::NodeError(format!( "Failed to get reserve boxes with status: {}", status ))); } - let parsed: ByAddressResponse = response.json().await.map_err(|e| { + let parsed: ByAddressResponse = response.json().map_err(|e| { ScannerError::JsonError(format!("Failed to parse reserve boxes response: {}", e)) })?; @@ -871,6 +1048,39 @@ 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, + ..NodeConfig::default() + }; + let state = ServerState::new(config, temp_dir.path()).unwrap(); + + let error = state.fetch_current_height().await.unwrap_err(); + + assert!(error + .to_string() + .contains("outbound node response body exceeds 2097152 bytes")); + } fn historical_tree() -> String { crate::contract_compiler::get_basis_reserve_ergo_tree_hex().unwrap() diff --git a/crates/basis_store/src/tracker_scanner.rs b/crates/basis_store/src/tracker_scanner.rs index 45a8ced..df4cf57 100644 --- a/crates/basis_store/src/tracker_scanner.rs +++ b/crates/basis_store/src/tracker_scanner.rs @@ -12,7 +12,7 @@ use tracing::{debug, error, info, warn}; use reqwest::Client; use crate::{ - ergo_scanner::{IndexedErgoBox, ScanBox}, + ergo_scanner::{node_http, BoundedResponse, IndexedErgoBox, ScanBox}, persistence::{ScannerMetadataStorage, TrackerStorage}, TrackerBoxInfo, }; @@ -81,15 +81,33 @@ pub struct TrackerServerState { 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) + } + + async fn execute_request( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + let client = + node_http().map_err(|error| TrackerScannerError::HttpError(error.to_string()))?; + client + .execute(request) + .await + .map_err(|error| TrackerScannerError::HttpError(error.to_string())) } /// Get unspent tracker boxes via `GET /blockchain/box/unspent/byTokenId/{trackerNftId}`. @@ -107,27 +125,20 @@ impl TrackerServerState { debug!("Fetching unspent tracker boxes from: {}", url); - let response = self - .request_builder(reqwest::Method::GET, &url) - .send() - .await - .map_err(|e| { - TrackerScannerError::HttpError(format!("Failed to fetch tracker boxes: {}", e)) - })?; + let request = self.request_builder(reqwest::Method::GET, &url)?; + let response = self.execute_request(request).await.map_err(|e| { + TrackerScannerError::HttpError(format!("Failed to fetch tracker boxes: {}", e)) + })?; 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 + "Failed to get unspent tracker boxes with status {}", + status ))); } - let indexed_boxes: Vec = response.json().await.map_err(|e| { + let indexed_boxes: Vec = response.json().map_err(|e| { error!("Failed to parse tracker boxes JSON: {}", e); TrackerScannerError::JsonError(format!("Failed to parse tracker boxes: {}", e)) })?; @@ -380,9 +391,9 @@ impl TrackerServerState { // Fetch from node let url = format!("{}/info", self.config.node_url); + let request = self.request_builder(reqwest::Method::GET, &url)?; let response = self - .request_builder(reqwest::Method::GET, &url) - .send() + .execute_request(request) .await .map_err(|e| TrackerScannerError::HttpError(format!("Failed to get height: {}", e)))?; @@ -393,7 +404,7 @@ impl TrackerServerState { ))); } - let info: serde_json::Value = response.json().await.map_err(|e| { + let info: serde_json::Value = response.json().map_err(|e| { TrackerScannerError::JsonError(format!("Failed to parse height: {}", e)) })?; diff --git a/crates/basis_store/src/tracker_scanner_test.rs b/crates/basis_store/src/tracker_scanner_test.rs index d02920e..1c35104 100644 --- a/crates/basis_store/src/tracker_scanner_test.rs +++ b/crates/basis_store/src/tracker_scanner_test.rs @@ -4,12 +4,53 @@ 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}, }; use std::collections::HashMap; use std::path::Path; + 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 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, temp_dir.path()); + + let error = state.get_current_height().await.unwrap_err(); + + assert!(error + .to_string() + .contains("outbound node response body exceeds 2097152 bytes")); + } #[tokio::test] async fn test_tracker_scan_registration_payload() { diff --git a/specs/service_resource_bounds.md b/specs/service_resource_bounds.md index 37926a6..47469b9 100644 --- a/specs/service_resource_bounds.md +++ b/specs/service_resource_bounds.md @@ -26,17 +26,20 @@ make point-in-time policy decisions atomic with later issuance. ## Outbound node HTTP -All node calls made by the API, redemption builder, and tracker-box updater use -one process-wide bounded client: +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, -and a chunked response that crosses the cap. No test contacts a live Ergo node. +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 From 8d9944ff677bb24787b6a19d0348be80a0c8b3f0 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:26:18 +0200 Subject: [PATCH 34/41] fix(scanner): reconcile only complete paginated snapshots --- crates/basis_store/src/ergo_scanner.rs | 390 ++++++++--- crates/basis_store/src/lib.rs | 2 + .../src/scanner_hardening_tests.rs | 648 ++++++++++++++++++ crates/basis_store/src/tests.rs | 18 - crates/basis_store/src/tracker_scanner.rs | 282 ++++++-- .../basis_store/src/tracker_scanner_test.rs | 3 +- specs/scanner_snapshot_reconciliation.md | 68 ++ 7 files changed, 1211 insertions(+), 200 deletions(-) create mode 100644 crates/basis_store/src/scanner_hardening_tests.rs create mode 100644 specs/scanner_snapshot_reconciliation.md diff --git a/crates/basis_store/src/ergo_scanner.rs b/crates/basis_store/src/ergo_scanner.rs index b841cca..002e75e 100644 --- a/crates/basis_store/src/ergo_scanner.rs +++ b/crates/basis_store/src/ergo_scanner.rs @@ -2,6 +2,7 @@ //! This module provides blockchain integration using /blockchain endpoints (no node scans). use std::{ + collections::HashSet, path::Path, sync::{Arc, LazyLock}, time::{Duration, SystemTime, UNIX_EPOCH}, @@ -15,6 +16,14 @@ use tracing::{debug, error, info, warn}; use reqwest::{Client, 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. @@ -69,6 +78,10 @@ impl BoundedResponse { pub fn text_lossy(&self) -> std::borrow::Cow<'_, str> { String::from_utf8_lossy(&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. @@ -186,6 +199,13 @@ pub fn node_http() -> Result<&'static BoundedHttpClient, BoundedHttpError> { .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; @@ -193,7 +213,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, @@ -217,6 +237,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 { @@ -263,6 +291,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)] @@ -320,12 +363,39 @@ 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, @@ -349,17 +419,6 @@ impl ServerState { Ok(request) } - async fn execute_request( - &self, - request: reqwest::RequestBuilder, - ) -> Result { - let client = node_http().map_err(|error| ScannerError::HttpError(error.to_string()))?; - client - .execute(request) - .await - .map_err(|error| ScannerError::HttpError(error.to_string())) - } - /// 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(|| { @@ -447,6 +506,7 @@ impl ServerState { config, inner, client, + request_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_SCANNER_REQUESTS)), reserve_tracker, metadata_storage, reserve_storage, @@ -460,21 +520,21 @@ impl ServerState { let url = format!("{}/info", self.config.node_url); info!("Fetching current blockchain height from: {}", url); - let request = self.request_builder(reqwest::Method::GET, &url)?; - let response = self - .execute_request(request) - .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() + 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(|| { @@ -524,21 +584,21 @@ impl ServerState { // Fetch from node let url = format!("{}/info", self.config.node_url); - let request = self.request_builder(reqwest::Method::GET, &url)?; - let response = self - .execute_request(request) - .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() + 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(|| { @@ -558,36 +618,145 @@ impl ServerState { Ok(height) } - /// Get 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()) - })?; + 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)) + }) + } - let url = format!("{}/blockchain/box/unspent/byAddress", self.config.node_url); - info!("Fetching unspent reserve boxes from: {}", url); + 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 response = self.execute_request(request).await.map_err(|e| { - ScannerError::HttpError(format!("Failed to fetch reserve boxes: {}", e)) - })?; + let (status, body) = self + .request_bytes(request, "Failed to fetch reserve boxes") + .await?; - let status = response.status(); + if status == StatusCode::NOT_FOUND { + return Ok(Vec::new()); + } if !status.is_success() { - error!("Failed to get reserve boxes with status {}", status); return Err(ScannerError::NodeError(format!( - "Failed to get reserve boxes with status: {}", - status + "Failed to get reserve boxes with status {}: {}", + status, + summarize_error_body(&body) ))); } - - let parsed: ByAddressResponse = response.json().map_err(|e| { + 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()) })?; - info!("Found {} reserve boxes", parsed.len()); + let before = self.fetch_indexed_height().await?; + Self::require_caught_up(before)?; + + 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 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 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 in a complete snapshot at indexed height {}", + parsed.len(), + after.indexed_height + ); Ok(parsed.into_iter().map(Into::into).collect()) } @@ -742,51 +911,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", @@ -794,33 +941,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!( diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index 995faf3..618a470 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -111,6 +111,8 @@ pub mod real_scanner_integration_tests; #[cfg(test)] pub mod reserve_tracking_test; #[cfg(test)] +mod scanner_hardening_tests; +#[cfg(test)] pub mod tracker_scanner_test; use basis_core; 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..075b480 --- /dev/null +++ b/crates/basis_store/src/scanner_hardening_tests.rs @@ -0,0 +1,648 @@ +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); +} + +#[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 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 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); +} diff --git a/crates/basis_store/src/tests.rs b/crates/basis_store/src/tests.rs index d552c42..0cdf069 100644 --- a/crates/basis_store/src/tests.rs +++ b/crates/basis_store/src/tests.rs @@ -1808,24 +1808,6 @@ mod confirmation_state_tests { assert!(!manager.is_healthy()); } - #[test] - fn proof_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.generate_proof(&issuer, &recipient), - 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(); diff --git a/crates/basis_store/src/tracker_scanner.rs b/crates/basis_store/src/tracker_scanner.rs index df4cf57..372d339 100644 --- a/crates/basis_store/src/tracker_scanner.rs +++ b/crates/basis_store/src/tracker_scanner.rs @@ -1,18 +1,22 @@ //! Tracker box scanner for monitoring Basis tracker state commitment boxes //! This module provides blockchain integration using /blockchain endpoints (no node scans). +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::{Client, StatusCode}; use crate::{ - ergo_scanner::{node_http, BoundedResponse, 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, }; @@ -43,6 +47,21 @@ 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 @@ -75,11 +94,40 @@ pub struct TrackerServerState { pub config: TrackerNodeConfig, pub inner: Arc>, pub client: Client, + pub(crate) request_permits: Arc, pub metadata_storage: ScannerMetadataStorage, pub tracker_storage: TrackerStorage, } 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()) + } + /// Create HTTP request builder with API key header if configured fn request_builder( &self, @@ -98,19 +146,75 @@ impl TrackerServerState { Ok(builder) } - async fn execute_request( + 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, - request: reqwest::RequestBuilder, - ) -> Result { - let client = - node_http().map_err(|error| TrackerScannerError::HttpError(error.to_string()))?; - client - .execute(request) - .await - .map_err(|error| TrackerScannerError::HttpError(error.to_string())) + 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?; + if status == StatusCode::NOT_FOUND { + return Ok(Vec::new()); + } + if !status.is_success() { + 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 unspent tracker boxes via `GET /blockchain/box/unspent/byTokenId/{trackerNftId}`. + /// 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 @@ -118,34 +222,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 request = self.request_builder(reqwest::Method::GET, &url)?; - let response = self.execute_request(request).await.map_err(|e| { - TrackerScannerError::HttpError(format!("Failed to fetch tracker boxes: {}", e)) - })?; + 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; + + 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(); - return Err(TrackerScannerError::NodeError(format!( - "Failed to get unspent tracker boxes with status {}", - status + if !exhausted { + return Err(TrackerScannerError::ScanLimitExceeded(format!( + "scan did not exhaust within {} pages", + MAX_SCAN_PAGES ))); } - let indexed_boxes: Vec = response.json().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) } @@ -246,7 +387,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(), )); } @@ -274,30 +416,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!( @@ -342,9 +476,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 ); } @@ -391,20 +533,21 @@ impl TrackerServerState { // Fetch from node let url = format!("{}/info", self.config.node_url); - let request = self.request_builder(reqwest::Method::GET, &url)?; - let response = self - .execute_request(request) - .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().map_err(|e| { + let info: serde_json::Value = serde_json::from_slice(&body).map_err(|e| { TrackerScannerError::JsonError(format!("Failed to parse height: {}", e)) })?; @@ -482,6 +625,7 @@ pub fn create_tracker_server_state( config, inner: Arc::new(Mutex::new(inner)), client: Client::new(), + request_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_SCANNER_REQUESTS)), metadata_storage, tracker_storage, } diff --git a/crates/basis_store/src/tracker_scanner_test.rs b/crates/basis_store/src/tracker_scanner_test.rs index 1c35104..b4a2ffb 100644 --- a/crates/basis_store/src/tracker_scanner_test.rs +++ b/crates/basis_store/src/tracker_scanner_test.rs @@ -42,8 +42,7 @@ mod tests { scan_name: Some("bounded-http-test".to_string()), api_key: None, }; - let state = - create_tracker_server_state(config, metadata_storage, tracker_storage, temp_dir.path()); + let state = create_tracker_server_state(config, metadata_storage, tracker_storage); let error = state.get_current_height().await.unwrap_err(); diff --git a/specs/scanner_snapshot_reconciliation.md b/specs/scanner_snapshot_reconciliation.md new file mode 100644 index 0000000..88a2a85 --- /dev/null +++ b/specs/scanner_snapshot_reconciliation.md @@ -0,0 +1,68 @@ +# 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. An incomplete or malformed + snapshot never does. +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. + +## 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 | Short-page exhaustion plus successful after-height probe | Stale reserve removal | Spent reserves remain authoritative, or live reserves are deleted after failure | `complete_empty_snapshot_removes_stale_reserves` / 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 | All scanner HTTP paths and clones | Concurrent callers accumulate indefinitely | Sequential/multi-page positives / `shared_gate_bounds_concurrent_scanner_requests` | +| 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. From 8b663013007ea1f236fb6c415f443dc7ba4e56ab Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:44:42 +0200 Subject: [PATCH 35/41] fix(scanner): fail closed on ambiguous page responses --- crates/basis_store/src/ergo_scanner.rs | 8 +- .../src/scanner_hardening_tests.rs | 226 ++++++++++++++++++ crates/basis_store/src/tracker_scanner.rs | 8 +- specs/scanner_snapshot_reconciliation.md | 31 ++- 4 files changed, 260 insertions(+), 13 deletions(-) diff --git a/crates/basis_store/src/ergo_scanner.rs b/crates/basis_store/src/ergo_scanner.rs index 002e75e..670cb5a 100644 --- a/crates/basis_store/src/ergo_scanner.rs +++ b/crates/basis_store/src/ergo_scanner.rs @@ -668,10 +668,10 @@ impl ServerState { .request_bytes(request, "Failed to fetch reserve boxes") .await?; - if status == StatusCode::NOT_FOUND { - return Ok(Vec::new()); - } - if !status.is_success() { + // 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, diff --git a/crates/basis_store/src/scanner_hardening_tests.rs b/crates/basis_store/src/scanner_hardening_tests.rs index 075b480..aaabf39 100644 --- a/crates/basis_store/src/scanner_hardening_tests.rs +++ b/crates/basis_store/src/scanner_hardening_tests.rs @@ -336,6 +336,65 @@ fn assert_only_stale_reserve(state: &ServerState, stale_box_id: &str) { 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) @@ -442,6 +501,39 @@ async fn complete_empty_snapshot_removes_stale_reserves() { .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) @@ -470,6 +562,80 @@ async fn failed_later_page_preserves_previous_snapshot_without_partial_upserts() 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| { @@ -646,3 +812,63 @@ async fn shared_gate_bounds_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/tracker_scanner.rs b/crates/basis_store/src/tracker_scanner.rs index 372d339..2ad6be1 100644 --- a/crates/basis_store/src/tracker_scanner.rs +++ b/crates/basis_store/src/tracker_scanner.rs @@ -198,10 +198,10 @@ impl TrackerServerState { let (status, body) = self .request_bytes(request, "Failed to fetch tracker boxes") .await?; - if status == StatusCode::NOT_FOUND { - return Ok(Vec::new()); - } - if !status.is_success() { + // 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, diff --git a/specs/scanner_snapshot_reconciliation.md b/specs/scanner_snapshot_reconciliation.md index 88a2a85..d544aab 100644 --- a/specs/scanner_snapshot_reconciliation.md +++ b/specs/scanner_snapshot_reconciliation.md @@ -21,11 +21,31 @@ multi-store transaction journal. 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. An incomplete or malformed - snapshot never does. + 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. + 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 @@ -50,9 +70,10 @@ move to an application-owned block indexer. | 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 | Short-page exhaustion plus successful after-height probe | Stale reserve removal | Spent reserves remain authoritative, or live reserves are deleted after failure | `complete_empty_snapshot_removes_stale_reserves` / page-failure, malformed, duplicate, lag and drift tests | +| 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 | All scanner HTTP paths and clones | Concurrent callers accumulate indefinitely | Sequential/multi-page positives / `shared_gate_bounds_concurrent_scanner_requests` | +| 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 From 964cc114cdb6be08dbfc290433e6e2852adeba9a Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:22:42 +0200 Subject: [PATCH 36/41] fix: bound v2 manifest admission and outputs --- crates/basis_cli/src/v2_manifest.rs | 16 +- crates/basis_store/src/basis_v2_builder.rs | 539 ++++++++++++++++++++- specs/basis_v2_runtime.md | 59 ++- specs/redemption_cli_spec.md | 6 +- 4 files changed, 593 insertions(+), 27 deletions(-) diff --git a/crates/basis_cli/src/v2_manifest.rs b/crates/basis_cli/src/v2_manifest.rs index 013fae4..4f9d33a 100644 --- a/crates/basis_cli/src/v2_manifest.rs +++ b/crates/basis_cli/src/v2_manifest.rs @@ -20,8 +20,8 @@ use anyhow::Result; use basis_store::basis_v2_builder::{ - with_validated_v2_redemption_manifest, V2RedemptionManifest, V2SigningIntent, - ValidatedV2RedemptionManifest, + 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 @@ -36,3 +36,15 @@ pub fn with_validated_v2_manifest( ) -> 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_store/src/basis_v2_builder.rs b/crates/basis_store/src/basis_v2_builder.rs index ceb0f3e..301a455 100644 --- a/crates/basis_store/src/basis_v2_builder.rs +++ b/crates/basis_store/src/basis_v2_builder.rs @@ -9,6 +9,14 @@ //! 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, @@ -24,7 +32,9 @@ use basis_offchain::ergo_tx::{ }; use basis_trees::{ReserveAvlTree, TrackerAvlTree}; use ergo_lib::ergo_chain_types::ADDigest; -use ergo_lib::ergotree_ir::chain::ergo_box::{ErgoBox, NonMandatoryRegisterId}; +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}; @@ -37,6 +47,32 @@ 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"; @@ -47,6 +83,8 @@ pub enum V2BuilderError { 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), } @@ -281,8 +319,7 @@ impl From<&ConfirmedChainBoxV2> for AuthenticatedInputManifestV2 { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct V2RedemptionManifest { schema: String, claim: ClaimManifestV2, @@ -308,7 +345,101 @@ pub struct V2RedemptionManifest { 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 } @@ -413,6 +544,10 @@ impl V2SigningIntent { "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, @@ -452,6 +587,18 @@ pub fn with_validated_v2_redemption_manifest<'a, T>( 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( @@ -597,6 +744,14 @@ pub fn validate_v2_redemption_manifest<'a>( 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 @@ -865,6 +1020,8 @@ impl TrackerWitnessView for ManifestTrackerWitness { } 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) @@ -941,6 +1098,7 @@ fn validate_scalar_request( 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")); } @@ -1301,9 +1459,45 @@ fn expected_outputs_raw( 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, @@ -1439,6 +1633,21 @@ fn parse_p2pk_tree(tree: &str) -> Result<[u8; 33], V2BuilderError> { } 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() @@ -1446,11 +1655,33 @@ fn decode_array(encoded: &str, label: &str) -> Result<[u8; N], V } 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!( @@ -1703,16 +1934,33 @@ mod tests { } 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!(result.is_err()); 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] @@ -1865,6 +2113,13 @@ mod tests { 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; @@ -1967,4 +2222,280 @@ mod tests { 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/specs/basis_v2_runtime.md b/specs/basis_v2_runtime.md index 02b0859..08ffc89 100644 --- a/specs/basis_v2_runtime.md +++ b/specs/basis_v2_runtime.md @@ -62,15 +62,39 @@ 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 -This foundation recognizes the exact v2 identity but deliberately rejects its -activation at server startup. The current scanner and state store are v1-shaped -and must not interpret v2 registers. The historical strict-insert identity is -retained only as a temporary compatibility mode, while every construction -endpoint stays disabled; this does not establish legacy acceptance safety. V2 -activation requires its scanner, BNS2/BRS2 state, and a builder -that supplies all of the following as one coherent manifest: +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 @@ -82,18 +106,15 @@ that supplies all of the following as one coherent manifest: - reserve-NFT-specific proof/root/box lineage and an idempotent confirmed-chain settlement record. -Until that join exists, normal server startup rejects the exact v2 P2S and the -reserve creation, P2S distribution, and HTTP redemption-build endpoints fail -closed. The v1 scanner constructor also rejects exact v2 and unknown configured -identities, and its internal parser checks the historical ErgoTree before -decoding registers. The older public library transaction builder remains a -separate legacy-quarantine dependency; this foundation does not claim that all -v1 code has been removed. +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 -V1 and v2 records must use separate storage namespaces and APIs. 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 v1 state. +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/redemption_cli_spec.md b/specs/redemption_cli_spec.md index ea8768a..10db602 100644 --- a/specs/redemption_cli_spec.md +++ b/specs/redemption_cli_spec.md @@ -12,8 +12,10 @@ - The ignored local-sign v1 fixture is removed. - The TUI exposes no redemption or transaction navigation. -The v2 client currently validates an exact `V2RedemptionManifest` before an -opaque callback. It has no concrete prover, signer, wallet adapter, submitter, +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. From 59c00b0dd42dfeeb9e78b309f8aa02ec1df82b6c Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:45:24 +0200 Subject: [PATCH 37/41] refactor: seal scanner HTTP executor --- crates/basis_store/src/ergo_scanner.rs | 5 +---- crates/basis_store/src/tracker_scanner.rs | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/basis_store/src/ergo_scanner.rs b/crates/basis_store/src/ergo_scanner.rs index 670cb5a..3d5fede 100644 --- a/crates/basis_store/src/ergo_scanner.rs +++ b/crates/basis_store/src/ergo_scanner.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{debug, error, info, warn}; -use reqwest::{Client, RequestBuilder, StatusCode}; +use reqwest::{RequestBuilder, StatusCode}; use serde::de::DeserializeOwned; pub(crate) const SCAN_PAGE_SIZE: usize = 100; @@ -362,7 +362,6 @@ 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, @@ -436,7 +435,6 @@ impl ServerState { )); } 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) @@ -505,7 +503,6 @@ impl ServerState { Ok(Self { config, inner, - client, request_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_SCANNER_REQUESTS)), reserve_tracker, metadata_storage, diff --git a/crates/basis_store/src/tracker_scanner.rs b/crates/basis_store/src/tracker_scanner.rs index 2ad6be1..66264d1 100644 --- a/crates/basis_store/src/tracker_scanner.rs +++ b/crates/basis_store/src/tracker_scanner.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{debug, error, info, warn}; -use reqwest::{Client, StatusCode}; +use reqwest::StatusCode; use crate::{ ergo_scanner::{ @@ -93,7 +93,6 @@ pub struct TrackerServerStateInner { pub struct TrackerServerState { pub config: TrackerNodeConfig, pub inner: Arc>, - pub client: Client, pub(crate) request_permits: Arc, pub metadata_storage: ScannerMetadataStorage, pub tracker_storage: TrackerStorage, @@ -624,7 +623,6 @@ pub fn create_tracker_server_state( TrackerServerState { config, inner: Arc::new(Mutex::new(inner)), - client: Client::new(), request_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_SCANNER_REQUESTS)), metadata_storage, tracker_storage, From 6f147fed0e0b8bc08c202635ddef7b11206ac314 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:07:08 +0200 Subject: [PATCH 38/41] security: close scanner and retired CLI disclosure gaps --- crates/basis_cli/src/commands/reserve.rs | 39 ++++++++++++++++++- crates/basis_store/src/tracker_scanner.rs | 14 ++++++- .../basis_store/src/tracker_scanner_test.rs | 16 ++++++++ specs/SCHNORR_SIGNATURE_SPEC.md | 8 +++- specs/security_boundary_remediation.md | 16 ++++++++ .../redemption_transaction_format_spec.md | 7 ++++ 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/crates/basis_cli/src/commands/reserve.rs b/crates/basis_cli/src/commands/reserve.rs index 7139766..cee0da1 100644 --- a/crates/basis_cli/src/commands/reserve.rs +++ b/crates/basis_cli/src/commands/reserve.rs @@ -211,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."); } } } @@ -270,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_store/src/tracker_scanner.rs b/crates/basis_store/src/tracker_scanner.rs index 66264d1..9d2da81 100644 --- a/crates/basis_store/src/tracker_scanner.rs +++ b/crates/basis_store/src/tracker_scanner.rs @@ -65,7 +65,7 @@ pub enum TrackerScannerError { } /// 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, @@ -79,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 { diff --git a/crates/basis_store/src/tracker_scanner_test.rs b/crates/basis_store/src/tracker_scanner_test.rs index b4a2ffb..bcb6faf 100644 --- a/crates/basis_store/src/tracker_scanner_test.rs +++ b/crates/basis_store/src/tracker_scanner_test.rs @@ -12,6 +12,22 @@ mod tests { 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(); 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/security_boundary_remediation.md b/specs/security_boundary_remediation.md index a58a232..71bb7a3 100644 --- a/specs/security_boundary_remediation.md +++ b/specs/security_boundary_remediation.md @@ -22,6 +22,22 @@ construction, and settlement boundaries. 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 the same exact-input and owner-derived-change rule. +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. ## HTTP compatibility changes 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). From 6c87ce1af53253ed51a43c1f2da64249f02c482d Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:09:00 +0200 Subject: [PATCH 39/41] fix: bind confirmed-chain recovery authority --- crates/basis_server/src/main.rs | 62 +- .../basis_server/src/tracker_box_updater.rs | 235 +++-- .../basis_store/src/chain_reconciliation.rs | 875 +++++++++++++++--- crates/basis_store/src/lib.rs | 42 +- crates/basis_store/src/tests.rs | 52 +- specs/confirmed_chain_reconciliation.md | 36 +- 6 files changed, 1105 insertions(+), 197 deletions(-) diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index a3ebee8..d4ce999 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -16,6 +16,34 @@ 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; @@ -671,16 +699,13 @@ async fn main() { recipient_pubkey, response_tx, } => { - let result = Ok(redemption_manager + let result = redemption_manager .tracker - .get_confirmation(&issuer_pubkey, &recipient_pubkey)); + .try_get_confirmation(&issuer_pubkey, &recipient_pubkey); let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let result = redemption_manager - .tracker - .validated_state() - .map(|_| redemption_manager.tracker.all_confirmations()); + let result = redemption_manager.tracker.try_all_confirmations(); let _ = response_tx.send(result); } TrackerCommand::BeginPublication { @@ -817,7 +842,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, @@ -825,7 +851,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"); @@ -1196,6 +1226,22 @@ mod publication_fence_tests { )); } + #[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(); diff --git a/crates/basis_server/src/tracker_box_updater.rs b/crates/basis_server/src/tracker_box_updater.rs index a682742..27df540 100644 --- a/crates/basis_server/src/tracker_box_updater.rs +++ b/crates/basis_server/src/tracker_box_updater.rs @@ -19,6 +19,8 @@ use std::{ 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] { [ @@ -62,7 +64,7 @@ pub struct SharedTrackerState { 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>, @@ -202,6 +204,9 @@ 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() } @@ -462,6 +467,8 @@ mod secret_redaction_tests { 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")] @@ -588,6 +595,22 @@ impl TrackerBoxUpdater { /// 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<()>, @@ -632,15 +655,20 @@ impl TrackerBoxUpdater { )); } 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( - shared_state.has_confirmation_history(), + confirmation_history_present, restored_pending.is_some(), config.allow_fresh_reconciliation_journal, ); @@ -654,6 +682,8 @@ impl TrackerBoxUpdater { &journal, restored_pending.as_ref(), historical_confirmation.as_ref(), + confirmation_history_present, + &policy, )?; info!( @@ -664,9 +694,18 @@ 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}" + ))); + } + } } } @@ -734,14 +773,13 @@ impl TrackerBoxUpdater { &shared_state, &journal, &accepted, - policy, + &policy, ) .await { - if Self::is_retryable_chain_observation_error(&error) { - warn!(%error, tx_id = %accepted.tx_id(), "Unable to revalidate accepted chain anchor; retaining fail-closed state"); - continue; - } + // 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); } @@ -769,16 +807,20 @@ impl TrackerBoxUpdater { } RecoveryAction::QueryExactTransaction(intent) => { Self::ensure_pending_matches(&shared_state, &intent)?; - match Self::observe_transaction(&config, &client, &intent, policy).await { + 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) => { + 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); + } } continue; } @@ -956,9 +998,16 @@ impl TrackerBoxUpdater { 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) + .validate_tracker_startup_join( + restored_pending, + historical_confirmation, + confirmation_history_present, + policy, + ) .map_err(|error| { TrackerBoxUpdaterError::BroadcastOutcomeUnknown(format!( "tracker startup reconciliation join failed: {error}" @@ -995,29 +1044,21 @@ impl TrackerBoxUpdater { fn unix_time_ms() -> Result { let millis = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map_err(|error| TrackerBoxUpdaterError::HttpError(error.to_string()))? + .map_err(|error| TrackerBoxUpdaterError::InvalidNodeResponse(error.to_string()))? .as_millis(); u64::try_from(millis).map_err(|_| { - TrackerBoxUpdaterError::HttpError("system time exceeds u64 milliseconds".to_string()) + TrackerBoxUpdaterError::InvalidNodeResponse( + "system time exceeds u64 milliseconds".to_string(), + ) }) } - fn is_retryable_chain_observation_error(error: &TrackerBoxUpdaterError) -> bool { + fn is_retryable_pending_observation_error(error: &TrackerBoxUpdaterError) -> bool { match error { TrackerBoxUpdaterError::HttpError(_) => true, - TrackerBoxUpdaterError::Reconciliation(error) => !matches!( - error, - ReconciliationError::TicketInProgress - | ReconciliationError::DuplicateTransactionConflict - | ReconciliationError::NoTicket - | ReconciliationError::IntentMismatch - | ReconciliationError::InvalidPhase - | ReconciliationError::Journal(_) - | ReconciliationError::JournalBindingRequired - | ReconciliationError::JournalBindingMismatch - | ReconciliationError::AccountingProjectionMismatch - | ReconciliationError::OutcomeUnknown(_) - ), + TrackerBoxUpdaterError::Reconciliation( + ReconciliationError::IncoherentSnapshot | ReconciliationError::StaleEvidence, + ) => true, _ => false, } } @@ -1064,14 +1105,16 @@ impl TrackerBoxUpdater { ) -> Result { let bytes = Self::get_node_bytes(config, client, "/info", false) .await? - .ok_or_else(|| TrackerBoxUpdaterError::HttpError("missing /info body".to_string()))?; + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("missing /info body".to_string()) + })?; let value: serde_json::Value = serde_json::from_slice(&bytes) - .map_err(|error| TrackerBoxUpdaterError::HttpError(error.to_string()))?; + .map_err(|error| TrackerBoxUpdaterError::InvalidNodeResponse(error.to_string()))?; let height = value .get("fullHeight") .and_then(serde_json::Value::as_u64) .ok_or_else(|| { - TrackerBoxUpdaterError::HttpError("/info lacks fullHeight".to_string()) + TrackerBoxUpdaterError::InvalidNodeResponse("/info lacks fullHeight".to_string()) })?; let ids = ["bestFullHeaderId", "bestHeaderId"] .iter() @@ -1080,7 +1123,7 @@ impl TrackerBoxUpdater { .map(str::to_string) .collect::>(); if ids.len() != 1 { - return Err(TrackerBoxUpdaterError::HttpError( + return Err(TrackerBoxUpdaterError::InvalidNodeResponse( "/info does not expose one coherent full-chain tip id".to_string(), )); } @@ -1096,14 +1139,15 @@ impl TrackerBoxUpdater { inclusion_height: u64, ) -> Result { let before = Self::fetch_tip(config, client).await?; - let to_height = before - .height - .checked_add(1) - .ok_or_else(|| TrackerBoxUpdaterError::HttpError("node height overflow".to_string()))?; + 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::HttpError("missing chain slice".to_string()))?; + .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, @@ -1128,13 +1172,15 @@ impl TrackerBoxUpdater { ReconciliationError::IncompleteAncestry, )); } - let to_height = selected_through_height - .checked_add(1) - .ok_or_else(|| TrackerBoxUpdaterError::HttpError("node height overflow".to_string()))?; + 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::HttpError("missing chain slice".to_string()))?; + .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, @@ -1152,7 +1198,7 @@ impl TrackerBoxUpdater { config: &TrackerBoxUpdateConfig, client: &reqwest::Client, intent: &ReconciliationIntent, - policy: ReconciliationPolicy, + policy: &ReconciliationPolicy, ) -> Result { let transaction_path = format!("/blockchain/transaction/byId/{}", intent.tx_id()); let Some(observation_bytes) = @@ -1161,7 +1207,7 @@ impl TrackerBoxUpdater { return Ok(TransactionObservation::Pending); }; let observation: serde_json::Value = serde_json::from_slice(&observation_bytes) - .map_err(|error| TrackerBoxUpdaterError::HttpError(error.to_string()))?; + .map_err(|error| TrackerBoxUpdaterError::InvalidNodeResponse(error.to_string()))?; let Some(inclusion_height) = observation .get("inclusionHeight") .and_then(serde_json::Value::as_u64) @@ -1174,7 +1220,7 @@ impl TrackerBoxUpdater { "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::HttpError(error.to_string()))?; + .map_err(|error| TrackerBoxUpdaterError::InvalidNodeResponse(error.to_string()))?; let before = Self::fetch_tip(config, client).await?; if before @@ -1184,15 +1230,16 @@ impl TrackerBoxUpdater { { return Ok(TransactionObservation::Pending); } - let to_height = before - .height - .checked_add(1) - .ok_or_else(|| TrackerBoxUpdaterError::HttpError("node height overflow".to_string()))?; + 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::HttpError("missing chain slice".to_string()))?; + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("missing chain slice".to_string()) + })?; let first_header: serde_json::Value = serde_json::from_slice::(&chain_slice) .ok() @@ -1203,19 +1250,19 @@ impl TrackerBoxUpdater { .cloned() }) .ok_or_else(|| { - TrackerBoxUpdaterError::HttpError("empty chain slice".to_string()) + 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::HttpError("first header lacks id".to_string()) + 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::HttpError("missing full block".to_string()) + TrackerBoxUpdaterError::InvalidNodeResponse("missing full block".to_string()) })?; let predecessor = Self::get_node_bytes( config, @@ -1224,7 +1271,9 @@ impl TrackerBoxUpdater { false, ) .await? - .ok_or_else(|| TrackerBoxUpdaterError::HttpError("missing predecessor".to_string()))?; + .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, @@ -1247,7 +1296,7 @@ impl TrackerBoxUpdater { Ok(TransactionObservation::Accepted(validate_chain_effect( intent, &evidence, - policy, + policy.clone(), Self::unix_time_ms()?, )?)) } @@ -1319,7 +1368,7 @@ impl TrackerBoxUpdater { shared_state: &SharedTrackerState, journal: &ReconciliationJournal, effect: &ValidatedChainEffect, - policy: ReconciliationPolicy, + policy: &ReconciliationPolicy, ) -> Result<(), TrackerBoxUpdaterError> { let tip = Self::fetch_tip(config, client).await?; let observed_depth = tip @@ -2272,28 +2321,47 @@ mod publication_health_tests { 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()); } #[test] - fn only_chain_observation_failures_are_retryable_after_anchor_revalidation() { - assert!(TrackerBoxUpdater::is_retryable_chain_observation_error( + 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_chain_observation_error( + assert!(TrackerBoxUpdater::is_retryable_pending_observation_error( &TrackerBoxUpdaterError::Reconciliation(ReconciliationError::StaleEvidence) )); - assert!(!TrackerBoxUpdater::is_retryable_chain_observation_error( + 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_chain_observation_error( + assert!(!TrackerBoxUpdater::is_retryable_pending_observation_error( &TrackerBoxUpdaterError::BroadcastOutcomeUnknown("actor rejected".to_string()) )); } @@ -2451,9 +2519,10 @@ mod publication_health_tests { }; let (_shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel(1); assert!(matches!( - TrackerBoxUpdater::start(config, state, shutdown_rx, None).await, + 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 @@ -2576,6 +2645,48 @@ mod publication_health_tests { ); } + #[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(); diff --git a/crates/basis_store/src/chain_reconciliation.rs b/crates/basis_store/src/chain_reconciliation.rs index 1308aaa..91f687a 100644 --- a/crates/basis_store/src/chain_reconciliation.rs +++ b/crates/basis_store/src/chain_reconciliation.rs @@ -36,6 +36,13 @@ const JOURNAL_MANIFEST_CHECKSUM_DOMAIN: &[u8] = b"basis-confirmed-chain-manifest 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. @@ -900,48 +907,58 @@ fn validate_full_block_inclusion( Ok(()) } -/// Exact Ergo `BlockTransactions.transactionsRoot` construction. For modern -/// blocks the witness bytes are appended to the transaction id in the same -/// Merkle leaf; they are not hashed, truncated, or inserted as separate -/// leaves. Version 1 commits only to transaction ids. +/// 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 leaves = transactions - .iter() - .map(|transaction| { - 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); - } - let mut leaf = transaction_id.to_vec(); - if block_version != 1 { - leaf.extend( - transaction - .inputs - .iter() - .flat_map(|input| input.spending_proof.proof.as_ref().iter().copied()), - ); - } - Ok(MerkleNode::from_bytes(leaf)) - }) - .collect::, ReconciliationError>>()?; + 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, Copy, PartialEq, Eq)] +#[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 { @@ -949,14 +966,34 @@ impl ReconciliationPolicy { 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 } @@ -966,7 +1003,16 @@ impl ReconciliationPolicy { } fn validate(&self) -> Result<(), ReconciliationError> { - if self.max_evidence_age_ms == 0 + 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 @@ -1006,6 +1052,9 @@ pub struct ValidatedChainEffect { successor_box_id: String, observed_at_unix_ms: u64, effect: ReconciliationEffect, + policy: ReconciliationPolicy, + evidence_hash: [u8; 32], + decision_id: [u8; 32], } #[derive(Deserialize)] @@ -1020,6 +1069,9 @@ struct StoredValidatedChainEffect { 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 { @@ -1044,11 +1096,99 @@ impl<'de> Deserialize<'de> for ValidatedChainEffect { 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 } @@ -1073,6 +1213,10 @@ impl ValidatedChainEffect { &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, .. } @@ -1105,7 +1249,7 @@ pub(crate) fn validated_tracker_effect_for_test( successor_depth: u64, root: [u8; 33], ) -> ValidatedChainEffect { - ValidatedChainEffect { + let mut effect = ValidatedChainEffect { intent_id, tx_id, block_id, @@ -1120,7 +1264,12 @@ pub(crate) fn validated_tracker_effect_for_test( 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( @@ -1167,7 +1316,8 @@ pub fn validate_chain_effect( required: policy.min_successor_depth, }); } - Ok(ValidatedChainEffect { + 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(), @@ -1178,7 +1328,13 @@ pub fn validate_chain_effect( 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 @@ -1193,6 +1349,9 @@ pub struct ValidatedRollback { 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)] @@ -1205,6 +1364,9 @@ struct StoredValidatedRollback { 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 { @@ -1227,11 +1389,73 @@ impl<'de> Deserialize<'de> for ValidatedRollback { 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 } @@ -1247,7 +1471,7 @@ impl ValidatedRollback { #[cfg(test)] pub(crate) fn validated_rollback_for_test(effect: &ValidatedChainEffect) -> ValidatedRollback { - ValidatedRollback { + let mut rollback = ValidatedRollback { intent_id: effect.intent_id.clone(), tx_id: effect.tx_id.clone(), removed_block_id: effect.block_id.clone(), @@ -1256,15 +1480,21 @@ pub(crate) fn validated_rollback_for_test(effect: &ValidatedChainEffect) -> Vali 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, + policy: &ReconciliationPolicy, now_unix_ms: u64, ) -> Result { + accepted.validate_runtime_policy(policy)?; selected_chain.validate()?; if !selected_chain.covers_tip() { return Err(ReconciliationError::IncompleteAncestry); @@ -1277,7 +1507,8 @@ pub fn validate_rollback( if replacement == accepted.block_id { return Err(ReconciliationError::RollbackNotProven); } - Ok(ValidatedRollback { + 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(), @@ -1286,7 +1517,13 @@ pub fn validate_rollback( 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 @@ -1294,9 +1531,10 @@ pub fn validate_rollback( pub fn validate_anchor_still_active( accepted: &ValidatedChainEffect, selected_chain: &ActiveChainProof, - policy: ReconciliationPolicy, + 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); @@ -1323,6 +1561,9 @@ pub struct ValidatedRetirement { 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)] @@ -1335,6 +1576,9 @@ struct StoredValidatedRetirement { 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 { @@ -1357,10 +1601,73 @@ impl<'de> Deserialize<'de> for ValidatedRetirement { 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)] @@ -1379,9 +1686,10 @@ pub enum ReorgHorizonDecision { pub fn validate_reorg_horizon( accepted: &ValidatedChainEffect, selected_window: &ActiveChainProof, - policy: ReconciliationPolicy, + 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 { @@ -1397,7 +1705,8 @@ pub fn validate_reorg_horizon( return Err(ReconciliationError::IncompleteAncestry); } if selected_window.first_block_id() != accepted.block_id { - return Ok(ReorgHorizonDecision::Rollback(ValidatedRollback { + 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(), @@ -1406,9 +1715,16 @@ pub fn validate_reorg_horizon( 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)); } - Ok(ReorgHorizonDecision::Retire(ValidatedRetirement { + 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(), @@ -1417,7 +1733,13 @@ pub fn validate_reorg_horizon( 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)] @@ -1437,6 +1759,7 @@ struct PendingTicket { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct AcceptedAnchor { + intent: ReconciliationIntent, effect: ValidatedChainEffect, rollback: Option, applied: bool, @@ -1584,9 +1907,12 @@ impl ReconciliationJournal { &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 @@ -1641,6 +1967,7 @@ impl ReconciliationJournal { 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| { @@ -1648,10 +1975,14 @@ impl ReconciliationJournal { .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() @@ -1666,7 +1997,7 @@ impl ReconciliationJournal { .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 && !safe_absence { + if (accepted_was_applied || confirmation_history_present) && !safe_absence { return Err(ReconciliationError::AccountingProjectionMismatch); } } @@ -1771,10 +2102,7 @@ impl ReconciliationJournal { .pending .as_mut() .ok_or(ReconciliationError::NoTicket)?; - if pending.intent.intent_id != effect.intent_id || pending.intent.tx_id != effect.tx_id - { - return Err(ReconciliationError::IntentMismatch); - } + effect.validate_against_intent(&pending.intent)?; if pending.phase == PendingPhase::AcceptanceReady { return if pending.accepted_effect.as_ref() == Some(&effect) { Ok(false) @@ -1809,7 +2137,9 @@ impl ReconciliationJournal { { return Err(ReconciliationError::InvalidPhase); } + let accepted_intent = pending.intent.clone(); state.accepted = Some(AcceptedAnchor { + intent: accepted_intent, effect: effect.clone(), rollback: None, applied: true, @@ -1827,6 +2157,7 @@ impl ReconciliationJournal { .accepted .as_mut() .ok_or(ReconciliationError::NoTicket)?; + rollback.validate_against_effect(&anchor.effect)?; if anchor.retirement.is_some() { return Err(ReconciliationError::InvalidPhase); } @@ -1859,6 +2190,7 @@ impl ReconciliationJournal { .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 @@ -1952,6 +2284,7 @@ impl ReconciliationJournal { } } 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(()) @@ -2090,12 +2423,32 @@ fn append_event( } 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(), @@ -2103,16 +2456,81 @@ fn validate_state(state: &JournalState) -> Result<(), ReconciliationError> { } 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 pending.phase == PendingPhase::AcceptanceReady && pending.accepted_effect.is_none() { + if !has_event("prepared", pending.intent.tx_id()) { return Err(ReconciliationError::Journal( - "acceptance-ready ticket lacks evidence".to_string(), + "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 { - normalize_id(accepted.effect.intent_id.clone(), "accepted intent id")?; + 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(()) } @@ -2165,6 +2583,19 @@ fn deserialize_state(bytes: &[u8]) -> Result 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 @@ -2223,6 +2654,10 @@ pub enum ReconciliationError { 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")] @@ -2580,7 +3015,7 @@ mod tests { } fn policy() -> ReconciliationPolicy { - ReconciliationPolicy::new(6, 100, 12) + ReconciliationPolicy::new(6, 100, 12, "ergo-test", [0x42; 32]) } fn journal_binding() -> ReconciliationJournalBinding { @@ -2592,6 +3027,14 @@ mod tests { .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( @@ -2737,45 +3180,116 @@ mod tests { } #[test] - fn transaction_root_matches_frozen_reference_and_rejects_old_mutants() { - let (_, signed, _) = tracker_fixture(); - let transaction = parse_transaction(&signed).unwrap(); - let root_v2 = transaction_merkle_root(&[transaction.clone()], 2).unwrap(); - // Frozen from sigma-rust `ergo-chain-generation::transactions_root` - // for this exact transaction fixture. + 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()), - "7950d92caa1b621ed4deed01f326883792f9e6505e99513daf737cc42431fa78" + "c392f72d35ee256968ae2d2426280d38f4f89d4f722aea8b7e0c0b864b1dce4f" ); - let unsigned_id = blake2b256_hash(&transaction.bytes_to_sign().unwrap()); - assert_eq!(transaction.id().as_ref(), unsigned_id.as_slice()); - let root_v1 = transaction_merkle_root(&[transaction.clone()], 1).unwrap(); + 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(vec![MerkleNode::from_bytes(unsigned_id.to_vec())]).root_hash_special() + MerkleTree::new( + transaction_ids + .iter() + .map(|id| MerkleNode::from_bytes(id.to_vec())) + .collect::>() + ) + .root_hash_special() ); - let witness = transaction - .inputs - .iter() - .flat_map(|input| input.spending_proof.proof.as_ref().iter().copied()) - .collect::>(); - let separated = MerkleTree::new(vec![ - MerkleNode::from_bytes(unsigned_id.to_vec()), - MerkleNode::from_bytes(blake2b256_hash(&witness)[1..].to_vec()), - ]) + 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(); - let mut hashed_leaf = unsigned_id.to_vec(); - hashed_leaf.extend_from_slice(&blake2b256_hash(&witness)[1..]); - let hashed = MerkleTree::new(vec![MerkleNode::from_bytes(hashed_leaf)]).root_hash_special(); - let mut truncated_leaf = unsigned_id.to_vec(); - truncated_leaf.extend_from_slice(&witness[1..]); - let truncated = - MerkleTree::new(vec![MerkleNode::from_bytes(truncated_leaf)]).root_hash_special(); - assert_ne!(root_v2, separated); - assert_ne!(root_v2, hashed); - assert_ne!(root_v2, truncated); + 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] @@ -2967,11 +3481,11 @@ mod tests { ) .unwrap(); assert_eq!( - validate_rollback(&effect, &original_chain, policy(), 1_000), + 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(); + 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() @@ -2984,7 +3498,7 @@ mod tests { let mut forged = replacement; forged.headers[1].parent_id = id(3); - assert!(validate_rollback(&effect, &forged, policy(), 1_000).is_err()); + assert!(validate_rollback(&effect, &forged, &policy(), 1_000).is_err()); } #[test] @@ -3012,7 +3526,7 @@ mod tests { &signed, ); let retirement = - match validate_reorg_horizon(&effect, &old_but_active, policy(), 1_000).unwrap() { + match validate_reorg_horizon(&effect, &old_but_active, &policy(), 1_000).unwrap() { ReorgHorizonDecision::Retire(retirement) => retirement, ReorgHorizonDecision::Rollback(_) => panic!("unchanged anchor must retire"), }; @@ -3029,7 +3543,7 @@ mod tests { None, ); assert!(matches!( - validate_reorg_horizon(&effect, &replacement, policy(), 1_000).unwrap(), + validate_reorg_horizon(&effect, &replacement, &policy(), 1_000).unwrap(), ReorgHorizonDecision::Rollback(rollback) if rollback.removed_block_id() == effect.block_id() )); @@ -3042,7 +3556,7 @@ mod tests { &signed, ); assert_eq!( - validate_reorg_horizon(&effect, &too_short, policy(), 1_000), + validate_reorg_horizon(&effect, &too_short, &policy(), 1_000), Err(ReconciliationError::IncompleteAncestry) ); let too_long = bounded_chain_for_transaction( @@ -3053,7 +3567,7 @@ mod tests { &signed, ); assert_eq!( - validate_reorg_horizon(&effect, &too_long, policy(), 1_000), + validate_reorg_horizon(&effect, &too_long, &policy(), 1_000), Err(ReconciliationError::IncompleteAncestry) ); @@ -3080,9 +3594,10 @@ mod tests { Err(ReconciliationError::DepthMismatch) ); - let invalid = ReconciliationPolicy::new(6, 100, MAX_REORG_MONITOR_DEPTH + 1); + 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), + validate_reorg_horizon(&effect, &old_but_active, &invalid, 1_000), Err(ReconciliationError::InvalidPolicy) ); } @@ -3110,7 +3625,7 @@ mod tests { &signed_a, ); let retirement = - match validate_reorg_horizon(&effect_a, &selected, policy(), 1_000).unwrap() { + match validate_reorg_horizon(&effect_a, &selected, &policy(), 1_000).unwrap() { ReorgHorizonDecision::Retire(retirement) => retirement, ReorgHorizonDecision::Rollback(_) => panic!("same anchor"), }; @@ -3260,7 +3775,7 @@ mod tests { .iter() .any(|candidate| history_a.matches_validated_effect(candidate))); assert_eq!( - journal.validate_tracker_startup_join(None, Some(&history_a)), + journal.validate_tracker_startup_join(None, Some(&history_a), true, &policy()), Err(ReconciliationError::AccountingProjectionMismatch) ); } @@ -3300,34 +3815,177 @@ mod tests { acceptance.arm_submission(intent.intent_id()).unwrap(); acceptance.record_validated_effect(effect.clone()).unwrap(); assert!(acceptance - .validate_tracker_startup_join(Some(&pending), None) + .validate_tracker_startup_join(Some(&pending), None, false, &policy()) .is_ok()); assert_eq!( - acceptance.validate_tracker_startup_join(None, None), + acceptance.validate_tracker_startup_join(None, None, false, &policy()), Err(ReconciliationError::AccountingProjectionMismatch) ); assert!(acceptance - .validate_tracker_startup_join(None, Some(&projection)) + .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,), + 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,), + 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), + acceptance.validate_tracker_startup_join(None, None, false, &policy()), Err(ReconciliationError::AccountingProjectionMismatch) ); assert!(acceptance - .validate_tracker_startup_join(None, Some(&projection)) + .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(); @@ -3369,14 +4027,19 @@ mod tests { RecoveryAction::ApplyRollback(found) if found == rollback )); assert!(journal - .validate_tracker_startup_join(Some(&pending_b), None) + .validate_tracker_startup_join(Some(&pending_b), None, true, &policy()) .is_ok()); assert_eq!( - journal.validate_tracker_startup_join(None, None), + 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,), + journal.validate_tracker_startup_join( + Some(&("aa".repeat(32), pending_b.1)), + None, + true, + &policy(), + ), Err(ReconciliationError::AccountingProjectionMismatch) ); } @@ -3467,7 +4130,7 @@ mod tests { 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(); + let rollback = validate_rollback(&effect_a, &replacement, &policy(), 1_000).unwrap(); journal.record_rollback(rollback.clone()).unwrap(); assert!(matches!( journal.recovery_action().unwrap(), diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index ae3447a..9d07fb0 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -534,9 +534,9 @@ impl TrackerStateManager { } fn ensure_healthy(&self) -> Result<(), NoteError> { - if self.poisoned.load(Ordering::SeqCst) { + if self.poisoned.load(Ordering::SeqCst) || !self.publication_health.is_healthy() { Err(NoteError::StorageOutcomeUnknown( - "Tracker state manager is quarantined after an indeterminate durable write; restart and reconcile before reuse" + "Tracker state manager or confirmed-chain publisher is quarantined; restart and reconcile before exposing commitment effects" .to_string(), )) } else { @@ -1089,27 +1089,41 @@ impl TrackerStateManager { } /// 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 { - if let Err(e) = self.ensure_healthy() { - panic!("Cannot read confirmation from quarantined tracker: {:?}", e); - } - 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 { - if let Err(e) = self.ensure_healthy() { - panic!( - "Cannot read confirmations from quarantined tracker: {:?}", - e - ); - } - 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 diff --git a/crates/basis_store/src/tests.rs b/crates/basis_store/src/tests.rs index ed937a1..4a30431 100644 --- a/crates/basis_store/src/tests.rs +++ b/crates/basis_store/src/tests.rs @@ -509,8 +509,8 @@ mod test_module { #[cfg(test)] mod confirmation_state_tests { use crate::{ - FreshGenerationApproval, IouNote, NoteConfirmationStatus, TrackerGenerationConfig, - TrackerStateManager, + FreshGenerationApproval, IouNote, NoteConfirmationStatus, NoteError, + TrackerGenerationConfig, TrackerStateManager, }; use secp256k1::{Secp256k1, SecretKey}; @@ -743,6 +743,54 @@ mod confirmation_state_tests { 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(); diff --git a/specs/confirmed_chain_reconciliation.md b/specs/confirmed_chain_reconciliation.md index 22781fb..de8d455 100644 --- a/specs/confirmed_chain_reconciliation.md +++ b/specs/confirmed_chain_reconciliation.md @@ -34,8 +34,11 @@ binds all of the following: 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 -spending-proof bytes of all inputs are appended to that same transaction-id -leaf. Witnesses are not separate leaves, hashed independently, or truncated. +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 @@ -51,6 +54,15 @@ 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 @@ -87,9 +99,16 @@ 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. An absent receipt is permitted only because the +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. @@ -140,8 +159,9 @@ These are application-finality controls, not consensus finality claims. ## Integration dependency: bounded node responses This change is not a standalone service-resource-bounds closure. It must be -integrated with the bounded-node-request work rooted at commit `248929c` before -deployment. In this module, every reconciler evidence request flows through +integrated with the bounded-node-request work rooted at exact commit +`248929c5dbb923e4ce7e3530374f4fe66be13fbc` before deployment. In this module, +every reconciler evidence request flows through `get_node_bytes`: `/info`, `/blocks/chainSlice`, `/blockchain/transaction/byId`, `/blocks/{id}`, and `/blockchain/box/byId`. Its `send` followed by `bytes` needs the shared bounded @@ -149,3 +169,9 @@ body reader. The transaction-production side also has direct node response reads after `send` in `find_tracker_box`, `get_wallet_boxes`, `get_box_binary`, `get_node_height`, `sign_transaction`, and `broadcast_transaction`; those JSON, text, and error-body reads must consume the same service-bounds abstraction. +The actor request and reply waits in `begin_publication`, `abort_publication`, +`record_publication_attempt`, `confirm_publication`, and +`rollback_publication` also require the same integration's bounded admission +and response deadlines. Until that exact merge is reviewed and its combined +closure replayed, this change must not be described or deployed as a standalone +resource-bounds fix. From a6dae0cd2e5369e475219fc97a4d3292ad6ff064 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Sun, 9 Aug 2026 19:46:22 +0200 Subject: [PATCH 40/41] test: make resource guards portable --- .../tests/legacy_command_surface_guard.rs | 2 +- crates/basis_store/src/ergo_scanner.rs | 11 +++++++---- crates/basis_store/src/tracker_scanner_test.rs | 11 +++++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/basis_server/tests/legacy_command_surface_guard.rs b/crates/basis_server/tests/legacy_command_surface_guard.rs index a00049f..7e3c429 100644 --- a/crates/basis_server/tests/legacy_command_surface_guard.rs +++ b/crates/basis_server/tests/legacy_command_surface_guard.rs @@ -31,7 +31,7 @@ fn production_actor_has_no_legacy_proof_commands_or_repair_environment() { #[test] fn openapi_exposes_exactly_the_nine_retired_routes_as_gone() { - let openapi = include_str!("../../../openapi.yaml"); + let openapi = include_str!("../../../openapi.yaml").replace("\r\n", "\n"); assert!(!openapi.contains(" /proof:\n")); let routes = [ diff --git a/crates/basis_store/src/ergo_scanner.rs b/crates/basis_store/src/ergo_scanner.rs index ea08cdb..505091b 100644 --- a/crates/basis_store/src/ergo_scanner.rs +++ b/crates/basis_store/src/ergo_scanner.rs @@ -1241,15 +1241,18 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let config = NodeConfig { node_url: oversized_declared_response_server().await, - ..NodeConfig::default() + ..historical_config() }; let state = ServerState::new(config, temp_dir.path()).unwrap(); let error = state.fetch_current_height().await.unwrap_err(); - assert!(error - .to_string() - .contains("outbound node response body exceeds 2097152 bytes")); + assert!(matches!( + error, + ScannerError::ResponseTooLarge { + max_bytes: NODE_HTTP_MAX_BODY_BYTES + } + )); } fn historical_tree() -> String { diff --git a/crates/basis_store/src/tracker_scanner_test.rs b/crates/basis_store/src/tracker_scanner_test.rs index bcb6faf..8e33b78 100644 --- a/crates/basis_store/src/tracker_scanner_test.rs +++ b/crates/basis_store/src/tracker_scanner_test.rs @@ -6,7 +6,7 @@ mod tests { use crate::{ 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; @@ -62,9 +62,12 @@ mod tests { let error = state.get_current_height().await.unwrap_err(); - assert!(error - .to_string() - .contains("outbound node response body exceeds 2097152 bytes")); + assert!(matches!( + error, + TrackerScannerError::ResponseTooLarge { + max_bytes: NODE_HTTP_MAX_BODY_BYTES + } + )); } #[tokio::test] From 342355c90f5db7729a16408c7ad2eec263d920b7 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:57:10 +0200 Subject: [PATCH 41/41] security: sign tracker updates locally --- config/basis.toml.example | 2 +- crates/basis_server/src/config.rs | 4 +- crates/basis_server/src/main.rs | 1 - .../basis_server/src/tracker_box_updater.rs | 1686 +++++++++++++---- .../tests/tracker_signing_surface_guard.rs | 12 + docs/TRACKER_BOX_SETUP.md | 512 +---- specs/security_boundary_remediation.md | 12 +- specs/server/tracker_box_update_spec.md | 52 +- 8 files changed, 1448 insertions(+), 833 deletions(-) create mode 100644 crates/basis_server/tests/tracker_signing_surface_guard.rs diff --git a/config/basis.toml.example b/config/basis.toml.example index 36ea514..a05ba7d 100644 --- a/config/basis.toml.example +++ b/config/basis.toml.example @@ -23,7 +23,7 @@ allow_fresh_tracker_generation = 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_server/src/config.rs b/crates/basis_server/src/config.rs index 28a4cf5..b0d789d 100644 --- a/crates/basis_server/src/config.rs +++ b/crates/basis_server/src/config.rs @@ -64,8 +64,8 @@ pub struct ErgoConfig { pub allow_fresh_tracker_generation: 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, } diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index 3a95a4b..18874b1 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -592,7 +592,6 @@ 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(), }; let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1); diff --git a/crates/basis_server/src/tracker_box_updater.rs b/crates/basis_server/src/tracker_box_updater.rs index 7043836..90f3e30 100644 --- a/crates/basis_server/src/tracker_box_updater.rs +++ b/crates/basis_server/src/tracker_box_updater.rs @@ -1,10 +1,33 @@ //! 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 ergo_lib::chain::transaction::Transaction; +//! 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 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; use std::sync::{Arc, RwLock}; use tokio::time::{interval, Duration}; use tracing::{error, info, warn}; @@ -174,7 +197,6 @@ pub struct TrackerBoxUpdateConfig { pub api_key: Option, pub update_interval_seconds: u64, pub fee: u64, - pub change_address: Option, pub tracker_secret_key: Option<[u8; 32]>, } @@ -185,7 +207,6 @@ impl std::fmt::Debug for TrackerBoxUpdateConfig { .field("api_key", &self.api_key.as_ref().map(|_| "")) .field("update_interval_seconds", &self.update_interval_seconds) .field("fee", &self.fee) - .field("change_address", &self.change_address) .field( "tracker_secret_key", &self.tracker_secret_key.as_ref().map(|_| ""), @@ -201,7 +222,6 @@ impl Default for TrackerBoxUpdateConfig { api_key: None, update_interval_seconds: 600, fee: 1_000_000, - change_address: None, tracker_secret_key: None, } } @@ -209,86 +229,7 @@ impl Default for TrackerBoxUpdateConfig { #[cfg(test)] mod secret_redaction_tests { - use super::{TrackerBoxUpdateConfig, TrackerBoxUpdater}; - use std::io::{self, Write}; - use std::sync::{Arc, Mutex}; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - #[derive(Clone, Default)] - struct SharedWriter(Arc>>); - - struct BufferWriter(Arc>>); - - impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedWriter { - type Writer = BufferWriter; - - fn make_writer(&'a self) -> Self::Writer { - BufferWriter(Arc::clone(&self.0)) - } - } - - impl Write for BufferWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.0 - .lock() - .expect("log buffer lock") - .extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - async fn one_error_response(body: &'static str) -> (String, tokio::task::JoinHandle>) { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind loopback listener"); - let address = listener.local_addr().expect("loopback address"); - let task = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept request"); - let mut request = Vec::new(); - let mut buffer = [0u8; 4096]; - loop { - let count = stream.read(&mut buffer).await.expect("read request"); - if count == 0 { - break; - } - request.extend_from_slice(&buffer[..count]); - if let Some(header_end) = - request.windows(4).position(|window| window == b"\r\n\r\n") - { - 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; - } - } - } - - let response = format!( - "HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - stream - .write_all(response.as_bytes()) - .await - .expect("write response"); - request - }); - (format!("http://{address}"), task) - } + use super::TrackerBoxUpdateConfig; #[test] fn updater_config_debug_redacts_all_secrets() { @@ -304,53 +245,6 @@ mod secret_redaction_tests { assert!(!rendered.contains("171, 171")); assert!(rendered.matches("").count() >= 2); } - - #[tokio::test(flavor = "current_thread")] - async fn node_signing_error_and_logs_do_not_echo_secret_bearing_bodies() { - let tracker_sentinel = "sentinel-tracker-private-key-do-not-log"; - let api_sentinel = "sentinel-updater-api-key-do-not-log"; - let response_sentinel = "sentinel-node-response-do-not-log"; - let (node_url, server) = one_error_response(response_sentinel).await; - let config = TrackerBoxUpdateConfig { - node_url, - api_key: Some(api_sentinel.to_string()), - ..TrackerBoxUpdateConfig::default() - }; - let unsigned_tx = serde_json::json!({ - "tx": {"inputs": [], "dataInputs": [], "outputs": []}, - "secrets": {"dlog": [tracker_sentinel]} - }); - - let writer = SharedWriter::default(); - let subscriber = tracing_subscriber::fmt() - .without_time() - .with_ansi(false) - .with_writer(writer.clone()) - .finish(); - let dispatch = tracing::Dispatch::new(subscriber); - let guard = tracing::dispatcher::set_default(&dispatch); - let error = TrackerBoxUpdater::sign_transaction(&config, unsigned_tx) - .await - .expect_err("loopback node must reject signing") - .to_string(); - drop(guard); - - let request = server.await.expect("loopback server task"); - assert!(request - .windows(tracker_sentinel.len()) - .any(|window| window == tracker_sentinel.as_bytes())); - assert!(request - .windows(api_sentinel.len()) - .any(|window| window == api_sentinel.as_bytes())); - - let logs = String::from_utf8(writer.0.lock().expect("log buffer lock").clone()) - .expect("UTF-8 logs"); - for sentinel in [tracker_sentinel, api_sentinel, response_sentinel] { - assert!(!error.contains(sentinel)); - assert!(!logs.contains(sentinel)); - } - assert!(logs.contains("Node signing request completed")); - } } /// Error type for tracker box updater operations @@ -370,14 +264,22 @@ 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("Broadcast outcome is unknown; tracker publication remains fenced: {0}")] BroadcastOutcomeUnknown(String), } /// 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, @@ -432,6 +334,18 @@ pub struct TrackerBoxUpdater; struct PreparedTrackerUpdate { signed_tx: serde_json::Value, tx_id: String, + submitted_height: u64, +} + +struct LocalSigningMaterial { + secret: SecretKey, + tracker_point: EcPoint, + p2pk_tree: ErgoTree, +} + +struct LocalSigningContext { + state_context: ErgoStateContext, + creation_height: u32, } impl TrackerBoxUpdater { @@ -689,10 +603,7 @@ impl TrackerBoxUpdater { } }; - let submitted_height = Self::get_node_height(&config) - .await - .map(|height| height as u64) - .unwrap_or(tracker_box.creation_height as u64); + let submitted_height = prepared.submitted_height; if !Self::record_publication_attempt( &cmd_tx, publication_lease, @@ -952,64 +863,48 @@ impl TrackerBoxUpdater { Ok(entries.into_iter().map(|e| e.box_details).collect()) } - /// 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], + /// 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, - ) -> (Vec, u64) { - let candidates: Vec<&ErgoBoxApi> = wallet_boxes - .iter() - .filter(|b| b.box_id != tracker_box_id) - .collect(); - - // 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); - - if let Some(box_) = token_free.iter().find(|b| b.value >= required) { - return (vec![box_.box_id.clone()], box_.value); - } - - 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); - } - } - - // Fall back to token-bearing boxes if necessary. - let mut token_boxes: Vec<&ErgoBoxApi> = candidates + 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. @@ -1051,80 +946,465 @@ impl TrackerBoxUpdater { 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 { + ) -> Result { let client = crate::bounded_http::node_http() .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; - let url = format!("{}/info", config.node_url.trim_end_matches('/')); + 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 = client - .execute(request) + let headers_response = client + .execute(headers_request) .await .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; + if !headers_response.status().is_success() { + let status = headers_response.status(); + let body = headers_response.text_lossy(); + return Err(TrackerBoxUpdaterError::HttpError(format!( + "HTTP {} fetching signing headers: {}", + status, body + ))); + } + let headers: Vec
= headers_response.json().map_err(|error| { + TrackerBoxUpdaterError::StateContextValidation(format!("invalid header JSON: {error}")) + })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text_lossy(); + let info_response = client + .execute(info_request) + .await + .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 node height: {}", + "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) + } - let body: serde_json::Value = response - .json() - .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; + 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(), + )); + } + } - body["fullHeight"] - .as_u64() - .map(|h| h as u32) - .ok_or_else(|| TrackerBoxUpdaterError::HttpError("Missing fullHeight".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!("Requesting node signature for tracker-box update"); + 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 = crate::bounded_http::node_http() - .map_err(|e| TrackerBoxUpdaterError::SigningFailed(e.to_string()))?; - 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 = client - .execute(request) - .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(); - info!(status = %status, "Node signing request completed"); + 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 - ))); + 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(), + )); } - response - .json() - .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. @@ -1246,7 +1526,7 @@ impl TrackerBoxUpdater { Ok(tx_id) } - /// Submit a tracker box update transaction via /wallet/transaction/sign + /// 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, @@ -1254,141 +1534,235 @@ impl TrackerBoxUpdater { 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); - 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); - - // Preserve any tokens from the fee inputs in the change output so they are not burned. - let change_assets: Vec = fee_input_ids - .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() - }) - .collect(); - - // 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()); - } else { - other_assets.push(asset.clone()); + 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, } } - 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": {} - }), - ]; + 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 { - outputs.push(serde_json::json!({ - "value": change_amount, - "ergoTree": change_address_to_ergo_tree(&change_address)?, - "creationHeight": current_height, - "assets": change_assets, - "additionalRegisters": {} - })); + 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, + }); } - let secrets = config - .tracker_secret_key - .as_ref() - .map(|sk| vec![hex::encode(sk)]) - .unwrap_or_default(); - - let unsigned_tx = serde_json::json!({ - "tx": { - "inputs": inputs, - "dataInputs": [], - "outputs": outputs - }, - "inputsRaw": inputs_raw, - "dataInputsRaw": [], - "secrets": { - "dlog": secrets - } - }); - - let signed_tx = Self::sign_transaction(config, unsigned_tx).await?; + let inputs = exact_boxes + .iter() + .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)?; - Ok(PreparedTrackerUpdate { signed_tx, tx_id }) + Ok(PreparedTrackerUpdate { + signed_tx, + tx_id, + submitted_height: u64::from(current_height), + }) } /// Check if a transaction has been confirmed on-chain by querying the blockchain API @@ -1428,49 +1802,568 @@ impl TrackerBoxUpdater { } } -/// 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)) -} +#[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) + } -/// 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; + 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") + } - 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 + 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"), )) - })?; - Ok(hex::encode(tree.sigma_serialize_bytes().map_err(|e| { - TrackerBoxUpdaterError::SerializationError(format!("Failed to serialize ergoTree: {:?}", e)) - })?)) + } + + 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 { + 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" + ); + } + 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() + ); + } + + #[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, + }); + } + assert!(matches!( + TrackerBoxUpdater::select_fee_inputs( + &advertised[3..], + u64::MAX, + &tracker.box_id().to_string(), + &owner_tree, + ), + Err(TrackerBoxUpdaterError::ArithmeticError(_)) + )); + } + + #[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 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, + }, + 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::{SharedTrackerState, TrackerBoxUpdater, TrackerBoxUpdaterError}; + use super::{ + AssetApi, ErgoBoxApi, SharedTrackerState, TrackerBoxUpdater, TrackerBoxUpdaterError, + }; + use ergo_lib::ergotree_ir::{ergo_tree::ErgoTree, serialization::SigmaSerializable}; + use std::collections::HashMap; const SIGNED_TRANSACTION_JSON: &str = r#"{ "id": "9148408c04c2e38a6402a7950d6157730fa7d49e9ab3b9cadec481d7769918e9", @@ -1512,6 +2405,35 @@ mod publication_health_tests { assert!(!state.is_publication_healthy()); } + #[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, + }; + + 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(); 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/docs/TRACKER_BOX_SETUP.md b/docs/TRACKER_BOX_SETUP.md index 57f9b6d..84797a2 100644 --- a/docs/TRACKER_BOX_SETUP.md +++ b/docs/TRACKER_BOX_SETUP.md @@ -1,431 +1,97 @@ -# Tracker Box Setup Guide +# Tracker Box Setup -**Document Version:** 1.0 -**Last Updated:** 2026-03-02 -**Status:** Historical v1 operational reference — superseded +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. -> The proof routes and redemption procedures below are retired and return -> `410 Gone`. This file is not current deployment or activation authority. +## Required identity ---- +The active tracker input must have all of the following properties: -## Overview +- 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. -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**. - -### 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/specs/security_boundary_remediation.md b/specs/security_boundary_remediation.md index 71bb7a3..a4433b4 100644 --- a/specs/security_boundary_remediation.md +++ b/specs/security_boundary_remediation.md @@ -27,7 +27,10 @@ construction, and settlement boundaries. 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 the same exact-input and owner-derived-change rule. + 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, @@ -38,6 +41,13 @@ construction, and settlement boundaries. 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 diff --git a/specs/server/tracker_box_update_spec.md b/specs/server/tracker_box_update_spec.md index ff77c42..a282b9a 100644 --- a/specs/server/tracker_box_update_spec.md +++ b/specs/server/tracker_box_update_spec.md @@ -1,10 +1,10 @@ # Historical Tracker Box Update Mechanism Specification -> **Status: partially superseded design reference.** The updater remains a -> migration input, but the v1 redemption builder and raw completion command -> shown below have been retired from production APIs. Actor-owned durable state, -> confirmed-chain reconciliation, and exact v2 generation admission are defined -> in separate current workstreams. This document is not deployment authority. +> **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 @@ -69,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]>, } ``` @@ -184,16 +182,21 @@ The background task executes the following algorithm in a continuous loop: - 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) -9. **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 -10. **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 -11. **Error Handling**: +12. **Error Handling**: - If any step fails, log an appropriate ERROR message - Continue with the scheduled interval regardless of failures @@ -295,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, @@ -304,8 +307,9 @@ 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 } @@ -528,7 +532,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 @@ -546,8 +550,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