Skip to content

Commit 662dc1a

Browse files
authored
Merge pull request #30 from stellar-sharpy/feat/p26-checked-arithmetic
feat: Protocol 25/26 — checked arithmetic, TTL extension, invoice fingerprint
2 parents 9d7215e + c04c4b9 commit 662dc1a

2 files changed

Lines changed: 136 additions & 7 deletions

File tree

contracts/sharpy/src/lib.rs

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ mod types;
88
#[cfg(test)]
99
mod test;
1010

11-
use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Map, Symbol, Vec};
11+
use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Bytes, Env, Map, Symbol, Vec};
1212
use types::{
1313
AuditEntry, CreateInvoiceParams, DisputeState, Invoice, InvoiceOptions, InvoicePayment,
1414
InvoiceStats, InvoiceStatus, Payment, SplitRule, SubscriptionParams,
@@ -43,7 +43,9 @@ fn load_invoice(env: &Env, id: u64) -> Invoice {
4343

4444
fn save_invoice(env: &Env, id: u64, invoice: &Invoice) {
4545
env.storage().persistent().set(&invoice_key(id), invoice);
46-
// Extend TTL to ~1 year (in ledgers at ~5s each: 365*24*3600/5 = 6_307_200)
46+
// Protocol 26 CAP-78: limited TTL extension host function.
47+
// Extend to ~1 year (6_307_200 ledgers at ~5s each).
48+
// Only bumps if current TTL < min_ttl (100_000 ledgers ~ 6 days).
4749
env.storage().persistent().extend_ttl(&invoice_key(id), 100_000, 6_307_200);
4850
}
4951

@@ -382,20 +384,39 @@ impl SharpyContract {
382384
match invoice.split_rules.get(i as u32).unwrap() {
383385
SplitRule::Fixed(fixed_amt) => fixed_amt,
384386
SplitRule::Percentage(bps) => {
385-
(invoice.funded as u128 * bps as u128 / 10_000u128) as i128
387+
// Protocol 26 CAP-82: use checked arithmetic to prevent overflow
388+
// funded * bps / 10_000 — all i128, safe for balances up to i128::MAX
389+
invoice.funded
390+
.checked_mul(bps as i128)
391+
.expect("percentage: overflow in funded * bps")
392+
.checked_div(10_000)
393+
.expect("percentage: division failed")
386394
}
387395
SplitRule::Tiered(threshold, bps) => {
388396
if invoice.funded > threshold {
389-
(invoice.funded as u128 * bps as u128 / 10_000u128) as i128
397+
// Protocol 26 CAP-82: checked arithmetic for tiered splits
398+
invoice.funded
399+
.checked_mul(bps as i128)
400+
.expect("tiered: overflow in funded * bps")
401+
.checked_div(10_000)
402+
.expect("tiered: division failed")
390403
} else {
391404
0
392405
}
393406
}
394407
}
395408
} else if i == n - 1 {
396-
invoice.funded - distributed
409+
// Last recipient gets the remainder to avoid dust from rounding
410+
invoice.funded
411+
.checked_sub(distributed)
412+
.expect("release: underflow computing remainder")
397413
} else {
398-
(amount as u128 * invoice.funded as u128 / total as u128) as i128
414+
// Proportional split: amount * funded / total — checked throughout
415+
amount
416+
.checked_mul(invoice.funded)
417+
.expect("proportional: overflow in amount * funded")
418+
.checked_div(total)
419+
.expect("proportional: division by zero total")
399420
};
400421

401422
distributed += proportional;
@@ -519,7 +540,12 @@ impl SharpyContract {
519540
}
520541
}
521542
let completion_bps = if total > 0 {
522-
(invoice.funded as u128 * 10_000u128 / total as u128) as u32
543+
// Protocol 26 CAP-82: checked arithmetic for completion percentage
544+
invoice.funded
545+
.checked_mul(10_000)
546+
.expect("stats: overflow in funded * 10_000")
547+
.checked_div(total)
548+
.expect("stats: division by zero") as u32
523549
} else {
524550
0
525551
};
@@ -535,4 +561,34 @@ impl SharpyContract {
535561
pub fn get_escrow_state(env: Env, invoice_id: u64) -> Option<DisputeState> {
536562
env.storage().persistent().get(&escrow_state_key(invoice_id))
537563
}
564+
565+
/// Extend the TTL of an invoice entry to ~1 year.
566+
/// Protocol 26 CAP-78: host function for limited TTL extension keeps long-lived
567+
/// and recurring invoices accessible without requiring a full state restore.
568+
pub fn bump_invoice_ttl(env: Env, invoice_id: u64) {
569+
let _ = load_invoice(&env, invoice_id);
570+
env.storage().persistent().extend_ttl(&invoice_key(invoice_id), 100_000, 6_307_200);
571+
}
572+
573+
/// Returns a SHA-256 fingerprint of the invoice's immutable fields.
574+
/// Protocol 25 CAP-75 / Protocol 26 crypto module: deterministic, tamper-evident
575+
/// content hash. The fingerprint commits to invoice_id, deadline, funded amount,
576+
/// and total — any modification produces a different hash.
577+
/// Use this for off-chain verification or receipt generation.
578+
pub fn get_invoice_fingerprint(env: Env, invoice_id: u64) -> soroban_sdk::BytesN<32> {
579+
let invoice = load_invoice(&env, invoice_id);
580+
let total: i128 = invoice.amounts.iter().sum();
581+
582+
// Build a flat byte buffer of key invoice fields for deterministic hashing
583+
let mut buf: [u8; 40] = [0u8; 40]; // 8 + 8 + 8 + 16 bytes
584+
buf[0..8].copy_from_slice(&invoice_id.to_be_bytes());
585+
buf[8..16].copy_from_slice(&invoice.deadline.to_be_bytes());
586+
buf[16..24].copy_from_slice(&(invoice.recipients.len() as u64).to_be_bytes());
587+
buf[24..40].copy_from_slice(&total.to_be_bytes());
588+
589+
let data = Bytes::from_array(&env, &buf);
590+
591+
// SHA-256 via Protocol 25/26 crypto host function — returns Hash<32>
592+
env.crypto().sha256(&data).into()
593+
}
538594
}

contracts/sharpy/src/test.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,4 +518,77 @@ mod tests {
518518

519519
client.release_escrow(&id);
520520
}
521+
522+
// ---------------------------------------------------------
523+
// Protocol 26 CAP-78: bump_invoice_ttl
524+
// Protocol 25/26 crypto: get_invoice_fingerprint
525+
// Protocol 26 CAP-82: checked arithmetic in stats
526+
// ---------------------------------------------------------
527+
528+
#[test]
529+
fn test_bump_invoice_ttl_succeeds() {
530+
let (env, client) = setup();
531+
let creator = Address::generate(&env);
532+
let recipient = Address::generate(&env);
533+
let token = Address::generate(&env);
534+
let deadline = env.ledger().timestamp() + 86400;
535+
536+
let id = client.create_invoice(
537+
&creator,
538+
&Vec::from_array(&env, [recipient]),
539+
&Vec::from_array(&env, [500i128]),
540+
&Vec::from_array(&env, [token]),
541+
&deadline,
542+
&default_options(&env),
543+
);
544+
545+
// Should succeed without panic — CAP-78 TTL extension
546+
client.bump_invoice_ttl(&id);
547+
}
548+
549+
#[test]
550+
fn test_invoice_fingerprint_is_deterministic() {
551+
let (env, client) = setup();
552+
let creator = Address::generate(&env);
553+
let recipient = Address::generate(&env);
554+
let token = Address::generate(&env);
555+
let deadline = env.ledger().timestamp() + 86400;
556+
557+
let id = client.create_invoice(
558+
&creator,
559+
&Vec::from_array(&env, [recipient]),
560+
&Vec::from_array(&env, [500i128]),
561+
&Vec::from_array(&env, [token]),
562+
&deadline,
563+
&default_options(&env),
564+
);
565+
566+
// Same invoice should produce same fingerprint on repeated calls
567+
let fp1 = client.get_invoice_fingerprint(&id);
568+
let fp2 = client.get_invoice_fingerprint(&id);
569+
assert_eq!(fp1, fp2, "fingerprint must be deterministic");
570+
}
571+
572+
#[test]
573+
fn test_invoice_stats_checked_arithmetic() {
574+
let (env, client) = setup();
575+
let creator = Address::generate(&env);
576+
let recipient = Address::generate(&env);
577+
let token = Address::generate(&env);
578+
let deadline = env.ledger().timestamp() + 86400;
579+
580+
let id = client.create_invoice(
581+
&creator,
582+
&Vec::from_array(&env, [recipient]),
583+
&Vec::from_array(&env, [1000i128]),
584+
&Vec::from_array(&env, [token]),
585+
&deadline,
586+
&default_options(&env),
587+
);
588+
589+
let stats = client.get_invoice_stats(&id);
590+
assert_eq!(stats.total, 1000i128);
591+
assert_eq!(stats.funded, 0i128);
592+
assert_eq!(stats.completion_bps, 0u32);
593+
}
521594
}

0 commit comments

Comments
 (0)