Skip to content

Commit 03ff017

Browse files
committed
feat:implemented the requested APIs and release routing
1 parent de28e99 commit 03ff017

3 files changed

Lines changed: 246 additions & 33 deletions

File tree

contracts/split/src/events.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,23 @@ pub fn delegate_revoked(env: &Env, invoice_id: u64) {
9999
(),
100100
);
101101
}
102+
103+
/// Emitted when a payment reminder is triggered.
104+
/// Topics: (split, payment_reminder, invoice_id)
105+
/// Data: (address)
106+
pub fn payment_reminder(env: &Env, invoice_id: u64, address: &Address) {
107+
env.events().publish(
108+
(symbol_short!("split"), symbol_short!("payment_reminder"), invoice_id),
109+
(address.clone(),),
110+
);
111+
}
112+
113+
/// Emitted when a memo-based payment is matched to an invoice.
114+
/// Topics: (split, payment_matched, invoice_id)
115+
/// Data: (memo, payer)
116+
pub fn payment_matched(env: &Env, invoice_id: u64, memo: u64, payer: &Address) {
117+
env.events().publish(
118+
(symbol_short!("split"), symbol_short!("payment_matched"), invoice_id),
119+
(memo, payer.clone()),
120+
);
121+
}

contracts/split/src/lib.rs

Lines changed: 207 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
}
@@ -253,6 +270,26 @@ fn group_all_funded(env: &Env, group_id: u64) -> bool {
253270
true
254271
}
255272

273+
fn treasury_record_for_invoice(env: &Env, invoice_id: u64) -> Option<(u64, TreasuryRecord)> {
274+
if let Some(group_id) = env
275+
.storage()
276+
.persistent()
277+
.get::<(Symbol, u64), u64>(&invoice_treasury_key(invoice_id))
278+
{
279+
if let Some(record) = env.storage().persistent().get(&group_treasury_key(group_id)) {
280+
return Some((group_id, record));
281+
}
282+
}
283+
None
284+
}
285+
286+
fn load_treasury_record(env: &Env, group_id: u64) -> TreasuryRecord {
287+
env.storage()
288+
.persistent()
289+
.get(&group_treasury_key(group_id))
290+
.expect("treasury record not found")
291+
}
292+
256293
// ---------------------------------------------------------------------------
257294
// Contract
258295
// ---------------------------------------------------------------------------
@@ -533,6 +570,8 @@ impl SplitContract {
533570
options.accepted_tokens,
534571
options.split_rules,
535572
options.auto_resolve_rules,
573+
options.oracle_address,
574+
options.cross_chain_ref,
536575
)
537576
}
538577

@@ -558,6 +597,7 @@ impl SplitContract {
558597
release_stages: Vec<u32>,
559598
price_oracle: Option<Address>,
560599
swap_tokens: Vec<Option<Address>>,
600+
oracle_address: Option<Address>,
561601
tax_bps: u32,
562602
tax_authority: Option<Address>,
563603
insurance_premium_bps: u32,
@@ -566,6 +606,7 @@ impl SplitContract {
566606
accepted_tokens: Vec<Address>,
567607
split_rules: Vec<SplitRule>,
568608
auto_resolve_rules: Vec<ResolveRule>,
609+
cross_chain_ref: Option<String>,
569610
) -> u64 {
570611
assert!(
571612
recipients.len() == amounts.len(),
@@ -767,11 +808,14 @@ impl SplitContract {
767808
tax_authority,
768809
insurance_premium_bps,
769810
insurance_fund: 0,
811+
oracle_address,
812+
condition_met: false,
770813
smart_route,
771814
convert_to_stream,
772815
accepted_tokens,
773816
split_rules,
774817
auto_resolve_rules,
818+
cross_chain_ref,
775819
};
776820

777821
save_invoice(env, id, &invoice);
@@ -854,6 +898,9 @@ impl SplitContract {
854898
Vec::new(&env),
855899
Vec::new(&env),
856900
Vec::new(&env),
901+
None,
902+
None,
903+
None,
857904
);
858905
ids.push_back(id);
859906
}
@@ -911,6 +958,9 @@ impl SplitContract {
911958
Vec::new(&env),
912959
Vec::new(&env),
913960
Vec::new(&env),
961+
None,
962+
None,
963+
None,
914964
);
915965

916966
if months > 1 {
@@ -1042,7 +1092,8 @@ impl SplitContract {
10421092
|| !invoice.tranches.is_empty()
10431093
|| !invoice.release_stages.is_empty()
10441094
|| in_group
1045-
|| !invoice.co_signers.is_empty();
1095+
|| !invoice.co_signers.is_empty()
1096+
|| (invoice.oracle_address.is_some() && !invoice.condition_met);
10461097
if guarded {
10471098
save_invoice(&env, invoice_id, &invoice);
10481099
} else {
@@ -1208,7 +1259,8 @@ impl SplitContract {
12081259
|| !invoice.tranches.is_empty()
12091260
|| !invoice.release_stages.is_empty()
12101261
|| in_group
1211-
|| !invoice.co_signers.is_empty();
1262+
|| !invoice.co_signers.is_empty()
1263+
|| (invoice.oracle_address.is_some() && !invoice.condition_met);
12121264
if guarded {
12131265
save_invoice(env, invoice_id, &invoice);
12141266
} else {
@@ -1306,7 +1358,8 @@ impl SplitContract {
13061358
|| !invoice.tranches.is_empty()
13071359
|| !invoice.release_stages.is_empty()
13081360
|| in_group
1309-
|| !invoice.co_signers.is_empty();
1361+
|| !invoice.co_signers.is_empty()
1362+
|| (invoice.oracle_address.is_some() && !invoice.condition_met);
13101363
if guarded {
13111364
save_invoice(&env, invoice_id, &invoice);
13121365
} else {
@@ -1381,7 +1434,8 @@ impl SplitContract {
13811434
|| !inv.tranches.is_empty()
13821435
|| !inv.release_stages.is_empty()
13831436
|| in_group
1384-
|| !inv.co_signers.is_empty();
1437+
|| !inv.co_signers.is_empty()
1438+
|| (inv.oracle_address.is_some() && !inv.condition_met);
13851439
if guarded {
13861440
save_invoice(&env, p.invoice_id, &inv);
13871441
} else {
@@ -1533,6 +1587,75 @@ impl SplitContract {
15331587
append_audit_entry(&env, invoice_id, symbol_short!("aprv"), approver);
15341588
}
15351589

1590+
/// Oracle confirms a condition for a gated invoice.
1591+
/// Requires the configured oracle address to authenticate.
1592+
pub fn confirm_condition(env: Env, invoice_id: u64) {
1593+
require_not_paused(&env);
1594+
let mut invoice = load_invoice(&env, invoice_id);
1595+
let oracle = invoice.oracle_address.as_ref().expect("no oracle set for invoice");
1596+
oracle.require_auth();
1597+
invoice.condition_met = true;
1598+
save_invoice(&env, invoice_id, &invoice);
1599+
append_audit_entry(&env, invoice_id, symbol_short!("oracle_ok"), oracle);
1600+
}
1601+
1602+
/// Set a payment reminder for an address on a specific invoice.
1603+
/// The `who` address must authenticate.
1604+
pub fn set_reminder(env: Env, who: Address, invoice_id: u64, remind_at: u64) {
1605+
require_not_paused(&env);
1606+
who.require_auth();
1607+
env.storage()
1608+
.persistent()
1609+
.set(&reminder_key(invoice_id, &who), &remind_at);
1610+
append_audit_entry(&env, invoice_id, symbol_short!("set_rmd"), &who);
1611+
}
1612+
1613+
/// Trigger a previously set reminder; must be called at or after `remind_at`.
1614+
pub fn trigger_reminder(env: Env, invoice_id: u64, who: Address) {
1615+
require_not_paused(&env);
1616+
let remind_at: u64 = env
1617+
.storage()
1618+
.persistent()
1619+
.get(&reminder_key(invoice_id, &who))
1620+
.expect("reminder not set");
1621+
assert!(env.ledger().timestamp() >= remind_at, "reminder not due");
1622+
events::payment_reminder(&env, invoice_id, &who);
1623+
env.storage().persistent().remove(&reminder_key(invoice_id, &who));
1624+
append_audit_entry(&env, invoice_id, symbol_short!("trig_rmd"), &who);
1625+
}
1626+
1627+
/// Create a treasury group linking multiple invoice IDs to a single treasury address.
1628+
/// Returns the new group id.
1629+
pub fn group_treasury_create(env: Env, creator: Address, invoice_ids: Vec<u64>, treasury: Address) -> u64 {
1630+
require_not_paused(&env);
1631+
creator.require_auth();
1632+
let id: u64 = env
1633+
.storage()
1634+
.persistent()
1635+
.get(&treasury_group_counter_key())
1636+
.unwrap_or(0u64)
1637+
+ 1;
1638+
env.storage().persistent().set(&treasury_group_counter_key(), &id);
1639+
let record = types::TreasuryRecord { invoice_ids: invoice_ids.clone(), treasury: treasury.clone() };
1640+
env.storage().persistent().set(&group_treasury_key(id), &record);
1641+
for iid in invoice_ids.iter() {
1642+
env.storage().persistent().set(&invoice_treasury_key(*iid), &id);
1643+
append_audit_entry(&env, *iid, symbol_short!("grp_tr"), &creator);
1644+
}
1645+
id
1646+
}
1647+
1648+
/// Pay toward an invoice using a memo that encodes the invoice id.
1649+
/// Requires payer auth and emits a payment_matched event on success.
1650+
pub fn pay_with_memo(env: Env, payer: Address, memo: u64, amount: i128, nonce: u64, auto_convert: bool) {
1651+
require_not_paused(&env);
1652+
payer.require_auth();
1653+
// Validate memo corresponds to an existing invoice.
1654+
let _ = load_invoice(&env, memo);
1655+
Self::_pay(&env, &payer, memo, amount, nonce, auto_convert);
1656+
events::payment_matched(&env, memo, memo, &payer);
1657+
}
1658+
15361659
/// Claim vesting cliff share after cliff timestamp has passed (issue #27).
15371660
///
15381661
/// Requires that the invoice status is Released and the cliff (if set) has passed.
@@ -1886,6 +2009,7 @@ impl SplitContract {
18862009
let mut distributed: i128 = 0;
18872010
let mut total_fee: i128 = 0;
18882011
let mut total_tax: i128 = 0;
2012+
let mut payouts: Vec<i128> = Vec::new(env);
18892013
for i in 0..n {
18902014
let recipient = invoice.recipients.get(i).unwrap();
18912015
let amount = invoice.amounts.get(i).unwrap();
@@ -1914,43 +2038,91 @@ impl SplitContract {
19142038
distributed += proportional;
19152039

19162040
let tax = (proportional as u128 * invoice.tax_bps as u128 / 10_000u128) as i128;
1917-
let post_tax = proportional - tax;
19182041
total_tax += tax;
2042+
let post_tax = proportional - tax;
19192043

19202044
let fee = (post_tax as u128 * platform_fee_bps as u128 / 10_000u128) as i128;
1921-
let payout = post_tax - fee;
19222045
total_fee += fee;
19232046

1924-
// Issue #41: if a swap token is configured for this recipient, invoke DEX swap.
1925-
let swap_token: Option<Address> = invoice
1926-
.swap_tokens
1927-
.get(i as u32)
1928-
.unwrap_or(None);
1929-
if let Some(ref out_token) = swap_token {
1930-
let from_token = invoice.tokens.get(0).expect("no token");
1931-
let mut args: Vec<Val> = Vec::new(env);
1932-
args.push_back(from_token.into_val(env));
1933-
args.push_back(out_token.clone().into_val(env));
1934-
args.push_back(payout.into_val(env));
1935-
args.push_back(recipient.into_val(env));
1936-
let _swapped: i128 = env.invoke_contract(out_token, &Symbol::new(env, "swap"), args);
1937-
} else if invoice.smart_route {
1938-
// Smart routing: query DEX router for optimal path, fall back to direct transfer.
1939-
let from_token = invoice.tokens.get(0).expect("no token");
1940-
let mut route_args: Vec<Val> = Vec::new(env);
1941-
route_args.push_back(from_token.into_val(env));
1942-
route_args.push_back(payout.into_val(env));
1943-
route_args.push_back(recipient.clone().into_val(env));
1944-
// Try DEX path-finding via invoke; on failure fall back to direct transfer.
1945-
// In production the router address would be stored; here we attempt invoke
1946-
// and catch failure by falling back.
1947-
token_client.transfer(&env.current_contract_address(), &recipient, &payout);
1948-
} else {
1949-
let routed = Self::execute_smart_route(env, invoice, &recipient, payout);
1950-
if !routed {
2047+
payouts.push_back(proportional);
2048+
}
2049+
2050+
// If this invoice belongs to a treasury group, route the net payouts to the group's treasury address.
2051+
if let Some((_group_id, record)) = treasury_record_for_invoice(env, invoice_id) {
2052+
// Transfer taxes first.
2053+
if total_tax > 0 {
2054+
if let Some(ref auth) = invoice.tax_authority {
2055+
token_client.transfer(&env.current_contract_address(), auth, &total_tax);
2056+
}
2057+
}
2058+
2059+
// Transfer platform fee to global treasury.
2060+
if total_fee > 0 {
2061+
let treasury: Address = env
2062+
.storage()
2063+
.instance()
2064+
.get(&treasury_key())
2065+
.expect("treasury not set");
2066+
token_client.transfer(&env.current_contract_address(), &treasury, &total_fee);
2067+
}
2068+
2069+
let net = distributed - total_tax - total_fee;
2070+
if net > 0 {
2071+
token_client.transfer(&env.current_contract_address(), &record.treasury, &net);
2072+
}
2073+
} else {
2074+
// Default behavior: transfer to each recipient (or route via DEX/router as configured).
2075+
for i in 0..n {
2076+
let recipient = invoice.recipients.get(i).unwrap();
2077+
let proportional = payouts.get(i as u32).unwrap();
2078+
2079+
let tax = (proportional as u128 * invoice.tax_bps as u128 / 10_000u128) as i128;
2080+
let post_tax = proportional - tax;
2081+
let fee = (post_tax as u128 * platform_fee_bps as u128 / 10_000u128) as i128;
2082+
let payout = post_tax - fee;
2083+
2084+
// Issue #41: if a swap token is configured for this recipient, invoke DEX swap.
2085+
let swap_token: Option<Address> = invoice
2086+
.swap_tokens
2087+
.get(i as u32)
2088+
.unwrap_or(None);
2089+
if let Some(ref out_token) = swap_token {
2090+
let from_token = invoice.tokens.get(0).expect("no token");
2091+
let mut args: Vec<Val> = Vec::new(env);
2092+
args.push_back(from_token.into_val(env));
2093+
args.push_back(out_token.clone().into_val(env));
2094+
args.push_back(payout.into_val(env));
2095+
args.push_back(recipient.into_val(env));
2096+
let _swapped: i128 = env.invoke_contract(out_token, &Symbol::new(env, "swap"), args);
2097+
} else if invoice.smart_route {
2098+
let from_token = invoice.tokens.get(0).expect("no token");
2099+
let mut route_args: Vec<Val> = Vec::new(env);
2100+
route_args.push_back(from_token.into_val(env));
2101+
route_args.push_back(payout.into_val(env));
2102+
route_args.push_back(recipient.clone().into_val(env));
19512103
token_client.transfer(&env.current_contract_address(), &recipient, &payout);
2104+
} else {
2105+
let routed = Self::execute_smart_route(env, invoice, &recipient, payout);
2106+
if !routed {
2107+
token_client.transfer(&env.current_contract_address(), &recipient, &payout);
2108+
}
2109+
}
2110+
}
2111+
2112+
if total_tax > 0 {
2113+
if let Some(ref auth) = invoice.tax_authority {
2114+
token_client.transfer(&env.current_contract_address(), auth, &total_tax);
19522115
}
19532116
}
2117+
2118+
if total_fee > 0 {
2119+
let treasury: Address = env
2120+
.storage()
2121+
.instance()
2122+
.get(&treasury_key())
2123+
.expect("treasury not set");
2124+
token_client.transfer(&env.current_contract_address(), &treasury, &total_fee);
2125+
}
19542126
}
19552127

19562128
if total_tax > 0 {
@@ -2166,6 +2338,8 @@ impl SplitContract {
21662338
Vec::new(env),
21672339
Vec::new(env),
21682340
Vec::new(env),
2341+
None,
2342+
None,
21692343
);
21702344
env.storage()
21712345
.persistent()

0 commit comments

Comments
 (0)