9acf6ab3b4
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
Testing the rewarded slice surfaced a contradiction: rewarded ads let a VK-iOS
user earn vk chips, but the blanket VK-iOS "spend freeze" then blocked spending
them (the wallet showed "5 (view-only)"). Apple's ToS forbids only BUYING in-app
values on VK-iOS (money -> chips), not spending or earning them — so the freeze is
corrected to purchase-only: vkFrozen() now gates only CreateOrder (the money-in
step), not spendableSources (spending). VK-wallet chips — earned via rewarded ads
or bought on the same account elsewhere (VK Android) — now spend on VK-iOS too.
(Owner's ToS finding.)
Also: the temporary contour diagnostic confirmed VK returns only {result:true} for
a rewarded view (no token/signature) — client-attested is final, no hardening
possible — so the diagnostic (the log and the diag wire field) is removed.
Docs: PAYMENTS(+ru) VK-iOS freeze + D17 amend + PLAN E6. Tests updated (gate /
wallet VK-iOS now spendable).
206 lines
7.4 KiB
Go
206 lines
7.4 KiB
Go
package payments
|
|
|
|
import (
|
|
"slices"
|
|
"time"
|
|
)
|
|
|
|
// Source is the platform axis a chip balance is segmented by (where it was funded) and a
|
|
// benefit is stamped with (where it was bought — its origin). The two roles share this value
|
|
// set but mean different things (see docs/PAYMENTS.md §3); Source names the axis for both.
|
|
type Source string
|
|
|
|
const (
|
|
// SourceVK is the VK platform: Votes purchases and VK rewarded ads fund here.
|
|
SourceVK Source = "vk"
|
|
// SourceTelegram is the Telegram platform: Stars purchases fund here.
|
|
SourceTelegram Source = "telegram"
|
|
// SourceDirect is the open web / native context: Robokassa purchases fund here.
|
|
SourceDirect Source = "direct"
|
|
)
|
|
|
|
// Valid reports whether s is one of the three known platform sources.
|
|
func (s Source) Valid() bool {
|
|
switch s {
|
|
case SourceVK, SourceTelegram, SourceDirect:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// SubtypeIOS is the one device subtype the gate keys on: VK on iOS is frozen for spending
|
|
// (Apple forbids spending virtual currency on digital goods outside IAP). It is trusted only
|
|
// for VK, where it rides inside the signed launch parameters.
|
|
const SubtypeIOS = "ios"
|
|
|
|
// SourceForIdentityKind maps a backend identity kind to the payments source whose segment that
|
|
// identity makes available: a vk/telegram identity to its own source, a durable email identity
|
|
// to the direct source (the web/native recovery anchor). A robot identity — or any unknown
|
|
// kind — maps to no source (second return false). Callers use it to build the present set the
|
|
// payments interface takes, since payments cannot read the account schema.
|
|
func SourceForIdentityKind(kind string) (Source, bool) {
|
|
switch kind {
|
|
case "vk":
|
|
return SourceVK, true
|
|
case "telegram":
|
|
return SourceTelegram, true
|
|
case "email":
|
|
return SourceDirect, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// PresentSources maps an account's identity kinds to the payments sources they make available
|
|
// (§6), de-duplicated: vk→vk, telegram→telegram, email→direct; a robot or unknown kind maps to
|
|
// nothing. Callers pass the kinds from account.Identities so the gate — which holds no
|
|
// cross-schema identity knowledge — can resolve which segments are awake.
|
|
func PresentSources(kinds []string) []Source {
|
|
var out []Source
|
|
for _, k := range kinds {
|
|
if s, ok := SourceForIdentityKind(k); ok && !has(out, s) {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Context is the trusted execution context the store-compliance gate keys on: the platform
|
|
// Kind (the source the wrapper enforces) plus, for VK, the trusted Subtype (only the VK-iOS
|
|
// freeze depends on it). The zero Context (empty Kind) is an untrusted platform — the gate is
|
|
// fail-closed there: no spend, no purchase, no foreign-origin benefit, view only.
|
|
type Context struct {
|
|
Kind Source
|
|
Subtype string
|
|
}
|
|
|
|
// NewContext builds a Context from the session platform's kind and subtype strings (as carried
|
|
// on the trusted X-Platform signal). An unrecognised or empty kind yields an untrusted Context.
|
|
func NewContext(kind, subtype string) Context {
|
|
k := Source(kind)
|
|
if !k.Valid() {
|
|
return Context{}
|
|
}
|
|
return Context{Kind: k, Subtype: subtype}
|
|
}
|
|
|
|
// Trusted reports whether the platform is trusted (a known kind). An untrusted context denies
|
|
// every spend/purchase and the application of any foreign origin.
|
|
func (c Context) Trusted() bool { return c.Kind.Valid() }
|
|
|
|
// vkFrozen reports whether this is the VK-iOS purchase freeze: VK context on the trusted iOS
|
|
// subtype. Apple's ToS forbids only BUYING in-app values there, so a purchase (money -> chips) is
|
|
// refused; earning chips (rewarded ads) and SPENDING chips already in the VK wallet — earned or
|
|
// bought on the same account elsewhere (e.g. VK Android) — are legal and stay allowed.
|
|
func (c Context) vkFrozen() bool { return c.Kind == SourceVK && c.Subtype == SubtypeIOS }
|
|
|
|
// spendPriority is the fixed draw order when several segments are spendable in one context
|
|
// (D7): the "home" direct segment first, the store-funded segments after.
|
|
var spendPriority = []Source{SourceDirect, SourceVK, SourceTelegram}
|
|
|
|
// has reports whether present contains s.
|
|
func has(present []Source, s Source) bool {
|
|
return slices.Contains(present, s)
|
|
}
|
|
|
|
// spendableSources returns the chip segments that may be SPENT in the context, in draw-priority
|
|
// order, restricted to the sources the account actually has (present). It is empty only when the
|
|
// platform is untrusted (fail-closed); VK-iOS is NOT excluded — the freeze is purchase-only, so
|
|
// spending VK-wallet chips there is allowed. Inside VK/TG only the same-named segment is spendable;
|
|
// on web/native all attached segments are, drained direct→vk→tg.
|
|
func spendableSources(c Context, present []Source) []Source {
|
|
if !c.Trusted() {
|
|
return nil
|
|
}
|
|
switch c.Kind {
|
|
case SourceVK, SourceTelegram:
|
|
if has(present, c.Kind) {
|
|
return []Source{c.Kind}
|
|
}
|
|
return nil
|
|
default: // direct (web/native)
|
|
var out []Source
|
|
for _, s := range spendPriority {
|
|
if has(present, s) {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
// applicableOrigins returns the benefit origins that APPLY in the context, in draw-priority
|
|
// order, restricted to present sources. It mirrors spendableSources (both gate only on a trusted
|
|
// platform now that the VK-iOS freeze is purchase-only). Inside VK/TG only the same-named origin
|
|
// applies (a foreign, e.g. direct, origin never activates inside a store — the compliance wall);
|
|
// on web/native direct+vk+tg all apply, drained direct→vk→tg.
|
|
func applicableOrigins(c Context, present []Source) []Source {
|
|
if !c.Trusted() {
|
|
return nil
|
|
}
|
|
switch c.Kind {
|
|
case SourceVK, SourceTelegram:
|
|
if has(present, c.Kind) {
|
|
return []Source{c.Kind}
|
|
}
|
|
return nil
|
|
default: // direct (web/native)
|
|
var out []Source
|
|
for _, s := range spendPriority {
|
|
if has(present, s) {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
// visibleSources returns the segments the wallet shows in the context, regardless of whether
|
|
// they are spendable: inside a store only the same-named segment (the others are invisible
|
|
// there); on web/native or an untrusted context all three (untrusted shows them view-only).
|
|
func visibleSources(c Context) []Source {
|
|
switch c.Kind {
|
|
case SourceVK, SourceTelegram:
|
|
return []Source{c.Kind}
|
|
default:
|
|
return spendPriority
|
|
}
|
|
}
|
|
|
|
// Segment is one chip balance the wallet shows: the source, its chip count, and whether it can
|
|
// be spent in the current context (false for a frozen VK-iOS balance or an untrusted platform).
|
|
type Segment struct {
|
|
Source Source
|
|
Chips int
|
|
Spendable bool
|
|
}
|
|
|
|
// BenefitView is the benefit state applicable in the current context: whether ads are off (and
|
|
// until when, or forever) and how many hints are available. It aggregates over the origins
|
|
// applicable in the context (§5).
|
|
type BenefitView struct {
|
|
AdsForever bool
|
|
AdsPaidUntil *time.Time
|
|
Hints int
|
|
}
|
|
|
|
// WalletView is the read model returned to the wallet: the visible segments plus the
|
|
// context-applicable benefits.
|
|
type WalletView struct {
|
|
Segments []Segment
|
|
Benefits BenefitView
|
|
}
|
|
|
|
// benefitDelta is the benefit change a spend or grant applies to one origin: hints added, a
|
|
// no-ads term in whole days (stacked from max(now, current end)), and the perpetual forever
|
|
// flag (which overrides terms).
|
|
type benefitDelta struct {
|
|
hintsAdd int
|
|
noAdsDays int
|
|
forever bool
|
|
}
|
|
|
|
// zero reports whether the delta changes nothing.
|
|
func (d benefitDelta) zero() bool { return d.hintsAdd == 0 && d.noAdsDays == 0 && !d.forever }
|