Skip to content

Commit 79b3f9a

Browse files
authored
Merge pull request #813 from ANTIDOT20/fix/stellar-wave-issues-614-616-617-632
feat(contracts): signer management, revenue tracking, beneficiary transfer, and scheduled batches
2 parents 5c65218 + 3213055 commit 79b3f9a

9 files changed

Lines changed: 782 additions & 41 deletions

File tree

contracts/bulk_payment/src/lib.rs

Lines changed: 236 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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]
96127
pub 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]
193245
pub 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

213269
const 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

Comments
 (0)