Skip to content

Commit b1762c7

Browse files
committed
fix(creator-keys): gate TTL extension event on tracked remaining TTL
1 parent a43514d commit b1762c7

4 files changed

Lines changed: 133 additions & 49 deletions

File tree

PR_DESCRIPTION.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ The `extend_creator_ttl` function unconditionally emitted a `ttl_ext` event on e
1313
### `creator-keys/src/lib.rs`
1414

1515
- **Added `TTL_EXTENSION_THRESHOLD` constant** (`100` ledgers) — the minimum remaining TTL below which a TTL extension event is emitted.
16+
- **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.
1617
- **Modified `extend_creator_ttl`** to:
17-
1. Read the creator key's remaining TTL **before** calling `extend_ttl` (using `get_ttl`).
18-
2. Evaluate `ttl::should_extend(ttl_before, TTL_EXTENSION_THRESHOLD)`.
19-
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.
20-
4. Only publish the `TTL_EXTENDED_EVENT_NAME` event when the check above returns `true`.
18+
1. Derive the remaining TTL from `CreatorTtlLiveUntil` and evaluate `ttl::should_extend(remaining, TTL_EXTENSION_THRESHOLD)`.
19+
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.
20+
3. Only publish the `TTL_EXTENDED_EVENT_NAME` event when the check above returns `true`, then update the tracked live-until.
21+
- **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.
2122

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

@@ -41,9 +42,9 @@ The `extend_creator_ttl` function unconditionally emitted a `ttl_ext` event on e
4142

4243
## Testing
4344

44-
- [ ] `cargo fmt --all -- --check`
45-
- [ ] `cargo clippy --workspace --all-targets -- -D warnings`
46-
- [ ] `cargo test --workspace`
45+
- [x] `cargo fmt --all -- --check`
46+
- [x] `cargo clippy --workspace --all-targets -- -D warnings`
47+
- [x] `cargo test --workspace`
4748

4849
**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:
4950
- They advance the ledger to near expiry before the first buy, so `should_extend` returns `true` and the event is still emitted.

creator-keys/src/lib.rs

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,12 @@ pub mod constants {
375375
pub fn referral_fee_bps() -> DataKey {
376376
DataKey::ReferralFeeBps
377377
}
378+
379+
/// Absolute live-until ledger the contract last set for `creator`'s
380+
/// profile key, used to decide whether to emit the TTL-extension event.
381+
pub fn creator_ttl_live_until(creator: &Address) -> DataKey {
382+
DataKey::CreatorTtlLiveUntil(creator.clone())
383+
}
378384
}
379385

380386
fn creator_key(creator: &Address) -> DataKey {
@@ -597,6 +603,11 @@ pub enum DataKey {
597603
StakedBalance(Address, Address), // (creator, holder) -> staked amount
598604
MaxKeysPerWallet(Address),
599605
ReferralFeeBps,
606+
/// Absolute live-until ledger the contract last set for the creator key
607+
/// via `extend_ttl`. Tracks the TTL extension state so the contract can
608+
/// decide whether to emit the TTL-extension event without a TTL read
609+
/// (the Soroban SDK does not expose TTL reads to contract code).
610+
CreatorTtlLiveUntil(Address),
600611
}
601612

602613
/// Time-locked key allocation for creator self-vesting.
@@ -1328,6 +1339,10 @@ fn settle_holder_dividends(
13281339
env.storage()
13291340
.persistent()
13301341
.set(&checkpoint_key, &accumulator);
1342+
// Keep dividend settlement state live for the same horizon as the
1343+
// creator profile between trades.
1344+
extend_key_ttl_to_full_window(env, &pending_key);
1345+
extend_key_ttl_to_full_window(env, &checkpoint_key);
13311346
Ok(())
13321347
}
13331348

@@ -1350,25 +1365,52 @@ fn compute_claimable_dividend(env: &Env, creator: &Address, holder: &Address) ->
13501365
pending.saturating_add(earned)
13511366
}
13521367

1368+
/// Extends the TTL of a freshly written storage entry to the full
1369+
/// [`CREATOR_TTL_LEDGERS`] window.
1370+
///
1371+
/// Uses `CREATOR_TTL_LEDGERS` as both the threshold and the extension window.
1372+
/// New entries start with the network-default TTL, which is shorter than
1373+
/// `CREATOR_TTL_LEDGERS` on fresh networks; forcing the full window at write
1374+
/// time keeps the entry's real TTL aligned with the live-until the contract
1375+
/// tracks for the TTL-extension event.
1376+
fn extend_key_ttl_to_full_window(env: &Env, key: &DataKey) {
1377+
env.storage()
1378+
.persistent()
1379+
.extend_ttl(key, CREATOR_TTL_LEDGERS, CREATOR_TTL_LEDGERS);
1380+
}
1381+
13531382
/// Extends TTL for all creator-related storage keys.
13541383
///
13551384
/// This function extends the TTL of the creator's primary storage entries
13561385
/// to prevent active creator state from expiring. Called after successful
1357-
/// buy and sell operations. Emits a [`events::TTL_EXTENDED_EVENT_NAME`] event
1358-
/// when the creator key's TTL was actually extended (checked via the SDK's
1359-
/// threshold-vs-expiration logic).
1386+
/// buy, sell, and buyback operations. Emits a [`events::TTL_EXTENDED_EVENT_NAME`]
1387+
/// event only when the creator key's remaining TTL was below
1388+
/// [`TTL_EXTENSION_THRESHOLD`] before this call — a healthy TTL silently
1389+
/// skips the event.
13601390
fn extend_creator_ttl(env: &Env, creator: &Address) {
13611391
let current_ledger = env.ledger().sequence();
13621392
let extend_to = current_ledger + CREATOR_TTL_LEDGERS;
13631393
let threshold = current_ledger;
13641394

13651395
let creator_key = constants::storage::creator(creator);
1366-
1367-
// Check remaining TTL before extending to decide whether to emit the event.
1368-
// The extend_ttl SDK call still happens unconditionally (it is a no-op when
1369-
// the entry already has a healthy expiration).
1370-
let ttl_before = env.storage().persistent().get_ttl(&creator_key);
1371-
let needs_event = ttl::should_extend(ttl_before, TTL_EXTENSION_THRESHOLD);
1396+
let live_until_key = constants::storage::creator_ttl_live_until(creator);
1397+
1398+
// The Soroban SDK does not expose TTL reads to contract code, so the
1399+
// contract tracks the live-until ledger it last set for the creator key
1400+
// in persistent storage ([`DataKey::CreatorTtlLiveUntil`]). The remaining
1401+
// TTL is derived from that value and used only to decide whether to emit
1402+
// the TTL-extension event. The tracked value is always <= the entry's
1403+
// real live-until (the network default can exceed `CREATOR_TTL_LEDGERS`),
1404+
// so the event may fire slightly early on such networks — never too late.
1405+
// The `extend_ttl` SDK calls below still run unconditionally — the
1406+
// runtime no-ops when the entry already has a healthy expiration.
1407+
let live_until: u32 = env
1408+
.storage()
1409+
.persistent()
1410+
.get(&live_until_key)
1411+
.unwrap_or(current_ledger);
1412+
let remaining = live_until.saturating_sub(current_ledger);
1413+
let needs_event = ttl::should_extend(remaining, TTL_EXTENSION_THRESHOLD);
13721414

13731415
env.storage()
13741416
.persistent()
@@ -1428,6 +1470,11 @@ fn extend_creator_ttl(env: &Env, creator: &Address) {
14281470
}
14291471
}
14301472

1473+
// Record the new live-until ledger so future trades can re-evaluate
1474+
// whether the TTL-extension event should be emitted.
1475+
env.storage().persistent().set(&live_until_key, &extend_to);
1476+
extend_key_ttl_to_full_window(env, &live_until_key);
1477+
14311478
// Only emit the TTL extension event when the remaining TTL was below the
14321479
// extension threshold before this call. A healthy TTL silently skips the event.
14331480
if needs_event {
@@ -1580,27 +1627,28 @@ impl CreatorKeysContract {
15801627
// Persist profile before event publication so indexers reading contract state
15811628
// after this tx observe the same registration payload that was emitted.
15821629
env.storage().persistent().set(&key, &profile);
1583-
// Set initial TTL for creator storage
1630+
// Set initial TTL for creator storage. The full window is forced at
1631+
// write time so the entry's real TTL matches the live-until the
1632+
// contract tracks for the TTL-extension event.
15841633
let extend_to = current_ledger + CREATOR_TTL_LEDGERS;
1585-
env.storage()
1586-
.persistent()
1587-
.extend_ttl(&key, current_ledger, extend_to);
1588-
env.storage()
1589-
.persistent()
1590-
.extend_ttl(&preset_key, current_ledger, extend_to);
1634+
extend_key_ttl_to_full_window(&env, &key);
1635+
extend_key_ttl_to_full_window(&env, &preset_key);
15911636
let co_creator_key = constants::storage::co_creator(&creator);
15921637
if env.storage().persistent().has(&co_creator_key) {
1593-
env.storage()
1594-
.persistent()
1595-
.extend_ttl(&co_creator_key, current_ledger, extend_to);
1638+
extend_key_ttl_to_full_window(&env, &co_creator_key);
15961639
}
15971640
let whitelist_key = constants::storage::whitelist(&creator);
15981641
if env.storage().persistent().has(&whitelist_key) {
1599-
env.storage()
1600-
.persistent()
1601-
.extend_ttl(&whitelist_key, current_ledger, extend_to);
1642+
extend_key_ttl_to_full_window(&env, &whitelist_key);
16021643
}
16031644

1645+
// Record the live-until the contract set for the creator key so
1646+
// `extend_creator_ttl` can later decide whether to emit the
1647+
// TTL-extension event.
1648+
let live_until_key = constants::storage::creator_ttl_live_until(&creator);
1649+
env.storage().persistent().set(&live_until_key, &extend_to);
1650+
extend_key_ttl_to_full_window(&env, &live_until_key);
1651+
16041652
env.events().publish(
16051653
events::register_event_topics(&profile.creator),
16061654
events::CreatorRegisteredEvent {
@@ -1719,6 +1767,9 @@ impl CreatorKeysContract {
17191767
.ok_or(ContractError::Overflow)?;
17201768
// Balance key is scoped by (creator, holder) so creator positions cannot collide.
17211769
env.storage().persistent().set(&balance_key, &new_balance);
1770+
// Grant the balance entry the full TTL window so long-held positions
1771+
// survive the same horizon as creator state between trades.
1772+
extend_key_ttl_to_full_window(&env, &balance_key);
17221773

17231774
if let Some(config) = read_protocol_fee_config(&env) {
17241775
let (creator_fee, protocol_fee) =
@@ -2553,6 +2604,9 @@ impl CreatorKeysContract {
25532604
env.storage()
25542605
.persistent()
25552606
.set(&constants::storage::KEY_PRICE, &price);
2607+
// Grant the price entry the full TTL window so buy/sell reads stay
2608+
// live for the same horizon as creator state.
2609+
extend_key_ttl_to_full_window(&env, &constants::storage::KEY_PRICE);
25562610
Ok(())
25572611
}
25582612

creator-keys/tests/ttl_extension_on_buy.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ fn setup(
2929
soroban_sdk::Address,
3030
) {
3131
let (client, contract_id) = register_creator_keys(env);
32+
// The test env archives the contract instance and code after ~4095
33+
// ledgers by default. Bump them to the full extension window so tests
34+
// that advance the ledger far into the future (to drain creator TTL)
35+
// can still invoke the contract.
36+
env.deployer().extend_ttl(
37+
contract_id.clone(),
38+
CREATOR_TTL_LEDGERS,
39+
CREATOR_TTL_LEDGERS,
40+
);
3241
set_key_price_for_tests(env, &client, KEY_PRICE);
3342
let creator = register_test_creator(env, &client, "alice");
3443
(client, contract_id, creator)
@@ -155,8 +164,6 @@ fn test_ttl_not_extended_when_already_high() {
155164
/// succeeds and emits its own event.
156165
#[test]
157166
fn test_no_ttl_extension_event_when_ttl_healthy() {
158-
#[test]
159-
fn buy_extends_instance_ttl() {
160167
let env = soroban_sdk::Env::default();
161168
env.mock_all_auths();
162169
let (client, contract_id, creator) = setup(&env);
@@ -205,6 +212,16 @@ fn buy_extends_instance_ttl() {
205212
assert_eq!(
206213
ttl_before, ttl_after,
207214
"TTL should remain unchanged after buy when TTL is healthy: before={ttl_before} after={ttl_after}"
215+
);
216+
}
217+
218+
#[test]
219+
fn buy_extends_instance_ttl() {
220+
let env = soroban_sdk::Env::default();
221+
env.mock_all_auths();
222+
let (client, contract_id, creator) = setup(&env);
223+
let holder = Address::generate(&env);
224+
208225
let ttl_before = creator_ttl_remaining(&env, &contract_id, &creator);
209226

210227
let mut ledger = env.ledger().get();

docs/storage-layout.md

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ distinguishable from other key types.
7373
| `ReferralFeeBps` | Global | `u32` | `set_referral_fee_bps` (admin) | `buy_key_with_referrer` (referral split), `get_referral_fee_bps` |
7474
| `DiscountTiers` | Global | `Vec<DiscountTier>` | `update_discount_tiers` (admin) | `get_discount_tiers` (volume-based fee discount evaluation) |
7575
| `CreatorVolume(Address)` | Per-creator | `i128` | *(not currently written by any entrypoint)* | `get_creator_volume` |
76+
| `CreatorTtlLiveUntil(Address)` | Per-creator | `u32` | `register_creator` (initial write), `extend_creator_ttl` (updated after every trade) | `extend_creator_ttl` (TTL-extension event gate) |
7677

7778
> **Note:** `CreatorVolume(Address)` is read by `get_creator_volume` but has no
7879
> writer in the current implementation, so it always resolves to its `0`
@@ -81,16 +82,10 @@ distinguishable from other key types.
8182
8283
## TTL extension behavior
8384

84-
The contract does **not** use the `ttl::should_extend(current_ttl, threshold)`
85-
pure helper (`creator-keys/src/lib.rs`) to decide *whether* to bump TTL on the
86-
hot buy/sell path — that helper is an isolated, unit-testable decision
87-
function (see its tests in `lib.rs`) but is not currently wired into
88-
`extend_creator_ttl`.
89-
90-
Instead, `extend_creator_ttl(env, creator)` runs **unconditionally** after
91-
every successful `register_creator`, `buy_key`/`buy_key_with_referrer`, and
92-
`sell_key` call, and extends the TTL of every creator-scoped key that is
93-
currently present in storage:
85+
`extend_creator_ttl(env, creator)` runs **unconditionally** after every
86+
successful `buy_key`/`buy_key_with_referrer`, `sell_key`, and `buyback` call,
87+
and extends the TTL of every creator-scoped key that is currently present in
88+
storage:
9489

9590
- `Creator(creator)` — always extended (the key that must exist for the call to have succeeded)
9691
- `CreatorFeeBalance(creator)` — extended only if present
@@ -114,15 +109,32 @@ effectively always satisfied for any contract that has been live longer than
114109
successful buy/sell/register call re-bumps creator-scoped TTLs by the full
115110
`CREATOR_TTL_LEDGERS` window.
116111

117-
A successful `extend_creator_ttl` call emits a TTL-extension event (see
118-
`events::ttl_extended_topics`) once per invocation, regardless of how many
119-
individual keys were extended underneath it.
120-
121-
Entries **not** covered by `extend_creator_ttl` (global config keys such as
122-
`FeeConfig`, `KeyPrice`, `AdminAddress`, `TreasuryAddress`,
112+
The Soroban SDK does not expose TTL reads to contract code, so the contract
113+
tracks the live-until ledger it last set for the creator profile key under
114+
`CreatorTtlLiveUntil(creator)` (initialised in `register_creator`, updated by
115+
every `extend_creator_ttl` call). The `ttl::should_extend(current_ttl,
116+
threshold)` pure helper is used on that tracked value to decide whether to
117+
emit the TTL-extension event:
118+
119+
- The `extend_ttl` SDK calls still run **unconditionally** on every trade
120+
(the runtime no-ops when an entry's TTL is already healthy).
121+
- A `TTL_EXTENDED_EVENT_NAME` event (see `events::ttl_extended_topics`) is
122+
emitted **only when the tracked remaining TTL was below
123+
`TTL_EXTENSION_THRESHOLD`** before the call — a healthy TTL silently skips
124+
the event.
125+
126+
New entries get the network-default TTL, which can be much shorter than
127+
`CREATOR_TTL_LEDGERS` on fresh networks. To keep an entry's real TTL aligned
128+
with the value the contract tracks, the contract forces the full
129+
`CREATOR_TTL_LEDGERS` window at write time (via `extend_key_ttl_to_full_window`)
130+
for: the creator profile and curve preset at `register_creator`, `KeyPrice` at
131+
`set_key_price`, holder `KeyBalance(creator, holder)` on buy, the dividend
132+
checkpoint/pending pair on settlement, and `CreatorTtlLiveUntil` itself.
133+
134+
Entries **not** covered by `extend_creator_ttl` or a write-time extension
135+
(global config keys such as `FeeConfig`, `AdminAddress`, `TreasuryAddress`,
123136
`ProtocolFeeRecipient`, `CurveSlope`, `ReferralFeeBps`, `DiscountTiers`, and
124-
per-holder keys like `KeyBalance(creator, holder)` and the dividend
125-
checkpoint/pending pair) do not receive automatic TTL bumps from trade
137+
any other per-holder keys) do not receive automatic TTL bumps from trade
126138
activity and should be covered by an operational maintenance job if long-term
127139
persistence is required — see
128140
[creator-state-storage-ttl.md](./creator-state-storage-ttl.md) for the

0 commit comments

Comments
 (0)