Werogram/Документация/Collectible Star gifts: listing flags, value info, withdrawal
RUWEROGRAM DOCS

Collectible Star gifts: listing flags, value info, withdrawal

What this service implements for collectible (unique) Star gifts on top of the Layer 232 TL schema: the saved-gift listing flags, payments.getUniqueStarGiftValueInfo, payments.getStarGiftWithdrawalUrl (with the 2FA gate it shares with the self-hosted claim page) and payments.deleteStarGift.

Saved gift listing (getSavedStarGifts family)

Filter fields live in domain.SavedStarGiftFilter (internal/domain/star_gift.go), both stores implement them (internal/store/postgres/star_gift.go, internal/store/memory/star_gift.go).

Flag Meaning
sort_by_value Order by price descending instead of the profile order.
peer_color_available Only gifts that are unique (unique_gift_id IS NOT NULL) and whose active catalog revision has peer_color_available.
exclude_hosted Drop rows that are only visible because this peer hosts the on-chain gift.
collection_id Restrict to one collection; also disables the profile order.

Price and the sort_by_value cursor

The price used for sorting and paging is priceExpr: the XTR listing price of the unique gift, else its recorded value_amount when value_currency='XTR', else the active catalog revision price in Stars. TON listings are not converted: they fall back to the catalog price rather than inventing a rate.

The cursor for sort_by_value is the opaque string v2:<price>:<id> (EncodeSavedStarGiftPriceCursor / DecodeSavedStarGiftPriceCursor), and the next page is price < cursor OR (price = cursor AND id < cursor_id) with ORDER BY price DESC, id DESC. The older list cursor (v1:<pinned>:<id>) keeps working for the other orderings.

Ordering

  • collection_id == 0 && !sort_by_value: profile order — pinned gifts first in pinned_order, then id DESC.
  • sort_by_value: price descending, ties broken by id DESC.
  • anything else: id DESC.

Hosted rows

PostgreSQL serves a gift to its owner while lifecycle_status='active', and to the host peer while the gift is exported on-chain (lifecycle_status='exported', non-empty owner_address, non-burned, host_peer_* equal to the requested peer). exclude_hosted removes the second case.

The memory store only ever produces active rows, so exclude_hosted is an explicit no-op there: no hosted row can exist to exclude. It is not a filter for "gifts that were upgraded to unique" — exclude_unique is.

payments.getUniqueStarGiftValueInfo

Takes a slug, normalizes it (TrimSpace + lowercase, at most domain.MaxStarGiftSlugBytes), and returns currency/value, initial sale, and optionally last sale, floor, average and listed_count when the self-hosted ledger recorded them.

  • Unknown, empty or over-long slug → STARGIFT_SLUG_INVALID.
  • Bot callers → BOT_METHOD_INVALID.
  • last_sale_on_fragment and fragment_listed_* are never set: this ledger has no Fragment feed, so the correct value is "unknown" (left absent).

Resale price (payments.updateStarGiftPrice)

  • A Stars listing must be at least domain.MinStarGiftResaleStars (125 Stars). help.getAppConfig advertises the same value as stars_stargift_resale_amount_min, so the client's price field and the server agree; a lower price is refused with STARGIFT_INVALID.
  • There is no market-derived minimum. A floor taken from the cheapest live listing could only rise, and stopped every seller, the cheapest one included, from pricing below it.
  • resell_min_stars and the floor_price of payments.getUniqueStarGiftValueInfo are displays of the cheapest live, unsuspended Stars listing of the gift type. Listings priced above domain.StarGiftResaleFloorMultiple (10) times the active revision's base price are ignored there, so one overpriced listing cannot move the display.
  • TON listings have no server-side minimum.

payments.getStarGiftWithdrawalUrl

Preconditions are checked in this order, because each later one would otherwise reveal state to a caller who has not proven who they are:

  1. Known method, non-bot caller (BOT_METHOD_INVALID).
  2. The account must have a 2FA password; without one there is nothing to prove and the call fails with PASSWORD_MISSING.
  3. The 2FA proof. Layer 232 carries tmp_password:bytes: it must be the grant that account.getTmpPassword issued to this authorization, still within its valid_until, on the same login and under the current 2FA password; anything else, empty included, is TMP_PASSWORD_INVALID. There is no SRP fallback. Exact Layer 225–231 clients call payments.getStarGiftWithdrawalUrlLegacy with the SRP challenge, and only inputCheckPasswordSRP is accepted there. Wrong answer → PASSWORD_HASH_INVALID, stale srp_id → SRP_ID_INVALID. Freshness follows the proof: PASSWORD_TOO_FRESH_<wait> for a recently changed password and SESSION_TOO_FRESH_<wait> for a session that is younger than the same window (revenueWithdrawalFreshWait).
  4. The auth key exists, belongs to the caller, and is not password_pending (AUTH_KEY_UNREGISTERED).
  5. The gift ref resolves and the caller is its owner (STARGIFT_INVALID, STARGIFT_OWNER_INVALID). Hosts of on-chain gifts are not owners and can never withdraw.
  6. stargifts.Service.Withdraw: the gift must be live and its can_export_at must have passed; otherwise ErrStarGiftExportCooldown → STARGIFT_WITHDRAWAL_UNAVAILABLE, and any other non-exportable state (STARGIFT_INVALID).

can_export_at is compared against the server clock (Service.Withdraw takes req.Date from the router), so a client cannot advance time locally.

Double withdrawal

StarGiftLifecycleStore.RecordStarGiftWithdrawal locks the saved gift row (FOR UPDATE), then locks the star_gift_withdrawal_requests row for that unique gift. A pending request that has not expired, or a completed one, is returned as-is instead of minting a second bearer URL; an expired one is reused/updated. Ten concurrent calls therefore collapse onto a single row and a single URL — covered by TestStarGiftWithdrawalConcurrentRecordCollapsesPostgres.

payments.deleteStarGift

Layer 232 added payments.deleteStarGift#3cdaf8aa stargift:InputSavedStarGift = Bool. The owner removes a received gift from the profile.

  • Only the caller may delete their own gift. A channel gift is a channel asset and is refused (STARGIFT_OWNER_INVALID); bots are refused (BOT_METHOD_INVALID). Official clients let a channel owner or an admin with transfer rights delete a channel gift; that needs a channel-ownership check of its own, so this method deliberately keeps refusing it until that path exists.

  • The delete is irreversible, so it is limited to a plain received gift that carries no Stars. Refused (STARGIFT_INVALID):

    • a gift upgraded into a collectible (NFT) — destroying a collectible is a different operation with its own financial rules;
    • a limited-edition or auction gift — the release is a scarce, numbered object;
    • a gift with a conversion value, or one carrying the sender's prepaid upgrade Stars — deleting it would silently destroy Stars that can still be claimed.

    This matches TDLib's deleteGift preconditions (not unique, availability_total == 0, convert_star_count == 0). TDLib also requires "not refunded"; a saved gift is never in a refunded state here, because a gift-purchase refund settles at the invoice layer before the gift is delivered.

  • The gift row is not deleted: upgrade commands, offers and transfers reference it, so the delete is a lifecycle transition (lifecycle_status='deleted', new state in migration 20261017010003_star_gift_deleted_state). Read paths only project lifecycle_status='active', so the gift leaves the profile, the account rating signal and the saved-gift lists immediately.

  • Refused states (STARGIFT_INVALID): converted, exported, burned, already deleted, upgraded into a collectible, limited/auction release, still convertible or prepaid.

  • A deleted gift cannot be upgraded afterwards either: the upgrade form refuses it (STARGIFT_INVALID) and the upgrade transaction re-checks the lifecycle under the row lock, so no Stars are charged and no collectible is minted.

  • The gift is dropped from every collection it was part of under the owner collection advisory lock, the affected collection hashes are refreshed (so payments.getStarGiftCollections cannot answer NotModified for a changed membership), and the profile projection is invalidated.

Claim page mirror (FlashFragment)

The claim page in the fragment UI posts the same proof to the server instead of MTProto. Routes are registered in internal/web/server.go, handled by internal/giftclaim:

Route Purpose
POST /claim/api/wallet/password (and /claimtest/...) Rotate and return the SRP challenge.
POST /claim/api/wallet/withdraw Confirm with {srp_id, a, m1} and get the withdrawal URL.

Both require the X-Telegram-Init-Data header and return 401 without it.

// response of /wallet/password
{"has_password": true, "srp_id": "123…", "srp_b": "…", "g": 5, "p": "…", "salt1": "…", "salt2": "…"}
// request of /wallet/withdraw
{"gift": "<slug or msg ref>", "password": {"srp_id": "123…", "a": "…", "m1": "…"}}

srp_id is a random int64 and is serialised as a string in both directions (json:",string"); JavaScript must not round-trip it through Number. g/p/salts are hex, a/m1 are hex too.

Without has_password, the client sends no password object and the server accepts inputCheckPasswordEmpty, exactly like MTProto. With has_password the Mini App asks for the Telegram password, derives

x  = SHA256(salt2 || PBKDF2-HMAC-SHA512(key=SHA256(salt2||SHA256(salt1||pw||salt1)||salt2), salt=salt1, 100000, 64) || salt2)
v  = g^x mod p
u  = SHA256(pad256(A) || pad256(B))
S  = ((B - k*g^x) mod p)^(a + u*x) mod p, k = SHA256(pad256(p) || pad256(g))
M1 = SHA256( SHA256(pad256(p)) XOR SHA256(pad256(g)) || SHA256(salt1) || SHA256(salt2) || pad256(A) || pad256(B) || SHA256(pad256(S)) )

and posts {srp_id, a, m1}. The server derives x again from the stored password, so the password itself never leaves the browser. The reference implementation is internal/app/account/srp.go; the browser copy lives in fragment.com/js/openfragment-stub.js (srpSign).

Errors map to the same claims codes: a bad password answer gives ErrPasswordInvalid (HTTP 400), an export that is on cooldown gives HTTP 409.

nginx exposes only the exact claim paths under location = /fragment/api/gift-claim/wallet/... → :2401/claim/... with the init-data header injected.

Not implemented

  • .ton username minting and the hosted Fragment UI (withdraw only).
  • fragment_listed_* / last_sale_on_fragment fields.
  • Any TON-to-Stars price conversion for sort_by_value.

Tests

  • internal/rpc/payments_star_gift_withdrawal_rpc_test.go — ownership, unknown slug, password freshness, session freshness, bot rejection, cooldown mapping, value-info slug error.
  • internal/giftclaim/withdraw_password_test.go — full challenge → SRP → withdraw round trip over HTTP against the real account.Service.
  • internal/store/memory/star_gift_value_filter_test.go — value ordering, price cursor paging, peer_color_available, exclude_hosted no-op.
  • internal/domain/star_gift_price_cursor_test.go — cursor round trip.
  • internal/store/postgres/star_gift_saved_listing_flags_integration_test.go — the same flags against PostgreSQL, including hosted rows.
  • internal/store/postgres/star_gift_withdrawal_race_integration_test.go — ten concurrent withdrawals collapse into one request row.
  • internal/store/postgres/star_gift_lifecycle_integration_test.go (TestStarGiftResaleFloorIgnoresTrollListingsPostgres) — the flat resale minimum, undercutting the cheapest listing, and the troll-proof display floor.

The PostgreSQL ones run only when TELESRV_TEST_POSTGRES_DSN names a database whose name contains test.