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
62 changes: 62 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
## 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.
- **Added `DataKey::CreatorTtlLiveUntil(creator)`** — a per-creator `u32` recording the absolute live-until ledger the contract last set for the creator profile key. The Soroban SDK does not expose TTL reads to contract code, so this tracked value is what `extend_creator_ttl` uses to decide whether to emit the event.
- **Modified `extend_creator_ttl`** to:
1. Derive the remaining TTL from `CreatorTtlLiveUntil` and evaluate `ttl::should_extend(remaining, TTL_EXTENSION_THRESHOLD)`.
2. Always call `extend_ttl` on all creator-scoped storage keys — the Soroban SDK call is a no-op when TTL is already healthy, preserving the existing on-chain behavior.
3. Only publish the `TTL_EXTENDED_EVENT_NAME` event when the check above returns `true`, then update the tracked live-until.
- **Write-time TTL alignment**: new entries start with the network-default TTL, which can be much shorter than `CREATOR_TTL_LEDGERS` on fresh networks. `register_creator` now forces the full `CREATOR_TTL_LEDGERS` window on the creator profile, curve preset, and tracked live-until; `set_key_price`, the buy path, and dividend settlement grant the same full window to `KeyPrice`, `KeyBalance(creator, holder)`, and the dividend checkpoint/pending keys.

### `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

- [x] `cargo fmt --all -- --check`
- [x] `cargo clippy --workspace --all-targets -- -D warnings`
- [x] `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
110 changes: 92 additions & 18 deletions creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,12 @@ pub mod constants {
pub fn referral_fee_bps() -> DataKey {
DataKey::ReferralFeeBps
}

/// Absolute live-until ledger the contract last set for `creator`'s
/// profile key, used to decide whether to emit the TTL-extension event.
pub fn creator_ttl_live_until(creator: &Address) -> DataKey {
DataKey::CreatorTtlLiveUntil(creator.clone())
}
}

fn creator_key(creator: &Address) -> DataKey {
Expand Down Expand Up @@ -515,6 +521,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 @@ -588,6 +603,11 @@ pub enum DataKey {
StakedBalance(Address, Address), // (creator, holder) -> staked amount
MaxKeysPerWallet(Address),
ReferralFeeBps,
/// Absolute live-until ledger the contract last set for the creator key
/// via `extend_ttl`. Tracks the TTL extension state so the contract can
/// decide whether to emit the TTL-extension event without a TTL read
/// (the Soroban SDK does not expose TTL reads to contract code).
CreatorTtlLiveUntil(Address),
}

/// Time-locked key allocation for creator self-vesting.
Expand Down Expand Up @@ -1319,6 +1339,10 @@ fn settle_holder_dividends(
env.storage()
.persistent()
.set(&checkpoint_key, &accumulator);
// Keep dividend settlement state live for the same horizon as the
// creator profile between trades.
extend_key_ttl_to_full_window(env, &pending_key);
extend_key_ttl_to_full_window(env, &checkpoint_key);
Ok(())
}

Expand All @@ -1341,19 +1365,53 @@ fn compute_claimable_dividend(env: &Env, creator: &Address, holder: &Address) ->
pending.saturating_add(earned)
}

/// Extends the TTL of a freshly written storage entry to the full
/// [`CREATOR_TTL_LEDGERS`] window.
///
/// Uses `CREATOR_TTL_LEDGERS` as both the threshold and the extension window.
/// New entries start with the network-default TTL, which is shorter than
/// `CREATOR_TTL_LEDGERS` on fresh networks; forcing the full window at write
/// time keeps the entry's real TTL aligned with the live-until the contract
/// tracks for the TTL-extension event.
fn extend_key_ttl_to_full_window(env: &Env, key: &DataKey) {
env.storage()
.persistent()
.extend_ttl(key, CREATOR_TTL_LEDGERS, CREATOR_TTL_LEDGERS);
}

/// Extends TTL for all creator-related storage keys.
///
/// This function extends the TTL of the creator's primary storage entries
/// to prevent active creator state from expiring. Called after successful
/// buy and sell operations. Emits a [`events::TTL_EXTENDED_EVENT_NAME`] event
/// when the creator key's TTL was actually extended (checked via the SDK's
/// threshold-vs-expiration logic).
/// buy, sell, and buyback operations. Emits a [`events::TTL_EXTENDED_EVENT_NAME`]
/// event only when the creator key's remaining TTL was below
/// [`TTL_EXTENSION_THRESHOLD`] before this call — a healthy TTL silently
/// skips the event.
fn extend_creator_ttl(env: &Env, creator: &Address) {
let current_ledger = env.ledger().sequence();
let extend_to = current_ledger + CREATOR_TTL_LEDGERS;
let threshold = current_ledger;

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

// The Soroban SDK does not expose TTL reads to contract code, so the
// contract tracks the live-until ledger it last set for the creator key
// in persistent storage ([`DataKey::CreatorTtlLiveUntil`]). The remaining
// TTL is derived from that value and used only to decide whether to emit
// the TTL-extension event. The tracked value is always <= the entry's
// real live-until (the network default can exceed `CREATOR_TTL_LEDGERS`),
// so the event may fire slightly early on such networks — never too late.
// The `extend_ttl` SDK calls below still run unconditionally — the
// runtime no-ops when the entry already has a healthy expiration.
let live_until: u32 = env
.storage()
.persistent()
.get(&live_until_key)
.unwrap_or(current_ledger);
let remaining = live_until.saturating_sub(current_ledger);
let needs_event = ttl::should_extend(remaining, TTL_EXTENSION_THRESHOLD);

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

env.events()
.publish(events::ttl_extended_topics(creator), extend_to);
// Record the new live-until ledger so future trades can re-evaluate
// whether the TTL-extension event should be emitted.
env.storage().persistent().set(&live_until_key, &extend_to);
extend_key_ttl_to_full_window(env, &live_until_key);

// 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 Expand Up @@ -1560,27 +1627,28 @@ impl CreatorKeysContract {
// Persist profile before event publication so indexers reading contract state
// after this tx observe the same registration payload that was emitted.
env.storage().persistent().set(&key, &profile);
// Set initial TTL for creator storage
// Set initial TTL for creator storage. The full window is forced at
// write time so the entry's real TTL matches the live-until the
// contract tracks for the TTL-extension event.
let extend_to = current_ledger + CREATOR_TTL_LEDGERS;
env.storage()
.persistent()
.extend_ttl(&key, current_ledger, extend_to);
env.storage()
.persistent()
.extend_ttl(&preset_key, current_ledger, extend_to);
extend_key_ttl_to_full_window(&env, &key);
extend_key_ttl_to_full_window(&env, &preset_key);
let co_creator_key = constants::storage::co_creator(&creator);
if env.storage().persistent().has(&co_creator_key) {
env.storage()
.persistent()
.extend_ttl(&co_creator_key, current_ledger, extend_to);
extend_key_ttl_to_full_window(&env, &co_creator_key);
}
let whitelist_key = constants::storage::whitelist(&creator);
if env.storage().persistent().has(&whitelist_key) {
env.storage()
.persistent()
.extend_ttl(&whitelist_key, current_ledger, extend_to);
extend_key_ttl_to_full_window(&env, &whitelist_key);
}

// Record the live-until the contract set for the creator key so
// `extend_creator_ttl` can later decide whether to emit the
// TTL-extension event.
let live_until_key = constants::storage::creator_ttl_live_until(&creator);
env.storage().persistent().set(&live_until_key, &extend_to);
extend_key_ttl_to_full_window(&env, &live_until_key);

env.events().publish(
events::register_event_topics(&profile.creator),
events::CreatorRegisteredEvent {
Expand Down Expand Up @@ -1699,6 +1767,9 @@ impl CreatorKeysContract {
.ok_or(ContractError::Overflow)?;
// Balance key is scoped by (creator, holder) so creator positions cannot collide.
env.storage().persistent().set(&balance_key, &new_balance);
// Grant the balance entry the full TTL window so long-held positions
// survive the same horizon as creator state between trades.
extend_key_ttl_to_full_window(&env, &balance_key);

if let Some(config) = read_protocol_fee_config(&env) {
let (creator_fee, protocol_fee) =
Expand Down Expand Up @@ -2533,6 +2604,9 @@ impl CreatorKeysContract {
env.storage()
.persistent()
.set(&constants::storage::KEY_PRICE, &price);
// Grant the price entry the full TTL window so buy/sell reads stay
// live for the same horizon as creator state.
extend_key_ttl_to_full_window(&env, &constants::storage::KEY_PRICE);
Ok(())
}

Expand Down
65 changes: 65 additions & 0 deletions creator-keys/tests/ttl_extension_on_buy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ fn setup(
soroban_sdk::Address,
) {
let (client, contract_id) = register_creator_keys(env);
// The test env archives the contract instance and code after ~4095
// ledgers by default. Bump them to the full extension window so tests
// that advance the ledger far into the future (to drain creator TTL)
// can still invoke the contract.
env.deployer().extend_ttl(
contract_id.clone(),
CREATOR_TTL_LEDGERS,
CREATOR_TTL_LEDGERS,
);
set_key_price_for_tests(env, &client, KEY_PRICE);
let creator = register_test_creator(env, &client, "alice");
(client, contract_id, creator)
Expand Down Expand Up @@ -150,6 +159,62 @@ fn test_ttl_not_extended_when_already_high() {
);
}

/// 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}"
);
}

#[test]
fn buy_extends_instance_ttl() {
let env = soroban_sdk::Env::default();
Expand Down
Loading
Loading