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:
@@ -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