Skip to content
Merged

muscal #1055

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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
29 changes: 29 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion contracts/split/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]

[dependencies]
soroban-sdk = { workspace = true }
soroban-sdk = { workspace = true, features = ["hazmat-address"] }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
proptest = "1"
ed25519-dalek = "2"

[features]
testutils = ["soroban-sdk/testutils"]
Expand Down
7 changes: 7 additions & 0 deletions contracts/split/proptest-regressions/fuzz_tests.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 6eb2ea546143b67b5c7c46631f20f5050deee08219cf36884bb76194104ef05b # shrinks to invoice_amount = 2, weights = [1870, 2749, 880]
2 changes: 1 addition & 1 deletion contracts/split/src/calc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use crate::types::BASIS_POINTS_TOTAL;
use soroban_sdk::{Address, Env, Map, Symbol, Vec};

use crate::error::ContractError;
use crate::math::{bps_of, mul_div, BPS_DENOMINATOR};
use crate::math::{bps_of, mul_div, mul_mod, BPS_DENOMINATOR};

// ---------------------------------------------------------------------------
// Issue #955: Fee event tracking
Expand Down
56 changes: 55 additions & 1 deletion contracts/split/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

use crate::analytics::{self, UsageKind};
use crate::storage_keys::ev_seq_key;
use crate::types::{DisputeOutcome, FeeSplit, InvoicePhase, InvoiceStatus, OverfundingPolicy, RepScore, TimelockAction};
use crate::types::{CircuitBreakerState, DisputeOutcome, FeeSplit, InvoicePhase, InvoiceStatus, OverfundingPolicy, RepScore, TimelockAction};
use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Symbol, Vec};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2529,6 +2529,7 @@ pub fn confidential_total_verified(env: &Env, invoice_id: u64, verified_by: &Add
mod tests {
use super::*;
use soroban_sdk::{Address, Env};
use soroban_sdk::testutils::Address as _;

fn contract_id(env: &Env) -> Address {
env.register(crate::SplitContract, ())
Expand Down Expand Up @@ -2623,3 +2624,56 @@ mod tests {
}
}
}

// ---------------------------------------------------------------------------
// Issues #957 / #937 / #938 / #953: guard, approval and transition events
// ---------------------------------------------------------------------------

/// Issue #957: Emitted when a release-path guard blocks an operation.
/// Topics: (split, guard_ko, invoice_id)
/// Data: (guard, detail)
pub fn guard_failure(env: &Env, invoice_id: u64, guard: &Symbol, detail: i128) {
env.events().publish(
(symbol_short!("split"), symbol_short!("guard_ko"), invoice_id),
(guard.clone(), detail),
);
}

/// Issue #953: Emitted on an invoice state transition with its trigger and
/// the amount involved.
/// Topics: (split, inv_trans, invoice_id)
/// Data: (trigger, from, to, actor, amount)
pub fn invoice_transition(
env: &Env,
invoice_id: u64,
trigger: Symbol,
from: &InvoiceStatus,
to: &InvoiceStatus,
actor: &Address,
amount: i128,
) {
env.events().publish(
(symbol_short!("split"), symbol_short!("inv_trans"), invoice_id),
(trigger, from.clone(), to.clone(), actor.clone(), amount),
);
}

/// Issue #937: Emitted when a co-signer approves an invoice with a signature.
/// Topics: (split, inv_aprv, invoice_id)
/// Data: signer
pub fn invoice_approved(env: &Env, invoice_id: u64, signer: &Address) {
env.events().publish(
(symbol_short!("split"), symbol_short!("inv_aprv"), invoice_id),
signer.clone(),
);
}

/// Issue #938: Emitted when a validated condition oracle is attached.
/// Topics: (split, cond_orc, invoice_id)
/// Data: (oracle, condition)
pub fn condition_oracle_set(env: &Env, invoice_id: u64, oracle: &Address, condition: &String) {
env.events().publish(
(symbol_short!("split"), symbol_short!("cond_orc"), invoice_id),
(oracle.clone(), condition.clone()),
);
}
34 changes: 20 additions & 14 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
#![allow(clippy::too_many_arguments)]

const SHARD_COUNT: u64 = 8;
/// Issue #936: maximum number of payments a single shard may hold.
const MAX_PAYMENTS_PER_SHARD: u32 = 1_000;
const ARCHIVE_AFTER_LEDGERS: u64 = 100_000;

/// Maximum number of co-creators allowed per invoice (bounded to prevent
Expand Down Expand Up @@ -78,6 +80,7 @@ mod events;
pub mod types;
mod validation;
mod calc;
mod math;
mod stats;
pub mod security_audit;
pub mod payment_tracking;
Expand Down Expand Up @@ -115,6 +118,7 @@ mod storage;
mod storage_keys;

mod migrations;
mod webhooks;

mod insurance_pool;
mod sustainability;
Expand Down Expand Up @@ -145,7 +149,9 @@ const MAX_PARENT_DEPTH: u32 = 10;

use types::{
AdminAction, AdminRole, AdminSet, ArchivalPolicy, ArchivalRecord, ArchivalStats,
ArchivalStatus, AuditEntry, Bid, CircuitBreakerStatus,
ArchivalStatus, AuditEntry, BatchExecutionReport, BatchingStats, Bid, CircuitBreakerConfig,
CircuitBreakerState, CircuitBreakerStatus, CircuitBreakerTransition, CostEstimate,
OperationCostModel, OperationCostReport, OperationProfile, PaymentBatch, PaymentBatchStatus,
CloneOverrides, CloneRecord, CompactInvoice, CompactMigrateResult, CompletionProof, ComputeEstimate,
ConfidentialPayment, ContributionResult, CreateInvoiceParams, CreatorStats, DelayedPayout,
DisputeOutcome, DisputeRecord, DisputeStatus, FeeBracket, FeeSplit, FeeTier, FeeWaiver,
Expand Down Expand Up @@ -464,7 +470,7 @@ fn cosigner_signing_payload(
/// replay protection.
///
/// Returns `Err(ContractError::InvalidSignature)` when the signature does not
/// verify, and `Err(ContractError::SignatureReplay)` when the nonce has already
/// verify, and `Err(ContractError::SignatureReplayed)` when the nonce has already
/// been consumed for this invoice.
fn verify_cosigner_signature(
env: &Env,
Expand All @@ -475,17 +481,19 @@ fn verify_cosigner_signature(
) -> Result<(), ContractError> {
let nonce_key = used_cosigner_nonce_key(invoice_id, nonce);
if env.storage().persistent().has(&nonce_key) {
return Err(ContractError::SignatureReplay);
return Err(ContractError::SignatureReplayed);
}

let payload = cosigner_signing_payload(env, invoice_id, signer, nonce);
let payload_hash: BytesN<32> = env.crypto().sha256(&payload).into();
if !env
.crypto()
.ed25519_verify(signature, &payload_hash.into(), &signer.clone().into())
{
return Err(ContractError::InvalidSignature);
}
// Only account (G...) addresses carry an Ed25519 public key.
let public_key = match signer.to_payload() {
Some(soroban_sdk::address_payload::AddressPayload::AccountIdPublicKeyEd25519(pk)) => pk,
_ => return Err(ContractError::InvalidSignature),
};
// `ed25519_verify` traps the invocation when the signature is invalid.
env.crypto()
.ed25519_verify(&public_key, &Bytes::from(payload_hash), signature);

// Consume the nonce so the same signature cannot be replayed.
env.storage().persistent().set(&nonce_key, &true);
Expand Down Expand Up @@ -650,7 +658,7 @@ fn load_invoice_ext_lazy(env: &Env, id: u64) -> InvoiceExt {
.storage()
.persistent()
.get(&invoice_ext_key(id))
.unwrap_or_else(|| InvoiceExt::default_for(env));
.unwrap_or_else(|| InvoiceExt::default(env));
cache.set(id, ext.clone());
env.storage().temporary().set(&invoice_ext_cache_key(), &cache);
ext
Expand Down Expand Up @@ -17977,9 +17985,7 @@ impl SplitContract {

invoice.approved = true;
invoice.approver = Some(signer.clone());
env.storage()
.persistent()
.set(&invoice_key(invoice_id), &invoice);
save_invoice(&env, invoice_id, &invoice);
events::invoice_approved(&env, invoice_id, &signer);
}

Expand Down Expand Up @@ -18811,7 +18817,7 @@ impl SplitContract {
}

/// Helper for clients: the response `recipient` must submit for `challenge`.
pub fn compute_recipient_challenge_response(
pub fn compute_challenge_response(
env: Env,
challenge: BytesN<32>,
recipient: Address,
Expand Down
71 changes: 71 additions & 0 deletions contracts/split/src/math.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,77 @@ impl TryFrom<i128> for FixedPoint {
}
}

/// Basis-point denominator (100% = 10 000 bps).
pub const BPS_DENOMINATOR: i128 = 10_000;

/// Overflow-safe `floor(a * b / d)` for non-negative `a`, `b` and positive `d`.
///
/// Tries the direct product first; if that overflows, decomposes
/// `a = q * d + r` so `a * b / d = q * b + (r * b) / d`, which only overflows
/// when the true result does not fit.
///
/// # Errors
/// [`ContractError::ArithmeticOverflow`] when `d <= 0`, an input is negative
/// and the direct product overflows, or the result does not fit in `i128`.
pub fn mul_div(a: i128, b: i128, d: i128) -> Result<i128, ContractError> {
if d <= 0 {
return Err(ContractError::ArithmeticOverflow);
}
if let Some(product) = a.checked_mul(b) {
return Ok(product.div_euclid(d));
}
if a < 0 || b < 0 {
return Err(ContractError::ArithmeticOverflow);
}
let q = a / d;
let r = a % d;
let whole = q.checked_mul(b).ok_or(ContractError::ArithmeticOverflow)?;
let frac = r.checked_mul(b).ok_or(ContractError::ArithmeticOverflow)? / d;
whole
.checked_add(frac)
.ok_or(ContractError::ArithmeticOverflow)
}

/// Overflow-safe `(a * b) mod d` for non-negative `a`, `b` and positive `d`.
///
/// This is the remainder left over by [`mul_div`], used by the
/// largest-remainder distribution step.
///
/// # Errors
/// [`ContractError::ArithmeticOverflow`] when `d <= 0`, an input is negative
/// and the direct product overflows, or `d` is too large for the reduced
/// product to fit.
pub fn mul_mod(a: i128, b: i128, d: i128) -> Result<i128, ContractError> {
if d <= 0 {
return Err(ContractError::ArithmeticOverflow);
}
if let Some(product) = a.checked_mul(b) {
return Ok(product.rem_euclid(d));
}
if a < 0 || b < 0 {
return Err(ContractError::ArithmeticOverflow);
}
let reduced = (a % d)
.checked_mul(b % d)
.ok_or(ContractError::ArithmeticOverflow)?;
Ok(reduced % d)
}

/// `part * 10 000 / whole`, saturating instead of failing.
///
/// Returns `0` when `whole <= 0`. Callers clamp the result to the bps range.
pub fn bps_of(part: i128, whole: i128) -> i128 {
if whole <= 0 {
return 0;
}
match mul_div(part, BPS_DENOMINATOR, whole) {
Ok(bps) => bps,
// Only reachable for `part` near `i128::MAX`; fall back to dividing
// the denominator first, which loses at most one bps of precision.
Err(_) => part / (whole / BPS_DENOMINATOR).max(1),
}
}

/// Scale a raw token amount to the canonical 7-decimal internal representation.
///
/// * `raw` — the amount in the token's native (smallest) unit.
Expand Down
Loading