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
4 changes: 2 additions & 2 deletions contracts/tipz/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ pub enum ContractError {
NoRefundRequest = 47,
InvalidMessage = 48,
SubLimitReached = 49,
RefundReqExpired = 50,
ConfigMismatch = 50,
}

impl ContractError {
Expand All @@ -75,7 +75,7 @@ impl ContractError {
pub const MigrationDowngradeRejected: Self = Self::MigrationDowngrade;
pub const InvalidMigrationVersion: Self = Self::InvalidMigration;
pub const SubscriptionLimitReached: Self = Self::SubLimitReached;
pub const RefundRequestExpired: Self = Self::RefundReqExpired;
pub const RefundRequestExpired: Self = Self::InvalidInput;
pub const MultisigRequired: Self = Self::InvalidInput;
pub const WdrBelowMin: Self = Self::ProposalExpired;
pub const WithdrawalBelowMinimum: Self = Self::WdrBelowMin;
Expand Down
9 changes: 9 additions & 0 deletions contracts/tipz/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ impl TipzContract {
// ──────────────────────────────────────────────

/// Send an XLM tip to a registered creator.
///
/// Optional `expected_min_tip` and `expected_fee_bps` pin the config the
/// caller observed. A mismatch returns [`ContractError::ConfigMismatch`]
/// before any state change. Passing `None` for either preserves existing
/// behaviour for that check.
pub fn send_tip(
env: Env,
tipper: Address,
Expand All @@ -228,6 +233,8 @@ impl TipzContract {
message: String,
is_anonymous: bool,
is_encrypted: bool,
expected_min_tip: Option<i128>,
expected_fee_bps: Option<u32>,
) -> Result<(), ContractError> {
tips::send_tip(
&env,
Expand All @@ -237,6 +244,8 @@ impl TipzContract {
&message,
is_anonymous,
is_encrypted,
expected_min_tip,
expected_fee_bps,
)
}

Expand Down
2 changes: 2 additions & 0 deletions contracts/tipz/src/subscription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ fn execute_due_subscription_internal(
&String::from_str(env, "Recurring Tip"),
false,
false,
None::<i128>,
None::<u32>,
)?;

// Advance next_due by exactly one interval, no drift
Expand Down
1 change: 1 addition & 0 deletions contracts/tipz/src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ mod test_migrations;
mod test_init;
mod test_multisig;
mod test_multisig_admin_guard;
mod test_tip_expectations;
2 changes: 1 addition & 1 deletion contracts/tipz/src/test/test_emergency_withdraw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ fn setup() -> TestCtx<'static> {

// Fund contract & creator profile balance via send_tip
let msg = String::from_str(&env, "tip");
client.send_tip(&tipper, &creator, &10_000_000, &msg, &false, &false);
client.send_tip(&tipper, &creator, &10_000_000, &msg, &false, &false, &None, &None);

TestCtx {
env,
Expand Down
187 changes: 187 additions & 0 deletions contracts/tipz/src/test/test_tip_expectations.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
//! Pinned min-tip / fee expectations on `send_tip`.
//!
//! If config changes between the caller signing and the transaction landing,
//! a mismatch must abort with `ConfigMismatch` before any state change.
//! Omitting the optional params preserves prior behaviour.

#![cfg(test)]

use soroban_sdk::{
testutils::{Address as _, Events},
token, Address, Env, String,
};

use crate::errors::ContractError;
use crate::TipzContract;
use crate::TipzContractClient;

struct TestCtx<'a> {
env: Env,
client: TipzContractClient<'a>,
admin: Address,
creator: Address,
tipper: Address,
native_token: Address,
}

fn setup() -> TestCtx<'static> {
let env = Env::default();
env.mock_all_auths();
env.budget().reset_unlimited();

let contract_id = env.register_contract(None, TipzContract);
let client = TipzContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let fee_collector = Address::generate(&env);
let token_admin = Address::generate(&env);
let native_token = env
.register_stellar_asset_contract_v2(token_admin.clone())
.address();

let creator = Address::generate(&env);
let tipper = Address::generate(&env);

let sac_client = token::StellarAssetClient::new(&env, &native_token);
sac_client.mint(&tipper, &100_000_000);

client.initialize(&admin, &fee_collector, &200_u32, &native_token);
client.register_profile(
&creator,
&String::from_str(&env, "alice"),
&String::from_str(&env, "Alice"),
&String::from_str(&env, "bio"),
&String::from_str(&env, "https://image.png"),
&String::from_str(&env, "alice_x"),
);

TestCtx {
env,
client,
admin,
creator,
tipper,
native_token,
}
}

fn tipper_balance(ctx: &TestCtx) -> i128 {
token::TokenClient::new(&ctx.env, &ctx.native_token).balance(&ctx.tipper)
}

fn creator_balance(ctx: &TestCtx) -> i128 {
ctx.client.get_profile(&ctx.creator).profile.balance
}

fn assert_tip_not_applied(ctx: &TestCtx, tipper_before: i128, events_before: u32) {
assert_eq!(tipper_balance(ctx), tipper_before);
assert_eq!(creator_balance(ctx), 0);
assert_eq!(ctx.client.get_stats().total_tips_count, 0);
assert_eq!(
ctx.env.events().all().len(),
events_before,
"rejected tip must not emit events"
);
}

#[test]
fn test_send_tip_matching_expectations() {
let ctx = setup();
let min_tip = ctx.client.get_min_tip_amount();
let fee_bps = ctx.client.get_config().fee_bps;
let amount = 10_000_000_i128;
let msg = String::from_str(&ctx.env, "tip");

ctx.client.send_tip(
&ctx.tipper,
&ctx.creator,
&amount,
&msg,
&false,
&false,
&Some(min_tip),
&Some(fee_bps),
);

assert_eq!(creator_balance(&ctx), amount);
assert_eq!(ctx.client.get_stats().total_tips_count, 1);
}

#[test]
fn test_send_tip_mismatched_min_tip() {
let ctx = setup();
let original_min = ctx.client.get_min_tip_amount();
let amount = 10_000_000_i128;
let msg = String::from_str(&ctx.env, "tip");

ctx.client
.set_min_tip_amount(&ctx.admin, &(original_min + 1_000_000));

let tipper_before = tipper_balance(&ctx);
let events_before = ctx.env.events().all().len();

let result = ctx.client.try_send_tip(
&ctx.tipper,
&ctx.creator,
&amount,
&msg,
&false,
&false,
&Some(original_min),
&None,
);

assert_eq!(result, Err(Ok(ContractError::ConfigMismatch)));
assert_tip_not_applied(&ctx, tipper_before, events_before);
}

#[test]
fn test_send_tip_mismatched_fee_bps() {
let ctx = setup();
let original_fee = ctx.client.get_config().fee_bps;
let amount = 10_000_000_i128;
let msg = String::from_str(&ctx.env, "tip");

// Fee decreases apply immediately, so the on-chain value diverges from
// the fee the caller pinned at sign time.
ctx.client.set_fee(&ctx.admin, &100_u32);
assert_ne!(ctx.client.get_config().fee_bps, original_fee);

let tipper_before = tipper_balance(&ctx);
let events_before = ctx.env.events().all().len();

let result = ctx.client.try_send_tip(
&ctx.tipper,
&ctx.creator,
&amount,
&msg,
&false,
&false,
&None,
&Some(original_fee),
);

assert_eq!(result, Err(Ok(ContractError::ConfigMismatch)));
assert_tip_not_applied(&ctx, tipper_before, events_before);
}

#[test]
fn test_send_tip_omitted_expectations() {
let ctx = setup();
let amount = 10_000_000_i128;
let msg = String::from_str(&ctx.env, "tip");

ctx.client.send_tip(
&ctx.tipper,
&ctx.creator,
&amount,
&msg,
&false,
&false,
&None,
&None,
);

assert_eq!(creator_balance(&ctx), amount);
assert_eq!(ctx.client.get_stats().total_tips_count, 1);
}
34 changes: 34 additions & 0 deletions contracts/tipz/src/tips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,38 @@ pub fn get_blocked_tipper_count(env: &Env, creator: &Address) -> u32 {
storage::get_creator_blocked_tipper_count(env, creator)
}

/// Abort when the caller pinned min-tip or fee values that no longer match
/// current config. Runs before any mutation or event so a rejected tip leaves
/// contract state unchanged. Omitting both params is a no-op.
fn check_pinned_config(
env: &Env,
expected_min_tip: Option<i128>,
expected_fee_bps: Option<u32>,
) -> Result<(), ContractError> {
if expected_min_tip.is_none() && expected_fee_bps.is_none() {
return Ok(());
}

let config = storage::get_runtime_config(env).ok_or(ContractError::NotInitialized)?;
if let Some(expected) = expected_min_tip {
if expected != config.min_tip_amount {
return Err(ContractError::ConfigMismatch);
}
}
if let Some(expected) = expected_fee_bps {
if expected != config.fee_bps {
return Err(ContractError::ConfigMismatch);
}
}
Ok(())
}

/// Send an XLM tip from `tipper` to a registered `creator`.
///
/// `expected_min_tip` and `expected_fee_bps` let the caller pin the config the
/// UI displayed. A mismatch aborts with [`ContractError::ConfigMismatch`]
/// before any state change. Omitting either value (passing `None`) skips that
/// check.
pub fn send_tip(
env: &Env,
tipper: &Address,
Expand All @@ -210,7 +241,10 @@ pub fn send_tip(
message: &String,
is_anonymous: bool,
is_encrypted: bool,
expected_min_tip: Option<i128>,
expected_fee_bps: Option<u32>,
) -> Result<(), ContractError> {
check_pinned_config(env, expected_min_tip, expected_fee_bps)?;
storage::extend_instance_ttl(env);
let config = storage::get_runtime_config(env).ok_or(ContractError::NotInitialized)?;
if config.paused {
Expand Down
Loading