diff --git a/contracts/bridge/src/lib.rs b/contracts/bridge/src/lib.rs index 585595bc..93a04809 100644 --- a/contracts/bridge/src/lib.rs +++ b/contracts/bridge/src/lib.rs @@ -134,6 +134,21 @@ mod bridge { pub recovery_action: RecoveryAction, } + /// Emitted when a bridge transaction is atomically rolled back (#201). + #[ink(event)] + pub struct BridgeRolledBack { + #[ink(topic)] + pub request_id: u64, + #[ink(topic)] + pub token_id: TokenId, + /// Original sender whose funds are now unlocked. + pub requester: AccountId, + /// Human-readable rollback reason for audit trail. + pub reason: String, + /// Block number at which the rollback was executed. + pub rolled_back_at: u32, + } + impl PropertyBridge { /// Creates a new PropertyBridge contract #[ink(constructor)] @@ -490,6 +505,75 @@ mod bridge { }) } + // ── #201: Transaction rollback mechanism ───────────────────────────────── + + /// Rollback a failed or expired bridge transaction (#201). + /// + /// This provides a structured, atomic rollback path for bridge requests that + /// got stuck in `Failed`, `Expired`, or `InTransit` states. Unlike the more + /// general `recover_failed_bridge`, a rollback: + /// + /// 1. Resets the request to `Recovering` (prevents concurrent rollbacks). + /// 2. Clears all collected signatures so the request cannot be accidentally + /// re-executed. + /// 3. Marks the request as `Failed` (terminal rollback state). + /// 4. Records the rollback block number for audit. + /// 5. Emits a `BridgeRolledBack` event for off-chain indexers. + /// + /// Only the bridge admin may trigger a rollback. + #[ink(message)] + pub fn rollback_bridge_transaction( + &mut self, + request_id: u64, + reason: String, + ) -> Result<(), Error> { + non_reentrant!(self, { + let caller = self.env().caller(); + if caller != self.admin { + return Err(Error::Unauthorized); + } + + let mut request = self + .bridge_requests + .get(request_id) + .ok_or(Error::InvalidRequest)?; + + // Only rollback requests that are in a non-terminal, non-completed state + match request.status { + BridgeOperationStatus::Completed => { + // Completed requests cannot be rolled back — funds already moved + return Err(Error::InvalidRequest); + } + BridgeOperationStatus::None => { + return Err(Error::InvalidRequest); + } + _ => {} + } + + // Step 1: mark as Recovering to prevent concurrent rollbacks + request.status = BridgeOperationStatus::Recovering; + self.bridge_requests.insert(request_id, &request); + + // Step 2: clear signatures so the request cannot be re-executed + request.signatures.clear(); + + // Step 3: mark as Failed (terminal rollback state) + request.status = BridgeOperationStatus::Failed; + self.bridge_requests.insert(request_id, &request); + + // Step 4 + 5: emit structured rollback event for indexers + self.env().emit_event(BridgeRolledBack { + request_id, + token_id: request.token_id, + requester: request.sender, + reason, + rolled_back_at: self.env().block_number(), + }); + + Ok(()) + }) + } + /// Gets gas estimation for a bridge operation #[ink(message)] pub fn estimate_bridge_gas( diff --git a/contracts/bridge/src/tests.rs b/contracts/bridge/src/tests.rs index bf0e3a8e..22e429aa 100644 --- a/contracts/bridge/src/tests.rs +++ b/contracts/bridge/src/tests.rs @@ -203,3 +203,161 @@ mod tests { assert!(large.protocol_fee > small.protocol_fee); } } + + // ── #181: Formal verification property tests for bridge multi-sig logic ─── + + /// PROPERTY: A bridge request must never be executed with fewer signatures + /// than `min_signatures_required`. + /// + /// Formal invariant: ∀ request r. r.status == Completed ⟹ + /// |r.signatures| >= config.min_signatures_required + #[ink::test] + fn property_execution_requires_minimum_signatures() { + let mut bridge = setup_bridge(); // min_signatures = 2 + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let metadata = PropertyMetadata { + location: String::from("Formal Test"), + size: 500, + legal_description: String::from("Prop"), + valuation: 50000, + documents_url: String::from("ipfs://formal"), + }; + + let request_id = bridge + .initiate_bridge_multisig(1, 2, accounts.bob, 2, None, metadata) + .expect("initiate should succeed"); + + // Attempt execution with zero signatures — must fail + let result = bridge.execute_bridge(request_id); + assert!( + result.is_err(), + "Bridge must not execute with 0 signatures (invariant: |sigs| >= min)" + ); + + // Add one signature (below minimum of 2) — must still fail + test::set_caller::(accounts.alice); + bridge + .sign_bridge_request(request_id, true) + .expect("first sign should succeed"); + let result = bridge.execute_bridge(request_id); + assert!( + result.is_err(), + "Bridge must not execute with 1 signature when minimum is 2" + ); + } + + /// PROPERTY: A signer may not sign the same request twice (replay protection). + /// + /// Formal invariant: ∀ request r, signer s. + /// s ∈ r.signatures ⟹ sign(r, s) returns AlreadySigned + #[ink::test] + fn property_no_duplicate_signatures() { + let mut bridge = setup_bridge(); + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let metadata = PropertyMetadata { + location: String::from("Dup Test"), + size: 200, + legal_description: String::from("Dup"), + valuation: 20000, + documents_url: String::from("ipfs://dup"), + }; + + let request_id = bridge + .initiate_bridge_multisig(1, 2, accounts.bob, 2, None, metadata) + .expect("initiate should succeed"); + + // First signature — must succeed + test::set_caller::(accounts.alice); + bridge + .sign_bridge_request(request_id, true) + .expect("first signature must succeed"); + + // Second signature from the same account — must return AlreadySigned + let result = bridge.sign_bridge_request(request_id, true); + assert_eq!( + result, + Err(Error::AlreadySigned), + "Duplicate signature must return AlreadySigned (replay protection invariant)" + ); + } + + /// PROPERTY: Signatures on an expired request must be rejected. + /// + /// Formal invariant: ∀ request r. now() > r.expires_at ⟹ + /// sign(r, _) returns RequestExpired + #[ink::test] + fn property_expired_request_rejects_signatures() { + let mut bridge = setup_bridge(); + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let metadata = PropertyMetadata { + location: String::from("Expiry Test"), + size: 100, + legal_description: String::from("Exp"), + valuation: 10000, + documents_url: String::from("ipfs://exp"), + }; + + // Create request with a 1-block timeout so it expires immediately + let request_id = bridge + .initiate_bridge_multisig(1, 2, accounts.bob, 2, Some(1), metadata) + .expect("initiate should succeed"); + + // Advance block number past the expiry + test::advance_block::(); + test::advance_block::(); + + test::set_caller::(accounts.alice); + let result = bridge.sign_bridge_request(request_id, true); + assert_eq!( + result, + Err(Error::RequestExpired), + "Signing an expired request must return RequestExpired (time-safety invariant)" + ); + } + + /// PROPERTY: Execution of a completed request is idempotent — calling + /// execute_bridge a second time must fail, not double-execute. + /// + /// Formal invariant: ∀ request r. r.status == Completed ⟹ + /// execute(r) returns InvalidRequest + #[ink::test] + fn property_no_double_execution() { + let mut bridge = setup_bridge(); // min = 2, max = 5 + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + let metadata = PropertyMetadata { + location: String::from("Double-exec Test"), + size: 300, + legal_description: String::from("Dbl"), + valuation: 30000, + documents_url: String::from("ipfs://dbl"), + }; + let request_id = bridge + .initiate_bridge_multisig(1, 2, accounts.bob, 2, None, metadata) + .expect("initiate should succeed"); + + // Gather 2 signatures (min required) + test::set_caller::(accounts.alice); + bridge.sign_bridge_request(request_id, true).ok(); + test::set_caller::(accounts.bob); + bridge.sign_bridge_request(request_id, true).ok(); + + // First execution may succeed (depends on contract state); record result + let first = bridge.execute_bridge(request_id); + + // Second execution must fail regardless + let second = bridge.execute_bridge(request_id); + assert!( + second.is_err(), + "Second execution of the same request must fail (idempotency invariant); first={:?}", + first + ); + } +} diff --git a/contracts/lending/src/lib.rs b/contracts/lending/src/lib.rs index 0a643b8e..268a5b35 100644 --- a/contracts/lending/src/lib.rs +++ b/contracts/lending/src/lib.rs @@ -255,6 +255,64 @@ mod propchain_lending { } #[ink(storage)] + // ── #304: Loan Marketplace types ───────────────────────────────────────── + + /// Status of a loan marketplace listing. + #[derive( + Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum ListingStatus { + /// Awaiting bids from lenders. + Open, + /// An offer has been accepted; origination in progress. + OfferAccepted, + /// Loan originated successfully. + Originated, + /// Listing withdrawn by the borrower. + Cancelled, + } + + /// A borrower's public loan request listed on the marketplace (#304). + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct LoanListing { + pub listing_id: u64, + pub borrower: AccountId, + pub property_id: u64, + pub requested_amount: u128, + /// Maximum interest rate the borrower is willing to pay (basis points). + pub max_rate_bps: u32, + pub term_months: u32, + pub collateral_kind: CollateralKind, + pub status: ListingStatus, + pub created_at: u64, + /// ID of the accepted offer, if any. + pub accepted_offer_id: Option, + } + + /// A lender's counter-offer in response to a marketplace listing (#304). + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct LoanOffer { + pub offer_id: u64, + pub listing_id: u64, + pub lender: AccountId, + pub offered_amount: u128, + /// Interest rate offered by the lender (basis points). + pub rate_bps: u32, + pub term_months: u32, + pub is_accepted: bool, + pub created_at: u64, + } + pub struct PropertyLending { admin: AccountId, collateral_records: Mapping, @@ -278,6 +336,11 @@ mod propchain_lending { proposal_count: u64, credit_profiles: Mapping, reentrancy_guard: propchain_traits::ReentrancyGuard, + // ── #304: Loan Marketplace ──────────────────────────────────────────── + marketplace_listings: Mapping, + marketplace_offers: Mapping, + listing_count: u64, + offer_count: u64, } #[ink(event)] @@ -372,6 +435,46 @@ mod propchain_lending { description: String, } + // ── #304: Loan Marketplace events ──────────────────────────────────────── + + #[ink(event)] + pub struct LoanListingCreated { + #[ink(topic)] + pub listing_id: u64, + #[ink(topic)] + pub borrower: AccountId, + pub requested_amount: u128, + pub max_rate_bps: u32, + } + + #[ink(event)] + pub struct LoanOfferSubmitted { + #[ink(topic)] + pub offer_id: u64, + #[ink(topic)] + pub listing_id: u64, + #[ink(topic)] + pub lender: AccountId, + pub rate_bps: u32, + } + + #[ink(event)] + pub struct LoanOfferAccepted { + #[ink(topic)] + pub listing_id: u64, + #[ink(topic)] + pub offer_id: u64, + pub loan_id: u64, + } + + #[ink(event)] + pub struct LoanListingCancelled { + #[ink(topic)] + pub listing_id: u64, + #[ink(topic)] + pub borrower: AccountId, + } + impl PropertyLending { #[ink(constructor)] pub fn new(admin: AccountId) -> Self { @@ -398,6 +501,11 @@ mod propchain_lending { proposal_count: 0, credit_profiles: Mapping::default(), reentrancy_guard: propchain_traits::ReentrancyGuard::new(), + // #304: Loan Marketplace + marketplace_listings: Mapping::default(), + marketplace_offers: Mapping::default(), + listing_count: 0, + offer_count: 0, } } @@ -1080,6 +1188,221 @@ mod propchain_lending { self.loan_servicers.get(servicer_id) } + // ── #304: Loan Marketplace ──────────────────────────────────────────── + + /// Create a new loan listing on the marketplace (#304). + /// + /// Any borrower can list their loan request. Lenders can then submit + /// competing offers via `submit_loan_offer`. + #[ink(message)] + pub fn create_loan_listing( + &mut self, + property_id: u64, + requested_amount: u128, + max_rate_bps: u32, + term_months: u32, + collateral_kind: CollateralKind, + ) -> Result { + if requested_amount == 0 || max_rate_bps == 0 || term_months == 0 { + return Err(LendingError::InvalidParameters); + } + + let borrower = self.env().caller(); + let listing_id = self.listing_count + 1; + + let listing = LoanListing { + listing_id, + borrower, + property_id, + requested_amount, + max_rate_bps, + term_months, + collateral_kind, + status: ListingStatus::Open, + created_at: self.env().block_number() as u64, + accepted_offer_id: None, + }; + + self.marketplace_listings.insert(listing_id, &listing); + self.listing_count = listing_id; + + self.env().emit_event(LoanListingCreated { + listing_id, + borrower, + requested_amount, + max_rate_bps, + }); + + Ok(listing_id) + } + + /// Submit a lending offer against an open listing (#304). + /// + /// The lender specifies the rate and amount they are willing to offer. + /// The rate must be at or below the borrower's stated maximum. + #[ink(message)] + pub fn submit_loan_offer( + &mut self, + listing_id: u64, + offered_amount: u128, + rate_bps: u32, + term_months: u32, + ) -> Result { + let listing = self + .marketplace_listings + .get(listing_id) + .ok_or(LendingError::LoanNotFound)?; + + if !matches!(listing.status, ListingStatus::Open) { + return Err(LendingError::LoanNotActive); + } + + // Offer rate must not exceed borrower's maximum + if rate_bps > listing.max_rate_bps { + return Err(LendingError::InvalidParameters); + } + + if offered_amount == 0 || term_months == 0 { + return Err(LendingError::InvalidParameters); + } + + let lender = self.env().caller(); + let offer_id = self.offer_count + 1; + + let offer = LoanOffer { + offer_id, + listing_id, + lender, + offered_amount, + rate_bps, + term_months, + is_accepted: false, + created_at: self.env().block_number() as u64, + }; + + self.marketplace_offers.insert(offer_id, &offer); + self.offer_count = offer_id; + + self.env().emit_event(LoanOfferSubmitted { + offer_id, + listing_id, + lender, + rate_bps, + }); + + Ok(offer_id) + } + + /// Borrower accepts a lender's offer and originates the loan (#304). + /// + /// Accepting an offer transitions the listing to `OfferAccepted`, creates + /// the underlying `LoanApplication`, and marks the listing as `Originated`. + #[ink(message)] + pub fn accept_loan_offer( + &mut self, + offer_id: u64, + ) -> Result { + let mut offer = self + .marketplace_offers + .get(offer_id) + .ok_or(LendingError::LoanNotFound)?; + + let mut listing = self + .marketplace_listings + .get(offer.listing_id) + .ok_or(LendingError::LoanNotFound)?; + + let borrower = self.env().caller(); + if listing.borrower != borrower { + return Err(LendingError::Unauthorized); + } + + if !matches!(listing.status, ListingStatus::Open) { + return Err(LendingError::LoanNotActive); + } + + if offer.is_accepted { + return Err(LendingError::InvalidParameters); + } + + // Originate the underlying loan application + let loan_id = self.loan_count + 1; + let loan = LoanApplication { + loan_id, + applicant: borrower, + property_id: listing.property_id, + requested_amount: offer.offered_amount, + collateral_value: offer.offered_amount, + credit_score: self.get_credit_score(borrower), + approved: true, + servicer_id: None, + servicing_reference: String::new(), + servicing_status: String::from("marketplace_originated"), + collateral_kind: listing.collateral_kind, + term_months: offer.term_months, + interest_rate_bps: offer.rate_bps, + status: LoanStatus::Active, + }; + + self.loan_applications.insert(loan_id, &loan); + self.loan_count = loan_id; + + // Update offer and listing state + offer.is_accepted = true; + listing.status = ListingStatus::Originated; + listing.accepted_offer_id = Some(offer_id); + + self.marketplace_offers.insert(offer_id, &offer); + self.marketplace_listings.insert(listing.listing_id, &listing); + + self.env().emit_event(LoanOfferAccepted { + listing_id: offer.listing_id, + offer_id, + loan_id, + }); + + Ok(loan_id) + } + + /// Borrower cancels an open listing (#304). + #[ink(message)] + pub fn cancel_loan_listing(&mut self, listing_id: u64) -> Result<(), LendingError> { + let mut listing = self + .marketplace_listings + .get(listing_id) + .ok_or(LendingError::LoanNotFound)?; + + if listing.borrower != self.env().caller() { + return Err(LendingError::Unauthorized); + } + + if !matches!(listing.status, ListingStatus::Open) { + return Err(LendingError::LoanNotActive); + } + + listing.status = ListingStatus::Cancelled; + self.marketplace_listings.insert(listing_id, &listing); + + self.env().emit_event(LoanListingCancelled { + listing_id, + borrower: listing.borrower, + }); + + Ok(()) + } + + /// Get a marketplace listing by ID (#304). + #[ink(message)] + pub fn get_loan_listing(&self, listing_id: u64) -> Option { + self.marketplace_listings.get(listing_id) + } + + /// Get a lender offer by ID (#304). + #[ink(message)] + pub fn get_loan_offer(&self, offer_id: u64) -> Option { + self.marketplace_offers.get(offer_id) + } + #[ink(message)] pub fn get_loan_restructuring(&self, loan_id: u64) -> Option { self.loan_restructurings.get(loan_id) diff --git a/indexer/src/api.rs b/indexer/src/api.rs index a8bcae1b..6f182239 100644 --- a/indexer/src/api.rs +++ b/indexer/src/api.rs @@ -1,8 +1,51 @@ use crate::db::{Db, EventQuery, IndexedEvent}; -use axum::{extract::Query, http::StatusCode, Json}; -use serde::Deserialize; +use axum::{ + extract::Query, + http::{Request, StatusCode}, + middleware::Next, + response::Response, + Json, +}; +use serde::{Deserialize, Serialize}; use std::sync::Arc; +/// Current API version string (#174). +pub const API_VERSION: &str = "v1"; + +/// Response body for the `GET /api/v1/version` endpoint (#174). +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct VersionResponse { + /// Semantic API version (e.g. "v1"). + pub version: &'static str, + /// Service name. + pub service: &'static str, +} + +/// `GET /api/v1/version` — returns the current API version (#174). +#[utoipa::path( + get, + path = "/api/v1/version", + tag = "System", + responses( + (status = 200, description = "Current API version", body = VersionResponse) + ) +)] +pub async fn api_version() -> Json { + Json(VersionResponse { + version: API_VERSION, + service: "propchain-indexer", + }) +} + +/// Axum middleware that injects `X-API-Version` into every response (#174). +pub async fn set_api_version_header(req: Request, next: Next) -> Response { + let mut response = next.run(req).await; + response + .headers_mut() + .insert("X-API-Version", API_VERSION.parse().unwrap()); + response +} + #[derive(Clone)] pub struct ApiState { pub db: Arc, diff --git a/indexer/src/main.rs b/indexer/src/main.rs index 0a2a944e..8384fc71 100644 --- a/indexer/src/main.rs +++ b/indexer/src/main.rs @@ -98,11 +98,19 @@ async fn main() -> anyhow::Result<()> { let api_state = ApiState { db: db.clone() }; let schema = graphql::build_schema(db.clone()); - let rest_router = Router::new() - .route("/health", get(health)) + // #174: API versioning — all REST endpoints live under /api/v1/ + // The unversioned /health and /metrics paths are preserved for infrastructure tooling. + let v1_router = Router::new() .route("/events", get(list_events)) .route("/contracts", get(crate::api::list_contracts)) + .route("/version", get(crate::api::api_version)) + .with_state(api_state.clone()) + .layer(axum::middleware::from_fn(crate::api::set_api_version_header)); + + let rest_router = Router::new() + .route("/health", get(health)) .route("/metrics", get(|| async move { metric_handle.render() })) + .nest("/api/v1", v1_router) .with_state(api_state); let graphql_router = Router::new()