Skip to content
Merged
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
79 changes: 78 additions & 1 deletion contracts/tip-splitter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ const INSTANCE_TTL_THRESHOLD: u32 = JAR_TTL_THRESHOLD;
///
/// This is a per-contract number for client feature detection. It complements
/// the per-event schema version proposed in #83 rather than replacing it.
const INTERFACE_VERSION: u32 = 4;
const INTERFACE_VERSION: u32 = 5;

/// One recipient and the share of every tip they receive, in basis points.
#[contracttype]
Expand Down Expand Up @@ -194,6 +194,10 @@ pub struct Jar {
/// `lock_splits`, cleared by `unlock_splits`; survives
/// `transfer_jar_ownership`.
pub locked: bool,
/// When `true`, every tip to this jar is rejected with `JarPaused` (see
/// #45). Toggled by the owner through `set_jar_paused`; reads stay
/// available throughout, and the flag survives `transfer_jar_ownership`.
pub paused: bool,
}

/// Versioned storage envelope. New fields must be added as a new variant so
Expand Down Expand Up @@ -457,6 +461,10 @@ pub enum Error {
BatchEmpty = 37,
/// `tip_many` was given more entries than `MAX_BATCH_TIPS`.
BatchTooLarge = 38,
/// A tip was attempted to a jar its owner has paused (#45). Checked before
/// any transfer, so a rejected tip moves no funds; every read stays
/// available while the jar is paused.
JarPaused = 39,
}

/// The interface a post-tip hook contract must implement (#176).
Expand Down Expand Up @@ -703,6 +711,7 @@ impl TipSplitter {
owner: owner.clone(),
splits,
locked: false,
paused: false,
},
);
env.storage()
Expand Down Expand Up @@ -779,6 +788,7 @@ impl TipSplitter {
owner: jar.owner,
splits,
locked: jar.locked,
paused: jar.paused,
},
);

Expand Down Expand Up @@ -821,6 +831,7 @@ impl TipSplitter {
owner: jar.owner,
splits: jar.splits,
locked: true,
paused: jar.paused,
},
);

Expand Down Expand Up @@ -861,6 +872,7 @@ impl TipSplitter {
owner: jar.owner,
splits,
locked: false,
paused: jar.paused,
},
);

Expand All @@ -884,6 +896,61 @@ impl TipSplitter {
jar.locked
}

/// Pause or resume tipping for a single jar (#45). Owner-only.
///
/// A creator mid-way through changing collaborators, or disputing how a jar
/// is split, can stop tips arriving while they sort it out without
/// abandoning the slug. While `paused` is `true`, every tip entry point
/// (`tip`, `tip_with_ref`, `tip_in_token`, `tip_many`) rejects this jar with
/// `JarPaused`; every read stays available. Passing `false` reopens it.
///
/// The flag is a property of the jar, so it survives
/// `transfer_jar_ownership` — a handover does not silently reopen a jar its
/// previous owner closed. Setting the flag to the value it already holds is
/// a no-op on state but still emits a `jar_pause` event, so an indexer stays
/// in sync with the intent.
pub fn set_jar_paused(env: Env, jar_id: String, paused: bool) {
let key = DataKey::Jar(jar_id.clone());
let jar: Jar = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| panic_with_error!(&env, Error::JarNotFound));
// Issue #92: extend both jar and min-tip in lockstep.
Self::extend_jar_and_min_ttl(&env, &jar_id);
jar.owner.require_auth();

env.storage().persistent().set(
&key,
&Jar {
owner: jar.owner,
splits: jar.splits,
locked: jar.locked,
paused,
},
);

env.events()
.publish((symbol_short!("jar_pause"), jar_id), paused);
}

/// Whether tipping is currently paused for a jar (#45).
///
/// Panics with `JarNotFound` if the slug is not registered, matching
/// `get_jar`.
pub fn is_jar_paused(env: Env, jar_id: String) -> bool {
let key = DataKey::Jar(jar_id);
let jar: Jar = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| panic_with_error!(&env, Error::JarNotFound));
env.storage()
.persistent()
.extend_ttl(&key, JAR_TTL_THRESHOLD, JAR_TTL_LEDGERS);
jar.paused
}

/// Let a recipient remove themselves from a jar (#172), authorized by that
/// recipient rather than the owner.
///
Expand Down Expand Up @@ -958,6 +1025,7 @@ impl TipSplitter {
owner: jar.owner,
splits: remaining,
locked: jar.locked,
paused: jar.paused,
},
);

Expand Down Expand Up @@ -1000,6 +1068,9 @@ impl TipSplitter {
// The lock is a promise to the collaborators, not a privilege
// of the owner, so it survives the transfer (#173).
locked: jar.locked,
// Likewise the pause is a property of the jar, so a handover
// does not silently reopen a jar the previous owner closed (#45).
paused: jar.paused,
},
);

Expand Down Expand Up @@ -1454,6 +1525,12 @@ impl TipSplitter {
.get(&jar_key)
.unwrap_or_else(|| panic_with_error!(env, Error::JarNotFound));

// Reject tips to a jar its owner has paused (#45), before any funds
// move. Reads are unaffected; only the tip entry points consult this.
if jar.paused {
panic_with_error!(env, Error::JarPaused);
}

// Enforce optional per-jar minimum tip amount if configured
let min_key = DataKey::MinTip(jar_id.clone());
if enforce_min {
Expand Down
176 changes: 175 additions & 1 deletion contracts/tip-splitter/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4758,6 +4758,177 @@ fn lock_survives_ownership_transfer() {
assert_eq!(client.get_jar_owner(&jar_id), new_owner);
}

// ---------------------------------------------------------------------------
// #45 — per-jar pause switch
// ---------------------------------------------------------------------------

/// A paused jar rejects tips with `JarPaused`; unpausing restores them. Reads
/// stay available throughout, which is the whole point of a temporary pause.
#[test]
fn paused_jar_rejects_tips_until_unpaused() {
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 alice = Address::generate(env);
let tipper = Address::generate(env);
token_admin.mint(&tipper, &1_000);

let jar_id = String::from_str(env, "@paused");
client.create_jar(
&owner,
&jar_id,
&vec![
env,
Split {
to: alice.clone(),
bps: 10000,
},
],
);

// A fresh jar is open, and a tip settles normally.
assert!(!client.is_jar_paused(&jar_id));
client.tip(&tipper, &jar_id, &100, &String::from_str(env, "before"));
assert_eq!(token.balance(&alice), 100);

// Pausing closes the jar: `tip`, `tip_in_token` and `tip_many` all reject
// with `JarPaused`, and no funds move.
client.set_jar_paused(&jar_id, &true);
assert!(client.is_jar_paused(&jar_id));

let res = client.try_tip(&tipper, &jar_id, &100, &String::from_str(env, "paused"));
assert_eq!(res, Err(Ok(Error::JarPaused.into())));
let res_token = client.try_tip_in_token(
&tipper,
&jar_id,
&s.token,
&100,
&String::from_str(env, "paused"),
);
assert_eq!(res_token, Err(Ok(Error::JarPaused.into())));
let batch = vec![
env,
TipRequest {
jar_id: jar_id.clone(),
amount: 100,
message: String::from_str(env, "paused"),
},
];
assert_eq!(
client.try_tip_many(&tipper, &batch),
Err(Ok(Error::JarPaused.into()))
);
// Balances are untouched by every rejected attempt.
assert_eq!(token.balance(&alice), 100);
assert_eq!(token.balance(&tipper), 900);

// Reads stay available while paused.
assert_eq!(client.get_jar(&jar_id).splits.get(0).unwrap().to, alice);
assert_eq!(
client.preview_split(&jar_id, &100).get(0).unwrap(),
100_i128
);

// Unpausing reopens the jar and the next tip settles normally.
client.set_jar_paused(&jar_id, &false);
assert!(!client.is_jar_paused(&jar_id));
client.tip(&tipper, &jar_id, &100, &String::from_str(env, "after"));
assert_eq!(token.balance(&alice), 200);
}

/// Only the jar owner may toggle the pause flag.
#[test]
fn set_jar_paused_is_owner_only() {
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 stranger = Address::generate(env);
let jar_id = String::from_str(env, "@ownpause");
client.create_jar(
&owner,
&jar_id,
&vec![
env,
Split {
to: alice.clone(),
bps: 10000,
},
],
);

// A caller who is not the owner cannot pause the jar: the owner's auth is
// required but only the stranger's is mocked, so the call is rejected and
// the flag stays clear.
env.mock_auths(&[MockAuth {
address: &stranger,
invoke: &MockAuthInvoke {
contract: &s.contract,
fn_name: "set_jar_paused",
args: (jar_id.clone(), true).into_val(env),
sub_invokes: &[],
},
}]);
assert!(client.try_set_jar_paused(&jar_id, &true).is_err());
assert!(!client.is_jar_paused(&jar_id));
}

/// `set_jar_paused` emits a `jar_pause` event, and the flag is a property of
/// the jar: it survives `transfer_jar_ownership` and shows up in `get_jar`.
#[test]
fn jar_pause_emits_event_and_survives_ownership_transfer() {
let s = setup();
let env = &s.env;
let client = TipSplitterClient::new(env, &s.contract);

let owner = Address::generate(env);
let new_owner = Address::generate(env);
let alice = Address::generate(env);
let jar_id = String::from_str(env, "@keeppause");
client.create_jar(
&owner,
&jar_id,
&vec![
env,
Split {
to: alice.clone(),
bps: 10000,
},
],
);

client.set_jar_paused(&jar_id, &true);

// Read the `jar_pause` event immediately: the test env only keeps the last
// invocation's events.
let expected_topics = vec![
env,
symbol_short!("jar_pause").into_val(env),
jar_id.clone().into_val(env),
];
let evs: std::vec::Vec<_> = env
.events()
.all()
.iter()
.filter(|e| e.0 == s.contract && e.1 == expected_topics)
.collect();
assert_eq!(evs.len(), 1);
let paused: bool = TryFromVal::try_from_val(env, &evs[0].2).unwrap();
assert!(paused);

// The flag is visible in the full jar read and survives the handover.
assert!(client.get_jar(&jar_id).paused);
client.transfer_jar_ownership(&jar_id, &new_owner);
assert!(client.is_jar_paused(&jar_id));
assert_eq!(client.get_jar_owner(&jar_id), new_owner);
}

#[test]
fn recurring_tip_create_read_cancel_roundtrip() {
let s = setup();
Expand Down Expand Up @@ -5338,7 +5509,7 @@ fn version_matches_the_interface_constant() {
assert_eq!(client.version(), INTERFACE_VERSION);
assert_eq!(
client.version(),
4,
5,
"the published version is part of the public interface; bump \
INTERFACE_VERSION when the shape changes, do not edit this test \
to match a silent change"
Expand Down Expand Up @@ -7403,6 +7574,9 @@ fn error_codes_are_pinned_to_the_public_interface() {
(Error::JarIdInUse, 34),
(Error::FeeTooHigh, 35),
(Error::InvalidMessage, 36),
(Error::BatchEmpty, 37),
(Error::BatchTooLarge, 38),
(Error::JarPaused, 39),
];

for (variant, expected) in codes {
Expand Down
Loading
Loading