diff --git a/contracts/tip-splitter/src/lib.rs b/contracts/tip-splitter/src/lib.rs index d2d6273..9360af5 100644 --- a/contracts/tip-splitter/src/lib.rs +++ b/contracts/tip-splitter/src/lib.rs @@ -548,6 +548,15 @@ impl TipSplitter { /// deployment is usable through `tip_in_token` immediately and /// `get_token`/`is_token_allowed` can never disagree about it. pub fn __constructor(env: Env, admin: Address, token: Address) { + // The admin manages privileged operations (fees, pause, hooks, allowlist) + // while the token address represents the Stellar Asset Contract that all tips + // settle through. Providing the same address for both is a deployment error. + // The token address is recorded permanently and can never be changed post-deploy, + // so catching this misconfiguration at construction prevents deploying a permanently + // bricked contract requiring complete redeployment and migration of jars. + if admin == token { + panic!("admin and token cannot be the same address"); + } env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::Token, &token); env.storage() diff --git a/contracts/tip-splitter/src/test.rs b/contracts/tip-splitter/src/test.rs index d9909a1..b069567 100644 --- a/contracts/tip-splitter/src/test.rs +++ b/contracts/tip-splitter/src/test.rs @@ -8790,3 +8790,86 @@ fn create_recurring_rejects_invalid_message() { let res = client.try_create_recurring(&supporter, &jar_id, &500, &100, &with_nul); assert_eq!(res, Err(Ok(Error::InvalidMessage.into()))); } + +/// The constructor must reject an admin and token that share the same address, +/// preventing deployment of a permanently misconfigured contract (#69). +#[test] +#[should_panic(expected = "admin and token cannot be the same address")] +fn constructor_rejects_admin_and_token_with_same_address() { + let env = Env::default(); + let addr = Address::generate(&env); + env.register(TipSplitter, (addr.clone(), addr)); +} + +/// An amount large enough that `amount * bps` overflows i128 in the minimum-share +/// guard or distribution loop must fail with `Error::AmountOverflow` rather than +/// trapping the WASM runtime (#68). +#[test] +fn compute_shares_overflow_fails_with_amount_overflow() { + let s = setup(); + let env = &s.env; + let client = TipSplitterClient::new(env, &s.contract); + + let owner = Address::generate(env); + let alice = Address::generate(env); + let jar_id = String::from_str(env, "@overflow"); + client.create_jar( + &owner, + &jar_id, + &vec![ + env, + Split { + to: alice.clone(), + bps: 10_000, + }, + ], + ); + + let res = client.try_preview_split(&jar_id, &i128::MAX); + assert_eq!(res, Err(Ok(Error::AmountOverflow.into()))); +} + +/// A tipper who is also a recipient holding a non-final share keeps their own +/// share instead of funding the last recipient (#66). +/// In a jar with tipper (3000 bps) and another recipient (7000 bps), tipping 100: +/// only 70 leaves the tipper and the recipient receives 70, leaving 30 with the tipper. +#[test] +fn tipper_who_is_non_final_recipient_keeps_own_share() { + let s = setup(); + let env = &s.env; + let client = TipSplitterClient::new(env, &s.contract); + let token = token::Client::new(env, &s.token); + let token_admin = token::StellarAssetClient::new(env, &s.token); + + let owner = Address::generate(env); + let tipper = Address::generate(env); + let recipient = Address::generate(env); + + token_admin.mint(&tipper, &100); + + let jar_id = String::from_str(env, "@self-nonfinal"); + client.create_jar( + &owner, + &jar_id, + &vec![ + env, + Split { + to: tipper.clone(), + bps: 3000, + }, + Split { + to: recipient.clone(), + bps: 7000, + }, + ], + ); + + client.tip(&tipper, &jar_id, &100, &String::from_str(env, "keep self share")); + + // Tipper started with 100, kept 30, only 70 left: + assert_eq!(token.balance(&tipper), 30); + // Recipient received exactly 70: + assert_eq!(token.balance(&recipient), 70); +} + + diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 75bcd64..7880fcd 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -31,6 +31,18 @@ withdrawal. match the on-chain event back to whatever it created off-chain. Never stored and never checked by the contract. +## Limits + +Quick reference for bounds enforced by the contract: + +| Limit | Value | Constant | Error when exceeded | +|---|---|---|---| +| Basis point denominator | `10_000` | `BPS_DENOM` | `SplitOutOfRange` (11), `SplitSumNot100Pct` (12) | +| Maximum recipient count | `20` | `MAX_RECIPIENTS` | `TooManyRecipients` (6) | +| Maximum message length | `280` bytes | `MAX_MESSAGE_LEN` | `MessageTooLong` (8) | +| Maximum batch tips | `10` | `MAX_BATCH_TIPS` | `BatchTooLarge` (38) | +| Maximum jar id length | `33` bytes (`@` + 32) | `MAX_JAR_SLUG_LEN + 1` | `InvalidJarId` (9) | + ## Types ```rust