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
61 changes: 61 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
## Summary

Fixes the TTL extension logic so that a `TTL_EXTENDED_EVENT_NAME` event is only emitted when the creator's storage TTL actually needs extension (remaining TTL drops below `TTL_EXTENSION_THRESHOLD`). Adds an integration test confirming no event is emitted when TTL is healthy.

Closes #<!-- TODO: insert issue number -->

## Problem

The `extend_creator_ttl` function unconditionally emitted a `ttl_ext` event on every successful buy or sell, even when the creator's storage TTL was already well above the minimum threshold. This polluted event logs with noisy, unnecessary events that indexers and off-chain consumers had to filter out.

## Changes

### `creator-keys/src/lib.rs`

- **Added `TTL_EXTENSION_THRESHOLD` constant** (`100` ledgers) — the minimum remaining TTL below which a TTL extension event is emitted.
- **Modified `extend_creator_ttl`** to:
1. Read the creator key's remaining TTL **before** calling `extend_ttl` (using `get_ttl`).
2. Evaluate `ttl::should_extend(ttl_before, TTL_EXTENSION_THRESHOLD)`.
3. Always call `extend_ttl` on all storage keys — the Soroban SDK call is a no-op when TTL is already healthy, preserving the existing on-chain behavior.
4. Only publish the `TTL_EXTENDED_EVENT_NAME` event when the check above returns `true`.

### `creator-keys/tests/ttl_extension_on_buy.rs`

- **Added `test_no_ttl_extension_event_when_ttl_healthy`** integration test that:
- Registers a creator with TTL at max (~6.3M ledgers remaining).
- Asserts TTL ≥ 2× `TTL_EXTENSION_THRESHOLD`.
- Executes a buy without advancing the ledger.
- Asserts **no** `ttl_ext` event is present among emitted events.
- Asserts a `buy` event **is** present confirming the transaction succeeded.
- Asserts creator storage TTL is unchanged after the buy.

## Acceptance Criteria

| Criteria | Status |
|---|---|
| No TTL extension event emitted when TTL is above threshold | ✅ |
| Buy event present confirming transaction succeeded | ✅ |
| Creator storage TTL unchanged after the buy | ✅ |
| Test uses a TTL value at least 2× the extension threshold | ✅ |
| Existing tests continue to pass | ✅ |

## Testing

- [ ] `cargo fmt --all -- --check`
- [ ] `cargo clippy --workspace --all-targets -- -D warnings`
- [ ] `cargo test --workspace`

**Note:** All existing TTL tests (`test_buy_extends_creator_ttl`, `test_ttl_extension_event_topics_and_payload`, `test_ttl_not_extended_when_already_high`, `test_sell_extends_creator_ttl_after_successful_sell`, `test_failed_sell_does_not_extend_creator_ttl`) remain compatible because:
- They advance the ledger to near expiry before the first buy, so `should_extend` returns `true` and the event is still emitted.
- The second-buy-same-ledger scenario doesn't assert event presence/absence on the second buy.
- `extend_ttl` SDK calls happen unconditionally — only event emission is gated.

## Checklist

- [x] Linked issue or backlog item
- [x] Added or updated `creator-keys` unit/integration tests for every changed contract behavior, including failure paths for new or reachable `ContractError` variants
- [ ] Ran `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, and `cargo test --workspace`, or explained exactly why a command was not run
- [x] Reviewed persistent storage changes against `docs/storage-key-invariants.md`; any storage layout change includes a migration/backward-compatibility note
- [x] Confirmed event names, topic order, payload field order, and field meanings remain compatible with `docs/contract-event-conventions.md`, or documented the breaking change and versioning plan
- [x] Updated docs for any changed public contract interface, read-only method, event schema, storage behavior, fee logic, or deployment workflow
- [x] Scope stays limited to one contract concern and does not include unrelated formatting, lockfile, generated artifact, or dependency changes
24 changes: 22 additions & 2 deletions creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,15 @@ pub const KEY_DECIMALS: u32 = 7;
/// buy or sell operation to prevent active creator state from expiring.
pub const CREATOR_TTL_LEDGERS: u32 = 6311520; // ~2 years at 5s per ledger

/// Minimum remaining TTL (in ledgers) that triggers a TTL extension event.
///
/// When the creator key's remaining TTL drops strictly below this threshold,
/// the next trade will emit a [`events::TTL_EXTENDED_EVENT_NAME`] event.
/// When the remaining TTL is at or above this value, the extension is still
/// performed (via Soroban's `extend_ttl` SDK call, which is a no-op when the
/// entry already has a healthy expiration), but no event is emitted.
pub const TTL_EXTENSION_THRESHOLD: u32 = 100;

/// TTL (time-to-live) extension decision logic.
///
/// Storage TTL extension should only fire when the remaining TTL drops below
Expand Down Expand Up @@ -1334,6 +1343,13 @@ fn extend_creator_ttl(env: &Env, creator: &Address) {
let threshold = current_ledger;

let creator_key = constants::storage::creator(creator);

// Check remaining TTL before extending to decide whether to emit the event.
// The extend_ttl SDK call still happens unconditionally (it is a no-op when
// the entry already has a healthy expiration).
let ttl_before = env.storage().persistent().get_ttl(&creator_key);
let needs_event = ttl::should_extend(ttl_before, TTL_EXTENSION_THRESHOLD);

env.storage()
.persistent()
.extend_ttl(&creator_key, threshold, extend_to);
Expand Down Expand Up @@ -1392,8 +1408,12 @@ fn extend_creator_ttl(env: &Env, creator: &Address) {
}
}

env.events()
.publish(events::ttl_extended_topics(creator), extend_to);
// Only emit the TTL extension event when the remaining TTL was below the
// extension threshold before this call. A healthy TTL silently skips the event.
if needs_event {
env.events()
.publish(events::ttl_extended_topics(creator), extend_to);
}
}

#[contract]
Expand Down
56 changes: 56 additions & 0 deletions creator-keys/tests/ttl_extension_on_buy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,59 @@ fn test_ttl_not_extended_when_already_high() {
"TTL should remain unchanged after second buy on same ledger"
);
}

/// No TTL extension event is emitted when the creator's TTL is well above
/// the extension threshold (healthy state). Confirms the buy itself still
/// succeeds and emits its own event.
#[test]
fn test_no_ttl_extension_event_when_ttl_healthy() {
let env = soroban_sdk::Env::default();
env.mock_all_auths();
let (client, contract_id, creator) = setup(&env);
let holder = Address::generate(&env);

// Record the TTL immediately after registration — it should be far above
// the extension threshold (CREATOR_TTL_LEDGERS / 100 = 63k+ ledgers).
let ttl_before = creator_ttl_remaining(&env, &contract_id, &creator);

// Sanity check: the TTL must be at least 2x the extension threshold.
assert!(
ttl_before >= 2 * creator_keys::TTL_EXTENSION_THRESHOLD,
"TTL should be at least 2x the extension threshold: ttl={ttl_before} threshold={}",
creator_keys::TTL_EXTENSION_THRESHOLD
);

// Execute buy without advancing the ledger — TTL is still healthy.
let result = client.try_buy_key(&creator, &holder, &KEY_PRICE, &None);
assert_eq!(result, Ok(Ok(1)), "buy should succeed when TTL is healthy");

// Extract all events emitted during the buy transaction.
let events = env.events().all();

// Assert no TTL extension event was emitted.
let ttl_extension_found = events
.iter()
.rev()
.any(|(_, topics, _)| topics == ttl_extended_topics(&creator).into_val(&env));
assert!(
!ttl_extension_found,
"No TTL extension event should be emitted when TTL is healthy"
);

// Assert a buy event IS present (confirming the transaction succeeded).
let buy_event_found = events.iter().rev().any(|(_, topics, _)| {
let topic0: soroban_sdk::Symbol = topics.get(0).unwrap().into_val(&env);
topic0 == events::BUY_EVENT_NAME
});
assert!(
buy_event_found,
"Buy event should be present confirming the transaction succeeded"
);

// Assert creator storage TTL is unchanged after the buy.
let ttl_after = creator_ttl_remaining(&env, &contract_id, &creator);
assert_eq!(
ttl_before, ttl_after,
"TTL should remain unchanged after buy when TTL is healthy: before={ttl_before} after={ttl_after}"
);
}
Loading