fix(payments): VK-iOS freeze is purchase-only; remove the rewarded diagnostic
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
Testing the rewarded slice surfaced a contradiction: rewarded ads let a VK-iOS
user earn vk chips, but the blanket VK-iOS "spend freeze" then blocked spending
them (the wallet showed "5 (view-only)"). Apple's ToS forbids only BUYING in-app
values on VK-iOS (money -> chips), not spending or earning them — so the freeze is
corrected to purchase-only: vkFrozen() now gates only CreateOrder (the money-in
step), not spendableSources (spending). VK-wallet chips — earned via rewarded ads
or bought on the same account elsewhere (VK Android) — now spend on VK-iOS too.
(Owner's ToS finding.)
Also: the temporary contour diagnostic confirmed VK returns only {result:true} for
a rewarded view (no token/signature) — client-attested is final, no hardening
possible — so the diagnostic (the log and the diag wire field) is removed.
Docs: PAYMENTS(+ru) VK-iOS freeze + D17 amend + PLAN E6. Tests updated (gate /
wallet VK-iOS now spendable).
This commit is contained in:
@@ -635,9 +635,13 @@ impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the backend `Credi
|
||||
order-less, idempotent on a client nonce, floored by the caps, payout from config
|
||||
`rewarded_payout_chips` default 0 = off), the `wallet.reward` edge op returning the updated wallet
|
||||
(with `reward_chips` gating the "watch for chips" CTA), and a **contour test stub** (`VITE_ADS_STUB` →
|
||||
a toast instead of a real ad; prod always real). A temporary diagnostic logs the raw VK `data` so we
|
||||
confirm on the contour exactly what VK returns (harden to signature-verify if it carries one). The
|
||||
legacy `paid_account` / `hint_balance` drop (D31) and the **interstitial** are the next slice.
|
||||
a toast instead of a real ad; prod always real). A temporary diagnostic confirmed on the contour that
|
||||
VK returns **only `{result:true}`** (no token/signature) — client-attested is final, no hardening
|
||||
possible; the diagnostic is removed. The slice also **corrects the VK-iOS freeze to purchase-only**
|
||||
(rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending —
|
||||
Apple forbids only *buying* in-app values, not spending or earning them; `vkFrozen()` now gates only
|
||||
`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). The legacy `paid_account`
|
||||
/ `hint_balance` drop (D31) and the **interstitial** are the next slice.
|
||||
|
||||
**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video
|
||||
(credits chips via server verify), plus extending the existing banner suppression to
|
||||
|
||||
@@ -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}},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,12 +175,10 @@ func (s *Server) handleWalletBuy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, walletDTOFrom(view))
|
||||
}
|
||||
|
||||
// walletRewardRequest is the POST body of a rewarded-video credit: a client nonce (the idempotency
|
||||
// key for a single watched view — a retry credits once) and Diag, the raw VK ad result forwarded for
|
||||
// a temporary diagnostic log while we confirm exactly what VK returns on the contour.
|
||||
// walletRewardRequest is the POST body of a rewarded-video credit: a client nonce, the idempotency
|
||||
// key for a single watched view (a retry credits once).
|
||||
type walletRewardRequest struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Diag string `json:"diag"`
|
||||
}
|
||||
|
||||
// handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and
|
||||
@@ -203,15 +201,6 @@ func (s *Server) handleWalletReward(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
// Temporary diagnostic: log the raw VK ad result (bounded — untrusted client input) to learn its
|
||||
// exact shape on the contour; if it carries a verifiable token we harden past client-attested.
|
||||
if req.Diag != "" {
|
||||
diag := req.Diag
|
||||
if len(diag) > 2000 {
|
||||
diag = diag[:2000]
|
||||
}
|
||||
s.log.Info("rewarded ad diagnostic", zap.String("account", uid.String()), zap.String("vk_data", diag))
|
||||
}
|
||||
outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
|
||||
+6
-4
@@ -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
|
||||
|
||||
@@ -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, только просмотр.
|
||||
|
||||
+6
-4
@@ -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 сервер активирует только одноимённый сегмент. Держится на
|
||||
|
||||
@@ -384,18 +384,16 @@ type walletBuyBody struct {
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
// walletRewardBody is the rewarded-video credit request: the per-view idempotency nonce and the raw
|
||||
// VK ad result forwarded for the temporary contour diagnostic.
|
||||
// walletRewardBody is the rewarded-video credit request: the per-view idempotency nonce.
|
||||
type walletRewardBody struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Diag string `json:"diag"`
|
||||
}
|
||||
|
||||
// WalletReward credits a watched rewarded video and returns the updated wallet (client-attested + a
|
||||
// backend daily cap). A reached cap or unconfigured payout surfaces as an APIError domain code.
|
||||
func (c *Client) WalletReward(ctx context.Context, userID, nonce, diag string) (WalletResp, error) {
|
||||
// backend daily/hourly cap). A reached cap or unconfigured payout surfaces as an APIError domain code.
|
||||
func (c *Client) WalletReward(ctx context.Context, userID, nonce string) (WalletResp, error) {
|
||||
var out WalletResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/reward", userID, "", walletRewardBody{Nonce: nonce, Diag: diag}, &out)
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/reward", userID, "", walletRewardBody{Nonce: nonce}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
|
||||
@@ -382,11 +382,11 @@ func walletOrderHandler(backend *backendclient.Client, minter InvoiceMinter) Han
|
||||
}
|
||||
|
||||
// walletRewardHandler credits a watched rewarded video and returns the updated wallet. The nonce is
|
||||
// the client's per-view idempotency key; diag carries the raw VK ad result for the contour diagnostic.
|
||||
// the client's per-view idempotency key.
|
||||
func walletRewardHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsWalletRewardRequest(req.Payload, 0)
|
||||
w, err := backend.WalletReward(ctx, req.UserID, string(in.Nonce()), string(in.Diag()))
|
||||
w, err := backend.WalletReward(ctx, req.UserID, string(in.Nonce()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -877,10 +877,9 @@ table WalletBuyRequest {
|
||||
}
|
||||
|
||||
// WalletRewardRequest credits a watched rewarded video: nonce is the per-view idempotency key (a
|
||||
// retry credits once); diag carries the raw VK ad result for a temporary contour diagnostic log.
|
||||
// retry credits once).
|
||||
table WalletRewardRequest {
|
||||
nonce:string;
|
||||
diag:string;
|
||||
}
|
||||
|
||||
// WalletOrderRequest opens a money order to fund a chip pack: the pack to buy.
|
||||
|
||||
@@ -49,23 +49,12 @@ func (rcv *WalletRewardRequest) Nonce() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *WalletRewardRequest) Diag() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func WalletRewardRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
builder.StartObject(1)
|
||||
}
|
||||
func WalletRewardRequestAddNonce(builder *flatbuffers.Builder, nonce flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(nonce), 0)
|
||||
}
|
||||
func WalletRewardRequestAddDiag(builder *flatbuffers.Builder, diag flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(diag), 0)
|
||||
}
|
||||
func WalletRewardRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -27,34 +27,22 @@ nonce(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
diag():string|null
|
||||
diag(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
diag(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
static startWalletRewardRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(2);
|
||||
builder.startObject(1);
|
||||
}
|
||||
|
||||
static addNonce(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, nonceOffset, 0);
|
||||
}
|
||||
|
||||
static addDiag(builder:flatbuffers.Builder, diagOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, diagOffset, 0);
|
||||
}
|
||||
|
||||
static endWalletRewardRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createWalletRewardRequest(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset, diagOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createWalletRewardRequest(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
WalletRewardRequest.startWalletRewardRequest(builder);
|
||||
WalletRewardRequest.addNonce(builder, nonceOffset);
|
||||
WalletRewardRequest.addDiag(builder, diagOffset);
|
||||
return WalletRewardRequest.endWalletRewardRequest(builder);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-7
@@ -17,12 +17,10 @@ export function adsStubEnabled(): boolean {
|
||||
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). */
|
||||
/** RewardedResult is one rewarded-ad view: whether it was watched, and whether it was the test stub
|
||||
* (so the caller can show the "ad fired" marker toast). */
|
||||
export interface RewardedResult {
|
||||
watched: boolean;
|
||||
data: string;
|
||||
stub: boolean;
|
||||
}
|
||||
|
||||
@@ -44,8 +42,7 @@ export async function rewardedReady(): Promise<boolean> {
|
||||
*/
|
||||
export async function showRewarded(): Promise<RewardedResult> {
|
||||
if (adsStubEnabled()) {
|
||||
return { watched: true, data: JSON.stringify({ stub: true }), stub: true };
|
||||
return { watched: true, stub: true };
|
||||
}
|
||||
const r = await vkShowRewarded();
|
||||
return { watched: r.watched, data: r.data, stub: false };
|
||||
return { watched: await vkShowRewarded(), stub: false };
|
||||
}
|
||||
|
||||
@@ -152,9 +152,9 @@ export interface GatewayClient {
|
||||
* client opens; chips are credited later, by the verified server callback. Direct rail only. */
|
||||
walletOrder(productId: string): Promise<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>;
|
||||
* 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[]>;
|
||||
|
||||
@@ -78,12 +78,11 @@ describe('codec', () => {
|
||||
expect(w.rewardChips).toBe(7);
|
||||
});
|
||||
|
||||
it('round-trips the wallet reward request (nonce + diag)', () => {
|
||||
it('round-trips the wallet reward request (nonce)', () => {
|
||||
const req = fb.WalletRewardRequest.getRootAsWalletRewardRequest(
|
||||
new ByteBuffer(encodeWalletReward('nonce-9', '{"result":true}')),
|
||||
new ByteBuffer(encodeWalletReward('nonce-9')),
|
||||
);
|
||||
expect(req.nonce()).toBe('nonce-9');
|
||||
expect(req.diag()).toBe('{"result":true}');
|
||||
});
|
||||
|
||||
it('round-trips the wallet order request and response', () => {
|
||||
|
||||
+2
-4
@@ -403,14 +403,12 @@ export function decodeWallet(buf: Uint8Array): Wallet {
|
||||
}
|
||||
|
||||
// encodeWalletReward wraps a watched rewarded video for crediting (POST /user/wallet/reward): the
|
||||
// per-view idempotency nonce and the raw VK ad result forwarded for the contour diagnostic.
|
||||
export function encodeWalletReward(nonce: string, diag: string): Uint8Array {
|
||||
// per-view idempotency nonce.
|
||||
export function encodeWalletReward(nonce: string): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const n = b.createString(nonce);
|
||||
const d = b.createString(diag);
|
||||
fb.WalletRewardRequest.startWalletRewardRequest(b);
|
||||
fb.WalletRewardRequest.addNonce(b, n);
|
||||
fb.WalletRewardRequest.addDiag(b, d);
|
||||
return finish(b, fb.WalletRewardRequest.endWalletRewardRequest(b));
|
||||
}
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ export class MockGateway implements GatewayClient {
|
||||
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId };
|
||||
}
|
||||
|
||||
async walletReward(_nonce: string, _diag: string): Promise<Wallet> {
|
||||
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;
|
||||
|
||||
@@ -240,8 +240,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async walletOrder(productId: string) {
|
||||
return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId)));
|
||||
},
|
||||
async walletReward(nonce: string, diag: string) {
|
||||
return codec.decodeWallet(await exec('wallet.reward', codec.encodeWalletReward(nonce, diag)));
|
||||
async walletReward(nonce: string) {
|
||||
return codec.decodeWallet(await exec('wallet.reward', codec.encodeWalletReward(nonce)));
|
||||
},
|
||||
|
||||
async friendsList() {
|
||||
|
||||
+6
-7
@@ -213,16 +213,15 @@ export async function vkRewardedReady(): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* vkShowRewarded shows a rewarded ad (VKWebAppShowNativeAds) and reports whether it was watched
|
||||
* (data.result) plus the full raw provider result as JSON — forwarded to the backend for the
|
||||
* temporary contour diagnostic (to confirm exactly what VK returns). A rejected call reports
|
||||
* not-watched with the error captured.
|
||||
* (data.result — VK returns only this boolean, no server-verifiable token). A rejected call reports
|
||||
* not-watched.
|
||||
*/
|
||||
export async function vkShowRewarded(): Promise<{ watched: boolean; data: string }> {
|
||||
export async function vkShowRewarded(): Promise<boolean> {
|
||||
try {
|
||||
const data = await (await bridge()).send('VKWebAppShowNativeAds', { ad_format: 'reward' });
|
||||
return { watched: !!(data as { result?: boolean }).result, data: JSON.stringify(data) };
|
||||
} catch (e) {
|
||||
return { watched: false, data: JSON.stringify({ error: String(e) }) };
|
||||
return !!(data as { result?: boolean }).result;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
const nonce = crypto.randomUUID();
|
||||
const before = wallet?.segments.find((s) => s.source === 'vk')?.chips ?? 0;
|
||||
const w = await gateway.walletReward(nonce, res.data);
|
||||
const w = await gateway.walletReward(nonce);
|
||||
wallet = w;
|
||||
const gained = (w.segments.find((s) => s.source === 'vk')?.chips ?? 0) - before;
|
||||
showToast(gained > 0 ? t('wallet.rewardCredited', { n: gained }) : t('wallet.rewardCredited', { n: w.rewardChips }));
|
||||
|
||||
Reference in New Issue
Block a user