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
3 changes: 3 additions & 0 deletions contracts/tip-splitter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,9 @@ impl TipSplitter {
locked: false,
},
);
env.storage()
.persistent()
.extend_ttl(&key, JAR_TTL_THRESHOLD, JAR_TTL_LEDGERS);

// Issue #91: extend instance TTL on jar creation so the contract stays
// alive alongside its jars.
Expand Down
156 changes: 150 additions & 6 deletions contracts/tip-splitter/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3670,6 +3670,17 @@ fn get_admin_returns_constructor_admin() {
assert_eq!(client.get_admin(), s.admin);
}

/// `get_token` returns the token address passed to the constructor (#71).
#[test]
fn get_token_returns_constructor_token() {
let s = setup();
let env = &s.env;
let client = TipSplitterClient::new(env, &s.contract);

assert_eq!(client.get_token(), s.token);
}


#[test]
fn set_min_tip_amount_sets_and_reads_minimum() {
let s = setup();
Expand Down Expand Up @@ -8392,15 +8403,22 @@ fn touched_jar_survives_a_ledger_advance_that_archives_an_idle_one() {
let touched = single_recipient_jar(env, &client, &owner, "@touched");
let idle = single_recipient_jar(env, &client, &owner, "@idle");

// `create_jar` leaves the entry at the network minimum; a read bumps it.
let idle_ttl = jar_ttl(env, &s.contract, &idle);
assert!(idle_ttl < JAR_TTL_THRESHOLD);
client.get_jar(&touched);
// Both start at JAR_TTL_LEDGERS because create_jar extends the TTL (#67).
assert_eq!(jar_ttl(env, &s.contract, &idle), JAR_TTL_LEDGERS);
assert_eq!(jar_ttl(env, &s.contract, &touched), JAR_TTL_LEDGERS);

// Advance to where the TTL falls below threshold
keep_instance_alive(env, &s.contract);
let seq = env.ledger().sequence();
env.ledger().set_sequence_number(seq + idle_ttl + 1);
let start = env.ledger().sequence();
let below = JAR_TTL_LEDGERS - JAR_TTL_THRESHOLD + 1;
env.ledger().set_sequence_number(start + below);

// Reading `touched` extends it back to JAR_TTL_LEDGERS
client.get_jar(&touched);
assert_eq!(jar_ttl(env, &s.contract, &touched), JAR_TTL_LEDGERS);

// Advance past idle's expiration
env.ledger().set_sequence_number(start + JAR_TTL_LEDGERS + 1);

assert_eq!(client.get_jar(&touched).owner, owner);
// The test host escalates any access to an archived entry into a panic
Expand All @@ -8414,6 +8432,27 @@ fn touched_jar_survives_a_ledger_advance_that_archives_an_idle_one() {
);
}

/// A freshly created jar has its storage lifetime extended on creation, so it
/// remains readable after a ledger advance that would otherwise archive an un-extended entry (#67).
#[test]
fn freshly_created_jar_extends_ttl_and_survives_ledger_advance() {
let s = setup();
let env = &s.env;
let client = TipSplitterClient::new(env, &s.contract);
let owner = Address::generate(env);

let jar_id = single_recipient_jar(env, &client, &owner, "@fresh");
assert_eq!(jar_ttl(env, &s.contract, &jar_id), JAR_TTL_LEDGERS);

keep_instance_alive(env, &s.contract);
let seq = env.ledger().sequence();
// A ledger advance that would archive a default-TTL entry
env.ledger().set_sequence_number(seq + 10_000);

// The jar was extended on creation, so it is still readable
assert_eq!(client.get_jar(&jar_id).owner, owner);
}

/// #72: below `JAR_TTL_THRESHOLD` an access extends the entry back to
/// `JAR_TTL_LEDGERS`; above it the access is a no-op and the TTL keeps falling.
#[test]
Expand Down Expand Up @@ -8872,4 +8911,109 @@ fn tipper_who_is_non_final_recipient_keeps_own_share() {
assert_eq!(token.balance(&recipient), 70);
}

/// A tipper holding a non-final share keeps their own share: no self-transfer
/// is made, the final recipient does not absorb it, and every balance reflects
/// only what actually moved (#70).
#[test]
fn tip_tipper_holding_non_final_share_preserves_all_balances() {
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 bob = Address::generate(env);
let carol = Address::generate(env);

token_admin.mint(&tipper, &1_000);

let jar_id = String::from_str(env, "@self-mid");
client.create_jar(
&owner,
&jar_id,
&vec![
env,
Split {
to: bob.clone(),
bps: 5_000,
},
Split {
to: tipper.clone(),
bps: 3_000,
},
Split {
to: carol.clone(),
bps: 2_000,
},
],
);

// Tipper tips 1000: bob receives 500, tipper keeps their 300, carol receives 200.
// Total transferred is 700.
client.tip(&tipper, &jar_id, &1_000, &String::from_str(env, "team tip"));

assert_eq!(token.balance(&bob), 500);
assert_eq!(token.balance(&carol), 200);
assert_eq!(
token.balance(&tipper),
300,
"tipper's 300 share stayed in their wallet: only 700 left for bob and carol"
);
assert_eq!(token.balance(&s.contract), 0);
}

/// A tipper holding the final share keeps their own share: no self-transfer is
/// made, the non-final recipients receive their exact splits, and every balance
/// reflects only what moved (#70).
#[test]
fn tip_tipper_holding_final_share_preserves_all_balances() {
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 bob = Address::generate(env);
let tipper = Address::generate(env);

token_admin.mint(&tipper, &1_000);

let jar_id = String::from_str(env, "@self-end");
client.create_jar(
&owner,
&jar_id,
&vec![
env,
Split {
to: alice.clone(),
bps: 6_000,
},
Split {
to: bob.clone(),
bps: 2_000,
},
Split {
to: tipper.clone(),
bps: 2_000,
},
],
);

// Tipper tips 1000: alice receives 600, bob receives 200, tipper keeps their 200.
// Total transferred is 800.
client.tip(&tipper, &jar_id, &1_000, &String::from_str(env, "ending split"));

assert_eq!(token.balance(&alice), 600);
assert_eq!(token.balance(&bob), 200);
assert_eq!(
token.balance(&tipper),
200,
"tipper's 200 final share stayed in their wallet: only 800 left for alice and bob"
);
assert_eq!(token.balance(&s.contract), 0);
}

77 changes: 77 additions & 0 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1634,3 +1634,80 @@ that tips a jar from the contract's own balance — with tests that run it
against the real `TipSplitter` and a test SAC, including the failure mode where
the fee transfer is not authorized. See its
[`README.md`](../examples/tip-forwarder/README.md).

## Troubleshooting

Common failures encountered when building, deploying, or invoking the contract that do not map to contract error codes:

### 1. Missing WebAssembly target during build

**Symptom:**
```text
error: target 'wasm32-unknown-unknown' not found
error: the contract build failed. If it is a missing target, run:
rustup target add wasm32-unknown-unknown
```
or:
```text
error[E0463]: can't find crate for 'core'
```

**Cause:** The Rust toolchain does not have the compilation target for Soroban WASM installed.

**Fix:** Add the target using `rustup`:
```bash
rustup target add wasm32-unknown-unknown
```

### 2. Recipient without a trustline for the token

**Symptom:**
```text
HostError: Error(Contract, #1)
```
or transaction simulation failure:
```text
txFailed: op_not_authorized / op_no_trustline
```

**Cause:** The Stellar Asset Contract (SAC) requires an existing trustline for custom/non-native assets before an account can receive tokens. If a recipient in a jar split has not established a trustline for the token, direct transfers to that address fail.

**Fix:**
- The recipient must open a trustline for the token's Stellar Asset Contract before receiving:
```bash
stellar contract invoke --id <SAC_CONTRACT_ID> -- trustline --for <RECIPIENT_ADDRESS>
```
- Alternatively, if using the tip-splitter contract post-#171, undeliverable shares are automatically escrowed into contract storage. The recipient can withdraw their held funds via `withdraw_held(recipient)` once their trustline is established.

### 3. Unfunded source account

**Symptom:**
```text
error: transaction simulation failed: HostError: Error(Storage, MissingValue)
```
or:
```text
txNO_ACCOUNT: The source account does not exist
```

**Cause:** The invoking account has not been created on the network and holds 0 XLM (below the network reserve).

**Fix:** Fund the account with the minimum reserve:
- On Testnet, use Friendbot:
```bash
curl "https://friendbot.stellar.org?addr=<ACCOUNT_ADDRESS>"
```
- On Mainnet, send at least 1–2 XLM from an existing wallet or exchange to establish the account.

### 4. Jar slug mismatch or invalid format

**Symptom:**
```text
HostError: Error(Contract, #3) # JarNotFound
HostError: Error(Contract, #9) # InvalidJarId
```

**Cause:** A tip or view was called with a slug that lacks the required leading `@` (producing `InvalidJarId`), uses invalid characters/length, or points to an unregistered jar slug (`JarNotFound`).

**Fix:** Ensure the slug matches `@` followed by 3–32 lowercase alphanumeric characters, dashes, or underscores (`@<slug>`), and verify registration using `get_jar(jar_id)` or `./scripts/jar-info.sh`.

Loading