Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
All notable changes to the Sharpy smart contract are documented here.

## [Unreleased]
- feat(contract): `resolve_route_chain(id, max_depth)` bounded follower + `get_route_chain_len(id)` cycle-safe depth view — feat/route-chain-depth (closes #192)
- feat(contract): `get_stream_state(id)` + `preview_vested(id)` pure views; `withdraw_vested` repeat-withdraw idempotency fix (`total - vested`), cancel edge docs — feat/stream-vest-preview (closes #191)
- feat(events): invoice_updated (`inv_upd`) now emitted on `set_discount` and all whitelist mutations (`set/add/remove`), after the field-specific event; `is_invoice_expired(id)` read-only helper mirrors the `refund`/`refund_batch` expiry trigger — feat/event-taxonomy (closes #183)
- feat(contract): `get_creator_invoices_paged(creator, limit, offset)` + `get_payer_invoices_paged(payer, limit, offset)` with total bounds guards (limit 0 / offset past end yield empty pages); unpaginated index queries unchanged — feat/index-pagination (closes #184)
Expand Down
51 changes: 51 additions & 0 deletions contracts/sharpy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1534,6 +1534,57 @@ impl SharpyContract {
/// Return the streaming state for `invoice_id`, if any.
/// Pure view for dashboards: exposes cliff-gated vesting params plus
/// already-vested accounting without mutating state or emitting events.
/// Follow route hops up to `max_depth`; cycle-safe and depth-capped.
/// Returns the final reachable target (or the start id when unrouted /
/// depth exhausted / cycle detected). Never loops: visited-set breaks
/// cycles and the loop is bounded by `max_depth` (plus a 32-hop cap).
/// `resolve_route` behavior is unchanged; use this for multi-hop chains.
pub fn resolve_route_chain(env: Env, invoice_id: u64, max_depth: u32) -> u64 {
let mut cur = invoice_id;
let mut seen: u32 = 0;
let mut visited: Vec<u64> = Vec::new(&env);
visited.push_back(cur);
while seen < max_depth {
let next: Option<ComposableRoute> = env.storage().persistent().get::<(Symbol,u64), ComposableRoute>(&route_key(cur));
match next {
None => break,
Some(r) => {
if visited.contains(&r.target_invoice) { break; }
visited.push_back(r.target_invoice);
cur = r.target_invoice;
if visited.len() > 32 { break; }
}
}
seen += 1;
}
cur
}

/// Chain-length helper pairs with `resolve_route_chain`: dashboards can
/// assert `get_route_chain_len <= max_depth` before resolving.
/// Number of hops reachable from `invoice_id` (cycle-safe, capped at 32).
/// 0 when unrouted. Pure view for dashboards validating chain length.
pub fn get_route_chain_len(env: Env, invoice_id: u64) -> u32 {
let mut cur = invoice_id;
let mut len: u32 = 0;
let mut visited: Vec<u64> = Vec::new(&env);
visited.push_back(cur);
loop {
if len >= 32 { break; }
let next: Option<ComposableRoute> = env.storage().persistent().get::<(Symbol,u64), ComposableRoute>(&route_key(cur));
match next {
None => break,
Some(r) => {
if visited.contains(&r.target_invoice) { break; }
visited.push_back(r.target_invoice);
cur = r.target_invoice;
len += 1;
}
}
}
len
}

pub fn get_stream_state(env: Env, invoice_id: u64) -> Option<StreamingState> {
env.storage().persistent().get::<(Symbol,u64), StreamingState>(&streaming_key(invoice_id))
}
Expand Down
139 changes: 139 additions & 0 deletions contracts/sharpy/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5353,3 +5353,142 @@ mod test_stream_preview_c {
assert_eq!(st.vested, 0i128);
}
}

#[cfg(test)]
mod test_route_chain_a {
use soroban_sdk::{testutils::Address as _, Address, Env, Vec};
use crate::SharpyContractClient;
fn setup() -> (Env, SharpyContractClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let cid = env.register(crate::SharpyContract, ());
let c = SharpyContractClient::new(&env, &cid);
let a = Address::generate(&env);
let t = Address::generate(&env);
c.initialize(&a, &t);
(env, c)
}
fn opts(env: &Env) -> crate::types::InvoiceOptions {
crate::types::InvoiceOptions { escrow_enabled: false, escrow_release_delay: None, split_rules: Vec::new(env), auto_resolve_rules: Vec::new(env), arbitrator: None }
}
fn mk(env: &Env, client: &SharpyContractClient<'_>, creator: &Address) -> u64 {
let r = Address::generate(env);
let tok = Address::generate(env);
let dl = env.ledger().timestamp() + 86400;
client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [100i128]), &Vec::from_array(env, [tok]), &dl, &opts(env))
}
#[test]
fn test_chain_unrouted_identity() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id = mk(&env, &client, &creator);
assert_eq!(client.resolve_route_chain(&id, &5u32), id);
assert_eq!(client.get_route_chain_len(&id), 0u32);
}
#[test]
fn test_chain_single_hop() {
let (env, client) = setup();
let creator = Address::generate(&env);
let a = mk(&env, &client, &creator);
let b = mk(&env, &client, &creator);
client.set_route(&creator, &a, &b);
assert_eq!(client.resolve_route_chain(&a, &5u32), b);
assert_eq!(client.get_route_chain_len(&a), 1u32);
}
}

#[cfg(test)]
mod test_route_chain_b {
use soroban_sdk::{testutils::Address as _, Address, Env, Vec};
use crate::SharpyContractClient;
fn setup() -> (Env, SharpyContractClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let cid = env.register(crate::SharpyContract, ());
let c = SharpyContractClient::new(&env, &cid);
let a = Address::generate(&env);
let t = Address::generate(&env);
c.initialize(&a, &t);
(env, c)
}
fn opts(env: &Env) -> crate::types::InvoiceOptions {
crate::types::InvoiceOptions { escrow_enabled: false, escrow_release_delay: None, split_rules: Vec::new(env), auto_resolve_rules: Vec::new(env), arbitrator: None }
}
fn mk(env: &Env, client: &SharpyContractClient<'_>, creator: &Address) -> u64 {
let r = Address::generate(env);
let tok = Address::generate(env);
let dl = env.ledger().timestamp() + 86400;
client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [100i128]), &Vec::from_array(env, [tok]), &dl, &opts(env))
}
#[test]
fn test_chain_three_hops_resolves() {
let (env, client) = setup();
let creator = Address::generate(&env);
let a = mk(&env, &client, &creator);
let b = mk(&env, &client, &creator);
let c = mk(&env, &client, &creator);
client.set_route(&creator, &a, &b);
client.set_route(&creator, &b, &c);
assert_eq!(client.resolve_route_chain(&a, &5u32), c);
assert_eq!(client.get_route_chain_len(&a), 2u32);
}
#[test]
fn test_chain_depth_zero_returns_start() {
let (env, client) = setup();
let creator = Address::generate(&env);
let a = mk(&env, &client, &creator);
let b = mk(&env, &client, &creator);
client.set_route(&creator, &a, &b);
assert_eq!(client.resolve_route_chain(&a, &0u32), a);
assert_eq!(client.resolve_route_chain(&a, &1u32), b);
}
}

#[cfg(test)]
mod test_route_chain_c {
use soroban_sdk::{testutils::Address as _, Address, Env, Vec};
use crate::SharpyContractClient;
fn setup() -> (Env, SharpyContractClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let cid = env.register(crate::SharpyContract, ());
let c = SharpyContractClient::new(&env, &cid);
let a = Address::generate(&env);
let t = Address::generate(&env);
c.initialize(&a, &t);
(env, c)
}
fn opts(env: &Env) -> crate::types::InvoiceOptions {
crate::types::InvoiceOptions { escrow_enabled: false, escrow_release_delay: None, split_rules: Vec::new(env), auto_resolve_rules: Vec::new(env), arbitrator: None }
}
fn mk(env: &Env, client: &SharpyContractClient<'_>, creator: &Address) -> u64 {
let r = Address::generate(env);
let tok = Address::generate(env);
let dl = env.ledger().timestamp() + 86400;
client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [100i128]), &Vec::from_array(env, [tok]), &dl, &opts(env))
}
#[test]
fn test_chain_depth_cap_truncates() {
let (env, client) = setup();
let creator = Address::generate(&env);
let a = mk(&env, &client, &creator);
let b = mk(&env, &client, &creator);
let c = mk(&env, &client, &creator);
client.set_route(&creator, &a, &b);
client.set_route(&creator, &b, &c);
assert_eq!(client.resolve_route_chain(&a, &1u32), b);
assert_eq!(client.get_route_chain_len(&a), 2u32);
}
#[test]
fn test_chain_overwrite_latest_wins() {
let (env, client) = setup();
let creator = Address::generate(&env);
let a = mk(&env, &client, &creator);
let b = mk(&env, &client, &creator);
let c = mk(&env, &client, &creator);
client.set_route(&creator, &a, &b);
client.set_route(&creator, &a, &c);
assert_eq!(client.resolve_route(&a), c);
assert_eq!(client.resolve_route_chain(&a, &5u32), c);
}
}
Loading