Skip to content

Commit 4f5ec67

Browse files
authored
Merge pull request #159 from luhrhenz/feature/oracle-reminder-treasury-memo
2 parents d3df3b1 + e4b3e80 commit 4f5ec67

2 files changed

Lines changed: 224 additions & 33 deletions

File tree

contracts/split/src/lib.rs

Lines changed: 206 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@ fn group_key(group_id: u64) -> (Symbol, u64) {
6666
fn invoice_group_key(invoice_id: u64) -> (Symbol, u64) {
6767
(symbol_short!("invgrp"), invoice_id)
6868
}
69+
70+
fn invoice_treasury_key(invoice_id: u64) -> (Symbol, u64) {
71+
(symbol_short!("inv_tr"), invoice_id)
72+
}
73+
74+
fn treasury_group_counter_key() -> Symbol {
75+
symbol_short!("grp_tr_cnt")
76+
}
77+
78+
fn reminder_key(invoice_id: u64, address: &Address) -> (Symbol, u64, Address) {
79+
(symbol_short!("rem"), invoice_id, address.clone())
80+
}
81+
82+
fn group_treasury_key(group_id: u64) -> (Symbol, u64) {
83+
(symbol_short!("grp_tr"), group_id)
84+
}
85+
6986
fn template_key(creator: &Address, name: &Symbol) -> (Symbol, Address, Symbol) {
7087
(symbol_short!("tmpl"), creator.clone(), name.clone())
7188
}
@@ -283,6 +300,26 @@ fn group_all_funded(env: &Env, group_id: u64) -> bool {
283300
true
284301
}
285302

303+
fn treasury_record_for_invoice(env: &Env, invoice_id: u64) -> Option<(u64, TreasuryRecord)> {
304+
if let Some(group_id) = env
305+
.storage()
306+
.persistent()
307+
.get::<(Symbol, u64), u64>(&invoice_treasury_key(invoice_id))
308+
{
309+
if let Some(record) = env.storage().persistent().get(&group_treasury_key(group_id)) {
310+
return Some((group_id, record));
311+
}
312+
}
313+
None
314+
}
315+
316+
fn load_treasury_record(env: &Env, group_id: u64) -> TreasuryRecord {
317+
env.storage()
318+
.persistent()
319+
.get(&group_treasury_key(group_id))
320+
.expect("treasury record not found")
321+
}
322+
286323
// ---------------------------------------------------------------------------
287324
// Contract
288325
// ---------------------------------------------------------------------------
@@ -581,6 +618,8 @@ impl SplitContract {
581618
options.velocity_window,
582619
options.split_rules,
583620
options.auto_resolve_rules,
621+
options.oracle_address,
622+
options.cross_chain_ref,
584623
)
585624
}
586625

@@ -606,6 +645,7 @@ impl SplitContract {
606645
release_stages: Vec<u32>,
607646
price_oracle: Option<Address>,
608647
swap_tokens: Vec<Option<Address>>,
648+
oracle_address: Option<Address>,
609649
tax_bps: u32,
610650
tax_authority: Option<Address>,
611651
insurance_premium_bps: u32,
@@ -619,6 +659,7 @@ impl SplitContract {
619659
velocity_window: u64,
620660
split_rules: Vec<SplitRule>,
621661
auto_resolve_rules: Vec<ResolveRule>,
662+
cross_chain_ref: Option<String>,
622663
) -> u64 {
623664
assert!(
624665
recipients.len() == amounts.len(),
@@ -824,6 +865,8 @@ impl SplitContract {
824865
tax_authority,
825866
insurance_premium_bps,
826867
insurance_fund: 0,
868+
oracle_address,
869+
condition_met: false,
827870
smart_route,
828871
convert_to_stream,
829872
accepted_tokens,
@@ -953,6 +996,9 @@ impl SplitContract {
953996
Vec::new(&env),
954997
Vec::new(&env),
955998
Vec::new(&env),
999+
None,
1000+
None,
1001+
None,
9561002
);
9571003
ids.push_back(id);
9581004
}
@@ -1014,6 +1060,9 @@ impl SplitContract {
10141060
0,
10151061
Vec::new(&env),
10161062
Vec::new(&env),
1063+
None,
1064+
None,
1065+
None,
10171066
);
10181067

10191068
if months > 1 {
@@ -1145,7 +1194,8 @@ impl SplitContract {
11451194
|| !invoice.tranches.is_empty()
11461195
|| !invoice.release_stages.is_empty()
11471196
|| in_group
1148-
|| !invoice.co_signers.is_empty();
1197+
|| !invoice.co_signers.is_empty()
1198+
|| (invoice.oracle_address.is_some() && !invoice.condition_met);
11491199
if guarded {
11501200
save_invoice(&env, invoice_id, &invoice);
11511201
} else {
@@ -1362,7 +1412,8 @@ impl SplitContract {
13621412
|| !invoice.tranches.is_empty()
13631413
|| !invoice.release_stages.is_empty()
13641414
|| in_group
1365-
|| !invoice.co_signers.is_empty();
1415+
|| !invoice.co_signers.is_empty()
1416+
|| (invoice.oracle_address.is_some() && !invoice.condition_met);
13661417
if guarded {
13671418
save_invoice(env, invoice_id, &invoice);
13681419
} else {
@@ -1460,7 +1511,8 @@ impl SplitContract {
14601511
|| !invoice.tranches.is_empty()
14611512
|| !invoice.release_stages.is_empty()
14621513
|| in_group
1463-
|| !invoice.co_signers.is_empty();
1514+
|| !invoice.co_signers.is_empty()
1515+
|| (invoice.oracle_address.is_some() && !invoice.condition_met);
14641516
if guarded {
14651517
save_invoice(&env, invoice_id, &invoice);
14661518
} else {
@@ -1535,7 +1587,8 @@ impl SplitContract {
15351587
|| !inv.tranches.is_empty()
15361588
|| !inv.release_stages.is_empty()
15371589
|| in_group
1538-
|| !inv.co_signers.is_empty();
1590+
|| !inv.co_signers.is_empty()
1591+
|| (inv.oracle_address.is_some() && !inv.condition_met);
15391592
if guarded {
15401593
save_invoice(&env, p.invoice_id, &inv);
15411594
} else {
@@ -1687,6 +1740,75 @@ impl SplitContract {
16871740
append_audit_entry(&env, invoice_id, symbol_short!("aprv"), approver);
16881741
}
16891742

1743+
/// Oracle confirms a condition for a gated invoice.
1744+
/// Requires the configured oracle address to authenticate.
1745+
pub fn confirm_condition(env: Env, invoice_id: u64) {
1746+
require_not_paused(&env);
1747+
let mut invoice = load_invoice(&env, invoice_id);
1748+
let oracle = invoice.oracle_address.as_ref().expect("no oracle set for invoice");
1749+
oracle.require_auth();
1750+
invoice.condition_met = true;
1751+
save_invoice(&env, invoice_id, &invoice);
1752+
append_audit_entry(&env, invoice_id, symbol_short!("oracle_ok"), oracle);
1753+
}
1754+
1755+
/// Set a payment reminder for an address on a specific invoice.
1756+
/// The `who` address must authenticate.
1757+
pub fn set_reminder(env: Env, who: Address, invoice_id: u64, remind_at: u64) {
1758+
require_not_paused(&env);
1759+
who.require_auth();
1760+
env.storage()
1761+
.persistent()
1762+
.set(&reminder_key(invoice_id, &who), &remind_at);
1763+
append_audit_entry(&env, invoice_id, symbol_short!("set_rmd"), &who);
1764+
}
1765+
1766+
/// Trigger a previously set reminder; must be called at or after `remind_at`.
1767+
pub fn trigger_reminder(env: Env, invoice_id: u64, who: Address) {
1768+
require_not_paused(&env);
1769+
let remind_at: u64 = env
1770+
.storage()
1771+
.persistent()
1772+
.get(&reminder_key(invoice_id, &who))
1773+
.expect("reminder not set");
1774+
assert!(env.ledger().timestamp() >= remind_at, "reminder not due");
1775+
events::payment_reminder(&env, invoice_id, &who);
1776+
env.storage().persistent().remove(&reminder_key(invoice_id, &who));
1777+
append_audit_entry(&env, invoice_id, symbol_short!("trig_rmd"), &who);
1778+
}
1779+
1780+
/// Create a treasury group linking multiple invoice IDs to a single treasury address.
1781+
/// Returns the new group id.
1782+
pub fn group_treasury_create(env: Env, creator: Address, invoice_ids: Vec<u64>, treasury: Address) -> u64 {
1783+
require_not_paused(&env);
1784+
creator.require_auth();
1785+
let id: u64 = env
1786+
.storage()
1787+
.persistent()
1788+
.get(&treasury_group_counter_key())
1789+
.unwrap_or(0u64)
1790+
+ 1;
1791+
env.storage().persistent().set(&treasury_group_counter_key(), &id);
1792+
let record = types::TreasuryRecord { invoice_ids: invoice_ids.clone(), treasury: treasury.clone() };
1793+
env.storage().persistent().set(&group_treasury_key(id), &record);
1794+
for iid in invoice_ids.iter() {
1795+
env.storage().persistent().set(&invoice_treasury_key(*iid), &id);
1796+
append_audit_entry(&env, *iid, symbol_short!("grp_tr"), &creator);
1797+
}
1798+
id
1799+
}
1800+
1801+
/// Pay toward an invoice using a memo that encodes the invoice id.
1802+
/// Requires payer auth and emits a payment_matched event on success.
1803+
pub fn pay_with_memo(env: Env, payer: Address, memo: u64, amount: i128, nonce: u64, auto_convert: bool) {
1804+
require_not_paused(&env);
1805+
payer.require_auth();
1806+
// Validate memo corresponds to an existing invoice.
1807+
let _ = load_invoice(&env, memo);
1808+
Self::_pay(&env, &payer, memo, amount, nonce, auto_convert);
1809+
events::payment_matched(&env, memo, memo, &payer);
1810+
}
1811+
16901812
/// Claim vesting cliff share after cliff timestamp has passed (issue #27).
16911813
///
16921814
/// Requires that the invoice status is Released and the cliff (if set) has passed.
@@ -2078,6 +2200,7 @@ impl SplitContract {
20782200
let mut distributed: i128 = 0;
20792201
let mut total_fee: i128 = 0;
20802202
let mut total_tax: i128 = 0;
2203+
let mut payouts: Vec<i128> = Vec::new(env);
20812204
for i in 0..n {
20822205
let recipient = invoice.recipients.get(i).unwrap();
20832206
let amount = invoice.amounts.get(i).unwrap();
@@ -2106,43 +2229,91 @@ impl SplitContract {
21062229
distributed += proportional;
21072230

21082231
let tax = (proportional as u128 * invoice.tax_bps as u128 / 10_000u128) as i128;
2109-
let post_tax = proportional - tax;
21102232
total_tax += tax;
2233+
let post_tax = proportional - tax;
21112234

21122235
let fee = (post_tax as u128 * platform_fee_bps as u128 / 10_000u128) as i128;
2113-
let payout = post_tax - fee;
21142236
total_fee += fee;
21152237

2116-
// Issue #41: if a swap token is configured for this recipient, invoke DEX swap.
2117-
let swap_token: Option<Address> = invoice
2118-
.swap_tokens
2119-
.get(i as u32)
2120-
.unwrap_or(None);
2121-
if let Some(ref out_token) = swap_token {
2122-
let from_token = invoice.tokens.get(0).expect("no token");
2123-
let mut args: Vec<Val> = Vec::new(env);
2124-
args.push_back(from_token.into_val(env));
2125-
args.push_back(out_token.clone().into_val(env));
2126-
args.push_back(payout.into_val(env));
2127-
args.push_back(recipient.into_val(env));
2128-
let _swapped: i128 = env.invoke_contract(out_token, &Symbol::new(env, "swap"), args);
2129-
} else if invoice.smart_route {
2130-
// Smart routing: query DEX router for optimal path, fall back to direct transfer.
2131-
let from_token = invoice.tokens.get(0).expect("no token");
2132-
let mut route_args: Vec<Val> = Vec::new(env);
2133-
route_args.push_back(from_token.into_val(env));
2134-
route_args.push_back(payout.into_val(env));
2135-
route_args.push_back(recipient.clone().into_val(env));
2136-
// Try DEX path-finding via invoke; on failure fall back to direct transfer.
2137-
// In production the router address would be stored; here we attempt invoke
2138-
// and catch failure by falling back.
2139-
token_client.transfer(&env.current_contract_address(), &recipient, &payout);
2140-
} else {
2141-
let routed = Self::execute_smart_route(env, invoice, &recipient, payout);
2142-
if !routed {
2238+
payouts.push_back(proportional);
2239+
}
2240+
2241+
// If this invoice belongs to a treasury group, route the net payouts to the group's treasury address.
2242+
if let Some((_group_id, record)) = treasury_record_for_invoice(env, invoice_id) {
2243+
// Transfer taxes first.
2244+
if total_tax > 0 {
2245+
if let Some(ref auth) = invoice.tax_authority {
2246+
token_client.transfer(&env.current_contract_address(), auth, &total_tax);
2247+
}
2248+
}
2249+
2250+
// Transfer platform fee to global treasury.
2251+
if total_fee > 0 {
2252+
let treasury: Address = env
2253+
.storage()
2254+
.instance()
2255+
.get(&treasury_key())
2256+
.expect("treasury not set");
2257+
token_client.transfer(&env.current_contract_address(), &treasury, &total_fee);
2258+
}
2259+
2260+
let net = distributed - total_tax - total_fee;
2261+
if net > 0 {
2262+
token_client.transfer(&env.current_contract_address(), &record.treasury, &net);
2263+
}
2264+
} else {
2265+
// Default behavior: transfer to each recipient (or route via DEX/router as configured).
2266+
for i in 0..n {
2267+
let recipient = invoice.recipients.get(i).unwrap();
2268+
let proportional = payouts.get(i as u32).unwrap();
2269+
2270+
let tax = (proportional as u128 * invoice.tax_bps as u128 / 10_000u128) as i128;
2271+
let post_tax = proportional - tax;
2272+
let fee = (post_tax as u128 * platform_fee_bps as u128 / 10_000u128) as i128;
2273+
let payout = post_tax - fee;
2274+
2275+
// Issue #41: if a swap token is configured for this recipient, invoke DEX swap.
2276+
let swap_token: Option<Address> = invoice
2277+
.swap_tokens
2278+
.get(i as u32)
2279+
.unwrap_or(None);
2280+
if let Some(ref out_token) = swap_token {
2281+
let from_token = invoice.tokens.get(0).expect("no token");
2282+
let mut args: Vec<Val> = Vec::new(env);
2283+
args.push_back(from_token.into_val(env));
2284+
args.push_back(out_token.clone().into_val(env));
2285+
args.push_back(payout.into_val(env));
2286+
args.push_back(recipient.into_val(env));
2287+
let _swapped: i128 = env.invoke_contract(out_token, &Symbol::new(env, "swap"), args);
2288+
} else if invoice.smart_route {
2289+
let from_token = invoice.tokens.get(0).expect("no token");
2290+
let mut route_args: Vec<Val> = Vec::new(env);
2291+
route_args.push_back(from_token.into_val(env));
2292+
route_args.push_back(payout.into_val(env));
2293+
route_args.push_back(recipient.clone().into_val(env));
21432294
token_client.transfer(&env.current_contract_address(), &recipient, &payout);
2295+
} else {
2296+
let routed = Self::execute_smart_route(env, invoice, &recipient, payout);
2297+
if !routed {
2298+
token_client.transfer(&env.current_contract_address(), &recipient, &payout);
2299+
}
2300+
}
2301+
}
2302+
2303+
if total_tax > 0 {
2304+
if let Some(ref auth) = invoice.tax_authority {
2305+
token_client.transfer(&env.current_contract_address(), auth, &total_tax);
21442306
}
21452307
}
2308+
2309+
if total_fee > 0 {
2310+
let treasury: Address = env
2311+
.storage()
2312+
.instance()
2313+
.get(&treasury_key())
2314+
.expect("treasury not set");
2315+
token_client.transfer(&env.current_contract_address(), &treasury, &total_fee);
2316+
}
21462317
}
21472318

21482319
if total_tax > 0 {
@@ -2394,6 +2565,8 @@ impl SplitContract {
23942565
Vec::new(env),
23952566
Vec::new(env),
23962567
Vec::new(env),
2568+
None,
2569+
None,
23972570
);
23982571
env.storage()
23992572
.persistent()

0 commit comments

Comments
 (0)