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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ snapshots/
*.snapshot
**/snapshots/
*.snap.bak
tests/snapshots/
tests/fixtures/

# Stellar CLI
.stellar/
Expand Down
2 changes: 2 additions & 0 deletions contracts/split/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,6 @@ pub enum ContractError {
RecipientNotFound = 62,
/// Issue #522: Parent chain depth exceeds the allowed maximum.
ParentChainTooDeep = 63,
/// Issue #526: Invoice has fewer recipients than the contract minimum.
TooFewRecipients = 64,
}
20 changes: 20 additions & 0 deletions contracts/split/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1664,3 +1664,23 @@ pub fn recipient_share_unlocked(
(recipient.clone(), admin.clone()),
);
}

/// Issue #528: Emitted when an admin transfer is proposed.
/// Topics: (split, adm_prop)
/// Data: (current_admin, proposed_admin)
pub fn admin_transfer_proposed(env: &Env, current_admin: &Address, proposed_admin: &Address) {
env.events().publish(
(symbol_short!("split"), symbol_short!("adm_prop")),
(current_admin.clone(), proposed_admin.clone()),
);
}

/// Issue #528: Emitted when an admin transfer is completed.
/// Topics: (split, adm_done)
/// Data: new_admin
pub fn admin_transfer_completed(env: &Env, new_admin: &Address) {
env.events().publish(
(symbol_short!("split"), symbol_short!("adm_done")),
new_admin.clone(),
);
}
84 changes: 82 additions & 2 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ use types::{
Invoice, InvoiceCore, InvoiceExt, InvoiceExt2, InvoiceExt3, InvoiceHot, InvoiceOptions,
InvoiceOptions2, InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTemplate,
InvoiceTemplateRecord, LegacyInvoice, OverflowBehavior, OverfundingPolicy, Payment,
PaymentCertificate, PaymentCommitment, PaymentProof, PendingAdminAction, ProtocolFeeConfig,
QueuedAction, RebateTier, Recipient, RepScore, ResolveAction,
PaymentCertificate, PaymentCommitment, PaymentProof, PaymentRecord, PendingAdminAction,
ProtocolFeeConfig, QueuedAction, RebateTier, Recipient, RepScore, ResolveAction,
ResolveRule, Role, SimulateReleaseResult, SplitRule, SubscriptionParams, TimelockAction,
Tombstone, Tranche, TransferRecord, TreasuryRecord, UpgradeProposal,
};
Expand Down Expand Up @@ -845,6 +845,16 @@ fn pending_admin_key() -> Symbol {
symbol_short!("pend_adm")
}

/// Issue #526: Minimum recipient count per invoice — instance storage.
fn min_recipients_key() -> Symbol {
symbol_short!("min_recip")
}

/// Issue #527: Per-payer payment history — persistent storage.
fn payer_history_key(payer: &Address) -> (Symbol, Address) {
(symbol_short!("pay_hist"), payer.clone())
}

/// Issue #310: pending upgrade proposal — instance storage.
fn upgrade_proposal_key() -> Symbol {
symbol_short!("upg_prop")
Expand Down Expand Up @@ -2982,6 +2992,20 @@ impl SplitContract {
}

save_invoice(&env, invoice_id, &invoice);

// Issue #527: append to payer payment history.
let hist_key = payer_history_key(&payer);
let mut history: Vec<PaymentRecord> = env
.storage()
.persistent()
.get(&hist_key)
.unwrap_or_else(|| Vec::new(&env));
history.push_back(PaymentRecord {
invoice_id,
amount: amount_applied,
ledger: env.ledger().sequence(),
});
env.storage().persistent().set(&hist_key, &history);
}

ContributionResult {
Expand Down Expand Up @@ -3371,6 +3395,7 @@ impl SplitContract {
env.storage()
.instance()
.set(&pending_admin_key(), &new_admin);
events::admin_transfer_proposed(&env, &admin, &new_admin);
}

/// Accept the admin role. Requires the proposed admin to authenticate.
Expand All @@ -3383,6 +3408,50 @@ impl SplitContract {
pending.require_auth();
env.storage().instance().set(&admin_key(), &pending);
env.storage().instance().remove(&pending_admin_key());
events::admin_transfer_completed(&env, &pending);
}

// -----------------------------------------------------------------------
// Issue #526: Minimum recipient count
// -----------------------------------------------------------------------

/// Set the minimum number of recipients required per invoice. Requires admin auth.
pub fn set_min_recipients(env: Env, admin: Address, min: u32) {
require_admin(&env);
let _ = admin;
env.storage()
.instance()
.set(&min_recipients_key(), &min);
}

/// Get the minimum number of recipients required per invoice. Default is 2.
pub fn get_min_recipients(env: Env) -> u32 {
env.storage()
.instance()
.get(&min_recipients_key())
.unwrap_or(2u32)
}

// -----------------------------------------------------------------------
// Issue #527: Payer history query
// -----------------------------------------------------------------------

/// Return a paginated slice of payment records for the given payer.
pub fn get_payer_history(env: Env, payer: Address, offset: u32, limit: u32) -> Vec<PaymentRecord> {
let hist_key = payer_history_key(&payer);
let history: Vec<PaymentRecord> = env
.storage()
.persistent()
.get(&hist_key)
.unwrap_or_else(|| Vec::new(&env));
let total = history.len();
let start = offset.min(total);
let end = (start + limit).min(total);
let mut result = Vec::new(&env);
for i in start..end {
result.push_back(history.get(i).unwrap());
}
result
}

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -5193,6 +5262,17 @@ impl SplitContract {
);

assert!(!recipients.is_empty(), "must have at least one recipient");
// Issue #526: enforce minimum recipient count.
{
let min_recipients: u32 = env
.storage()
.instance()
.get(&min_recipients_key())
.unwrap_or(2u32);
if (recipients.len() as u32) < min_recipients {
panic_with_error!(env, ContractError::TooFewRecipients);
}
}
// Issue #483: reject zero or negative amounts at entry point.
for amt in amounts.iter() {
guard_nonzero_amount(amt).expect("ZeroAmountNotAllowed");
Expand Down
6 changes: 6 additions & 0 deletions contracts/split/src/storage_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ pub enum StorageKey {
UpgradeProposal,
ProtocolFee,
ReentrancyGuard,
/// Issue #526: Minimum number of recipients required per invoice.
MinRecipients,
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -169,6 +171,8 @@ pub enum AddressKey {
PauseExempt(Address),
GlobalVelocity(Address),
CreatorVolMile(Address),
/// Issue #527: Payment history for a contributor address.
PayerHistory(Address),
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -282,6 +286,7 @@ mod tests {
StorageKey::PlatformVolThresh, StorageKey::PlatformVolMile,
StorageKey::CreatorVolThresh, StorageKey::UpgradeProposal,
StorageKey::ProtocolFee, StorageKey::ReentrancyGuard,
StorageKey::MinRecipients,
];
for i in 0..keys.len() {
for j in (i + 1)..keys.len() {
Expand Down Expand Up @@ -349,6 +354,7 @@ mod tests {
AddressKey::CreatorStatsPayers(addr.clone()),
AddressKey::GlobalVelocity(addr.clone()),
AddressKey::PauseExempt(addr.clone()),
AddressKey::PayerHistory(addr.clone()),
];
for i in 0..keys.len() {
for j in (i + 1)..keys.len() {
Expand Down
9 changes: 9 additions & 0 deletions contracts/split/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1662,3 +1662,12 @@ pub struct RecipientShare {
pub locked: bool,
}

/// Issue #527: A single payment record stored in a contributor's persistent history.
#[contracttype]
#[derive(Clone, Debug)]
pub struct PaymentRecord {
pub invoice_id: u64,
pub amount: i128,
pub ledger: u32,
}

Loading