From 2feeb02878a1d3713c0a66e8965776aad11d765e Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Wed, 25 Mar 2026 04:13:15 +0100 Subject: [PATCH 1/8] feat(vault): add FeeTooHigh error variant for BPS fee cap --- contracts/payment-vault-contract/src/error.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/payment-vault-contract/src/error.rs b/contracts/payment-vault-contract/src/error.rs index c746040..877bec6 100644 --- a/contracts/payment-vault-contract/src/error.rs +++ b/contracts/payment-vault-contract/src/error.rs @@ -13,4 +13,5 @@ pub enum VaultError { ReclaimTooEarly = 7, ContractPaused = 8, ExpertRateNotSet = 9, + FeeTooHigh = 11, } From f354df09bdb81db8569f8ef86ee2722c57c842d6 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Wed, 25 Mar 2026 04:13:15 +0100 Subject: [PATCH 2/8] feat(vault): add FeeBps and Treasury storage keys with setters/getters --- .../payment-vault-contract/src/storage.rs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/contracts/payment-vault-contract/src/storage.rs b/contracts/payment-vault-contract/src/storage.rs index e82c4b9..aca3b96 100644 --- a/contracts/payment-vault-contract/src/storage.rs +++ b/contracts/payment-vault-contract/src/storage.rs @@ -11,8 +11,10 @@ pub enum DataKey { BookingCounter, // Counter for generating unique booking IDs UserBookings(Address), // User Address -> Vec of booking IDs ExpertBookings(Address), // Expert Address -> Vec of booking IDs - IsPaused, // Circuit breaker flag - ExpertRate(Address), // Expert Address -> rate per second (i128) + IsPaused, + ExpertRate(Address), + FeeBps, + Treasury, } // --- Admin --- @@ -148,3 +150,24 @@ pub fn get_expert_rate(env: &Env, expert: &Address) -> Option { .persistent() .get(&DataKey::ExpertRate(expert.clone())) } + +// --- Fee BPS --- +pub fn set_fee_bps(env: &Env, fee_bps: u32) { + env.storage().instance().set(&DataKey::FeeBps, &fee_bps); +} + +pub fn get_fee_bps(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::FeeBps) + .unwrap_or(0) +} + +// --- Treasury --- +pub fn set_treasury(env: &Env, treasury: &Address) { + env.storage().instance().set(&DataKey::Treasury, treasury); +} + +pub fn get_treasury(env: &Env) -> Option
{ + env.storage().instance().get(&DataKey::Treasury) +} From 525336b46b714c7a53665a01ab66da9c522d0603 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Wed, 25 Mar 2026 04:13:15 +0100 Subject: [PATCH 3/8] feat(vault): emit fee_amount in session_finalized event --- contracts/payment-vault-contract/src/events.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/payment-vault-contract/src/events.rs b/contracts/payment-vault-contract/src/events.rs index 3d20162..ac04f37 100644 --- a/contracts/payment-vault-contract/src/events.rs +++ b/contracts/payment-vault-contract/src/events.rs @@ -15,9 +15,9 @@ pub fn booking_created( } /// Emitted when a session is finalized -pub fn session_finalized(env: &Env, booking_id: u64, actual_duration: u64, total_cost: i128) { +pub fn session_finalized(env: &Env, booking_id: u64, actual_duration: u64, expert_pay: i128, fee_amount: i128) { let topics = (symbol_short!("finalized"), booking_id); - env.events().publish(topics, (actual_duration, total_cost)); + env.events().publish(topics, (actual_duration, expert_pay, fee_amount)); } pub fn session_reclaimed(env: &Env, booking_id: u64, amount: i128) { From 46111cc838295093ec2ff740662fe0bac11bb6c2 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Wed, 25 Mar 2026 04:13:15 +0100 Subject: [PATCH 4/8] feat(vault): implement set_fee, set_treasury and BPS fee deduction in finalize_session --- .../payment-vault-contract/src/contract.rs | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/contracts/payment-vault-contract/src/contract.rs b/contracts/payment-vault-contract/src/contract.rs index 544b62f..2edb71b 100644 --- a/contracts/payment-vault-contract/src/contract.rs +++ b/contracts/payment-vault-contract/src/contract.rs @@ -39,6 +39,23 @@ pub fn unpause(env: &Env) -> Result<(), VaultError> { Ok(()) } +pub fn set_fee(env: &Env, new_fee_bps: u32) -> Result<(), VaultError> { + let admin = storage::get_admin(env).ok_or(VaultError::NotInitialized)?; + admin.require_auth(); + if new_fee_bps > 2000 { + return Err(VaultError::FeeTooHigh); + } + storage::set_fee_bps(env, new_fee_bps); + Ok(()) +} + +pub fn set_treasury(env: &Env, treasury: &Address) -> Result<(), VaultError> { + let admin = storage::get_admin(env).ok_or(VaultError::NotInitialized)?; + admin.require_auth(); + storage::set_treasury(env, treasury); + Ok(()) +} + pub fn set_my_rate(env: &Env, expert: &Address, rate_per_second: i128) -> Result<(), VaultError> { expert.require_auth(); @@ -137,26 +154,33 @@ pub fn finalize_session( } // 4. Calculate payments - let expert_pay = booking.rate_per_second * (actual_duration as i128); - let refund = booking.total_deposit - expert_pay; + let gross_expert_pay = booking.rate_per_second * (actual_duration as i128); + let refund = booking.total_deposit - gross_expert_pay; - // Ensure calculations are valid - if expert_pay < 0 || refund < 0 { + if gross_expert_pay < 0 || refund < 0 { return Err(VaultError::InvalidAmount); } + let fee_bps = storage::get_fee_bps(env); + let fee_amount = (gross_expert_pay * fee_bps as i128) / 10_000; + let expert_net_pay = gross_expert_pay - fee_amount; + // 5. Get token contract let token_address = storage::get_token(env); let token_client = token::Client::new(env, &token_address); let contract_address = env.current_contract_address(); // 6. Execute transfers - // Pay expert - if expert_pay > 0 { - token_client.transfer(&contract_address, &booking.expert, &expert_pay); + if fee_amount > 0 { + if let Some(treasury) = storage::get_treasury(env) { + token_client.transfer(&contract_address, &treasury, &fee_amount); + } + } + + if expert_net_pay > 0 { + token_client.transfer(&contract_address, &booking.expert, &expert_net_pay); } - // Refund user if refund > 0 { token_client.transfer(&contract_address, &booking.user, &refund); } @@ -165,7 +189,7 @@ pub fn finalize_session( storage::update_booking_status(env, booking_id, BookingStatus::Complete); // 8. Emit SessionFinalized event - events::session_finalized(env, booking_id, actual_duration, expert_pay); + events::session_finalized(env, booking_id, actual_duration, expert_net_pay, fee_amount); Ok(()) } From e3f359f6cad86ae8179d3b8330d5b29807e9d6fd Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Wed, 25 Mar 2026 04:13:15 +0100 Subject: [PATCH 5/8] feat(vault): expose set_fee and set_treasury as public contract entrypoints --- contracts/payment-vault-contract/src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/contracts/payment-vault-contract/src/lib.rs b/contracts/payment-vault-contract/src/lib.rs index 651c2c4..c2e63db 100644 --- a/contracts/payment-vault-contract/src/lib.rs +++ b/contracts/payment-vault-contract/src/lib.rs @@ -39,6 +39,14 @@ impl PaymentVaultContract { contract::unpause(&env) } + pub fn set_fee(env: Env, new_fee_bps: u32) -> Result<(), VaultError> { + contract::set_fee(&env, new_fee_bps) + } + + pub fn set_treasury(env: Env, treasury: Address) -> Result<(), VaultError> { + contract::set_treasury(&env, &treasury) + } + /// Set an expert's own rate per second pub fn set_my_rate(env: Env, expert: Address, rate_per_second: i128) -> Result<(), VaultError> { contract::set_my_rate(&env, &expert, rate_per_second) From ea0d8381fe18cdf140c00e404650a4d26ea7ab3a Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Wed, 25 Mar 2026 04:13:15 +0100 Subject: [PATCH 6/8] test(vault): add platform fee tests for 10% BPS, cap enforcement, and refund correctness --- contracts/payment-vault-contract/src/test.rs | 151 +++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/contracts/payment-vault-contract/src/test.rs b/contracts/payment-vault-contract/src/test.rs index ea0e77d..47c1ef9 100644 --- a/contracts/payment-vault-contract/src/test.rs +++ b/contracts/payment-vault-contract/src/test.rs @@ -697,6 +697,157 @@ fn test_reject_nonexistent_booking() { assert!(result.is_err()); } +// ==================== Platform Fee Tests ==================== + +#[test] +fn test_set_fee_and_treasury() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let token = Address::generate(&env); + let treasury = Address::generate(&env); + + let client = create_client(&env); + client.init(&admin, &token, &oracle); + + let res = client.try_set_fee(&1000_u32); + assert!(res.is_ok()); + + let res = client.try_set_treasury(&treasury); + assert!(res.is_ok()); +} + +#[test] +fn test_fee_cap_at_2000_bps() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let token = Address::generate(&env); + + let client = create_client(&env); + client.init(&admin, &token, &oracle); + + let res = client.try_set_fee(&2000_u32); + assert!(res.is_ok()); + + let res = client.try_set_fee(&2001_u32); + assert!(res.is_err()); +} + +#[test] +fn test_finalize_with_10_percent_fee() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let expert = Address::generate(&env); + let oracle = Address::generate(&env); + let treasury = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + token.mint(&user, &10_000); + + let client = create_client(&env); + client.init(&admin, &token.address, &oracle); + + // 10% fee = 1000 BPS + client.set_fee(&1000_u32); + client.set_treasury(&treasury); + + // rate = 10/s, max_duration = 100s => deposit = 1000 + let booking_id = { + client.set_my_rate(&expert, &10_i128); + client.book_session(&user, &expert, &100_u64) + }; + + assert_eq!(token.balance(&user), 9_000); + + // finalize at 100s => gross_expert_pay = 1000 + // fee = 1000 * 1000 / 10000 = 100 + // expert_net = 900, refund = 0 + client.finalize_session(&booking_id, &100_u64); + + assert_eq!(token.balance(&treasury), 100); + assert_eq!(token.balance(&expert), 900); + assert_eq!(token.balance(&user), 9_000); + assert_eq!(token.balance(&client.address), 0); +} + +#[test] +fn test_finalize_with_fee_and_partial_refund() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let expert = Address::generate(&env); + let oracle = Address::generate(&env); + let treasury = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + token.mint(&user, &10_000); + + let client = create_client(&env); + client.init(&admin, &token.address, &oracle); + + // 10% fee + client.set_fee(&1000_u32); + client.set_treasury(&treasury); + + // rate = 10/s, max_duration = 100s => deposit = 1000 + let booking_id = { + client.set_my_rate(&expert, &10_i128); + client.book_session(&user, &expert, &100_u64) + }; + + // finalize at 50s => gross_expert_pay = 500 + // fee = 500 * 1000 / 10000 = 50 + // expert_net = 450, refund = 500 + client.finalize_session(&booking_id, &50_u64); + + assert_eq!(token.balance(&treasury), 50); + assert_eq!(token.balance(&expert), 450); + assert_eq!(token.balance(&user), 9_500); + assert_eq!(token.balance(&client.address), 0); +} + +#[test] +fn test_finalize_zero_fee_behaves_as_before() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let expert = Address::generate(&env); + let oracle = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + token.mint(&user, &10_000); + + let client = create_client(&env); + client.init(&admin, &token.address, &oracle); + + let booking_id = { + client.set_my_rate(&expert, &10_i128); + client.book_session(&user, &expert, &100_u64) + }; + + // no fee set (defaults to 0) + client.finalize_session(&booking_id, &100_u64); + + assert_eq!(token.balance(&expert), 1_000); + assert_eq!(token.balance(&user), 9_000); + assert_eq!(token.balance(&client.address), 0); +} + // ==================== Expert Rate Tests ==================== #[test] From 069e99fcff53e26c54164473e490611f13490ca4 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Wed, 25 Mar 2026 04:13:15 +0100 Subject: [PATCH 7/8] docs: add PR description for issue #31 platform revenue service fee --- PR_31_platform_revenue_fee.md | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 PR_31_platform_revenue_fee.md diff --git a/PR_31_platform_revenue_fee.md b/PR_31_platform_revenue_fee.md new file mode 100644 index 0000000..0c74322 --- /dev/null +++ b/PR_31_platform_revenue_fee.md @@ -0,0 +1,71 @@ +# feat(vault): implement platform revenue service fee (#31) + +## Summary + +Extracts a configurable percentage-based fee (in Basis Points, 10000 = 100%) from the expert's gross pay on every successfully finalized session, routing it to a designated treasury address. The user's refund is unaffected. + +--- + +## Proof of Successful Build & Tests + + +![Test Results]() + +--- + +## Changes + +### `src/error.rs` + +- Added `FeeTooHigh = 11` — returned when `set_fee` is called with a value above 2000 BPS (20%). + +### `src/storage.rs` + +- Added `DataKey::FeeBps` and `DataKey::Treasury` variants to the `DataKey` enum. +- Added `set_fee_bps` / `get_fee_bps` (instance storage, defaults to 0). +- Added `set_treasury` / `get_treasury` (instance storage). + +### `src/events.rs` + +- Updated `session_finalized` signature to include `fee_amount: i128` as a third payload field, giving off-chain indexers full visibility into fee extraction. + +### `src/contract.rs` + +- `set_fee(env, new_fee_bps)` — Admin-only. Rejects values above 2000 BPS. +- `set_treasury(env, treasury)` — Admin-only. Stores the treasury address. +- `finalize_session` — Updated payment calculation: + ``` + fee_amount = (gross_expert_pay × fee_bps) / 10_000 + expert_net = gross_expert_pay − fee_amount + refund = total_deposit − gross_expert_pay (unchanged) + ``` + Transfers `fee_amount → treasury`, `expert_net → expert`, `refund → user`. + +### `src/lib.rs` + +- Exposed `pub fn set_fee(env: Env, new_fee_bps: u32) -> Result<(), VaultError>`. +- Exposed `pub fn set_treasury(env: Env, treasury: Address) -> Result<(), VaultError>`. + +### `src/test.rs` + +- `test_set_fee_and_treasury` — verifies admin can set both values. +- `test_fee_cap_at_2000_bps` — 2000 BPS accepted, 2001 rejected. +- `test_finalize_with_10_percent_fee` — full session: treasury=100, expert=900, user unchanged. +- `test_finalize_with_fee_and_partial_refund` — partial session: treasury=50, expert=450, user refunded 500. +- `test_finalize_zero_fee_behaves_as_before` — no regression when fee is not set. + +--- + +## Acceptance Criteria + +- [x] Contract correctly calculates fractional fees using BPS (`fee = gross × bps / 10000`). +- [x] Admin cannot raise fee above 20% (2000 BPS) — returns `FeeTooHigh`. +- [x] Fee is deducted from expert's gross pay, not the user's refund. +- [x] All 33 tests pass with zero failures. + + + + +## Closes + +Closes #31 From 0df5476723dae32a4efebdfda020e5fee753d612 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Wed, 25 Mar 2026 04:13:56 +0100 Subject: [PATCH 8/8] remove pr file --- PR_31_platform_revenue_fee.md | 71 ----------------------------------- 1 file changed, 71 deletions(-) delete mode 100644 PR_31_platform_revenue_fee.md diff --git a/PR_31_platform_revenue_fee.md b/PR_31_platform_revenue_fee.md deleted file mode 100644 index 0c74322..0000000 --- a/PR_31_platform_revenue_fee.md +++ /dev/null @@ -1,71 +0,0 @@ -# feat(vault): implement platform revenue service fee (#31) - -## Summary - -Extracts a configurable percentage-based fee (in Basis Points, 10000 = 100%) from the expert's gross pay on every successfully finalized session, routing it to a designated treasury address. The user's refund is unaffected. - ---- - -## Proof of Successful Build & Tests - - -![Test Results]() - ---- - -## Changes - -### `src/error.rs` - -- Added `FeeTooHigh = 11` — returned when `set_fee` is called with a value above 2000 BPS (20%). - -### `src/storage.rs` - -- Added `DataKey::FeeBps` and `DataKey::Treasury` variants to the `DataKey` enum. -- Added `set_fee_bps` / `get_fee_bps` (instance storage, defaults to 0). -- Added `set_treasury` / `get_treasury` (instance storage). - -### `src/events.rs` - -- Updated `session_finalized` signature to include `fee_amount: i128` as a third payload field, giving off-chain indexers full visibility into fee extraction. - -### `src/contract.rs` - -- `set_fee(env, new_fee_bps)` — Admin-only. Rejects values above 2000 BPS. -- `set_treasury(env, treasury)` — Admin-only. Stores the treasury address. -- `finalize_session` — Updated payment calculation: - ``` - fee_amount = (gross_expert_pay × fee_bps) / 10_000 - expert_net = gross_expert_pay − fee_amount - refund = total_deposit − gross_expert_pay (unchanged) - ``` - Transfers `fee_amount → treasury`, `expert_net → expert`, `refund → user`. - -### `src/lib.rs` - -- Exposed `pub fn set_fee(env: Env, new_fee_bps: u32) -> Result<(), VaultError>`. -- Exposed `pub fn set_treasury(env: Env, treasury: Address) -> Result<(), VaultError>`. - -### `src/test.rs` - -- `test_set_fee_and_treasury` — verifies admin can set both values. -- `test_fee_cap_at_2000_bps` — 2000 BPS accepted, 2001 rejected. -- `test_finalize_with_10_percent_fee` — full session: treasury=100, expert=900, user unchanged. -- `test_finalize_with_fee_and_partial_refund` — partial session: treasury=50, expert=450, user refunded 500. -- `test_finalize_zero_fee_behaves_as_before` — no regression when fee is not set. - ---- - -## Acceptance Criteria - -- [x] Contract correctly calculates fractional fees using BPS (`fee = gross × bps / 10000`). -- [x] Admin cannot raise fee above 20% (2000 BPS) — returns `FeeTooHigh`. -- [x] Fee is deducted from expert's gross pay, not the user's refund. -- [x] All 33 tests pass with zero failures. - - - - -## Closes - -Closes #31