From 848037f9e48d187b8a98eb059ed44d5393e0d95b Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 26 Sep 2026 01:11:57 -0400 Subject: [PATCH 01/11] feat(revenue_split): emit DistributionExecuted event on distribute Add a structured DistributionExecutedEvent with asset, total_amount, recipient_count, and split_percentages (basis points) so off-chain indexers can track revenue distributions. Replace the ad-hoc Symbol publish. Tests cover emission and one-event-per-asset. Closes #515 --- contracts/revenue_split/src/lib.rs | 28 +++++++- contracts/revenue_split/src/test.rs | 105 +++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/contracts/revenue_split/src/lib.rs b/contracts/revenue_split/src/lib.rs index c3158981..3b352219 100644 --- a/contracts/revenue_split/src/lib.rs +++ b/contracts/revenue_split/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Vec, token, Symbol}; +use soroban_sdk::{contract, contracterror, contractevent, contractimpl, contracttype, Address, Env, Vec, token}; use common::CommonError; #[cfg(test)] @@ -44,6 +44,19 @@ pub struct RecipientShare { pub const TOTAL_BASIS_POINTS: u32 = 10000; // 100% +// ── Events ──────────────────────────────────────────────────────────────────── + +/// Emitted once per asset distribution so off-chain indexers can track totals, +/// recipient counts, and the active split weights (basis points, 10000 = 100%). +#[contractevent] +pub struct DistributionExecutedEvent { + pub asset: Address, + pub total_amount: i128, + pub recipient_count: u32, + /// Share weights in basis points (10000 = 100%). + pub split_percentages: Vec, +} + #[contract] pub struct RevenueSplitContract; @@ -127,7 +140,18 @@ impl RevenueSplitContract { } } - env.events().publish((Symbol::new(&env, "distribute"), token.clone()), amount); + let mut split_percentages: Vec = Vec::new(&env); + for share in shares.iter() { + split_percentages.push_back(share.basis_points); + } + + DistributionExecutedEvent { + asset: token.clone(), + total_amount: amount, + recipient_count: shares.len(), + split_percentages, + } + .publish(&env); } Ok(()) diff --git a/contracts/revenue_split/src/test.rs b/contracts/revenue_split/src/test.rs index 46edc104..0bccd4e1 100644 --- a/contracts/revenue_split/src/test.rs +++ b/contracts/revenue_split/src/test.rs @@ -1,9 +1,11 @@ #![cfg(test)] use crate::{RevenueSplitContract, RevenueSplitContractClient, RecipientShare}; -use soroban_sdk::{testutils::{Address as _}, Address, Env, Vec}; -use soroban_sdk::token::Client as TokenClient; -use soroban_sdk::token::StellarAssetClient; +use soroban_sdk::{ + testutils::{Address as _, Events}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, FromVal, Symbol, Vec, +}; fn create_token_contract<'a>(e: &Env, admin: &Address) -> (Address, StellarAssetClient<'a>, TokenClient<'a>) { e.mock_all_auths(); @@ -240,3 +242,100 @@ fn test_update_recipients() { client.update_recipients(&new_shares); } + +// ── Event emission ──────────────────────────────────────────────────────────── + +fn count_named_events(env: &Env, contract_addr: &Address, event_name: &str) -> u32 { + let target_sym = Symbol::new(env, event_name); + let mut n = 0u32; + for (addr, topics, _data) in env.events().all().iter() { + if addr != *contract_addr { + continue; + } + if topics.iter().any(|t| Symbol::from_val(env, &t) == target_sym) { + n += 1; + } + } + n +} + +#[test] +fn test_distribute_emits_distribution_executed_event() { + let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + let (token_id, stellar_asset_client, token_client) = create_token_contract(&env, &token_admin); + + let contract_id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let recipient1 = Address::generate(&env); + let recipient2 = Address::generate(&env); + + let shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1.clone(), + basis_points: 6000, + }, + RecipientShare { + destination: recipient2.clone(), + basis_points: 4000, + }, + ], + ); + client.init(&admin, &shares); + + let sender = Address::generate(&env); + stellar_asset_client.mint(&sender, &1000); + + let assets = Vec::from_array(&env, [(token_id.clone(), 1000i128)]); + client.distribute(&sender, &assets); + + // Check events before any further host reads (balance calls can clear the log). + let n = count_named_events(&env, &client.address, "distribution_executed_event"); + assert_eq!(n, 1, "expected one DistributionExecutedEvent"); + + assert_eq!(token_client.balance(&recipient1), 600); + assert_eq!(token_client.balance(&recipient2), 400); +} + +#[test] +fn test_distribute_emits_one_event_per_asset() { + let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + let (token_id1, stellar_asset_client1, _) = create_token_contract(&env, &token_admin); + let (token_id2, stellar_asset_client2, _) = create_token_contract(&env, &token_admin); + + let contract_id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let recipient1 = Address::generate(&env); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient1.clone(), + basis_points: 10000, + }], + ); + client.init(&admin, &shares); + + let sender = Address::generate(&env); + stellar_asset_client1.mint(&sender, &100); + stellar_asset_client2.mint(&sender, &200); + + let assets = Vec::from_array( + &env, + [(token_id1.clone(), 100i128), (token_id2.clone(), 200i128)], + ); + client.distribute(&sender, &assets); + + let n = count_named_events(&env, &client.address, "distribution_executed_event"); + assert_eq!(n, 2, "expected one DistributionExecutedEvent per asset"); +} From b8673e49d7b54ba42177bd8f07accf3d0e6b8cee Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 26 Sep 2026 09:49:16 -0400 Subject: [PATCH 02/11] fix(revenue_split): transfer final dust before emitting distribution event --- contracts/revenue_split/src/lib.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/contracts/revenue_split/src/lib.rs b/contracts/revenue_split/src/lib.rs index 3b352219..3fb21b1d 100644 --- a/contracts/revenue_split/src/lib.rs +++ b/contracts/revenue_split/src/lib.rs @@ -122,18 +122,18 @@ impl RevenueSplitContract { let mut amount_distributed = 0; for (i, share) in shares.iter().enumerate() { - // Calculate slice of the total amount using basis points - // Formula: amount * basis_points / 10000 - let recipient_amount = (amount as i128 * share.basis_points as i128) / TOTAL_BASIS_POINTS as i128; - - if recipient_amount > 0 { - // To avoid precision loss dust, the last recipient takes any minor remainders. - if i as u32 == shares.len() - 1 { - let final_amount = amount - amount_distributed; - if final_amount > 0 { - client.transfer(&from, &share.destination, &final_amount); - } - } else { + // The last recipient receives the remainder even when its own + // rounded share is zero. Otherwise an event could claim that + // dust was distributed while the tokens remained with the sender. + if i as u32 == shares.len() - 1 { + let final_amount = amount - amount_distributed; + if final_amount > 0 { + client.transfer(&from, &share.destination, &final_amount); + } + } else { + let recipient_amount = + (amount * share.basis_points as i128) / TOTAL_BASIS_POINTS as i128; + if recipient_amount > 0 { client.transfer(&from, &share.destination, &recipient_amount); amount_distributed += recipient_amount; } From 2bd7755155b122a3e37c09e00bc21415978f6d17 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 26 Sep 2026 09:49:30 -0400 Subject: [PATCH 03/11] test(revenue_split): cover one-unit distribution remainder --- contracts/revenue_split/src/test.rs | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/contracts/revenue_split/src/test.rs b/contracts/revenue_split/src/test.rs index 0bccd4e1..3e15a020 100644 --- a/contracts/revenue_split/src/test.rs +++ b/contracts/revenue_split/src/test.rs @@ -339,3 +339,35 @@ fn test_distribute_emits_one_event_per_asset() { let n = count_named_events(&env, &client.address, "distribution_executed_event"); assert_eq!(n, 2, "expected one DistributionExecutedEvent per asset"); } + +#[test] +fn test_distribution_emits_truthful_total_when_last_share_rounds_to_zero() { + let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + let (token_id, stellar_asset_client, token_client) = create_token_contract(&env, &token_admin); + let contract_id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let first = Address::generate(&env); + let last = Address::generate(&env); + let shares = Vec::from_array( + &env, + [ + RecipientShare { destination: first.clone(), basis_points: 5000 }, + RecipientShare { destination: last.clone(), basis_points: 5000 }, + ], + ); + client.init(&admin, &shares); + + let sender = Address::generate(&env); + stellar_asset_client.mint(&sender, &1); + client.distribute(&sender, &Vec::from_array(&env, [(token_id, 1i128)])); + + assert_eq!(count_named_events(&env, &client.address, "distribution_executed_event"), 1); + assert_eq!(token_client.balance(&sender), 0); + assert_eq!(token_client.balance(&first), 0); + assert_eq!(token_client.balance(&last), 1); +} From e04c31bd6e594b24b4bcb7c56c5a2132e1e43921 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 04:03:22 -0400 Subject: [PATCH 04/11] Restore recognized CI and preserve build failures for distribution PR Remove unsupported workflow-root retention settings, make the Scaffold pipeline use explicit Bash failure propagation, and restore the missing Kubernetes placeholder checker from FE25's released PR632 source (edc76dac). Include the checker and its workflow in their own path triggers. Preserve every existing build/test command and the revenue_split contribution. Bounded validation: four YAML documents parsed and existing 17 build/2 guard steps retained. An actual failing Scaffold substitute piped through tee returns 0 with the prior shell and 23 with the explicit Bash shell, with logs preserved. Restored checker returns 0 for existing placeholders, 1 for an unknown hyphenated secret and 2 for unsupported nested/multiple documents, without disclosing its synthetic marker. No application or contract build rerun. Integration: Astra-3883-G / GPT-6 Astra Pro / ChatGPT cloud. --- .github/workflows/build.yml | 5 +- .github/workflows/secrets-check.yml | 8 +- scripts/check-k8s-secrets.sh | 117 ++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 8 deletions(-) create mode 100755 scripts/check-k8s-secrets.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52029992..4ce301d2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,6 @@ on: branches: ["main"] types: [opened, synchronize, reopened, ready_for_review] -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days env: CARGO_TERM_COLOR: always PKG_CONFIG_PATH: /usr/lib/pkgconfig @@ -44,6 +43,7 @@ jobs: echo "stellar-scaffold already installed. Clear cache to force reinstall." fi - name: Build with Scaffold and build client packages + shell: bash run: STELLAR_SCAFFOLD_ENV=development stellar-scaffold build --build-clients 2>&1 | tee build_clients.log - name: Install official Stellar CLI run: | @@ -113,6 +113,3 @@ jobs: - name: Run Tests working-directory: ./frontend run: npm test --if-present - -# Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/secrets-check.yml b/.github/workflows/secrets-check.yml index b8d3b7e8..1cea1b28 100644 --- a/.github/workflows/secrets-check.yml +++ b/.github/workflows/secrets-check.yml @@ -5,12 +5,15 @@ on: branches: ["main"] paths: - "k8s/**" + - ".github/workflows/secrets-check.yml" + - "scripts/check-k8s-secrets.sh" pull_request: branches: ["main"] paths: - "k8s/**" + - ".github/workflows/secrets-check.yml" + - "scripts/check-k8s-secrets.sh" -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days jobs: check-secrets-placeholders: name: Verify no real secrets in k8s manifests @@ -20,6 +23,3 @@ jobs: - name: Check backend-secret.yaml for non-placeholder values run: ./scripts/check-k8s-secrets.sh - -# Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/scripts/check-k8s-secrets.sh b/scripts/check-k8s-secrets.sh new file mode 100755 index 00000000..edc76dac --- /dev/null +++ b/scripts/check-k8s-secrets.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# check-k8s-secrets.sh — verify that k8s/base/backend-secret.yaml contains +# only the expected CHANGE_ME placeholders and no real secret values. +# +# Exit codes: +# 0 — file is clean (only placeholders) +# 1 — file contains values that are NOT known placeholders (possible leak) +# 2 — file not found or parse error +# +# Usage: +# ./scripts/check-k8s-secrets.sh # standalone +# # Also wired into .husky/pre-commit and .github/workflows/secrets-check.yml + +set -euo pipefail + +SECRET_FILE="k8s/base/backend-secret.yaml" + +if [[ ! -f "$SECRET_FILE" ]]; then + echo "ERROR: $SECRET_FILE not found." + exit 2 +fi + +# Allowed placeholder values. Any stringData value not in this set is flagged. +ALLOWED_VALUES=( + "CHANGE_ME" + "CHANGE_ME_TO_A_SECURE_RANDOM_STRING" + "postgresql://payd_user:CHANGE_ME@postgres:5432/payd_db" + "payd_user" +) + +# Read the expected flat stringData mapping. Unsupported syntax fails closed +# instead of silently stopping before an entry that the checker must inspect. +values=$(python3 - "$SECRET_FILE" <<'PY' +from pathlib import Path +import re +import sys + +try: + content = Path(sys.argv[1]).read_text() +except (OSError, UnicodeError): + sys.exit(2) + +lines = content.splitlines() +headers = [ + i for i, line in enumerate(lines) + if re.fullmatch(r'stringData:[ \t]*(?:#.*)?', line) +] +if len(headers) != 1 or re.search(r'^[ \t]*(?:data|"data"|\'data\')[ \t]*:', content, re.MULTILINE): + sys.exit(2) + +# This guard supports one Secret document, not lists or nested Secret objects. +for line in lines[:headers[0]]: + if not line.strip() or line.lstrip().startswith('#'): + continue + if line.startswith((' ', '\t')): + if re.match(r'^[ \t]*(?:stringData|"stringData"|\'stringData\')[ \t]*:', line): + sys.exit(2) + continue + if not re.fullmatch(r'(?:apiVersion:[ \t]+v1|kind:[ \t]+Secret|metadata:[ \t]*|type:[ \t]+Opaque)(?:[ \t]+#.*)?', line): + sys.exit(2) + +values = [] +keys = set() +indent = None +for line in lines[headers[0] + 1:]: + if not line.strip() or line.lstrip().startswith('#'): + continue + entry = re.fullmatch(r'( +)([A-Za-z0-9_.-]+):[ \t]*(.*)', line) + if not entry: + sys.exit(2) + spaces, key, value = entry.groups() + if indent is None: + indent = len(spaces) + if len(spaces) != indent or key in keys: + sys.exit(2) + keys.add(key) + value = value.strip() + if value.startswith('"') or value.endswith('"'): + if len(value) < 2 or not (value.startswith('"') and value.endswith('"')) or '"' in value[1:-1]: + sys.exit(2) + value = value[1:-1] + values.append(value) + +if not values: + sys.exit(2) +print('\n'.join(values)) +PY +) + +if [[ -z "$values" ]]; then + echo "ERROR: Could not parse stringData values from $SECRET_FILE" + exit 2 +fi + +failed=0 +while IFS= read -r value; do + matched=0 + for allowed in "${ALLOWED_VALUES[@]}"; do + if [[ "$value" == "$allowed" ]]; then + matched=1 + break + fi + done + if [[ $matched -eq 0 ]]; then + echo "FAIL: $SECRET_FILE contains a non-placeholder value." + echo " Real secrets must not be committed. See k8s/README.md for safe alternatives." + failed=1 + fi +done <<< "$values" + +if [[ $failed -eq 1 ]]; then + exit 1 +fi + +echo "OK: $SECRET_FILE contains only placeholder values." +exit 0 From 7470e965d1eb610598a4598a3abb1bfe99fd22f1 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 04:43:49 -0400 Subject: [PATCH 05/11] fix(revenue_split): prevent share and distribution arithmetic overflow Reject overflowing basis-point totals instead of accepting modular sums. Compute each non-final share with exact quotient/remainder arithmetic so valid positive i128 amounts do not overflow an intermediate multiplication. Preserve final-recipient dust handling and the DistributionExecuted event schema. Add three focused native contract regressions for initialization, update rollback and large-amount balances/event fields. Runtime results are recorded separately after execution. --- .../revenue_split/src/arithmetic_test.rs | 98 +++++++++++++++++++ contracts/revenue_split/src/lib.rs | 15 ++- 2 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 contracts/revenue_split/src/arithmetic_test.rs diff --git a/contracts/revenue_split/src/arithmetic_test.rs b/contracts/revenue_split/src/arithmetic_test.rs new file mode 100644 index 00000000..f03e4640 --- /dev/null +++ b/contracts/revenue_split/src/arithmetic_test.rs @@ -0,0 +1,98 @@ +use crate::{ContractError, RecipientShare, RevenueSplitContract, RevenueSplitContractClient}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, FromVal, IntoVal, Map, Symbol, Val, Vec, +}; + +#[test] +fn overflowing_share_total_is_rejected_at_init() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &id); + let admin = Address::generate(&env); + let first = Address::generate(&env); + let last = Address::generate(&env); + let invalid = Vec::from_array(&env, [ + RecipientShare { destination: first.clone(), basis_points: u32::MAX }, + RecipientShare { destination: last.clone(), basis_points: 10001 }, + ]); + assert_eq!(client.try_init(&admin, &invalid), Err(Ok(ContractError::SharesMustSumToTotal))); + // A failed validation must not leave the contract initialized. + let valid = Vec::from_array(&env, [ + RecipientShare { destination: first, basis_points: 6000 }, + RecipientShare { destination: last, basis_points: 4000 }, + ]); + client.init(&admin, &valid); +} + +#[test] +fn overflowing_share_update_preserves_active_split() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &id); + let admin = Address::generate(&env); + let first = Address::generate(&env); + let last = Address::generate(&env); + client.init(&admin, &Vec::from_array(&env, [ + RecipientShare { destination: first.clone(), basis_points: 5000 }, + RecipientShare { destination: last.clone(), basis_points: 5000 }, + ])); + let invalid = Vec::from_array(&env, [ + RecipientShare { destination: first.clone(), basis_points: u32::MAX }, + RecipientShare { destination: last.clone(), basis_points: 10001 }, + ]); + assert_eq!(client.try_update_recipients(&invalid), Err(Ok(ContractError::SharesMustSumToTotal))); + let token_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let token = TokenClient::new(&env, &token_id); + let sender = Address::generate(&env); + StellarAssetClient::new(&env, &token_id).mint(&sender, &100); + client.distribute(&sender, &Vec::from_array(&env, [(token_id.clone(), 100)])); + assert_eq!(token.balance(&first), 50); + assert_eq!(token.balance(&last), 50); + assert_eq!(token.balance(&sender), 0); +} + +#[test] +fn large_distribution_preserves_balances_and_event_payload() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &id); + let admin = Address::generate(&env); + let first = Address::generate(&env); + let last = Address::generate(&env); + client.init(&admin, &Vec::from_array(&env, [ + RecipientShare { destination: first.clone(), basis_points: 6000 }, + RecipientShare { destination: last.clone(), basis_points: 4000 }, + ])); + let token_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let token = TokenClient::new(&env, &token_id); + let sender = Address::generate(&env); + let amount = i128::MAX / 2; + StellarAssetClient::new(&env, &token_id).mint(&sender, &amount); + assert_eq!(client.try_distribute(&sender, &Vec::from_array(&env, [(token_id.clone(), amount)])), Ok(Ok(()))); + + // Read the event before later token calls replace the invocation's log. + let topic: Val = Symbol::new(&env, "distribution_executed_event").into_val(&env); + let mut count = 0; + for (contract, topics, data) in env.events().all().iter() { + if contract != id || topics.first() != Some(topic) { + continue; + } + count += 1; + let fields = Map::::from_val(&env, &data); + assert_eq!(i128::from_val(&env, &fields.get(Symbol::new(&env, "total_amount")).unwrap()), amount); + assert_eq!(u32::from_val(&env, &fields.get(Symbol::new(&env, "recipient_count")).unwrap()), 2); + assert_eq!(Address::from_val(&env, &fields.get(Symbol::new(&env, "asset")).unwrap()), token_id); + assert_eq!(Vec::::from_val(&env, &fields.get(Symbol::new(&env, "split_percentages")).unwrap()), Vec::from_array(&env, [6000, 4000])); + } + assert_eq!(count, 1); + // Independent reduced-ratio oracle, avoiding the original multiply overflow. + let first_amount = (amount / 5) * 3 + ((amount % 5) * 3) / 5; + assert_eq!(token.balance(&sender), 0); + assert_eq!(token.balance(&first), first_amount); + assert_eq!(token.balance(&last), amount - first_amount); +} diff --git a/contracts/revenue_split/src/lib.rs b/contracts/revenue_split/src/lib.rs index 3fb21b1d..9a9540de 100644 --- a/contracts/revenue_split/src/lib.rs +++ b/contracts/revenue_split/src/lib.rs @@ -5,6 +5,8 @@ use common::CommonError; #[cfg(test)] mod test; +#[cfg(test)] +mod arithmetic_test; #[contracttype] pub enum DataKey { @@ -131,8 +133,13 @@ impl RevenueSplitContract { client.transfer(&from, &share.destination, &final_amount); } } else { - let recipient_amount = - (amount * share.basis_points as i128) / TOTAL_BASIS_POINTS as i128; + // Divide first without losing the fractional contribution. + // Validated weights are <= 10000, so neither product nor + // their sum can exceed the positive input amount. + let divisor = TOTAL_BASIS_POINTS as i128; + let weight = share.basis_points as i128; + let recipient_amount = (amount / divisor) * weight + + ((amount % divisor) * weight) / divisor; if recipient_amount > 0 { client.transfer(&from, &share.destination, &recipient_amount); amount_distributed += recipient_amount; @@ -166,7 +173,9 @@ impl RevenueSplitContract { let mut seen: Vec
= Vec::new(env); for share in shares.iter() { - total_bp = total_bp.wrapping_add(share.basis_points); + total_bp = total_bp + .checked_add(share.basis_points) + .ok_or(ContractError::SharesMustSumToTotal)?; // Prevent duplicates; duplicates create ambiguity and can cause unexpected dust behavior. for addr in seen.iter() { From 0d09b36c2e732d372ebdf484d9b7c31f4f139933 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 04:47:07 -0400 Subject: [PATCH 06/11] test(revenue_split): compare event topics as typed symbols Use the SDK's Symbol equality instead of comparing raw Val objects, which intentionally do not implement PartialEq. Contract implementation and all three boundary assertions remain unchanged. --- contracts/revenue_split/src/arithmetic_test.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/revenue_split/src/arithmetic_test.rs b/contracts/revenue_split/src/arithmetic_test.rs index f03e4640..cd82526f 100644 --- a/contracts/revenue_split/src/arithmetic_test.rs +++ b/contracts/revenue_split/src/arithmetic_test.rs @@ -2,7 +2,7 @@ use crate::{ContractError, RecipientShare, RevenueSplitContract, RevenueSplitCon use soroban_sdk::{ testutils::{Address as _, Events}, token::{Client as TokenClient, StellarAssetClient}, - Address, Env, FromVal, IntoVal, Map, Symbol, Val, Vec, + Address, Env, FromVal, Map, Symbol, Val, Vec, }; #[test] @@ -76,10 +76,10 @@ fn large_distribution_preserves_balances_and_event_payload() { assert_eq!(client.try_distribute(&sender, &Vec::from_array(&env, [(token_id.clone(), amount)])), Ok(Ok(()))); // Read the event before later token calls replace the invocation's log. - let topic: Val = Symbol::new(&env, "distribution_executed_event").into_val(&env); + let topic = Symbol::new(&env, "distribution_executed_event"); let mut count = 0; for (contract, topics, data) in env.events().all().iter() { - if contract != id || topics.first() != Some(topic) { + if contract != id || topics.first().map(|value| Symbol::from_val(&env, &value)) != Some(topic.clone()) { continue; } count += 1; From 6b4b0406e41d94ac3cc9752799ce055aff0de45f Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 04:51:10 -0400 Subject: [PATCH 07/11] docs(revenue_split): record exact native arithmetic replay All three boundary regressions fail against the original implementation. All 15 maintained package tests pass against product source 0d09b36c in run 37190079744 using the unchanged lockfile and pinned Rust 1.89 toolchain. Preserve source identities, commands, raw-result anchors and explicit runtime limits. --- docs/revenue-split-arithmetic-0d09b36c.md | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/revenue-split-arithmetic-0d09b36c.md diff --git a/docs/revenue-split-arithmetic-0d09b36c.md b/docs/revenue-split-arithmetic-0d09b36c.md new file mode 100644 index 00000000..e019003d --- /dev/null +++ b/docs/revenue-split-arithmetic-0d09b36c.md @@ -0,0 +1,53 @@ +# Revenue split arithmetic validation — 2026-10-04 + +Product source: `0d09b36c2e732d372ebdf484d9b7c31f4f139933`. +Baseline: `e04c31bd6e594b24b4bcb7c56c5a2132e1e43921`. +Existing submission: [Protocol-Guild/PayD PR 636](https://github.com/Protocol-Guild/PayD/pull/636), issue 515. + +## Executed result + +[Native replay run 37190079744](https://github.com/woahwhattheheck/PayD/actions/runs/37190079744), job `111400243183`, completed successfully. The command below ran the actual contract and Soroban token implementation, with synthetic balances and the repository's existing authentication mocks: + +```sh +cargo test --locked -p revenue_split --lib +``` + +Result: **15 passed, 0 failed, 0 ignored, 0 filtered out**, reported test time **0.07 s**. This comprises all 12 existing package tests, unchanged, plus three arithmetic regressions. This is a correctness result, not a performance benchmark. + +| Regression | Original implementation | Repaired implementation | +| --- | --- | --- | +| Overflowing share total at initialization | Accepted `[u32::MAX, 10001]`: `Ok(Ok(()))`; regression failed, exit 101 | Returns `SharesMustSumToTotal`; subsequent valid initialization succeeds | +| Overflowing share total at update | Accepted the invalid split: `Ok(Ok(()))`; regression failed, exit 101 | Rejects update; subsequent distribution retains the prior 50/50 balances | +| Large positive distribution, `i128::MAX / 2`, 60/40 split | Returns `Err(Err(Abort))`; regression failed, exit 101 | Correct sender/recipient balances and exactly one event with the correct asset, total, count and split weights | + +Baseline execution used the original `lib.rs` plus only the test-module declaration, with the same new regression file. Each case ran separately with `--exact --nocapture`; all three reached execution and failed their assertions. The workflow then restored and hash-checked the exact repaired production file and ran all 15 tests. The lockfile and existing test source remained unchanged. + +## Repair + +Share totals use checked addition instead of modular addition. Non-final recipient amounts use the exact identity + +```text +floor(amount * weight / 10000) += (amount / 10000) * weight + floor((amount % 10000) * weight / 10000) +``` + +For positive amounts and validated weights in `0..=10000`, both intermediates and their sum fit within the input amount. Final-recipient remainder handling and the event schema are unchanged. + +## Runtime and provenance + +Ubuntu 24.04.5, x86_64; Rust 1.89.0 (`29483883e`), Cargo 1.89.0; locked Soroban SDK 23.5.2 and host 23.0.1. The repository's pinned toolchain and Cargo.lock were used; no dependency changes were made. Compilation of the test target took 58.25 s, separately from the test-execution time above. + +Git blob identities: + +- Production `lib.rs`: `9a9540dee5ffc07b251e2f335d1dcbd44ab82159`. +- New `arithmetic_test.rs`: `cd82526f8a19ff019886003617f9dc83e3bd9cf8`. +- Unchanged `test.rs`: `3e15a0202d13cce144d3ac00bcf14287373e1d17`. +- Unchanged `Cargo.lock`: `927dac874854b3fb1fb4fd697b479d2b2fcdbbbc`. + +Replay workflow source: `777ff7d754ee15a72df6801833ccd1ad93596c6d` on the separate `validation/payd-636-arithmetic-rivet76f4-20261004` branch. It has read-only repository permission, no deployment step, and is not part of the product change. Logs were uploaded as artifact `11297804221`, SHA256 `fb3dc829be1d36997de0d27792886db5fdac4f90abbcc489c1086f247d499dd3`, with one-day artifact retention; the run log also contains the results. + +The first attempt, run 37189908720, stopped at compilation because the new test compared raw SDK `Val` objects. The test-only correction compares typed `Symbol` values; no successful runtime result is attributed to that first attempt. + +## Limits + +This does not claim a full-workspace build, WASM deployment, live-chain transaction, indexer integration, maintainer acceptance or reward payment. Existing unrelated workflow failures are not declared green by this focused replay. In particular, the inherited `contract-release.yml` still contains unsupported workflow-root `retention-days`; its tag-release behavior was not exercised or changed here. From 51b6da3eac570ad4f46aac56a09701b4fcc912a8 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:00:08 -0400 Subject: [PATCH 08/11] fix(ci): remove unsupported release workflow retention key Current source 6b4b0406 produced failed release run 37190265510 before any job was created. Remove only the unsupported root retention-days key and comments that claimed an unenforced per-outcome retention policy. Local YAML comparison verifies all remaining workflow values are unchanged, including the v*-tag trigger, permissions, concurrency, reusable workflow SHA, inputs and secret mapping. No tag, release or deployment was executed. Existing contract implementation and its 15-test execution receipt are unchanged. --- .github/workflows/contract-release.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/contract-release.yml b/.github/workflows/contract-release.yml index 2655fcbc..cca80486 100644 --- a/.github/workflows/contract-release.yml +++ b/.github/workflows/contract-release.yml @@ -5,7 +5,6 @@ on: tags: - "v*" -# Retention policy: Keep successful runs for 90 days, failed/cancelled for 14 days permissions: # required permissions for the workflow id-token: write contents: write # in order to create releases @@ -27,6 +26,3 @@ jobs: package: "..." secrets: release_token: ${{ secrets.GITHUB_TOKEN }} - -# Workflow run retention settings -retention-days: 90 \ No newline at end of file From ba8161f9d1723c34f2fb1531457e6f5897c4a18a Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:01:54 -0400 Subject: [PATCH 09/11] fix(ci): remove unsupported IPFS workflow retention key After the release-file correction, head 51b6da3e produced only failed IPFS workflow run 37190732682. Remove that file's matching unsupported root retention-days setting and unenforced retention comments. The complete parsed workflow is identical after removing that one root key: production/manual triggers, environment, all jobs/steps, permissions, action pins and secret mappings are preserved. No workflow dispatch, production-branch push, IPFS publication, release or deployment was performed. No contract source or existing validation changed. --- .github/workflows/dapp-ipfs.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/dapp-ipfs.yml b/.github/workflows/dapp-ipfs.yml index 29d16e55..5887cc7e 100644 --- a/.github/workflows/dapp-ipfs.yml +++ b/.github/workflows/dapp-ipfs.yml @@ -9,7 +9,6 @@ on: workflow_dispatch: -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true @@ -65,6 +64,3 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "- CID: ${{ steps.storacha.outputs.cid }}" >> "$GITHUB_STEP_SUMMARY" echo "- URL: ${{ steps.storacha.outputs.url }}" >> "$GITHUB_STEP_SUMMARY" - -# Workflow run retention settings -retention-days: 30 \ No newline at end of file From b4801f35bd217a226c5cf9a09ccc4110d695fdda Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:22:58 -0400 Subject: [PATCH 10/11] docs: reconcile completed distribution workflow follow-through [skip ci] --- docs/revenue-split-arithmetic-0d09b36c.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/revenue-split-arithmetic-0d09b36c.md b/docs/revenue-split-arithmetic-0d09b36c.md index e019003d..c15b4285 100644 --- a/docs/revenue-split-arithmetic-0d09b36c.md +++ b/docs/revenue-split-arithmetic-0d09b36c.md @@ -50,4 +50,12 @@ The first attempt, run 37189908720, stopped at compilation because the new test ## Limits -This does not claim a full-workspace build, WASM deployment, live-chain transaction, indexer integration, maintainer acceptance or reward payment. Existing unrelated workflow failures are not declared green by this focused replay. In particular, the inherited `contract-release.yml` still contains unsupported workflow-root `retention-days`; its tag-release behavior was not exercised or changed here. +This does not claim a full-workspace build, WASM deployment, live-chain transaction, indexer integration, maintainer acceptance or reward payment. Existing unrelated workflow failures are not declared green by this focused replay. At the validated product source above, `contract-release.yml` still contained unsupported workflow-root `retention-days`; its tag-release behavior was not exercised or changed by that replay. + +### Subsequent workflow follow-through — 2026-10-04 + +The original PR now includes the released workflow corrections through [`ba8161f9d1723c34f2fb1531457e6f5897c4a18a`](https://github.com/woahwhattheheck/PayD/commit/ba8161f9d1723c34f2fb1531457e6f5897c4a18a). The unsupported root retention settings have been removed from `contract-release.yml` and `ipfs-deploy.yml`. These are source/schema repairs; neither a production release nor an IPFS deployment was dispatched or demonstrated. Their existing triggers do not match this feature-branch push, so the absence of a run is not a successful deployment. + +The earlier [`e04c31bd6e594b24b4bcb7c56c5a2132e1e43921`](https://github.com/woahwhattheheck/PayD/commit/e04c31bd6e594b24b4bcb7c56c5a2132e1e43921) repaired the build and secrets-check workflows, restored their executable checker, and preserved failed Scaffold producer status through `tee`. The corresponding sponsor runs [37187697785](https://github.com/Protocol-Guild/PayD/actions/runs/37187697785) and [37187697799](https://github.com/Protocol-Guild/PayD/actions/runs/37187697799) were observed as `action_required`, not completed builds. + +These subsequent workflow and documentation changes leave the contract source, all 15 tests and dependency lockfile from the completed arithmetic result unchanged. That result remains pinned to `0d09b36c2e732d372ebdf484d9b7c31f4f139933`; no new execution, sponsor acceptance or reward payment is asserted. From 85f40a591c26767c1e868bbb5ec66574f06825a9 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 09:48:14 -0400 Subject: [PATCH 11/11] perf(revenue_split): reuse split weights across distribution events --- contracts/revenue_split/src/lib.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/contracts/revenue_split/src/lib.rs b/contracts/revenue_split/src/lib.rs index 9a9540de..ea3c3a3c 100644 --- a/contracts/revenue_split/src/lib.rs +++ b/contracts/revenue_split/src/lib.rs @@ -111,6 +111,10 @@ impl RevenueSplitContract { .get(&DataKey::Recipients) .ok_or(ContractError::NotInitialized)?; + // Build event weights only after the first successful asset distribution. + // Later events reuse the immutable host vector through cloned handles. + let mut event_split_percentages: Option> = None; + for asset_pair in assets.iter() { let token = asset_pair.0; let amount = asset_pair.1; @@ -147,16 +151,19 @@ impl RevenueSplitContract { } } - let mut split_percentages: Vec = Vec::new(&env); - for share in shares.iter() { - split_percentages.push_back(share.basis_points); - } + let split_percentages = event_split_percentages.get_or_insert_with(|| { + let mut percentages = Vec::new(&env); + for share in shares.iter() { + percentages.push_back(share.basis_points); + } + percentages + }); DistributionExecutedEvent { asset: token.clone(), total_amount: amount, recipient_count: shares.len(), - split_percentages, + split_percentages: split_percentages.clone(), } .publish(&env); }