From dbd76d53e8a4e58b4770974587689d84f406d758 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 00:41:42 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(ads):=20rewarded=20video=20(VK)=20?= =?UTF-8?q?=E2=80=94=20client-attested=20credit=20+=20daily/hourly=20caps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads (VKWebAppShowNativeAds) expose only a client-side watch result — no server-to-server verify — so the credit is client-attested, guarded by a server daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10). The caps are both anti-abuse (bounding a forger who skips the ad and calls the endpoint directly) and an economic conversion lever (limiting free chips so a player who wants more buys). D29 amended to VK's reality. 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 (reward_chips gates the "watch for chips" CTA). Additive migration (two config columns). Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge (vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub (VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary diagnostic that logs the raw VK data, so we confirm on the contour exactly what VK returns (harden to signature-verify if it carries one). Tests: backend integration (credit, nonce idempotency, hourly cap, disabled, non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend, PLAN E6. Bundle: shared budget 30->31 (reward i18n strings). --- .gitea/workflows/ci.yaml | 3 + PLAN.md | 22 +++- .../internal/inttest/payments_intake_test.go | 94 ++++++++++++++++ backend/internal/payments/service_intake.go | 30 +++++ backend/internal/payments/store_intake.go | 103 ++++++++++++++++++ .../postgres/jet/payments/model/config.go | 2 + .../postgres/jet/payments/table/config.go | 12 +- .../migrations/00013_reward_daily_cap.sql | 22 ++++ backend/internal/server/handlers.go | 2 + backend/internal/server/handlers_wallet.go | 78 ++++++++++++- deploy/docker-compose.yml | 3 + docs/PAYMENTS.md | 14 ++- docs/PAYMENTS_DECISIONS_ru.md | 8 +- docs/PAYMENTS_ru.md | 14 ++- gateway/Dockerfile | 6 +- gateway/internal/backendclient/api.go | 19 +++- gateway/internal/transcode/encode.go | 1 + gateway/internal/transcode/transcode.go | 15 +++ go.work.sum | 12 ++ pkg/fbs/scrabble.fbs | 10 ++ pkg/fbs/scrabblefb/Wallet.go | 17 ++- pkg/fbs/scrabblefb/WalletRewardRequest.go | 71 ++++++++++++ ui/scripts/bundle-size.mjs | 6 +- ui/src/gen/fbs/scrabblefb.ts | 1 + .../fbs/scrabblefb/wallet-reward-request.ts | 60 ++++++++++ ui/src/gen/fbs/scrabblefb/wallet.ts | 14 ++- ui/src/lib/ads.ts | 51 +++++++++ ui/src/lib/client.ts | 4 + ui/src/lib/codec.test.ts | 11 ++ ui/src/lib/codec.ts | 13 +++ ui/src/lib/i18n/en.ts | 5 + ui/src/lib/i18n/ru.ts | 5 + ui/src/lib/mock/client.ts | 9 ++ ui/src/lib/model.ts | 3 + ui/src/lib/transport.ts | 3 + ui/src/lib/vk.ts | 29 +++++ ui/src/lib/wallet.test.ts | 2 +- ui/src/screens/Wallet.svelte | 48 ++++++++ 38 files changed, 798 insertions(+), 24 deletions(-) create mode 100644 backend/internal/postgres/migrations/00013_reward_daily_cap.sql create mode 100644 pkg/fbs/scrabblefb/WalletRewardRequest.go create mode 100644 ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts create mode 100644 ui/src/lib/ads.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 3b97e91..72d5f62 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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 }} diff --git a/PLAN.md b/PLAN.md index 8fc8e53..afd6114 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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,27 @@ 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 logs the raw VK `data` so we +confirm on the contour exactly what VK returns (harden to signature-verify if it carries one). 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. diff --git a/backend/internal/inttest/payments_intake_test.go b/backend/internal/inttest/payments_intake_test.go index 7eeaafc..6929db5 100644 --- a/backend/internal/inttest/payments_intake_test.go +++ b/backend/internal/inttest/payments_intake_test.go @@ -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) { diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index df18165..93a58ce 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -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). diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go index ff15680..659254e 100644 --- a/backend/internal/payments/store_intake.go +++ b/backend/internal/payments/store_intake.go @@ -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 { diff --git a/backend/internal/postgres/jet/payments/model/config.go b/backend/internal/postgres/jet/payments/model/config.go index 55957f4..aa2ea28 100644 --- a/backend/internal/postgres/jet/payments/model/config.go +++ b/backend/internal/postgres/jet/payments/model/config.go @@ -14,4 +14,6 @@ type Config struct { CooldownVsAiSeconds int32 CooldownHintSeconds int32 OrderTTLSeconds int32 + RewardDailyCap int32 + RewardHourlyCap int32 } diff --git a/backend/internal/postgres/jet/payments/table/config.go b/backend/internal/postgres/jet/payments/table/config.go index 35682d4..af9a037 100644 --- a/backend/internal/postgres/jet/payments/table/config.go +++ b/backend/internal/postgres/jet/payments/table/config.go @@ -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, diff --git a/backend/internal/postgres/migrations/00013_reward_daily_cap.sql b/backend/internal/postgres/migrations/00013_reward_daily_cap.sql new file mode 100644 index 0000000..f9fde77 --- /dev/null +++ b/backend/internal/postgres/migrations/00013_reward_daily_cap.sql @@ -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; diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 693fabb..2d77715 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -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 diff --git a/backend/internal/server/handlers_wallet.go b/backend/internal/server/handlers_wallet.go index 518e18b..0f4ba9e 100644 --- a/backend/internal/server/handlers_wallet.go +++ b/backend/internal/server/handlers_wallet.go @@ -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,65 @@ 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) and Diag, the raw VK ad result forwarded for +// a temporary diagnostic log while we confirm exactly what VK returns on the contour. +type walletRewardRequest struct { + Nonce string `json:"nonce"` + Diag string `json:"diag"` +} + +// 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 + } + // Temporary diagnostic: log the raw VK ad result (bounded — untrusted client input) to learn its + // exact shape on the contour; if it carries a verifiable token we harden past client-attested. + if req.Diag != "" { + diag := req.Diag + if len(diag) > 2000 { + diag = diag[:2000] + } + s.log.Info("rewarded ad diagnostic", zap.String("account", uid.String()), zap.String("vk_data", diag)) + } + 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) +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a4c7d79..fe508ba 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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 diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 560cdf9..572e5dd 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -266,9 +266,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: diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index 784f58d..4fa0775 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -158,9 +158,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 мин** (соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index f917236..897a960 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -266,10 +266,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`) подменяет ролик тостом; прод всегда крутит настоящую рекламу. **Полноэкранный ролик** (после хода), конфигурируемые серверные значения: diff --git a/gateway/Dockerfile b/gateway/Dockerfile index aaf0a12..c919cc7 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -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. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index ca83a1d..3c2287c 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -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,21 @@ type walletBuyBody struct { ProductID string `json:"product_id"` } +// walletRewardBody is the rewarded-video credit request: the per-view idempotency nonce and the raw +// VK ad result forwarded for the temporary contour diagnostic. +type walletRewardBody struct { + Nonce string `json:"nonce"` + Diag string `json:"diag"` +} + +// WalletReward credits a watched rewarded video and returns the updated wallet (client-attested + a +// backend daily cap). A reached cap or unconfigured payout surfaces as an APIError domain code. +func (c *Client) WalletReward(ctx context.Context, userID, nonce, diag string) (WalletResp, error) { + var out WalletResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/reward", userID, "", walletRewardBody{Nonce: nonce, Diag: diag}, &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 diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index a836722..858c68a 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -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() } diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 452b374..0e3903d 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -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; diag carries the raw VK ad result for the contour diagnostic. +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()), string(in.Diag())) + 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) diff --git a/go.work.sum b/go.work.sum index 1d39efb..400f6c3 100644 --- a/go.work.sum +++ b/go.work.sum @@ -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= diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 196d2ce..c8f458b 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -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,13 @@ table WalletBuyRequest { product_id:string; } +// WalletRewardRequest credits a watched rewarded video: nonce is the per-view idempotency key (a +// retry credits once); diag carries the raw VK ad result for a temporary contour diagnostic log. +table WalletRewardRequest { + nonce:string; + diag:string; +} + // WalletOrderRequest opens a money order to fund a chip pack: the pack to buy. table WalletOrderRequest { product_id:string; diff --git a/pkg/fbs/scrabblefb/Wallet.go b/pkg/fbs/scrabblefb/Wallet.go index 258e348..383c705 100644 --- a/pkg/fbs/scrabblefb/Wallet.go +++ b/pkg/fbs/scrabblefb/Wallet.go @@ -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() } diff --git a/pkg/fbs/scrabblefb/WalletRewardRequest.go b/pkg/fbs/scrabblefb/WalletRewardRequest.go new file mode 100644 index 0000000..32c2a8e --- /dev/null +++ b/pkg/fbs/scrabblefb/WalletRewardRequest.go @@ -0,0 +1,71 @@ +// 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 (rcv *WalletRewardRequest) Diag() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func WalletRewardRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func WalletRewardRequestAddNonce(builder *flatbuffers.Builder, nonce flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(nonce), 0) +} +func WalletRewardRequestAddDiag(builder *flatbuffers.Builder, diag flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(diag), 0) +} +func WalletRewardRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 6e9f2a9..f10beb8 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -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. diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index c6dff38..7633964 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -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'; diff --git a/ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts b/ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts new file mode 100644 index 0000000..4e1d9cf --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts @@ -0,0 +1,60 @@ +// 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; +} + +diag():string|null +diag(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +diag(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startWalletRewardRequest(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addNonce(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, nonceOffset, 0); +} + +static addDiag(builder:flatbuffers.Builder, diagOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, diagOffset, 0); +} + +static endWalletRewardRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createWalletRewardRequest(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset, diagOffset:flatbuffers.Offset):flatbuffers.Offset { + WalletRewardRequest.startWalletRewardRequest(builder); + WalletRewardRequest.addNonce(builder, nonceOffset); + WalletRewardRequest.addDiag(builder, diagOffset); + return WalletRewardRequest.endWalletRewardRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/wallet.ts b/ui/src/gen/fbs/scrabblefb/wallet.ts index 0e3a73b..e5a18fb 100644 --- a/ui/src/gen/fbs/scrabblefb/wallet.ts +++ b/ui/src/gen/fbs/scrabblefb/wallet.ts @@ -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); } } diff --git a/ui/src/lib/ads.ts b/ui/src/lib/ads.ts new file mode 100644 index 0000000..573d76c --- /dev/null +++ b/ui/src/lib/ads.ts @@ -0,0 +1,51 @@ +// 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).VITE_ADS_STUB === '1'; +} + +/** RewardedResult is one rewarded-ad view: whether it was watched, the raw provider result as JSON + * (forwarded for the contour diagnostic), and whether it was the test stub (so the caller can show + * the "ad fired" marker toast). */ +export interface RewardedResult { + watched: boolean; + data: string; + 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 { + 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 { + if (adsStubEnabled()) { + return { watched: true, data: JSON.stringify({ stub: true }), stub: true }; + } + const r = await vkShowRewarded(); + return { watched: r.watched, data: r.data, stub: false }; +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 009a994..c999a03 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -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; + /** walletReward credits a watched rewarded video (VK ads) and returns the updated wallet. nonce is + * the per-view idempotency key; diag carries the raw VK ad result for the contour diagnostic. + * Client-attested + a server daily/hourly cap; a reached cap rejects with a domain code. */ + walletReward(nonce: string, diag: string): Promise; // --- friends --- friendsList(): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 127f8bf..9dcc6e6 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -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,15 @@ 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 + diag)', () => { + const req = fb.WalletRewardRequest.getRootAsWalletRewardRequest( + new ByteBuffer(encodeWalletReward('nonce-9', '{"result":true}')), + ); + expect(req.nonce()).toBe('nonce-9'); + expect(req.diag()).toBe('{"result":true}'); }); it('round-trips the wallet order request and response', () => { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 709ec03..801e493 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -398,9 +398,22 @@ 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 and the raw VK ad result forwarded for the contour diagnostic. +export function encodeWalletReward(nonce: string, diag: string): Uint8Array { + const b = new Builder(64); + const n = b.createString(nonce); + const d = b.createString(diag); + fb.WalletRewardRequest.startWalletRewardRequest(b); + fb.WalletRewardRequest.addNonce(b, n); + fb.WalletRewardRequest.addDiag(b, d); + 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)); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index bde3ce2..ad9f10d 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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 ', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 89ed89c..75dec54 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -242,6 +242,11 @@ export const ru: Record = { '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. Попробуйте воспользоваться ', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 2314208..5622a7d 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -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, _diag: string): Promise { + // 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 { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index d71a9d7..2d02a8c 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -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"/ diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 7a7e5d2..9fe973b 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -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, diag: string) { + return codec.decodeWallet(await exec('wallet.reward', codec.encodeWalletReward(nonce, diag))); + }, async friendsList() { return codec.decodeFriendList(await exec('friends.list', codec.empty())); diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 1d9647c..6a0ab2f 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -197,6 +197,35 @@ export async function vkShowOrderBox(item: string): Promise { } } +/** + * 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 { + 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) plus the full raw provider result as JSON — forwarded to the backend for the + * temporary contour diagnostic (to confirm exactly what VK returns). A rejected call reports + * not-watched with the error captured. + */ +export async function vkShowRewarded(): Promise<{ watched: boolean; data: string }> { + try { + const data = await (await bridge()).send('VKWebAppShowNativeAds', { ad_format: 'reward' }); + return { watched: !!(data as { result?: boolean }).result, data: JSON.stringify(data) }; + } catch (e) { + return { watched: false, data: JSON.stringify({ error: String(e) }) }; + } +} + /** * vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) — * the mobile in-app file delivery, where the webview ignores . Resolves false diff --git a/ui/src/lib/wallet.test.ts b/ui/src/lib/wallet.test.ts index 5c4e0a6..c958be6 100644 --- a/ui/src/lib/wallet.test.ts +++ b/ui/src/lib/wallet.test.ts @@ -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', () => { diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index bded17e..57842bd 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -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, res.data); + 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 @@

{t('wallet.iosBlockedPre')}{t('wallet.iosBlockedLink')}{t('wallet.iosBlockedPost')}

{/if} + {#if canReward} +
+ {t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })} + +
+ {/if} {#each values as p (p.productId)}
{p.title} From 9acf6ab3b478e101664d83c08365dbdc544b287a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 01:27:26 +0200 Subject: [PATCH 2/2] fix(payments): VK-iOS freeze is purchase-only; remove the rewarded diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- PLAN.md | 10 +++++--- backend/internal/payments/gate.go | 23 +++++++++++-------- backend/internal/payments/gate_test.go | 7 +++--- .../internal/payments/service_intake_test.go | 2 +- backend/internal/payments/wallet_test.go | 7 +++--- backend/internal/server/handlers_wallet.go | 15 ++---------- docs/PAYMENTS.md | 10 ++++---- docs/PAYMENTS_DECISIONS_ru.md | 5 ++++ docs/PAYMENTS_ru.md | 10 ++++---- gateway/internal/backendclient/api.go | 10 ++++---- gateway/internal/transcode/transcode.go | 4 ++-- pkg/fbs/scrabble.fbs | 3 +-- pkg/fbs/scrabblefb/WalletRewardRequest.go | 13 +---------- .../fbs/scrabblefb/wallet-reward-request.ts | 16 ++----------- ui/src/lib/ads.ts | 11 ++++----- ui/src/lib/client.ts | 6 ++--- ui/src/lib/codec.test.ts | 5 ++-- ui/src/lib/codec.ts | 6 ++--- ui/src/lib/mock/client.ts | 2 +- ui/src/lib/transport.ts | 4 ++-- ui/src/lib/vk.ts | 13 +++++------ ui/src/screens/Wallet.svelte | 2 +- 22 files changed, 79 insertions(+), 105 deletions(-) diff --git a/PLAN.md b/PLAN.md index afd6114..bf7c203 100644 --- a/PLAN.md +++ b/PLAN.md @@ -635,9 +635,13 @@ impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the backend `Credi 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 logs the raw VK `data` so we -confirm on the contour exactly what VK returns (harden to signature-verify if it carries one). The -legacy `paid_account` / `hint_balance` drop (D31) and the **interstitial** are the next slice. +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 diff --git a/backend/internal/payments/gate.go b/backend/internal/payments/gate.go index a136536..a3965a0 100644 --- a/backend/internal/payments/gate.go +++ b/backend/internal/payments/gate.go @@ -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 diff --git a/backend/internal/payments/gate_test.go b/backend/internal/payments/gate_test.go index 304276b..1899469 100644 --- a/backend/internal/payments/gate_test.go +++ b/backend/internal/payments/gate_test.go @@ -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}}, diff --git a/backend/internal/payments/service_intake_test.go b/backend/internal/payments/service_intake_test.go index 11159b1..a1252a6 100644 --- a/backend/internal/payments/service_intake_test.go +++ b/backend/internal/payments/service_intake_test.go @@ -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 { diff --git a/backend/internal/payments/wallet_test.go b/backend/internal/payments/wallet_test.go index 8fc9aaf..e0e7150 100644 --- a/backend/internal/payments/wallet_test.go +++ b/backend/internal/payments/wallet_test.go @@ -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) } } diff --git a/backend/internal/server/handlers_wallet.go b/backend/internal/server/handlers_wallet.go index 0f4ba9e..89f68e7 100644 --- a/backend/internal/server/handlers_wallet.go +++ b/backend/internal/server/handlers_wallet.go @@ -175,12 +175,10 @@ 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) and Diag, the raw VK ad result forwarded for -// a temporary diagnostic log while we confirm exactly what VK returns on the contour. +// 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"` - Diag string `json:"diag"` } // handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and @@ -203,15 +201,6 @@ func (s *Server) handleWalletReward(c *gin.Context) { s.abortErr(c, err) return } - // Temporary diagnostic: log the raw VK ad result (bounded — untrusted client input) to learn its - // exact shape on the contour; if it carries a verifiable token we harden past client-attested. - if req.Diag != "" { - diag := req.Diag - if len(diag) > 2000 { - diag = diag[:2000] - } - s.log.Info("rewarded ad diagnostic", zap.String("account", uid.String()), zap.String("vk_data", diag)) - } outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce) if err != nil { s.abortErr(c, err) diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 572e5dd..222e08d 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -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 diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index 4fa0775..c808915 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -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, только просмотр. diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 897a960..f7c73ee 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -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 сервер активирует только одноимённый сегмент. Держится на diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 3c2287c..28b929b 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -384,18 +384,16 @@ type walletBuyBody struct { ProductID string `json:"product_id"` } -// walletRewardBody is the rewarded-video credit request: the per-view idempotency nonce and the raw -// VK ad result forwarded for the temporary contour diagnostic. +// walletRewardBody is the rewarded-video credit request: the per-view idempotency nonce. type walletRewardBody struct { Nonce string `json:"nonce"` - Diag string `json:"diag"` } // WalletReward credits a watched rewarded video and returns the updated wallet (client-attested + a -// backend daily cap). A reached cap or unconfigured payout surfaces as an APIError domain code. -func (c *Client) WalletReward(ctx context.Context, userID, nonce, diag string) (WalletResp, error) { +// 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, Diag: diag}, &out) + err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/reward", userID, "", walletRewardBody{Nonce: nonce}, &out) return out, err } diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 0e3903d..e3c60e4 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -382,11 +382,11 @@ 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; diag carries the raw VK ad result for the contour diagnostic. +// 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()), string(in.Diag())) + w, err := backend.WalletReward(ctx, req.UserID, string(in.Nonce())) if err != nil { return nil, err } diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index c8f458b..124cfb8 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -877,10 +877,9 @@ table WalletBuyRequest { } // WalletRewardRequest credits a watched rewarded video: nonce is the per-view idempotency key (a -// retry credits once); diag carries the raw VK ad result for a temporary contour diagnostic log. +// retry credits once). table WalletRewardRequest { nonce:string; - diag:string; } // WalletOrderRequest opens a money order to fund a chip pack: the pack to buy. diff --git a/pkg/fbs/scrabblefb/WalletRewardRequest.go b/pkg/fbs/scrabblefb/WalletRewardRequest.go index 32c2a8e..2694dde 100644 --- a/pkg/fbs/scrabblefb/WalletRewardRequest.go +++ b/pkg/fbs/scrabblefb/WalletRewardRequest.go @@ -49,23 +49,12 @@ func (rcv *WalletRewardRequest) Nonce() []byte { return nil } -func (rcv *WalletRewardRequest) Diag() []byte { - o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) - if o != 0 { - return rcv._tab.ByteVector(o + rcv._tab.Pos) - } - return nil -} - func WalletRewardRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(2) + builder.StartObject(1) } func WalletRewardRequestAddNonce(builder *flatbuffers.Builder, nonce flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(nonce), 0) } -func WalletRewardRequestAddDiag(builder *flatbuffers.Builder, diag flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(diag), 0) -} func WalletRewardRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts b/ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts index 4e1d9cf..5353be7 100644 --- a/ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts +++ b/ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts @@ -27,34 +27,22 @@ nonce(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } -diag():string|null -diag(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null -diag(optionalEncoding?:any):string|Uint8Array|null { - const offset = this.bb!.__offset(this.bb_pos, 6); - return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; -} - static startWalletRewardRequest(builder:flatbuffers.Builder) { - builder.startObject(2); + builder.startObject(1); } static addNonce(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset) { builder.addFieldOffset(0, nonceOffset, 0); } -static addDiag(builder:flatbuffers.Builder, diagOffset:flatbuffers.Offset) { - builder.addFieldOffset(1, diagOffset, 0); -} - static endWalletRewardRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createWalletRewardRequest(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset, diagOffset:flatbuffers.Offset):flatbuffers.Offset { +static createWalletRewardRequest(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset):flatbuffers.Offset { WalletRewardRequest.startWalletRewardRequest(builder); WalletRewardRequest.addNonce(builder, nonceOffset); - WalletRewardRequest.addDiag(builder, diagOffset); return WalletRewardRequest.endWalletRewardRequest(builder); } } diff --git a/ui/src/lib/ads.ts b/ui/src/lib/ads.ts index 573d76c..f68a442 100644 --- a/ui/src/lib/ads.ts +++ b/ui/src/lib/ads.ts @@ -17,12 +17,10 @@ export function adsStubEnabled(): boolean { return (import.meta.env as Record).VITE_ADS_STUB === '1'; } -/** RewardedResult is one rewarded-ad view: whether it was watched, the raw provider result as JSON - * (forwarded for the contour diagnostic), and whether it was the test stub (so the caller can show - * the "ad fired" marker toast). */ +/** 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; - data: string; stub: boolean; } @@ -44,8 +42,7 @@ export async function rewardedReady(): Promise { */ export async function showRewarded(): Promise { if (adsStubEnabled()) { - return { watched: true, data: JSON.stringify({ stub: true }), stub: true }; + return { watched: true, stub: true }; } - const r = await vkShowRewarded(); - return { watched: r.watched, data: r.data, stub: false }; + return { watched: await vkShowRewarded(), stub: false }; } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index c999a03..a79ba4c 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -152,9 +152,9 @@ export interface GatewayClient { * client opens; chips are credited later, by the verified server callback. Direct rail only. */ walletOrder(productId: string): Promise; /** walletReward credits a watched rewarded video (VK ads) and returns the updated wallet. nonce is - * the per-view idempotency key; diag carries the raw VK ad result for the contour diagnostic. - * Client-attested + a server daily/hourly cap; a reached cap rejects with a domain code. */ - walletReward(nonce: string, diag: string): Promise; + * the per-view idempotency key. Client-attested + a server daily/hourly cap; a reached cap rejects + * with a domain code. */ + walletReward(nonce: string): Promise; // --- friends --- friendsList(): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 9dcc6e6..3bc24df 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -78,12 +78,11 @@ describe('codec', () => { expect(w.rewardChips).toBe(7); }); - it('round-trips the wallet reward request (nonce + diag)', () => { + it('round-trips the wallet reward request (nonce)', () => { const req = fb.WalletRewardRequest.getRootAsWalletRewardRequest( - new ByteBuffer(encodeWalletReward('nonce-9', '{"result":true}')), + new ByteBuffer(encodeWalletReward('nonce-9')), ); expect(req.nonce()).toBe('nonce-9'); - expect(req.diag()).toBe('{"result":true}'); }); it('round-trips the wallet order request and response', () => { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 801e493..f229873 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -403,14 +403,12 @@ export function decodeWallet(buf: Uint8Array): Wallet { } // encodeWalletReward wraps a watched rewarded video for crediting (POST /user/wallet/reward): the -// per-view idempotency nonce and the raw VK ad result forwarded for the contour diagnostic. -export function encodeWalletReward(nonce: string, diag: string): Uint8Array { +// per-view idempotency nonce. +export function encodeWalletReward(nonce: string): Uint8Array { const b = new Builder(64); const n = b.createString(nonce); - const d = b.createString(diag); fb.WalletRewardRequest.startWalletRewardRequest(b); fb.WalletRewardRequest.addNonce(b, n); - fb.WalletRewardRequest.addDiag(b, d); return finish(b, fb.WalletRewardRequest.endWalletRewardRequest(b)); } diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 5622a7d..0e6363c 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -236,7 +236,7 @@ export class MockGateway implements GatewayClient { return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId }; } - async walletReward(_nonce: string, _diag: string): Promise { + async walletReward(_nonce: string): Promise { // 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; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 9fe973b..2c066da 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -240,8 +240,8 @@ export function createTransport(baseUrl: string): GatewayClient { async walletOrder(productId: string) { return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId))); }, - async walletReward(nonce: string, diag: string) { - return codec.decodeWallet(await exec('wallet.reward', codec.encodeWalletReward(nonce, diag))); + async walletReward(nonce: string) { + return codec.decodeWallet(await exec('wallet.reward', codec.encodeWalletReward(nonce))); }, async friendsList() { diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 6a0ab2f..bca6746 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -213,16 +213,15 @@ export async function vkRewardedReady(): Promise { /** * vkShowRewarded shows a rewarded ad (VKWebAppShowNativeAds) and reports whether it was watched - * (data.result) plus the full raw provider result as JSON — forwarded to the backend for the - * temporary contour diagnostic (to confirm exactly what VK returns). A rejected call reports - * not-watched with the error captured. + * (data.result — VK returns only this boolean, no server-verifiable token). A rejected call reports + * not-watched. */ -export async function vkShowRewarded(): Promise<{ watched: boolean; data: string }> { +export async function vkShowRewarded(): Promise { try { const data = await (await bridge()).send('VKWebAppShowNativeAds', { ad_format: 'reward' }); - return { watched: !!(data as { result?: boolean }).result, data: JSON.stringify(data) }; - } catch (e) { - return { watched: false, data: JSON.stringify({ error: String(e) }) }; + return !!(data as { result?: boolean }).result; + } catch { + return false; } } diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 57842bd..332b00a 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -63,7 +63,7 @@ } const nonce = crypto.randomUUID(); const before = wallet?.segments.find((s) => s.source === 'vk')?.chips ?? 0; - const w = await gateway.walletReward(nonce, res.data); + 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 }));