feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps #228

Merged
developer merged 2 commits from feature/ads-rewarded into development 2026-07-09 23:33:10 +00:00
42 changed files with 797 additions and 49 deletions
+3
View File
@@ -407,6 +407,9 @@ jobs:
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# Rewarded-ad test stub: set TEST_VITE_ADS_STUB=1 to swap real ads for a toast on the
# contour (empty = real ads, for capturing the real VK ad result). Prod never sets it.
VITE_ADS_STUB: ${{ vars.TEST_VITE_ADS_STUB }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
+24 -2
View File
@@ -35,7 +35,7 @@ status — without re-deriving decisions.
| E3 | Wallet UI | 1 | DONE |
| E4 | Durability (PITR) | 2 | DONE |
| E5 | Payment intake | 2 | DONE |
| E6 | Ads | 2 | TODO |
| E6 | Ads | 2 | WIP |
| E7 | Admin & reports | 2 | TODO |
| E8 | Guest limits | — | TODO |
| E9 | Tournament fee | future | TODO |
@@ -618,9 +618,31 @@ force-recreate when the Caddyfile changes.
## E6 — Ads
**Status:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
**Status:** WIP · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
mechanics: PAYMENTS §10.
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**,
then interstitial. Baked: the interstitial cooldowns already exist in `payments.config` (E0) and the
per-origin banner suppression is already done (E2 `AdFree`), so E6 is the two ad DISPLAY paths + the
rewarded credit. **VK reality (checked live in the VK docs via Playwright):** VK Mini App ads
(`VKWebAppShowNativeAds`, both `reward` and `interstitial`) expose **only a client-side `data.result`
boolean** — no server verify, no signature. So **D29 is amended**: rewarded is **client-attested**,
guarded by a server **daily + hourly cap** (config `reward_daily_cap` / `reward_hourly_cap`, default
50 / 10) that is both anti-abuse and an economic conversion lever (limits free chips so players buy);
the cooldown state for the interstitial is **client-mirrored** (owner's pick). Delivered on
`feature/ads-rewarded` (the **rewarded** slice): the ads-network abstraction (`ui/src/lib/ads.ts`, VK
impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the backend `CreditReward` (VK-only,
order-less, idempotent on a client nonce, floored by the caps, payout from config
`rewarded_payout_chips` default 0 = off), the `wallet.reward` edge op returning the updated wallet
(with `reward_chips` gating the "watch for chips" CTA), and a **contour test stub** (`VITE_ADS_STUB` →
a toast instead of a real ad; prod always real). A temporary diagnostic confirmed on the contour that
VK returns **only `{result:true}`** (no token/signature) — client-attested is final, no hardening
possible; the diagnostic is removed. The slice also **corrects the VK-iOS freeze to purchase-only**
(rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending —
Apple forbids only *buying* in-app values, not spending or earning them; `vkFrozen()` now gates only
`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). The legacy `paid_account`
/ `hint_balance` drop (D31) and the **interstitial** are the next slice.
**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video
(credits chips via server verify), plus extending the existing banner suppression to
per-origin.
@@ -240,6 +240,100 @@ func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
}
}
// setRewardConfig sets the rewarded payout and caps on the shared config row, restoring the defaults
// after the test (the row is a singleton shared across the sequential suite).
func setRewardConfig(t *testing.T, payout, dailyCap, hourlyCap int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
payout, dailyCap, hourlyCap); err != nil {
t.Fatalf("set reward config: %v", err)
}
t.Cleanup(func() {
_, _ = testDB.ExecContext(context.Background(),
`UPDATE payments.config SET rewarded_payout_chips=0, reward_daily_cap=50, reward_hourly_cap=10`)
})
}
// TestPaymentsRewardCredit exercises the rewarded-video credit: a watched view credits the VK segment
// the configured payout, a retried view (same nonce) credits once, and the hourly cap blocks the
// third distinct view.
func TestPaymentsRewardCredit(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
setRewardConfig(t, 5, 5, 2) // 5 chips/view, daily 5, hourly 2
vk := payments.NewContext("vk", "web")
present := []payments.Source{payments.SourceVK}
out, err := svc.CreditReward(ctx, acc, vk, present, "n1")
if err != nil {
t.Fatalf("credit: %v", err)
}
if out.Chips != 5 || out.Capped {
t.Fatalf("reward outcome = %+v, want 5 chips, not capped", out)
}
if got := readBalance(t, acc, "vk"); got != 5 {
t.Errorf("vk balance = %d, want 5", got)
}
// A retried view (same nonce) credits nothing more.
out2, err := svc.CreditReward(ctx, acc, vk, present, "n1")
if err != nil {
t.Fatalf("retry: %v", err)
}
if !out2.AlreadyCredited {
t.Error("retried view not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "vk"); got != 5 {
t.Errorf("vk balance after retry = %d, want 5 (credited once)", got)
}
// A second distinct view credits again (2 total).
if _, err := svc.CreditReward(ctx, acc, vk, present, "n2"); err != nil {
t.Fatalf("credit 2: %v", err)
}
if got := readBalance(t, acc, "vk"); got != 10 {
t.Errorf("vk balance = %d, want 10", got)
}
// The third distinct view hits the hourly cap (2) — capped, no credit.
out3, err := svc.CreditReward(ctx, acc, vk, present, "n3")
if err != nil {
t.Fatalf("credit 3: %v", err)
}
if !out3.Capped {
t.Error("third view not capped (hourly cap 2)")
}
if got := readBalance(t, acc, "vk"); got != 10 {
t.Errorf("vk balance after cap = %d, want 10 (capped view credited nothing)", got)
}
}
// TestPaymentsRewardDisabledAndContext verifies rewarded is inert when unconfigured (0 payout) and
// refused outside a VK context (rewarded is VK-only, D28).
func TestPaymentsRewardDisabledAndContext(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
setRewardConfig(t, 0, 50, 10) // payout 0 = disabled
vk := payments.NewContext("vk", "web")
out, err := svc.CreditReward(ctx, acc, vk, []payments.Source{payments.SourceVK}, "d1")
if err != nil {
t.Fatalf("credit (disabled): %v", err)
}
if out.Chips != 0 || out.Capped || out.AlreadyCredited {
t.Fatalf("disabled reward outcome = %+v, want 0 chips, not capped", out)
}
// A direct (non-VK) context is refused — rewarded is VK-only.
direct := payments.NewContext("direct", "web")
if _, err := svc.CreditReward(ctx, acc, direct, []payments.Source{payments.SourceDirect}, "d2"); !errors.Is(err, payments.ErrUntrusted) {
t.Fatalf("direct-context reward = %v, want ErrUntrusted", err)
}
}
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
// refused and credits nothing (§9: verify the amount after matching by order id).
func TestPaymentsFundAmountMismatch(t *testing.T) {
+13 -10
View File
@@ -89,8 +89,10 @@ func NewContext(kind, subtype string) Context {
// 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 spend freeze: VK context on the trusted iOS
// subtype. A previously bought benefit still applies there, but no spend or purchase is possible.
// 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
@@ -103,11 +105,12 @@ func has(present []Source, s Source) bool {
}
// 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 when the
// platform is untrusted (fail-closed) or VK-iOS (frozen): inside VK/TG only the same-named
// segment is spendable; on web/native all attached segments are, drained direct→vk→tg.
// 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() || c.vkFrozen() {
if !c.Trusted() {
return nil
}
switch c.Kind {
@@ -128,10 +131,10 @@ func spendableSources(c Context, present []Source) []Source {
}
// applicableOrigins returns the benefit origins that APPLY in the context, in draw-priority
// order, restricted to present sources. It differs from spendableSources in one way: VK-iOS is
// NOT excluded — a benefit bought earlier still applies while spending is frozen. 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.
// 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
+4 -3
View File
@@ -16,7 +16,8 @@ func TestSpendableSources(t *testing.T) {
want []Source
}{
{"vk android, vk present", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
{"vk ios frozen", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, nil},
// VK-iOS spends its own vk segment: the freeze is purchase-only, spending is allowed.
{"vk ios spends vk", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
{"vk android, vk absent", Context{Kind: SourceVK, Subtype: "android"}, []Source{SourceDirect}, nil},
{"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}},
{"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil},
@@ -41,8 +42,8 @@ func TestApplicableOrigins(t *testing.T) {
present []Source
want []Source
}{
// A benefit still APPLIES on VK-iOS while spending is frozen.
{"vk ios still applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
// A vk-origin benefit applies on VK-iOS (spending is allowed there — the freeze is purchase-only).
{"vk ios applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
{"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
{"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{SourceTelegram}},
{"direct all, priority", Context{Kind: SourceDirect}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}},
@@ -135,6 +135,36 @@ func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, am
return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
}
// providerVKAds tags a rewarded-video credit from the VK ads network in the ledger (distinct from
// the "vk" Votes-purchase provider), so the daily cap counts only ad credits and the report separates
// them.
const providerVKAds = "vk_ads"
// RewardPayout reports the chips a rewarded-video view earns in the caller's context — the config
// payout in a trusted VK context with the VK segment attached, and 0 everywhere else (rewarded is
// VK-only, D28). The client uses it to gate the "watch for chips" button.
func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Source) (int, error) {
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
return 0, nil
}
payout, _, _, err := s.store.rewardConfig(ctx)
return payout, err
}
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
// client nonce and order-less. It credits nothing when rewarded is unconfigured (0 payout) or the
// daily cap is reached. Rewarded video is VK-only (D28) and is an ad view — not a purchase — so the
// VK-iOS purchase freeze does not apply; it requires a trusted VK context with the VK segment
// attached.
func (s *Service) CreditReward(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, nonce string) (RewardOutcome, error) {
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
return RewardOutcome{}, ErrUntrusted
}
return s.store.creditReward(ctx, accountID, SourceVK, providerVKAds, nonce, s.clock())
}
// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
// still credits — see Fund).
@@ -28,7 +28,7 @@ func TestCreateOrderGateRejections(t *testing.T) {
cxt Context
}{
{"untrusted context", Context{}},
{"vk-ios spend freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}},
{"vk-ios purchase freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}},
{"method segment not attached", Context{Kind: SourceTelegram}},
}
for _, tc := range cases {
+103
View File
@@ -375,6 +375,96 @@ func (s *Store) refund(ctx context.Context, orderID uuid.UUID, provider, provide
return outcome, nil
}
// RewardOutcome reports a rewarded-video credit: the chips credited (0 when rewarded is unconfigured
// or the daily cap is reached), whether the daily cap blocked it, and whether it was a duplicate view
// (same client nonce) that credited nothing more.
type RewardOutcome struct {
AccountID uuid.UUID
Chips int
Capped bool
AlreadyCredited bool
}
// rewardConfig reads the rewarded payout (chips per view) and the per-day and per-hour caps. The
// caps are both anti-abuse (bounding a forger's free chips) and an economic conversion lever (free
// rewarded chips are limited so a player who wants more buys) — tuned in the admin.
func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
var cfg model.Config
if e := postgres.SELECT(table.Config.RewardedPayoutChips, table.Config.RewardDailyCap, table.Config.RewardHourlyCap).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &cfg); e != nil {
return 0, 0, 0, fmt.Errorf("payments: read reward config: %w", e)
}
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
}
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
// (provider, provider_payment_id) index), so a retried view credits once, and order-less (a free
// credit, no order). The cap counts today's rewarded credits for this network (UTC day); a rare
// concurrent race may allow cap+1, which the per-user rate limiter bounds and the cap tolerates.
func (s *Store) creditReward(ctx context.Context, accountID uuid.UUID, source Source, provider, nonce string, now time.Time) (RewardOutcome, error) {
payout, dailyCap, hourlyCap, err := s.rewardConfig(ctx)
if err != nil {
return RewardOutcome{}, err
}
outcome := RewardOutcome{AccountID: accountID}
if payout <= 0 {
return outcome, nil // rewarded not configured (0 payout) — inert until the owner sets it
}
// Count this network's rewarded credits in the last day and last hour (one scan over the last
// 25 h covers both windows); either cap reached blocks the credit. A rare concurrent race may
// allow cap+1, which the per-user rate limiter bounds and the anti-abuse cap tolerates.
var today, lastHour int
if e := s.db.QueryRowContext(ctx,
`SELECT count(*) FILTER (WHERE created_at >= date_trunc('day', now())),
count(*) FILTER (WHERE created_at >= now() - interval '1 hour')
FROM payments.ledger
WHERE account_id = $1 AND kind = 'fund' AND provider = $2 AND created_at >= now() - interval '25 hours'`,
accountID, provider).Scan(&today, &lastHour); e != nil {
return RewardOutcome{}, fmt.Errorf("payments: count rewarded views: %w", e)
}
if today >= dailyCap || lastHour >= hourlyCap {
outcome.Capped = true
return outcome, nil
}
snapshot, err := marshalRewardSnapshot(payout)
if err != nil {
return RewardOutcome{}, err
}
src := source
pv, pp := provider, nonce
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if e := insertLedgerTx(ctx, tx, accountID, "fund", &src, &src, payout, nil, nil, &pv, &pp, snapshot, now); e != nil {
if isUniqueViolation(e) {
outcome.AlreadyCredited = true
return errAlreadyCredited
}
return e
}
if _, e := tx.ExecContext(ctx,
`INSERT INTO payments.balances (account_id, source, chips, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (account_id, source) DO UPDATE
SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
accountID, string(src), payout); e != nil {
return fmt.Errorf("payments: credit rewarded balance: %w", e)
}
return nil
})
if err != nil {
if errors.Is(err, errAlreadyCredited) {
return outcome, nil
}
return RewardOutcome{}, err
}
outcome.Chips = payout
s.cache.invalidate(accountID)
return outcome, nil
}
// insertPaymentEvent appends an undispatched lifecycle event (succeeded/failed/refunded) for the
// dispatcher to deliver. orderID and payload (a jsonb detail blob) are optional.
func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte, now time.Time) error {
@@ -498,6 +588,19 @@ func marshalRefundSnapshot(productID uuid.UUID, title string, chips, revoked, lo
return b, nil
}
// marshalRewardSnapshot records a rewarded-video credit on its ledger row: the marker distinguishing
// it from a paid fund, and the chips granted — so the report separates ad-earned chips from purchases.
func marshalRewardSnapshot(chips int) ([]byte, error) {
b, err := json.Marshal(struct {
Reward bool `json:"reward"`
Chips int `json:"chips"`
}{true, chips})
if err != nil {
return nil, fmt.Errorf("payments: marshal reward snapshot: %w", err)
}
return b, nil
}
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint violation (SQLSTATE
// 23505) — here, a duplicate provider callback hitting the ledger idempotency index.
func isUniqueViolation(err error) bool {
+4 -3
View File
@@ -102,10 +102,11 @@ func TestWalletSegments(t *testing.T) {
if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable {
t.Errorf("vk-android wallet = %+v", got.Segments)
}
// VK iOS: only vk shown, frozen (not spendable) but the balance is visible.
// VK iOS: only vk shown, and spendable — the freeze is purchase-only, so VK-wallet chips still
// spend there (only buying more chips for money is blocked).
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present)
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || got.Segments[0].Spendable {
t.Errorf("vk-ios wallet = %+v (want vk 50 frozen)", got.Segments)
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || !got.Segments[0].Spendable {
t.Errorf("vk-ios wallet = %+v (want vk 50 spendable)", got.Segments)
}
}
@@ -14,4 +14,6 @@ type Config struct {
CooldownVsAiSeconds int32
CooldownHintSeconds int32
OrderTTLSeconds int32
RewardDailyCap int32
RewardHourlyCap int32
}
@@ -23,6 +23,8 @@ type configTable struct {
CooldownVsAiSeconds postgres.ColumnInteger
CooldownHintSeconds postgres.ColumnInteger
OrderTTLSeconds postgres.ColumnInteger
RewardDailyCap postgres.ColumnInteger
RewardHourlyCap postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -70,9 +72,11 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
RewardDailyCapColumn = postgres.IntegerColumn("reward_daily_cap")
RewardHourlyCapColumn = postgres.IntegerColumn("reward_hourly_cap")
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
)
return configTable{
@@ -85,6 +89,8 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
CooldownHintSeconds: CooldownHintSecondsColumn,
OrderTTLSeconds: OrderTTLSecondsColumn,
RewardDailyCap: RewardDailyCapColumn,
RewardHourlyCap: RewardHourlyCapColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,22 @@
-- Rewarded-video caps: the anti-abuse ceilings on free rewarded credits per user, per
-- day and per hour. VK Mini App ads expose only a client-side watch result (no
-- server-to-server verify), so a rewarded credit is client-attested; the caps bound a
-- forger who skips the ad and calls the credit endpoint directly (the daily cap bounds the
-- total; the hourly cap smooths a burst). Chips-per-view already exists
-- (rewarded_payout_chips, default 0 = rewarded inert until the owner sets it). All are
-- config, tuned in the admin without a release. Additive columns only — applies forward via
-- goose with no data rewrite (no contour wipe), and an image rollback ignores them.
-- +goose Up
ALTER TABLE payments.config
ADD COLUMN reward_daily_cap integer DEFAULT 50 NOT NULL,
ADD COLUMN reward_hourly_cap integer DEFAULT 10 NOT NULL;
ALTER TABLE payments.config
ADD CONSTRAINT config_reward_daily_cap_chk CHECK (reward_daily_cap >= 0),
ADD CONSTRAINT config_reward_hourly_cap_chk CHECK (reward_hourly_cap >= 0);
-- +goose Down
ALTER TABLE payments.config
DROP COLUMN reward_daily_cap,
DROP COLUMN reward_hourly_cap;
+2
View File
@@ -72,6 +72,8 @@ func (s *Server) registerRoutes() {
u.GET("/wallet", s.handleWallet)
u.GET("/wallet/catalog", s.handleWalletCatalog)
u.POST("/wallet/buy", s.handleWalletBuy)
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
u.POST("/wallet/reward", s.handleWalletReward)
}
if s.payments != nil {
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
+65 -2
View File
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
)
@@ -20,11 +21,15 @@ type walletSegmentDTO struct {
// walletDTO is the user-facing wallet: the context-visible chip segments and the
// context-applicable benefits (the no-ads term or forever flag, and the available hints).
// RewardChips is the chips a rewarded-video view earns in the current context (0 when rewarded is
// unavailable here — outside VK, or unconfigured); the client shows the "watch for chips" button
// only when it is positive.
type walletDTO struct {
Segments []walletSegmentDTO `json:"segments"`
AdsForever bool `json:"ads_forever"`
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
Hints int `json:"hints"`
RewardChips int `json:"reward_chips"`
}
// walletBuyRequest is the POST body of a chip spend: the product to buy with chips.
@@ -108,7 +113,8 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
}
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
// trusted execution context.
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
// unconfigured), which gates the client's "watch for chips" button.
func (s *Server) handleWallet(c *gin.Context) {
uid, ok := userID(c)
if !ok {
@@ -125,7 +131,13 @@ func (s *Server) handleWallet(c *gin.Context) {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, walletDTOFrom(view))
dto := walletDTOFrom(view)
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr != nil {
s.log.Warn("wallet: reward payout read failed", zap.String("account", uid.String()), zap.Error(perr))
} else {
dto.RewardChips = payout
}
c.JSON(http.StatusOK, dto)
}
// handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is
@@ -162,3 +174,54 @@ func (s *Server) handleWalletBuy(c *gin.Context) {
}
c.JSON(http.StatusOK, walletDTOFrom(view))
}
// walletRewardRequest is the POST body of a rewarded-video credit: a client nonce, the idempotency
// key for a single watched view (a retry credits once).
type walletRewardRequest struct {
Nonce string `json:"nonce"`
}
// handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and
// bounded by the config daily cap. It is VK-only and idempotent on the nonce; a reached cap answers
// reward_capped, and an unconfigured payout answers reward_unavailable. On success it returns the
// updated wallet (like a spend).
func (s *Server) handleWalletReward(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
var req walletRewardRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Nonce == "" {
abortBadRequest(c, "nonce is required")
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce)
if err != nil {
s.abortErr(c, err)
return
}
if outcome.Capped {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_capped", Message: "daily reward limit reached"}})
return
}
if outcome.Chips == 0 && !outcome.AlreadyCredited {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_unavailable", Message: "rewarded video is not available"}})
return
}
view, err := s.payments.Wallet(ctx, uid, cxt, present)
if err != nil {
s.abortErr(c, err)
return
}
view2 := walletDTOFrom(view)
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr == nil {
view2.RewardChips = payout
}
c.JSON(http.StatusOK, view2)
}
+3
View File
@@ -207,6 +207,9 @@ services:
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev}
# The rewarded-ad test stub (1 = a toast instead of a real ad; the test contour only, empty
# elsewhere so production shows real ads).
VITE_ADS_STUB: ${VITE_ADS_STUB:-}
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
VERSION: ${APP_VERSION:-dev}
restart: unless-stopped
+17 -7
View File
@@ -82,7 +82,7 @@ leaking out to the open web) is allowed.
| Execution context | Spendable chip segments | Spend priority |
|---------------------|-------------------------|--------------------|
| Inside VK (Android) | `vk` | — |
| Inside VK (iOS) | none — frozen (view only)| — |
| Inside VK (iOS) | `vk` (purchase-frozen) | — |
| Inside Telegram | `telegram` | — |
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
@@ -90,9 +90,11 @@ leaking out to the open web) is allowed.
invisible-as-spendable there.
- On the web the store has no jurisdiction, so all attached segments are spendable, drained
by priority direct → vk → tg.
- **VK iOS** is frozen for spending (Apple forbids spending virtual currency on digital
goods outside IAP inside VK on iOS): the balance is shown as a number, but no purchase or
spend is possible. A previously bought benefit still *applies* there.
- **VK iOS is a PURCHASE freeze, not a spend freeze.** Apple's ToS forbids only **buying** in-app
values there (for any currency) — so a purchase (money → chips) is refused. **Spending** VK-wallet
chips (earned via rewarded ads or bought on the same VK account elsewhere, e.g. VK Android) and
earning them are legal and stay allowed on VK iOS; a bought benefit also *applies* there. Only the
money-in step is blocked.
The account is **single** (identities merge, one profile/friends/stats). The gate is
**logical**: in a VK/TG context the server activates only the same-named segment. It rests
@@ -266,9 +268,17 @@ ruble-paying in-app network exists. The ad provider is behind an **abstraction**
network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob)
are rejected — no legal ruble income for a self-employed (НПД) developer.
**Rewarded** (voluntary video for chips) credits chips through payments on the network's
**server verify callback** (client not believed, like a payment). On-launch anti-fraud is
the provider's verify only (no own daily cap yet; the abstraction allows adding caps later).
**Rewarded** (voluntary video for chips) credits chips through payments. **VK Mini App ads expose
only a client-side watch result** (`VKWebAppShowNativeAds``data.result`) — there is no
server-to-server verify — so a rewarded credit is **client-attested** (D29 amended: server verify is
not possible on VK). The anti-abuse (and economic) guard is a **server-side daily and hourly cap**
(config `reward_daily_cap` / `reward_hourly_cap`, default 50 / 10), which bounds a forger who skips
the ad and calls the credit endpoint directly and, as importantly, limits free rewarded chips so a
player who wants more **buys**. The credit is idempotent on a client nonce and order-less; the payout
is config (`rewarded_payout_chips`, default 0 = rewarded off until set). Rewarded is VK-only and is
not suppressed by no-ads (D9). A future network that offers a server verify slots in behind the ads
abstraction. On the test contour a build flag (`VITE_ADS_STUB`) swaps a toast for the real ad; prod
always shows real ads (a failed real ad must not credit).
**Interstitial** (post-move fullscreen), configurable server-side values:
+11 -2
View File
@@ -110,6 +110,11 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
сессии. Платформа несёт **kind** (vk/tg/direct) **+ подтип** (ios/android/web) —
подтип обязателен (VK iOS заморожен). TG `initData`-валидатор уже есть
(`platform/telegram/internal/initdata`).
**АМЕНД (E6, находка владельца по ToS):** VK iOS — **заморозка только ПОКУПОК** (деньги→Фишки),
а не траты. Apple запрещает там лишь **покупать** внутриигровые ценности за любую валюту; а
**тратить** Фишки VK-кошелька (заработанные rewarded-рекламой или купленные на том же VK-аккаунте
в Android) и зарабатывать их — легально и на iOS. Код: `vkFrozen()` гейтит только `CreateOrder`
(покупку), не `spendableSources` (трату).
- **D18. Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без
валидной подписи на старте; старая сессия без записанной платформы) → запрет
трат/покупок/применения чужого origin, только просмотр.
@@ -158,9 +163,13 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
закладываем **абстракцией** (будущая крутилка для других платформ встроится без
переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого
дохода самозанятому (санкции + НПД не учитывает крипту).
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments по
**серверному verify-колбэку** рекламной сети (клиенту не верим, как платёж). Не
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments. Не
гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики.
**АМЕНД (E6, по факту реализации):** у VK Mini App серверного verify-колбэка **нет**
`VKWebAppShowNativeAds` отдаёт только клиентский `data.result`. Поэтому начисление
**client-attested**, а защита (и экономический рычаг) — **серверный дневной и часовой кап**
(config `reward_daily_cap` / `reward_hourly_cap`, дефолт 50 / 10) + идемпотентность по клиентскому
nonce. Сеть с серверным verify встроится за ads-абстракцией позже.
- **D30. Частота навязанного interstitial** (конфигурируемые серверные значения):
глобальный кулдаун **на юзера сквозь все партии**, дефолт **5 мин**; **vs_ai — 30 мин**
(соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода
+16 -8
View File
@@ -83,7 +83,7 @@
| Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты |
|----------------------|---------------------------|--------------------|
| Внутри VK (Android) | `vk` | — |
| Внутри VK (iOS) | нет — заморожено (только просмотр) | — |
| Внутри VK (iOS) | `vk` (заморожена покупка) | — |
| Внутри Telegram | `telegram` | — |
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
@@ -91,9 +91,11 @@
`direct`) там невидимо как тратимое.
- В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по
приоритету direct → vk → tg.
- **VK iOS** заморожен для траты (Apple запрещает тратить виртуальную валюту на цифровые
товары мимо IAP внутри VK на iOS): баланс показывается числом, но покупка/трата
невозможны. Ранее купленный бенефит там всё равно *действует*.
- **VK iOS заморозка ПОКУПОК, а не траты.** ToS Apple запрещает там только **покупать**
внутриигровые ценности (за любую валюту) — поэтому покупка (деньги → Фишки) отклоняется. А
**тратить** Фишки VK-кошелька (заработанные рекламой или купленные на том же VK-аккаунте, напр. в
VK Android) и зарабатывать их — легально и на VK iOS разрешено; купленный бенефит там тоже
*действует*. Блокируется только шаг «деньги внутрь».
Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт
**логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на
@@ -266,10 +268,16 @@ web/native/TG держат только существующий наш **тек
платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального
рублёвого дохода самозанятому (НПД).
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен по
**серверному verify-колбэку** сети (клиенту не верим, как платежу). Антифрод на старте — только
verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты
позже).
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен. **VK Mini
App отдаёт только клиентский результат просмотра** (`VKWebAppShowNativeAds``data.result`) —
серверной проверки нет — поэтому начисление **client-attested** (D29 амендим: server-verify у VK
невозможен). Защита — **серверный дневной и часовой кап** (config `reward_daily_cap` /
`reward_hourly_cap`, дефолт 50 / 10): он ограничивает читера, который пропускает ролик и дёргает
эндпоинт напрямую, и — не менее важно — лимитирует бесплатные Фишки, чтобы желающий больше **покупал**.
Начисление идемпотентно по клиентскому nonce и order-less; выплата — config
(`rewarded_payout_chips`, дефолт 0 = ролик выключен, пока не задан). Rewarded только в VK и не
гасится «без рекламы» (D9). Сеть с серверным verify встроится за ads-абстракцией. На тест-контуре
build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; прод всегда крутит настоящую рекламу.
**Полноэкранный ролик** (после хода), конфигурируемые серверные значения:
+5 -1
View File
@@ -29,6 +29,9 @@ ARG VITE_VK_APP_ID=
ARG VITE_VK_ID_REDIRECT_URL=
ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION=
# VITE_ADS_STUB=1 substitutes a toast stub for real ads (the test contour only); production leaves it
# empty so it always shows real ads (a failed real ad must not credit).
ARG VITE_ADS_STUB=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
@@ -36,7 +39,8 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_VK_APP_ID=$VITE_VK_APP_ID \
VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
VITE_APP_VERSION=$VITE_APP_VERSION
VITE_APP_VERSION=$VITE_APP_VERSION \
VITE_ADS_STUB=$VITE_ADS_STUB
# Install with the lockfile first (the workspace file carries pnpm's build-script
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
+16 -1
View File
@@ -369,12 +369,14 @@ type WalletSegmentResp struct {
}
// WalletResp is the caller's wallet: the context-visible chip segments and the context-applicable
// benefits (no-ads term end as unix millis / forever flag, and the available hints).
// benefits (no-ads term end as unix millis / forever flag, and the available hints), plus the
// rewarded-video payout available in the current context (0 = unavailable — outside VK/unconfigured).
type WalletResp struct {
Segments []WalletSegmentResp `json:"segments"`
AdsForever bool `json:"ads_forever"`
AdsPaidUntil int64 `json:"ads_paid_until_ms"`
Hints int `json:"hints"`
RewardChips int `json:"reward_chips"`
}
// walletBuyBody is the chip-spend request body.
@@ -382,6 +384,19 @@ type walletBuyBody struct {
ProductID string `json:"product_id"`
}
// walletRewardBody is the rewarded-video credit request: the per-view idempotency nonce.
type walletRewardBody struct {
Nonce string `json:"nonce"`
}
// WalletReward credits a watched rewarded video and returns the updated wallet (client-attested + a
// backend daily/hourly cap). A reached cap or unconfigured payout surfaces as an APIError domain code.
func (c *Client) WalletReward(ctx context.Context, userID, nonce string) (WalletResp, error) {
var out WalletResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/reward", userID, "", walletRewardBody{Nonce: nonce}, &out)
return out, err
}
// Wallet fetches the caller's wallet in their current execution context.
func (c *Client) Wallet(ctx context.Context, userID string) (WalletResp, error) {
var out WalletResp
+1
View File
@@ -116,6 +116,7 @@ func encodeWallet(w backendclient.WalletResp) []byte {
fb.WalletAddAdsForever(b, w.AdsForever)
fb.WalletAddAdsPaidUntilMs(b, w.AdsPaidUntil)
fb.WalletAddHints(b, int32(w.Hints))
fb.WalletAddRewardChips(b, int32(w.RewardChips))
b.Finish(fb.WalletEnd(b))
return b.FinishedBytes()
}
+15
View File
@@ -56,6 +56,7 @@ const (
MsgWalletCatalog = "wallet.catalog"
MsgWalletBuy = "wallet.buy"
MsgWalletOrder = "wallet.order"
MsgWalletReward = "wallet.reward"
)
// Request is one decoded Execute call.
@@ -113,6 +114,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op
r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true}
r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true}
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, nil), Auth: true}
r.ops[MsgWalletReward] = Op{Handler: walletRewardHandler(backend), Auth: true}
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
@@ -379,6 +381,19 @@ func walletOrderHandler(backend *backendclient.Client, minter InvoiceMinter) Han
}
}
// walletRewardHandler credits a watched rewarded video and returns the updated wallet. The nonce is
// the client's per-view idempotency key.
func walletRewardHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsWalletRewardRequest(req.Payload, 0)
w, err := backend.WalletReward(ctx, req.UserID, string(in.Nonce()))
if err != nil {
return nil, err
}
return encodeWallet(w), nil
}
}
func blockStatusHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
bs, err := backend.BlockStatus(ctx, req.UserID)
+12
View File
@@ -77,6 +77,7 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk=
@@ -92,6 +93,7 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
@@ -112,6 +114,7 @@ github.com/moby/sys/reexec v0.1.0 h1:RrBi8e0EBTLEgfruBOFcxtElzRGTEUkeIFaVXgU7wok
github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8=
github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
@@ -206,14 +209,23 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v3 v3.17.0/go.mod h1:Sg3fwVpmLvCUTaqEUjiBDAvshIaKDB0RXaf+zgqFu8I=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/ccorpus2 v1.6.0/go.mod h1:Wifvo4Q/qS/h1aRoC2TffcHsnxwTikmi1AuLANuucJQ=
modernc.org/ebnf v1.1.0/go.mod h1:CNIo7vuji3SyjIP/VhEumIKlAguC1g64mcdk/+VJW/w=
modernc.org/ebnfutil v1.1.0/go.mod h1:hdAyhM1jZSq9ygKhEeYgerbagyuLxyxzXcakBPyNqUI=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/lex v1.1.1/go.mod h1:6r8o8DLJkAnOsQaGi8fMoi+Vt6LTbDaCrkUK729D8xM=
modernc.org/lexer v1.0.4/go.mod h1:tOajb8S4sdfOYitzCgXDFmbVJ/LE0v1fNJ7annTw36U=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/scannertest v1.0.2/go.mod h1:RzTm5RwglF/6shsKoEivo8N91nQIoWtcWI7ns+zPyGA=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+9
View File
@@ -866,6 +866,9 @@ table Wallet {
ads_forever:bool;
ads_paid_until_ms:long;
hints:int;
// reward_chips is the chips a rewarded-video view earns in the current context (0 = unavailable
// here — outside VK, or unconfigured); the client shows the "watch for chips" button only when > 0.
reward_chips:int;
}
// WalletBuyRequest buys a chip-priced value with chips: the product to buy.
@@ -873,6 +876,12 @@ table WalletBuyRequest {
product_id:string;
}
// WalletRewardRequest credits a watched rewarded video: nonce is the per-view idempotency key (a
// retry credits once).
table WalletRewardRequest {
nonce:string;
}
// WalletOrderRequest opens a money order to fund a chip pack: the pack to buy.
table WalletOrderRequest {
product_id:string;
+16 -1
View File
@@ -97,8 +97,20 @@ func (rcv *Wallet) MutateHints(n int32) bool {
return rcv._tab.MutateInt32Slot(10, n)
}
func (rcv *Wallet) RewardChips() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *Wallet) MutateRewardChips(n int32) bool {
return rcv._tab.MutateInt32Slot(12, n)
}
func WalletStart(builder *flatbuffers.Builder) {
builder.StartObject(4)
builder.StartObject(5)
}
func WalletAddSegments(builder *flatbuffers.Builder, segments flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(segments), 0)
@@ -115,6 +127,9 @@ func WalletAddAdsPaidUntilMs(builder *flatbuffers.Builder, adsPaidUntilMs int64)
func WalletAddHints(builder *flatbuffers.Builder, hints int32) {
builder.PrependInt32Slot(3, hints, 0)
}
func WalletAddRewardChips(builder *flatbuffers.Builder, rewardChips int32) {
builder.PrependInt32Slot(4, rewardChips, 0)
}
func WalletEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+60
View File
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type WalletRewardRequest struct {
_tab flatbuffers.Table
}
func GetRootAsWalletRewardRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletRewardRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &WalletRewardRequest{}
x.Init(buf, n+offset)
return x
}
func FinishWalletRewardRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsWalletRewardRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletRewardRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &WalletRewardRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedWalletRewardRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *WalletRewardRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *WalletRewardRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *WalletRewardRequest) Nonce() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func WalletRewardRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func WalletRewardRequestAddNonce(builder *flatbuffers.Builder, nonce flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(nonce), 0)
}
func WalletRewardRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+4 -2
View File
@@ -7,7 +7,9 @@
//
// Three independent gates on the natural chunk boundaries, each with realistic headroom:
// - app entry (main): the app's own code; grows with features.
// - shared (svelte+i18n): near-static framework runtime; only drifts on a dep/Svelte bump.
// - shared (svelte+i18n): the framework runtime plus the flat i18n map; drifts on a dep/Svelte
// bump and, slightly, as feature copy adds i18n keys (raised to 31 for
// the rewarded-ad strings).
// - landing own: the landing's own code; kept minimal.
// Today ~74 KB (app entry) + ~23 KB (shared) = ~97 KB for the app; the landing's own chunk is
// ~2 KB. Lazy-loading was analysed and rejected (no total-size win — every chunk still
@@ -32,7 +34,7 @@ const DIST = 'dist';
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 125, shared: 30, landing: 5 };
const BUDGET = { app: 125, shared: 31, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+1
View File
@@ -88,6 +88,7 @@ export { Wallet } from './scrabblefb/wallet.js';
export { WalletBuyRequest } from './scrabblefb/wallet-buy-request.js';
export { WalletOrderRequest } from './scrabblefb/wallet-order-request.js';
export { WalletOrderResponse } from './scrabblefb/wallet-order-response.js';
export { WalletRewardRequest } from './scrabblefb/wallet-reward-request.js';
export { WalletSegment } from './scrabblefb/wallet-segment.js';
export { WordCheckResult } from './scrabblefb/word-check-result.js';
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class WalletRewardRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):WalletRewardRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsWalletRewardRequest(bb:flatbuffers.ByteBuffer, obj?:WalletRewardRequest):WalletRewardRequest {
return (obj || new WalletRewardRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsWalletRewardRequest(bb:flatbuffers.ByteBuffer, obj?:WalletRewardRequest):WalletRewardRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new WalletRewardRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
nonce():string|null
nonce(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
nonce(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startWalletRewardRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addNonce(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, nonceOffset, 0);
}
static endWalletRewardRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createWalletRewardRequest(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset):flatbuffers.Offset {
WalletRewardRequest.startWalletRewardRequest(builder);
WalletRewardRequest.addNonce(builder, nonceOffset);
return WalletRewardRequest.endWalletRewardRequest(builder);
}
}
+12 -2
View File
@@ -48,8 +48,13 @@ hints():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
rewardChips():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startWallet(builder:flatbuffers.Builder) {
builder.startObject(4);
builder.startObject(5);
}
static addSegments(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset) {
@@ -80,17 +85,22 @@ static addHints(builder:flatbuffers.Builder, hints:number) {
builder.addFieldInt32(3, hints, 0);
}
static addRewardChips(builder:flatbuffers.Builder, rewardChips:number) {
builder.addFieldInt32(4, rewardChips, 0);
}
static endWallet(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createWallet(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset, adsForever:boolean, adsPaidUntilMs:bigint, hints:number):flatbuffers.Offset {
static createWallet(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset, adsForever:boolean, adsPaidUntilMs:bigint, hints:number, rewardChips:number):flatbuffers.Offset {
Wallet.startWallet(builder);
Wallet.addSegments(builder, segmentsOffset);
Wallet.addAdsForever(builder, adsForever);
Wallet.addAdsPaidUntilMs(builder, adsPaidUntilMs);
Wallet.addHints(builder, hints);
Wallet.addRewardChips(builder, rewardChips);
return Wallet.endWallet(builder);
}
}
+48
View File
@@ -0,0 +1,48 @@
// The ads-network abstraction (D28). VK is the only network now; a future network for another
// platform implements the same rewardedReady/showRewarded surface without touching the callers. On
// the contour a build flag (VITE_ADS_STUB) substitutes a toast stub for the real ad, so the reward
// flow is testable without waiting for a real ad — production never sets the flag, so a real ad that
// fails to load there must not credit (no stub fallback in prod).
import { executionContext } from './wallet';
import { vkRewardedReady, vkShowRewarded } from './vk';
/**
* adsStubEnabled reports the contour test stub (VITE_ADS_STUB=1). Production never sets it, so it
* always shows real ads. The mock e2e forces it on so the reward flow is exercisable without a VK
* bridge. To capture the real VK ad result on the contour (the diagnostic), deploy with the flag
* off first; flip it on afterwards for routine stub testing.
*/
export function adsStubEnabled(): boolean {
if (import.meta.env.MODE === 'mock') return true;
return (import.meta.env as Record<string, string | undefined>).VITE_ADS_STUB === '1';
}
/** RewardedResult is one rewarded-ad view: whether it was watched, and whether it was the test stub
* (so the caller can show the "ad fired" marker toast). */
export interface RewardedResult {
watched: boolean;
stub: boolean;
}
/**
* rewardedReady reports whether a rewarded ad is ready to show, gating the "watch for chips" button.
* The stub is always ready; a real ad is ready only inside VK with preloaded material. Rewarded is
* VK-only (D28).
*/
export async function rewardedReady(): Promise<boolean> {
if (adsStubEnabled()) return true;
if (executionContext() !== 'vk') return false;
return vkRewardedReady();
}
/**
* showRewarded shows a rewarded ad and reports whether it was watched, the raw provider result, and
* whether it was the stub. On the stub it resolves watched immediately (the caller shows the "ad
* fired" toast).
*/
export async function showRewarded(): Promise<RewardedResult> {
if (adsStubEnabled()) {
return { watched: true, stub: true };
}
return { watched: await vkShowRewarded(), stub: false };
}
+4
View File
@@ -151,6 +151,10 @@ export interface GatewayClient {
/** walletOrder opens a money order to fund a chip pack and returns the provider launch URL the
* client opens; chips are credited later, by the verified server callback. Direct rail only. */
walletOrder(productId: string): Promise<WalletOrder>;
/** walletReward credits a watched rewarded video (VK ads) and returns the updated wallet. nonce is
* the per-view idempotency key. Client-attested + a server daily/hourly cap; a reached cap rejects
* with a domain code. */
walletReward(nonce: string): Promise<Wallet>;
// --- friends ---
friendsList(): Promise<AccountRef[]>;
+10
View File
@@ -15,6 +15,7 @@ import {
decodeOutgoingList,
decodeProfile,
decodeWallet,
encodeWalletReward,
decodeCatalog,
encodeWalletBuy,
encodeWalletOrder,
@@ -66,6 +67,7 @@ describe('codec', () => {
fb.Wallet.addAdsForever(b, false);
fb.Wallet.addAdsPaidUntilMs(b, BigInt(1_700_000_000_000));
fb.Wallet.addHints(b, 5);
fb.Wallet.addRewardChips(b, 7);
b.finish(fb.Wallet.endWallet(b));
const w = decodeWallet(b.asUint8Array());
@@ -73,6 +75,14 @@ describe('codec', () => {
expect(w.adsForever).toBe(false);
expect(w.adsPaidUntilMs).toBe(1_700_000_000_000);
expect(w.hints).toBe(5);
expect(w.rewardChips).toBe(7);
});
it('round-trips the wallet reward request (nonce)', () => {
const req = fb.WalletRewardRequest.getRootAsWalletRewardRequest(
new ByteBuffer(encodeWalletReward('nonce-9')),
);
expect(req.nonce()).toBe('nonce-9');
});
it('round-trips the wallet order request and response', () => {
+11
View File
@@ -398,9 +398,20 @@ export function decodeWallet(buf: Uint8Array): Wallet {
adsForever: w.adsForever(),
adsPaidUntilMs: Number(w.adsPaidUntilMs()),
hints: w.hints(),
rewardChips: w.rewardChips(),
};
}
// encodeWalletReward wraps a watched rewarded video for crediting (POST /user/wallet/reward): the
// per-view idempotency nonce.
export function encodeWalletReward(nonce: string): Uint8Array {
const b = new Builder(64);
const n = b.createString(nonce);
fb.WalletRewardRequest.startWalletRewardRequest(b);
fb.WalletRewardRequest.addNonce(b, n);
return finish(b, fb.WalletRewardRequest.endWalletRewardRequest(b));
}
// decodeWalletOrder reads the created order: its id and the provider launch URL the client opens.
export function decodeWalletOrder(buf: Uint8Array): WalletOrder {
const o = fb.WalletOrderResponse.getRootAsWalletOrderResponse(new ByteBuffer(buf));
+5
View File
@@ -242,6 +242,11 @@ export const en = {
'wallet.store': 'Store',
'wallet.empty': 'The store is empty for now',
'wallet.buy': 'Buy',
'wallet.rewardWatch': 'Watch an ad for {n} 🪙',
'wallet.rewardCta': 'Watch',
'wallet.rewardCredited': '+{n} 🪙 for watching',
'wallet.rewardCapped': "You've reached today's reward limit",
'wallet.rewardFailed': 'The ad could not be shown',
'wallet.offer': 'Public offer',
'wallet.platformNoBuy': 'Purchases are not available on this platform',
'wallet.iosBlockedPre': 'Purchases on iOS are prohibited by Apple policy. Try the ',
+5
View File
@@ -242,6 +242,11 @@ export const ru: Record<MessageKey, string> = {
'wallet.store': 'Магазин',
'wallet.empty': 'Магазин пока пуст',
'wallet.buy': 'Купить',
'wallet.rewardWatch': 'Ролик за {n} 🪙',
'wallet.rewardCta': 'Смотреть',
'wallet.rewardCredited': '+{n} 🪙 за просмотр',
'wallet.rewardCapped': 'Достигнут дневной лимит наград',
'wallet.rewardFailed': 'Не удалось показать ролик',
'wallet.offer': 'Публичная оферта',
'wallet.platformNoBuy': 'На данной платформе покупки невозможны',
'wallet.iosBlockedPre': 'Покупки на iOS запрещены политикой Apple. Попробуйте воспользоваться ',
+9
View File
@@ -126,6 +126,7 @@ export class MockGateway implements GatewayClient {
adsForever: false,
adsPaidUntilMs: 0,
hints: 5,
rewardChips: 5,
};
// The mock storefront: two chip-priced values (one cheap enough to draw direct only, one that
// reaches into vk → the warning) and one RUB-priced chip pack (the direct-context method).
@@ -234,12 +235,20 @@ export class MockGateway implements GatewayClient {
// the provider hand-off without leaving the app.
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId };
}
async walletReward(_nonce: string): Promise<Wallet> {
// Mock rewarded credit: add the configured payout to the vk segment (no cap in the mock).
const vk = this.mockWallet.segments.find((s) => s.source === 'vk');
if (vk) vk.chips += this.mockWallet.rewardChips;
return this.cloneWallet();
}
private cloneWallet(): Wallet {
return {
segments: this.mockWallet.segments.map((s) => ({ ...s })),
adsForever: this.mockWallet.adsForever,
adsPaidUntilMs: this.mockWallet.adsPaidUntilMs,
hints: this.mockWallet.hints,
rewardChips: this.mockWallet.rewardChips,
};
}
async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
+3
View File
@@ -168,6 +168,9 @@ export interface Wallet {
/** No-ads term end as unix milliseconds; 0 = no active term. */
adsPaidUntilMs: number;
hints: number;
/** Chips a rewarded-video view earns in the current context; 0 = unavailable here (outside VK or
* unconfigured). The client shows the "watch for chips" button only when this is positive. */
rewardChips: number;
}
/** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/
+3
View File
@@ -240,6 +240,9 @@ export function createTransport(baseUrl: string): GatewayClient {
async walletOrder(productId: string) {
return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId)));
},
async walletReward(nonce: string) {
return codec.decodeWallet(await exec('wallet.reward', codec.encodeWalletReward(nonce)));
},
async friendsList() {
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
+28
View File
@@ -197,6 +197,34 @@ export async function vkShowOrderBox(item: string): Promise<boolean> {
}
}
/**
* vkRewardedReady reports whether a rewarded ad is preloaded and ready to show
* (VKWebAppCheckNativeAds, required before a rewarded view). A false — or an unavailable method —
* hides the "watch for chips" button, since tapping it would show nothing.
*/
export async function vkRewardedReady(): Promise<boolean> {
try {
const data = await (await bridge()).send('VKWebAppCheckNativeAds', { ad_format: 'reward' });
return !!(data as { result?: boolean }).result;
} catch {
return false;
}
}
/**
* vkShowRewarded shows a rewarded ad (VKWebAppShowNativeAds) and reports whether it was watched
* (data.result — VK returns only this boolean, no server-verifiable token). A rejected call reports
* not-watched.
*/
export async function vkShowRewarded(): Promise<boolean> {
try {
const data = await (await bridge()).send('VKWebAppShowNativeAds', { ad_format: 'reward' });
return !!(data as { result?: boolean }).result;
} catch {
return false;
}
}
/**
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
+1 -1
View File
@@ -7,7 +7,7 @@ function seg(source: string, chips: number, spendable = true): WalletSegment {
}
function wallet(segments: WalletSegment[]): Wallet {
return { segments, adsForever: false, adsPaidUntilMs: 0, hints: 0 };
return { segments, adsForever: false, adsPaidUntilMs: 0, hints: 0, rewardChips: 0 };
}
describe('money formatting', () => {
+48
View File
@@ -10,6 +10,8 @@
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
import { telegramOpenInvoice } from '../lib/telegram';
import { rewardedReady, showRewarded } from '../lib/ads';
import { GatewayError } from '../lib/client';
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
@@ -41,6 +43,41 @@
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
// Rewarded video (VK ads): the "watch for chips" CTA shows only when the server offers a payout in
// this context (wallet.rewardChips > 0 — VK, configured) and an ad is ready to show.
let rewardReady = $state(false);
const canReward = $derived(!!wallet && wallet.rewardChips > 0 && rewardReady);
// onWatchAd shows a rewarded ad and, if watched, credits the payout server-side (client-attested +
// a server daily/hourly cap). A stub view (contour test) shows an "ad fired" marker; a reached cap
// is surfaced as its own toast, distinct from a generic error.
async function onWatchAd() {
if (busy) return;
busyId = 'reward';
try {
const res = await showRewarded();
if (res.stub) showToast('ad fired');
if (!res.watched) {
showToast(t('wallet.rewardFailed'));
return;
}
const nonce = crypto.randomUUID();
const before = wallet?.segments.find((s) => s.source === 'vk')?.chips ?? 0;
const w = await gateway.walletReward(nonce);
wallet = w;
const gained = (w.segments.find((s) => s.source === 'vk')?.chips ?? 0) - before;
showToast(gained > 0 ? t('wallet.rewardCredited', { n: gained }) : t('wallet.rewardCredited', { n: w.rewardChips }));
} catch (e) {
if (e instanceof GatewayError && e.code === 'reward_capped') {
showToast(t('wallet.rewardCapped'));
} else {
handleError(e);
}
} finally {
busyId = null;
}
}
async function load() {
try {
const [w, c] = await Promise.all([gateway.wallet(), gateway.catalog()]);
@@ -54,6 +91,9 @@
}
onMount(() => {
void load();
// Probe rewarded-ad readiness once so the "watch for chips" button only shows when a view would
// actually play.
void rewardedReady().then((r) => (rewardReady = r));
// Fallback to the payment push: re-fetch when the tab regains focus, i.e. the user returned
// from the provider's payment window.
const onVisible = () => {
@@ -171,6 +211,14 @@
<!-- prettier-ignore -->
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
{/if}
{#if canReward}
<div class="row reward" data-testid="reward-row">
<span class="name">{t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })}</span>
<button class="buy" class:working={busyId === 'reward'} data-testid="watch-ad" disabled={busy} onclick={onWatchAd}
>{t('wallet.rewardCta')}</button
>
</div>
{/if}
{#each values as p (p.productId)}
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
<span class="name">{p.title}</span>