Merge pull request 'feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps' (#228) from feature/ads-rewarded into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
This commit was merged in pull request #228.
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -35,7 +35,7 @@ status — without re-deriving decisions.
|
||||
| E3 | Wallet UI | 1 | DONE |
|
||||
| E4 | Durability (PITR) | 2 | DONE |
|
||||
| E5 | Payment intake | 2 | DONE |
|
||||
| E6 | Ads | 2 | TODO |
|
||||
| E6 | Ads | 2 | WIP |
|
||||
| E7 | Admin & reports | 2 | TODO |
|
||||
| E8 | Guest limits | — | TODO |
|
||||
| E9 | Tournament fee | future | TODO |
|
||||
@@ -618,9 +618,31 @@ force-recreate when the Caddyfile changes.
|
||||
|
||||
## E6 — Ads
|
||||
|
||||
**Status:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
|
||||
**Status:** WIP · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
|
||||
mechanics: PAYMENTS §10.
|
||||
|
||||
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**,
|
||||
then interstitial. Baked: the interstitial cooldowns already exist in `payments.config` (E0) and the
|
||||
per-origin banner suppression is already done (E2 `AdFree`), so E6 is the two ad DISPLAY paths + the
|
||||
rewarded credit. **VK reality (checked live in the VK docs via Playwright):** VK Mini App ads
|
||||
(`VKWebAppShowNativeAds`, both `reward` and `interstitial`) expose **only a client-side `data.result`
|
||||
boolean** — no server verify, no signature. So **D29 is amended**: rewarded is **client-attested**,
|
||||
guarded by a server **daily + hourly cap** (config `reward_daily_cap` / `reward_hourly_cap`, default
|
||||
50 / 10) that is both anti-abuse and an economic conversion lever (limits free chips so players buy);
|
||||
the cooldown state for the interstitial is **client-mirrored** (owner's pick). Delivered on
|
||||
`feature/ads-rewarded` (the **rewarded** slice): the ads-network abstraction (`ui/src/lib/ads.ts`, VK
|
||||
impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the backend `CreditReward` (VK-only,
|
||||
order-less, idempotent on a client nonce, floored by the caps, payout from config
|
||||
`rewarded_payout_chips` default 0 = off), the `wallet.reward` edge op returning the updated wallet
|
||||
(with `reward_chips` gating the "watch for chips" CTA), and a **contour test stub** (`VITE_ADS_STUB` →
|
||||
a toast instead of a real ad; prod always real). A temporary diagnostic confirmed on the contour that
|
||||
VK returns **only `{result:true}`** (no token/signature) — client-attested is final, no hardening
|
||||
possible; the diagnostic is removed. The slice also **corrects the VK-iOS freeze to purchase-only**
|
||||
(rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending —
|
||||
Apple forbids only *buying* in-app values, not spending or earning them; `vkFrozen()` now gates only
|
||||
`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). The legacy `paid_account`
|
||||
/ `hint_balance` drop (D31) and the **interstitial** are the next slice.
|
||||
|
||||
**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video
|
||||
(credits chips via server verify), plus extending the existing banner suppression to
|
||||
per-origin.
|
||||
|
||||
@@ -240,6 +240,100 @@ func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// setRewardConfig sets the rewarded payout and caps on the shared config row, restoring the defaults
|
||||
// after the test (the row is a singleton shared across the sequential suite).
|
||||
func setRewardConfig(t *testing.T, payout, dailyCap, hourlyCap int) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||
payout, dailyCap, hourlyCap); err != nil {
|
||||
t.Fatalf("set reward config: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.config SET rewarded_payout_chips=0, reward_daily_cap=50, reward_hourly_cap=10`)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPaymentsRewardCredit exercises the rewarded-video credit: a watched view credits the VK segment
|
||||
// the configured payout, a retried view (same nonce) credits once, and the hourly cap blocks the
|
||||
// third distinct view.
|
||||
func TestPaymentsRewardCredit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
setRewardConfig(t, 5, 5, 2) // 5 chips/view, daily 5, hourly 2
|
||||
vk := payments.NewContext("vk", "web")
|
||||
present := []payments.Source{payments.SourceVK}
|
||||
|
||||
out, err := svc.CreditReward(ctx, acc, vk, present, "n1")
|
||||
if err != nil {
|
||||
t.Fatalf("credit: %v", err)
|
||||
}
|
||||
if out.Chips != 5 || out.Capped {
|
||||
t.Fatalf("reward outcome = %+v, want 5 chips, not capped", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 5 {
|
||||
t.Errorf("vk balance = %d, want 5", got)
|
||||
}
|
||||
|
||||
// A retried view (same nonce) credits nothing more.
|
||||
out2, err := svc.CreditReward(ctx, acc, vk, present, "n1")
|
||||
if err != nil {
|
||||
t.Fatalf("retry: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("retried view not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 5 {
|
||||
t.Errorf("vk balance after retry = %d, want 5 (credited once)", got)
|
||||
}
|
||||
|
||||
// A second distinct view credits again (2 total).
|
||||
if _, err := svc.CreditReward(ctx, acc, vk, present, "n2"); err != nil {
|
||||
t.Fatalf("credit 2: %v", err)
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 10 {
|
||||
t.Errorf("vk balance = %d, want 10", got)
|
||||
}
|
||||
|
||||
// The third distinct view hits the hourly cap (2) — capped, no credit.
|
||||
out3, err := svc.CreditReward(ctx, acc, vk, present, "n3")
|
||||
if err != nil {
|
||||
t.Fatalf("credit 3: %v", err)
|
||||
}
|
||||
if !out3.Capped {
|
||||
t.Error("third view not capped (hourly cap 2)")
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 10 {
|
||||
t.Errorf("vk balance after cap = %d, want 10 (capped view credited nothing)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRewardDisabledAndContext verifies rewarded is inert when unconfigured (0 payout) and
|
||||
// refused outside a VK context (rewarded is VK-only, D28).
|
||||
func TestPaymentsRewardDisabledAndContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
setRewardConfig(t, 0, 50, 10) // payout 0 = disabled
|
||||
|
||||
vk := payments.NewContext("vk", "web")
|
||||
out, err := svc.CreditReward(ctx, acc, vk, []payments.Source{payments.SourceVK}, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("credit (disabled): %v", err)
|
||||
}
|
||||
if out.Chips != 0 || out.Capped || out.AlreadyCredited {
|
||||
t.Fatalf("disabled reward outcome = %+v, want 0 chips, not capped", out)
|
||||
}
|
||||
|
||||
// A direct (non-VK) context is refused — rewarded is VK-only.
|
||||
direct := payments.NewContext("direct", "web")
|
||||
if _, err := svc.CreditReward(ctx, acc, direct, []payments.Source{payments.SourceDirect}, "d2"); !errors.Is(err, payments.ErrUntrusted) {
|
||||
t.Fatalf("direct-context reward = %v, want ErrUntrusted", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
|
||||
// refused and credits nothing (§9: verify the amount after matching by order id).
|
||||
func TestPaymentsFundAmountMismatch(t *testing.T) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,8 @@ func TestSpendableSources(t *testing.T) {
|
||||
want []Source
|
||||
}{
|
||||
{"vk android, vk present", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
|
||||
{"vk ios frozen", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, nil},
|
||||
// VK-iOS spends its own vk segment: the freeze is purchase-only, spending is allowed.
|
||||
{"vk ios spends vk", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
|
||||
{"vk android, vk absent", Context{Kind: SourceVK, Subtype: "android"}, []Source{SourceDirect}, nil},
|
||||
{"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}},
|
||||
{"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil},
|
||||
@@ -41,8 +42,8 @@ func TestApplicableOrigins(t *testing.T) {
|
||||
present []Source
|
||||
want []Source
|
||||
}{
|
||||
// A benefit still APPLIES on VK-iOS while spending is frozen.
|
||||
{"vk ios still applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
|
||||
// A vk-origin benefit applies on VK-iOS (spending is allowed there — the freeze is purchase-only).
|
||||
{"vk ios applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
|
||||
{"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
|
||||
{"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{SourceTelegram}},
|
||||
{"direct all, priority", Context{Kind: SourceDirect}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}},
|
||||
|
||||
@@ -135,6 +135,36 @@ func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, am
|
||||
return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
|
||||
}
|
||||
|
||||
// providerVKAds tags a rewarded-video credit from the VK ads network in the ledger (distinct from
|
||||
// the "vk" Votes-purchase provider), so the daily cap counts only ad credits and the report separates
|
||||
// them.
|
||||
const providerVKAds = "vk_ads"
|
||||
|
||||
// RewardPayout reports the chips a rewarded-video view earns in the caller's context — the config
|
||||
// payout in a trusted VK context with the VK segment attached, and 0 everywhere else (rewarded is
|
||||
// VK-only, D28). The client uses it to gate the "watch for chips" button.
|
||||
func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Source) (int, error) {
|
||||
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
|
||||
return 0, nil
|
||||
}
|
||||
payout, _, _, err := s.store.rewardConfig(ctx)
|
||||
return payout, err
|
||||
}
|
||||
|
||||
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
||||
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
||||
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
||||
// client nonce and order-less. It credits nothing when rewarded is unconfigured (0 payout) or the
|
||||
// daily cap is reached. Rewarded video is VK-only (D28) and is an ad view — not a purchase — so the
|
||||
// VK-iOS purchase freeze does not apply; it requires a trusted VK context with the VK segment
|
||||
// attached.
|
||||
func (s *Service) CreditReward(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, nonce string) (RewardOutcome, error) {
|
||||
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
|
||||
return RewardOutcome{}, ErrUntrusted
|
||||
}
|
||||
return s.store.creditReward(ctx, accountID, SourceVK, providerVKAds, nonce, s.clock())
|
||||
}
|
||||
|
||||
// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how
|
||||
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
|
||||
// still credits — see Fund).
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestCreateOrderGateRejections(t *testing.T) {
|
||||
cxt Context
|
||||
}{
|
||||
{"untrusted context", Context{}},
|
||||
{"vk-ios spend freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}},
|
||||
{"vk-ios purchase freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}},
|
||||
{"method segment not attached", Context{Kind: SourceTelegram}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -102,10 +102,11 @@ func TestWalletSegments(t *testing.T) {
|
||||
if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable {
|
||||
t.Errorf("vk-android wallet = %+v", got.Segments)
|
||||
}
|
||||
// VK iOS: only vk shown, frozen (not spendable) but the balance is visible.
|
||||
// VK iOS: only vk shown, and spendable — the freeze is purchase-only, so VK-wallet chips still
|
||||
// spend there (only buying more chips for money is blocked).
|
||||
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present)
|
||||
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || got.Segments[0].Spendable {
|
||||
t.Errorf("vk-ios wallet = %+v (want vk 50 frozen)", got.Segments)
|
||||
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || !got.Segments[0].Spendable {
|
||||
t.Errorf("vk-ios wallet = %+v (want vk 50 spendable)", got.Segments)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,4 +14,6 @@ type Config struct {
|
||||
CooldownVsAiSeconds int32
|
||||
CooldownHintSeconds int32
|
||||
OrderTTLSeconds int32
|
||||
RewardDailyCap int32
|
||||
RewardHourlyCap int32
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ type configTable struct {
|
||||
CooldownVsAiSeconds postgres.ColumnInteger
|
||||
CooldownHintSeconds postgres.ColumnInteger
|
||||
OrderTTLSeconds postgres.ColumnInteger
|
||||
RewardDailyCap postgres.ColumnInteger
|
||||
RewardHourlyCap postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -70,9 +72,11 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
||||
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
|
||||
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
|
||||
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
|
||||
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
RewardDailyCapColumn = postgres.IntegerColumn("reward_daily_cap")
|
||||
RewardHourlyCapColumn = postgres.IntegerColumn("reward_hourly_cap")
|
||||
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
|
||||
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
|
||||
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
|
||||
)
|
||||
|
||||
return configTable{
|
||||
@@ -85,6 +89,8 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
||||
CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
|
||||
CooldownHintSeconds: CooldownHintSecondsColumn,
|
||||
OrderTTLSeconds: OrderTTLSecondsColumn,
|
||||
RewardDailyCap: RewardDailyCapColumn,
|
||||
RewardHourlyCap: RewardHourlyCapColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Rewarded-video caps: the anti-abuse ceilings on free rewarded credits per user, per
|
||||
-- day and per hour. VK Mini App ads expose only a client-side watch result (no
|
||||
-- server-to-server verify), so a rewarded credit is client-attested; the caps bound a
|
||||
-- forger who skips the ad and calls the credit endpoint directly (the daily cap bounds the
|
||||
-- total; the hourly cap smooths a burst). Chips-per-view already exists
|
||||
-- (rewarded_payout_chips, default 0 = rewarded inert until the owner sets it). All are
|
||||
-- config, tuned in the admin without a release. Additive columns only — applies forward via
|
||||
-- goose with no data rewrite (no contour wipe), and an image rollback ignores them.
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE payments.config
|
||||
ADD COLUMN reward_daily_cap integer DEFAULT 50 NOT NULL,
|
||||
ADD COLUMN reward_hourly_cap integer DEFAULT 10 NOT NULL;
|
||||
ALTER TABLE payments.config
|
||||
ADD CONSTRAINT config_reward_daily_cap_chk CHECK (reward_daily_cap >= 0),
|
||||
ADD CONSTRAINT config_reward_hourly_cap_chk CHECK (reward_hourly_cap >= 0);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE payments.config
|
||||
DROP COLUMN reward_daily_cap,
|
||||
DROP COLUMN reward_hourly_cap;
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
@@ -20,11 +21,15 @@ type walletSegmentDTO struct {
|
||||
|
||||
// walletDTO is the user-facing wallet: the context-visible chip segments and the
|
||||
// context-applicable benefits (the no-ads term or forever flag, and the available hints).
|
||||
// RewardChips is the chips a rewarded-video view earns in the current context (0 when rewarded is
|
||||
// unavailable here — outside VK, or unconfigured); the client shows the "watch for chips" button
|
||||
// only when it is positive.
|
||||
type walletDTO struct {
|
||||
Segments []walletSegmentDTO `json:"segments"`
|
||||
AdsForever bool `json:"ads_forever"`
|
||||
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
|
||||
Hints int `json:"hints"`
|
||||
RewardChips int `json:"reward_chips"`
|
||||
}
|
||||
|
||||
// walletBuyRequest is the POST body of a chip spend: the product to buy with chips.
|
||||
@@ -108,7 +113,8 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
|
||||
}
|
||||
|
||||
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
|
||||
// trusted execution context.
|
||||
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
|
||||
// unconfigured), which gates the client's "watch for chips" button.
|
||||
func (s *Server) handleWallet(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
@@ -125,7 +131,13 @@ func (s *Server) handleWallet(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, walletDTOFrom(view))
|
||||
dto := walletDTOFrom(view)
|
||||
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr != nil {
|
||||
s.log.Warn("wallet: reward payout read failed", zap.String("account", uid.String()), zap.Error(perr))
|
||||
} else {
|
||||
dto.RewardChips = payout
|
||||
}
|
||||
c.JSON(http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is
|
||||
@@ -162,3 +174,54 @@ func (s *Server) handleWalletBuy(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, walletDTOFrom(view))
|
||||
}
|
||||
|
||||
// walletRewardRequest is the POST body of a rewarded-video credit: a client nonce, the idempotency
|
||||
// key for a single watched view (a retry credits once).
|
||||
type walletRewardRequest struct {
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
// handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and
|
||||
// bounded by the config daily cap. It is VK-only and idempotent on the nonce; a reached cap answers
|
||||
// reward_capped, and an unconfigured payout answers reward_unavailable. On success it returns the
|
||||
// updated wallet (like a spend).
|
||||
func (s *Server) handleWalletReward(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req walletRewardRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Nonce == "" {
|
||||
abortBadRequest(c, "nonce is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
cxt, present, err := s.walletGate(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
if outcome.Capped {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_capped", Message: "daily reward limit reached"}})
|
||||
return
|
||||
}
|
||||
if outcome.Chips == 0 && !outcome.AlreadyCredited {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_unavailable", Message: "rewarded video is not available"}})
|
||||
return
|
||||
}
|
||||
view, err := s.payments.Wallet(ctx, uid, cxt, present)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
view2 := walletDTOFrom(view)
|
||||
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr == nil {
|
||||
view2.RewardChips = payout
|
||||
}
|
||||
c.JSON(http.StatusOK, view2)
|
||||
}
|
||||
|
||||
@@ -207,6 +207,9 @@ services:
|
||||
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||
# The rewarded-ad test stub (1 = a toast instead of a real ad; the test contour only, empty
|
||||
# elsewhere so production shows real ads).
|
||||
VITE_ADS_STUB: ${VITE_ADS_STUB:-}
|
||||
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
||||
VERSION: ${APP_VERSION:-dev}
|
||||
restart: unless-stopped
|
||||
|
||||
+17
-7
@@ -82,7 +82,7 @@ leaking out to the open web) is allowed.
|
||||
| Execution context | Spendable chip segments | Spend priority |
|
||||
|---------------------|-------------------------|--------------------|
|
||||
| Inside VK (Android) | `vk` | — |
|
||||
| Inside VK (iOS) | none — frozen (view only)| — |
|
||||
| Inside VK (iOS) | `vk` (purchase-frozen) | — |
|
||||
| Inside Telegram | `telegram` | — |
|
||||
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
|
||||
|
||||
@@ -90,9 +90,11 @@ leaking out to the open web) is allowed.
|
||||
invisible-as-spendable there.
|
||||
- On the web the store has no jurisdiction, so all attached segments are spendable, drained
|
||||
by priority direct → vk → tg.
|
||||
- **VK iOS** is frozen for spending (Apple forbids spending virtual currency on digital
|
||||
goods outside IAP inside VK on iOS): the balance is shown as a number, but no purchase or
|
||||
spend is possible. A previously bought benefit still *applies* there.
|
||||
- **VK iOS is a PURCHASE freeze, not a spend freeze.** Apple's ToS forbids only **buying** in-app
|
||||
values there (for any currency) — so a purchase (money → chips) is refused. **Spending** VK-wallet
|
||||
chips (earned via rewarded ads or bought on the same VK account elsewhere, e.g. VK Android) and
|
||||
earning them are legal and stay allowed on VK iOS; a bought benefit also *applies* there. Only the
|
||||
money-in step is blocked.
|
||||
|
||||
The account is **single** (identities merge, one profile/friends/stats). The gate is
|
||||
**logical**: in a VK/TG context the server activates only the same-named segment. It rests
|
||||
@@ -266,9 +268,17 @@ ruble-paying in-app network exists. The ad provider is behind an **abstraction**
|
||||
network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob)
|
||||
are rejected — no legal ruble income for a self-employed (НПД) developer.
|
||||
|
||||
**Rewarded** (voluntary video for chips) credits chips through payments on the network's
|
||||
**server verify callback** (client not believed, like a payment). On-launch anti-fraud is
|
||||
the provider's verify only (no own daily cap yet; the abstraction allows adding caps later).
|
||||
**Rewarded** (voluntary video for chips) credits chips through payments. **VK Mini App ads expose
|
||||
only a client-side watch result** (`VKWebAppShowNativeAds` → `data.result`) — there is no
|
||||
server-to-server verify — so a rewarded credit is **client-attested** (D29 amended: server verify is
|
||||
not possible on VK). The anti-abuse (and economic) guard is a **server-side daily and hourly cap**
|
||||
(config `reward_daily_cap` / `reward_hourly_cap`, default 50 / 10), which bounds a forger who skips
|
||||
the ad and calls the credit endpoint directly and, as importantly, limits free rewarded chips so a
|
||||
player who wants more **buys**. The credit is idempotent on a client nonce and order-less; the payout
|
||||
is config (`rewarded_payout_chips`, default 0 = rewarded off until set). Rewarded is VK-only and is
|
||||
not suppressed by no-ads (D9). A future network that offers a server verify slots in behind the ads
|
||||
abstraction. On the test contour a build flag (`VITE_ADS_STUB`) swaps a toast for the real ad; prod
|
||||
always shows real ads (a failed real ad must not credit).
|
||||
|
||||
**Interstitial** (post-move fullscreen), configurable server-side values:
|
||||
|
||||
|
||||
@@ -110,6 +110,11 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
сессии. Платформа несёт **kind** (vk/tg/direct) **+ подтип** (ios/android/web) —
|
||||
подтип обязателен (VK iOS заморожен). TG `initData`-валидатор уже есть
|
||||
(`platform/telegram/internal/initdata`).
|
||||
**АМЕНД (E6, находка владельца по ToS):** VK iOS — **заморозка только ПОКУПОК** (деньги→Фишки),
|
||||
а не траты. Apple запрещает там лишь **покупать** внутриигровые ценности за любую валюту; а
|
||||
**тратить** Фишки VK-кошелька (заработанные rewarded-рекламой или купленные на том же VK-аккаунте
|
||||
в Android) и зарабатывать их — легально и на iOS. Код: `vkFrozen()` гейтит только `CreateOrder`
|
||||
(покупку), не `spendableSources` (трату).
|
||||
- **D18. Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без
|
||||
валидной подписи на старте; старая сессия без записанной платформы) → запрет
|
||||
трат/покупок/применения чужого origin, только просмотр.
|
||||
@@ -158,9 +163,13 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
закладываем **абстракцией** (будущая крутилка для других платформ встроится без
|
||||
переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого
|
||||
дохода самозанятому (санкции + НПД не учитывает крипту).
|
||||
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments по
|
||||
**серверному verify-колбэку** рекламной сети (клиенту не верим, как платёж). Не
|
||||
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments. Не
|
||||
гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики.
|
||||
**АМЕНД (E6, по факту реализации):** у VK Mini App серверного verify-колбэка **нет** —
|
||||
`VKWebAppShowNativeAds` отдаёт только клиентский `data.result`. Поэтому начисление
|
||||
**client-attested**, а защита (и экономический рычаг) — **серверный дневной и часовой кап**
|
||||
(config `reward_daily_cap` / `reward_hourly_cap`, дефолт 50 / 10) + идемпотентность по клиентскому
|
||||
nonce. Сеть с серверным verify встроится за ads-абстракцией позже.
|
||||
- **D30. Частота навязанного interstitial** (конфигурируемые серверные значения):
|
||||
глобальный кулдаун **на юзера сквозь все партии**, дефолт **5 мин**; **vs_ai — 30 мин**
|
||||
(соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода
|
||||
|
||||
+16
-8
@@ -83,7 +83,7 @@
|
||||
| Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты |
|
||||
|----------------------|---------------------------|--------------------|
|
||||
| Внутри VK (Android) | `vk` | — |
|
||||
| Внутри VK (iOS) | нет — заморожено (только просмотр) | — |
|
||||
| Внутри VK (iOS) | `vk` (заморожена покупка) | — |
|
||||
| Внутри Telegram | `telegram` | — |
|
||||
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
|
||||
|
||||
@@ -91,9 +91,11 @@
|
||||
`direct`) там невидимо как тратимое.
|
||||
- В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по
|
||||
приоритету direct → vk → tg.
|
||||
- **VK iOS** заморожен для траты (Apple запрещает тратить виртуальную валюту на цифровые
|
||||
товары мимо IAP внутри VK на iOS): баланс показывается числом, но покупка/трата
|
||||
невозможны. Ранее купленный бенефит там всё равно *действует*.
|
||||
- **VK iOS — заморозка ПОКУПОК, а не траты.** ToS Apple запрещает там только **покупать**
|
||||
внутриигровые ценности (за любую валюту) — поэтому покупка (деньги → Фишки) отклоняется. А
|
||||
**тратить** Фишки VK-кошелька (заработанные рекламой или купленные на том же VK-аккаунте, напр. в
|
||||
VK Android) и зарабатывать их — легально и на VK iOS разрешено; купленный бенефит там тоже
|
||||
*действует*. Блокируется только шаг «деньги внутрь».
|
||||
|
||||
Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт
|
||||
**логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на
|
||||
@@ -266,10 +268,16 @@ web/native/TG держат только существующий наш **тек
|
||||
платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального
|
||||
рублёвого дохода самозанятому (НПД).
|
||||
|
||||
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен по
|
||||
**серверному verify-колбэку** сети (клиенту не верим, как платежу). Антифрод на старте — только
|
||||
verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты
|
||||
позже).
|
||||
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен. **VK Mini
|
||||
App отдаёт только клиентский результат просмотра** (`VKWebAppShowNativeAds` → `data.result`) —
|
||||
серверной проверки нет — поэтому начисление **client-attested** (D29 амендим: server-verify у VK
|
||||
невозможен). Защита — **серверный дневной и часовой кап** (config `reward_daily_cap` /
|
||||
`reward_hourly_cap`, дефолт 50 / 10): он ограничивает читера, который пропускает ролик и дёргает
|
||||
эндпоинт напрямую, и — не менее важно — лимитирует бесплатные Фишки, чтобы желающий больше **покупал**.
|
||||
Начисление идемпотентно по клиентскому nonce и order-less; выплата — config
|
||||
(`rewarded_payout_chips`, дефолт 0 = ролик выключен, пока не задан). Rewarded только в VK и не
|
||||
гасится «без рекламы» (D9). Сеть с серверным verify встроится за ads-абстракцией. На тест-контуре
|
||||
build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; прод всегда крутит настоящую рекламу.
|
||||
|
||||
**Полноэкранный ролик** (после хода), конфигурируемые серверные значения:
|
||||
|
||||
|
||||
+5
-1
@@ -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.
|
||||
|
||||
@@ -369,12 +369,14 @@ type WalletSegmentResp struct {
|
||||
}
|
||||
|
||||
// WalletResp is the caller's wallet: the context-visible chip segments and the context-applicable
|
||||
// benefits (no-ads term end as unix millis / forever flag, and the available hints).
|
||||
// benefits (no-ads term end as unix millis / forever flag, and the available hints), plus the
|
||||
// rewarded-video payout available in the current context (0 = unavailable — outside VK/unconfigured).
|
||||
type WalletResp struct {
|
||||
Segments []WalletSegmentResp `json:"segments"`
|
||||
AdsForever bool `json:"ads_forever"`
|
||||
AdsPaidUntil int64 `json:"ads_paid_until_ms"`
|
||||
Hints int `json:"hints"`
|
||||
RewardChips int `json:"reward_chips"`
|
||||
}
|
||||
|
||||
// walletBuyBody is the chip-spend request body.
|
||||
@@ -382,6 +384,19 @@ type walletBuyBody struct {
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
// walletRewardBody is the rewarded-video credit request: the per-view idempotency nonce.
|
||||
type walletRewardBody struct {
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
// WalletReward credits a watched rewarded video and returns the updated wallet (client-attested + a
|
||||
// backend daily/hourly cap). A reached cap or unconfigured payout surfaces as an APIError domain code.
|
||||
func (c *Client) WalletReward(ctx context.Context, userID, nonce string) (WalletResp, error) {
|
||||
var out WalletResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/reward", userID, "", walletRewardBody{Nonce: nonce}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Wallet fetches the caller's wallet in their current execution context.
|
||||
func (c *Client) Wallet(ctx context.Context, userID string) (WalletResp, error) {
|
||||
var out WalletResp
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const (
|
||||
MsgWalletCatalog = "wallet.catalog"
|
||||
MsgWalletBuy = "wallet.buy"
|
||||
MsgWalletOrder = "wallet.order"
|
||||
MsgWalletReward = "wallet.reward"
|
||||
)
|
||||
|
||||
// Request is one decoded Execute call.
|
||||
@@ -113,6 +114,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op
|
||||
r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true}
|
||||
r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true}
|
||||
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, nil), Auth: true}
|
||||
r.ops[MsgWalletReward] = Op{Handler: walletRewardHandler(backend), Auth: true}
|
||||
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
|
||||
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
||||
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
|
||||
@@ -379,6 +381,19 @@ func walletOrderHandler(backend *backendclient.Client, minter InvoiceMinter) Han
|
||||
}
|
||||
}
|
||||
|
||||
// walletRewardHandler credits a watched rewarded video and returns the updated wallet. The nonce is
|
||||
// the client's per-view idempotency key.
|
||||
func walletRewardHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsWalletRewardRequest(req.Payload, 0)
|
||||
w, err := backend.WalletReward(ctx, req.UserID, string(in.Nonce()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeWallet(w), nil
|
||||
}
|
||||
}
|
||||
|
||||
func blockStatusHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
bs, err := backend.BlockStatus(ctx, req.UserID)
|
||||
|
||||
+12
@@ -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=
|
||||
|
||||
@@ -866,6 +866,9 @@ table Wallet {
|
||||
ads_forever:bool;
|
||||
ads_paid_until_ms:long;
|
||||
hints:int;
|
||||
// reward_chips is the chips a rewarded-video view earns in the current context (0 = unavailable
|
||||
// here — outside VK, or unconfigured); the client shows the "watch for chips" button only when > 0.
|
||||
reward_chips:int;
|
||||
}
|
||||
|
||||
// WalletBuyRequest buys a chip-priced value with chips: the product to buy.
|
||||
@@ -873,6 +876,12 @@ table WalletBuyRequest {
|
||||
product_id:string;
|
||||
}
|
||||
|
||||
// WalletRewardRequest credits a watched rewarded video: nonce is the per-view idempotency key (a
|
||||
// retry credits once).
|
||||
table WalletRewardRequest {
|
||||
nonce:string;
|
||||
}
|
||||
|
||||
// WalletOrderRequest opens a money order to fund a chip pack: the pack to buy.
|
||||
table WalletOrderRequest {
|
||||
product_id:string;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package scrabblefb
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type WalletRewardRequest struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsWalletRewardRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletRewardRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &WalletRewardRequest{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishWalletRewardRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsWalletRewardRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletRewardRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &WalletRewardRequest{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedWalletRewardRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *WalletRewardRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *WalletRewardRequest) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *WalletRewardRequest) Nonce() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func WalletRewardRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(1)
|
||||
}
|
||||
func WalletRewardRequestAddNonce(builder *flatbuffers.Builder, nonce flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(nonce), 0)
|
||||
}
|
||||
func WalletRewardRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -88,6 +88,7 @@ export { Wallet } from './scrabblefb/wallet.js';
|
||||
export { WalletBuyRequest } from './scrabblefb/wallet-buy-request.js';
|
||||
export { WalletOrderRequest } from './scrabblefb/wallet-order-request.js';
|
||||
export { WalletOrderResponse } from './scrabblefb/wallet-order-response.js';
|
||||
export { WalletRewardRequest } from './scrabblefb/wallet-reward-request.js';
|
||||
export { WalletSegment } from './scrabblefb/wallet-segment.js';
|
||||
export { WordCheckResult } from './scrabblefb/word-check-result.js';
|
||||
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class WalletRewardRequest {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):WalletRewardRequest {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsWalletRewardRequest(bb:flatbuffers.ByteBuffer, obj?:WalletRewardRequest):WalletRewardRequest {
|
||||
return (obj || new WalletRewardRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsWalletRewardRequest(bb:flatbuffers.ByteBuffer, obj?:WalletRewardRequest):WalletRewardRequest {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new WalletRewardRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
nonce():string|null
|
||||
nonce(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
nonce(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
static startWalletRewardRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(1);
|
||||
}
|
||||
|
||||
static addNonce(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, nonceOffset, 0);
|
||||
}
|
||||
|
||||
static endWalletRewardRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createWalletRewardRequest(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
WalletRewardRequest.startWalletRewardRequest(builder);
|
||||
WalletRewardRequest.addNonce(builder, nonceOffset);
|
||||
return WalletRewardRequest.endWalletRewardRequest(builder);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// The ads-network abstraction (D28). VK is the only network now; a future network for another
|
||||
// platform implements the same rewardedReady/showRewarded surface without touching the callers. On
|
||||
// the contour a build flag (VITE_ADS_STUB) substitutes a toast stub for the real ad, so the reward
|
||||
// flow is testable without waiting for a real ad — production never sets the flag, so a real ad that
|
||||
// fails to load there must not credit (no stub fallback in prod).
|
||||
import { executionContext } from './wallet';
|
||||
import { vkRewardedReady, vkShowRewarded } from './vk';
|
||||
|
||||
/**
|
||||
* adsStubEnabled reports the contour test stub (VITE_ADS_STUB=1). Production never sets it, so it
|
||||
* always shows real ads. The mock e2e forces it on so the reward flow is exercisable without a VK
|
||||
* bridge. To capture the real VK ad result on the contour (the diagnostic), deploy with the flag
|
||||
* off first; flip it on afterwards for routine stub testing.
|
||||
*/
|
||||
export function adsStubEnabled(): boolean {
|
||||
if (import.meta.env.MODE === 'mock') return true;
|
||||
return (import.meta.env as Record<string, string | undefined>).VITE_ADS_STUB === '1';
|
||||
}
|
||||
|
||||
/** RewardedResult is one rewarded-ad view: whether it was watched, and whether it was the test stub
|
||||
* (so the caller can show the "ad fired" marker toast). */
|
||||
export interface RewardedResult {
|
||||
watched: boolean;
|
||||
stub: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* rewardedReady reports whether a rewarded ad is ready to show, gating the "watch for chips" button.
|
||||
* The stub is always ready; a real ad is ready only inside VK with preloaded material. Rewarded is
|
||||
* VK-only (D28).
|
||||
*/
|
||||
export async function rewardedReady(): Promise<boolean> {
|
||||
if (adsStubEnabled()) return true;
|
||||
if (executionContext() !== 'vk') return false;
|
||||
return vkRewardedReady();
|
||||
}
|
||||
|
||||
/**
|
||||
* showRewarded shows a rewarded ad and reports whether it was watched, the raw provider result, and
|
||||
* whether it was the stub. On the stub it resolves watched immediately (the caller shows the "ad
|
||||
* fired" toast).
|
||||
*/
|
||||
export async function showRewarded(): Promise<RewardedResult> {
|
||||
if (adsStubEnabled()) {
|
||||
return { watched: true, stub: true };
|
||||
}
|
||||
return { watched: await vkShowRewarded(), stub: false };
|
||||
}
|
||||
@@ -151,6 +151,10 @@ export interface GatewayClient {
|
||||
/** walletOrder opens a money order to fund a chip pack and returns the provider launch URL the
|
||||
* client opens; chips are credited later, by the verified server callback. Direct rail only. */
|
||||
walletOrder(productId: string): Promise<WalletOrder>;
|
||||
/** walletReward credits a watched rewarded video (VK ads) and returns the updated wallet. nonce is
|
||||
* the per-view idempotency key. Client-attested + a server daily/hourly cap; a reached cap rejects
|
||||
* with a domain code. */
|
||||
walletReward(nonce: string): Promise<Wallet>;
|
||||
|
||||
// --- friends ---
|
||||
friendsList(): Promise<AccountRef[]>;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
decodeOutgoingList,
|
||||
decodeProfile,
|
||||
decodeWallet,
|
||||
encodeWalletReward,
|
||||
decodeCatalog,
|
||||
encodeWalletBuy,
|
||||
encodeWalletOrder,
|
||||
@@ -66,6 +67,7 @@ describe('codec', () => {
|
||||
fb.Wallet.addAdsForever(b, false);
|
||||
fb.Wallet.addAdsPaidUntilMs(b, BigInt(1_700_000_000_000));
|
||||
fb.Wallet.addHints(b, 5);
|
||||
fb.Wallet.addRewardChips(b, 7);
|
||||
b.finish(fb.Wallet.endWallet(b));
|
||||
|
||||
const w = decodeWallet(b.asUint8Array());
|
||||
@@ -73,6 +75,14 @@ describe('codec', () => {
|
||||
expect(w.adsForever).toBe(false);
|
||||
expect(w.adsPaidUntilMs).toBe(1_700_000_000_000);
|
||||
expect(w.hints).toBe(5);
|
||||
expect(w.rewardChips).toBe(7);
|
||||
});
|
||||
|
||||
it('round-trips the wallet reward request (nonce)', () => {
|
||||
const req = fb.WalletRewardRequest.getRootAsWalletRewardRequest(
|
||||
new ByteBuffer(encodeWalletReward('nonce-9')),
|
||||
);
|
||||
expect(req.nonce()).toBe('nonce-9');
|
||||
});
|
||||
|
||||
it('round-trips the wallet order request and response', () => {
|
||||
|
||||
@@ -398,9 +398,20 @@ export function decodeWallet(buf: Uint8Array): Wallet {
|
||||
adsForever: w.adsForever(),
|
||||
adsPaidUntilMs: Number(w.adsPaidUntilMs()),
|
||||
hints: w.hints(),
|
||||
rewardChips: w.rewardChips(),
|
||||
};
|
||||
}
|
||||
|
||||
// encodeWalletReward wraps a watched rewarded video for crediting (POST /user/wallet/reward): the
|
||||
// per-view idempotency nonce.
|
||||
export function encodeWalletReward(nonce: string): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const n = b.createString(nonce);
|
||||
fb.WalletRewardRequest.startWalletRewardRequest(b);
|
||||
fb.WalletRewardRequest.addNonce(b, n);
|
||||
return finish(b, fb.WalletRewardRequest.endWalletRewardRequest(b));
|
||||
}
|
||||
|
||||
// decodeWalletOrder reads the created order: its id and the provider launch URL the client opens.
|
||||
export function decodeWalletOrder(buf: Uint8Array): WalletOrder {
|
||||
const o = fb.WalletOrderResponse.getRootAsWalletOrderResponse(new ByteBuffer(buf));
|
||||
|
||||
@@ -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 ',
|
||||
|
||||
@@ -242,6 +242,11 @@ export const ru: Record<MessageKey, string> = {
|
||||
'wallet.store': 'Магазин',
|
||||
'wallet.empty': 'Магазин пока пуст',
|
||||
'wallet.buy': 'Купить',
|
||||
'wallet.rewardWatch': 'Ролик за {n} 🪙',
|
||||
'wallet.rewardCta': 'Смотреть',
|
||||
'wallet.rewardCredited': '+{n} 🪙 за просмотр',
|
||||
'wallet.rewardCapped': 'Достигнут дневной лимит наград',
|
||||
'wallet.rewardFailed': 'Не удалось показать ролик',
|
||||
'wallet.offer': 'Публичная оферта',
|
||||
'wallet.platformNoBuy': 'На данной платформе покупки невозможны',
|
||||
'wallet.iosBlockedPre': 'Покупки на iOS запрещены политикой Apple. Попробуйте воспользоваться ',
|
||||
|
||||
@@ -126,6 +126,7 @@ export class MockGateway implements GatewayClient {
|
||||
adsForever: false,
|
||||
adsPaidUntilMs: 0,
|
||||
hints: 5,
|
||||
rewardChips: 5,
|
||||
};
|
||||
// The mock storefront: two chip-priced values (one cheap enough to draw direct only, one that
|
||||
// reaches into vk → the warning) and one RUB-priced chip pack (the direct-context method).
|
||||
@@ -234,12 +235,20 @@ export class MockGateway implements GatewayClient {
|
||||
// the provider hand-off without leaving the app.
|
||||
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId };
|
||||
}
|
||||
|
||||
async walletReward(_nonce: string): Promise<Wallet> {
|
||||
// Mock rewarded credit: add the configured payout to the vk segment (no cap in the mock).
|
||||
const vk = this.mockWallet.segments.find((s) => s.source === 'vk');
|
||||
if (vk) vk.chips += this.mockWallet.rewardChips;
|
||||
return this.cloneWallet();
|
||||
}
|
||||
private cloneWallet(): Wallet {
|
||||
return {
|
||||
segments: this.mockWallet.segments.map((s) => ({ ...s })),
|
||||
adsForever: this.mockWallet.adsForever,
|
||||
adsPaidUntilMs: this.mockWallet.adsPaidUntilMs,
|
||||
hints: this.mockWallet.hints,
|
||||
rewardChips: this.mockWallet.rewardChips,
|
||||
};
|
||||
}
|
||||
async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
|
||||
|
||||
@@ -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"/
|
||||
|
||||
@@ -240,6 +240,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async walletOrder(productId: string) {
|
||||
return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId)));
|
||||
},
|
||||
async walletReward(nonce: string) {
|
||||
return codec.decodeWallet(await exec('wallet.reward', codec.encodeWalletReward(nonce)));
|
||||
},
|
||||
|
||||
async friendsList() {
|
||||
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
|
||||
|
||||
@@ -197,6 +197,34 @@ export async function vkShowOrderBox(item: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkRewardedReady reports whether a rewarded ad is preloaded and ready to show
|
||||
* (VKWebAppCheckNativeAds, required before a rewarded view). A false — or an unavailable method —
|
||||
* hides the "watch for chips" button, since tapping it would show nothing.
|
||||
*/
|
||||
export async function vkRewardedReady(): Promise<boolean> {
|
||||
try {
|
||||
const data = await (await bridge()).send('VKWebAppCheckNativeAds', { ad_format: 'reward' });
|
||||
return !!(data as { result?: boolean }).result;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkShowRewarded shows a rewarded ad (VKWebAppShowNativeAds) and reports whether it was watched
|
||||
* (data.result — VK returns only this boolean, no server-verifiable token). A rejected call reports
|
||||
* not-watched.
|
||||
*/
|
||||
export async function vkShowRewarded(): Promise<boolean> {
|
||||
try {
|
||||
const data = await (await bridge()).send('VKWebAppShowNativeAds', { ad_format: 'reward' });
|
||||
return !!(data as { result?: boolean }).result;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
|
||||
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
||||
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
||||
import { telegramOpenInvoice } from '../lib/telegram';
|
||||
import { rewardedReady, showRewarded } from '../lib/ads';
|
||||
import { GatewayError } from '../lib/client';
|
||||
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
||||
|
||||
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
||||
@@ -41,6 +43,41 @@
|
||||
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
|
||||
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
|
||||
|
||||
// Rewarded video (VK ads): the "watch for chips" CTA shows only when the server offers a payout in
|
||||
// this context (wallet.rewardChips > 0 — VK, configured) and an ad is ready to show.
|
||||
let rewardReady = $state(false);
|
||||
const canReward = $derived(!!wallet && wallet.rewardChips > 0 && rewardReady);
|
||||
|
||||
// onWatchAd shows a rewarded ad and, if watched, credits the payout server-side (client-attested +
|
||||
// a server daily/hourly cap). A stub view (contour test) shows an "ad fired" marker; a reached cap
|
||||
// is surfaced as its own toast, distinct from a generic error.
|
||||
async function onWatchAd() {
|
||||
if (busy) return;
|
||||
busyId = 'reward';
|
||||
try {
|
||||
const res = await showRewarded();
|
||||
if (res.stub) showToast('ad fired');
|
||||
if (!res.watched) {
|
||||
showToast(t('wallet.rewardFailed'));
|
||||
return;
|
||||
}
|
||||
const nonce = crypto.randomUUID();
|
||||
const before = wallet?.segments.find((s) => s.source === 'vk')?.chips ?? 0;
|
||||
const w = await gateway.walletReward(nonce);
|
||||
wallet = w;
|
||||
const gained = (w.segments.find((s) => s.source === 'vk')?.chips ?? 0) - before;
|
||||
showToast(gained > 0 ? t('wallet.rewardCredited', { n: gained }) : t('wallet.rewardCredited', { n: w.rewardChips }));
|
||||
} catch (e) {
|
||||
if (e instanceof GatewayError && e.code === 'reward_capped') {
|
||||
showToast(t('wallet.rewardCapped'));
|
||||
} else {
|
||||
handleError(e);
|
||||
}
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [w, c] = await Promise.all([gateway.wallet(), gateway.catalog()]);
|
||||
@@ -54,6 +91,9 @@
|
||||
}
|
||||
onMount(() => {
|
||||
void load();
|
||||
// Probe rewarded-ad readiness once so the "watch for chips" button only shows when a view would
|
||||
// actually play.
|
||||
void rewardedReady().then((r) => (rewardReady = r));
|
||||
// Fallback to the payment push: re-fetch when the tab regains focus, i.e. the user returned
|
||||
// from the provider's payment window.
|
||||
const onVisible = () => {
|
||||
@@ -171,6 +211,14 @@
|
||||
<!-- prettier-ignore -->
|
||||
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
|
||||
{/if}
|
||||
{#if canReward}
|
||||
<div class="row reward" data-testid="reward-row">
|
||||
<span class="name">{t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })}</span>
|
||||
<button class="buy" class:working={busyId === 'reward'} data-testid="watch-ad" disabled={busy} onclick={onWatchAd}
|
||||
>{t('wallet.rewardCta')}</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#each values as p (p.productId)}
|
||||
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
||||
<span class="name">{p.title}</span>
|
||||
|
||||
Reference in New Issue
Block a user