From 5d4d5608fb4cc3b0babf1ea6b3167c3307b8ad1e Mon Sep 17 00:00:00 2001 From: Opulence Chuks Date: Fri, 17 Jul 2026 14:19:30 +0100 Subject: [PATCH 1/2] feat(splitter): implement bounded cascade distribution helper Draining a nested split tree currently needs one distribute call per node. This change introduces a `distribute_cascade` function that distributes a parent split and recursively distributes any freshly-credited direct children (and their children, etc.) in a single call. The cascade is bounded by a `max_depth` parameter up to `MAX_CASCADE_DEPTH` (5) to protect against Soroban gas limit exhaustion. A private helper `distribute_node` is extracted to reuse the balance-draining logic. Unit tests cover a two-level tree, depth bounds, and maximum depth limits. --- contracts/splitter/src/lib.rs | 124 +++++++++++++++++++++++++-------- contracts/splitter/src/test.rs | 99 ++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 29 deletions(-) diff --git a/contracts/splitter/src/lib.rs b/contracts/splitter/src/lib.rs index 8710baf..3ebd981 100644 --- a/contracts/splitter/src/lib.rs +++ b/contracts/splitter/src/lib.rs @@ -19,6 +19,7 @@ contractmeta!( pub const TOTAL_SHARES: u32 = 10_000; pub const MAX_RECIPIENTS: u32 = 32; +pub const MAX_CASCADE_DEPTH: u32 = 5; const DAY_LEDGERS: u32 = 17_280; const TTL_THRESHOLD: u32 = 30 * DAY_LEDGERS; @@ -69,6 +70,8 @@ pub enum Error { /// `validate` forbids, but we surface it as a typed error rather than panic. ArithmeticOverflow = 11, SplitHasBalance = 12, + /// Code 13. The cascade depth exceeds the maximum allowed limit. + MaxDepthExceeded = 13, } #[contracttype] @@ -372,35 +375,7 @@ impl Splitter { /// Pays out everything credited to the split for the given token. /// Anyone can call this; the routing table decides where funds go. pub fn distribute(env: Env, id: u64, token: Address) -> Result { - let split = load(&env, id)?; - let key = DataKey::Balance(id, token.clone()); - let amount: i128 = env.storage().persistent().get(&key).unwrap_or(0); - if amount <= 0 { - return Err(Error::NothingToDistribute); - } - env.storage().persistent().remove(&key); - - let tokens_key = DataKey::HeldTokens(id); - if let Some(mut tokens) = env - .storage() - .persistent() - .get::<_, Vec
>(&tokens_key) - { - if let Some(idx) = tokens.first_index_of(&token) { - tokens.remove(idx); - if tokens.is_empty() { - env.storage().persistent().remove(&tokens_key); - } else { - env.storage().persistent().set(&tokens_key, &tokens); - env.storage().persistent().extend_ttl( - &tokens_key, - TTL_THRESHOLD, - TTL_EXTEND_TO, - ); - } - } - } - + let (split, amount) = distribute_node(&env, id, &token)?; payout( &env, &split, @@ -412,6 +387,25 @@ impl Splitter { Ok(amount) } + /// Distributes a parent split and recursively distributes any freshly-credited + /// direct children (and their children, etc.) in one call, bounded by `max_depth`. + /// + /// Depth Bound & Gas: + /// - Each level of recursion increases the depth. A `max_depth` of 0 only distributes the parent. + /// - Each distribution load/writes to persistent storage and does token transfers. + /// - To prevent out-of-gas errors or stack overflow, `max_depth` must be limited to `MAX_CASCADE_DEPTH` (5). + pub fn distribute_cascade( + env: Env, + id: u64, + token: Address, + max_depth: u32, + ) -> Result { + if max_depth > MAX_CASCADE_DEPTH { + return Err(Error::MaxDepthExceeded); + } + distribute_recursive(&env, id, &token, 0, max_depth) + } + /// Returns the exact per-recipient amounts a payment of `amount` would /// produce, without moving any funds. pub fn preview_payout(env: Env, id: u64, amount: i128) -> Result, Error> { @@ -603,5 +597,77 @@ fn load(env: &Env, id: u64) -> Result { Ok(split) } +fn distribute_node(env: &Env, id: u64, token: &Address) -> Result<(Split, i128), Error> { + let split = load(env, id)?; + let key = DataKey::Balance(id, token.clone()); + let amount: i128 = env.storage().persistent().get(&key).unwrap_or(0); + if amount <= 0 { + return Err(Error::NothingToDistribute); + } + env.storage().persistent().remove(&key); + + let tokens_key = DataKey::HeldTokens(id); + if let Some(mut tokens) = env + .storage() + .persistent() + .get::<_, Vec
>(&tokens_key) + { + if let Some(idx) = tokens.first_index_of(token) { + tokens.remove(idx); + if tokens.is_empty() { + env.storage().persistent().remove(&tokens_key); + } else { + env.storage().persistent().set(&tokens_key, &tokens); + env.storage() + .persistent() + .extend_ttl(&tokens_key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + } + } + Ok((split, amount)) +} + +fn distribute_recursive( + env: &Env, + id: u64, + token: &Address, + current_depth: u32, + max_depth: u32, +) -> Result { + let (split, amount) = match distribute_node(env, id, token) { + Ok(res) => res, + Err(Error::NothingToDistribute) => { + if current_depth == 0 { + return Err(Error::NothingToDistribute); + } else { + return Ok(0); + } + } + Err(e) => return Err(e), + }; + + payout(env, &split, &env.current_contract_address(), token, amount); + Distributed { + id, + token: token.clone(), + amount, + } + .publish(env); + + if current_depth < max_depth { + let parts = amounts(env, &split, amount).unwrap_or_else(|_| Vec::new(env)); + for i in 0..split.recipients.len() { + let part = parts.get_unchecked(i); + if part > 0 { + if let Recipient::Split(child_id) = split.recipients.get_unchecked(i) { + distribute_recursive(env, child_id, token, current_depth + 1, max_depth)?; + } + } + } + } + + Ok(amount) +} + #[cfg(test)] mod test; diff --git a/contracts/splitter/src/test.rs b/contracts/splitter/src/test.rs index 151221c..414ceb5 100644 --- a/contracts/splitter/src/test.rs +++ b/contracts/splitter/src/test.rs @@ -1004,3 +1004,102 @@ fn immutable_split_cannot_be_updated() { .try_update_split(&id, &vec![&s.env, acct(&b)], &vec![&s.env, 10_000]); assert_eq!(result, Err(Ok(Error::SplitImmutable))); } + +#[test] +fn distribute_cascade_basic() { + let s = setup(); + let creator = Address::generate(&s.env); + let leaf_a = Address::generate(&s.env); + let leaf_b = Address::generate(&s.env); + let direct = Address::generate(&s.env); + let payer = Address::generate(&s.env); + let (token_id, token_client) = fund_token(&s.env, &payer, 10_000); + + let child = s.client.create_split( + &creator, + &vec![&s.env, acct(&leaf_a), acct(&leaf_b)], + &vec![&s.env, 5_000, 5_000], + &None, + ); + let parent = s.client.create_split( + &creator, + &vec![&s.env, acct(&direct), Recipient::Split(child)], + &vec![&s.env, 6_000, 4_000], + &None, + ); + + // Pay parent so it has 1_000 tokens deposited + s.client.deposit(&payer, &parent, &token_id, &1_000); + + // Distribute with depth=1 (meaning parent and direct children) + let amount = s.client.distribute_cascade(&parent, &token_id, &1); + assert_eq!(amount, 1_000); + + // direct gets 60% of 1_000 = 600 + assert_eq!(token_client.balance(&direct), 600); + // leaf_a gets 50% of 400 = 200 + assert_eq!(token_client.balance(&leaf_a), 200); + // leaf_b gets 50% of 400 = 200 + assert_eq!(token_client.balance(&leaf_b), 200); + + // Everything in parent and child is distributed, no balance left in contract + assert_eq!(s.client.balance(&parent, &token_id), 0); + assert_eq!(s.client.balance(&child, &token_id), 0); + assert_eq!(token_client.balance(&s.client.address), 0); +} + +#[test] +fn distribute_cascade_depth_0() { + let s = setup(); + let creator = Address::generate(&s.env); + let leaf_a = Address::generate(&s.env); + let leaf_b = Address::generate(&s.env); + let direct = Address::generate(&s.env); + let payer = Address::generate(&s.env); + let (token_id, token_client) = fund_token(&s.env, &payer, 10_000); + + let child = s.client.create_split( + &creator, + &vec![&s.env, acct(&leaf_a), acct(&leaf_b)], + &vec![&s.env, 5_000, 5_000], + &None, + ); + let parent = s.client.create_split( + &creator, + &vec![&s.env, acct(&direct), Recipient::Split(child)], + &vec![&s.env, 6_000, 4_000], + &None, + ); + + s.client.deposit(&payer, &parent, &token_id, &1_000); + + // Distribute with depth=0 (no cascade to children) + let amount = s.client.distribute_cascade(&parent, &token_id, &0); + assert_eq!(amount, 1_000); + + assert_eq!(token_client.balance(&direct), 600); + // Child gets credited but NOT distributed because depth=0 + assert_eq!(s.client.balance(&child, &token_id), 400); + assert_eq!(token_client.balance(&leaf_a), 0); + assert_eq!(token_client.balance(&leaf_b), 0); +} + +#[test] +fn distribute_cascade_exceeds_max_depth() { + let s = setup(); + let creator = Address::generate(&s.env); + let a = Address::generate(&s.env); + let payer = Address::generate(&s.env); + let (token_id, _) = fund_token(&s.env, &payer, 1_000); + + let id = s.client.create_split( + &creator, + &vec![&s.env, acct(&a)], + &vec![&s.env, 10_000], + &None, + ); + + // MAX_CASCADE_DEPTH is 5, so depth 6 should fail + let result = s.client.try_distribute_cascade(&id, &token_id, &6); + assert_eq!(result, Err(Ok(Error::MaxDepthExceeded))); +} From d9ae4eeaa4e236e8bd3193a8b615d568a550e834 Mon Sep 17 00:00:00 2001 From: Opulence Chuks Date: Fri, 17 Jul 2026 16:57:53 +0100 Subject: [PATCH 2/2] feat(sdk): add typed event decoders for contract events --- sdk/package.json | 1 + sdk/src/index.test.ts | 148 +++++++++++++++++++++++++++++++++++++ sdk/src/index.ts | 168 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 sdk/src/index.test.ts diff --git a/sdk/package.json b/sdk/package.json index 42690bb..55c62f3 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -19,6 +19,7 @@ }, "scripts": { "build": "tsc", + "test": "node --test dist/index.test.js", "prepublishOnly": "npm run build" }, "dependencies": { diff --git a/sdk/src/index.test.ts b/sdk/src/index.test.ts new file mode 100644 index 0000000..bc3447e --- /dev/null +++ b/sdk/src/index.test.ts @@ -0,0 +1,148 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { decodeEvent } from "./index.js"; +import { nativeToScVal, xdr } from "@stellar/stellar-sdk"; + +test("decodeEvent decodes SplitCreated event from ScVal", () => { + const topic = [ + nativeToScVal("SplitCreated"), + nativeToScVal(123n), + ]; + const value = nativeToScVal({ + creator: "GBXXXTST12345", + }); + + const decoded = decodeEvent({ topic, value }); + assert.deepEqual(decoded, { + type: "SplitCreated", + id: 123n, + creator: "GBXXXTST12345", + }); +}); + +test("decodeEvent decodes SplitPaid event from base64 strings", () => { + const topic = [ + nativeToScVal("SplitPaid").toXDR("base64"), + nativeToScVal(456n).toXDR("base64"), + ]; + const value = nativeToScVal({ + token: "GATOKEN12345", + amount: 10000000n, + }).toXDR("base64"); + + const decoded = decodeEvent({ topic, value }); + assert.deepEqual(decoded, { + type: "SplitPaid", + id: 456n, + token: "GATOKEN12345", + amount: 10000000n, + }); +}); + +test("decodeEvent decodes SplitUpdated event with wrapper object", () => { + const topic = [ + nativeToScVal("SplitUpdated"), + nativeToScVal(789n), + ]; + const value = { + xdr: nativeToScVal(null).toXDR("base64"), + }; + + const decoded = decodeEvent({ topic, value }); + assert.deepEqual(decoded, { + type: "SplitUpdated", + id: 789n, + }); +}); + +test("decodeEvent decodes SplitClosed event", () => { + const topic = [ + nativeToScVal("SplitClosed"), + nativeToScVal(101n), + ]; + const value = nativeToScVal(null); + + const decoded = decodeEvent({ topic, value }); + assert.deepEqual(decoded, { + type: "SplitClosed", + id: 101n, + }); +}); + +test("decodeEvent decodes ControlTransferred event with new controller address", () => { + const topic = [ + nativeToScVal("ControlTransferred"), + nativeToScVal(202n), + ]; + const value = nativeToScVal({ + new_controller: "GNEWCONTROLLER123", + }); + + const decoded = decodeEvent({ topic, value }); + assert.deepEqual(decoded, { + type: "ControlTransferred", + id: 202n, + new_controller: "GNEWCONTROLLER123", + }); +}); + +test("decodeEvent decodes ControlTransferred event with null new controller", () => { + const topic = [ + nativeToScVal("ControlTransferred"), + nativeToScVal(202n), + ]; + const value = nativeToScVal({ + new_controller: null, + }); + + const decoded = decodeEvent({ topic, value }); + assert.deepEqual(decoded, { + type: "ControlTransferred", + id: 202n, + new_controller: null, + }); +}); + +test("decodeEvent decodes Deposited event", () => { + const topic = [ + nativeToScVal("Deposited"), + nativeToScVal(303n), + ]; + const value = nativeToScVal({ + token: "GDEPOSITTOKEN", + amount: 5000n, + }); + + const decoded = decodeEvent({ topic, value }); + assert.deepEqual(decoded, { + type: "Deposited", + id: 303n, + token: "GDEPOSITTOKEN", + amount: 5000n, + }); +}); + +test("decodeEvent decodes Distributed event", () => { + const topic = [ + nativeToScVal("Distributed"), + nativeToScVal(404n), + ]; + const value = nativeToScVal({ + token: "GDISTRIBUTETOKEN", + amount: 9999n, + }); + + const decoded = decodeEvent({ topic, value }); + assert.deepEqual(decoded, { + type: "Distributed", + id: 404n, + token: "GDISTRIBUTETOKEN", + amount: 9999n, + }); +}); + +test("decodeEvent returns null for invalid or unrecognized events", () => { + assert.equal(decodeEvent(null as any), null); + assert.equal(decodeEvent({ topic: [], value: null }), null); + assert.equal(decodeEvent({ topic: [nativeToScVal("UnknownEvent"), nativeToScVal(1n)], value: null }), null); +}); diff --git a/sdk/src/index.ts b/sdk/src/index.ts index bc84254..1f25764 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -1,5 +1,5 @@ import { Buffer } from "buffer"; -import { Address } from "@stellar/stellar-sdk"; +import { Address, xdr, scValToNative } from "@stellar/stellar-sdk"; import { AssembledTransaction, Client as ContractClient, @@ -242,4 +242,170 @@ export async function waitForConfirmation( throw new Error( `Transaction ${txHash} was not confirmed within ${timeout / 1_000}s`, ); +} + +export interface SplitCreatedEvent { + type: "SplitCreated"; + id: bigint; + creator: string; +} + +export interface SplitPaidEvent { + type: "SplitPaid"; + id: bigint; + token: string; + amount: bigint; +} + +export interface SplitUpdatedEvent { + type: "SplitUpdated"; + id: bigint; +} + +export interface SplitClosedEvent { + type: "SplitClosed"; + id: bigint; +} + +export interface ControlTransferredEvent { + type: "ControlTransferred"; + id: bigint; + new_controller: string | null; +} + +export interface DepositedEvent { + type: "Deposited"; + id: bigint; + token: string; + amount: bigint; +} + +export interface DistributedEvent { + type: "Distributed"; + id: bigint; + token: string; + amount: bigint; +} + +export type ContractEvent = + | SplitCreatedEvent + | SplitPaidEvent + | SplitUpdatedEvent + | SplitClosedEvent + | ControlTransferredEvent + | DepositedEvent + | DistributedEvent; + +function parseScVal(val: any): any { + if (typeof val === "string") { + try { + return xdr.ScVal.fromXDR(val, "base64"); + } catch { + return val; + } + } + if (val && typeof val === "object") { + if (typeof val.xdr === "string") { + try { + return xdr.ScVal.fromXDR(val.xdr, "base64"); + } catch { + return val; + } + } + if (typeof val.toXDR === "function") { + return val; + } + } + return val; +} + +/** + * Decodes a raw contract event from RPC or indexer into a typed event object. + * Returns null if the event is not a recognized contract event or cannot be parsed. + */ +export function decodeEvent(event: { + topic: ReadonlyArray; + value: any; +}): ContractEvent | null { + if (!event || !Array.isArray(event.topic) || event.topic.length === 0) { + return null; + } + + try { + const parsedTopics = event.topic.map(t => parseScVal(t)); + const parsedValue = parseScVal(event.value); + + const nativeTopics = parsedTopics.map(t => { + if (t && typeof t.toXDR === "function") { + return scValToNative(t); + } + return t; + }); + + const type = nativeTopics[0]; + if (typeof type !== "string") { + return null; + } + + const idVal = nativeTopics[1]; + if (idVal === undefined || idVal === null) { + return null; + } + const id = typeof idVal === "bigint" ? idVal : BigInt(idVal); + + let nativeValue = parsedValue; + if (parsedValue && typeof parsedValue.toXDR === "function") { + nativeValue = scValToNative(parsedValue); + } + + switch (type) { + case "SplitCreated": + return { + type: "SplitCreated", + id, + creator: nativeValue.creator, + }; + case "SplitPaid": + return { + type: "SplitPaid", + id, + token: nativeValue.token, + amount: typeof nativeValue.amount === "bigint" ? nativeValue.amount : BigInt(nativeValue.amount), + }; + case "SplitUpdated": + return { + type: "SplitUpdated", + id, + }; + case "SplitClosed": + return { + type: "SplitClosed", + id, + }; + case "ControlTransferred": + return { + type: "ControlTransferred", + id, + new_controller: nativeValue.new_controller ?? null, + }; + case "Deposited": + return { + type: "Deposited", + id, + token: nativeValue.token, + amount: typeof nativeValue.amount === "bigint" ? nativeValue.amount : BigInt(nativeValue.amount), + }; + case "Distributed": + return { + type: "Distributed", + id, + token: nativeValue.token, + amount: typeof nativeValue.amount === "bigint" ? nativeValue.amount : BigInt(nativeValue.amount), + }; + default: + return null; + } + } catch (err) { + return null; + } } \ No newline at end of file