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
31 changes: 31 additions & 0 deletions contracts/tip-splitter/interface.json
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,21 @@
"name": "count",
"type_": "u32"
},
{
"name": "splits",
"type_": {
"vec": {
"element_type": {
"tuple": {
"value_types": [
"address",
"u32"
]
}
}
}
}
},
{
"name": "version",
"type_": "u32"
Expand Down Expand Up @@ -416,6 +431,22 @@
]
}
},
{
"udt_struct_v0": {
"lib": "",
"name": "JarDeletedEvent",
"fields": [
{
"name": "jar_id",
"type_": "string"
},
{
"name": "version",
"type_": "u32"
}
]
}
},
{
"udt_struct_v0": {
"lib": "",
Expand Down
54 changes: 47 additions & 7 deletions contracts/tip-splitter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ const INTERFACE_VERSION: u32 = 4;

/// One recipient and the share of every tip they receive, in basis points.
#[contracttype]
#[derive(Clone)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Split {
/// Recipient that receives this share of every tip.
pub to: Address,
Expand Down Expand Up @@ -494,6 +494,7 @@ pub struct JarCreatedEvent {
pub struct SplitsUpdatedEvent {
pub version: u32,
pub count: u32,
pub splits: Vec<Split>,
}

/// Data published by `jar_xfer`; fields are named for forward-compatible decoding.
Expand All @@ -505,6 +506,14 @@ pub struct JarTransferredEvent {
pub new_owner: Address,
}

/// Data published by `jar_del`; fields are named for forward-compatible decoding.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct JarDeletedEvent {
pub version: u32,
pub jar_id: String,
}

/// Data published by `min_tip`; fields are named for forward-compatible decoding.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -762,6 +771,7 @@ impl TipSplitter {
}

let split_count = splits.len();
let new_splits = splits.clone();
let key = DataKey::Jar(jar_id.clone());
env.storage().persistent().set(
&key,
Expand All @@ -777,6 +787,7 @@ impl TipSplitter {
SplitsUpdatedEvent {
version: 1,
count: split_count,
splits: new_splits,
},
);
}
Expand Down Expand Up @@ -954,10 +965,11 @@ impl TipSplitter {
.publish((symbol_short!("splits"), jar_id), split_count);
}

/// Transfer control of a jar to a new owner. Only the current owner may do
/// this; the new owner does not need to authorize. Splits are unchanged.
/// Emits a `jar_xfer` event carrying both the outgoing and the incoming
/// owner, so indexers can update who controls the jar.
/// Transfer control of a jar to a new owner. Both the current owner and the
/// incoming owner must authorize the exact `(jar_id, new_owner)` handoff. A
/// transfer to the current owner is rejected as a no-op. Splits are
/// unchanged. Emits a `jar_xfer` event carrying both owners, so indexers can
/// update who controls the jar.
pub fn transfer_jar_ownership(env: Env, jar_id: String, new_owner: Address) {
let key = DataKey::Jar(jar_id.clone());
let jar: Jar = env
Expand All @@ -967,17 +979,18 @@ impl TipSplitter {
.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();
// Issue #94: reject the contract itself or its token as new owner.
let contract_addr = env.current_contract_address();
let token_addr: Address = env
.storage()
.instance()
.get(&DataKey::Token)
.unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized));
if new_owner == contract_addr || new_owner == token_addr {
if new_owner == contract_addr || new_owner == token_addr || new_owner == jar.owner {
panic_with_error!(&env, Error::InvalidRecipient);
}
jar.owner.require_auth();
new_owner.require_auth_for_args((jar_id.clone(), new_owner.clone()).into_val(&env));
let prev_owner = jar.owner;
env.storage().persistent().set(
&key,
Expand All @@ -1004,6 +1017,33 @@ impl TipSplitter {
);
}

/// Delete a jar and release its slug. Only the current owner may do this,
/// and the slug must currently be registered. Removes the jar and its
/// per-jar metadata so the slug can be reused by `create_jar`.
pub fn delete_jar(env: Env, jar_id: String) {
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().remove(&key);
env.storage().persistent().remove(&DataKey::Stats(jar_id.clone()));
env.storage().persistent().remove(&DataKey::MinTip(jar_id.clone()));

env.events().publish(
(symbol_short!("jar_del"),),
JarDeletedEvent {
version: 1,
jar_id,
},
);
}

/// Transfer admin control to a new address. Only the current admin may do this.
/// Rejects if the new admin is the same as the current one. Emits an event
/// carrying both addresses so indexers can track the handover.
Expand Down
167 changes: 160 additions & 7 deletions contracts/tip-splitter/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2411,7 +2411,8 @@ fn update_splits_emits_splits_event() {
];
client.update_splits(&jar_id, &new_splits);

// The splits event must be published with the jar id and new split count.
// The splits event must carry the exact updated split vector, matching the
// jar immediately after the mutation.
let expected_topics = vec![
env,
symbol_short!("splits").into_val(env),
Expand All @@ -2427,7 +2428,9 @@ fn update_splits_emits_splits_event() {
let (_, _, data) = splits_events.first().unwrap();
let decoded: SplitsUpdatedEvent =
TryFromVal::try_from_val(env, data).expect("splits event data shape");
assert_eq!(decoded.count, new_splits.len());
let jar = client.get_jar(&jar_id);
assert_eq!(decoded.count, jar.splits.len() as u32);
assert_eq!(decoded.splits, jar.splits);
}

#[test]
Expand Down Expand Up @@ -2803,12 +2806,161 @@ fn transfer_jar_ownership_moves_control_to_new_owner() {
],
);

env.mock_auths(&[
MockAuth {
address: &owner,
invoke: &MockAuthInvoke {
contract: &s.contract,
fn_name: "transfer_jar_ownership",
args: (jar_id.clone(), new_owner.clone()).into_val(env),
sub_invokes: &[],
},
},
MockAuth {
address: &new_owner,
invoke: &MockAuthInvoke {
contract: &s.contract,
fn_name: "transfer_jar_ownership",
args: (jar_id.clone(), new_owner.clone()).into_val(env),
sub_invokes: &[],
},
},
]);

client.transfer_jar_ownership(&jar_id, &new_owner);
assert_eq!(client.get_jar(&jar_id).owner, new_owner);
// Splits must survive the transfer untouched.
assert_eq!(client.get_jar(&jar_id).splits.get(0).unwrap().to, alice);
}

#[test]
fn transfer_jar_ownership_requires_new_owner_auth() {
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, "@handoff_new_auth");

client.create_jar(
&owner,
&jar_id,
&vec![
env,
Split {
to: alice.clone(),
bps: 10000,
},
],
);

env.mock_auths(&[MockAuth {
address: &owner,
invoke: &MockAuthInvoke {
contract: &s.contract,
fn_name: "transfer_jar_ownership",
args: (jar_id.clone(), new_owner.clone()).into_val(env),
sub_invokes: &[],
},
}]);

let res = client.try_transfer_jar_ownership(&jar_id, &new_owner);
assert!(res.is_err(), "the incoming owner must authorize the transfer");
assert_eq!(client.get_jar(&jar_id).owner, owner);
}

#[test]
fn transfer_jar_ownership_rejects_current_owner() {
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, "@self_xfer");

client.create_jar(
&owner,
&jar_id,
&vec![
env,
Split {
to: alice.clone(),
bps: 10000,
},
],
);

env.mock_all_auths();
let res = client.try_transfer_jar_ownership(&jar_id, &owner);
assert!(res.is_err(), "transferring to the current owner is invalid");
assert_eq!(client.get_jar(&jar_id).owner, owner);
}

#[test]
fn delete_jar_releases_slug_and_requires_owner_auth() {
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, "@retire");
client.create_jar(
&owner,
&jar_id,
&vec![
env,
Split {
to: alice.clone(),
bps: 10000,
},
],
);

env.mock_auths(&[MockAuth {
address: &owner,
invoke: &MockAuthInvoke {
contract: &s.contract,
fn_name: "delete_jar",
args: (jar_id.clone(),).into_val(env),
sub_invokes: &[],
},
}]);
client.delete_jar(&jar_id);

assert!(!client.jar_exists(&jar_id));
env.mock_auths(&[MockAuth {
address: &owner,
invoke: &MockAuthInvoke {
contract: &s.contract,
fn_name: "create_jar",
args: (owner.clone(), jar_id.clone(), vec![
env,
Split {
to: alice.clone(),
bps: 10000,
},
])
.into_val(env),
sub_invokes: &[],
},
}]);
client.create_jar(
&owner,
&jar_id,
&vec![
env,
Split {
to: alice.clone(),
bps: 10000,
},
],
);
assert!(client.jar_exists(&jar_id));
}

/// Creates a single-recipient jar owned by `owner` and transfers it to
/// `new_owner` under `mock_all_auths`, so the authority tests below start
/// from a completed handoff and only the call under test runs with strict
Expand Down Expand Up @@ -4290,8 +4442,8 @@ fn leave_jar_reassigns_departing_share_to_last_entry() {

client.leave_jar(&jar_id, &bob);

// a `splits` event was published with the new count — read it before any
// further invocation, since the test env only exposes the last call's events
// A `splits` event must publish the post-update split vector, which matches
// the jar contents read back immediately afterward.
let expected_topics = vec![
env,
symbol_short!("splits").into_val(env),
Expand All @@ -4304,10 +4456,11 @@ fn leave_jar_reassigns_departing_share_to_last_entry() {
.filter(|e| e.0 == s.contract && e.1 == expected_topics)
.collect();
assert_eq!(evs.len(), 1);
let count: u32 = TryFromVal::try_from_val(env, &evs[0].2).unwrap();
assert_eq!(count, 2);

let decoded: SplitsUpdatedEvent =
TryFromVal::try_from_val(env, &evs[0].2).expect("splits event data shape");
let jar = client.get_jar(&jar_id);
assert_eq!(decoded.count, jar.splits.len() as u32);
assert_eq!(decoded.splits, jar.splits);
assert_eq!(jar.splits.len(), 2);
// middle entry removed; the last remaining entry (carol) absorbs bob's 3000.
assert_eq!(jar.splits.get(0).unwrap().to, alice);
Expand Down
Loading
Loading