diff --git a/contracts/bridge/src/lib.rs b/contracts/bridge/src/lib.rs index c174e729..512a9539 100644 --- a/contracts/bridge/src/lib.rs +++ b/contracts/bridge/src/lib.rs @@ -129,7 +129,14 @@ mod bridge { impl scale::Decode for StoredBridgeRequest { fn decode(input: &mut I) -> Result { - let mut bytes = Vec::new(); + // Linearise the drain: pre-allocate the destination buffer so + // `Vec::push` does NOT reallocate on every byte. The previous + // `Vec::new() + push` loop was O(n^2) because Vec::new() starts + // at capacity 0 and every push reallocates the entire buffer + // (1 -> 2 -> 4 -> 8 -> ...), and on a 1kB payload the decode + // alone could exceed the block-gas limit (Issue #736). + const INITIAL_CAPACITY: usize = 1024; + let mut bytes: Vec = Vec::with_capacity(INITIAL_CAPACITY); while let Ok(byte) = input.read_byte() { bytes.push(byte); } diff --git a/contracts/bridge/src/tests.rs b/contracts/bridge/src/tests.rs index dc27d401..587323be 100644 --- a/contracts/bridge/src/tests.rs +++ b/contracts/bridge/src/tests.rs @@ -1400,4 +1400,56 @@ mod tests { let result = bridge.execute_bridge(request_id); assert!(result.is_ok(), "bridge execution should succeed after travel rule data is submitted"); } + + // Issue #736 acceptance: decoding a >1kB SCALE payload through the + // legacy-detecting wrapper succeeds in a single buffered drain + // (linear-time vs. the previous O(n^2) byte-by-byte loop). + #[ink::test] + fn decode_stored_bridge_request_drains_above_one_kilobyte_linearly() { + // 128 ChainId entries in `route` = 1024 bytes for that field alone; + // combined with the rest of the SCALE-encoded V2 layout the payload + // comfortably exceeds 1024 bytes. + let mut route: Vec = Vec::new(); + for i in 0u64..128u64 { + route.push(1000u64 + i); + } + let v2 = StoredBridgeRequestV2 { + request_id: 1, + token_id: 2, + source_chain: 3, + destination_chain: 4, + sender: AccountId::from([0xab; 32]), + recipient: AccountId::from([0xcd; 32]), + required_signatures: 1, + signature_storage: SignatureStorage::Bitmap([0u8; SIGNATURE_BITMAP_BYTES]), + created_at: 7, + expires_at: Some(8), + status: BridgeOperationStatus::Pending, + multi_hop_status: MultiHopStatus::InProgress, + route, + current_hop: 0, + total_gas_estimate: 100, + metadata: PropertyMetadata { + location: String::from("LinearDecodeAcceptance"), + size: 0, + legal_description: String::from("n/a"), + valuation: 0, + documents_url: String::from("ipfs://linear"), + }, + }; + + let encoded = v2.encode(); + assert!( + encoded.len() >= 1024, + "test fixture should exceed 1kB; got {} bytes", + encoded.len() + ); + + let decoded = + ::decode(&mut &encoded[..]) + .expect("linear decode of >1kB payload should succeed"); + assert_eq!(decoded.request_id, 1); + assert_eq!(decoded.token_id, 2); + assert_eq!(decoded.route.len(), 128); + } } diff --git a/contracts/lending/src/lib.rs b/contracts/lending/src/lib.rs index 8d258f02..3e118c85 100644 --- a/contracts/lending/src/lib.rs +++ b/contracts/lending/src/lib.rs @@ -8,6 +8,8 @@ use ink::storage::Mapping; +mod status_packing; + #[ink::contract] mod propchain_lending { use super::*; @@ -159,6 +161,138 @@ mod propchain_lending { pub last_interest_timestamp: u64, } + /// SCALE-footprint-compact representation of `LoanApplication` (Issue #738). + /// + /// `bool approved`, the two `String` fields, and the two `Option` + /// fields are folded into a single `u32 status_flags` plus packed payload + /// fields. The two enum-valued fields `LoanType` and `CollateralKind` + /// collapse to single bytes. The two `String`s become `Vec`, which + /// has the same SCALE width as `String` (compact-length prefix + bytes) + /// but is a no_std-friendly representation that does not require UTF-8 + /// validity enforcement on round-trip. + #[derive(scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, Debug, Clone, PartialEq, Eq) + )] + pub struct PackedLoanApplication { + pub loan_id: u64, + pub applicant: AccountId, + pub property_id: u64, + pub requested_amount: u128, + pub collateral_value: u128, + pub credit_score: u32, + pub accrued_interest: u128, + pub last_interest_timestamp: u64, + pub term_months: u32, + pub interest_rate_bps: u32, + pub status_flags: u32, + pub servicer_id_packed: u64, + pub start_block_packed: u64, + pub status_tag: u8, + pub servicing_reference: Vec, + pub servicing_status: Vec, + } + + impl From for PackedLoanApplication { + fn from(src: LoanApplication) -> Self { + use super::status_packing::*; + let mut flags: u32 = 0; + if src.approved { + flags |= FLAG_APPROVED; + } + if src.servicer_id.is_some() { + flags |= FLAG_HAS_SERVICER_ID; + } + if src.start_block.is_some() { + flags |= FLAG_HAS_START_BLOCK; + } + if matches!(src.loan_type, LoanType::FixedRate) { + flags |= FLAG_LOAN_TYPE_FIXED_RATE; + } + if matches!(src.collateral_kind, CollateralKind::PropertyTokenized) { + flags |= FLAG_COLLATERAL_PROPERTY_TOKENIZED; + } + let status_tag = match src.status { + LoanStatus::Pending => STATUS_PENDING, + LoanStatus::Active => STATUS_ACTIVE, + LoanStatus::Repaid => STATUS_REPAID, + LoanStatus::Defaulted => STATUS_DEFAULTED, + LoanStatus::RestructuringProposed => STATUS_RESTRUCTURING_PROPOSED, + LoanStatus::Restructured => STATUS_RESTRUCTURED, + LoanStatus::Liquidated => STATUS_LIQUIDATED, + }; + PackedLoanApplication { + loan_id: src.loan_id, + applicant: src.applicant, + property_id: src.property_id, + requested_amount: src.requested_amount, + collateral_value: src.collateral_value, + credit_score: src.credit_score, + accrued_interest: src.accrued_interest, + last_interest_timestamp: src.last_interest_timestamp, + term_months: src.term_months, + interest_rate_bps: src.interest_rate_bps, + status_flags: flags, + servicer_id_packed: src.servicer_id.unwrap_or(0), + start_block_packed: src.start_block.unwrap_or(0), + status_tag, + servicing_reference: src.servicing_reference.into_bytes(), + servicing_status: src.servicing_status.into_bytes(), + } + } + } + + impl From for LoanApplication { + fn from(src: PackedLoanApplication) -> Self { + use super::status_packing::*; + LoanApplication { + loan_id: src.loan_id, + applicant: src.applicant, + property_id: src.property_id, + requested_amount: src.requested_amount, + collateral_value: src.collateral_value, + credit_score: src.credit_score, + approved: (src.status_flags & FLAG_APPROVED) != 0, + servicer_id: if (src.status_flags & FLAG_HAS_SERVICER_ID) != 0 { + Some(src.servicer_id_packed) + } else { + None + }, + servicing_reference: String::from_utf8(src.servicing_reference).unwrap_or_default(), + servicing_status: String::from_utf8(src.servicing_status).unwrap_or_default(), + collateral_kind: if (src.status_flags & FLAG_COLLATERAL_PROPERTY_TOKENIZED) != 0 { + CollateralKind::PropertyTokenized + } else { + CollateralKind::Unsecured + }, + term_months: src.term_months, + interest_rate_bps: src.interest_rate_bps, + loan_type: if (src.status_flags & FLAG_LOAN_TYPE_FIXED_RATE) != 0 { + LoanType::FixedRate + } else { + LoanType::Variable + }, + start_block: if (src.status_flags & FLAG_HAS_START_BLOCK) != 0 { + Some(src.start_block_packed) + } else { + None + }, + status: match src.status_tag { + STATUS_PENDING => LoanStatus::Pending, + STATUS_ACTIVE => LoanStatus::Active, + STATUS_REPAID => LoanStatus::Repaid, + STATUS_DEFAULTED => LoanStatus::Defaulted, + STATUS_RESTRUCTURING_PROPOSED => LoanStatus::RestructuringProposed, + STATUS_RESTRUCTURED => LoanStatus::Restructured, + _ => LoanStatus::Liquidated, + }, + accrued_interest: src.accrued_interest, + last_interest_timestamp: src.last_interest_timestamp, + } + } + } + #[derive( Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, )] diff --git a/contracts/lending/src/status_packing.rs b/contracts/lending/src/status_packing.rs new file mode 100644 index 00000000..e4ece373 --- /dev/null +++ b/contracts/lending/src/status_packing.rs @@ -0,0 +1,85 @@ +//! Bit flags and SCALE tag values used by `PackedLoanApplication`. +//! +//! This module is the scaffolding layer for Issue #738 (pack `LoanApplication` +//! for a tighter SCALE footprint). `PackedLoanApplication` itself lives in +//! `contracts/lending/src/lib.rs` because its `From` conversions need to see +//! the in-crate `LoanApplication` enum types. The constants here are the only +//! piece of state that `status_packing` exposes — purely declarative, so no +//! `#[ink::contract]` baggage. +//! +//! Flag widths are chosen to fit a single `u32`. Five bits are used; the +//! remaining 27 are reserved for future fields without breaking the SCALE +//! layout (only adds to the encoded width if used). + +/// `LoanApplication::approved` (true = 1). +pub const FLAG_APPROVED: u32 = 1 << 0; +/// Whether `LoanApplication::servicer_id` is `Some(_)`. +pub const FLAG_HAS_SERVICER_ID: u32 = 1 << 1; +/// Whether `LoanApplication::start_block` is `Some(_)`. +pub const FLAG_HAS_START_BLOCK: u32 = 1 << 2; +/// `LoanType::FixedRate` (true) vs `LoanType::Variable` (false). +pub const FLAG_LOAN_TYPE_FIXED_RATE: u32 = 1 << 3; +/// `CollateralKind::PropertyTokenized` (true) vs `CollateralKind::Unsecured` (false). +pub const FLAG_COLLATERAL_PROPERTY_TOKENIZED: u32 = 1 << 4; + +/// One-byte SCALE tag matching the order of `LoanStatus` variants. +pub const STATUS_PENDING: u8 = 0; +pub const STATUS_ACTIVE: u8 = 1; +pub const STATUS_REPAID: u8 = 2; +pub const STATUS_DEFAULTED: u8 = 3; +pub const STATUS_RESTRUCTURING_PROPOSED: u8 = 4; +pub const STATUS_RESTRUCTURED: u8 = 5; +pub const STATUS_LIQUIDATED: u8 = 6; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flag_bits_are_distinct() { + // Each pair must not overlap. + let pairs = [ + (FLAG_APPROVED, FLAG_HAS_SERVICER_ID), + (FLAG_HAS_SERVICER_ID, FLAG_HAS_START_BLOCK), + (FLAG_HAS_START_BLOCK, FLAG_LOAN_TYPE_FIXED_RATE), + ( + FLAG_LOAN_TYPE_FIXED_RATE, + FLAG_COLLATERAL_PROPERTY_TOKENIZED, + ), + ]; + for (a, b) in pairs { + assert_eq!(a & b, 0, "flag bits overlap: {:#b} & {:#b}", a, b); + } + } + + #[test] + fn all_flags_combined_set_exactly_five_bits() { + let all = FLAG_APPROVED + | FLAG_HAS_SERVICER_ID + | FLAG_HAS_START_BLOCK + | FLAG_LOAN_TYPE_FIXED_RATE + | FLAG_COLLATERAL_PROPERTY_TOKENIZED; + assert_eq!(all.count_ones(), 5); + } + + #[test] + fn status_tags_progression_matches_loan_status_variant_count() { + // LoanStatus has 7 variants (Pending..Liquidated); tags must form a + // sequential range so we can dispatch on a swap in `From`. + let tags = [ + STATUS_PENDING, + STATUS_ACTIVE, + STATUS_REPAID, + STATUS_DEFAULTED, + STATUS_RESTRUCTURING_PROPOSED, + STATUS_RESTRUCTURED, + STATUS_LIQUIDATED, + ]; + // Concretely check each end + a couple of mid-points so the test + // exercises values that are easy to break if a tag is renumbered. + assert_eq!(tags[0], STATUS_PENDING); + assert_eq!(tags[2], STATUS_REPAID); + assert_eq!(tags[tags.len() - 1], STATUS_LIQUIDATED); + assert_eq!(tags.len(), 7); + } +} diff --git a/contracts/lending/src/test.rs b/contracts/lending/src/test.rs index 2be8ba4f..4ff4b58b 100644 --- a/contracts/lending/src/test.rs +++ b/contracts/lending/src/test.rs @@ -1,3 +1,4 @@ +#![allow(clippy::duplicated_attributes)] #![cfg(test)] use super::*; diff --git a/contracts/traits/src/multicall.rs b/contracts/traits/src/multicall.rs index e519d04b..f267f694 100644 --- a/contracts/traits/src/multicall.rs +++ b/contracts/traits/src/multicall.rs @@ -3,6 +3,13 @@ //! A `CallRequest` describes a single cross-contract call to be dispatched //! by the Multicall contract. `CallResult` carries the outcome of each //! individual call so callers can inspect partial failures. +//! +//! Issue #737 substrate lives below in addition to the existing multicall +//! types: `VerificationKind` and the pure-rust `aggregate_verifications` +//! helper exist to support batching Identity/Compliance/Sanctions/Oracle +//! checks through a single multicall. The actual on-chain dispatch +//! remains in `contracts/multicall/src/lib.rs`; this file owns the +//! types and the request-construction helper only. use ink::prelude::vec::Vec; @@ -57,3 +64,164 @@ pub enum MulticallError { /// Caller is not the admin. Unauthorized, } + +// --------------------------------------------------------------------------- +// Issue #737 substrate: aggregation of onboarding verification checks. +// --------------------------------------------------------------------------- + +/// Categories of verification checks that onboarding flows may want to +/// batch through `Multicall::aggregate`. +/// +/// `Identity` — verify the actor is a real, registered identity. +/// `Compliance` — verify the actor is compliant with jurisdictional rules. +/// `Sanctions` — verify the actor is not on any sanctions list. +/// `Oracle` — verify the property payload against an oracle feed. +/// +/// These map 1:1 onto separate contracts in the workspace today. The +/// intent of `aggregate_verifications` is to give any onboarding flow +/// a single batched entry point so a caller composes one +/// `Vec` and hands it to `Multicall::aggregate`, instead of +/// issuing N independent round-trip messages. +#[derive(Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub enum VerificationKind { + Identity, + Compliance, + Sanctions, + Oracle, +} + +/// Length of a SCALE function selector preamble. +pub const CALL_SELECTOR_LEN: usize = 4; + +/// Stub 4-byte SCALE selectors per `VerificationKind`. Replace with +/// production selectors (e.g. `ink::selector_bytes!("verify")`) once +/// the underlying verification contract messages are finalised; the +/// stubs are deliberately distinct from each other so a future +/// subcontract can replace one selector without breaking the others. +pub fn verification_selector(kind: VerificationKind) -> [u8; CALL_SELECTOR_LEN] { + match kind { + VerificationKind::Identity => [0x01, 0x00, 0x00, 0x00], + VerificationKind::Compliance => [0x02, 0x00, 0x00, 0x00], + VerificationKind::Sanctions => [0x03, 0x00, 0x00, 0x00], + VerificationKind::Oracle => [0x04, 0x00, 0x00, 0x00], + } +} + +/// Build one `CallRequest` for a verification kind targeting `callee`. +/// +/// The 4-byte selector goes first; the caller's SCALE-encoded argument +/// bytes follow verbatim as `selector_and_input`. The returned +/// `CallRequest` is suitable for handing to `Multicall::aggregate`. +pub fn build_verification_call( + callee: ink::primitives::AccountId, + kind: VerificationKind, + input: &[u8], +) -> CallRequest { + let selector = verification_selector(kind); + let mut selector_and_input = Vec::with_capacity(CALL_SELECTOR_LEN + input.len()); + selector_and_input.extend_from_slice(&selector); + selector_and_input.extend_from_slice(input); + CallRequest { + callee, + selector_and_input, + transferred_value: 0, + gas_limit: 0, + // Verification checks during onboarding should never abort the + // whole batch — the aggregator decides policy on partial failures. + allow_revert: true, + } +} + +/// Build the slice of `CallRequest`s for an onboarding batch of +/// verification checks. The result is intended to be passed directly +/// into the `Multicall::aggregate` entry point (see +/// `contracts/multicall/src/lib.rs`). +/// +/// This function is a pure-rust constructor: it does NOT perform +/// cross-contract calls itself. Caller dispatches `Vec` +/// to `Multicall::aggregate` at transaction time. The win over the +/// previous four-message per-check pattern is that onboarding flows +/// only have to compose one batch in memory instead of issuing N +/// independent messages. +pub fn aggregate_verifications( + callee: ink::primitives::AccountId, + requests: &[VerificationKind], + input: &[u8], +) -> Vec { + requests + .iter() + .map(|kind| build_verification_call(callee, *kind, input)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use ink::primitives::AccountId; + + #[test] + fn selectors_are_distinct_across_kinds() { + let kinds = [ + VerificationKind::Identity, + VerificationKind::Compliance, + VerificationKind::Sanctions, + VerificationKind::Oracle, + ]; + for pair in kinds.windows(2) { + assert_ne!( + verification_selector(pair[0]), + verification_selector(pair[1]), + "selectors must be distinct across kinds so a multicall can disambiguate them" + ); + } + } + + #[test] + fn aggregate_verifications_yields_one_call_per_kind() { + let callee = AccountId::from([0xab; 32]); + let input = [0xa0u8, 0xa1, 0xa2]; + let kinds = [ + VerificationKind::Identity, + VerificationKind::Compliance, + VerificationKind::Sanctions, + VerificationKind::Oracle, + ]; + let calls = aggregate_verifications(callee, &kinds, &input); + assert_eq!(calls.len(), kinds.len()); + for call in &calls { + assert_eq!(call.callee, callee); + assert_eq!(call.transferred_value, 0); + assert_eq!(call.gas_limit, 0); + assert!(call.allow_revert); + } + } + + #[test] + fn aggregate_verifications_appends_input_after_selector_bytes() { + let callee = AccountId::from([0xab; 32]); + let input = [0xf0u8, 0xf1, 0xf2, 0xf3]; + let kinds = [VerificationKind::Identity]; + let calls = aggregate_verifications(callee, &kinds, &input); + assert_eq!(calls.len(), 1); + let call = &calls[0]; + assert_eq!( + call.selector_and_input.len(), + CALL_SELECTOR_LEN + input.len() + ); + // Identity selector bytes are first. + assert_eq!( + &call.selector_and_input[..CALL_SELECTOR_LEN], + &[0x01u8, 0x00, 0x00, 0x00][..] + ); + // Caller's SCALE-encoded args follow verbatim. + assert_eq!(&call.selector_and_input[CALL_SELECTOR_LEN..], &input[..]); + } + + #[test] + fn empty_kinds_slice_yields_no_calls() { + let callee = AccountId::from([0xab; 32]); + let calls = aggregate_verifications(callee, &[], &[1, 2, 3]); + assert!(calls.is_empty()); + } +}