@@ -25,15 +25,23 @@ pub enum ContractError {
2525 MonthlyLimitExceeded = 12 ,
2626 InvalidLimitConfig = 13 ,
2727 /// Payment is not in a Failed state, so no refund is available.
28- RefundNotAvailable = 14 ,
28+ RefundNotAvailable = 14 ,
2929 /// Payment has already been refunded; cannot refund twice.
30- AlreadyRefunded = 15 ,
30+ AlreadyRefunded = 15 ,
3131 /// No PaymentEntry found for the given (batch_id, payment_index).
32- PaymentNotFound = 16 ,
32+ PaymentNotFound = 16 ,
3333 /// Contract is paused — all payment operations are suspended.
34- ContractPaused = 17 ,
34+ ContractPaused = 17 ,
3535 /// Sender already executed a batch in this ledger sequence.
36- LedgerReplayDetected = 18 ,
36+ LedgerReplayDetected = 18 ,
37+ /// Scheduled batch does not exist or has expired.
38+ ScheduledBatchNotFound = 19 ,
39+ /// Scheduled batch cannot be executed yet — target ledger not reached.
40+ ScheduledBatchNotReady = 20 ,
41+ /// Scheduled batch has already been executed or cancelled.
42+ ScheduledBatchConsumed = 21 ,
43+ /// Only the original sender may cancel a scheduled batch.
44+ ScheduledBatchUnauthorized = 22 ,
3745}
3846
3947// ── Events ────────────────────────────────────────────────────────────────────
@@ -91,6 +99,29 @@ pub struct ContractStatusChangedEvent {
9199 pub admin : Address ,
92100}
93101
102+ /// Emitted when a batch is scheduled for future execution.
103+ #[ contractevent]
104+ pub struct BatchScheduledEvent {
105+ pub scheduled_id : u64 ,
106+ pub sender : Address ,
107+ pub execute_after_ledger : u32 ,
108+ }
109+
110+ /// Emitted when a scheduled batch is executed.
111+ #[ contractevent]
112+ pub struct ScheduledBatchExecutedEvent {
113+ pub scheduled_id : u64 ,
114+ pub batch_id : u64 ,
115+ pub total_sent : i128 ,
116+ }
117+
118+ /// Emitted when a scheduled batch is cancelled by its sender.
119+ #[ contractevent]
120+ pub struct ScheduledBatchCancelledEvent {
121+ pub scheduled_id : u64 ,
122+ pub sender : Address ,
123+ }
124+
94125/// Emitted when an all-or-nothing batch completes successfully.
95126#[ contractevent]
96127pub struct BatchExecutedEvent {
@@ -189,6 +220,27 @@ pub struct PaymentEntry {
189220 pub status : PaymentStatus ,
190221}
191222
223+ /// Status of a scheduled batch.
224+ #[ contracttype]
225+ #[ derive( Copy , Clone , Debug , PartialEq ) ]
226+ #[ repr( u32 ) ]
227+ pub enum ScheduledBatchStatus {
228+ Pending = 0 ,
229+ Executed = 1 ,
230+ Cancelled = 2 ,
231+ }
232+
233+ /// A batch queued for future execution.
234+ #[ contracttype]
235+ #[ derive( Clone , Debug ) ]
236+ pub struct ScheduledBatch {
237+ pub sender : Address ,
238+ pub token : Address ,
239+ pub payments : Vec < PaymentOp > ,
240+ pub execute_after_ledger : u32 ,
241+ pub status : ScheduledBatchStatus ,
242+ }
243+
192244#[ contracttype]
193245pub enum DataKey {
194246 Admin ,
@@ -208,6 +260,10 @@ pub enum DataKey {
208260 Paused ,
209261 /// Tracks the last ledger sequence in which a batch was executed (per sender).
210262 LastBatchLedger ( Address ) ,
263+ /// Scheduled batch record
264+ ScheduledBatch ( u64 ) ,
265+ /// Counter for scheduled batches
266+ ScheduledBatchCount ,
211267}
212268
213269const MAX_BATCH_SIZE : u32 = 100 ;
@@ -396,7 +452,7 @@ impl BulkPaymentContract {
396452
397453 let mut total: i128 = 0 ;
398454 let mut success_count: u32 = 0 ;
399-
455+
400456 // Use a single loop to calculate total and validate (O(n))
401457 for op in payments. iter ( ) {
402458 if op. amount <= 0 { return Err ( ContractError :: InvalidAmount ) ; }
@@ -461,12 +517,12 @@ impl BulkPaymentContract {
461517
462518 let mut total: i128 = 0 ;
463519 let mut success_count: u32 = 0 ;
464-
520+
465521 // Use a single loop to calculate total and validate (O(n))
466522 // This is more efficient than looping twice
467523 for op in payments. iter ( ) {
468- if op. amount <= 0 {
469- // Invalid amount — skip it and mark fail
524+ if op. amount <= 0 {
525+ // Invalid amount — skip it and mark fail
470526 continue ;
471527 }
472528 total = total. checked_add ( op. amount ) . ok_or ( ContractError :: AmountOverflow ) ?;
@@ -645,6 +701,177 @@ impl BulkPaymentContract {
645701 Ok ( ( ) )
646702 }
647703
704+ // ── Scheduled batch execution (Issue #187 / Part 42) ─────────────────
705+
706+ /// Schedules a batch payment to be executed no earlier than
707+ /// `execute_after_ledger`. Funds are pulled from the sender at schedule
708+ /// time and held by the contract until execution or cancellation.
709+ ///
710+ /// ### Security
711+ /// - Only the sender can cancel the scheduled batch and reclaim held funds.
712+ /// - Execution is open to anyone once the ledger condition is met (e.g. a
713+ /// keeper or the sender themselves), ensuring liveness.
714+ pub fn schedule_batch (
715+ env : Env ,
716+ sender : Address ,
717+ token : Address ,
718+ payments : Vec < PaymentOp > ,
719+ execute_after_ledger : u32 ,
720+ ) -> Result < u64 , ContractError > {
721+ Self :: require_not_paused ( & env) ?;
722+ sender. require_auth ( ) ;
723+ Self :: bump_core_ttl ( & env) ;
724+
725+ let len = payments. len ( ) ;
726+ if len == 0 { return Err ( ContractError :: EmptyBatch ) ; }
727+ if len > MAX_BATCH_SIZE { return Err ( ContractError :: BatchTooLarge ) ; }
728+
729+ let mut total: i128 = 0 ;
730+ for op in payments. iter ( ) {
731+ if op. amount <= 0 { return Err ( ContractError :: InvalidAmount ) ; }
732+ total = total. checked_add ( op. amount ) . ok_or ( ContractError :: AmountOverflow ) ?;
733+ }
734+
735+ Self :: check_limits ( & env, & sender, total) ?;
736+
737+ // Pull funds into the contract now so execution requires no sender auth later
738+ let token_client = token:: Client :: new ( & env, & token) ;
739+ token_client. transfer ( & sender, & env. current_contract_address ( ) , & total) ;
740+
741+ let count: u64 = env. storage ( ) . persistent ( )
742+ . get ( & DataKey :: ScheduledBatchCount )
743+ . unwrap_or ( 0 ) + 1 ;
744+ env. storage ( ) . persistent ( ) . set ( & DataKey :: ScheduledBatchCount , & count) ;
745+ env. storage ( ) . persistent ( ) . extend_ttl (
746+ & DataKey :: ScheduledBatchCount , PERSISTENT_TTL_THRESHOLD , PERSISTENT_TTL_EXTEND_TO ,
747+ ) ;
748+
749+ let scheduled = ScheduledBatch {
750+ sender : sender. clone ( ) ,
751+ token,
752+ payments,
753+ execute_after_ledger,
754+ status : ScheduledBatchStatus :: Pending ,
755+ } ;
756+
757+ let key = DataKey :: ScheduledBatch ( count) ;
758+ env. storage ( ) . persistent ( ) . set ( & key, & scheduled) ;
759+ env. storage ( ) . persistent ( ) . extend_ttl ( & key, PERSISTENT_TTL_THRESHOLD , PERSISTENT_TTL_EXTEND_TO ) ;
760+
761+ BatchScheduledEvent { scheduled_id : count, sender, execute_after_ledger } . publish ( & env) ;
762+ Ok ( count)
763+ }
764+
765+ /// Executes a previously scheduled batch once the target ledger has been
766+ /// reached. Funds were already pulled at schedule time and are distributed
767+ /// from the contract's balance. Open to any caller once the ledger condition
768+ /// is satisfied.
769+ pub fn execute_scheduled_batch (
770+ env : Env ,
771+ scheduled_id : u64 ,
772+ ) -> Result < u64 , ContractError > {
773+ Self :: require_not_paused ( & env) ?;
774+ Self :: bump_core_ttl ( & env) ;
775+
776+ let key = DataKey :: ScheduledBatch ( scheduled_id) ;
777+ let mut scheduled: ScheduledBatch = env. storage ( ) . persistent ( )
778+ . get ( & key)
779+ . ok_or ( ContractError :: ScheduledBatchNotFound ) ?;
780+
781+ if scheduled. status != ScheduledBatchStatus :: Pending {
782+ return Err ( ContractError :: ScheduledBatchConsumed ) ;
783+ }
784+
785+ let current_ledger = env. ledger ( ) . sequence ( ) ;
786+ if current_ledger < scheduled. execute_after_ledger {
787+ return Err ( ContractError :: ScheduledBatchNotReady ) ;
788+ }
789+
790+ let mut total: i128 = 0 ;
791+ for op in scheduled. payments . iter ( ) {
792+ total = total. checked_add ( op. amount ) . ok_or ( ContractError :: AmountOverflow ) ?;
793+ }
794+
795+ let token_client = token:: Client :: new ( & env, & scheduled. token ) ;
796+ let contract_addr = env. current_contract_address ( ) ;
797+
798+ // Funds are already held by the contract — distribute to recipients
799+ for op in scheduled. payments . iter ( ) {
800+ token_client. transfer ( & contract_addr, & op. recipient , & op. amount ) ;
801+ }
802+
803+ Self :: record_usage ( & env, & scheduled. sender , total) ;
804+
805+ let batch_id = Self :: next_batch_id ( & env) ;
806+ let success_count = scheduled. payments . len ( ) ;
807+ env. storage ( ) . persistent ( ) . set ( & DataKey :: Batch ( batch_id) , & BatchRecord {
808+ sender : scheduled. sender . clone ( ) ,
809+ token : scheduled. token . clone ( ) ,
810+ total_sent : total,
811+ success_count,
812+ fail_count : 0 ,
813+ status : symbol_short ! ( "completed" ) ,
814+ } ) ;
815+ env. storage ( ) . persistent ( ) . extend_ttl (
816+ & DataKey :: Batch ( batch_id) , PERSISTENT_TTL_THRESHOLD , PERSISTENT_TTL_EXTEND_TO ,
817+ ) ;
818+
819+ // Mark scheduled batch as executed
820+ scheduled. status = ScheduledBatchStatus :: Executed ;
821+ env. storage ( ) . persistent ( ) . set ( & key, & scheduled) ;
822+ env. storage ( ) . persistent ( ) . extend_ttl ( & key, PERSISTENT_TTL_THRESHOLD , PERSISTENT_TTL_EXTEND_TO ) ;
823+
824+ ScheduledBatchExecutedEvent { scheduled_id, batch_id, total_sent : total } . publish ( & env) ;
825+ Ok ( batch_id)
826+ }
827+
828+ /// Cancels a pending scheduled batch and returns held funds to the original
829+ /// sender. Only the original sender may cancel.
830+ pub fn cancel_scheduled_batch (
831+ env : Env ,
832+ sender : Address ,
833+ scheduled_id : u64 ,
834+ ) -> Result < ( ) , ContractError > {
835+ sender. require_auth ( ) ;
836+
837+ let key = DataKey :: ScheduledBatch ( scheduled_id) ;
838+ let mut scheduled: ScheduledBatch = env. storage ( ) . persistent ( )
839+ . get ( & key)
840+ . ok_or ( ContractError :: ScheduledBatchNotFound ) ?;
841+
842+ if scheduled. status != ScheduledBatchStatus :: Pending {
843+ return Err ( ContractError :: ScheduledBatchConsumed ) ;
844+ }
845+ if scheduled. sender != sender {
846+ return Err ( ContractError :: ScheduledBatchUnauthorized ) ;
847+ }
848+
849+ // Return held funds to sender
850+ let mut total: i128 = 0 ;
851+ for op in scheduled. payments . iter ( ) {
852+ total = total. checked_add ( op. amount ) . ok_or ( ContractError :: AmountOverflow ) ?;
853+ }
854+ let token_client = token:: Client :: new ( & env, & scheduled. token ) ;
855+ token_client. transfer ( & env. current_contract_address ( ) , & sender, & total) ;
856+
857+ scheduled. status = ScheduledBatchStatus :: Cancelled ;
858+ env. storage ( ) . persistent ( ) . set ( & key, & scheduled) ;
859+ env. storage ( ) . persistent ( ) . extend_ttl ( & key, PERSISTENT_TTL_THRESHOLD , PERSISTENT_TTL_EXTEND_TO ) ;
860+
861+ ScheduledBatchCancelledEvent { scheduled_id, sender } . publish ( & env) ;
862+ Ok ( ( ) )
863+ }
864+
865+ /// Returns a scheduled batch record by ID.
866+ pub fn get_scheduled_batch (
867+ env : Env ,
868+ scheduled_id : u64 ,
869+ ) -> Result < ScheduledBatch , ContractError > {
870+ env. storage ( ) . persistent ( )
871+ . get ( & DataKey :: ScheduledBatch ( scheduled_id) )
872+ . ok_or ( ContractError :: ScheduledBatchNotFound )
873+ }
874+
648875 /// Query the status and details of a single payment within a batch.
649876 pub fn get_payment_entry (
650877 env : Env ,
0 commit comments