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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -113,6 +113,3 @@ jobs:
- name: Run Tests
working-directory: ./frontend
run: npm test --if-present

# Workflow run retention settings
retention-days: 30
4 changes: 0 additions & 4 deletions .github/workflows/contract-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +26,3 @@ jobs:
package: "..."
secrets:
release_token: ${{ secrets.GITHUB_TOKEN }}

# Workflow run retention settings
retention-days: 90
4 changes: 0 additions & 4 deletions .github/workflows/dapp-ipfs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
8 changes: 4 additions & 4 deletions .github/workflows/secrets-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
98 changes: 98 additions & 0 deletions contracts/revenue_split/src/arithmetic_test.rs
Original file line number Diff line number Diff line change
@@ -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, 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 = Symbol::new(&env, "distribution_executed_event");
let mut count = 0;
for (contract, topics, data) in env.events().all().iter() {
if contract != id || topics.first().map(|value| Symbol::from_val(&env, &value)) != Some(topic.clone()) {
continue;
}
count += 1;
let fields = Map::<Symbol, Val>::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::<u32>::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);
}
70 changes: 55 additions & 15 deletions contracts/revenue_split/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
#![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)]
mod test;
#[cfg(test)]
mod arithmetic_test;

#[contracttype]
pub enum DataKey {
Expand Down Expand Up @@ -44,6 +46,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<u32>,
}

#[contract]
pub struct RevenueSplitContract;

Expand Down Expand Up @@ -96,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<Vec<u32>> = None;

for asset_pair in assets.iter() {
let token = asset_pair.0;
let amount = asset_pair.1;
Expand All @@ -109,25 +128,44 @@ 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 {
// 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;
}
}
}

env.events().publish((Symbol::new(&env, "distribute"), token.clone()), amount);
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.clone(),
}
.publish(&env);
}

Ok(())
Expand All @@ -142,7 +180,9 @@ impl RevenueSplitContract {
let mut seen: Vec<Address> = 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() {
Expand Down
Loading