Audience: auditors, integrators, and operators reasoning about storage layout, state transitions, and the safety invariants that hold across all code paths.
Companion documents:
docs/API_REFERENCE.md— public method surface.docs/ADMIN_RUNBOOK.md— operator response procedures.docs/tutorial.md— end-to-end testnet walkthrough.
The contract evolves along two independent lifecycles:
- Contract lifecycle — governed by
init()/pause()/unpause(). - Per-address lifecycle — governed by
register()/tip()/withdraw()/unregister().
These are decoupled: a paused contract still allows admin setters, and a non-existent creator cannot receive tips regardless of pause state.
stateDiagram-v2
[*] --> NotInitialized: deploy
NotInitialized --> Active: init()
Active --> Paused: pause()
Paused --> Active: unpause()
There is no selfdestruct opcode available to Soroban contracts, so the
contract lifecycle is terminal: recovery from pause is unpause(),
recovery from a fundamentally broken contract is a redeploy.
Every address passes through this state machine independently.
stateDiagram-v2
[*] --> Stranger
Stranger --> Registered: register()
Registered --> RegisteredWithTips: tip() (balance > 0)
RegisteredWithTips --> Registered: withdraw() (balance = 0)
Registered --> Stranger: unregister() (all balances = 0)
RegisteredWithTips --> RegisteredWithTips: tip() (more tokens / amounts)
note right of RegisteredWithTips
The `Map<Address, ()>` CreatorTokens set
tracks how many distinct token contracts
the creator has accumulated balance in.
end note
The "registered but never tipped" state is collapsed to Registered; the
RegisteredWithTips distinction matters only because unregister() and
withdraw() consult the CreatorTokens set to ensure every per-token
balance is drained before the profile is removed.
sequenceDiagram
actor Supporter
participant Wallet as Supporter Wallet
participant Tip as TipContract
participant SAC as Token SAC
participant Recipient as FeeRecipient
Supporter->>Wallet: sign tip(creator, token, amount, msg)
Wallet->>Tip: tip(from, creator, token, amount, msg)
Tip->>Tip: validate amount, cap, FeeRecipient
alt amount > 0 AND creator registered AND cap not full
Tip->>SAC: transfer(from → contract, amount)
SAC-->>Tip: ok
opt fee_bps > 0
Tip->>SAC: transfer(contract → recipient, fee)
SAC-->>Tip: ok
end
Tip->>Tip: Balance(creator, token) += amount - fee
Tip->>Tip: append Tip(creator, index)
Tip->>Tip: TipCount(creator) += 1
Tip-->>Wallet: index (u64)
Tip-->>Wallet: emit TIP event
else
Tip-->>Wallet: panic! TipError
end
The owner of creator's internal balance only learns the balance changed via
the TIP event and a subsequent get_balance() poll — they do not need to
co-sign.
Defined in src/lib.rs as the DataKey #[contracttype] enum. There are
two storage domains:
- Instance storage — small, frequently-read config and indexes
(
Admin,Paused,FeeBps,FeeRecipient,MaxCreators,MaxTipsPerCreator,MinTipAmount,CreatorCount,Profile,UsernameToAddress). - Persistent storage — large, long-lived creator state and history.
Persistent entries have TTL extended on every read/write
(
extend_persistent_ttl(env, &key)).
DataKey variant |
Domain | Typed value | Owner / scope | Read by | Written by |
|---|---|---|---|---|---|
Admin |
Instance | Address |
Singleton | every admin setter / check_initialized_and_not_paused |
init(), set_admin() |
Paused |
Instance | bool |
Singleton | check_initialized_and_not_paused |
init(), pause(), unpause() |
FeeBps |
Instance | u32 (0–10 000) |
Singleton | tip() |
init(), set_fee_percentage() |
FeeRecipient |
Instance | Address |
Singleton | tip() |
init(), set_fee_recipient() |
MaxCreators |
Instance | u32 (0=unlimited) |
Singleton | register() |
init(), set_max_creators() |
MaxTipsPerCreator |
Instance | u32 (0=unlimited) |
Singleton | tip() |
init(), set_max_tips_per_creator() |
MinTipAmount |
Instance | i128 (0=no minimum) |
Singleton | tip() |
init(), set_min_tip_amount() |
CreatorCount |
Instance | u32 |
Singleton | register() |
register() (+1), unregister() (−1) |
Profile(address) |
Instance | CreatorProfile |
Per-creator | view fns + auth | register(), update_profile() (mutates in place) |
UsernameToAddress(symbol) |
Instance | Address |
Per-username | view fns | register() (set), unregister() (remove) |
Balance(creator, token) |
Persistent | i128 |
Per (creator, token) pair | withdraw(), view fns |
tip() (credit), withdraw() (debit / remove when zero) |
TipCount(creator) |
Persistent | u64 |
Per creator | tip() (cap check + index), unregister() |
register() (init 0), tip() (+1), unregister() (remove) |
Tip(creator, index) |
Persistent | Tip |
Per tip record | get_tip(), get_tips() |
tip() |
CreatorTokens(creator) |
Persistent | Map<Address, ()> |
Per creator; O(log n) set | tip(), withdraw(), unregister() |
tip() (insert on first tip in token), withdraw() (remove on zero), unregister() (remove all) |
Persistent entries use a 15-day threshold / 30-day extension target
(TTL_THRESHOLD = 17_280 * 15, TTL_EXTEND = 17_280 * 30 ledgers):
env.storage().persistent().extend_ttl(key, TTL_THRESHOLD, TTL_EXTEND);
Persistent TTL is extended on every write of these keys; view
functions (get_tip_count, get_balance, get_tip, get_tips,
get_all_tokens) deliberately do not extend TTL to keep poll costs
predictable. Concretely, extend_persistent_ttl(&env, &key) is invoked by:
TipCount(creator)— written byregister()(init0) andtip()(increment).Balance(creator, token)— written on eachtip()credit and onwithdraw()while the balance remains > 0.Tip(creator, idx)— written when a tip is recorded.CreatorTokens(creator)— written whenever the set is mutated.
unregister() removes the TipCount(caller) key rather than
extending it, so the persistent slot is freed once all balances are
drained.
Instance TTL is centrally extended by extend_instance_ttl(env) at the top
of check_initialized_and_not_paused, which is called by every public
state-changing method. Read-only views do not extend instance TTL to
avoid gas amplification on repeat polling.
The following properties hold for every code path in src/lib.rs. They are
the audit baseline; any PR that weakens one of these needs to call it out
explicitly in the change description.
- Forward-only capacity caps. Lowering
MaxCreatorsorMaxTipsPerCreatorblocks new activity past the cap but never retroactively evicts existing creators or tip history. Recovery from a "cap reached" condition is achieved by raising the cap orunregister()-ing underused creators. - Conservation of funds on
unregister().unregister()iterates theCreatorTokens(creator)map; if any per-tokenBalance(creator, token)is non-zero, the call aborts with#13 BalanceNotEmpty. Zero-balance records are then pruned atomically. There is no path that destroys funds or leaves stranded credits. - Fee math precision.
tip()computesfee = (amount * fee_bps) / 10_000and thencreator_amount = amount - fee, giving the recipient the floor of the sharp fee. Integer truncation is acceptable: a single tip cannot round to a value larger thanMAX_FEE_BPS * 1. - Self-reference guards. Neither
set_fee_recipient()norset_admin()accepts the contract's own address;init()additionally rejects the Stellar all-zero address forfee_recipient. This blocks fee-routing loops and deadlocked admin handovers. - No
panic!outside typed errors. Every failure path usespanic_with_error!(env, TipError::Variant); there are no bare.unwrap()calls on storage reads.L-1fromdocs/static-analysis-findings.mdcaptured this as a low-severity advisory, and the contract follows the guidance in production. - Fail-fast on missing preconditions. All state changes call
check_initialized_and_not_paused(&env)first, andtip()further re-validates the fee-recipient invariant underunwrap_or_elseto surface#15 FeeRecipientNotSetrather than a generic panic.7.TipCountis the canonical history length.get_tip_count()reads the persistentTipCountdirectly;tip()uses the same value as the next free index. Theindex >= max_tipscap check uses the pre-increment value, so the contract never rounds a tip index below0even under degenerateMaxTipsPerCreatorconfigurations. - Reservation of
#7 NoTips.TipError::NoTipsis defined but not raised by any current code path; it is reserved for a future "no tip history" surface. Renumbering this code is forbidden by ABI stability. - Token set is
Map<Address, ()>, notVec<Address>. Membership and removal are O(log n) rather than O(n). This avoids a linear-scan-based DoS surface onunregister()for popular creators. - Pause does not lock admins out.
pause()blocksregister/tip/withdraw(and view-fn access viacheck_initialized_and_not_paused), but admin setters still need to call that helper and therefore still execute while paused — by design, so admin recovery can proceed during an active incident.
See the events table in docs/API_REFERENCE.md
for the canonical list including topics and payloads. Indexers should treat
every TipEvent::INIT event as the start of a contract life and trust
get_contract_version() thereafter.
TipError::Variant numeric values are part of the ABI on Stellar Soroban —
they're emitted as #N strings via panic_with_error!. See the
errors table in docs/API_REFERENCE.md for the
current numbering. Do not reorder existing codes.
init()is single-shot. There is noreinit()or upgrade hook; changes are deployed by redeploying the WASM and migrating state off-chain.get_tipspaginates but does not enforce a server-sidelimitceiling (100ceiling, perM-2recommendation) — clients should constrainlimitthemselves (advisory fromM-2indocs/static-analysis-findings.md).