package domain import ( "encoding/base64" "errors" "fmt" "strconv" ) // Stars local ledger domain model (no TL types, mirroring the boost.go style). This implementation is a local ledger, // not a real payment: the balance is integer Stars (Nanos is always 0 online), and debits are atomic and never go negative. // StarsBalance is an account's current available Stars balance. type StarsBalance struct { UserID int64 Balance int64 // currently spendable Stars, always >= 0 Granted bool // whether the starting grant has been applied (idempotency guard for the lazy first-read grant) } // StarsPurchaseKind identifies the balance owner affected by a fiat Stars // checkout. It is persisted with the form so a client cannot reinterpret a // self top-up as a friend gift (or vice versa) when submitting the form. type StarsPurchaseKind string const ( StarsPurchaseTopup StarsPurchaseKind = "topup" StarsPurchaseGift StarsPurchaseKind = "gift" StarsPurchaseGiveaway StarsPurchaseKind = "giveaway" // StarsPurchaseBotInvoice is a settled XTR invoice whose proceeds belong to // a bot's own revenue wallet rather than to the payer's personal balance. StarsPurchaseBotInvoice StarsPurchaseKind = "bot_invoice" ) func (k StarsPurchaseKind) Valid() bool { return k == StarsPurchaseTopup || k == StarsPurchaseGift || k == StarsPurchaseGiveaway || k == StarsPurchaseBotInvoice } // BotStarsCredit is one settled XTR invoice that credits a bot's own revenue // wallet. InvoiceKey makes the credit idempotent across retried // payments.sendPaymentForm calls: a replay returns the stored balance instead // of minting a second credit. Amount must be positive; refunds carry their own // key and a negative Amount. type BotStarsCredit struct { BotUserID int64 PayerUserID int64 Amount int64 Reason StarsTransactionReason InvoiceKey string Date int } // Valid reports whether the credit describes a well-formed wallet mutation. func (c BotStarsCredit) Valid() bool { switch c.Reason { case StarsReasonBotInvoice, StarsReasonBotRefund: default: return false } return c.BotUserID > 0 && c.PayerUserID > 0 && c.Date > 0 && c.Amount != 0 && c.InvoiceKey != "" && len(c.InvoiceKey) <= 128 } // BotStarsPayment is the durable receipt of one settled invoice credit. It // records the post-transaction balance so a duplicate settlement can be // answered without re-reading the mutable balance row. type BotStarsPayment struct { InvoiceKey string BotUserID int64 PayerUserID int64 Amount int64 BalanceAfter int64 SettledAt int } // BotStarsRevenuePoint is one UTC day of bot wallet activity. DayStart is the // Unix second at 00:00 UTC, which the RPC layer multiplies by 1000 because the // official chart renderer expects millisecond x values. // // Amount is the day's inbound sum and Spent the day's outbound sum. Spent is // deliberately not called "withdrawn": Telegram uses "withdrawal" for a // Fragment cash-out, which telesrv does not support for a bot wallet at all - // withdrawal_enabled stays false - so that word would promise a payout that // does not exist. type BotStarsRevenuePoint struct { DayStart int Amount int64 Spent int64 } // Valid reports whether the point can be rendered on the revenue chart. func (p BotStarsRevenuePoint) Valid() bool { return p.DayStart > 0 && p.Amount >= 0 && p.Spent >= 0 } // StarsGiveawayPurchase is the complete immutable purpose behind one direct // fiat Stars giveaway checkout. The launch purchase persists this shape; the // eventual winner draw is a separate lifecycle transition. type StarsGiveawayPurchase struct { BoostPeer Peer `json:"boost_peer"` AdditionalPeers []Peer `json:"additional_peers,omitempty"` CountriesISO2 []string `json:"countries_iso2,omitempty"` PrizeDescription string `json:"prize_description,omitempty"` RandomID int64 `json:"random_id"` UntilDate int `json:"until_date"` Users int `json:"users"` PerUserStars int64 `json:"per_user_stars"` YearlyBoosts int `json:"yearly_boosts"` OnlyNewSubscribers bool `json:"only_new_subscribers,omitempty"` WinnersAreVisible bool `json:"winners_are_visible,omitempty"` } // StarsPurchaseForm binds one short-lived fiat Stars checkout to its // authenticated buyer, purpose and exact server-advertised package. Recipient // is zero for a self top-up and mandatory for a friend gift. type StarsPurchaseForm struct { FormID int64 Kind StarsPurchaseKind BuyerUserID int64 RecipientUserID int64 SpendPurposePeer Peer Giveaway *StarsGiveawayPurchase Stars int64 Currency string Amount int64 IssuedAt int ExpiresAt int } // StarsPurchaseRequest is the immutable settlement command carried by // inputInvoiceStars. Android and TDesktop sendPaymentForm both resolve to this // command after the ordinary fiat checkout has produced provider credentials. type StarsPurchaseRequest struct { StarsPurchaseForm Date int OriginAuthKeyID [8]byte OriginSessionID int64 } // StarsPurchaseResult is the atomically committed credit and, for a friend // gift, bilateral service-message receipt. Duplicate means an exact form replay. type StarsPurchaseResult struct { Balance StarsBalance Send SendPrivateTextResult ChannelSend SendChannelMessageResult TransactionID string Duplicate bool } // StarsGiveawayInfo is the viewer-specific state of one durable launch card. // Winner selection/results are intentionally outside the purchase aggregate. type StarsGiveawayInfo struct { StartDate int Participating bool PreparingResults bool JoinedTooEarlyDate int AdminDisallowedChatID int64 DisallowedCountry string } // StarsTransactionReason marks the semantics of a ledger entry (projected to tg.StarsTransaction's flags/title). type StarsTransactionReason string const StarsReasonTransfer StarsTransactionReason = "transfer" const ( StarsReasonGrant StarsTransactionReason = "grant" // automatic starting-balance grant StarsReasonTopup StarsTransactionReason = "topup" // top-up (minted locally) StarsReasonReaction StarsTransactionReason = "reaction" // paid reaction spend StarsReasonGift StarsTransactionReason = "gift" // star gift spend/receive StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // upgrade a regular gift into a unique gift StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer" StarsReasonGiftResale StarsTransactionReason = "gift_resale" StarsReasonGiftOffer StarsTransactionReason = "gift_offer" StarsReasonGiftAuction StarsTransactionReason = "gift_auction" StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade" StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details" StarsReasonPaidMedia StarsTransactionReason = "paid_media" // paid media unlock StarsReasonPaidMessage StarsTransactionReason = "paid_message" // channel Direct Message spend StarsReasonSuggestedPost StarsTransactionReason = "suggested_post" StarsReasonPremium StarsTransactionReason = "premium" StarsReasonWithdrawal StarsTransactionReason = "withdrawal" StarsReasonAdjust StarsTransactionReason = "adjust" // fallback/manual adjustment StarsReasonDonationDeposit StarsTransactionReason = "donation_deposit" // crypto deposit credited after on-chain confirmation StarsReasonBotInvoice StarsTransactionReason = "bot_invoice" // settle an XTR invoice, crediting the bot wallet StarsReasonBotSpend StarsTransactionReason = "bot_spend" // bot wallet spend StarsReasonBotRefund StarsTransactionReason = "bot_refund" // invoice refund, debiting the bot wallet ) // StarsTransaction is one ledger entry. amount is signed: credit > 0 (including refund/receive), debit < 0. type StarsTransaction struct { ID int64 // monotonic ledger id (keyset cursor) UserID int64 // ledger owner Peer Peer // counterparty (a zero Peer when there is none, e.g. grant/topup) Amount int64 // signed amount Date int // Unix seconds Reason StarsTransactionReason Title string // optional, projected to tg.StarsTransaction.Title Description string // optional, projected to tg.StarsTransaction.Description PaymentID int64 RecipientUserID int64 PremiumMonths int } // IsCredit reports whether the entry is a credit, projected to tg.StarsTransaction.Refund. func (t StarsTransaction) IsCredit() bool { return t.Amount > 0 } // StarsTransactionDirection scopes one payments.getStarsTransactions view. // The zero value intentionally means the combined inbound/outbound history. type StarsTransactionDirection uint8 const ( StarsTransactionDirectionAll StarsTransactionDirection = iota StarsTransactionDirectionIncoming StarsTransactionDirectionOutgoing ) func (d StarsTransactionDirection) Valid() bool { return d <= StarsTransactionDirectionOutgoing } func (d StarsTransactionDirection) IncludesAmount(amount int64) bool { switch d { case StarsTransactionDirectionAll: return true case StarsTransactionDirectionIncoming: return amount > 0 case StarsTransactionDirectionOutgoing: return amount < 0 default: return false } } // StarsTransactionQuery keeps direction, ordering and the opaque keyset cursor // together so filtering is applied before LIMIT in every ledger backend. type StarsTransactionQuery struct { Offset string Limit int Direction StarsTransactionDirection Ascending bool } // NormalizeStarsTransactionQuery preserves the existing bounded limit/offset // behavior while rejecting impossible internal direction values. func NormalizeStarsTransactionQuery(query StarsTransactionQuery) (StarsTransactionQuery, error) { if !query.Direction.Valid() { return StarsTransactionQuery{}, ErrStarsTransactionQueryInvalid } if len(query.Offset) > MaxStarsTransactionsOffsetBytes { query.Offset = "" } if query.Limit <= 0 || query.Limit > MaxStarsTransactionsLimit { query.Limit = MaxStarsTransactionsLimit } return query, nil } // StarsTransactionPage is one page of ledger entries + current balance + pagination cursor + the counterparty user enrichment set. type StarsTransactionPage struct { Balance int64 Transactions []StarsTransaction NextOffset string // empty means no more pages (DrKLO stops paging on it; do not give a non-empty value on the last page) Users []User // counterparty users mentioned in History, for tg Users enrichment } // TonUSDRate is the fixed local-ledger USD reference rate, not a live market quote. const TonUSDRate = 1.3448279670684584 // TonTransaction is an entry in telesrv's internal nanoton ledger. It models // the Telegram TON-denominated gift UI without contacting a wallet, Fragment, // a TON node, or any blockchain service. type TonTransaction struct { Comment string ID int64 UserID int64 Peer Peer GiftID int64 Amount int64 // signed nanoton amount Date int Reason StarsTransactionReason Title string Description string } type TonTransactionPage struct { Balance int64 Transactions []TonTransaction NextOffset string Users []User } // Stars ledger boundary constants. const ( // DefaultStarsStartingGrant is the starting Stars balance of the lazy first-read grant (for local testing). DefaultStarsStartingGrant = 1000 // MaxStarsTransactionsLimit is the per-page limit of getStarsTransactions. MaxStarsTransactionsLimit = 100 // MaxStarsTransactionsOffsetBytes is the keyset cursor string length limit. MaxStarsTransactionsOffsetBytes = 64 ) // Stars ledger sentinel errors (the rpc layer matches via errors.Is and maps to tgerr, mirroring ErrPremiumRequired). var ( // ErrStarsInsufficient means the balance is insufficient to complete the debit (maps to BALANCE_TOO_LOW). ErrStarsInsufficient = errors.New("stars: insufficient balance") // ErrStarsInvalidAmount means the amount is invalid (<=0). ErrStarsInvalidAmount = errors.New("stars: invalid amount") // ErrStarsTransactionQueryInvalid means an impossible transaction direction was constructed internally. ErrStarsTransactionQueryInvalid = errors.New("stars: invalid transaction query") // ErrStarsPurchaseFormInvalid covers a missing/cross-account/mutated form. ErrStarsPurchaseFormInvalid = errors.New("stars: purchase form invalid") // ErrStarsPurchaseFormExpired is returned before any settlement write. ErrStarsPurchaseFormExpired = errors.New("stars: purchase form expired") // ErrStarsGiftUnavailable covers a recipient that cannot receive the gift. ErrStarsGiftUnavailable = errors.New("stars: gift unavailable") // ErrStarsAirdropUnsupported means the underlying store does not support bulk credit (e.g. the in-memory backend). ErrStarsAirdropUnsupported = errors.New("stars: airdrop unsupported") ) // StarsPaymentRequiredError reports the minimum paid-message authorization the // sender must include in allow_paid_stars. The authorization is a ceiling; the // ledger debits only the channel's current configured price. type StarsPaymentRequiredError struct { Stars int64 } func (e *StarsPaymentRequiredError) Error() string { return fmt.Sprintf("stars: allow payment required: %d", e.Stars) } // EncodeStarsCursor encodes the keyset cursor (the last entry id) into a client-opaque string. func EncodeStarsCursor(id int64) string { return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) } // DecodeStarsCursor reverses EncodeStarsCursor; it returns ok=false when it cannot parse (including an empty string), // and the caller should start from the first page (the client only echoes back cursors we gave it; malformed is only a fallback). func DecodeStarsCursor(s string) (int64, bool) { if s == "" { return 0, false } raw, err := base64.RawURLEncoding.DecodeString(s) if err != nil { return 0, false } id, err := strconv.ParseInt(string(raw), 10, 64) if err != nil || id <= 0 { return 0, false } return id, true } var ErrWalletTransferStartingGrant = errors.New("wallet transfers require a zero starting grant") var ErrWalletTransferRecipientBlocked = errors.New("wallet transfer recipient blocked sender") // WalletGramTransferRequest describes one gram transfer between two accounts. // // The request is the whole movement: who sends, who receives, how much, and // what the note says. The address is the recipient's wallet address, which // the caller knows from the wallet and the ledger does not. type WalletGramTransferRequest struct { // AuthorizationHash identifies the one-use SRP grant; no secret is persisted. AuthorizationHash [32]byte // FromUserID is the account the money leaves. FromUserID int64 // ToUserID is the account the money arrives at. ToUserID int64 // AmountNanoton is the amount, in nanoton. AmountNanoton int64 // Reason is the ledger reason of the movement. Reason StarsTransactionReason // PeerAddress is the recipient's wallet address, shown in the service // message. It is empty when the caller does not know it. PeerAddress string // Comment is the note the sender wrote on the transfer. Comment string // Date is when the movement happened. Date int // Title describes the movement in the ledger. Title string // CommandKey makes the movement idempotent. An empty key means no // idempotency. CommandKey string // OriginAuthKeyID and OriginSessionID carry the origin of the request, so // a server-generated service message is delivered like the client's own. OriginAuthKeyID [8]byte OriginSessionID int64 // OriginUserID is the authenticated initiator when the message is // authored by another user, as an operator transfer is. OriginUserID int64 } // WalletGramTransferResult is the result of one gram transfer. type WalletGramTransferResult struct { Date int Duplicate bool // Balance is the receiver's balance after the transfer. Balance int64 // TransactionID is the sender's ledger row. The service message and the // history entry are the same movement, and the client correlates them by // this id. TransactionID int64 // Send is the service message that told the chat about the transfer. Send SendPrivateTextResult }