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

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:
Ilia Denisov
2026-07-10 01:27:26 +02:00
parent dbd76d53e8
commit 9acf6ab3b4
22 changed files with 79 additions and 105 deletions
@@ -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
View File
@@ -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 };
}
+3 -3
View File
@@ -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[]>;
+2 -3
View File
@@ -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
View File
@@ -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));
}
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -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;
}
}
+1 -1
View File
@@ -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 }));