Ctrl K
ComunidadGuías…

docs/collectible-gifts.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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.

```jsonc
// 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`.