From 1ab23530cf6312a55bdd4420f6157705c7a0f719 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:04:25 +0100 Subject: [PATCH 1/6] feat(contract): add resolve_route_chain bounded follower --- contracts/sharpy/src/lib.rs | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index e631adc..a4c5f99 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -1534,6 +1534,55 @@ 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 = Vec::new(&env); + visited.push_back(cur); + while seen < max_depth { + let next: Option = 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 + } + + /// 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 = Vec::new(&env); + visited.push_back(cur); + loop { + if len >= 32 { break; } + let next: Option = 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 { env.storage().persistent().get::<(Symbol,u64), StreamingState>(&streaming_key(invoice_id)) } From 3b695bcf64dcfb01ba99d06bbcd1f4da18095d75 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:04:25 +0100 Subject: [PATCH 2/6] docs(contract): clarify route chain depth pairing --- contracts/sharpy/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index a4c5f99..51464fb 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -1560,6 +1560,8 @@ impl SharpyContract { 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 { From ccb40a25f484f9f535e8634b2f4cf4a3b4d89d68 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:04:33 +0100 Subject: [PATCH 3/6] test(contract): add route chain identity and single-hop cases --- contracts/sharpy/src/test.rs | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index a78d7da..1697c37 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5353,3 +5353,46 @@ 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); + } +} From 2028440ae791ea49ac5471b6a090d56b490077a2 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:04:33 +0100 Subject: [PATCH 4/6] test(contract): add route chain multi-hop and depth-zero cases --- contracts/sharpy/src/test.rs | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 1697c37..b8f8388 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5396,3 +5396,50 @@ mod test_route_chain_a { 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); + } +} From dea2327b9c5ab218106e82d811faf7cf98c91d67 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:04:38 +0100 Subject: [PATCH 5/6] test(contract): add route chain depth-cap and overwrite cases --- contracts/sharpy/src/test.rs | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index b8f8388..5818657 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5443,3 +5443,52 @@ mod test_route_chain_b { 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); + } +} From 883816945925d195b505783253f2dee115e8f988 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:05:02 +0100 Subject: [PATCH 6/6] docs(changelog): record route chain depth views for #192 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4657f..35e2c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)