Receives a single USDC tip and splits it across one or more recipients by basis-point shares, in one transaction. A recipient who cannot receive the token no longer blocks the others: their share is escrowed by the contract for later withdrawal.
- Jar — a creator's tip target, identified by a public slug (e.g.
@alice). Holds anownerand a list ofSplits. A slug (jar_id) is canonical: a leading@followed by 3–32 lower-case ASCII letters, digits,_or-— exactly the backend'sSLUG_REGEX. Anything else (upper case, whitespace, control bytes, a missing@, or a length outside that range) is rejected withInvalidJarId, so lookalikes cannot exist next to a real creator's slug. The storage bound is alsoMAX_JAR_ID_LEN(64 bytes) — it doubles as a storage key, an event topic, and a public URL slug. - Split — a recipient
Addressand its share in basis points (bps). Every split must havebps >= 1, all splits in a jar must sum to exactly10_000(= 100%), and no address may appear more than once. - USDC token — the Stellar Asset Contract id is fixed at deploy time; every tip settles in that asset.
- Message — the free-text note a supporter attaches to a tip. Capped at
280bytes (MAX_MESSAGE_LEN), must be valid UTF-8, and may not contain control characters other than newline; echoed into thetipevent. - Sequence number — a contract-wide
u64, incremented once per successful tip, returned bytipand published in thetipevent. Gives each tip an identifier of its own where the transaction hash does not. - Reference — an optional 32-byte opaque id a tipper attaches with
tip_with_ref, echoed in thetipevent so the calling integration can match the on-chain event back to whatever it created off-chain. Never stored and never checked by the contract.
Quick reference for bounds enforced by the contract:
| Limit | Value | Constant | Error when exceeded |
|---|---|---|---|
| Basis point denominator | 10_000 |
BPS_DENOM |
SplitOutOfRange (11), SplitSumNot100Pct (12) |
| Maximum recipient count | 20 |
MAX_RECIPIENTS |
TooManyRecipients (6) |
| Maximum message length | 280 bytes |
MAX_MESSAGE_LEN |
MessageTooLong (8) |
| Maximum batch tips | 10 |
MAX_BATCH_TIPS |
BatchTooLarge (38) |
| Maximum jar id length | 33 bytes (@ + 32) |
MAX_JAR_SLUG_LEN + 1 |
InvalidJarId (9) |
struct Split { to: Address, bps: u32 }
struct Jar { owner: Address, splits: Vec<Split>, locked: bool, paused: bool }
struct Limits { bps_denom: u32, max_recipients: u32, max_message_len: u32, max_batch_tips: u32 }
struct Recurring {
supporter: Address, jar_id: String, amount: i128,
period_ledgers: u32, message: String, next_due_ledger: u32,
}
struct Split { to: Address, bps: u32 }
struct Jar { owner: Address, splits: Vec<Split> }
struct Limits { bps_denom: u32, max_recipients: u32, max_message_len: u32, max_batch_tips: u32 }
struct TipRequest { jar_id: String, amount: i128, message: String }
struct JarStats { tip_count: u64, total: i128 }
struct SupporterStats { tip_count: u64, total: i128 }TipRequest is one entry of a tip_many batch: the
slug to tip, the amount, and the message that rides along with that jar's tip
event. It is exactly the per-jar argument list of tip, so a batched entry
carries no new fields to reason about.
JarStats and SupporterStats are the on-chain totals behind
reading tip totals. They share a shape but not a key:
one belongs to a jar, the other to a (jar, supporter) pair.
| Function | Auth | Description |
|---|---|---|
__constructor(admin, token) |
— | Deploy-time init. Stores the admin and USDC token address, and puts that token on the tip allowlist as its first entry. |
create_jar(owner, jar_id, splits) |
owner, plus the registrar's co-signature over (jar_id, owner) while one is registered |
Register a new jar. Fails if the slug is not a canonical @-prefixed [a-z0-9_-]{3,32} slug, already exists, or splits are invalid. Emits a jar_crtd event. See the registrar gate. |
update_splits(jar_id, splits) |
jar owner, plus every current recipient whose share decreases or is removed when the jar is locked |
Replace a jar's splits. Subject to the same validation as create_jar. On a locked jar the owner alone cannot cut a collaborator — see lock_splits. Emits a splits event. |
lock_splits(jar_id) |
jar owner |
Lock a jar so update_splits needs each reduced/removed recipient's consent. Emits a lock event with data true. |
unlock_splits(jar_id) |
jar owner and every current recipient |
Remove the lock. Consent mirrors a reduction: all recipients must sign, or the owner could disarm the lock and then cut shares. Emits a lock event with data false. |
is_splits_locked(jar_id) -> bool |
— | Whether the jar's splits are locked. Panics with JarNotFound if the slug is free. |
set_jar_paused(jar_id, paused) |
jar owner |
Pause (true) or resume (false) tipping for one jar (#45). While paused, every tip entry point rejects the jar with JarPaused; reads stay available. The flag survives transfer_jar_ownership. Emits a jar_pause event carrying the new value. See pausing a jar. |
is_jar_paused(jar_id) -> bool |
— | Whether tipping is currently paused for the jar. Panics with JarNotFound if the slug is free. |
leave_jar(jar_id, recipient) |
recipient |
Remove recipient from the jar's splits. Their share is added to the last remaining entry so the splits still sum to 10 000. Rejected with SoleRecipient (only entry) or NotRecipient (not in the jar). Emits a splits event. |
set_min_tip_amount(jar_id, min_amount) |
jar owner |
Set or clear an optional minimum tip amount for a jar. min_amount must be strictly positive (> 0) or None to clear. Emits a min_tip event. |
transfer_jar_ownership(jar_id, new_owner) |
current jar owner and new_owner |
Hand control of a jar to new_owner. The exact (jar_id, new_owner) pair must be signed by both parties; a transfer to the current owner is rejected. Splits are unchanged. Emits a jar_xfer event carrying both owners. |
delete_jar(jar_id) |
current jar owner |
Delete a registered jar and free its slug so it can be recreated. Removes the jar, its stats, and any minimum-tip value for that slug. Emits a jar_del event. |
rename_jar(jar_id, new_jar_id) |
jar owner |
Move a jar, its stats and its minimum to a new slug. The old slug stops resolving immediately; new_jar_id must be free and pass the same length checks as create_jar. Emits a jar_rnm event carrying both ids. |
tip(from, jar_id, amount, message) |
from |
Move amount USDC out of from and route it across the jar's recipients. A recipient who cannot receive the token has their share escrowed in the contract (see get_held/withdraw_held) instead of reverting the tip. Emits a tip event carrying the per-recipient breakdown. Rejects amounts below the jar's minimum with BelowMinTipAmount. |
get_held(recipient) -> i128 |
— | How much the contract is holding for recipient because a past tip could not deliver their share. 0 when nothing is held. |
withdraw_held(recipient) |
recipient |
Pay a recipient their held balance once they can receive again. Rejected with NothingHeld when nothing is owed. Emits a withdraw event. |
create_recurring(supporter, jar_id, amount, period_ledgers, message) -> u128 |
supporter |
Register a recurring-tip schedule. Validates the jar exists and the amount/message pass the same checks tip applies; period_ledgers must be non-zero. Assigns a fresh id, stores the entry with its first next_due_ledger, and emits a recr_add event. Returns the id. The schedule moves funds through collect_recurring: the supporter must have approved this contract as a token spender for at least amount per period. |
cancel_recurring(id) |
the entry's supporter |
Remove a recurring-tip record. Emits a recr_del event. |
get_recurring(id) -> Recurring |
— | Read a stored recurring-tip entry. Panics with RecurringNotFound for an unknown id. |
collect_recurring(id) |
— (any caller, typically a keeper) | Collect one due period of a recurring tip (#175). Pays the schedule's amount from the supporter's approved allowance with transfer_from (this contract is the spender), split by the same arithmetic as tip, and publishes the identical tip event. next_due_ledger advances by exactly period_ledgers. Too early — NotDue; allowance below amount (exhausted or expired) — InsufficientAllowance; supporter balance below amount — InsufficientBalance; all raised before any transfer, with zero state change. See collecting recurring tips. |
tip(from, jar_id, amount, message) |
from |
Transfer amount of the default token from from, split across the jar's recipients. Emits a tip event carrying the per-recipient breakdown and the token paid in. Rejects amounts below the jar's minimum with BelowMinTipAmount. |
tip_in_token(from, jar_id, token, amount, message) |
from |
Same split, paid in token, which must be on the allowlist — otherwise TokenNotAllowed, raised before any transfer. The jar minimum applies only when token is the default token. Emits the same tip event. |
allow_token(token) |
contract admin |
Add token to the allowlist. Idempotent. Emits a tk_allow event carrying the admin who authorised it. |
disallow_token(token) |
contract admin |
Remove token from the allowlist. Refuses the default token with CannotDisallowDefault. Emits a tk_dis event carrying the admin. |
set_tip_hook(hook) |
contract admin |
Register or clear (None) the post-tip hook contract (#176). Emits a tip_hook event carrying the admin who made the change. See the post-tip hook. |
get_tip_hook() -> Option<Address> |
— | The registered hook, or None when tips settle without one. |
set_registrar(registrar) |
contract admin |
Register or clear (None) the registrar whose co-signature create_jar requires (#178). Emits a reg_set event carrying the admin. See the registrar gate. |
get_registrar() -> Option<Address> |
— | The registrar create_jar currently requires, or None when jar creation is permissionless. |
set_fee_bps(fee_bps) |
contract admin |
Set the protocol fee taken from every tip before the split, in basis points (#79). Defaults to 0; above 500 (5%) is rejected with FeeTooHigh. The fee is paid to the admin. Emits a fee_set event. See the protocol fee. |
get_fee_bps() -> u32 |
— | The protocol fee currently taken from every tip, in basis points. 0 until the admin sets one. |
tip(from, jar_id, amount, message) -> u64 |
from |
Transfer amount USDC from from, split across the jar's recipients. Emits a tip event carrying the per-recipient breakdown and this tip's sequence number, and returns that number. Rejects amounts below the jar's minimum with BelowMinTipAmount. |
tip_with_ref(from, jar_id, amount, message, reference) -> u64 |
from |
Identical to tip, plus an optional 32-byte opaque reference echoed in the tip event. Same transfers, same validation, same counter — both entry points call one internal implementation. |
tip_many(from, tips) -> Vec<u64> |
from |
Tip several jars in one transaction, each with its own amount and message. from authorises once for the whole batch, and every entry settles exactly as a single tip would — same validation, same minimum-tip check, same protocol fee, same self-transfer skip — and publishes its own tip event. The batch is bounded by MAX_BATCH_TIPS (BatchTooLarge); an empty batch is BatchEmpty. All-or-nothing: a failure in any jar reverts the whole call, leaving the earlier jars untouched. Returns one sequence number per entry, in order. See tipping many jars at once. |
get_tip_count() -> u64 |
— | How many tips the contract has taken, i.e. the number the next tip will receive. 0 on a fresh deploy. |
get_jar(jar_id) -> Jar |
— | Read a jar's configuration. Panics with JarNotFound if the slug is free. |
get_jar_owner(jar_id) -> Address |
— | Read just a jar's owner. Panics with JarNotFound if the slug is free. |
get_split_count(jar_id) -> u32 |
— | How many recipients a jar pays. Panics with JarNotFound if the slug is free. |
preview_split(jar_id, amount) -> Vec<i128> |
— | What each recipient would receive from a tip of amount, in split order. Rejects amounts below the jar's minimum with BelowMinTipAmount as well as the amounts tip rejects; panics with JarNotFound if the slug is free. |
preview_split_details(jar_id, amount) -> Vec<PreviewSplit> |
— | The same preview paired with each recipient's address, so a client that renders the breakdown needs one call instead of preview_split plus get_jar. Validates exactly as preview_split does. |
get_min_accepted_tip(jar_id) -> i128 |
— | The smallest positive amount that pays every configured recipient a non-zero share, raised to the jar's minimum when that minimum is larger. Panics with JarNotFound if the slug is free. |
preview_tip(jar_id, from, amount) -> Vec<i128> |
— | What each recipient would actually receive from a tip of amount sent by from (#177), in split order: the sender-aware sibling of preview_split. A recipient who is the sender is reported as 0, exactly as the tip event's breakdown records them, because their share is never transferred and stays with them. Computed by the same private helper the tip payout loop uses, so preview and payout cannot drift. Rejects what tip rejects. |
jar_exists(jar_id) -> bool |
— | Whether the slug is already registered. |
is_recipient(jar_id, address) -> bool |
— | Whether address appears in the jar's splits. Panics with JarNotFound if the slug is free. |
get_min_tip_amount(jar_id) -> Option<i128> |
— | Read a jar's optional minimum tip amount. Returns None if no minimum is set. Panics with JarNotFound if the slug is free. |
get_min_accepted_tip(jar_id) -> i128 |
— | The smallest tip this jar accepts: the larger of the amount needed to give every recipient a non-zero share and the jar's configured minimum. See the smallest amount a jar accepts. Panics with JarNotFound if the slug is free. |
get_token() -> Address |
— | The USDC token address tips settle in. |
get_admin() -> Address |
— | The contract admin recorded at deploy time. |
transfer_admin(new_admin) |
contract admin |
Hand admin control to new_admin. Rejected with AdminExists when it is already the current admin. Emits an adm_xfer event carrying both addresses. |
pause() |
contract admin |
Pause tipping: tip, tip_with_ref and tip_in_token reject with ContractPaused. Emits a pause event. |
resume() |
contract admin |
Lift a pause. Emits a resume event. |
is_paused() -> bool |
— | Whether tipping is currently paused. Reads instance storage. |
upgrade(new_wasm_hash) |
contract admin |
Replace the running contract Wasm while keeping the address and all stored state. Emits an upgrade event carrying the new hash. |
get_limits() -> Limits |
— | The bounds this contract enforces: bps_denom, max_recipients, max_message_len, max_batch_tips. Reads no storage. |
get_limits() -> Limits |
— | The bounds this contract enforces: bps_denom, max_recipients, max_message_len, max_jar_id_len. Reads no storage. |
version() -> u32 |
— | Which interface shape this deployment speaks. Reads no storage. See detecting the interface version. |
get_jar_stats(jar_id) -> JarStats |
— | A jar's on-chain tip count and total. Zeros for a jar nobody has tipped; JarNotFound if the slug is free. |
get_supporter_stats(jar_id, supporter) -> SupporterStats |
— | One address's tip count and total against one jar. Zeros for an address that has never tipped it; JarNotFound if the slug is free. |
is_token_allowed(token) -> bool |
— | Whether the admin has put token on the tip allowlist. The default token is always allowed. |
The Errors column lists the tip_splitter error codes a call can return, |
||
| in the order the contract checks them — only the first failure a call hits | ||
| is reported, so the order tells a client what it will actually see. An error is | ||
| written as raised only when every earlier check has passed. |
| Function | Auth | Errors (in check order) | Description |
|---|---|---|---|
__constructor(admin, token) |
— | — | Deploy-time init. Stores the admin and USDC token address. |
create_jar(owner, jar_id, splits) |
owner (+ registrar co-auth when set) |
InvalidJarId → JarExists → SplitsEmpty → TooManyRecipients → SplitOutOfRange / DuplicateRecipient (per entry) → SplitSumNot100Pct → Soroban auth error (missing registrar co-signature, #178) |
Register a new jar. Fails if the slug is not a canonical @-prefixed [a-z0-9_-]{3,32} slug, already exists, or splits are invalid. The registrar's co-signature is demanded only after validation passes, so a malformed request never burns one. Emits a jar_crtd event. |
update_splits(jar_id, splits) |
jar owner |
JarNotFound → SplitsEmpty → TooManyRecipients → SplitOutOfRange / DuplicateRecipient (per entry) → SplitSumNot100Pct |
Replace a jar's splits. Subject to the same validation as create_jar. Emits a splits event. |
set_min_tip_amount(jar_id, min_amount) |
jar owner |
JarNotFound → InvalidMinimumAmount |
Set or clear an optional minimum tip amount for a jar. min_amount must be strictly positive (> 0) or None to clear. Emits a min_tip event. |
transfer_jar_ownership(jar_id, new_owner) |
current jar owner and new_owner |
JarNotFound → InvalidRecipient (new owner is the current owner, the contract itself, or the token) |
Hand control of a jar to new_owner. The exact (jar_id, new_owner) pair is signed by both owners. Splits are unchanged. Emits a jar_xfer event carrying both owners. |
delete_jar(jar_id) |
current jar owner |
JarNotFound |
Delete a registered jar and free its slug for reuse. Removes the jar, its stats, and the associated minimum-tip value. Emits a jar_del event. |
rename_jar(jar_id, new_jar_id) |
jar owner |
InvalidJarId (new id not a canonical @-prefixed [a-z0-9_-]{3,32} slug) → JarNotFound → JarIdInUse |
Move a jar, its stats and its minimum to new_jar_id. The old id stops resolving once the move is complete. Emits a jar_rnm event carrying both ids. |
tip(from, jar_id, amount, message) |
from |
ContractPaused → NotInitialized → AmountZero (amount = 0) / AmountNegative (amount < 0) → MessageTooLong → JarNotFound → JarPaused → BelowMinTipAmount → InsufficientBalance → SplitAmountTooSmall (a share truncates to zero) / AmountOverflow (amount * bps overflows i128) |
Transfer amount USDC from from, split across the jar's recipients. Emits a tip event carrying the per-recipient breakdown. Rejects amounts below the jar's minimum with BelowMinTipAmount and amounts that cannot pay every recipient with SplitAmountTooSmall; see the smallest amount a jar accepts. A failing token transfer reverts the whole call with the token contract's own error, not one of this contract's codes (e.g. an insufficient balance). |
get_jar(jar_id) -> Jar |
— | JarNotFound |
Read a jar's configuration. Panics with JarNotFound if the slug is free. |
get_jar_owner(jar_id) -> Address |
— | JarNotFound |
Read just a jar's owner. Panics with JarNotFound if the slug is free. |
get_split_count(jar_id) -> u32 |
— | JarNotFound |
How many recipients a jar pays. Panics with JarNotFound if the slug is free. |
preview_split(jar_id, amount) -> Vec<i128> |
— | AmountZero / AmountNegative → JarNotFound → BelowMinTipAmount → SplitAmountTooSmall / AmountOverflow |
What each recipient would receive from a tip of amount, in split order. Rejects amounts below the jar's minimum with BelowMinTipAmount as well as the amounts tip rejects, in the same order; panics with JarNotFound if the slug is free. |
preview_tip(jar_id, from, amount) -> Vec<i128> |
— | AmountZero (amount = 0) / AmountNegative (amount < 0) → JarNotFound → BelowMinTipAmount → SplitAmountTooSmall / AmountOverflow |
The sender-aware preview (#177): exactly the amounts tip will transfer for from, helper-shared with the payout. |
preview_tip(jar_id, from, amount) -> Vec<i128> |
— | InvalidAmount (amount ≤ 0) → JarNotFound → BelowMinTipAmount → InvalidAmount (a share truncates to zero, or amount * bps overflows) |
The sender-aware preview (#177): exactly the amounts tip will transfer for from, helper-shared with the payout. |
tip_many(from, tips) -> Vec<u64> |
from |
Soroban auth error (missing from signature) → ContractPaused → BatchEmpty → BatchTooLarge → NotInitialized, then per entry the same sequence as tip: AmountZero / AmountNegative → MessageTooLong → JarNotFound → BelowMinTipAmount → SplitAmountTooSmall / AmountOverflow → InsufficientBalance → the token contract's own error |
Tip several jars in one transaction (#84). The batch-size and pause checks run first, before any jar is looked up; each entry is then settled exactly as tip would settle it (including the per-jar JarPaused check), and a failure in any entry reverts the whole call. |
collect_recurring(id) |
— | RecurringNotFound → NotDue → JarNotFound → NotInitialized → InsufficientAllowance → InsufficientBalance |
Collect one due period of a recurring schedule through the supporter's token allowance (#175). Publishes the same tip event and fires the post-tip hook. |
jar_exists(jar_id) -> bool |
— | — | Whether the slug is already registered. Reads one key and returns a plain bool; never panics on a free slug. |
is_recipient(jar_id, address) -> bool |
— | JarNotFound |
Whether address appears in the jar's splits. Panics with JarNotFound if the slug is free. |
get_min_tip_amount(jar_id) -> Option<i128> |
— | JarNotFound |
Read a jar's optional minimum tip amount. Returns None if no minimum is set. Panics with JarNotFound if the slug is free. |
get_min_accepted_tip(jar_id) -> i128 |
— | JarNotFound |
The smallest tip this jar accepts: the larger of the truncation floor and the configured minimum. See the smallest amount a jar accepts. |
get_token() -> Address |
— | NotInitialized |
The USDC token address tips settle in. Reads instance storage; NotInitialized if the deploy-time token is absent. |
get_admin() -> Address |
— | NotInitialized |
The contract admin recorded at deploy time. Reads instance storage; NotInitialized if the deploy-time admin is absent. |
get_limits() -> Limits |
— | — | The bounds this contract enforces: bps_denom, max_recipients, max_message_len, max_batch_tips. Reads no storage. |
get_limits() -> Limits |
— | — | The bounds this contract enforces: bps_denom, max_recipients, max_message_len, max_jar_id_len. Reads no storage. |
Two ordering notes that are easy to get wrong:
- Auth is not a contract error code. A call that lacks the required
signature fails with Soroban's authorization error, not a
tip_splittervariant. The owner-only setters (update_splits,set_min_tip_amount,transfer_jar_ownership) look the jar up before requiring the owner's signature, so an unauthenticated caller using a free slug seesJarNotFound, not an auth failure.create_jarrequires the declared owner's signature first. tipvalidates the message before it looks up the jar, so an over-long message to an unregistered slug reportsMessageTooLong, notJarNotFound, and a non-UTF-8 or control-byte message reportsInvalidMessageat the same point. Likewisevalidate_splitschecks each entry'sbpsrange before scanning for that entry's duplicates, so[{to: a, bps: 0}, {to: a, bps: 10_000}]reportsSplitOutOfRange.
A collaborator added to someone else's jar has no cheap way to confirm they are
actually in it. The alternative is fetching the whole jar with get_jar and
scanning the split vector for your own address, which is awkward from a wallet
or a one-line script.
is_recipient(jar_id, address) answers it directly and returns a plain
bool. It reads the same single persistent key get_jar does, so it is
cheap enough for a lightweight client.
Membership is not ownership. The answer tracks the split vector only: a jar
owner who takes no share of a tip gets false, and an address listed in the
splits gets true whether or not it owns the jar. Use
get_jar_owner to ask who controls a jar.
A missing jar is an error, not a false. An unregistered slug panics with
JarNotFound, exactly as get_jar does. Returning false would make a typo'd
jar id indistinguishable from a genuine non-membership. Use jar_exists when
the question is whether the slug is registered at all.
Membership follows update_splits: a collaborator removed by a splits update
immediately reads as false, and one added reads as true. There is no
historical view — the answer describes the jar's current splits, so it is not a
record of who was paid by past tips. For that, read the per-recipient
breakdown in the tip event. For
what a future tip would pay each of them, use
preview_split.
BPS_DENOM, MAX_RECIPIENTS, MAX_MESSAGE_LEN and MAX_BATCH_TIPS are
private constants, so a client that wants to validate input before paying for a
transaction has no way to read them. The result was that every client carried
its own copy — the tip form capped messages at its own number, the splits editor
hardcoded the twenty recipient limit — and changing a bound here silently
desynchronised the stack.
BPS_DENOM, MAX_RECIPIENTS, MAX_MESSAGE_LEN, MAX_JAR_SLUG_LEN and
MAX_JAR_ID_LEN are private constants, so a client that wants to validate
input before paying for a transaction has no way to read them. The result was
that every client carried its own copy — the tip form capped messages at its
own number, the splits editor hardcoded the twenty recipient limit, the
onboarding form hardcoded 64 for the jar id — and changing a bound here
silently desynchronised the stack.
get_limits returns all four:
Limits {
bps_denom: 10_000, // BPS_DENOM — the denominator every `bps` is a share of
max_recipients: 20, // MAX_RECIPIENTS — most recipients in one jar
max_message_len: 280, // MAX_MESSAGE_LEN — longest tip message, in UTF-8 bytes
max_batch_tips: 10, // MAX_BATCH_TIPS — most jars one `tip_many` call may tip
max_jar_id_len: 33, // MAX_JAR_SLUG_LEN + 1 — longest canonical jar id ("@" + 32)
}All four are compile-time constants, so the view reads no storage and costs the same regardless of contract state. A client can fetch it once at startup and cache it for the session.
max_jar_id_len is the effective input bound, not the 64-byte
MAX_JAR_ID_LEN storage cap. The canonical rule in
jar_id validation already caps an id at a leading @
plus 32 characters, so a longer value is rejected with InvalidJarId long
before the storage cap matters; reporting 64 would let an onboarding form
submit a slug the contract refuses.
Consumer-facing change: max_jar_id_len is a new field in the map
get_limits returns (#98). A decoder typed against the previous three-field
Limits must add the field, or ignore map keys it does not know, before
pointing at a deployment that reports it; version() stays at 3 because no
existing function, error code or event changed shape.
max_message_len is a byte count, not a character count — see
Message above. A client showing a remaining-characters indicator
must count UTF-8 bytes against this number, not message.length.
A test asserts the returned values equal the constants (get_limits_matches_ contract_constants in test.rs), so the view and the enforcement cannot drift
apart. The current values are also asserted literally, which means changing a
bound fails the suite until the constants are updated with it. The document
you are reading is checked too: scripts/check-docs.sh (make check-docs,
and a CI step) builds the contract, reads its public interface out of the
built wasm, and fails when the Functions table above or the Errors table
below no longer matches the contract — a function or error variant missing
from either table, listed there although the contract does not have it, or
documented with a different error number.
A tipper who supports several creators pays for it per creator: one transaction, one wallet approval and one network fee each. For micro-tips, where the point is speed and the amounts are small, that overhead is a large fraction of the whole interaction.
tip_many(from, tips) settles a whole batch under one authorisation. Each
entry in tips is a TipRequest — the slug, the amount, and the message — and
each is settled exactly as a standalone tip
would settle it:
TipRequest { jar_id: String, amount: i128, message: String }"Exactly" is literal. Every entry goes through the same private settle_tip the
single-jar entry points share, so a batched jar gets the same validation, the
same minimum-tip check, the same protocol fee, the same self-transfer skip, the
same held-funds fallback, and its own tip event carrying its own sequence
number. Each entry's amount and message are its own; the single authorisation is
the only thing the entries share.
So a batched tip is indistinguishable, on chain, from the same tips sent one at
a time — an indexer that already decodes a tip event needs no change
whatsoever to follow a batch. tip_many returns one sequence number per entry,
in the order the entries were given.
The batch is all-or-nothing. Soroban rolls the whole transaction back if any entry panics, so a batch whose last jar is rejected leaves the earlier jars' balances and totals untouched, and no sequence number is consumed by the entries that would have settled. A client that wants partial success tips the jars that would fail separately, or filters them out first.
The batch is bounded by MAX_BATCH_TIPS (see
reading the contract's limits) and that bound is
a typed error, not a budget abort: each entry can fan out to MAX_RECIPIENTS
transfers, so an unbounded batch would eventually exhaust the transaction's
budget and fail with an opaque wasm trap. MAX_BATCH_TIPS * MAX_RECIPIENTS
keeps the worst case at 200 transfers, the same order of magnitude as a single
maximum-size jar. Exceeding the bound is BatchTooLarge; an empty batch is
BatchEmpty.
get_jar_owner answers "who controls this jar?" without moving the recipient
list. The alternative — get_jar(jar_id).owner — deserializes the whole
splits vector to read one address, which for a jar near the 20 recipient cap
is a lot of data to render a "you own this jar" badge or to decide whether the
connected wallet may call update_splits.
It reads the same stored jar get_jar does, so the two never disagree, and it
tracks transfer_jar_ownership immediately. A free slug is a JarNotFound
panic rather than a placeholder address — a zero address in a return value
would read to a client as a jar somebody owns.
get_split_count returns the number of entries in a jar's splits. A tip page
rendering a "3 collaborators" badge needs that one integer, and
get_jar(jar_id).splits.len() makes it pay for the whole recipient vector —
up to 20 addresses and shares — to compute it. It also gives a client a cheap
way to decide whether fetching the full list is worth it at all.
The count is always between 1 and 20 (MAX_RECIPIENTS) for a stored jar,
since validation rejects an empty splits list. An unregistered slug therefore
panics with JarNotFound rather than returning 0, which no real jar can
have.
preview_split answers "what does each collaborator actually get?" before the
supporter signs. It returns one i128 per recipient, in the same order as the
jar's splits, computed by the same code path tip pays out with — the two
share one internal helper, so the quoted split and the paid split cannot drift.
Clients should call it instead of recomputing the split themselves. Doing the arithmetic client-side puts the rounding rule in two places, and the copy inevitably falls behind.
splits: alice 70%, bob 30%
preview_split(jar_id, 101) -> [70, 31]
Alice's share truncates from 70.7 to 70 and Bob absorbs the leftover 1 on top of his 30 — the dust goes to the last recipient, so a UI that rounds evenly would show Bob the wrong number.
The shares always sum to exactly amount minus the
protocol fee — amount itself while no fee is set. That holds for awkward amounts too:
a 3333 / 3333 / 3334 jar previewing 100 returns [33, 33, 34], not [33, 33, 33] with a unit lost.
preview_split rejects exactly what tip rejects, so a preview that returns
at all describes a tip that can go through:
amount == 0—AmountZero, checked before the jar lookup and the minimum.amount < 0—AmountNegative, at the same point.- an
amountsmall enough that some recipient's share would truncate to zero (amount * bps < 10_000) —SplitAmountTooSmall. See the smallest amount a jar accepts for the floor this implies. - an
amountso large thatamount * bpsoverflowsi128—AmountOverflow. - an unregistered slug —
JarNotFound, rather than an empty vector, which would read as a jar that pays nobody. - an
amountbelow the jar's configured minimum —BelowMinTipAmount.
The sign checks come first, exactly as in tip, so the same amount gets the
same code from both calls even on a jar with a minimum (#97).
One caveat: tip withholds a recipient's share when that recipient is also the
tipper — rather than making them pay themselves — and the share simply stays
with them. preview_split does not take a from address, so
it reports every recipient's full share and disagrees with the actual payout
for a tipper who is on the jar. Do not redo that arithmetic client-side: call
preview_tip instead, which takes the
sender and matches the payout exactly.
preview_tip(jar_id, from, amount) answers the same question as
preview_split but for a specific tipper (#177). It returns what tip will
actually transfer to each recipient, in split order: a recipient who is the
sender is reported as 0 — the same number the tip event's breakdown records
for them — and their share stays with them; every other recipient gets exactly
their preview_split entry (#78).
splits: alice 50%, bob 30%, carol 20%
preview_split(jar_id, 1000) -> [500, 300, 200]
preview_tip(jar_id, alice, 1000) -> [0, 300, 200] # alice keeps her 500
preview_tip(jar_id, stranger, 1000) -> [500, 300, 200]
The numbers come from the same private helper (payout_amounts) that
tip's transfer loop consumes, so the view and the payout are one
implementation and cannot drift apart. Earlier builds rolled the tipper's
withheld share into the last recipient, so a tipper on the jar paid their own
share to someone else; #78 fixed that, and both the view and the payout
changed together. Validation mirrors
tip (same errors, same precedence); like preview_split it reads no
balance, so it never says whether from can pay, and a share that ends up
escrowed as held at transfer time (#171) is still reported, because that
amount leaves the tipper either way.
A creator can opt out of micro-dust tips by setting an optional floor on their jar.
By default, jars have no minimum tip amount (None), accepting any positive amount.
set_min_tip_amount(jar_id, min_amount) lets the jar owner configure an optional
minimum tip amount (Some(amount) where amount > 0) or clear it (None). Only the
jar owner may authorize this call. Passing a non-positive amount (<= 0) panics with
InvalidMinimumAmount (error code 29).
When a minimum is configured, any tip with amount < min_amount is rejected with
BelowMinTipAmount (error code 13) before any tokens move or events emit. preview_split
similarly rejects amounts below the minimum with BelowMinTipAmount.
get_min_tip_amount(jar_id) reads the configured floor:
- Returns
Noneif no minimum is set (the default for all newly created jars). - Returns
Some(amount)if a minimum floor has been configured. - Panics with
JarNotFound(error code 3) if the slug is not registered.
The configured minimum is only one of two floors. Independently of it, tip
rejects any amount that cannot give every recipient a non-zero share. A
jar's effective minimum is the larger of the two, and
get_min_accepted_tip(jar_id) returns it.
1. The truncation floor. A recipient on bps receives
amount * bps / 10_000 with integer division, so their share is zero unless
net * bps >= 10_000, where net is what is left after the
protocol fee. The smallest net that pays everyone is
net_min = max over every recipient of ceil(10_000 / bps)
Every entry is checked, including the last one — even though the last entry absorbs the rounding remainder, the guard runs before any share is computed.
2. The fee gross-up. The fee comes off the top, so the signed amount must
be large enough that net_min survives it. Since net = amount - floor(amount * fee_bps / 10_000), the first gross amount that leaves a net of
at least net_min is
gross_min = floor((net_min - 1) * 10_000 / (10_000 - fee_bps)) + 1
With no fee (fee_bps = 0) this is simply net_min.
3. The configured minimum. The owner's optional floor is applied last:
accepted_min = max(gross_min, configured_minimum)
(no configured minimum counts as 0).
get_min_accepted_tip runs exactly this calculation, so a client can show the
floor — and disable a submit button below it — without reimplementing the
arithmetic. It is a view: it reads no balance, so it says nothing about whether
the tipper can actually pay. The configured component is a default-token
amount; tip_in_token only enforces it when the tip is paid in the default
token, while the truncation floor is pure basis-point arithmetic and applies to
any allowed token.
Worked example — a jar with a 1 bps share. Splits of 1 bps for a
micro-collaborator and 9 999 bps for everyone else. ceil(10_000 / 1) is
10 000, so with no fee:
| Tip | Result |
|---|---|
| 9 999 | rejected with SplitAmountTooSmall (27); nothing moves |
| 10 000 | accepted: 1 to the 1 bps recipient, 9 999 to the other |
Worked example — a fee raises the floor. Keep the 1 bps share but set a 5%
protocol fee (fee_bps = 500). A gross of 104 leaves a net of
104 - floor(104 * 500 / 10_000) = 99, which truncates the 1 bps share to
zero; 105 leaves 100, the first net that pays it. get_min_accepted_tip
reports 105, a tip of 104 fails with SplitAmountTooSmall, and 105
succeeds.
Which error a too-small amount returns. The floors are enforced by different checks, so the code tells the client which one it missed:
- below the configured minimum —
BelowMinTipAmount(13); - below the truncation floor —
SplitAmountTooSmall(27), even when the configured minimum is unset or smaller.
An amount below get_min_accepted_tip is therefore always one of those two.
InvalidAmount (5) is not returned; the variant is retired and kept only
so existing codes keep their values. A client that handles InvalidAmount as
the minimum-tip error will never see it, and should handle 13 and 27 instead.
jar_exists is the intended way to test whether a slug is taken. The
alternative — calling get_jar and catching the JarNotFound panic — is
awkward from the SDK, since a missing jar is an ordinary answer here rather
than an error. jar_exists reads one persistent key and returns a plain
bool, so the onboarding form can call it on every (debounced) keystroke.
Matching is exact: jar_exists("@ali") is false while "@alice" is taken.
A jar rejected by validation is never stored, so its slug stays free.
Note that availability is not a reservation. Between the check and the
create_jar call, another transaction can claim the slug — create_jar still
panics with JarExists, and clients must handle that rather than treating a
false from jar_exists as a guarantee.
A jar_id is a canonical slug: a leading @ followed by 3–32 characters
from [a-z0-9_-]. create_jar and rename_jar reject anything else with
InvalidJarId — upper case, whitespace, control bytes, invalid UTF-8, a
missing @, or a body outside the length range. The rule matches
novatip-backend's SLUG_REGEX, so every jar a tip page can create is
accepted, and no lookalike (@Alice, @alice ) can be registered next to a
real creator's slug.
The id is also used as a storage key, an event topic, and a public URL slug.
The storage bound is MAX_JAR_ID_LEN (64 bytes); the canonical rule is
stricter, so any id that passes it is well inside that bound. jar_exists
returns false for ids create_jar would reject rather than panicking on
them.
create_jar and update_splits share one validator, so both enforce all of:
- The list must be non-empty —
SplitsEmpty. - At most 20 entries (
MAX_RECIPIENTS) —TooManyRecipients. - No entry may have
bps == 0—SplitOutOfRange. - No address may appear twice —
DuplicateRecipient. - No entry may have
bps > 10_000—SplitOutOfRange. - The
bpsvalues must sum to exactly10_000—SplitSumNot100Pct.
A bps == 0 entry is rejected rather than accepted-and-ignored. Such a
recipient could never be paid (tip skips zero shares), but it would still
consume one of the 20 recipient slots and surface in clients as a collaborator
who never receives funds. Rejecting it at write time keeps a stored jar's
recipient list an accurate record of who actually gets paid.
Note that this is a validation-time rule about bps, not a guarantee about
transferred amounts: a recipient with a valid non-zero bps can still receive
0 on a small tip, because amount * bps / 10_000 truncates (e.g. bps: 100
on a tip of 50 yields 0).
Duplicates are likewise rejected rather than merged. A repeated address is not a
loss-of-funds bug — the shares still total 100% — but it makes tip issue
several separate transfers to one destination in a single call, wasting fees,
and leaves an on-chain record that per-collaborator accounting has to
de-duplicate after the fact. Clients that want to let a user enter the same
collaborator twice should sum the shares before submitting.
The check is a pairwise comparison over the vector, so position doesn't matter:
[a, b, a] is rejected just as [a, a, b] is.
A bps > 10_000 entry claims more than the whole tip, so it could never belong
to a set summing to 100% — the sum check would reject it anyway. It is rejected
per entry because that also bounds the running total: with at most 20 entries
of at most 10_000 each, the accumulator can never exceed 200_000, far below
u32::MAX. The sum is additionally accumulated with checked_add, which fails
with SplitSumNot100Pct rather than trapping.
This matters because bps is caller-supplied and unbounded in the wire type.
Before, a set of shares whose true sum exceeded u32::MAX relied on
overflow-checks = true in the release profile to trap — which reverted the
transaction, so the 100% invariant did hold, but clients saw an opaque wasm
error instead of error code 4, and the guarantee lived in Cargo.toml rather
than in the validator. Both the per-entry bound and checked_add now put it in
the code, so flipping that profile setting cannot turn it into a bypass.
- Each non-final recipient receives
amount * bps / 10_000(integer division). - The last recipient receives
amount - (sum of prior shares), so rounding dust is never lost and the full amount is always distributed. - A tip is not all-or-nothing. Each recipient is delivered with
try_transfer; a recipient who cannot receive the token (a dropped trustline or a deauthorised account) has their share moved from the tipper into the contract and credited underDataKey::Held(recipient). Everyone else is paid normally and the tip still succeeds.withdraw_heldreleases the escrow once the recipient can receive again. preview_split(jar_id, amount)runs this same calculation as a view, so a client can show the exact per-recipient amounts before the supporter signs.- The amounts actually transferred are published in the
tipevent'sbreakdown, so no consumer has to reproduce this arithmetic after the fact either.
Trust implication (escrow). Holding an undeliverable share means the contract temporarily custodies tokens it did not before — the previous all-or-nothing design let the contract hold no balance. The contract account must therefore be able to hold the jar token (a trustline / authorized account on the asset), and a held share is only ever claimable by the recipient it is credited to. The tipper is debited the full amount either way.
If the address sending a tip appears in the jar's splits, tip does not move
their share. The split is computed as for any other recipient, and then the
tipper's entry is skipped: no transfer to themselves, and none to the contract.
Every other recipient is paid exactly as usual. The one zero-share case a plan
can hold is therefore the tipper — a share that would truncate to zero is
rejected before any transfer with SplitAmountTooSmall
(the minimum tip), so a 0 in
the plan can only mean "this recipient is the sender".
What the tipper actually pays. The tipper is debited the other recipients'
shares plus the protocol fee, not the amount they passed in. In a jar that
pays the tipper a share s, the amount leaving their balance is amount - s.
This is the only configuration in which the amount that leaves the tipper
differs from amount, and it is why the tip event reports the tipper's
breakdown entry as 0: the event is a record of the transfers the call made,
and the call made no transfer for them.
Worked example. A jar split 50/50 between the supporter and alice, with the
supporter listed first:
splits = [{ to: supporter, bps: 5_000 }, { to: alice, bps: 5_000 }]The supporter tips 100, with no protocol fee configured. The split runs over
the whole amount — the supporter would receive 100 * 5_000 / 10_000 = 50, and
alice, the final entry, receives the remainder 50 — and then the supporter's
entry is skipped:
| Account | Before | After | Change |
|---|---|---|---|
| supporter (the tipper) | 100 | 50 | −50 — only alice's share leaves them; their own 50 stays |
| alice | 0 | 50 | +50 |
| admin | 0 | 0 | — the fee is 0 |
The tip event's breakdown is [(supporter, 0), (alice, 50)], and the
identity holds: fee (0) + moved (50) + the tipper's own share (50) = amount (100). The supporter's balance confirms it — they were debited 50, not 100.
With a protocol fee the rule applies to what is left after the fee comes off
the top. At 100 bps (1%), a tip of 100 moves 1 to the admin and splits the
remaining 99: the supporter's 49 stays with them, and alice receives 50.
The supporter is debited 51 (amount - 49), and
fee (1) + moved (50) + own share (49) = 100.
Being the tipper decides the skip, not the position. Where the entry sits in
the list does not change the rule, and the other recipients are paid by the same
arithmetic either way. Position does decide which entry absorbs the rounding
dust, because the split gives the remainder to the last entry: if the tipper
is last, the dust-inclusive remainder is the share they keep and the others
receive their truncated shares; if the tipper is elsewhere, the last other
recipient absorbs that dust instead. The difference is bounded by the dust — at
most n - 1 stroops — and is a property of the split arithmetic, not of the
self-tip rule.
A tipper who is the jar's only recipient moves nothing, and the call is
rejected with NoFundsMoved (30) rather than publishing a tip event that
claims a tip happened at no cost.
To see these amounts before signing, use
preview_tip(jar_id, from, amount): it
reports the tipper as 0 and every other recipient's actual payout, computed by
the same helper tip pays with.
Every function that names an address in its signature requires that address's signature, and nothing else does. What a signature actually commits to is the part clients get asked about, because it decides when a previously signed transaction stops being valid.
A tipper authorizes the tip call itself, and the same authorization tree
includes each token transfer(from, recipient, share) that tip performs. The
transfers are nested under the contract invocation, carrying the exact recipient
address and the exact amount.
Consequences a client has to design for:
- The wallet shows one transfer per recipient, not one payment to the contract. A jar splitting 70/30 between two collaborators prompts the supporter to approve two token transfers. Support teams get asked why the tipper is never asked to send anything to the jar's contract address: the contract never holds the funds, it moves them from the tipper straight to each recipient.
- A tip signed against one set of splits cannot pay a different set. If the
jar's splits (or its minimum tip amount) change between simulation and
submission, the signed transaction no longer matches what the contract will
actually do, and Stellar rejects it with
Error(Context, InvalidAction)before any funds move. This is the answer to the obvious question about a jar owner "redirecting" a tip that is already in flight: they can callupdate_splits, and the tip in transit simply fails. The old signature cannot pay the new recipients. - The client's recovery is to re-simulate and ask for a fresh signature.
Without this documented the rejection looks random, because nothing in the
tip request changed — the jar did. On
InvalidAction, re-simulate (preview_splitgives the same per-recipient amounts the new tree will commit to), re-sign, and submit; a wallet that only retries the same envelope will fail identically forever. - A tipper who is also a recipient is not paid, so no transfer appears for them
in the tree, and their share stays with them. With a
protocol fee set, the tree also holds one transfer of
the fee to the admin. See
when the tipper is also a recipient
for what the tipper is actually charged, and
breakdown.
| Function | Authorizes | Tree contains |
|---|---|---|
create_jar(owner, jar_id, splits) |
owner |
The contract call only. No token moves. |
update_splits(jar_id, splits) |
the jar's current owner |
The contract call only. |
transfer_jar_ownership(jar_id, new_owner) |
the jar's current owner and new_owner |
The exact (jar_id, new_owner) transfer pair, signed by both parties. |
delete_jar(jar_id) |
the jar's current owner |
The contract call only. |
set_min_tip_amount(jar_id, min_amount) |
the jar's current owner |
The contract call only. |
set_jar_paused(jar_id, paused) |
the jar's current owner |
The contract call only. No token moves. |
Each of these is a single contract-level invocation with no nested token
transfers, so the signer approves one action, and the amount is not part of what
is signed. Reading the jar (get_jar, get_split_count, preview_split, …)
requires no authorization at all.
The same staleness rule applies to a different degree: these calls do not commit
to a jar's future state, so an update_splits signed against the current owner
still succeeds after that owner changes — it just targets whichever jar the
slug names at submission time.
A test that pins the
tipauthorization behaviour is tracked separately from this document; link it here once it lands.
A single address — recorded at deploy time and readable through get_admin() —
authorizes the contract's configuration entry points. All of them go through the
same require_admin helper, which loads the stored address and demands its
signature, so the table below is exhaustive: an entry point that is not in it
does not check the admin.
| Function | What only the admin can change |
|---|---|
allow_token(token) |
Add an asset to the tip allowlist. Idempotent. |
disallow_token(token) |
Remove one, except the deploy-time token (CannotDisallowDefault). |
set_tip_hook(hook) |
Point the post-tip callback at a contract, or clear it. |
set_registrar(registrar) |
Require a registrar co-signature for new jars, or clear it. |
set_fee_bps(fee_bps) |
Set the protocol fee in 0..=500 bps; the fee is paid to the admin. |
transfer_admin(new_admin) |
Hand the role to another address. It takes effect immediately; there is no acceptance step. |
pause() / resume() |
Stop and restart tip, tip_with_ref and tip_in_token. |
upgrade(new_wasm_hash) |
Replace the running Wasm, keeping the address and all stored state. |
The powers are configuration, not custody. The admin cannot:
- move a jar's funds. Nothing can: tips go straight from the tipper to the
recipients in the same invocation. There is no sweep, no withdraw-all, and
withdraw_heldpays only the address it names. - edit a jar it does not own. Splits, minimum, ownership, lock and pause state are gated on the jar's owner — plus each affected recipient for a reduction on a locked jar — regardless of who holds the admin key.
- repoint the settlement token.
DataKey::Tokenis written exactly once, in__constructor, and there is no setter, soget_token()is immutable after deploy. The admin may add other assets to the allowlist, buttipalways pays through the default one anddisallow_tokenrefuses to remove it. - charge more than 500 bps (5%).
set_fee_bpsrejects larger values withFeeTooHighand leaves the stored fee unchanged. - change the compiled-in bounds: 20 recipients, a 280-byte message, 10 000 bps for the whole split. They ship with the code, so changing one means shipping new code.
- stop scheduled collection.
pausegates the three tip entry points only;collect_recurringandwithdraw_heldkeep working while paused.
upgrade is the widest power on the list and it is deliberately in the admin's
hands: new Wasm can change everything the bullets above pin down. Operators
should treat the admin key as the contract's root authority for that reason,
and transfer_admin as the rotation mechanism. The stored token address
survives an upgrade because storage is kept.
The token address is immutable after deploy. It is fixed by the
__constructor argument, exposed read-only through get_token(), and used by
tip, collect_recurring and the fee transfer. Rotating the settlement asset
means deploying a new contract, not reconfiguring this one; the allowlist
exists so supporters can pay in additional assets without that migration.
Maintenance note. This table is the complete list of admin-gated entry
points. When a function starts or stops calling require_admin, update this
section, the Auth column of the function tables, and the
DataKey::Admin comment in contracts/tip-splitter/src/lib.rs in the same
change — otherwise the "what can the admin do?" answer drifts from the code
exactly the way issue #87 found it had.
| Code | Name | Cause |
|---|---|---|
| 1 | NotInitialized |
A deploy-time value is missing from instance storage: the USDC token address (raised by get_token and by tip) or the admin (raised by get_admin). Should never happen post-deploy. |
| 2 | JarExists |
Slug already registered. |
| 3 | JarNotFound |
Slug not registered. |
| 4 | InvalidSplits |
No longer raised. Split validation now reports the specific failure as code 10, 11 or 12. The variant is retained so existing codes keep their values. |
| 5 | InvalidAmount |
No longer raised. Amount validation reports the specific failure: AmountZero (25), AmountNegative (26), SplitAmountTooSmall (27), AmountOverflow (28), or InvalidMinimumAmount (29). The variant is retained so existing codes keep their values. |
| 6 | TooManyRecipients |
More than 20 recipients. |
| 7 | DuplicateRecipient |
The same address appears more than once in the splits. |
| 8 | MessageTooLong |
Tip message exceeds 280 bytes. |
| 9 | InvalidJarId |
jar_id is not a canonical slug: not @ followed by 3–32 characters from [a-z0-9_-] (or over the 64-byte MAX_JAR_ID_LEN storage bound). |
| 10 | SplitsEmpty |
The splits list is empty. |
| 11 | SplitOutOfRange |
An entry has bps == 0 or bps > 10_000. Checked per entry, before the sum. |
| 12 | SplitSumNot100Pct |
The shares sum to something other than exactly 10 000 bps, including a sum that would overflow u32. |
| 13 | BelowMinTipAmount |
Tip amount is strictly below the jar's configured minimum tip amount. Raised by tip, by preview_split, by preview_tip, and by tip_in_token when it is paid in the default token. |
| 14 | NotRecipient |
leave_jar was called for an address not present in the jar's splits. |
| 15 | SoleRecipient |
leave_jar would empty the jar — the caller is its only recipient. |
| 16 | RecurringNotFound |
cancel_recurring / get_recurring / collect_recurring named an id that does not exist. |
| 17 | InvalidPeriod |
A recurring tip was registered with a zero period_ledgers (or its due-ledger computation would overflow). |
| 18 | NothingHeld |
withdraw_held was called for a recipient the contract holds nothing for. |
| 19 | TokenNotAllowed |
tip_in_token was given a token the admin has not allowed. Raised before any transfer, so a rejected tip moves nothing. |
| 20 | CannotDisallowDefault |
disallow_token was given the deploy-time token, which tip always pays through and which therefore stays non-removable. |
| 21 | StatsOverflow |
A running tip total would not fit u64/i128. Effectively unreachable; the counters use checked arithmetic so an exhaustion reports as this code rather than a wasm trap. |
| 22 | InsufficientBalance |
Tip amount exceeds the sender's token balance. Checked once per tip and once per collect_recurring, before any transfer. |
| 23 | NotDue |
collect_recurring was called before the schedule's next_due_ledger. A typed no-op: the entry is untouched and the same call succeeds once the ledger is reached. |
| 24 | InsufficientAllowance |
collect_recurring found the supporter's allowance for this contract below the scheduled amount. An expired allowance reads as zero and fails the same check. Raised before any transfer, so nothing moves. |
| 25 | AmountZero |
The tip (or preview, or recurring) amount is zero. |
| 26 | AmountNegative |
The tip (or preview, or recurring) amount is negative. |
| 27 | SplitAmountTooSmall |
The amount — after the protocol fee, when one is set — is small enough that some recipient's share would truncate to zero (net * bps < 10_000). get_min_accepted_tip reports the smallest amount that clears it. |
| 28 | AmountOverflow |
amount * bps (or amount * fee_bps) would overflow i128. |
| 29 | InvalidMinimumAmount |
set_min_tip_amount was given a minimum that is zero or negative. |
| 30 | NoFundsMoved |
A tip where every share was skipped (every recipient is the tipper) would move no funds. |
| 31 | InvalidRecipient |
A split recipient or jar owner is the contract itself or its token. |
| 32 | AdminExists |
transfer_admin was called with the current admin's address. |
| 33 | ContractPaused |
A tip was attempted while the contract is paused. |
| 34 | JarIdInUse |
rename_jar was given a jar id that is already in use. |
| 35 | FeeTooHigh |
set_fee_bps was given a fee above 500 bps (5%). The stored fee is unchanged. |
| 36 | InvalidMessage |
The tip message is not valid UTF-8, or contains a control character other than newline. Raised before any transfer, and by create_recurring for the message it stores. |
| 37 | BatchEmpty |
tip_many was given a batch with no entries. Raised before any jar is looked up, so an empty batch never moves funds. |
| 38 | BatchTooLarge |
tip_many was given more entries than MAX_BATCH_TIPS (10). Raised before any jar is looked up. |
| 39 | JarPaused |
A tip was attempted to a jar its owner has paused with set_jar_paused (#45). Raised after JarNotFound and before any transfer, so a rejected tip moves no funds; reads stay available while the jar is paused. |
Error codes are part of the public interface: @novatip/sdk and the frontend
both map these numbers to user-facing messages. New variants are appended
and existing values are not renumbered, which is why code 4 stays in place
rather than being removed.
One renumbering did happen, and it had to. By the time the allowlist,
leave_jar, and the recurring-tip PRs were merged,maincarried a three-way collision: codes 14, 15, and 16 were each claimed by two variants (NotRecipient/TokenNotAllowed,SoleRecipient/CannotDisallowDefault,RecurringNotFound/StatsOverflow), andInsufficientBalancealso sat on 14. Duplicate codes are not decidable, so the later-added side of every collision moved to a fresh number:TokenNotAllowed14 → 19,CannotDisallowDefault15 → 20,StatsOverflow16 → 21,InsufficientBalance14 → 22. Codes 1–18 as consumed by the working deployments (the original ladder) are unchanged; anything wired to the colliding 14/15/16 of amainbuild could not have worked deterministically anyway.
tip moves funds with the USDC Stellar Asset Contract's transfer. When that
call fails for a reason other than an empty wallet, the token's error
propagates unchanged — and the SAC numbers its errors from 1 just like this
contract does, so the numbers overlap:
| SAC code | SAC meaning | Collides with this contract's |
|---|---|---|
10 (BalanceError) |
balance too low to spend (also: resulting balance too low/high, clawback on a non-clawback trustline) | 10 SplitsEmpty |
11 (BalanceDeauthorizedError) |
balance authorization revoked (frozen) by the issuer | 11 SplitOutOfRange |
12 (OverflowError) |
spender allowance would overflow | 12 SplitSumNot100Pct |
13 (TrustlineMissingError) |
trustline entry missing for the account | 13 BelowMinTipAmount |
(The SAC also uses 3, 6, 8 and 9, which likewise coincide with this contract's codes of the same numbers.)
The one exception is the empty wallet: tip (and collect_recurring) read
the sender's token balance once, before the first transfer, and fail with
the contract's own code 22 InsufficientBalance when balance < amount. A tip larger than the
sender's balance therefore never reaches the token, so it can never be
mislabelled as "Add at least one collaborator".
Any other token failure — a missing trustline on either end, a frozen
(deauthorized) trustline, an unauthorized or overflowing allowance — still
surfaces with the token's number. Clients must check which contract raised
the error before mapping the number to a message. On failure the diagnostic
events name the failing contract (the SAC's contract id vs this contract's
id); only errors raised by tip_splitter itself may be looked up in the table
above. A bare Error(Contract, #10) with no source is ambiguous between
SplitsEmpty and the SAC's BalanceError.
- Topics:
(symbol "jar_crtd", jar_id: String) - Data:
JarCreatedEvent { owner: Address }
Indexers must subscribe to this event to build and maintain the full list of
registered jars. There is no on-chain get_jar_ids function — event scanning
is the canonical discovery mechanism. This keeps create_jar cost constant
(O(1) storage writes) regardless of how many jars have been created.
Enumeration and existence are separate concerns: jar_exists answers "is this
one slug taken?" straight from storage, so a client never has to scan the event
log or an indexer's jar list just to validate a name.
- Topics:
(symbol "splits", jar_id: String) - Data:
SplitsUpdatedEvent { count: u32, splits: Vec<Split> }
Lets an indexer that has cached a jar's splits know they went stale, without
having to re-poll every jar on a schedule. The event carries the updated
split vector itself, so a consumer can refresh immediately and avoid the stale
read window created by a second update racing with a follow-up get_jar call.
- Topics:
(symbol "jar_xfer", jar_id: String) - Data:
JarTransferredEvent { previous_owner: Address, new_owner: Address }
Lets an indexer update who controls a jar without re-polling get_jar for
every jar on a schedule.
Both ends of the move are published, in that order, so each event is
independently meaningful. With only the new owner, a consumer replaying the log
could see where a jar went but not where it came from, unless it had already
indexed every prior event for that jar and kept the running state — which an
indexer starting from a partial history has not. Carrying prev_owner also
means successive transfers chain: each event's prev_owner is the previous
event's new_owner, so an ownership history can be reconstructed from the
events alone.
prev_owner is the address that previously controlled the jar, and the
contract now requires both sides of the handoff to authorize the exact
(jar_id, new_owner) pair. A transfer to the current owner is rejected with
InvalidRecipient, so the event log never records a no-op handoff. Splits are
unchanged by a transfer, so no splits event accompanies this one.
Consumer impact. Event data is a named map. Decoders should read fields by name and ignore unknown fields added in future versions.
- Topics:
(symbol "jar_del") - Data:
JarDeletedEvent { jar_id: String }
Lets an indexer drop a jar from its cached list as soon as the slug is retired,
without polling jar_exists or re-reading the jar. A deleted slug is free for
create_jar to reuse immediately after the write settles.
- Topics:
(symbol "min_tip", jar_id: String) - Data:
MinTipEvent { amount: Option<i128> }
Lets indexers track the current minimum tip floor for a jar without polling get_min_tip_amount.
-
Topics:
(symbol "tip", jar_id: String) -
Data:
TipEvent { from, amount, fee, message, breakdown, token, seq, reference }Published by
tip,tip_with_ref,tip_in_token, and — with the supporter asfromand an explicitNonereference —collect_recurring(#175), all through one internal settle path, so one decoder serves every on-chain tip.
The backend indexer subscribes to this event to update balances, leaderboards,
and notifications. message is valid UTF-8 with no control characters other
than newline and at most 280 bytes, so the payload size is bounded and a
varchar(280) column is enough to store it.
breakdown lists one (recipient, moved, held) triple per split, in split
order, so the event is self-describing: a consumer that wants
per-collaborator earnings never has to fetch the jar and redo the arithmetic.
That matters because a recomputed figure can disagree with the contract's —
the jar's splits may have been changed by update_splits between the tip and
the read, and the last recipient's share includes rounding dust that depends
on the exact amount. amount is the gross tip and fee is the
protocol fee this tip moved to the admin (0 while no
fee is set). fee plus the moved values equals amount, except that a
recipient who is also the sender is reported with moved = 0 and keeps their
share (see below).
All fields are named in the event map. Every settle path publishes the same
fields — an absent reference is an explicit None — so consumers can decode
by field name and ignore fields added by future versions.
The held flag distinguishes a delivered share (false) from one the
contract could not deliver and has escrowed for later withdrawal (true, #171).
For a held triple, moved is the amount the contract took into custody for that
recipient.
The triples report what each transfer actually moved, not the notional
amount * bps / 10_000 that preview_split quotes. The
two differ in one case: tip skips paying a recipient who is also the sender,
so that entry reports 0 where the preview would have shown their full share. The recipient is
still listed, which lets a consumer tell "listed but paid nothing this tip"
apart from "not in this jar". The skipped share stays with the tipper; no
other entry grows to absorb it (#78).
breakdown.len() equals the jar's split count at the moment of the tip, so it
is at most MAX_RECIPIENTS (20) entries — see
get_limits. The payload stays bounded.
Note that breakdown is a snapshot, not the jar's current state. To ask
whether an address is a recipient now, use
is_recipient.
Consumer impact. The outer data tuple keeps its four elements (
from, amount, message, breakdown), but eachbreakdownentry is now a triple(recipient, moved, held)rather than a pair — the extraheldflag marks a share the contract escrowed instead of delivering (#171).decodeTipEventin@novatip/sdkand the backend indexer must read the third element of each breakdown entry before consuming events from a contract built from this version. A recipient who cannot receive no longer aborts the whole tip, so indexers that assumed all-or-nothing must treatheld = trueentries as escrowed (claimable viawithdraw_held) rather than as failures.
- Topics:
(symbol "lock", jar_id: String) - Data:
locked: bool(truewhen locked,falsewhen unlocked)
Lets an indexer cache whether a jar protects its collaborators against a unilateral owner edit (#173).
- Topics:
(symbol "jar_pause", jar_id: String) - Data:
paused: bool(truewhen paused,falsewhen resumed)
Lets an indexer cache whether a jar is currently refusing tips (#45), so a tip page can show a "paused" badge without a separate read. Emitted even when the flag is set to the value it already held, so the intent is always recorded.
- Topics:
(symbol "withdraw", recipient: Address) - Data:
amount: i128
Records the moment an escrowed share (#171) is released to its recipient. The recipient is a topic (not just data) so an indexer can subscribe to one address's withdrawals.
recr_addTopics:(symbol "recr_add", supporter: Address), Data:id: u128recr_delTopics:(symbol "recr_del", supporter: Address), Data:id: u128
Publishes the assigned schedule id (and recr_del the id it removed).
A schedule becomes a payment rail through collect_recurring (#175): the
supporter approves this contract as a token spender, and any keeper collects
due periods one at a time. Each collection publishes the ordinary tip event,
so indexers need no separate decoder for recurring payments.
The
splitsevent is also emitted byleave_jar(#172), not only byupdate_splits, so an indexer refreshes its cached splits after a recipient removes themselves.
- Topics:
(symbol "tip_hook", admin: Address) - Data:
hook: Option<Address>(Nonewhen the hook was cleared)
Records who changed the post-tip hook configuration and to what, so the setting's history is attributable in the event log (#176).
- Topics:
(symbol "reg_set", admin: Address) - Data:
registrar: Option<Address>(Nonewhen cleared)
Records every installation or removal of the create_jar registrar (#178),
including the moment the squatting gate goes up or down.
lock_splits(jar_id) turns a jar from the owner's alone into an agreement
between collaborators. Once locked, update_splits no longer accepts the
owner's signature by itself: every current recipient whose share decreases or
who is removed must also authorize. Recipients who are unchanged or increased,
and newly added recipients, do not sign — the lock only guards against being cut
out behind your back.
unlock_splits(jar_id) restores unilateral owner control, and because unlocking
re-enables reductions it requires authorization from all current recipients,
not just the owner; otherwise an owner could disarm the lock and cut shares a
moment later. is_splits_locked(jar_id) reads the flag.
The lock is a property of the jar, not the owner, so it survives
transfer_jar_ownership: handing control to a new owner does not silently
disarm the protection the collaborators agreed to. Interactions with the
recipient-self-removal (#172) and hold/escrow (#171) paths: leave_jar is
authorized by the departing recipient (who is consenting to their own removal),
so it is compatible with a lock; held balances are keyed by recipient and are
unaffected by locks or split edits.
set_jar_paused(jar_id, true) lets a creator stop tips arriving to one jar
without giving up the slug — useful mid-way through changing collaborators or
while a split is in dispute. Every tip entry point (tip, tip_with_ref,
tip_in_token, and each entry of tip_many) then rejects that jar with
JarPaused, checked after the jar is looked up and before any funds move.
set_jar_paused(jar_id, false) reopens it, and is_jar_paused(jar_id) reads
the flag (which also rides on the Jar that get_jar returns).
The pause only closes the tip paths. Every read stays available throughout, so a
tip page can still resolve the jar, show its splits, and preview a tip while
explaining why it is temporarily closed. Like the split lock, the flag is a
property of the jar rather than the owner, so it survives
transfer_jar_ownership: a handover does not silently reopen a jar the
previous owner closed. It is independent of the contract-wide
pause: either being set is enough to refuse a tip,
and the per-jar switch is owner-gated while the contract-wide one is admin-gated.
token is the address the tip was paid in — get_token() for a tip call,
the caller's argument for a tip_in_token call. It has to be in the event: the
other four elements carry a number and nothing else says which asset the number
counts, so an indexer could not tell a tip of 10 USDC from a tip of 10 XLM, and
a jar tipped in two assets would have its per-supporter balances summed across
them as if they were one currency. Reading it from the transaction instead is
not enough, because the contract calls the token itself and the event consumer
sees only the tip's own envelope.
Consumer impact. The data tuple grew from three elements to four, and now from four to five. A decoder that reads it positionally —
decodeTipEventin@novatip/sdk, and the backend indexer — must be updated to accept the fifth element before it reads events from a contract built from this version. The first four are unchanged and keep their positions, so a consumer that ignores trailing elements is unaffected; a consumer that validates the element count rejects them all, which is why this shape change also bumpsversion().
Two views report what has actually settled on-chain:
get_jar_stats(jar_id) -> JarStats { tip_count, total }
get_supporter_stats(jar_id, supporter) -> SupporterStats { tip_count, total }Both are bumped at the end of a tip that paid out, and neither is written by a tip that reverted — a transfer that traps aborts the whole call, counters included. That is what makes them usable as a check rather than a second opinion:
get_jar_statsis the figure a creator compares against the backend's. The indexer has blind spots it cannot report:planBatchgives up on any ledger that returns a full page of events, and an event older than the RPC's retention window can never be backfilled. A missed tip silently lowers a creator's total, and until now nothing on-chain contradicted it. A third-party page or embeddable button also gets a total with no dependence on Novatip's backend.get_supporter_statsis the data the roadmap'ssupporter_badgecontract needs. Milestones are phrased as "10 tips or $10 cumulative", and a contract cannot read a Postgres row; before this, a badge could only be minted by a trusted off-chain service asserting the milestone.
A jar that exists but has never been tipped reads tip_count: 0, total: 0, as
does a supporter who has not tipped a given jar. A free slug is
JarNotFound, not zeros — a typo'd handle must not look like a jar nobody has
tipped.
Storage. DataKey::Stats(jar_id) and DataKey::Supporter(jar_id, supporter) are persistent entries under the same TTL policy as the jar itself
(JAR_TTL_LEDGERS, bumped at the same threshold), and both views bump on read.
The supporter entry describes one supporter's relationship with one jar, so an
archived entry means they restore it or tip again — the history is still in the
event log, and a badge check that reads zeros asks for a restore rather than
minting on a wrong count.
The totals are two extra persistent writes on a path that already writes.
Measured with env.cost_estimate() in test::tip_writes_the_two_totals_entries
(one recipient, one supporter tipping a fresh jar twice), against the same tip
with the counters disabled:
| without totals | with totals | difference | |
|---|---|---|---|
| Ledger writes | 4 | 6 | +2 entries |
| Bytes written | 884 | 1288 | +404 bytes |
| Persistent rent bumps | 3 | 5 | +2 |
The write columns were re-measured against the merged payout path — the "without totals" baseline is now 4 entries, not 3, because the tip sequence counter writes the contract instance each tip and the escrow fallback (#171) enlarged the per-tip footprint. The differential the #167 PR documented still holds exactly: two writes, 404 bytes, two rent bumps per tip. The modelled CPU delta predates those merges and was not re-measured here.
The difference is flat, not cumulative: a second tip by the same supporter to the same jar rewrites the same two entries and adds no new ones (the test asserts this). What the extra rent buys is the ability to disagree with the indexer — a tip the counters show and the database does not is a bug report, where previously the two were indistinguishable.
tip settles in the deploy-time token, fixed at construction. A supporter
holding EURC had to swap before tipping anything. The admin now manages an
allowlist and there is a second entry point:
allow_token(token) // admin only, idempotent
disallow_token(token) // admin only, refuses the default token
is_token_allowed(token) -> bool
tip_in_token(from, jar_id, token, amount, message)tip_in_token checks the allowlist before it loads the jar or moves funds, so
an unlisted token fails with TokenNotAllowed and pays nobody. Everything else
about it is identical to tip: the same compute_shares, the same
self-transfer skip, the same breakdown, the same counters, the same event —
which is why the event names the token.
tip itself is unchanged and always pays through get_token(), which is
allowlisted by the constructor. That keeps every existing integration working,
and means get_token() and is_token_allowed(get_token()) can never disagree.
Why the default token cannot be disallowed. tip ignores the allowlist by
design — it is the entry point the whole product is built on. Removing its
token would leave a working tip path for an asset the view calls closed, and
get_token() would return something is_token_allowed rejects. So
disallow_token refuses it with CannotDisallowDefault; an operator who wants
to retire the default asset deploys a new contract.
Why the list lives in instance storage. An allowlist entry is contract
configuration, in the same category as Admin and Token. A persistent entry
that nobody touches is archived silently, and the failure would look like
"EURC stopped working" with no admin action behind it — the one behaviour an
allowlist must not have. Instance storage is extended with the contract
instance, so an entry cannot expire ahead of the contract that reads it.
set_min_tip_amount stores a raw i128 with no unit. Issue #170 asked for a
decision, and there were two defensible ones — apply the floor to the default
token only, or make it per-token:
Chosen: the minimum applies to the default token only. A tip in another
allowed asset is not gated by it, even if that asset is the one the minimum was
set in mind for. Making it per-token would mean changing set_min_tip_amount's
signature and its storage key, which breaks every jar that already configured a
minimum — a migration for a feature nobody has asked to price per-asset. The
number is also genuinely unpriceable across assets: 500 is a different amount of
money in a 6-decimal token than in a 7-decimal one, so a per-token floor needs a
unit per entry, not a rename.
The consequence is real and deliberate: a jar can be tipped below its floor in
another allowed asset. A creator who cares about a floor should keep the
allowlist to the token they set the minimum in. preview_split keeps enforcing
the minimum as it always did, so it describes a default-token tip.
compute_shares needed no equivalent decision. It splits by basis points and
never interprets magnitude, so the same arithmetic is correct for any asset —
which is exactly what makes an EURC tip of 100 pay 70/30 just as a USDC tip of
100 does.
create_recurring stores a schedule; collect_recurring(id) moves the money.
The payment rail is the token allowance the supporter granted this
contract: an approve on the token with spender = tip_splitter covering at
least one period's amount. With that in place, anyone may call
collect_recurring — typically the backend keeper or the jar's creator — and
no supporter signature is requested or needed.
A collection:
- checks the schedule exists (
RecurringNotFound), that the ledger has reachednext_due_ledger(NotDue), that the allowance coversamount(InsufficientAllowance— an expired allowance reads as zero and fails the same check), and that the supporter's balance covers it (InsufficientBalance); every one of these is raised before any funds move, so a rejected call changes nothing, and an early call does not cancel the schedule — the same call succeeds once the ledger is reached; - splits
amountwith the same arithmetictipuses (the sharedpayout_amountshelper: basis-point shares, dust to the last recipient, supporter-is-recipient skipped), and moves each recipient's share withtransfer_fromwith the contract itself as the spender; a recipient who cannot receive is escrowed exactly as intip(#171); - publishes the identical
tipevent — supporter asfrom, the stored message, breakdown, token, sequence number, and an explicit absent reference — so the indexer, webhooks and analytics see a collected installment and a manual tip as the same thing; - fires the post-tip hook (#176) like any settled tip;
- advances
next_due_ledgerby exactly oneperiod_ledgers, anchored on the old due ledger rather than on "now". At most one period is collected per call, so a keeper that was offline for months cannot drain months in one call; a backlogged keeper catches up one installment per call, and the supporter's allowance is the hard ceiling on the total — revoking it (orcancel_recurring) stops collections dead.
The per-jar minimum tip is enforced at registration, not re-enforced at collection: a minimum raised later must not silently strand schedules already registered under the old floor. A supporter who wants the new floor honoured cancels and re-registers.
The admin can register one optional contract — the supporter_badge in the
roadmap — that is notified after every settled tip:
set_tip_hook(hook: Option<Address]) // admin-only; None clears
get_tip_hook() -> Option<Address> // the current hook, if anyThe contract exposes it as a #[contractclient] trait (TipHook), so a hook
contract can code against it directly:
#[contractclient(name = "TipHookClient")]
pub trait TipHook {
fn on_tip(env: Env, jar_id: String, from: Address, amount: i128);
}on_tip receives the jar id, the tipper, and the tip amount. It is called
with no authorisation requested and its return value is ignored.
Every settle path — tip, tip_with_ref, tip_in_token, and a
collect_recurring installment — fires the hook last, after all transfers
and the tip event, through env.try_invoke_contract:
- If the hook panics or returns an error, the failure is caught and discarded: the tip has already paid out and published, and stays settled. A broken badge contract must never stop a creator being paid.
- If the hook burns the transaction's remaining instruction budget, the tip
fails too. This is inherent to the Soroban budget model —
try_invoke_contractcan catch a contract error, but an out-of-gas abort tears down the whole transaction and cannot be caught anywhere inside it. Budget exhaustion in a hook is therefore the one way a hook can still revert a tip.
The combination is why the hook is an opt-in, admin-controlled pointer:
admins should point it at a contract they have audited and budget-checked,
and get_tip_hook lets anyone verify what is wired in. Every change emits a
tip_hook event carrying the admin who made it. With no hook set, tips behave
exactly as before the feature existed.
Novatip derives a jar's on-chain id from the claimed slug (@alice for
/alice). The backend guarantees slug uniqueness in its database, but the
chain does not know about the claim: until Alice finishes onboarding, anyone
can call create_jar("@alice", …) first and strand her with an "already
registered on-chain" error that has no on-chain recourse.
set_registrar(registrar: Option<Address>) (admin-only) names a contract
whose co-signature create_jar then requires:
registrar.require_auth_for_args((jar_id.clone(), owner.clone()).into_val(&env));- The co-signature is over the exact
(jar_id, owner)pair being registered — a signature for a different slug or a different owner authorises nothing. - The check runs after all input validation and the duplicate-slug check, so a malformed request never burns a co-signature, and a rejected call stores nothing.
- While no registrar is set,
create_jarbehaves exactly as before: owner auth only. Existing deployments and third-party users of the contract are untouched until the admin opts in — which is the reversible half of the rollout the issue asks for. - Every installation or removal emits a
reg_setevent carrying the admin.
The trust model, stated plainly. The intended registrar is the Novatip backend itself: it signs the authorisation entry when a slug claim succeeds, and the creator's wallet submits the transaction as today. That converts slug-squatting from anyone on the public internet into only whoever holds the backend's signing key — and the backend was designed to hold no keys, so this mechanism does not decide the trust question, it only makes it concrete and reversible: an operational decision (key custody, probably a dedicated low-privilege hot wallet that can sign nothing else) the team can take — or roll back by clearing the registrar — without redeploying the contract. What the gate never does is touch existing jars: the registrar can only gate creations of free slugs; owners, splits and funds of registered jars remain exactly as unreachable to it as they were before.
set_fee_bps(fee_bps: u32) // admin only; 0..=500
get_fee_bps() -> u32 // 0 until setEvery tip used to go entirely to the jar's recipients, and the contract had no way to fund the platform running it. A fee taken on-chain is more honest than one taken off-chain: a tipper can read it before signing.
- Default 0. No deployment starts charging on upgrade; the admin opts in.
The intended configured value is 100 bps (1%) — Buy Me a Coffee takes 5%
and Patreon 8–12%, and 1% keeps cross-border micro-tipping viable. Set it
with
FEE_BPS=100 ./scripts/set-fee.sh. - Capped at 500 bps (5%). Anything higher is rejected with
FeeTooHigh, so the fee can never be set to swallow a tip. - Taken before the split.
fee = floor(amount * fee_bps / 10_000)goes to the contract admin andnet = amount - feeis split across the recipients. The too-small-to-split guard (SplitAmountTooSmall) and the rule that the last recipient absorbs rounding dust both apply tonet, so the recipients' total plus the fee equals the tip exactly.
fee_bps = 100, splits alice 70%, bob 30%
tip 10 000 -> fee 100, alice 6 930, bob 2 970
tip 10 099 -> fee 100, alice 6 999, bob 3 000 (fee truncates; bob takes the dust)
Every preview (preview_split, preview_split_details, preview_tip) reports
post-fee shares, so a client shows the fee as amount - sum(preview_split)
without redoing the arithmetic. get_min_accepted_tip reports the smallest
gross amount whose net still pays every recipient.
The fee applies to tip, tip_with_ref, tip_in_token (in the token tipped),
and collect_recurring (drawn from the same allowance, which must still cover
amount). Stats and the post-tip hook record the gross amount. Two edge
cases follow the self-transfer rule: an admin tipping keeps the fee rather than
paying themselves, and the event then reports fee = 0 because nothing moved.
A fee transfer the admin cannot receive reverts the tip; the admin can set the
fee to 0 to restore tipping.
set_fee_bps emits a fee_set event: topics (symbol "fee_set", admin), data
the new fee_bps: u32.
version() -> u32 // reads no storageThe shape of tip's event has changed twice and jar_xfer's data once, always
under the same function names, and decodeTipEvent in the SDK kept reading
three elements after the contract published four. A client given only a
contract id had no way to find out which build it was talking to before
decoding, so a mismatch surfaced as a parse failure — or as silently ignored
new data.
version() lets it ask first. It returns a compile-time constant, so it costs
no storage read and works against a contract with no jars and no callers.
When the number moves. Bump INTERFACE_VERSION by one when a function is
added or removed, an error code is added or renumbered, or an event's shape
changes. Do not bump it for an internal refactor, a new storage key, or a doc
edit — none of those are visible to a consumer, and a version that changes when
nothing observable did is a version consumers stop trusting.
This build ships version() == 3. Version 3 adds set_fee_bps /
get_fee_bps, the fee_set event, the FeeTooHigh error (35), and the fee
field in the tip event (#79); it also changes what a tipper who is a
recipient pays (#78), which preview_tip already reports.
Version 2 Version 1 covered the escrow/held
breakdown, the token allowlist, the totals views, and the sequence-numbered
tip event. This PR adds four functions and one view beyond that point —
collect_recurring, preview_tip, set_tip_hook/get_tip_hook,
set_registrar/get_registrar — plus the tip_hook and reg_set events,
the single canonical seven-element tip event data, and the repair of the
14/15/16 error-code collision by renumbering the affected codes to 19–22.
Every one of those is a function, event-shape, or error-code change, so the
constant moves up under the rule above.
How a client should use it. Treat an unrecognised number as "newer than I was built for", not as an error. Every field a lower version documented still exists at a higher one — changes are appended — so the safe response is to read what you know and ignore the rest. Refusing to connect on a version you have not seen would turn a future additive release into an outage.
version() is per-contract feature detection. It complements the per-event
schema version proposed in #83 rather than replacing it: that answers "what
shape is this record", this answers "what build is this deployment".
The previous contract exposed a get_jar_ids() -> Vec<String> view backed by
an instance-storage vector that grew by one entry on every create_jar call.
This had two problems:
seq is a contract-wide counter, incremented once per successful tip, and is
also the return value of tip / tip_with_ref. Numbers start at 1 and a
reverted tip consumes none, so the sequence has no holes: an indexer that has
stored 1..N and reads N from get_tip_count has lost nothing, and a gap
means its own scan is incomplete rather than that a tip never happened.
This exists because consumers previously keyed on the transaction hash. The
backend indexer upserts on txHash and the SDK's TipEvent tells callers to
deduplicate on it — which holds only while there is one tip per transaction. A
smart wallet batching two tips, or any client submitting tips from one account
in a single transaction, puts several tip events in one transaction that all
share that hash, and every tip after the first is dropped as a duplicate.
seq is unique per tip by construction.
seq is deliberately in the data, not the topics. The SDK's
tipTopicFilter must have exactly one segment per published topic, so adding a
third topic would make its existing two-segment filter match nothing — silently,
for every consumer. get_tip_count() -> u64 exposes the running total, 0 on a
fresh deploy.
reference lets the integration that started a tip recognise it when the event
comes back through an indexer or a webhook. An embeddable button, a stream
overlay, and a Discord/Telegram bot each create something off-chain first (a
pending alert, a bot command, a checkout session) and then wait for the matching
tip event. Sender, amount and message are none of them unique, and the message
is visible to the creator, so it cannot carry an opaque id.
The value is opaque to the contract and the contract's role stops at echoing it:
- it is not stored — no key is written, and reading the jar or the tip count is unaffected by whether a tip carried one;
- it is not checked for uniqueness — two integrations may send the same
reference, and two tips in the same jar may carry it. Reconciling a collision
is the consumer's job; if a consumer needs uniqueness it must use
seq, which the contract does guarantee; - it costs nothing when absent —
tippassesNone, and an absent reference still occupies position six.
It is exactly 32 bytes (Option<BytesN<32>>): enough for a binary UUID, a
sha256 tag, or a big-endian primary key, and bounded so the payload cannot grow
the way an unbounded string would. The entry point is tip_with_ref; tip is
unchanged and remains the positional call the SDK and the web tip form use.
Consumer impact. SDK and backend decoders must switch from tuple destructuring to named fields. Unknown fields can be ignored safely.
An earlier change in the same section took the tuple from three elements to four by appending
breakdown; that wording describes thebreakdownaddition and is kept for the migration note.
Indexing needs a way to enumerate every registered jar. An earlier design kept
that list on-chain as a get_jar_ids() -> Vec<String> view backed by an
instance-storage vector that grew by one entry on every create_jar. It had
two problems:
- Unbounded growth. The vector had no removal path and no size cap, so it grew permanently with every jar ever created.
- Escalating cost. Instance storage is read and written in full on every
create_jar. As the vector grew, each new jar creation cost more than the last, and the entry would eventually approach the ledger entry size limit, causingcreate_jarto fail for all callers.
Decision — the design in use today: keep no on-chain list and move discovery
to the event log. create_jar emits a jar_crtd event carrying the jar_id
and owner; indexers reconstruct the full jar list by scanning those events
from the contract's creation ledger (or from their last checkpoint). This is the
standard pattern for Soroban contracts where enumeration is needed but unbounded
on-chain state is not acceptable, and it keeps create_jar cost O(1) regardless
of how many jars exist.
The get_jar_ids view and its backing vector were added and removed within a
single unreleased cycle (see CHANGELOG.md), so no published build ever
exposed the function — there is nothing to migrate off or seed from. v0.1.0
ships only __constructor, create_jar, update_splits, tip, get_jar and
get_token.
The real gap is event coverage, not a removed function. jar_crtd is emitted
only by builds after v0.1.0:
- A deployment built from
v0.1.0never emittedjar_crtdat all, so the jars it created are absent from the event log. The testnet contract referenced in the README predates the current event payloads as well. - To find jars created on such a deployment, replay its
create_jarinvocations from Horizon (Soroban transaction history), or seed them from an off-chain snapshot — not from aget_jar_idsresponse, which no deployment has ever returned.
Every jar created by a build that emits jar_crtd needs no special handling:
scanning the event log from the contract's creation ledger is enough.
The scripts load .env from the repository root themselves — exported
variables win over .env, and no manual source step is needed. They work
when started from any directory.
./scripts/deploy.sh # deploys, writes .contract-id.<NETWORK>
./scripts/create-jar.sh # registers an example jar
./scripts/set-min-tip.sh # sets (or clears) a jar's minimum tip
./scripts/transfer-jar.sh # hands a jar to a new ownerSee .env.example for required variables, and the
README's scripts section for what each script needs and
how MIN_TIP=none reaches Option::None.
Once a jar exists:
./scripts/jar-info.sh # read-only: owner, recipients, minimum tip
AMOUNT=100000 ./scripts/jar-info.sh # ... plus what a tip of that size pays each recipient
./scripts/update-splits.sh # replace a jar's splits (signed by the jar owner)
SPLITS='[{"to":"G...A","bps":7000},{"to":"G...B","bps":3000}]' ./scripts/update-splits.shjar-info.sh only simulates — it submits no transactions and pays no fee, and
any SOURCE identity works. update-splits.sh validates the new splits with
the same checks as create-jar.sh (both share scripts/lib/splits.sh), prints
the old and new splits side by side, refuses to send when they are unchanged,
and requires SOURCE to be the jar's owner before invoking update_splits.
See .env.example for required variables.
Every classic G... account that sends or receives a tip needs a trustline
to the settlement asset first, and tips are all-or-nothing — one recipient
without a line fails the whole tip with the token's Error(Contract, #13).
scripts/trustline.sh adds the line for the identities in IDENTITIES,
deriving the classic asset (USDC:<issuer>) from USDC_TOKEN itself, and
skips accounts that already have one. With SLUG set it also checks the jar's
recipients; --check reports without changing anything and exits non-zero if
anyone cannot be paid:
IDENTITIES="tipper alice bob" ./scripts/trustline.sh # add lines
SLUG=@demo ./scripts/trustline.sh --check # report onlyTestnet USDC is issued by the account encoded in the derived asset name
(USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5
on testnet). There is no on-chain claim — fund an account by payment from a
holder (see the usage comment at the top of scripts/trustline.sh), after
stellar fund covers the XLM reserves.
When an indexer misses something, the first question is whether the event was
ever emitted. scripts/events.sh computes the topic filters with
stellar xdr encode --type ScVal instead of hand-rolled base64 (a wrong
encoding fails loudly, a wrong segment count silently returns nothing — see
the encoding walkthrough in the issues thread #57):
EVENT=tip SLUG=@demo ./scripts/events.sh # tips to one jar
EVENT=all ./scripts/events.sh # every event type, recent windowIt lists each of the five event types (jar_crtd, splits, jar_xfer,
min_tip, tip), one line per event with ledger, name, jar id and decoded
payload, defaulting to a recent ledger window; a START_LEDGER outside the
RPC's retention window gets an explanatory error. Sample output is in the
script's usage comment.
tip authorizes from with from.require_auth(), which a calling contract
satisfies automatically — but the per-recipient transfer(from, …) calls it
then makes are invoked by tip_splitter, not by the caller, so they are not
covered. A contract tipping from its own balance must pre-authorize each
transfer with env.authorize_as_current_contract, listing every recipient and
the exact amount, or the tip fails with an auth error.
The amounts to authorize are what preview_tip(jar_id, caller, amount)
returns, skipping any 0 entry — a calling contract that is itself a recipient
makes no transfer to itself and keeps its share. When a
protocol fee is set, also authorize one transfer of the
fee to get_admin(): the fee is amount - sum(preview_split(jar_id, amount)).
examples/tip-forwarder is a worked example of exactly this — one function
that tips a jar from the contract's own balance — with tests that run it
against the real TipSplitter and a test SAC, including the failure mode where
the fee transfer is not authorized. See its
README.md.
Common failures encountered when building, deploying, or invoking the contract that do not map to contract error codes:
Symptom:
error: target 'wasm32-unknown-unknown' not found
error: the contract build failed. If it is a missing target, run:
rustup target add wasm32-unknown-unknown
or:
error[E0463]: can't find crate for 'core'
Cause: The Rust toolchain does not have the compilation target for Soroban WASM installed.
Fix: Add the target using rustup:
rustup target add wasm32-unknown-unknownSymptom:
HostError: Error(Contract, #1)
or transaction simulation failure:
txFailed: op_not_authorized / op_no_trustline
Cause: The Stellar Asset Contract (SAC) requires an existing trustline for custom/non-native assets before an account can receive tokens. If a recipient in a jar split has not established a trustline for the token, direct transfers to that address fail.
Fix:
- The recipient must open a trustline for the token's Stellar Asset Contract before receiving:
stellar contract invoke --id <SAC_CONTRACT_ID> -- trustline --for <RECIPIENT_ADDRESS>
- Alternatively, if using the tip-splitter contract post-#171, undeliverable shares are automatically escrowed into contract storage. The recipient can withdraw their held funds via
withdraw_held(recipient)once their trustline is established.
Symptom:
error: transaction simulation failed: HostError: Error(Storage, MissingValue)
or:
txNO_ACCOUNT: The source account does not exist
Cause: The invoking account has not been created on the network and holds 0 XLM (below the network reserve).
Fix: Fund the account with the minimum reserve:
- On Testnet, use Friendbot:
curl "https://friendbot.stellar.org?addr=<ACCOUNT_ADDRESS>" - On Mainnet, send at least 1–2 XLM from an existing wallet or exchange to establish the account.
Symptom:
HostError: Error(Contract, #3) # JarNotFound
HostError: Error(Contract, #9) # InvalidJarId
Cause: A tip or view was called with a slug that lacks the required leading @ (producing InvalidJarId), uses invalid characters/length, or points to an unregistered jar slug (JarNotFound).
Fix: Ensure the slug matches @ followed by 3–32 lowercase alphanumeric characters, dashes, or underscores (@<slug>), and verify registration using get_jar(jar_id) or ./scripts/jar-info.sh.