Skip to content

Commit 080f5fa

Browse files
authored
Merge branch 'main' into feat/issue-215-214-212-204
2 parents 187a6e5 + d80f324 commit 080f5fa

2 files changed

Lines changed: 136 additions & 13 deletions

File tree

contracts/split/src/lib.rs

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ fn paused_key() -> Symbol {
4141
fn paused_fns_key() -> Symbol {
4242
symbol_short!("ps_fns")
4343
}
44+
45+
fn pause_exempt_key(address: &Address) -> (Symbol, Address) {
46+
(symbol_short!("p_exempt"), address.clone())
47+
}
48+
49+
fn global_payer_limit_key() -> Symbol {
50+
symbol_short!("g_vel_lim")
51+
}
52+
53+
fn global_payer_window_key() -> Symbol {
54+
symbol_short!("g_vel_win")
55+
}
56+
57+
fn global_vel_key(payer: &Address) -> (Symbol, Address) {
58+
(symbol_short!("g_vel"), payer.clone())
59+
}
4460
fn creation_fee_key() -> Symbol {
4561
symbol_short!("crt_fee")
4662
}
@@ -708,6 +724,25 @@ impl SplitContract {
708724
env.storage().persistent().set(&paused_fns_key(), &new_list);
709725
}
710726

727+
/// Set an address as exempt from the global pause for invoice creation.
728+
/// Requires admin auth.
729+
pub fn set_pause_exempt(env: Env, admin: Address, address: Address, exempt: bool) {
730+
require_role(&env, &admin, AdminRole::Operator);
731+
if exempt {
732+
env.storage().persistent().set(&pause_exempt_key(&address), &true);
733+
} else {
734+
env.storage().persistent().remove(&pause_exempt_key(&address));
735+
}
736+
}
737+
738+
/// Set the global payer aggregate limit and window. Requires admin auth.
739+
pub fn set_global_payer_limit(env: Env, admin: Address, limit: i128, window_secs: u64) {
740+
require_role(&env, &admin, AdminRole::Operator);
741+
assert!(limit >= 0, "limit must be non-negative");
742+
env.storage().persistent().set(&global_payer_limit_key(), &limit);
743+
env.storage().persistent().set(&global_payer_window_key(), &window_secs);
744+
}
745+
711746
/// Update the creation fee. Requires admin auth.
712747
pub fn set_creation_fee(env: Env, admin: Address, creation_fee: i128) {
713748
require_role(&env, &admin, AdminRole::Operator);
@@ -1331,7 +1366,12 @@ impl SplitContract {
13311366
deadline: u64,
13321367
options: InvoiceOptions,
13331368
) -> u64 {
1334-
require_not_paused(&env);
1369+
// Check if contract is paused, but allow exempt creators
1370+
let is_paused = is_paused(&env);
1371+
let is_exempt = env.storage().persistent().get::<_, bool>(&pause_exempt_key(&creator)).unwrap_or(false);
1372+
if is_paused && !is_exempt {
1373+
panic!("contract is paused");
1374+
}
13351375
creator.require_auth();
13361376
Self::_apply_rate_limit(&env, &creator);
13371377

@@ -1400,6 +1440,7 @@ impl SplitContract {
14001440
options.refund_grace_secs,
14011441
options.priorities,
14021442
options.require_kyc,
1443+
options.scheduled_release_at,
14031444
)
14041445
}
14051446

@@ -1449,6 +1490,7 @@ impl SplitContract {
14491490
refund_grace_secs: Option<u64>,
14501491
priorities: Vec<u32>,
14511492
require_kyc: bool,
1493+
scheduled_release_at: Option<u64>,
14521494
) -> u64 {
14531495
assert!(
14541496
recipients.len() == amounts.len(),
@@ -1742,6 +1784,7 @@ impl SplitContract {
17421784
payment_cooldown_secs,
17431785
max_payments_per_window,
17441786
payment_window_secs,
1787+
scheduled_release_at,
17451788
refund_grace_secs,
17461789
cross_chain_ref,
17471790
require_kyc,
@@ -2176,7 +2219,7 @@ impl SplitContract {
21762219
let mut new_payments: Vec<Payment> = Vec::new(&env);
21772220
for (payer, amount) in payer_amounts.iter() {
21782221
let tip = payer_tips.get(payer.clone()).unwrap_or(0);
2179-
new_payments.push_back(Payment { payer, amount, tip, donate_on_failure: false });
2222+
new_payments.push_back(Payment { payer, amount, tip, attestation_hash: None });
21802223
}
21812224

21822225
// Verify total funded is unchanged (optional assertion, as asked by Acceptance Criteria)
@@ -2259,7 +2302,7 @@ impl SplitContract {
22592302
.persistent()
22602303
.get::<(Symbol, u64, u64), Vec<Payment>>(&pay_shard_key(invoice_id, shard_id))
22612304
.unwrap_or_else(|| Vec::new(&env));
2262-
shard_payments.push_back(Payment { payer: payer.clone(), amount: net_paid, tip: 0, donate_on_failure: false });
2305+
shard_payments.push_back(Payment { payer: payer.clone(), amount: net_paid, tip: 0, attestation_hash: None });
22632306
env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments);
22642307

22652308
invoice.funded += net_paid;
@@ -2300,10 +2343,22 @@ impl SplitContract {
23002343
pub fn pay(env: Env, payer: Address, invoice_id: u64, amount: i128, nonce: u64, _auto_convert: bool, donate_on_failure: bool) {
23012344
require_fn_not_paused(&env, &symbol_short!("pay"));
23022345
payer.require_auth();
2303-
Self::_pay(&env, &payer, invoice_id, amount, nonce, _auto_convert, donate_on_failure);
2346+
Self::_pay(&env, &payer, invoice_id, amount, nonce, _auto_convert, None);
23042347
}
23052348

2306-
fn _pay(env: &Env, payer: &Address, invoice_id: u64, amount: i128, nonce: u64, _auto_convert: bool, donate_on_failure: bool) {
2349+
/// Pay with a signed attestation binding the payment to an off-chain identity
2350+
pub fn pay_with_attestation(env: Env, payer: Address, invoice_id: u64, amount: i128, nonce: u64, attestation_hash: BytesN<32>, signature: BytesN<64>, signer_pubkey: BytesN<32>, _auto_convert: bool) {
2351+
require_fn_not_paused(&env, &symbol_short!("pay"));
2352+
payer.require_auth();
2353+
2354+
// Verify ed25519 signature over attestation_hash
2355+
env.crypto().ed25519_verify(&signer_pubkey, &attestation_hash, &signature);
2356+
2357+
// Proceed with payment, storing the attestation hash
2358+
Self::_pay(&env, &payer, invoice_id, amount, nonce, _auto_convert, Some(attestation_hash));
2359+
}
2360+
2361+
fn _pay(env: &Env, payer: &Address, invoice_id: u64, amount: i128, nonce: u64, _auto_convert: bool, attestation_hash: Option<BytesN<32>>) {
23072362
let mut invoice = load_invoice(env, invoice_id);
23082363

23092364
assert!(
@@ -2418,6 +2473,26 @@ impl SplitContract {
24182473
env.storage().persistent().set(&vel_key(invoice_id, payer), &window);
24192474
}
24202475

2476+
// Global cross-invoice velocity limiting per payer
2477+
let global_limit: i128 = env.storage().persistent().get(&global_payer_limit_key()).unwrap_or(0i128);
2478+
if global_limit > 0 {
2479+
let global_window_secs: u64 = env.storage().persistent().get(&global_payer_window_key()).unwrap_or(0u64);
2480+
let now = env.ledger().timestamp();
2481+
let mut global_window: (u64, i128) = env
2482+
.storage()
2483+
.persistent()
2484+
.get(&global_vel_key(payer))
2485+
.unwrap_or((0u64, 0i128));
2486+
if now > global_window.0 + global_window_secs {
2487+
// reset global window
2488+
global_window.0 = now;
2489+
global_window.1 = 0;
2490+
}
2491+
assert!(global_window.1 + amount <= global_limit, "global payer limit exceeded");
2492+
global_window.1 += amount;
2493+
env.storage().persistent().set(&global_vel_key(payer), &global_window);
2494+
}
2495+
24212496
let token_client = token::Client::new(env, &invoice.tokens.get(0).expect("no token"));
24222497

24232498
let credited_amount = match invoice.overflow_behavior {
@@ -2493,7 +2568,7 @@ impl SplitContract {
24932568
.persistent()
24942569
.get::<(Symbol, u64, u64), Vec<Payment>>(&pay_shard_key(invoice_id, shard_id))
24952570
.unwrap_or_else(|| Vec::new(env));
2496-
shard_payments.push_back(Payment { payer: payer.clone(), amount: credited_amount, tip: 0, donate_on_failure });
2571+
shard_payments.push_back(Payment { payer: payer.clone(), amount: credited_amount, tip: 0, attestation_hash });
24972572
env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments);
24982573

24992574
invoice.funded += credited_amount;
@@ -2652,7 +2727,7 @@ impl SplitContract {
26522727
.persistent()
26532728
.get::<(Symbol, u64, u64), Vec<Payment>>(&pay_shard_key(invoice_id, shard_id))
26542729
.unwrap_or_else(|| Vec::new(&env));
2655-
shard_payments.push_back(Payment { payer: payer.clone(), amount: credited_amount, tip: 0, donate_on_failure: false });
2730+
shard_payments.push_back(Payment { payer: payer.clone(), amount: credited_amount, tip: 0, attestation_hash });
26562731
env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments);
26572732

26582733
invoice.funded += credited_amount;
@@ -2724,7 +2799,7 @@ impl SplitContract {
27242799
.persistent()
27252800
.get::<(Symbol, u64, u64), Vec<Payment>>(&pay_shard_key(invoice_id, shard_id))
27262801
.unwrap_or_else(|| Vec::new(&env));
2727-
shard_payments.push_back(Payment { payer: payer.clone(), amount: converted, tip: 0, donate_on_failure: false });
2802+
shard_payments.push_back(Payment { payer: payer.clone(), amount: converted, tip: 0, attestation_hash: None });
27282803
env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments);
27292804

27302805
invoice.funded += converted;
@@ -2811,7 +2886,7 @@ impl SplitContract {
28112886
.persistent()
28122887
.get::<(Symbol, u64, u64), Vec<Payment>>(&pay_shard_key(p.invoice_id, shard_id))
28132888
.unwrap_or_else(|| Vec::new(&env));
2814-
shard_payments.push_back(Payment { payer: payer.clone(), amount: p.amount, tip: 0, donate_on_failure: false });
2889+
shard_payments.push_back(Payment { payer: payer.clone(), amount: p.amount, tip: 0, attestation_hash: None });
28152890
env.storage().persistent().set(&pay_shard_key(p.invoice_id, shard_id), &shard_payments);
28162891

28172892
inv.funded += p.amount;
@@ -2952,6 +3027,48 @@ impl SplitContract {
29523027
Self::_release(&env, invoice_id, &mut invoice, &caller);
29533028
}
29543029

3030+
/// Trigger a scheduled release at the configured timestamp, respecting min_funding_bps
3031+
pub fn trigger_scheduled_release(env: Env, invoice_id: u64) {
3032+
require_not_paused(&env);
3033+
let mut invoice = load_invoice(&env, invoice_id);
3034+
3035+
assert!(!invoice.frozen, "invoice is frozen");
3036+
assert!(!invoice.admin_frozen, "invoice frozen by admin");
3037+
assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending");
3038+
3039+
let scheduled_at = invoice.scheduled_release_at.expect("no scheduled release time");
3040+
assert!(env.ledger().timestamp() >= scheduled_at, "scheduled release time not reached");
3041+
3042+
// Check min funding requirement if set
3043+
if invoice.min_funding_bps > 0 {
3044+
let total: i128 = invoice.amounts.iter().sum();
3045+
let min_required = (total as u128 * invoice.min_funding_bps as u128 / 10_000u128) as i128;
3046+
assert!(invoice.funded >= min_required, "minimum funding not reached");
3047+
}
3048+
3049+
// Approval check (issue #25)
3050+
if invoice.approver.is_some() && !invoice.approved {
3051+
panic!("awaiting approval");
3052+
}
3053+
3054+
// Prerequisite check (issue #22)
3055+
if let Some(prereq_id) = invoice.prerequisite_id {
3056+
let prereq = load_invoice(&env, prereq_id);
3057+
assert!(prereq.status == InvoiceStatus::Released, "prerequisite not released");
3058+
}
3059+
3060+
// Co-signer approval check
3061+
if !invoice.co_signers.is_empty() {
3062+
assert!(
3063+
invoice.signatures.len() >= invoice.required_signatures,
3064+
"not enough co-signer approvals"
3065+
);
3066+
}
3067+
3068+
let caller = env.current_contract_address();
3069+
Self::_release(&env, invoice_id, &mut invoice, &caller);
3070+
}
3071+
29553072
fn _release(env: &Env, invoice_id: u64, invoice: &mut Invoice, actor: &Address) {
29563073
if invoice.tranches.is_empty() {
29573074
Self::_release_full(env, invoice_id, invoice, actor);
@@ -3979,7 +4096,7 @@ impl SplitContract {
39794096
.persistent()
39804097
.get::<(Symbol, u64, u64), Vec<Payment>>(&pay_shard_key(target_id, shard_id))
39814098
.unwrap_or_else(|| Vec::new(env));
3982-
shard_payments.push_back(Payment { payer: env.current_contract_address(), amount: leftover, tip: 0, donate_on_failure: false });
4099+
shard_payments.push_back(Payment { payer: env.current_contract_address(), amount: leftover, tip: 0, attestation_hash: None });
39834100
env.storage().persistent().set(&pay_shard_key(target_id, shard_id), &shard_payments);
39844101

39854102
target.funded += leftover;

contracts/split/src/types.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,7 @@ pub struct Payment {
8484
pub payer: Address,
8585
pub amount: i128,
8686
pub tip: i128,
87-
/// Issue #204: when true, this payer's contribution is donated to the
88-
/// creator on failure instead of being refunded.
89-
pub donate_on_failure: bool,
87+
pub attestation_hash: Option<BytesN<32>>,
9088
}
9189

9290
#[contracttype]
@@ -232,6 +230,8 @@ pub struct InvoiceOptions {
232230
pub priorities: Vec<u32>,
233231
/// Issue #199: grace period in seconds after deadline before refund is allowed.
234232
pub refund_grace_secs: Option<u64>,
233+
/// Scheduled release timestamp (issue #207).
234+
pub scheduled_release_at: Option<u64>,
235235
}
236236

237237
/// Legacy invoice layout used by stored invoices created before the `version`
@@ -335,6 +335,7 @@ pub struct InvoiceExt {
335335
pub payment_cooldown_secs: Option<u64>,
336336
pub max_payments_per_window: Option<u32>,
337337
pub payment_window_secs: Option<u64>,
338+
pub scheduled_release_at: Option<u64>,
338339
}
339340

340341
#[contracttype]
@@ -432,6 +433,8 @@ pub struct Invoice {
432433
pub payment_cooldown_secs: Option<u64>,
433434
pub max_payments_per_window: Option<u32>,
434435
pub payment_window_secs: Option<u64>,
436+
/// Scheduled release timestamp (issue #207).
437+
pub scheduled_release_at: Option<u64>,
435438
/// Issue #199: grace period in seconds after deadline before refund is allowed.
436439
pub refund_grace_secs: Option<u64>,
437440
pub notification_contract: Option<Address>,
@@ -511,6 +514,7 @@ impl Invoice {
511514
payment_cooldown_secs: self.payment_cooldown_secs,
512515
max_payments_per_window: self.max_payments_per_window,
513516
payment_window_secs: self.payment_window_secs,
517+
scheduled_release_at: self.scheduled_release_at,
514518
},
515519
InvoiceExt2 {
516520
notification_contract: self.notification_contract,
@@ -587,6 +591,7 @@ impl Invoice {
587591
payment_cooldown_secs: ext.payment_cooldown_secs,
588592
max_payments_per_window: ext.max_payments_per_window,
589593
payment_window_secs: ext.payment_window_secs,
594+
scheduled_release_at: ext.scheduled_release_at,
590595
notification_contract: ext2.notification_contract,
591596
overflow_behavior: ext2.overflow_behavior,
592597
cross_chain_ref: ext2.cross_chain_ref,
@@ -795,6 +800,7 @@ impl Invoice {
795800
payment_cooldown_secs: None,
796801
max_payments_per_window: None,
797802
payment_window_secs: None,
803+
scheduled_release_at: None,
798804
refund_grace_secs: None,
799805
forward_to: None,
800806
forward_invoice_id: None,

0 commit comments

Comments
 (0)