# 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::` (`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::`) 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_` for a recently changed password and `SESSION_TOO_FRESH_` 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. ```jsonc // response of /wallet/password {"has_password": true, "srp_id": "123…", "srp_b": "…", "g": 5, "p": "…", "salt1": "…", "salt2": "…"} // request of /wallet/withdraw {"gift": "", "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`.