Release v1.14.0 — monetization launch (E5-E8) #237
@@ -407,6 +407,9 @@ jobs:
|
|||||||
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
|
# 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_LINK: ${{ vars.VITE_VK_APP_LINK }}
|
||||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
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
|
# 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.
|
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
|
||||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ status — without re-deriving decisions.
|
|||||||
| E3 | Wallet UI | 1 | DONE |
|
| E3 | Wallet UI | 1 | DONE |
|
||||||
| E4 | Durability (PITR) | 2 | DONE |
|
| E4 | Durability (PITR) | 2 | DONE |
|
||||||
| E5 | Payment intake | 2 | DONE |
|
| E5 | Payment intake | 2 | DONE |
|
||||||
| E6 | Ads | 2 | TODO |
|
| E6 | Ads | 2 | WIP |
|
||||||
| E7 | Admin & reports | 2 | TODO |
|
| E7 | Admin & reports | 2 | TODO |
|
||||||
| E8 | Guest limits | — | TODO |
|
| E8 | Guest limits | — | TODO |
|
||||||
| E9 | Tournament fee | future | TODO |
|
| E9 | Tournament fee | future | TODO |
|
||||||
@@ -618,9 +618,27 @@ force-recreate when the Caddyfile changes.
|
|||||||
|
|
||||||
## E6 — Ads
|
## 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.
|
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
|
**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
|
(credits chips via server verify), plus extending the existing banner suppression to
|
||||||
per-origin.
|
per-origin.
|
||||||
|
|||||||
@@ -240,6 +240,100 @@ func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setRewardConfig sets the rewarded payout and caps on the shared config row, restoring the defaults
|
||||||
|
// after the test (the row is a singleton shared across the sequential suite).
|
||||||
|
func setRewardConfig(t *testing.T, payout, dailyCap, hourlyCap int) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := testDB.ExecContext(context.Background(),
|
||||||
|
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||||
|
payout, dailyCap, hourlyCap); err != nil {
|
||||||
|
t.Fatalf("set reward config: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = testDB.ExecContext(context.Background(),
|
||||||
|
`UPDATE payments.config SET rewarded_payout_chips=0, reward_daily_cap=50, reward_hourly_cap=10`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsRewardCredit exercises the rewarded-video credit: a watched view credits the VK segment
|
||||||
|
// the configured payout, a retried view (same nonce) credits once, and the hourly cap blocks the
|
||||||
|
// third distinct view.
|
||||||
|
func TestPaymentsRewardCredit(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newPaymentsService()
|
||||||
|
acc := uuid.New()
|
||||||
|
setRewardConfig(t, 5, 5, 2) // 5 chips/view, daily 5, hourly 2
|
||||||
|
vk := payments.NewContext("vk", "web")
|
||||||
|
present := []payments.Source{payments.SourceVK}
|
||||||
|
|
||||||
|
out, err := svc.CreditReward(ctx, acc, vk, present, "n1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credit: %v", err)
|
||||||
|
}
|
||||||
|
if out.Chips != 5 || out.Capped {
|
||||||
|
t.Fatalf("reward outcome = %+v, want 5 chips, not capped", out)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "vk"); got != 5 {
|
||||||
|
t.Errorf("vk balance = %d, want 5", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A retried view (same nonce) credits nothing more.
|
||||||
|
out2, err := svc.CreditReward(ctx, acc, vk, present, "n1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retry: %v", err)
|
||||||
|
}
|
||||||
|
if !out2.AlreadyCredited {
|
||||||
|
t.Error("retried view not flagged AlreadyCredited")
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "vk"); got != 5 {
|
||||||
|
t.Errorf("vk balance after retry = %d, want 5 (credited once)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second distinct view credits again (2 total).
|
||||||
|
if _, err := svc.CreditReward(ctx, acc, vk, present, "n2"); err != nil {
|
||||||
|
t.Fatalf("credit 2: %v", err)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "vk"); got != 10 {
|
||||||
|
t.Errorf("vk balance = %d, want 10", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The third distinct view hits the hourly cap (2) — capped, no credit.
|
||||||
|
out3, err := svc.CreditReward(ctx, acc, vk, present, "n3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credit 3: %v", err)
|
||||||
|
}
|
||||||
|
if !out3.Capped {
|
||||||
|
t.Error("third view not capped (hourly cap 2)")
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "vk"); got != 10 {
|
||||||
|
t.Errorf("vk balance after cap = %d, want 10 (capped view credited nothing)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsRewardDisabledAndContext verifies rewarded is inert when unconfigured (0 payout) and
|
||||||
|
// refused outside a VK context (rewarded is VK-only, D28).
|
||||||
|
func TestPaymentsRewardDisabledAndContext(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newPaymentsService()
|
||||||
|
acc := uuid.New()
|
||||||
|
setRewardConfig(t, 0, 50, 10) // payout 0 = disabled
|
||||||
|
|
||||||
|
vk := payments.NewContext("vk", "web")
|
||||||
|
out, err := svc.CreditReward(ctx, acc, vk, []payments.Source{payments.SourceVK}, "d1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credit (disabled): %v", err)
|
||||||
|
}
|
||||||
|
if out.Chips != 0 || out.Capped || out.AlreadyCredited {
|
||||||
|
t.Fatalf("disabled reward outcome = %+v, want 0 chips, not capped", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A direct (non-VK) context is refused — rewarded is VK-only.
|
||||||
|
direct := payments.NewContext("direct", "web")
|
||||||
|
if _, err := svc.CreditReward(ctx, acc, direct, []payments.Source{payments.SourceDirect}, "d2"); !errors.Is(err, payments.ErrUntrusted) {
|
||||||
|
t.Fatalf("direct-context reward = %v, want ErrUntrusted", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
|
// 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).
|
// refused and credits nothing (§9: verify the amount after matching by order id).
|
||||||
func TestPaymentsFundAmountMismatch(t *testing.T) {
|
func TestPaymentsFundAmountMismatch(t *testing.T) {
|
||||||
|
|||||||
@@ -135,6 +135,36 @@ func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, am
|
|||||||
return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
|
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
|
// 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
|
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
|
||||||
// still credits — see Fund).
|
// still credits — see Fund).
|
||||||
|
|||||||
@@ -375,6 +375,96 @@ func (s *Store) refund(ctx context.Context, orderID uuid.UUID, provider, provide
|
|||||||
return outcome, nil
|
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
|
// insertPaymentEvent appends an undispatched lifecycle event (succeeded/failed/refunded) for the
|
||||||
// dispatcher to deliver. orderID and payload (a jsonb detail blob) are optional.
|
// 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 {
|
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
|
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
|
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint violation (SQLSTATE
|
||||||
// 23505) — here, a duplicate provider callback hitting the ledger idempotency index.
|
// 23505) — here, a duplicate provider callback hitting the ledger idempotency index.
|
||||||
func isUniqueViolation(err error) bool {
|
func isUniqueViolation(err error) bool {
|
||||||
|
|||||||
@@ -14,4 +14,6 @@ type Config struct {
|
|||||||
CooldownVsAiSeconds int32
|
CooldownVsAiSeconds int32
|
||||||
CooldownHintSeconds int32
|
CooldownHintSeconds int32
|
||||||
OrderTTLSeconds int32
|
OrderTTLSeconds int32
|
||||||
|
RewardDailyCap int32
|
||||||
|
RewardHourlyCap int32
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type configTable struct {
|
|||||||
CooldownVsAiSeconds postgres.ColumnInteger
|
CooldownVsAiSeconds postgres.ColumnInteger
|
||||||
CooldownHintSeconds postgres.ColumnInteger
|
CooldownHintSeconds postgres.ColumnInteger
|
||||||
OrderTTLSeconds postgres.ColumnInteger
|
OrderTTLSeconds postgres.ColumnInteger
|
||||||
|
RewardDailyCap postgres.ColumnInteger
|
||||||
|
RewardHourlyCap postgres.ColumnInteger
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -70,9 +72,11 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
|||||||
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
|
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
|
||||||
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
|
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
|
||||||
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
|
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
|
||||||
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
RewardDailyCapColumn = postgres.IntegerColumn("reward_daily_cap")
|
||||||
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
RewardHourlyCapColumn = postgres.IntegerColumn("reward_hourly_cap")
|
||||||
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
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{
|
return configTable{
|
||||||
@@ -85,6 +89,8 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
|||||||
CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
|
CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
|
||||||
CooldownHintSeconds: CooldownHintSecondsColumn,
|
CooldownHintSeconds: CooldownHintSecondsColumn,
|
||||||
OrderTTLSeconds: OrderTTLSecondsColumn,
|
OrderTTLSeconds: OrderTTLSecondsColumn,
|
||||||
|
RewardDailyCap: RewardDailyCapColumn,
|
||||||
|
RewardHourlyCap: RewardHourlyCapColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Rewarded-video caps: the anti-abuse ceilings on free rewarded credits per user, per
|
||||||
|
-- day and per hour. VK Mini App ads expose only a client-side watch result (no
|
||||||
|
-- server-to-server verify), so a rewarded credit is client-attested; the caps bound a
|
||||||
|
-- forger who skips the ad and calls the credit endpoint directly (the daily cap bounds the
|
||||||
|
-- total; the hourly cap smooths a burst). Chips-per-view already exists
|
||||||
|
-- (rewarded_payout_chips, default 0 = rewarded inert until the owner sets it). All are
|
||||||
|
-- config, tuned in the admin without a release. Additive columns only — applies forward via
|
||||||
|
-- goose with no data rewrite (no contour wipe), and an image rollback ignores them.
|
||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE payments.config
|
||||||
|
ADD COLUMN reward_daily_cap integer DEFAULT 50 NOT NULL,
|
||||||
|
ADD COLUMN reward_hourly_cap integer DEFAULT 10 NOT NULL;
|
||||||
|
ALTER TABLE payments.config
|
||||||
|
ADD CONSTRAINT config_reward_daily_cap_chk CHECK (reward_daily_cap >= 0),
|
||||||
|
ADD CONSTRAINT config_reward_hourly_cap_chk CHECK (reward_hourly_cap >= 0);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE payments.config
|
||||||
|
DROP COLUMN reward_daily_cap,
|
||||||
|
DROP COLUMN reward_hourly_cap;
|
||||||
@@ -72,6 +72,8 @@ func (s *Server) registerRoutes() {
|
|||||||
u.GET("/wallet", s.handleWallet)
|
u.GET("/wallet", s.handleWallet)
|
||||||
u.GET("/wallet/catalog", s.handleWalletCatalog)
|
u.GET("/wallet/catalog", s.handleWalletCatalog)
|
||||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
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 {
|
if s.payments != nil {
|
||||||
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/payments"
|
"scrabble/backend/internal/payments"
|
||||||
)
|
)
|
||||||
@@ -20,11 +21,15 @@ type walletSegmentDTO struct {
|
|||||||
|
|
||||||
// walletDTO is the user-facing wallet: the context-visible chip segments and the
|
// 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).
|
// 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 {
|
type walletDTO struct {
|
||||||
Segments []walletSegmentDTO `json:"segments"`
|
Segments []walletSegmentDTO `json:"segments"`
|
||||||
AdsForever bool `json:"ads_forever"`
|
AdsForever bool `json:"ads_forever"`
|
||||||
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
|
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
|
||||||
Hints int `json:"hints"`
|
Hints int `json:"hints"`
|
||||||
|
RewardChips int `json:"reward_chips"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// walletBuyRequest is the POST body of a chip spend: the product to buy with 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
|
// 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) {
|
func (s *Server) handleWallet(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -125,7 +131,13 @@ func (s *Server) handleWallet(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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
|
// 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))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -207,6 +207,9 @@ services:
|
|||||||
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
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).
|
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
||||||
VERSION: ${APP_VERSION:-dev}
|
VERSION: ${APP_VERSION:-dev}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+11
-3
@@ -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)
|
network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob)
|
||||||
are rejected — no legal ruble income for a self-employed (НПД) developer.
|
are rejected — no legal ruble income for a self-employed (НПД) developer.
|
||||||
|
|
||||||
**Rewarded** (voluntary video for chips) credits chips through payments on the network's
|
**Rewarded** (voluntary video for chips) credits chips through payments. **VK Mini App ads expose
|
||||||
**server verify callback** (client not believed, like a payment). On-launch anti-fraud is
|
only a client-side watch result** (`VKWebAppShowNativeAds` → `data.result`) — there is no
|
||||||
the provider's verify only (no own daily cap yet; the abstraction allows adding caps later).
|
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:
|
**Interstitial** (post-move fullscreen), configurable server-side values:
|
||||||
|
|
||||||
|
|||||||
@@ -158,9 +158,13 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
закладываем **абстракцией** (будущая крутилка для других платформ встроится без
|
закладываем **абстракцией** (будущая крутилка для других платформ встроится без
|
||||||
переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого
|
переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого
|
||||||
дохода самозанятому (санкции + НПД не учитывает крипту).
|
дохода самозанятому (санкции + НПД не учитывает крипту).
|
||||||
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments по
|
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments. Не
|
||||||
**серверному verify-колбэку** рекламной сети (клиенту не верим, как платёж). Не
|
|
||||||
гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики.
|
гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики.
|
||||||
|
**АМЕНД (E6, по факту реализации):** у VK Mini App серверного verify-колбэка **нет** —
|
||||||
|
`VKWebAppShowNativeAds` отдаёт только клиентский `data.result`. Поэтому начисление
|
||||||
|
**client-attested**, а защита (и экономический рычаг) — **серверный дневной и часовой кап**
|
||||||
|
(config `reward_daily_cap` / `reward_hourly_cap`, дефолт 50 / 10) + идемпотентность по клиентскому
|
||||||
|
nonce. Сеть с серверным verify встроится за ads-абстракцией позже.
|
||||||
- **D30. Частота навязанного interstitial** (конфигурируемые серверные значения):
|
- **D30. Частота навязанного interstitial** (конфигурируемые серверные значения):
|
||||||
глобальный кулдаун **на юзера сквозь все партии**, дефолт **5 мин**; **vs_ai — 30 мин**
|
глобальный кулдаун **на юзера сквозь все партии**, дефолт **5 мин**; **vs_ai — 30 мин**
|
||||||
(соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода
|
(соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода
|
||||||
|
|||||||
+10
-4
@@ -266,10 +266,16 @@ web/native/TG держат только существующий наш **тек
|
|||||||
платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального
|
платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального
|
||||||
рублёвого дохода самозанятому (НПД).
|
рублёвого дохода самозанятому (НПД).
|
||||||
|
|
||||||
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен по
|
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен. **VK Mini
|
||||||
**серверному verify-колбэку** сети (клиенту не верим, как платежу). Антифрод на старте — только
|
App отдаёт только клиентский результат просмотра** (`VKWebAppShowNativeAds` → `data.result`) —
|
||||||
verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты
|
серверной проверки нет — поэтому начисление **client-attested** (D29 амендим: server-verify у VK
|
||||||
позже).
|
невозможен). Защита — **серверный дневной и часовой кап** (config `reward_daily_cap` /
|
||||||
|
`reward_hourly_cap`, дефолт 50 / 10): он ограничивает читера, который пропускает ролик и дёргает
|
||||||
|
эндпоинт напрямую, и — не менее важно — лимитирует бесплатные Фишки, чтобы желающий больше **покупал**.
|
||||||
|
Начисление идемпотентно по клиентскому nonce и order-less; выплата — config
|
||||||
|
(`rewarded_payout_chips`, дефолт 0 = ролик выключен, пока не задан). Rewarded только в VK и не
|
||||||
|
гасится «без рекламы» (D9). Сеть с серверным verify встроится за ads-абстракцией. На тест-контуре
|
||||||
|
build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; прод всегда крутит настоящую рекламу.
|
||||||
|
|
||||||
**Полноэкранный ролик** (после хода), конфигурируемые серверные значения:
|
**Полноэкранный ролик** (после хода), конфигурируемые серверные значения:
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -29,6 +29,9 @@ ARG VITE_VK_APP_ID=
|
|||||||
ARG VITE_VK_ID_REDIRECT_URL=
|
ARG VITE_VK_ID_REDIRECT_URL=
|
||||||
ARG VITE_GATEWAY_URL=
|
ARG VITE_GATEWAY_URL=
|
||||||
ARG VITE_APP_VERSION=
|
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 \
|
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
|
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_APP_ID=$VITE_VK_APP_ID \
|
||||||
VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \
|
VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \
|
||||||
VITE_GATEWAY_URL=$VITE_GATEWAY_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
|
# 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.
|
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
|
||||||
|
|||||||
@@ -369,12 +369,14 @@ type WalletSegmentResp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WalletResp is the caller's wallet: the context-visible chip segments and the context-applicable
|
// 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 {
|
type WalletResp struct {
|
||||||
Segments []WalletSegmentResp `json:"segments"`
|
Segments []WalletSegmentResp `json:"segments"`
|
||||||
AdsForever bool `json:"ads_forever"`
|
AdsForever bool `json:"ads_forever"`
|
||||||
AdsPaidUntil int64 `json:"ads_paid_until_ms"`
|
AdsPaidUntil int64 `json:"ads_paid_until_ms"`
|
||||||
Hints int `json:"hints"`
|
Hints int `json:"hints"`
|
||||||
|
RewardChips int `json:"reward_chips"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// walletBuyBody is the chip-spend request body.
|
// walletBuyBody is the chip-spend request body.
|
||||||
@@ -382,6 +384,21 @@ type walletBuyBody struct {
|
|||||||
ProductID string `json:"product_id"`
|
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.
|
// Wallet fetches the caller's wallet in their current execution context.
|
||||||
func (c *Client) Wallet(ctx context.Context, userID string) (WalletResp, error) {
|
func (c *Client) Wallet(ctx context.Context, userID string) (WalletResp, error) {
|
||||||
var out WalletResp
|
var out WalletResp
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ func encodeWallet(w backendclient.WalletResp) []byte {
|
|||||||
fb.WalletAddAdsForever(b, w.AdsForever)
|
fb.WalletAddAdsForever(b, w.AdsForever)
|
||||||
fb.WalletAddAdsPaidUntilMs(b, w.AdsPaidUntil)
|
fb.WalletAddAdsPaidUntilMs(b, w.AdsPaidUntil)
|
||||||
fb.WalletAddHints(b, int32(w.Hints))
|
fb.WalletAddHints(b, int32(w.Hints))
|
||||||
|
fb.WalletAddRewardChips(b, int32(w.RewardChips))
|
||||||
b.Finish(fb.WalletEnd(b))
|
b.Finish(fb.WalletEnd(b))
|
||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const (
|
|||||||
MsgWalletCatalog = "wallet.catalog"
|
MsgWalletCatalog = "wallet.catalog"
|
||||||
MsgWalletBuy = "wallet.buy"
|
MsgWalletBuy = "wallet.buy"
|
||||||
MsgWalletOrder = "wallet.order"
|
MsgWalletOrder = "wallet.order"
|
||||||
|
MsgWalletReward = "wallet.reward"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request is one decoded Execute call.
|
// 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[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true}
|
||||||
r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true}
|
r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true}
|
||||||
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, nil), 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[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
|
||||||
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
||||||
r.ops[MsgGameState] = Op{Handler: gameStateHandler(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 {
|
func blockStatusHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
bs, err := backend.BlockStatus(ctx, req.UserID)
|
bs, err := backend.BlockStatus(ctx, req.UserID)
|
||||||
|
|||||||
+12
@@ -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/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 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-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/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk=
|
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/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 h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc=
|
||||||
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
|
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/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
|
||||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
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=
|
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/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 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
|
||||||
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
|
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 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
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=
|
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 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
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/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/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/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
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/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/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/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/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/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -866,6 +866,9 @@ table Wallet {
|
|||||||
ads_forever:bool;
|
ads_forever:bool;
|
||||||
ads_paid_until_ms:long;
|
ads_paid_until_ms:long;
|
||||||
hints:int;
|
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.
|
// WalletBuyRequest buys a chip-priced value with chips: the product to buy.
|
||||||
@@ -873,6 +876,13 @@ table WalletBuyRequest {
|
|||||||
product_id:string;
|
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.
|
// WalletOrderRequest opens a money order to fund a chip pack: the pack to buy.
|
||||||
table WalletOrderRequest {
|
table WalletOrderRequest {
|
||||||
product_id:string;
|
product_id:string;
|
||||||
|
|||||||
@@ -97,8 +97,20 @@ func (rcv *Wallet) MutateHints(n int32) bool {
|
|||||||
return rcv._tab.MutateInt32Slot(10, n)
|
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) {
|
func WalletStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(4)
|
builder.StartObject(5)
|
||||||
}
|
}
|
||||||
func WalletAddSegments(builder *flatbuffers.Builder, segments flatbuffers.UOffsetT) {
|
func WalletAddSegments(builder *flatbuffers.Builder, segments flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(segments), 0)
|
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) {
|
func WalletAddHints(builder *flatbuffers.Builder, hints int32) {
|
||||||
builder.PrependInt32Slot(3, hints, 0)
|
builder.PrependInt32Slot(3, hints, 0)
|
||||||
}
|
}
|
||||||
|
func WalletAddRewardChips(builder *flatbuffers.Builder, rewardChips int32) {
|
||||||
|
builder.PrependInt32Slot(4, rewardChips, 0)
|
||||||
|
}
|
||||||
func WalletEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func WalletEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
//
|
//
|
||||||
// Three independent gates on the natural chunk boundaries, each with realistic headroom:
|
// Three independent gates on the natural chunk boundaries, each with realistic headroom:
|
||||||
// - app entry (main): the app's own code; grows with features.
|
// - 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.
|
// - 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
|
// 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
|
// ~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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export { Wallet } from './scrabblefb/wallet.js';
|
|||||||
export { WalletBuyRequest } from './scrabblefb/wallet-buy-request.js';
|
export { WalletBuyRequest } from './scrabblefb/wallet-buy-request.js';
|
||||||
export { WalletOrderRequest } from './scrabblefb/wallet-order-request.js';
|
export { WalletOrderRequest } from './scrabblefb/wallet-order-request.js';
|
||||||
export { WalletOrderResponse } from './scrabblefb/wallet-order-response.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 { WalletSegment } from './scrabblefb/wallet-segment.js';
|
||||||
export { WordCheckResult } from './scrabblefb/word-check-result.js';
|
export { WordCheckResult } from './scrabblefb/word-check-result.js';
|
||||||
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
|
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,8 +48,13 @@ hints():number {
|
|||||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
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) {
|
static startWallet(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(4);
|
builder.startObject(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addSegments(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset) {
|
static addSegments(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset) {
|
||||||
@@ -80,17 +85,22 @@ static addHints(builder:flatbuffers.Builder, hints:number) {
|
|||||||
builder.addFieldInt32(3, hints, 0);
|
builder.addFieldInt32(3, hints, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addRewardChips(builder:flatbuffers.Builder, rewardChips:number) {
|
||||||
|
builder.addFieldInt32(4, rewardChips, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endWallet(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endWallet(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startWallet(builder);
|
||||||
Wallet.addSegments(builder, segmentsOffset);
|
Wallet.addSegments(builder, segmentsOffset);
|
||||||
Wallet.addAdsForever(builder, adsForever);
|
Wallet.addAdsForever(builder, adsForever);
|
||||||
Wallet.addAdsPaidUntilMs(builder, adsPaidUntilMs);
|
Wallet.addAdsPaidUntilMs(builder, adsPaidUntilMs);
|
||||||
Wallet.addHints(builder, hints);
|
Wallet.addHints(builder, hints);
|
||||||
|
Wallet.addRewardChips(builder, rewardChips);
|
||||||
return Wallet.endWallet(builder);
|
return Wallet.endWallet(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string | undefined>).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<boolean> {
|
||||||
|
if (adsStubEnabled()) return true;
|
||||||
|
if (executionContext() !== 'vk') return false;
|
||||||
|
return vkRewardedReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* showRewarded shows a rewarded ad and reports whether it was watched, the raw provider result, and
|
||||||
|
* whether it was the stub. On the stub it resolves watched immediately (the caller shows the "ad
|
||||||
|
* fired" toast).
|
||||||
|
*/
|
||||||
|
export async function showRewarded(): Promise<RewardedResult> {
|
||||||
|
if (adsStubEnabled()) {
|
||||||
|
return { watched: true, data: JSON.stringify({ stub: true }), stub: true };
|
||||||
|
}
|
||||||
|
const r = await vkShowRewarded();
|
||||||
|
return { watched: r.watched, data: r.data, stub: false };
|
||||||
|
}
|
||||||
@@ -151,6 +151,10 @@ export interface GatewayClient {
|
|||||||
/** walletOrder opens a money order to fund a chip pack and returns the provider launch URL the
|
/** 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. */
|
* client opens; chips are credited later, by the verified server callback. Direct rail only. */
|
||||||
walletOrder(productId: string): Promise<WalletOrder>;
|
walletOrder(productId: string): Promise<WalletOrder>;
|
||||||
|
/** walletReward credits a watched rewarded video (VK ads) and returns the updated wallet. nonce is
|
||||||
|
* the per-view idempotency key; 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<Wallet>;
|
||||||
|
|
||||||
// --- friends ---
|
// --- friends ---
|
||||||
friendsList(): Promise<AccountRef[]>;
|
friendsList(): Promise<AccountRef[]>;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
decodeOutgoingList,
|
decodeOutgoingList,
|
||||||
decodeProfile,
|
decodeProfile,
|
||||||
decodeWallet,
|
decodeWallet,
|
||||||
|
encodeWalletReward,
|
||||||
decodeCatalog,
|
decodeCatalog,
|
||||||
encodeWalletBuy,
|
encodeWalletBuy,
|
||||||
encodeWalletOrder,
|
encodeWalletOrder,
|
||||||
@@ -66,6 +67,7 @@ describe('codec', () => {
|
|||||||
fb.Wallet.addAdsForever(b, false);
|
fb.Wallet.addAdsForever(b, false);
|
||||||
fb.Wallet.addAdsPaidUntilMs(b, BigInt(1_700_000_000_000));
|
fb.Wallet.addAdsPaidUntilMs(b, BigInt(1_700_000_000_000));
|
||||||
fb.Wallet.addHints(b, 5);
|
fb.Wallet.addHints(b, 5);
|
||||||
|
fb.Wallet.addRewardChips(b, 7);
|
||||||
b.finish(fb.Wallet.endWallet(b));
|
b.finish(fb.Wallet.endWallet(b));
|
||||||
|
|
||||||
const w = decodeWallet(b.asUint8Array());
|
const w = decodeWallet(b.asUint8Array());
|
||||||
@@ -73,6 +75,15 @@ describe('codec', () => {
|
|||||||
expect(w.adsForever).toBe(false);
|
expect(w.adsForever).toBe(false);
|
||||||
expect(w.adsPaidUntilMs).toBe(1_700_000_000_000);
|
expect(w.adsPaidUntilMs).toBe(1_700_000_000_000);
|
||||||
expect(w.hints).toBe(5);
|
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', () => {
|
it('round-trips the wallet order request and response', () => {
|
||||||
|
|||||||
@@ -398,9 +398,22 @@ export function decodeWallet(buf: Uint8Array): Wallet {
|
|||||||
adsForever: w.adsForever(),
|
adsForever: w.adsForever(),
|
||||||
adsPaidUntilMs: Number(w.adsPaidUntilMs()),
|
adsPaidUntilMs: Number(w.adsPaidUntilMs()),
|
||||||
hints: w.hints(),
|
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.
|
// decodeWalletOrder reads the created order: its id and the provider launch URL the client opens.
|
||||||
export function decodeWalletOrder(buf: Uint8Array): WalletOrder {
|
export function decodeWalletOrder(buf: Uint8Array): WalletOrder {
|
||||||
const o = fb.WalletOrderResponse.getRootAsWalletOrderResponse(new ByteBuffer(buf));
|
const o = fb.WalletOrderResponse.getRootAsWalletOrderResponse(new ByteBuffer(buf));
|
||||||
|
|||||||
@@ -242,6 +242,11 @@ export const en = {
|
|||||||
'wallet.store': 'Store',
|
'wallet.store': 'Store',
|
||||||
'wallet.empty': 'The store is empty for now',
|
'wallet.empty': 'The store is empty for now',
|
||||||
'wallet.buy': 'Buy',
|
'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.offer': 'Public offer',
|
||||||
'wallet.platformNoBuy': 'Purchases are not available on this platform',
|
'wallet.platformNoBuy': 'Purchases are not available on this platform',
|
||||||
'wallet.iosBlockedPre': 'Purchases on iOS are prohibited by Apple policy. Try the ',
|
'wallet.iosBlockedPre': 'Purchases on iOS are prohibited by Apple policy. Try the ',
|
||||||
|
|||||||
@@ -242,6 +242,11 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'wallet.store': 'Магазин',
|
'wallet.store': 'Магазин',
|
||||||
'wallet.empty': 'Магазин пока пуст',
|
'wallet.empty': 'Магазин пока пуст',
|
||||||
'wallet.buy': 'Купить',
|
'wallet.buy': 'Купить',
|
||||||
|
'wallet.rewardWatch': 'Ролик за {n} 🪙',
|
||||||
|
'wallet.rewardCta': 'Смотреть',
|
||||||
|
'wallet.rewardCredited': '+{n} 🪙 за просмотр',
|
||||||
|
'wallet.rewardCapped': 'Достигнут дневной лимит наград',
|
||||||
|
'wallet.rewardFailed': 'Не удалось показать ролик',
|
||||||
'wallet.offer': 'Публичная оферта',
|
'wallet.offer': 'Публичная оферта',
|
||||||
'wallet.platformNoBuy': 'На данной платформе покупки невозможны',
|
'wallet.platformNoBuy': 'На данной платформе покупки невозможны',
|
||||||
'wallet.iosBlockedPre': 'Покупки на iOS запрещены политикой Apple. Попробуйте воспользоваться ',
|
'wallet.iosBlockedPre': 'Покупки на iOS запрещены политикой Apple. Попробуйте воспользоваться ',
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
adsForever: false,
|
adsForever: false,
|
||||||
adsPaidUntilMs: 0,
|
adsPaidUntilMs: 0,
|
||||||
hints: 5,
|
hints: 5,
|
||||||
|
rewardChips: 5,
|
||||||
};
|
};
|
||||||
// The mock storefront: two chip-priced values (one cheap enough to draw direct only, one that
|
// 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).
|
// 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.
|
// the provider hand-off without leaving the app.
|
||||||
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId };
|
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async walletReward(_nonce: string, _diag: string): Promise<Wallet> {
|
||||||
|
// Mock rewarded credit: add the configured payout to the vk segment (no cap in the mock).
|
||||||
|
const vk = this.mockWallet.segments.find((s) => s.source === 'vk');
|
||||||
|
if (vk) vk.chips += this.mockWallet.rewardChips;
|
||||||
|
return this.cloneWallet();
|
||||||
|
}
|
||||||
private cloneWallet(): Wallet {
|
private cloneWallet(): Wallet {
|
||||||
return {
|
return {
|
||||||
segments: this.mockWallet.segments.map((s) => ({ ...s })),
|
segments: this.mockWallet.segments.map((s) => ({ ...s })),
|
||||||
adsForever: this.mockWallet.adsForever,
|
adsForever: this.mockWallet.adsForever,
|
||||||
adsPaidUntilMs: this.mockWallet.adsPaidUntilMs,
|
adsPaidUntilMs: this.mockWallet.adsPaidUntilMs,
|
||||||
hints: this.mockWallet.hints,
|
hints: this.mockWallet.hints,
|
||||||
|
rewardChips: this.mockWallet.rewardChips,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
|
async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ export interface Wallet {
|
|||||||
/** No-ads term end as unix milliseconds; 0 = no active term. */
|
/** No-ads term end as unix milliseconds; 0 = no active term. */
|
||||||
adsPaidUntilMs: number;
|
adsPaidUntilMs: number;
|
||||||
hints: 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"/
|
/** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/
|
||||||
|
|||||||
@@ -240,6 +240,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async walletOrder(productId: string) {
|
async walletOrder(productId: string) {
|
||||||
return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId)));
|
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() {
|
async friendsList() {
|
||||||
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
|
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
|
||||||
|
|||||||
@@ -197,6 +197,35 @@ export async function vkShowOrderBox(item: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkRewardedReady reports whether a rewarded ad is preloaded and ready to show
|
||||||
|
* (VKWebAppCheckNativeAds, required before a rewarded view). A false — or an unavailable method —
|
||||||
|
* hides the "watch for chips" button, since tapping it would show nothing.
|
||||||
|
*/
|
||||||
|
export async function vkRewardedReady(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const data = await (await bridge()).send('VKWebAppCheckNativeAds', { ad_format: 'reward' });
|
||||||
|
return !!(data as { result?: boolean }).result;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkShowRewarded shows a rewarded ad (VKWebAppShowNativeAds) and reports whether it was watched
|
||||||
|
* (data.result) 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) —
|
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
|
||||||
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
|
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function seg(source: string, chips: number, spendable = true): WalletSegment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wallet(segments: WalletSegment[]): Wallet {
|
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', () => {
|
describe('money formatting', () => {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
||||||
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
||||||
import { telegramOpenInvoice } from '../lib/telegram';
|
import { telegramOpenInvoice } from '../lib/telegram';
|
||||||
|
import { rewardedReady, showRewarded } from '../lib/ads';
|
||||||
|
import { GatewayError } from '../lib/client';
|
||||||
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
||||||
|
|
||||||
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
// 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 values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
|
||||||
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
|
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() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [w, c] = await Promise.all([gateway.wallet(), gateway.catalog()]);
|
const [w, c] = await Promise.all([gateway.wallet(), gateway.catalog()]);
|
||||||
@@ -54,6 +91,9 @@
|
|||||||
}
|
}
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void load();
|
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
|
// Fallback to the payment push: re-fetch when the tab regains focus, i.e. the user returned
|
||||||
// from the provider's payment window.
|
// from the provider's payment window.
|
||||||
const onVisible = () => {
|
const onVisible = () => {
|
||||||
@@ -171,6 +211,14 @@
|
|||||||
<!-- prettier-ignore -->
|
<!-- prettier-ignore -->
|
||||||
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
|
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if canReward}
|
||||||
|
<div class="row reward" data-testid="reward-row">
|
||||||
|
<span class="name">{t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })}</span>
|
||||||
|
<button class="buy" class:working={busyId === 'reward'} data-testid="watch-ad" disabled={busy} onclick={onWatchAd}
|
||||||
|
>{t('wallet.rewardCta')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#each values as p (p.productId)}
|
{#each values as p (p.productId)}
|
||||||
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
||||||
<span class="name">{p.title}</span>
|
<span class="name">{p.title}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user