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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion contracts/bridge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,14 @@ mod bridge {

impl scale::Decode for StoredBridgeRequest {
fn decode<I: scale::Input>(input: &mut I) -> Result<Self, scale::Error> {
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<u8> = Vec::with_capacity(INITIAL_CAPACITY);
while let Ok(byte) = input.read_byte() {
bytes.push(byte);
}
Expand Down
52 changes: 52 additions & 0 deletions contracts/bridge/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> = 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 =
<StoredBridgeRequest as Decode>::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);
}
}
134 changes: 134 additions & 0 deletions contracts/lending/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

use ink::storage::Mapping;

mod status_packing;

#[ink::contract]
mod propchain_lending {
use super::*;
Expand Down Expand Up @@ -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<u64>`
/// 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<u8>`, 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<u8>,
pub servicing_status: Vec<u8>,
}

impl From<LoanApplication> 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<PackedLoanApplication> 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,
)]
Expand Down
85 changes: 85 additions & 0 deletions contracts/lending/src/status_packing.rs
Original file line number Diff line number Diff line change
@@ -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<Packed>`.
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);
}
}
1 change: 1 addition & 0 deletions contracts/lending/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#![allow(clippy::duplicated_attributes)]
#![cfg(test)]

use super::*;
Expand Down
Loading
Loading