From 0eda1b377868c72ea6e480246cefec62cd9870fd Mon Sep 17 00:00:00 2001 From: Justjoseph0 Date: Sat, 18 Jul 2026 02:21:53 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(splitter):=20add=20optional=20payment?= =?UTF-8?q?=20reference=20to=20pay,=20pay=5Fmany,=20pay=5Fmany=5FmultiInte?= =?UTF-8?q?grators=20had=20no=20way=20to=20tag=20a=20payment=20with=20an?= =?UTF-8?q?=20external=20id=20(order/invoice)for=20reconciliation.=20Adds?= =?UTF-8?q?=20an=20optional=20reference:=20Option>=20to=20pay,a?= =?UTF-8?q?nd=20references:=20Vec>>=20to=20pay=5Fmany/pa?= =?UTF-8?q?y=5Fmany=5Fmulti=20(emptyvec=20means=20no=20reference=20for=20a?= =?UTF-8?q?ny=20split=20in=20the=20batch,=20non-empty=20must=20matchids.le?= =?UTF-8?q?n()=20exactly=20or=20returns=20LengthMismatch).Carried=20throug?= =?UTF-8?q?h=20into=20the=20SplitPaid=20event=20as=20a=20plain=20data=20fi?= =?UTF-8?q?eld=20(not=20atopic,=20to=20avoid=20spending=20a=20topic=20slot?= =?UTF-8?q?).=20Indexer=20and=20export-csv.mjs=20updatedto=20decode=20and?= =?UTF-8?q?=20surface=20it.=20SDK=20regenerated=20via=20the=20stellar=20CL?= =?UTF-8?q?I=20and=20exposesreference=20as=20an=20optional=20argument=20on?= =?UTF-8?q?=20all=20three=20functions.deposit()=20intentionally=20left=20u?= =?UTF-8?q?ntouched=20=E2=80=94=20separate=20design=20question,=20outof=20?= =?UTF-8?q?scope=20here.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/splitter/src/lib.rs | 45 +++- contracts/splitter/src/test.rs | 425 +++++++++++++++++++++++++++++++-- indexer/export-csv.mjs | 2 +- indexer/index.mjs | 11 +- sdk/src/index.ts | 140 +++++++++-- 5 files changed, 588 insertions(+), 35 deletions(-) diff --git a/contracts/splitter/src/lib.rs b/contracts/splitter/src/lib.rs index 8710baf..f757117 100644 --- a/contracts/splitter/src/lib.rs +++ b/contracts/splitter/src/lib.rs @@ -8,7 +8,7 @@ use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contractmeta, contracttype, token, - Address, Env, Vec, I256, + Address, BytesN, Env, Vec, I256, }; contractmeta!(key = "name", val = "tributary-splitter"); @@ -111,6 +111,10 @@ pub struct SplitPaid { pub id: u64, pub token: Address, pub amount: i128, + /// Optional caller-supplied tag (e.g. an order or invoice id) so + /// integrators can reconcile a payment against their own records. + /// Not a topic: it rides along as data and never costs a topic slot. + pub reference: Option>, } #[contractevent] @@ -203,6 +207,7 @@ impl Splitter { id: u64, token: Address, amount: i128, + reference: Option>, ) -> Result<(), Error> { from.require_auth(); if amount <= 0 { @@ -210,18 +215,29 @@ impl Splitter { } let split = load(&env, id)?; payout(&env, &split, &from, &token, amount); - SplitPaid { id, token, amount }.publish(&env); + SplitPaid { + id, + token, + amount, + reference, + } + .publish(&env); Ok(()) } /// Pays several splits from one signer in a single transaction. /// `ids` and `amounts` pair up positionally; any failure reverts all. + /// + /// `references` optionally tags each split's payment for reconciliation + /// and pairs up positionally too. An empty `references` vec means "no + /// reference for any split"; otherwise it must match `ids.len()` exactly. pub fn pay_many( env: Env, from: Address, ids: Vec, amounts: Vec, token: Address, + references: Vec>>, ) -> Result<(), Error> { from.require_auth(); if ids.is_empty() { @@ -230,6 +246,9 @@ impl Splitter { if ids.len() != amounts.len() { return Err(Error::LengthMismatch); } + if !references.is_empty() && references.len() != ids.len() { + return Err(Error::LengthMismatch); + } for amount in amounts.iter() { if amount <= 0 { return Err(Error::InvalidAmount); @@ -244,6 +263,7 @@ impl Splitter { id, token: token.clone(), amount, + reference: reference_at(&references, i), } .publish(&env); } @@ -253,12 +273,17 @@ impl Splitter { /// Pays several splits from one signer in a single transaction, each /// with its own token. `ids`, `amounts`, and `tokens` pair up /// positionally; any failure reverts all. + /// + /// `references` optionally tags each split's payment for reconciliation + /// and pairs up positionally too. An empty `references` vec means "no + /// reference for any split"; otherwise it must match `ids.len()` exactly. pub fn pay_many_multi( env: Env, from: Address, ids: Vec, amounts: Vec, tokens: Vec
, + references: Vec>>, ) -> Result<(), Error> { from.require_auth(); if ids.is_empty() { @@ -267,6 +292,9 @@ impl Splitter { if ids.len() != amounts.len() || ids.len() != tokens.len() { return Err(Error::LengthMismatch); } + if !references.is_empty() && references.len() != ids.len() { + return Err(Error::LengthMismatch); + } for amount in amounts.iter() { if amount <= 0 { return Err(Error::InvalidAmount); @@ -282,6 +310,7 @@ impl Splitter { id, token: token.clone(), amount, + reference: reference_at(&references, i), } .publish(&env); } @@ -482,6 +511,18 @@ impl Splitter { } } +/// Picks the reference for the split at index `i` in a batch. An empty +/// `references` vec means "no reference for any split", so every index +/// resolves to `None` without a bounds check. Callers guarantee a non-empty +/// vec matches the batch length, so `get_unchecked` is safe. +fn reference_at(references: &Vec>>, i: u32) -> Option> { + if references.is_empty() { + None + } else { + references.get_unchecked(i) + } +} + fn validate( env: &Env, own_id: u64, diff --git a/contracts/splitter/src/test.rs b/contracts/splitter/src/test.rs index 151221c..9d17df4 100644 --- a/contracts/splitter/src/test.rs +++ b/contracts/splitter/src/test.rs @@ -243,7 +243,7 @@ fn pay_distributes_by_shares() { &None, ); - s.client.pay(&payer, &id, &token_id, &100_000); + s.client.pay(&payer, &id, &token_id, &100_000, &None); let expected_paid = expected_event( &s.env, @@ -253,6 +253,7 @@ fn pay_distributes_by_shares() { &[ ("token", token_id.clone().into_val(&s.env)), ("amount", 100_000i128.into_val(&s.env)), + ("reference", None::>.into_val(&s.env)), ], ); assert_eq!( @@ -283,7 +284,7 @@ fn rounding_dust_goes_to_last_recipient() { &None, ); - s.client.pay(&payer, &id, &token_id, &100); + s.client.pay(&payer, &id, &token_id, &100, &None); assert_eq!(token_client.balance(&a), 33); assert_eq!(token_client.balance(&b), 33); @@ -311,7 +312,7 @@ fn preview_matches_actual_payout() { let preview = s.client.preview_payout(&id, &1_000); assert_eq!(preview, vec![&s.env, 333, 333, 334]); - s.client.pay(&payer, &id, &token_id, &1_000); + s.client.pay(&payer, &id, &token_id, &1_000, &None); assert_eq!(token_client.balance(&a), preview.get_unchecked(0)); assert_eq!(token_client.balance(&b), preview.get_unchecked(1)); assert_eq!(token_client.balance(&c), preview.get_unchecked(2)); @@ -332,10 +333,10 @@ fn rejects_non_positive_amounts() { &None, ); - let zero = s.client.try_pay(&payer, &id, &token_id, &0); + let zero = s.client.try_pay(&payer, &id, &token_id, &0, &None); assert_eq!(zero, Err(Ok(Error::InvalidAmount))); - let negative = s.client.try_pay(&payer, &id, &token_id, &-5); + let negative = s.client.try_pay(&payer, &id, &token_id, &-5, &None); assert_eq!(negative, Err(Ok(Error::InvalidAmount))); } @@ -366,6 +367,7 @@ fn pay_many_settles_several_splits_at_once() { &vec![&s.env, first, second], &vec![&s.env, 1_000, 2_000], &token_id, + &vec![&s.env], ); assert_eq!(token_client.balance(&a), 2_000); @@ -401,6 +403,7 @@ fn pay_many_multi_settles_mixed_tokens_at_once() { &vec![&s.env, first, second], &vec![&s.env, 1_000, 2_000], &vec![&s.env, token_x.clone(), token_y.clone()], + &vec![&s.env], ); assert_eq!(client_x.balance(&a), 1_000); @@ -430,6 +433,7 @@ fn pay_many_multi_reverts_the_whole_batch_on_failure() { &vec![&s.env, id, 99], &vec![&s.env, 100, 200], &vec![&s.env, token_x.clone(), token_y], + &vec![&s.env], ); assert_eq!(result, Err(Ok(Error::SplitNotFound))); assert_eq!(client_x.balance(&a), 0); @@ -451,9 +455,9 @@ fn pay_many_rejects_bad_batches() { &None, ); - let empty = s - .client - .try_pay_many(&payer, &vec![&s.env], &vec![&s.env], &token_id); + let empty = + s.client + .try_pay_many(&payer, &vec![&s.env], &vec![&s.env], &token_id, &vec![&s.env]); assert_eq!(empty, Err(Ok(Error::NoRecipients))); let mismatch = s.client.try_pay_many( @@ -461,6 +465,7 @@ fn pay_many_rejects_bad_batches() { &vec![&s.env, id], &vec![&s.env, 100, 200], &token_id, + &vec![&s.env], ); assert_eq!(mismatch, Err(Ok(Error::LengthMismatch))); @@ -469,11 +474,400 @@ fn pay_many_rejects_bad_batches() { &vec![&s.env, id, 99], &vec![&s.env, 100, 200], &token_id, + &vec![&s.env], ); assert_eq!(unknown, Err(Ok(Error::SplitNotFound))); assert_eq!(token_client.balance(&a), 0); } +// Builds a deterministic 32-byte reference from a single seed byte, so tests +// can tell one payment tag apart from another. +fn reference(env: &Env, seed: u8) -> BytesN<32> { + BytesN::from_array(env, &[seed; 32]) +} + +#[test] +fn pay_carries_a_reference_through_to_the_event() { + let s = setup(); + let creator = Address::generate(&s.env); + let a = Address::generate(&s.env); + let payer = Address::generate(&s.env); + let (token_id, token_client) = 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, + ); + + let ref_1 = reference(&s.env, 0xAB); + s.client + .pay(&payer, &id, &token_id, &500, &Some(ref_1.clone())); + + let expected_paid = expected_event( + &s.env, + &s.client.address, + "split_paid", + id, + &[ + ("token", token_id.clone().into_val(&s.env)), + ("amount", 500i128.into_val(&s.env)), + ("reference", Some(ref_1).into_val(&s.env)), + ], + ); + assert_eq!( + s.env.events().all().filter_by_contract(&s.client.address), + soroban_sdk::vec![&s.env, expected_paid] + ); + + // Funds still route exactly as before. + assert_eq!(token_client.balance(&a), 500); +} + +#[test] +fn pay_without_a_reference_emits_none() { + let s = setup(); + let creator = Address::generate(&s.env); + let a = Address::generate(&s.env); + let payer = Address::generate(&s.env); + let (token_id, token_client) = 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, + ); + + s.client.pay(&payer, &id, &token_id, &500, &None); + + let expected_paid = expected_event( + &s.env, + &s.client.address, + "split_paid", + id, + &[ + ("token", token_id.clone().into_val(&s.env)), + ("amount", 500i128.into_val(&s.env)), + ("reference", None::>.into_val(&s.env)), + ], + ); + assert_eq!( + s.env.events().all().filter_by_contract(&s.client.address), + soroban_sdk::vec![&s.env, expected_paid] + ); + + assert_eq!(token_client.balance(&a), 500); +} + +#[test] +fn pay_many_tags_each_split_with_its_own_reference() { + 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, 10_000); + + let first = s.client.create_split( + &creator, + &vec![&s.env, acct(&a)], + &vec![&s.env, 10_000], + &None, + ); + let second = s.client.create_split( + &creator, + &vec![&s.env, acct(&a)], + &vec![&s.env, 10_000], + &None, + ); + + let ref_1 = reference(&s.env, 0x11); + let ref_2 = reference(&s.env, 0x22); + s.client.pay_many( + &payer, + &vec![&s.env, first, second], + &vec![&s.env, 1_000, 2_000], + &token_id, + &vec![&s.env, Some(ref_1.clone()), Some(ref_2.clone())], + ); + + let expected_first = expected_event( + &s.env, + &s.client.address, + "split_paid", + first, + &[ + ("token", token_id.clone().into_val(&s.env)), + ("amount", 1_000i128.into_val(&s.env)), + ("reference", Some(ref_1).into_val(&s.env)), + ], + ); + let expected_second = expected_event( + &s.env, + &s.client.address, + "split_paid", + second, + &[ + ("token", token_id.clone().into_val(&s.env)), + ("amount", 2_000i128.into_val(&s.env)), + ("reference", Some(ref_2).into_val(&s.env)), + ], + ); + assert_eq!( + s.env.events().all().filter_by_contract(&s.client.address), + soroban_sdk::vec![&s.env, expected_first, expected_second] + ); +} + +#[test] +fn pay_many_with_empty_references_emits_none_for_every_split() { + 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, 10_000); + + let first = s.client.create_split( + &creator, + &vec![&s.env, acct(&a)], + &vec![&s.env, 10_000], + &None, + ); + let second = s.client.create_split( + &creator, + &vec![&s.env, acct(&a)], + &vec![&s.env, 10_000], + &None, + ); + + // Empty references vec: no length check, every event carries None. + s.client.pay_many( + &payer, + &vec![&s.env, first, second], + &vec![&s.env, 1_000, 2_000], + &token_id, + &vec![&s.env], + ); + + let expected_first = expected_event( + &s.env, + &s.client.address, + "split_paid", + first, + &[ + ("token", token_id.clone().into_val(&s.env)), + ("amount", 1_000i128.into_val(&s.env)), + ("reference", None::>.into_val(&s.env)), + ], + ); + let expected_second = expected_event( + &s.env, + &s.client.address, + "split_paid", + second, + &[ + ("token", token_id.clone().into_val(&s.env)), + ("amount", 2_000i128.into_val(&s.env)), + ("reference", None::>.into_val(&s.env)), + ], + ); + assert_eq!( + s.env.events().all().filter_by_contract(&s.client.address), + soroban_sdk::vec![&s.env, expected_first, expected_second] + ); +} + +#[test] +fn pay_many_rejects_a_mismatched_references_length() { + let s = setup(); + let creator = Address::generate(&s.env); + let a = Address::generate(&s.env); + let payer = Address::generate(&s.env); + let (token_id, token_client) = fund_token(&s.env, &payer, 10_000); + + let first = s.client.create_split( + &creator, + &vec![&s.env, acct(&a)], + &vec![&s.env, 10_000], + &None, + ); + let second = s.client.create_split( + &creator, + &vec![&s.env, acct(&a)], + &vec![&s.env, 10_000], + &None, + ); + + // Non-empty but wrong length (1 reference for 2 splits) is rejected. + let result = s.client.try_pay_many( + &payer, + &vec![&s.env, first, second], + &vec![&s.env, 1_000, 2_000], + &token_id, + &vec![&s.env, Some(reference(&s.env, 0x11))], + ); + assert_eq!(result, Err(Ok(Error::LengthMismatch))); + // Whole batch reverted; nothing moved. + assert_eq!(token_client.balance(&a), 0); + assert_eq!(token_client.balance(&payer), 10_000); +} + +#[test] +fn pay_many_multi_tags_each_split_with_its_own_reference() { + let s = setup(); + let creator = Address::generate(&s.env); + let a = Address::generate(&s.env); + let b = Address::generate(&s.env); + let payer = Address::generate(&s.env); + let (token_x, _) = fund_token(&s.env, &payer, 10_000); + let (token_y, _) = fund_token(&s.env, &payer, 10_000); + + let first = s.client.create_split( + &creator, + &vec![&s.env, acct(&a)], + &vec![&s.env, 10_000], + &None, + ); + let second = s.client.create_split( + &creator, + &vec![&s.env, acct(&b)], + &vec![&s.env, 10_000], + &None, + ); + + let ref_1 = reference(&s.env, 0x33); + let ref_2 = reference(&s.env, 0x44); + s.client.pay_many_multi( + &payer, + &vec![&s.env, first, second], + &vec![&s.env, 1_000, 2_000], + &vec![&s.env, token_x.clone(), token_y.clone()], + &vec![&s.env, Some(ref_1.clone()), Some(ref_2.clone())], + ); + + let expected_first = expected_event( + &s.env, + &s.client.address, + "split_paid", + first, + &[ + ("token", token_x.clone().into_val(&s.env)), + ("amount", 1_000i128.into_val(&s.env)), + ("reference", Some(ref_1).into_val(&s.env)), + ], + ); + let expected_second = expected_event( + &s.env, + &s.client.address, + "split_paid", + second, + &[ + ("token", token_y.clone().into_val(&s.env)), + ("amount", 2_000i128.into_val(&s.env)), + ("reference", Some(ref_2).into_val(&s.env)), + ], + ); + assert_eq!( + s.env.events().all().filter_by_contract(&s.client.address), + soroban_sdk::vec![&s.env, expected_first, expected_second] + ); +} + +#[test] +fn pay_many_multi_with_empty_references_emits_none_for_every_split() { + let s = setup(); + let creator = Address::generate(&s.env); + let a = Address::generate(&s.env); + let b = Address::generate(&s.env); + let payer = Address::generate(&s.env); + let (token_x, _) = fund_token(&s.env, &payer, 10_000); + let (token_y, _) = fund_token(&s.env, &payer, 10_000); + + let first = s.client.create_split( + &creator, + &vec![&s.env, acct(&a)], + &vec![&s.env, 10_000], + &None, + ); + let second = s.client.create_split( + &creator, + &vec![&s.env, acct(&b)], + &vec![&s.env, 10_000], + &None, + ); + + s.client.pay_many_multi( + &payer, + &vec![&s.env, first, second], + &vec![&s.env, 1_000, 2_000], + &vec![&s.env, token_x.clone(), token_y.clone()], + &vec![&s.env], + ); + + let expected_first = expected_event( + &s.env, + &s.client.address, + "split_paid", + first, + &[ + ("token", token_x.clone().into_val(&s.env)), + ("amount", 1_000i128.into_val(&s.env)), + ("reference", None::>.into_val(&s.env)), + ], + ); + let expected_second = expected_event( + &s.env, + &s.client.address, + "split_paid", + second, + &[ + ("token", token_y.clone().into_val(&s.env)), + ("amount", 2_000i128.into_val(&s.env)), + ("reference", None::>.into_val(&s.env)), + ], + ); + assert_eq!( + s.env.events().all().filter_by_contract(&s.client.address), + soroban_sdk::vec![&s.env, expected_first, expected_second] + ); +} + +#[test] +fn pay_many_multi_rejects_a_mismatched_references_length() { + let s = setup(); + let creator = Address::generate(&s.env); + let a = Address::generate(&s.env); + let b = Address::generate(&s.env); + let payer = Address::generate(&s.env); + let (token_x, client_x) = fund_token(&s.env, &payer, 10_000); + let (token_y, _) = fund_token(&s.env, &payer, 10_000); + + let first = s.client.create_split( + &creator, + &vec![&s.env, acct(&a)], + &vec![&s.env, 10_000], + &None, + ); + let second = s.client.create_split( + &creator, + &vec![&s.env, acct(&b)], + &vec![&s.env, 10_000], + &None, + ); + + let result = s.client.try_pay_many_multi( + &payer, + &vec![&s.env, first, second], + &vec![&s.env, 1_000, 2_000], + &vec![&s.env, token_x.clone(), token_y.clone()], + &vec![&s.env, Some(reference(&s.env, 0x33))], + ); + assert_eq!(result, Err(Ok(Error::LengthMismatch))); + assert_eq!(client_x.balance(&a), 0); + assert_eq!(client_x.balance(&payer), 10_000); +} + #[test] fn pay_requires_the_payers_authorization() { let s = setup(); @@ -494,7 +888,7 @@ fn pay_requires_the_payers_authorization() { let result = s.env.try_invoke_contract::<(), Error>( &s.client.address, &soroban_sdk::symbol_short!("pay"), - (&intruder, id, &token_id, 100i128).into_val(&s.env), + (&intruder, id, &token_id, 100i128, None::>).into_val(&s.env), ); assert!(result.is_err()); } @@ -521,7 +915,7 @@ fn conservation_holds_across_share_mixes() { addrs.push_back(addr); } let id = s.client.create_split(&creator, &recipients, &shares, &None); - s.client.pay(&payer, &id, &token_id, &amount); + s.client.pay(&payer, &id, &token_id, &amount, &None); let mut received: i128 = 0; for addr in addrs.iter() { @@ -554,7 +948,7 @@ fn nested_portions_credit_the_child_split() { &None, ); - s.client.pay(&payer, &parent, &token_id, &1_000); + s.client.pay(&payer, &parent, &token_id, &1_000, &None); assert_eq!(token_client.balance(&direct), 600); assert_eq!(s.client.balance(&child, &token_id), 400); @@ -602,7 +996,7 @@ fn pay_unknown_split_fails() { let payer = Address::generate(&s.env); let (token_id, _) = fund_token(&s.env, &payer, 1_000); - let result = s.client.try_pay(&payer, &99, &token_id, &100); + let result = s.client.try_pay(&payer, &99, &token_id, &100, &None); assert_eq!(result, Err(Ok(Error::SplitNotFound))); } @@ -845,7 +1239,7 @@ fn every_error_code_maps_to_its_triggering_call() { // 1 NoRecipients — pay_many with empty ids assert_eq!( s.client - .try_pay_many(&payer, &vec![&s.env], &vec![&s.env], &token_id), + .try_pay_many(&payer, &vec![&s.env], &vec![&s.env], &token_id, &vec![&s.env]), Err(Ok(Error::NoRecipients)) ); @@ -872,6 +1266,7 @@ fn every_error_code_maps_to_its_triggering_call() { &vec![&s.env, id], &vec![&s.env, 100, 200], &token_id, + &vec![&s.env], ), Err(Ok(Error::LengthMismatch)) ); @@ -900,7 +1295,7 @@ fn every_error_code_maps_to_its_triggering_call() { // 5 SplitNotFound — pay references an unknown split (via load) assert_eq!( - s.client.try_pay(&payer, &99, &token_id, &100), + s.client.try_pay(&payer, &99, &token_id, &100, &None), Err(Ok(Error::SplitNotFound)) ); @@ -925,7 +1320,7 @@ fn every_error_code_maps_to_its_triggering_call() { // 7 InvalidAmount — pay with zero amount assert_eq!( - s.client.try_pay(&payer, &id, &token_id, &0), + s.client.try_pay(&payer, &id, &token_id, &0, &None), Err(Ok(Error::InvalidAmount)) ); // 7 InvalidAmount — deposit with zero amount diff --git a/indexer/export-csv.mjs b/indexer/export-csv.mjs index 2d73a3e..d62d0b6 100644 --- a/indexer/export-csv.mjs +++ b/indexer/export-csv.mjs @@ -2,7 +2,7 @@ import { createInterface } from "node:readline"; import { createReadStream, existsSync } from "node:fs"; const IN = process.argv[2] ?? "events.ndjson"; -const COLUMNS = ["at", "ledger", "type", "split", "amount", "token", "creator", "txHash"]; +const COLUMNS = ["at", "ledger", "type", "split", "amount", "token", "reference", "creator", "txHash"]; if (!existsSync(IN)) { console.error(`${IN} not found. Run the indexer first.`); diff --git a/indexer/index.mjs b/indexer/index.mjs index 998fc11..93ada83 100644 --- a/indexer/index.mjs +++ b/indexer/index.mjs @@ -47,6 +47,15 @@ function saveCursor(cursor) { writeFileSync(STATE, JSON.stringify({ cursor })); } +// Coerces a decoded event value into something JSON can round-trip. bigints +// become strings; byte arrays (e.g. a BytesN<32> payment reference) become +// hex so they survive JSON.stringify instead of turning into {"0":..,"1":..}. +function normalizeValue(value) { + if (typeof value === "bigint") return String(value); + if (value instanceof Uint8Array) return Buffer.from(value).toString("hex"); + return value; +} + function decode(ev) { const record = { ledger: ev.ledger, @@ -60,7 +69,7 @@ function decode(ev) { const data = scValToNative(ev.value); if (data && typeof data === "object") { for (const [key, value] of Object.entries(data)) { - record[key] = typeof value === "bigint" ? String(value) : value; + record[key] = normalizeValue(value); } } } catch { diff --git a/sdk/src/index.ts b/sdk/src/index.ts index bc84254..9a36118 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -39,17 +39,72 @@ export const networks = { } } as const + + export const Errors = { + /** + * Code 1. The recipient list is empty. + * Raised by `create_split`, `update_split` (via `validate`), and + * `pay_many` (empty `ids` list). + */ 1: {message:"NoRecipients"}, + /** + * Code 2. The `recipients` and `shares` vectors have different lengths. + * Raised by `create_split`, `update_split` (via `validate`), and + * `pay_many` (mismatched `ids`/`amounts`). + */ 2: {message:"LengthMismatch"}, + /** + * Code 3. A share value is `0`. + * Raised by `create_split` and `update_split` (via `validate`). + */ 3: {message:"ZeroShare"}, + /** + * Code 4. Shares do not sum to `TOTAL_SHARES` (10_000), or the sum + * overflows `u32`. + * Raised by `create_split` and `update_split` (via `validate`). + */ 4: {message:"BadShareTotal"}, + /** + * Code 5. The split `id` does not exist in storage. + * Raised by `pay`, `pay_many`, `update_split`, `transfer_control`, + * `distribute`, `preview_payout`, and `get_split` (all via `load`). + */ 5: {message:"SplitNotFound"}, + /** + * Code 6. An edit was attempted on a split with `controller == None`. + * Raised by `update_split` and `transfer_control`. + */ 6: {message:"SplitImmutable"}, + /** + * Code 7. The payment amount is zero or negative. + * Raised by `pay`, `pay_many`, `deposit`, and `preview_payout`. + */ 7: {message:"InvalidAmount"}, + /** + * Code 8. `distribute` was called on a split/token with an empty + * escrow balance. + * Raised by `distribute`. + */ 8: {message:"NothingToDistribute"}, + /** + * Code 9. More than `MAX_RECIPIENTS` (32) recipients were supplied. + * Raised by `create_split` and `update_split` (via `validate`). + */ 9: {message:"TooManyRecipients"}, - 10: {message:"BadChildSplit"} + /** + * Code 10. A `Recipient::Split(child)` reference is unknown, or a split + * references itself (directly or as its own update target). + * Raised by `create_split` and `update_split` (via `validate`). + */ + 10: {message:"BadChildSplit"}, + /** + * An arithmetic path produced a value that does not fit the i128 the + * contract stores. Can only happen if a share exceeds TOTAL_SHARES, which + * `validate` forbids, but we surface it as a typed error rather than panic. + */ + 11: {message:"ArithmeticOverflow"}, + 12: {message:"SplitHasBalance"} } @@ -67,13 +122,14 @@ export type Recipient = {tag: "Account", values: readonly [string]} | {tag: "Spl + export interface Client { /** * Construct and simulate a pay transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Moves `amount` of `token` from the payer to every recipient of the * split in one call. Rounding dust goes to the last recipient. */ - pay: ({from, id, token, amount}: {from: string, id: u64, token: string, amount: i128}, options?: MethodOptions) => Promise>> + pay: ({from, id, token, amount, reference}: {from: string, id: u64, token: string, amount: i128, reference: Option}, options?: MethodOptions) => Promise>> /** * Construct and simulate a balance transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -85,6 +141,10 @@ export interface Client { * Moves funds into the contract and credits them to the split without * paying anyone yet. Useful when money arrives before a distribution * should happen. + * + * Credits the amount the vault's balance actually increased by rather + * than the requested `amount`, so fee-on-transfer tokens that deliver + * less than requested cannot over-credit the split. */ deposit: ({from, id, token, amount}: {from: string, id: u64, token: string, amount: i128}, options?: MethodOptions) => Promise>> @@ -92,8 +152,12 @@ export interface Client { * Construct and simulate a pay_many transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Pays several splits from one signer in a single transaction. * `ids` and `amounts` pair up positionally; any failure reverts all. + * + * `references` optionally tags each split's payment for reconciliation + * and pairs up positionally too. An empty `references` vec means "no + * reference for any split"; otherwise it must match `ids.len()` exactly. */ - pay_many: ({from, ids, amounts, token}: {from: string, ids: Array, amounts: Array, token: string}, options?: MethodOptions) => Promise>> + pay_many: ({from, ids, amounts, token, references}: {from: string, ids: Array, amounts: Array, token: string, references: Array>}, options?: MethodOptions) => Promise>> /** * Construct and simulate a get_split transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -112,6 +176,18 @@ export interface Client { */ distribute: ({id, token}: {id: u64, token: string}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a close_split transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Closes a split and reclaims its storage. Only the controller can do this, + * and only if the split holds no balances. + */ + close_split: ({id}: {id: u64}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a held_tokens transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + held_tokens: ({id}: {id: u64}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a split_count transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -131,6 +207,18 @@ export interface Client { */ update_split: ({id, recipients, shares}: {id: u64, recipients: Array, shares: Array}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a pay_many_multi transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Pays several splits from one signer in a single transaction, each + * with its own token. `ids`, `amounts`, and `tokens` pair up + * positionally; any failure reverts all. + * + * `references` optionally tags each split's payment for reconciliation + * and pairs up positionally too. An empty `references` vec means "no + * reference for any split"; otherwise it must match `ids.len()` exactly. + */ + pay_many_multi: ({from, ids, amounts, tokens, references}: {from: string, ids: Array, amounts: Array, tokens: Array, references: Array>}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a preview_payout transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Returns the exact per-recipient amounts a payment of `amount` would @@ -138,6 +226,16 @@ export interface Client { */ preview_payout: ({id, amount}: {id: u64, amount: i128}, options?: MethodOptions) => Promise>>> + /** + * Construct and simulate a splits_of_count transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + splits_of_count: ({creator}: {creator: string}, options?: MethodOptions) => Promise> + + /** + * Construct and simulate a splits_of_paged transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + splits_of_paged: ({creator, start, limit}: {creator: string, start: u32, limit: u32}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a transfer_control transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Hands control of a mutable split to another address, or locks it @@ -163,27 +261,33 @@ export class Client extends ContractClient { } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAACgAAAAAAAAAMTm9SZWNpcGllbnRzAAAAAQAAAAAAAAAOTGVuZ3RoTWlzbWF0Y2gAAAAAAAIAAAAAAAAACVplcm9TaGFyZQAAAAAAAAMAAAAAAAAADUJhZFNoYXJlVG90YWwAAAAAAAAEAAAAAAAAAA1TcGxpdE5vdEZvdW5kAAAAAAAABQAAAAAAAAAOU3BsaXRJbW11dGFibGUAAAAAAAYAAAAAAAAADUludmFsaWRBbW91bnQAAAAAAAAHAAAAAAAAABNOb3RoaW5nVG9EaXN0cmlidXRlAAAAAAgAAAAAAAAAEVRvb01hbnlSZWNpcGllbnRzAAAAAAAACQAAAAAAAAANQmFkQ2hpbGRTcGxpdAAAAAAAAAo=", + new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAADAAAAIJDb2RlIDEuIFRoZSByZWNpcGllbnQgbGlzdCBpcyBlbXB0eS4KUmFpc2VkIGJ5IGBjcmVhdGVfc3BsaXRgLCBgdXBkYXRlX3NwbGl0YCAodmlhIGB2YWxpZGF0ZWApLCBhbmQKYHBheV9tYW55YCAoZW1wdHkgYGlkc2AgbGlzdCkuAAAAAAAMTm9SZWNpcGllbnRzAAAAAQAAAK1Db2RlIDIuIFRoZSBgcmVjaXBpZW50c2AgYW5kIGBzaGFyZXNgIHZlY3RvcnMgaGF2ZSBkaWZmZXJlbnQgbGVuZ3Rocy4KUmFpc2VkIGJ5IGBjcmVhdGVfc3BsaXRgLCBgdXBkYXRlX3NwbGl0YCAodmlhIGB2YWxpZGF0ZWApLCBhbmQKYHBheV9tYW55YCAobWlzbWF0Y2hlZCBgaWRzYC9gYW1vdW50c2ApLgAAAAAAAA5MZW5ndGhNaXNtYXRjaAAAAAAAAgAAAFtDb2RlIDMuIEEgc2hhcmUgdmFsdWUgaXMgYDBgLgpSYWlzZWQgYnkgYGNyZWF0ZV9zcGxpdGAgYW5kIGB1cGRhdGVfc3BsaXRgICh2aWEgYHZhbGlkYXRlYCkuAAAAAAlaZXJvU2hhcmUAAAAAAAADAAAAj0NvZGUgNC4gU2hhcmVzIGRvIG5vdCBzdW0gdG8gYFRPVEFMX1NIQVJFU2AgKDEwXzAwMCksIG9yIHRoZSBzdW0Kb3ZlcmZsb3dzIGB1MzJgLgpSYWlzZWQgYnkgYGNyZWF0ZV9zcGxpdGAgYW5kIGB1cGRhdGVfc3BsaXRgICh2aWEgYHZhbGlkYXRlYCkuAAAAAA1CYWRTaGFyZVRvdGFsAAAAAAAABAAAALRDb2RlIDUuIFRoZSBzcGxpdCBgaWRgIGRvZXMgbm90IGV4aXN0IGluIHN0b3JhZ2UuClJhaXNlZCBieSBgcGF5YCwgYHBheV9tYW55YCwgYHVwZGF0ZV9zcGxpdGAsIGB0cmFuc2Zlcl9jb250cm9sYCwKYGRpc3RyaWJ1dGVgLCBgcHJldmlld19wYXlvdXRgLCBhbmQgYGdldF9zcGxpdGAgKGFsbCB2aWEgYGxvYWRgKS4AAAANU3BsaXROb3RGb3VuZAAAAAAAAAUAAAB0Q29kZSA2LiBBbiBlZGl0IHdhcyBhdHRlbXB0ZWQgb24gYSBzcGxpdCB3aXRoIGBjb250cm9sbGVyID09IE5vbmVgLgpSYWlzZWQgYnkgYHVwZGF0ZV9zcGxpdGAgYW5kIGB0cmFuc2Zlcl9jb250cm9sYC4AAAAOU3BsaXRJbW11dGFibGUAAAAAAAYAAABtQ29kZSA3LiBUaGUgcGF5bWVudCBhbW91bnQgaXMgemVybyBvciBuZWdhdGl2ZS4KUmFpc2VkIGJ5IGBwYXlgLCBgcGF5X21hbnlgLCBgZGVwb3NpdGAsIGFuZCBgcHJldmlld19wYXlvdXRgLgAAAAAAAA1JbnZhbGlkQW1vdW50AAAAAAAABwAAAGZDb2RlIDguIGBkaXN0cmlidXRlYCB3YXMgY2FsbGVkIG9uIGEgc3BsaXQvdG9rZW4gd2l0aCBhbiBlbXB0eQplc2Nyb3cgYmFsYW5jZS4KUmFpc2VkIGJ5IGBkaXN0cmlidXRlYC4AAAAAABNOb3RoaW5nVG9EaXN0cmlidXRlAAAAAAgAAAB/Q29kZSA5LiBNb3JlIHRoYW4gYE1BWF9SRUNJUElFTlRTYCAoMzIpIHJlY2lwaWVudHMgd2VyZSBzdXBwbGllZC4KUmFpc2VkIGJ5IGBjcmVhdGVfc3BsaXRgIGFuZCBgdXBkYXRlX3NwbGl0YCAodmlhIGB2YWxpZGF0ZWApLgAAAAARVG9vTWFueVJlY2lwaWVudHMAAAAAAAAJAAAAvUNvZGUgMTAuIEEgYFJlY2lwaWVudDo6U3BsaXQoY2hpbGQpYCByZWZlcmVuY2UgaXMgdW5rbm93biwgb3IgYSBzcGxpdApyZWZlcmVuY2VzIGl0c2VsZiAoZGlyZWN0bHkgb3IgYXMgaXRzIG93biB1cGRhdGUgdGFyZ2V0KS4KUmFpc2VkIGJ5IGBjcmVhdGVfc3BsaXRgIGFuZCBgdXBkYXRlX3NwbGl0YCAodmlhIGB2YWxpZGF0ZWApLgAAAAAAAA1CYWRDaGlsZFNwbGl0AAAAAAAACgAAANRBbiBhcml0aG1ldGljIHBhdGggcHJvZHVjZWQgYSB2YWx1ZSB0aGF0IGRvZXMgbm90IGZpdCB0aGUgaTEyOCB0aGUKY29udHJhY3Qgc3RvcmVzLiBDYW4gb25seSBoYXBwZW4gaWYgYSBzaGFyZSBleGNlZWRzIFRPVEFMX1NIQVJFUywgd2hpY2gKYHZhbGlkYXRlYCBmb3JiaWRzLCBidXQgd2Ugc3VyZmFjZSBpdCBhcyBhIHR5cGVkIGVycm9yIHJhdGhlciB0aGFuIHBhbmljLgAAABJBcml0aG1ldGljT3ZlcmZsb3cAAAAAAAsAAAAAAAAAD1NwbGl0SGFzQmFsYW5jZQAAAAAM", "AAAAAQAAAAAAAAAAAAAABVNwbGl0AAAAAAAAAwAAAAAAAAAKY29udHJvbGxlcgAAAAAD6AAAABMAAAAAAAAACnJlY2lwaWVudHMAAAAAA+oAAAfQAAAACVJlY2lwaWVudAAAAAAAAAAAAAAGc2hhcmVzAAAAAAPqAAAABA==", - "AAAAAAAAAH9Nb3ZlcyBgYW1vdW50YCBvZiBgdG9rZW5gIGZyb20gdGhlIHBheWVyIHRvIGV2ZXJ5IHJlY2lwaWVudCBvZiB0aGUKc3BsaXQgaW4gb25lIGNhbGwuIFJvdW5kaW5nIGR1c3QgZ29lcyB0byB0aGUgbGFzdCByZWNpcGllbnQuAAAAAANwYXkAAAAABAAAAAAAAAAEZnJvbQAAABMAAAAAAAAAAmlkAAAAAAAGAAAAAAAAAAV0b2tlbgAAAAAAABMAAAAAAAAABmFtb3VudAAAAAAACwAAAAEAAAPpAAAAAgAAAAM=", "AAAAAgAAAAAAAAAAAAAACVJlY2lwaWVudAAAAAAAAAIAAAABAAAAAAAAAAdBY2NvdW50AAAAAAEAAAATAAAAAQAAAAAAAAAFU3BsaXQAAAAAAAABAAAABg==", - "AAAAAAAAAAAAAAAHYmFsYW5jZQAAAAACAAAAAAAAAAJpZAAAAAAABgAAAAAAAAAFdG9rZW4AAAAAAAATAAAAAQAAAAs=", - "AAAAAAAAAJVNb3ZlcyBmdW5kcyBpbnRvIHRoZSBjb250cmFjdCBhbmQgY3JlZGl0cyB0aGVtIHRvIHRoZSBzcGxpdCB3aXRob3V0CnBheWluZyBhbnlvbmUgeWV0LiBVc2VmdWwgd2hlbiBtb25leSBhcnJpdmVzIGJlZm9yZSBhIGRpc3RyaWJ1dGlvbgpzaG91bGQgaGFwcGVuLgAAAAAAAAdkZXBvc2l0AAAAAAQAAAAAAAAABGZyb20AAAATAAAAAAAAAAJpZAAAAAAABgAAAAAAAAAFdG9rZW4AAAAAAAATAAAAAAAAAAZhbW91bnQAAAAAAAsAAAABAAAD6QAAAAIAAAAD", "AAAABQAAAAAAAAAAAAAACURlcG9zaXRlZAAAAAAAAAEAAAAJZGVwb3NpdGVkAAAAAAAAAwAAAAAAAAACaWQAAAAAAAYAAAABAAAAAAAAAAV0b2tlbgAAAAAAABMAAAAAAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAAAg==", - "AAAABQAAAAAAAAAAAAAACVNwbGl0UGFpZAAAAAAAAAEAAAAKc3BsaXRfcGFpZAAAAAAAAwAAAAAAAAACaWQAAAAAAAYAAAABAAAAAAAAAAV0b2tlbgAAAAAAABMAAAAAAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAAAg==", - "AAAAAAAAAH9QYXlzIHNldmVyYWwgc3BsaXRzIGZyb20gb25lIHNpZ25lciBpbiBhIHNpbmdsZSB0cmFuc2FjdGlvbi4KYGlkc2AgYW5kIGBhbW91bnRzYCBwYWlyIHVwIHBvc2l0aW9uYWxseTsgYW55IGZhaWx1cmUgcmV2ZXJ0cyBhbGwuAAAAAAhwYXlfbWFueQAAAAQAAAAAAAAABGZyb20AAAATAAAAAAAAAANpZHMAAAAD6gAAAAYAAAAAAAAAB2Ftb3VudHMAAAAD6gAAAAsAAAAAAAAABXRva2VuAAAAAAAAEwAAAAEAAAPpAAAAAgAAAAM=", - "AAAAAAAAAAAAAAAJZ2V0X3NwbGl0AAAAAAAAAQAAAAAAAAACaWQAAAAAAAYAAAABAAAD6QAAB9AAAAAFU3BsaXQAAAAAAAAD", - "AAAAAAAAAAAAAAAJc3BsaXRzX29mAAAAAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAQAAA+oAAAAG", + "AAAABQAAAAAAAAAAAAAACVNwbGl0UGFpZAAAAAAAAAEAAAAKc3BsaXRfcGFpZAAAAAAABAAAAAAAAAACaWQAAAAAAAYAAAABAAAAAAAAAAV0b2tlbgAAAAAAABMAAAAAAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAAvk9wdGlvbmFsIGNhbGxlci1zdXBwbGllZCB0YWcgKGUuZy4gYW4gb3JkZXIgb3IgaW52b2ljZSBpZCkgc28KaW50ZWdyYXRvcnMgY2FuIHJlY29uY2lsZSBhIHBheW1lbnQgYWdhaW5zdCB0aGVpciBvd24gcmVjb3Jkcy4KTm90IGEgdG9waWM6IGl0IHJpZGVzIGFsb25nIGFzIGRhdGEgYW5kIG5ldmVyIGNvc3RzIGEgdG9waWMgc2xvdC4AAAAAAAlyZWZlcmVuY2UAAAAAAAPoAAAD7gAAACAAAAAAAAAAAg==", "AAAABQAAAAAAAAAAAAAAC0Rpc3RyaWJ1dGVkAAAAAAEAAAALZGlzdHJpYnV0ZWQAAAAAAwAAAAAAAAACaWQAAAAAAAYAAAABAAAAAAAAAAV0b2tlbgAAAAAAABMAAAAAAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAAAg==", - "AAAAAAAAAH5QYXlzIG91dCBldmVyeXRoaW5nIGNyZWRpdGVkIHRvIHRoZSBzcGxpdCBmb3IgdGhlIGdpdmVuIHRva2VuLgpBbnlvbmUgY2FuIGNhbGwgdGhpczsgdGhlIHJvdXRpbmcgdGFibGUgZGVjaWRlcyB3aGVyZSBmdW5kcyBnby4AAAAAAApkaXN0cmlidXRlAAAAAAACAAAAAAAAAAJpZAAAAAAABgAAAAAAAAAFdG9rZW4AAAAAAAATAAAAAQAAA+kAAAALAAAAAw==", + "AAAABQAAAAAAAAAAAAAAC1NwbGl0Q2xvc2VkAAAAAAEAAAAMc3BsaXRfY2xvc2VkAAAAAQAAAAAAAAACaWQAAAAAAAYAAAABAAAAAg==", "AAAABQAAAAAAAAAAAAAADFNwbGl0Q3JlYXRlZAAAAAEAAAANc3BsaXRfY3JlYXRlZAAAAAAAAAIAAAAAAAAAAmlkAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAAI=", "AAAABQAAAAAAAAAAAAAADFNwbGl0VXBkYXRlZAAAAAEAAAANc3BsaXRfdXBkYXRlZAAAAAAAAAEAAAAAAAAAAmlkAAAAAAAGAAAAAQAAAAI=", + "AAAABQAAAAAAAAAAAAAAEkNvbnRyb2xUcmFuc2ZlcnJlZAAAAAAAAQAAABNjb250cm9sX3RyYW5zZmVycmVkAAAAAAIAAAAAAAAAAmlkAAAAAAAGAAAAAQAAAAAAAAAObmV3X2NvbnRyb2xsZXIAAAAAA+gAAAATAAAAAAAAAAI=", + "AAAAAAAAAH9Nb3ZlcyBgYW1vdW50YCBvZiBgdG9rZW5gIGZyb20gdGhlIHBheWVyIHRvIGV2ZXJ5IHJlY2lwaWVudCBvZiB0aGUKc3BsaXQgaW4gb25lIGNhbGwuIFJvdW5kaW5nIGR1c3QgZ29lcyB0byB0aGUgbGFzdCByZWNpcGllbnQuAAAAAANwYXkAAAAABQAAAAAAAAAEZnJvbQAAABMAAAAAAAAAAmlkAAAAAAAGAAAAAAAAAAV0b2tlbgAAAAAAABMAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAJcmVmZXJlbmNlAAAAAAAD6AAAA+4AAAAgAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAAAAAAAHYmFsYW5jZQAAAAACAAAAAAAAAAJpZAAAAAAABgAAAAAAAAAFdG9rZW4AAAAAAAATAAAAAQAAAAs=", + "AAAAAAAAAVBNb3ZlcyBmdW5kcyBpbnRvIHRoZSBjb250cmFjdCBhbmQgY3JlZGl0cyB0aGVtIHRvIHRoZSBzcGxpdCB3aXRob3V0CnBheWluZyBhbnlvbmUgeWV0LiBVc2VmdWwgd2hlbiBtb25leSBhcnJpdmVzIGJlZm9yZSBhIGRpc3RyaWJ1dGlvbgpzaG91bGQgaGFwcGVuLgoKQ3JlZGl0cyB0aGUgYW1vdW50IHRoZSB2YXVsdCdzIGJhbGFuY2UgYWN0dWFsbHkgaW5jcmVhc2VkIGJ5IHJhdGhlcgp0aGFuIHRoZSByZXF1ZXN0ZWQgYGFtb3VudGAsIHNvIGZlZS1vbi10cmFuc2ZlciB0b2tlbnMgdGhhdCBkZWxpdmVyCmxlc3MgdGhhbiByZXF1ZXN0ZWQgY2Fubm90IG92ZXItY3JlZGl0IHRoZSBzcGxpdC4AAAAHZGVwb3NpdAAAAAAEAAAAAAAAAARmcm9tAAAAEwAAAAAAAAACaWQAAAAAAAYAAAAAAAAABXRva2VuAAAAAAAAEwAAAAAAAAAGYW1vdW50AAAAAAALAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAU9QYXlzIHNldmVyYWwgc3BsaXRzIGZyb20gb25lIHNpZ25lciBpbiBhIHNpbmdsZSB0cmFuc2FjdGlvbi4KYGlkc2AgYW5kIGBhbW91bnRzYCBwYWlyIHVwIHBvc2l0aW9uYWxseTsgYW55IGZhaWx1cmUgcmV2ZXJ0cyBhbGwuCgpgcmVmZXJlbmNlc2Agb3B0aW9uYWxseSB0YWdzIGVhY2ggc3BsaXQncyBwYXltZW50IGZvciByZWNvbmNpbGlhdGlvbgphbmQgcGFpcnMgdXAgcG9zaXRpb25hbGx5IHRvby4gQW4gZW1wdHkgYHJlZmVyZW5jZXNgIHZlYyBtZWFucyAibm8KcmVmZXJlbmNlIGZvciBhbnkgc3BsaXQiOyBvdGhlcndpc2UgaXQgbXVzdCBtYXRjaCBgaWRzLmxlbigpYCBleGFjdGx5LgAAAAAIcGF5X21hbnkAAAAFAAAAAAAAAARmcm9tAAAAEwAAAAAAAAADaWRzAAAAA+oAAAAGAAAAAAAAAAdhbW91bnRzAAAAA+oAAAALAAAAAAAAAAV0b2tlbgAAAAAAABMAAAAAAAAACnJlZmVyZW5jZXMAAAAAA+oAAAPoAAAD7gAAACAAAAABAAAD6QAAAAIAAAAD", + "AAAAAAAAAAAAAAAJZ2V0X3NwbGl0AAAAAAAAAQAAAAAAAAACaWQAAAAAAAYAAAABAAAD6QAAB9AAAAAFU3BsaXQAAAAAAAAD", + "AAAAAAAAAAAAAAAJc3BsaXRzX29mAAAAAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAQAAA+oAAAAG", + "AAAAAAAAAH5QYXlzIG91dCBldmVyeXRoaW5nIGNyZWRpdGVkIHRvIHRoZSBzcGxpdCBmb3IgdGhlIGdpdmVuIHRva2VuLgpBbnlvbmUgY2FuIGNhbGwgdGhpczsgdGhlIHJvdXRpbmcgdGFibGUgZGVjaWRlcyB3aGVyZSBmdW5kcyBnby4AAAAAAApkaXN0cmlidXRlAAAAAAACAAAAAAAAAAJpZAAAAAAABgAAAAAAAAAFdG9rZW4AAAAAAAATAAAAAQAAA+kAAAALAAAAAw==", + "AAAAAAAAAHJDbG9zZXMgYSBzcGxpdCBhbmQgcmVjbGFpbXMgaXRzIHN0b3JhZ2UuIE9ubHkgdGhlIGNvbnRyb2xsZXIgY2FuIGRvIHRoaXMsCmFuZCBvbmx5IGlmIHRoZSBzcGxpdCBob2xkcyBubyBiYWxhbmNlcy4AAAAAAAtjbG9zZV9zcGxpdAAAAAABAAAAAAAAAAJpZAAAAAAABgAAAAEAAAPpAAAAAgAAAAM=", + "AAAAAAAAAAAAAAALaGVsZF90b2tlbnMAAAAAAQAAAAAAAAACaWQAAAAAAAYAAAABAAAD6gAAABM=", "AAAAAAAAAAAAAAALc3BsaXRfY291bnQAAAAAAAAAAAEAAAAG", "AAAAAAAAAL5SZWdpc3RlcnMgYSBuZXcgc3BsaXQgYW5kIHJldHVybnMgaXRzIGlkLiBTaGFyZXMgYXJlIGJhc2lzIHBvaW50cwphbmQgbXVzdCBzdW0gdG8gZXhhY3RseSAxMF8wMDAuIFBhc3NpbmcgYSBjb250cm9sbGVyIG1ha2VzIHRoZQpzcGxpdCBtdXRhYmxlIGJ5IHRoYXQgYWRkcmVzczsgcGFzc2luZyBOb25lIGxvY2tzIGl0IGZvcmV2ZXIuAAAAAAAMY3JlYXRlX3NwbGl0AAAABAAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAApyZWNpcGllbnRzAAAAAAPqAAAH0AAAAAlSZWNpcGllbnQAAAAAAAAAAAAABnNoYXJlcwAAAAAD6gAAAAQAAAAAAAAACmNvbnRyb2xsZXIAAAAAA+gAAAATAAAAAQAAA+kAAAAGAAAAAw==", "AAAAAAAAADZSZXBsYWNlcyB0aGUgcmVjaXBpZW50cyBhbmQgc2hhcmVzIG9mIGEgbXV0YWJsZSBzcGxpdC4AAAAAAAx1cGRhdGVfc3BsaXQAAAADAAAAAAAAAAJpZAAAAAAABgAAAAAAAAAKcmVjaXBpZW50cwAAAAAD6gAAB9AAAAAJUmVjaXBpZW50AAAAAAAAAAAAAAZzaGFyZXMAAAAAA+oAAAAEAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAXNQYXlzIHNldmVyYWwgc3BsaXRzIGZyb20gb25lIHNpZ25lciBpbiBhIHNpbmdsZSB0cmFuc2FjdGlvbiwgZWFjaAp3aXRoIGl0cyBvd24gdG9rZW4uIGBpZHNgLCBgYW1vdW50c2AsIGFuZCBgdG9rZW5zYCBwYWlyIHVwCnBvc2l0aW9uYWxseTsgYW55IGZhaWx1cmUgcmV2ZXJ0cyBhbGwuCgpgcmVmZXJlbmNlc2Agb3B0aW9uYWxseSB0YWdzIGVhY2ggc3BsaXQncyBwYXltZW50IGZvciByZWNvbmNpbGlhdGlvbgphbmQgcGFpcnMgdXAgcG9zaXRpb25hbGx5IHRvby4gQW4gZW1wdHkgYHJlZmVyZW5jZXNgIHZlYyBtZWFucyAibm8KcmVmZXJlbmNlIGZvciBhbnkgc3BsaXQiOyBvdGhlcndpc2UgaXQgbXVzdCBtYXRjaCBgaWRzLmxlbigpYCBleGFjdGx5LgAAAAAOcGF5X21hbnlfbXVsdGkAAAAAAAUAAAAAAAAABGZyb20AAAATAAAAAAAAAANpZHMAAAAD6gAAAAYAAAAAAAAAB2Ftb3VudHMAAAAD6gAAAAsAAAAAAAAABnRva2VucwAAAAAD6gAAABMAAAAAAAAACnJlZmVyZW5jZXMAAAAAA+oAAAPoAAAD7gAAACAAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAGZSZXR1cm5zIHRoZSBleGFjdCBwZXItcmVjaXBpZW50IGFtb3VudHMgYSBwYXltZW50IG9mIGBhbW91bnRgIHdvdWxkCnByb2R1Y2UsIHdpdGhvdXQgbW92aW5nIGFueSBmdW5kcy4AAAAAAA5wcmV2aWV3X3BheW91dAAAAAAAAgAAAAAAAAACaWQAAAAAAAYAAAAAAAAABmFtb3VudAAAAAAACwAAAAEAAAPpAAAD6gAAAAsAAAAD", - "AAAAAAAAAGlIYW5kcyBjb250cm9sIG9mIGEgbXV0YWJsZSBzcGxpdCB0byBhbm90aGVyIGFkZHJlc3MsIG9yIGxvY2tzIGl0CmZvcmV2ZXIgd2hlbiB0aGUgbmV3IGNvbnRyb2xsZXIgaXMgTm9uZS4AAAAAAAAQdHJhbnNmZXJfY29udHJvbAAAAAIAAAAAAAAAAmlkAAAAAAAGAAAAAAAAAA5uZXdfY29udHJvbGxlcgAAAAAD6AAAABMAAAABAAAD6QAAAAIAAAAD", - "AAAABQAAAAAAAAAAAAAAEkNvbnRyb2xUcmFuc2ZlcnJlZAAAAAAAAQAAABNjb250cm9sX3RyYW5zZmVycmVkAAAAAAIAAAAAAAAAAmlkAAAAAAAGAAAAAQAAAAAAAAAObmV3X2NvbnRyb2xsZXIAAAAAA+gAAAATAAAAAAAAAAI=" ]), + "AAAAAAAAAAAAAAAPc3BsaXRzX29mX2NvdW50AAAAAAEAAAAAAAAAB2NyZWF0b3IAAAAAEwAAAAEAAAAE", + "AAAAAAAAAAAAAAAPc3BsaXRzX29mX3BhZ2VkAAAAAAMAAAAAAAAAB2NyZWF0b3IAAAAAEwAAAAAAAAAFc3RhcnQAAAAAAAAEAAAAAAAAAAVsaW1pdAAAAAAAAAQAAAABAAAD6gAAAAY=", + "AAAAAAAAAGlIYW5kcyBjb250cm9sIG9mIGEgbXV0YWJsZSBzcGxpdCB0byBhbm90aGVyIGFkZHJlc3MsIG9yIGxvY2tzIGl0CmZvcmV2ZXIgd2hlbiB0aGUgbmV3IGNvbnRyb2xsZXIgaXMgTm9uZS4AAAAAAAAQdHJhbnNmZXJfY29udHJvbAAAAAIAAAAAAAAAAmlkAAAAAAAGAAAAAAAAAA5uZXdfY29udHJvbGxlcgAAAAAD6AAAABMAAAABAAAD6QAAAAIAAAAD" ]), options ) } @@ -195,14 +299,18 @@ export class Client extends ContractClient { get_split: this.txFromJSON>, splits_of: this.txFromJSON>, distribute: this.txFromJSON>, + close_split: this.txFromJSON>, + held_tokens: this.txFromJSON>, split_count: this.txFromJSON, create_split: this.txFromJSON>, update_split: this.txFromJSON>, + pay_many_multi: this.txFromJSON>, preview_payout: this.txFromJSON>>, + splits_of_count: this.txFromJSON, + splits_of_paged: this.txFromJSON>, transfer_control: this.txFromJSON> } } - /** * Polls for a transaction to be confirmed or fail, with a timeout. * @@ -242,4 +350,4 @@ export async function waitForConfirmation( throw new Error( `Transaction ${txHash} was not confirmed within ${timeout / 1_000}s`, ); -} \ No newline at end of file +} From d94b169e09bf66ace6718e59561dfb4c345e23c4 Mon Sep 17 00:00:00 2001 From: Justjoseph0 Date: Sat, 18 Jul 2026 14:44:33 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20satisfy=20CI=20=E2=80=94=20cargo=20f?= =?UTF-8?q?mt=20and=20make=20SDK=20reference=20params=20actually=20optiona?= =?UTF-8?q?lcargo=20fmt=20--all=20--check=20was=20failing=20on=20two=20mul?= =?UTF-8?q?ti-line=20call=20sites=20in=20test.rs,pure=20formatting,=20no?= =?UTF-8?q?=20behavior=20change.The=20generated=20SDK=20interface=20marked?= =?UTF-8?q?=20reference/references=20as=20required=20keys(value=20type=20O?= =?UTF-8?q?ption,=20but=20the=20key=20itself=20was=20mandatory),=20whic?= =?UTF-8?q?h=20brokePaySplit.tsx,=20an=20existing=20caller=20that=20predat?= =?UTF-8?q?es=20this=20feature=20and=20doesn'tpass=20a=20reference.=20Made?= =?UTF-8?q?=20reference/references=20actual=20optional=20TS=20keys,=20anda?= =?UTF-8?q?dded=20a=20small=20constructor-level=20patch=20so=20the=20base?= =?UTF-8?q?=20ContractClient=20alwaysreceives=20the=20key=20with=20a=20sen?= =?UTF-8?q?sible=20default=20(undefined=20for=20reference,=20[]=20forrefer?= =?UTF-8?q?ences)=20when=20the=20caller=20omits=20it=20=E2=80=94=20verifie?= =?UTF-8?q?d=20at=20the=20ScVal=20encodinglevel=20that=20omitted=20still?= =?UTF-8?q?=20resolves=20to=20None/empty=20and=20explicit=20values=20still?= =?UTF-8?q?pass=20through=20byte-identical.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/splitter/src/test.rs | 19 ++++++++++++++----- sdk/src/index.ts | 22 +++++++++++++++++++--- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/contracts/splitter/src/test.rs b/contracts/splitter/src/test.rs index 9d17df4..cbb8938 100644 --- a/contracts/splitter/src/test.rs +++ b/contracts/splitter/src/test.rs @@ -455,9 +455,13 @@ fn pay_many_rejects_bad_batches() { &None, ); - let empty = - s.client - .try_pay_many(&payer, &vec![&s.env], &vec![&s.env], &token_id, &vec![&s.env]); + let empty = s.client.try_pay_many( + &payer, + &vec![&s.env], + &vec![&s.env], + &token_id, + &vec![&s.env], + ); assert_eq!(empty, Err(Ok(Error::NoRecipients))); let mismatch = s.client.try_pay_many( @@ -1238,8 +1242,13 @@ fn every_error_code_maps_to_its_triggering_call() { ); // 1 NoRecipients — pay_many with empty ids assert_eq!( - s.client - .try_pay_many(&payer, &vec![&s.env], &vec![&s.env], &token_id, &vec![&s.env]), + s.client.try_pay_many( + &payer, + &vec![&s.env], + &vec![&s.env], + &token_id, + &vec![&s.env] + ), Err(Ok(Error::NoRecipients)) ); diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 9a36118..42adafc 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -129,7 +129,7 @@ export interface Client { * Moves `amount` of `token` from the payer to every recipient of the * split in one call. Rounding dust goes to the last recipient. */ - pay: ({from, id, token, amount, reference}: {from: string, id: u64, token: string, amount: i128, reference: Option}, options?: MethodOptions) => Promise>> + pay: ({from, id, token, amount, reference}: {from: string, id: u64, token: string, amount: i128, reference?: Option}, options?: MethodOptions) => Promise>> /** * Construct and simulate a balance transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -157,7 +157,7 @@ export interface Client { * and pairs up positionally too. An empty `references` vec means "no * reference for any split"; otherwise it must match `ids.len()` exactly. */ - pay_many: ({from, ids, amounts, token, references}: {from: string, ids: Array, amounts: Array, token: string, references: Array>}, options?: MethodOptions) => Promise>> + pay_many: ({from, ids, amounts, token, references}: {from: string, ids: Array, amounts: Array, token: string, references?: Array>}, options?: MethodOptions) => Promise>> /** * Construct and simulate a get_split transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -217,7 +217,7 @@ export interface Client { * and pairs up positionally too. An empty `references` vec means "no * reference for any split"; otherwise it must match `ids.len()` exactly. */ - pay_many_multi: ({from, ids, amounts, tokens, references}: {from: string, ids: Array, amounts: Array, tokens: Array, references: Array>}, options?: MethodOptions) => Promise>> + pay_many_multi: ({from, ids, amounts, tokens, references}: {from: string, ids: Array, amounts: Array, tokens: Array, references?: Array>}, options?: MethodOptions) => Promise>> /** * Construct and simulate a preview_payout transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -290,6 +290,22 @@ export class Client extends ContractClient { "AAAAAAAAAGlIYW5kcyBjb250cm9sIG9mIGEgbXV0YWJsZSBzcGxpdCB0byBhbm90aGVyIGFkZHJlc3MsIG9yIGxvY2tzIGl0CmZvcmV2ZXIgd2hlbiB0aGUgbmV3IGNvbnRyb2xsZXIgaXMgTm9uZS4AAAAAAAAQdHJhbnNmZXJfY29udHJvbAAAAAIAAAAAAAAAAmlkAAAAAAAGAAAAAAAAAA5uZXdfY29udHJvbGxlcgAAAAAD6AAAABMAAAABAAAD6QAAAAIAAAAD" ]), options ) + + // Override methods to supply defaults for omitted optional reference params. + // The parent ContractClient's funcArgsToScVals requires every field to be present, + // so we intercept and fill in undefined → undefined (which encodes as None). + const payOrig = (this as any).pay; + (this as any).pay = (args: {from: string, id: u64, token: string, amount: i128, reference?: Option}, options?: MethodOptions) => { + return payOrig.call(this, { ...args, reference: args.reference }, options); + }; + const payManyOrig = (this as any).pay_many; + (this as any).pay_many = (args: {from: string, ids: Array, amounts: Array, token: string, references?: Array>}, options?: MethodOptions) => { + return payManyOrig.call(this, { ...args, references: args.references ?? [] }, options); + }; + const payManyMultiOrig = (this as any).pay_many_multi; + (this as any).pay_many_multi = (args: {from: string, ids: Array, amounts: Array, tokens: Array, references?: Array>}, options?: MethodOptions) => { + return payManyMultiOrig.call(this, { ...args, references: args.references ?? [] }, options); + } } public readonly fromJSON = { pay: this.txFromJSON>,