@@ -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+
388398fn 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