Soroban storage is not permanent. Every ledger entry has a
live_until_ledger; when the current ledger passes it, the entry stops
being live. This page documents what the ticketing contract does about
that, what an operator has to do, and where the policy has gaps.
Background on Soroban storage types and archival semantics lives in the Stellar state archival docs.
Both are defined at the top of
contracts/ticketing/src/lib.rs:
const LEDGER_BUMP: u32 = 535_679; // ~31 days at 5s/ledger
const LEDGER_THRESHOLD: u32 = 500_000; // ~29 days at 5s/ledgerEvery extend_ttl call in the contract uses the same pair:
extend_ttl(LEDGER_THRESHOLD, LEDGER_BUMP).
Soroban's two-argument form means: if this entry's remaining TTL is
below LEDGER_THRESHOLD, push live_until_ledger out to
current_ledger + LEDGER_BUMP; otherwise do nothing. It never shortens
an entry's life, so the call is safe to make on every write.
| Quantity | Ledgers | Wall clock at 5s/ledger |
|---|---|---|
LEDGER_BUMP — TTL granted by an extension |
535,679 | ~31.0 days |
LEDGER_THRESHOLD — refresh trigger |
500,000 | ~28.9 days |
Headroom between them (BUMP - THRESHOLD) |
35,679 | ~2.1 days |
Two consequences follow from the headroom being small, and both matter operationally:
- ~31 days is the survival window. Right after an extending write the entry has 535,679 ledgers of life left. If nothing ever touches it again, that is how long it stays live.
- Writes close together do not refresh. The entry only falls back
under the 500,000 threshold after 35,679 ledgers (~2.1 days) of
inactivity, so a write inside that window finds
extend_ttla no-op. That is intentional — refreshing on every write would pay rent repeatedly for the same entry — but it means "touched recently" and "TTL was extended" are not the same thing when reading the contract.
LEDGER_BUMP is comfortably below the network's maximum entry TTL
(max_entry_ttl is 6,312,000 ledgers in the ledger configuration the
test suite runs against, visible in
contracts/ticketing/test_snapshots/), so neither limit is the binding
constraint here.
| Storage | Key | Extended on | Not extended on |
|---|---|---|---|
| Instance | Admin, PaymentToken, TokenDecimals, PendingPaymentToken, MinPurchaseSpacing, NextTicketId |
initialize only |
propose_payment_token, apply_payment_token, set_purchase_throttle, every mint that bumps NextTicketId |
| Persistent | Event(id) |
create_event, create_event_with_options |
allocate_lottery, enable_escrow, set_event_payment_token, issue_ticket, purchase_primary, release_escrow |
| Persistent | Ticket(id) |
every write, via save_ticket |
— |
| Persistent | GiftClaim(id) |
create_gift_claim |
— (removed on claim, transfer, check-in or revoke, which is the intended disposal) |
| Persistent | LastPurchaseLedger(buyer) |
record_purchase |
never removed; expires with the buyer's inactivity |
| Persistent | OrganizerEventAt(organizer, index) |
create_event, create_event_with_options (written once per event) |
never removed; read by list_organizer_events |
| Persistent | TicketMetadataUri(id) |
issue_ticket_with_metadata (only when a URI is supplied) |
— (renewed with its ticket by extend_ticket_ttl, and absent for tickets without a URI) |
| Persistent | OwnerBalance(owner) |
every mint and ownership change; extend_ticket_ttl renews the owner's counter |
— (a holder whose tickets are never renewed or moved lets the counter lapse, so balance then reads 0; see SEP50.md) |
| Instance | PlatformFeeBps, OutstandingResaleFunds |
every entry point, through extend_instance_ttl |
— (instance storage shares the instance TTL) |
| Persistent | Auction(id) |
start_auction, each place_bid, extend_ticket_ttl |
removed on settle_auction or cancel_resale |
| Persistent | Offer(id, buyer) |
make_offer only |
removed on accept_offer or cancel_offer. Not renewed by extend_ticket_ttl. An offer left idle past its TTL is archived, and its escrowed funds come back only after the entry is restored. |
| Persistent | TicketApproval(id) |
approve, extend_ticket_ttl |
removed on any owner change, on burn, or by revoke_approval |
Two structural notes:
- Instance storage is a single entry. All instance keys share the
contract instance's TTL, and one
instance().extend_ttl()call extends the contract instance and its code entry as well. That is whyinitialize's single call covers every instance key — and why there is nothing to gain from calling it again per key. Ticketis the well-covered entry. Every ticket mutation, in every code path, goes throughsave_ticket, which extends. A ticket that is transferred, listed, bought, checked in or revoked is refreshed as a side effect of that operation.
TicketsIssued(id) and OrganizerEvents(address) are separate persistent
counter entries. They are extended when created and whenever their counters
change.
This is the most important operational fact in this document.
Event(id) is written in six places after creation — allocate_lottery,
enable_escrow, set_event_payment_token, issue_ticket,
purchase_primary and release_escrow — and none of them call
extend_ttl. The only extension happens in the two create_event*
functions. tickets_issued, escrow_balance, escrow_enabled and
payment_token therefore get persisted but the entry's TTL is left to
decay from the moment the event was created.
In practice that means an event record is on the clock from
create_event, not from its last sale. An event created more than
~31 days before its starts_at — a long-presale concert, a seasonal
pass, an event created early and activated later — can have its
Event entry go live-less while the event is still very much active.
Sales on such an event fail at the host level, because the archival
check happens before the contract body runs; the contract's own
EventNotFound is never reached.
Mitigations available today, in order of preference:
- Create events close to the start of sales rather than months ahead.
- Run a TTL keeper (below) that keeps the entry alive.
- Treat the redeploy-and-migrate path in
MAINNET.mdas the recovery mechanism if an event lapses.
A code fix — calling extend_ttl on the event key in the same helper
that writes it — is a one-line change per write site, but it is a
contract change and this document only records the current policy.
Event, Ticket and GiftClaim are persistent entries. When their
TTL reaches zero they are archived, not deleted:
- The entry stays in the ledger but cannot be read by contract code.
- A transaction whose footprint contains an archived persistent entry
but does not list it for restore fails during the apply stage, before
the contract is entered. This is why a lapsed event produces a
storage error rather than
EventNotFound. - Since Protocol 23, the Stellar RPC simulation populates a restore
preamble automatically when it detects an archived entry, and the SDK
can perform the restore and retry. Manual
RestoreFootprintOpremains available for cases where the automatic path is too large or the operator wants to pay the restoration cost instead of the user's. - A restored entry comes back with the network's minimum TTL for a
newly created entry, not the ~31 days this policy grants. It is
immediately eligible for a fresh
extend_ttl, so plan a keeper run right after a restore rather than assuming the original window came back.
This contract uses no temporary storage, so nothing here is subject to permanent deletion on TTL expiry.
Because the contract only extends what it writes, keeping a dormant event or a long-held ticket alive is an off-chain job. There are two mechanisms:
| Mechanism | When to use | Notes |
|---|---|---|
ExtendFootprintTTLOp |
Preferred for a passive keepalive | Extends the entries named in the transaction's read-only footprint without calling contract code. Must be the only operation in the transaction, and its resource fees must be set from a simulation. |
| A normal write call | When a state change is wanted anyway | Any ticket-touching call (set_seat, list_for_resale + cancel_resale, check_in) extends the Ticket entry as a side effect. Does not extend the Event entry. |
A workable keeper cadence: run the extend job roughly every 20 days for every event and ticket that has had no on-chain activity, which leaves a ~11-day margin against the ~31-day survival window. There is no reason to run it more often than that: any extension issued less than ~2.1 days after the previous one is a no-op and pays nothing useful, and any extension at all resets the full window.
Sizing the job:
- Read the entries you intend to extend from the RPC ledger entries
endpoint, put them in the read-only footprint, simulate to get
minResourceFee, and submit. - Remember the extended entries' TTL is capped by the network's maximum
entry TTL, not by
LEDGER_BUMP.
Alert on the conditions described in
MAINNET.md, specifically:
- Any invocation that fails with a storage/archival error rather than a
contract
Errorcode — that is the signature of a lapsed entry. - Events and tickets whose last on-chain write is older than ~20 days,
especially events whose
starts_atis still in the future. - The age of the contract instance and code entries, which no
contract call extends after
initialize.
extend_ttl is not free — it is one of the more expensive operations
per unit of work — but it is only paid when the call is actually
effective. The policy above deliberately spends rent only on writes that
are more than ~2.1 days apart, which is why the threshold is set so
close to the bump: a lower threshold would refresh on every write in a
busy sale burst and pay rent repeatedly for the same entry.
ARCHITECTURE.md— why events and tickets are in persistent storageERRORS.md— including how archival differs fromEventNotFoundGAS_AND_FEES.md— the fee model this cost sits insideINTEGRATION.md— keeper and reconciliation jobs for a backend- Stellar: state archival
- Stellar: state archival guides