Skip to content

Commit bc084e9

Browse files
authored
Merge pull request #319 from Simcrypt/feat/307-308-309-310-multi-token-refund-allowlist-upgrade
feat: multi-token support, auto-refund, allowlist fixes, upgrade authority (#307 #308 #309 #310)
2 parents 919fb98 + 9ff3166 commit bc084e9

2 files changed

Lines changed: 591 additions & 6 deletions

File tree

contracts/split/src/lib.rs

Lines changed: 164 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use types::{
2525
Invoice, InvoiceCore, InvoiceExt, InvoiceExt2, InvoiceOptions, InvoicePayment, InvoiceStatus,
2626
InvoiceTemplate, LegacyInvoice, OverflowBehavior, Payment, PaymentCertificate, PaymentProof,
2727
QueuedAction, ResolveAction, ResolveRule, SplitRule, SubscriptionParams,
28-
TimelockAction, Tranche, TreasuryRecord,
28+
TimelockAction, Tranche, TreasuryRecord, UpgradeProposal,
2929
};
3030

3131
// ---------------------------------------------------------------------------
@@ -385,6 +385,16 @@ fn pending_admin_key() -> Symbol {
385385
symbol_short!("pend_adm")
386386
}
387387

388+
/// Issue #310: pending upgrade proposal — instance storage.
389+
fn upgrade_proposal_key() -> Symbol {
390+
symbol_short!("upg_prop")
391+
}
392+
393+
/// Issue #308: per-invoice refunded-addresses set — persistent storage.
394+
fn refunded_key(invoice_id: u64) -> (Symbol, u64) {
395+
(symbol_short!("refunded"), invoice_id)
396+
}
397+
388398
fn maybe_record_created(env: &Env, creator: &Address, total: i128) {
389399
if let Some(dashboard) = env.storage().persistent().get::<Symbol, Address>(&dashboard_contract_key()) {
390400
let _: Val = env.invoke_contract(
@@ -475,6 +485,7 @@ fn load_invoice(env: &Env, id: u64) -> Invoice {
475485
penalty_tiers: Vec::new(env),
476486
allowed_callers: None,
477487
refund_grace_secs: None,
488+
refunded_addresses: Vec::new(env),
478489
});
479490
let ext2: InvoiceExt2 = env.storage().persistent()
480491
.get(&invoice_ext2_key(id))
@@ -1272,6 +1283,7 @@ impl SplitContract {
12721283
penalty_tiers: Vec::new(&env),
12731284
allowed_callers: None,
12741285
refund_grace_secs: None,
1286+
refunded_addresses: Vec::new(&env),
12751287
})
12761288
});
12771289
let ext2: types::InvoiceExt2 = env
@@ -1534,7 +1546,8 @@ impl SplitContract {
15341546
creator,
15351547
recipients,
15361548
amounts,
1537-
token,
1549+
// Issue #307: if payment_token is explicitly set, use it instead of the token param.
1550+
options.payment_token.unwrap_or(token),
15381551
deadline,
15391552
options.co_creators,
15401553
options.allow_early_withdrawal,
@@ -1933,6 +1946,7 @@ impl SplitContract {
19331946
priorities,
19341947
penalty_tiers: Vec::new(env),
19351948
allowed_callers: None,
1949+
refunded_addresses: Vec::new(env),
19361950
admin_frozen: false,
19371951
min_funding_amount: 0,
19381952
target_usd_cents: None,
@@ -2313,6 +2327,7 @@ impl SplitContract {
23132327
scheduled_release_at: source.scheduled_release_at,
23142328
priorities: source.priorities.clone(),
23152329
target_usd_cents: source.target_usd_cents,
2330+
refunded_addresses: Vec::new(&env),
23162331
};
23172332

23182333
save_invoice(&env, id, &new_invoice);
@@ -3374,14 +3389,16 @@ impl SplitContract {
33743389
invoice.allowed_payers = Some(new_whitelist);
33753390
save_invoice(&env, invoice_id, &invoice);
33763391
append_audit_entry(&env, invoice_id, symbol_short!("rem_payer"), &creator);
3392+
// Issue #309: emit AllowlistUpdated event on removal
3393+
events::allowlist_updated(&env, invoice_id, &creator, &payer, false);
33773394
}
33783395
}
33793396

33803397
/// Add a payer to the invoice's allowed_payers allowlist.
33813398
///
33823399
/// Only the creator (or a co-creator) may call this. If allowed_payers is None
3383-
/// (open invoice), this is a no-op and does not error. If the payer is already
3384-
/// in the allowlist, this is a no-op.
3400+
/// (open invoice), initializes the list with this payer (making the invoice private).
3401+
/// If the payer is already in the allowlist, this is a no-op.
33853402
pub fn add_allowed_payer(env: Env, creator: Address, invoice_id: u64, payer: Address) {
33863403
require_not_paused(&env);
33873404
creator.require_auth();
@@ -3393,13 +3410,21 @@ impl SplitContract {
33933410
"only creator can modify allowlist"
33943411
);
33953412

3396-
// No-op if allowed_payers is None (open invoice)
3413+
// If allowed_payers is None, initialize a new list (makes invoice private).
3414+
if invoice.allowed_payers.is_none() {
3415+
invoice.allowed_payers = Some(Vec::new(&env));
3416+
}
3417+
33973418
if let Some(ref mut whitelist) = invoice.allowed_payers {
33983419
// Only add if not already present
33993420
if !whitelist.iter().any(|p| p == payer) {
3400-
whitelist.push_back(payer);
3421+
// Issue #309: enforce max 100 allowed payers
3422+
assert!(whitelist.len() < 100, "allowlist is full");
3423+
whitelist.push_back(payer.clone());
34013424
save_invoice(&env, invoice_id, &invoice);
34023425
append_audit_entry(&env, invoice_id, symbol_short!("add_payer"), &creator);
3426+
// Issue #309: emit AllowlistUpdated event
3427+
events::allowlist_updated(&env, invoice_id, &creator, &payer, true);
34033428
}
34043429
}
34053430
}
@@ -5825,6 +5850,7 @@ impl SplitContract {
58255850
payment_cooldown_secs: None, max_payments_per_window: None, payment_window_secs: None,
58265851
scheduled_release_at: None, refund_grace_secs: None,
58275852
penalty_tiers: Vec::new(&env), allowed_callers: None,
5853+
refunded_addresses: Vec::new(&env),
58285854
});
58295855
let ext2: InvoiceExt2 = env.storage().persistent()
58305856
.get(&invoice_ext2_key(invoice_id))
@@ -6168,4 +6194,136 @@ impl SplitContract {
61686194
.get::<_, PaymentCertificate>(&cert_key(invoice_id))
61696195
.expect("certificate not found")
61706196
}
6197+
6198+
// -----------------------------------------------------------------------
6199+
// Issue #308: Per-payer claim_refund after deadline
6200+
// -----------------------------------------------------------------------
6201+
6202+
/// Claim a refund for the calling payer's contribution after the deadline.
6203+
///
6204+
/// Conditions: invoice must be Pending, deadline must have passed, invoice
6205+
/// must NOT be fully funded, and the payer must not have already claimed.
6206+
/// Idempotent — calling twice after the first claim is a no-op.
6207+
pub fn claim_refund(env: Env, payer: Address, invoice_id: u64) {
6208+
require_fn_not_paused(&env, &symbol_short!("refund"));
6209+
payer.require_auth();
6210+
6211+
let mut invoice = load_invoice(&env, invoice_id);
6212+
6213+
// Must still be pending (not fully released/refunded via bulk refund).
6214+
if invoice.status != InvoiceStatus::Pending {
6215+
// Idempotent: already refunded via bulk path — nothing to do.
6216+
return;
6217+
}
6218+
6219+
// Deadline must have passed (respecting grace period).
6220+
let refund_deadline = if let Some(grace_secs) = invoice.refund_grace_secs {
6221+
invoice.deadline.saturating_add(grace_secs)
6222+
} else {
6223+
invoice.deadline
6224+
};
6225+
assert!(
6226+
env.ledger().timestamp() > refund_deadline,
6227+
"invoice has not expired yet"
6228+
);
6229+
6230+
// Invoice must NOT be fully funded.
6231+
let total: i128 = invoice.amounts.iter().sum();
6232+
assert!(invoice.funded < total, "invoice is fully funded");
6233+
6234+
// Idempotent: if payer already claimed, silently return.
6235+
if invoice.refunded_addresses.iter().any(|a| a == payer) {
6236+
return;
6237+
}
6238+
6239+
// Compute this payer's total contribution (across all shards).
6240+
let mut payer_total: i128 = 0;
6241+
for shard_id in 0..SHARD_COUNT {
6242+
if let Some(shard_payments) = env.storage().persistent().get::<(Symbol, u64, u64), Vec<Payment>>(&pay_shard_key(invoice_id, shard_id)) {
6243+
for payment in shard_payments.iter() {
6244+
if payment.payer == payer && !payment.donate_on_failure {
6245+
payer_total += payment.amount;
6246+
}
6247+
}
6248+
}
6249+
}
6250+
6251+
if payer_total == 0 {
6252+
return; // No contribution to refund.
6253+
}
6254+
6255+
let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token"));
6256+
token_client.transfer(&env.current_contract_address(), &payer, &payer_total);
6257+
6258+
// Record that this payer has claimed.
6259+
invoice.refunded_addresses.push_back(payer.clone());
6260+
invoice.funded = invoice.funded.saturating_sub(payer_total);
6261+
save_invoice(&env, invoice_id, &invoice);
6262+
6263+
events::refund_claimed(&env, invoice_id, &payer, payer_total);
6264+
append_audit_entry(&env, invoice_id, symbol_short!("clm_ref"), &payer);
6265+
}
6266+
6267+
// -----------------------------------------------------------------------
6268+
// Issue #310: Two-step upgrade with 48-hour timelock
6269+
// -----------------------------------------------------------------------
6270+
6271+
/// Propose a contract upgrade. Only the admin may call this.
6272+
///
6273+
/// Stores a pending proposal with an eligible_at = now + 48 h.
6274+
/// Overwrites any existing proposal (only one active at a time).
6275+
pub fn propose_upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>) {
6276+
require_admin(&env);
6277+
let _ = admin;
6278+
6279+
const FORTY_EIGHT_HOURS: u64 = 48 * 60 * 60;
6280+
let eligible_at = env.ledger().timestamp().saturating_add(FORTY_EIGHT_HOURS);
6281+
6282+
let proposal = UpgradeProposal {
6283+
new_wasm_hash: new_wasm_hash.clone(),
6284+
eligible_at,
6285+
};
6286+
env.storage().instance().set(&upgrade_proposal_key(), &proposal);
6287+
6288+
events::upgrade_proposed(&env, &new_wasm_hash, eligible_at);
6289+
}
6290+
6291+
/// Execute a pending upgrade once the 48-hour timelock has elapsed.
6292+
///
6293+
/// Callable by anyone after the timelock expires. Clears the proposal on success.
6294+
pub fn execute_upgrade(env: Env) {
6295+
let proposal: UpgradeProposal = env
6296+
.storage()
6297+
.instance()
6298+
.get(&upgrade_proposal_key())
6299+
.expect("no upgrade proposal");
6300+
6301+
assert!(
6302+
env.ledger().timestamp() >= proposal.eligible_at,
6303+
"upgrade timelock still active"
6304+
);
6305+
6306+
env.storage().instance().remove(&upgrade_proposal_key());
6307+
events::upgrade_executed(&env, &proposal.new_wasm_hash);
6308+
env.deployer().update_current_contract_wasm(proposal.new_wasm_hash);
6309+
}
6310+
6311+
/// Cancel a pending upgrade proposal. Only the admin may call this.
6312+
pub fn cancel_upgrade(env: Env, admin: Address) {
6313+
let admin_addr = require_admin(&env);
6314+
let _ = admin;
6315+
6316+
assert!(
6317+
env.storage().instance().has(&upgrade_proposal_key()),
6318+
"no upgrade proposal"
6319+
);
6320+
env.storage().instance().remove(&upgrade_proposal_key());
6321+
6322+
events::upgrade_cancelled(&env, &admin_addr);
6323+
}
6324+
6325+
/// Return the pending upgrade proposal, or None if none is active.
6326+
pub fn get_upgrade_proposal(env: Env) -> Option<UpgradeProposal> {
6327+
env.storage().instance().get(&upgrade_proposal_key())
6328+
}
61716329
}

0 commit comments

Comments
 (0)