Skip to content

Commit 4e298b3

Browse files
committed
Wallet: New send screen with WebGPU effects (#7432)
1 parent 1c6730a commit 4e298b3

107 files changed

Lines changed: 8900 additions & 3012 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Telegram web app using TypeScript and Teact, a custom React-like UI library. Fol
3838
- Cache a pure function's result when using it more than once in the same scope.
3939
- When a value is guaranteed at runtime but TypeScript cannot infer it, use `!` instead of a guard that silently skips work. Avoid unnecessary `as` casts; prefer `satisfies` where appropriate.
4040
- Use `undefined`, not `null`.
41+
- Compare with server dates (message dates, expiries, `validUntil`) through `getServerTime()` from `src/util/serverTime.ts`, not `Date.now()`: the device clock can be off. Use `Date.now()` or `performance.now()` only for local durations.
4142
- Avoid conditional object spreads: use `{ field: condition ? value : undefined }` so TypeScript checks the field against the target type.
4243
- Comments explain complex logic. Start with a capital letter, wrap code entities in backticks, and omit the trailing period for single-sentence comments; punctuate each sentence in multi-sentence comments.
4344
- Docs and comments assert current behavior in the present tense. Keep bug/change history and comparisons to prior code in Git history. Comparisons with hypothetical alternatives are fine when they explain the current design.

‎src/@types/global.d.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ declare module '*.strings?raw' {
126126
const content: string;
127127
export default content;
128128
}
129-
declare module '*.glsl?raw' {
129+
declare module '*.wgsl?raw' {
130130
const content: string;
131131
export default content;
132132
}
@@ -229,3 +229,25 @@ interface FileSystemSyncAccessHandle {
229229
type FilesystemReadWriteOptions = {
230230
at: number;
231231
};
232+
233+
// WebGPU parts missing from the DOM library of TypeScript
234+
interface HTMLCanvasElement {
235+
getContext(contextId: 'webgpu'): GPUCanvasContext | null;
236+
}
237+
238+
declare const GPUBufferUsage: {
239+
readonly COPY_DST: GPUBufferUsageFlags;
240+
readonly VERTEX: GPUBufferUsageFlags;
241+
readonly UNIFORM: GPUBufferUsageFlags;
242+
};
243+
244+
declare const GPUShaderStage: {
245+
readonly VERTEX: GPUShaderStageFlags;
246+
readonly FRAGMENT: GPUShaderStageFlags;
247+
};
248+
249+
declare const GPUTextureUsage: {
250+
readonly COPY_SRC: GPUTextureUsageFlags;
251+
readonly COPY_DST: GPUTextureUsageFlags;
252+
readonly RENDER_ATTACHMENT: GPUTextureUsageFlags;
253+
};

‎src/api/gramjs/methods/tonConnect.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ import { deserializeBytes, serializeBytes } from '../helpers/misc';
5656
import { invokeRequest } from './client';
5757
import {
5858
checkIsAddressActive, deriveWalletSeeds, estimateFee, exportWalletSecretPhrase, fetchToncenterWalletState,
59-
forgetRefusedTransfer, isTransferPending, loadSentTransfer, rememberSentTransfer, runWalletSend, sendTransferData,
59+
forgetRefusedTransfer, isTransferPending, loadSentTransfer, markSentTransferAccepted, rememberSentTransfer,
60+
runWalletSend, sendTransferData,
6061
} from './wallet';
6162

6263
type TonConnectCredentials = {
@@ -645,6 +646,7 @@ async function processTonConnectRequest({
645646
const sent = buildApiWalletSentTransfer(updates, { seqno: current.seqno, validUntil });
646647

647648
if (sent) {
649+
await markSentTransferAccepted(credentials.ownAddress, current.seqno);
648650
sentTransfer = {
649651
msgHash: sent.msgHash, seqno: current.seqno, validUntil, amount: total, toAddress: firstMessage.address,
650652
};

‎src/api/gramjs/methods/wallet.ts‎

Lines changed: 130 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Api as GramJs } from '../../../lib/gramjs';
2-
import { RPCError } from '../../../lib/gramjs/errors';
2+
import { RPCError, TimedOutError } from '../../../lib/gramjs/errors';
33
import { generateRandomBigInt } from '../../../lib/gramjs/Helpers';
44

55
import type { WalletSeeds } from '../../../lib/ton/bip39';
@@ -113,6 +113,10 @@ type DnsRecordsResponse = ToncenterError & {
113113
records?: { domain?: string; dns_wallet?: string }[];
114114
};
115115

116+
type TransactionsResponse = ToncenterError & {
117+
transactions?: { in_msg?: { hash?: string; message_content?: { hash?: string } } }[];
118+
};
119+
116120
type WalletKeys = { seed: Uint8Array; publicKey: Uint8Array };
117121

118122
type WalletKeyChange = {
@@ -142,6 +146,14 @@ const PUBLIC_KEY_LENGTH = 32;
142146
// Nanotons: below this balance the chain bounces the rotation for the fee
143147
const KEY_ROTATION_MIN_BALANCE = 10_000_000n;
144148
const KEY_ROTATION_POLL_INTERVAL = 3000;
149+
const PENDING_TRANSFER_POLL_INTERVAL = 1000;
150+
const MAX_FAILED_PENDING_POLLS = 3;
151+
// A transfer without an answer is looked up only while its history row exists (up to 5 minutes), so the latest
152+
// transactions are enough
153+
const UNANSWERED_TRANSFER_LOOKUP_LIMIT = 20;
154+
// A transfer waits behind the previous one no longer than a transfer of ours lives, even when a TON Connect request
155+
// keeps its message valid for minutes: the wallet lock and the queued row should not hang that long
156+
const MAX_PENDING_TRANSFER_WAIT = TRANSFER_TTL_SECONDS * 1000;
145157
// Seconds past the request's expiry before it counts as never executed: the indexer and the local clock can lag
146158
// behind the chain, as the iOS app assumes too
147159
const KEY_ROTATION_EXPIRY_MARGIN = 120;
@@ -150,7 +162,15 @@ const KEY_CHANGES_PAGE_SIZE = 100;
150162
const previousSigningKeysCache = new Map<string, WalletKeys[]>();
151163
// The last transfer broadcast per wallet, until its seqno moves on or it expires. Kept on disk as well:
152164
// a transfer sent right before a reload is not in the history yet.
153-
type SentTransfer = { seqno: number; validUntil: number; isKeyRotation?: boolean; replacedRotation?: SentTransfer };
165+
type SentTransfer = {
166+
seqno: number;
167+
validUntil: number;
168+
isKeyRotation?: boolean;
169+
isAccepted?: boolean;
170+
// Set when the send got no answer: the transfer may still go through, see `isTransferUnanswered`
171+
isUnanswered?: boolean;
172+
replacedRotation?: SentTransfer;
173+
};
154174
const sentTransfers = new Map<string, SentTransfer>();
155175
let sentTransfersRestored: Promise<void> | undefined;
156176
const SEND_LOCK_PREFIX = 'wallet-send:';
@@ -217,6 +237,23 @@ export async function fetchWalletTransactionsByMsgHash({ msgHashes }: { msgHashe
217237
return buildWalletTransactionList(result);
218238
}
219239

240+
// Returns the hash of the message that brought one of the bodies to the wallet. The server finds the transaction by
241+
// this hash. The transaction hash does not work: the server history sometimes lists a transfer under an id that
242+
// toncenter does not know.
243+
export async function fetchWalletMsgHashByBody({ ownAddress, bodyHashes }: {
244+
ownAddress: string;
245+
bodyHashes: string[];
246+
}) {
247+
const result = await invokeTonApiRequest<TransactionsResponse>({
248+
endpoint: '/api/v3/transactions',
249+
query: `account=${encodeURIComponent(ownAddress)}&limit=${UNANSWERED_TRANSFER_LOOKUP_LIMIT}&sort=desc`,
250+
});
251+
252+
return result?.transactions?.find(({ in_msg }) => (
253+
in_msg?.message_content?.hash && bodyHashes.includes(in_msg.message_content.hash)
254+
))?.in_msg?.hash;
255+
}
256+
220257
function buildWalletTransactionList(result: GramJs.wallet.Transactions) {
221258
return {
222259
transactions: result.transactions.map(buildApiWalletTransaction),
@@ -780,7 +817,7 @@ async function sendKeyRotation({
780817
// A lost connection leaves the outcome unknown, so the new phrase must stay on the device
781818
forgetRefusedTransfer(ownAddress, state.seqno, err);
782819

783-
return { error: mapWalletRpcError(err, 'BACKUP_FAILED'), isSent: err instanceof RPCError ? undefined : true };
820+
return { error: mapWalletRpcError(err, 'BACKUP_FAILED'), isSent: getIsServerRefusal(err) ? undefined : true };
784821
}
785822

786823
return { now };
@@ -1169,7 +1206,7 @@ export async function sendTonTransfer({
11691206
pendingValidUntil?: number;
11701207
words?: string[];
11711208
password?: string;
1172-
}, onLocalMessage?: NoneToVoidFunction): Promise<SendTonTransferResult | undefined> {
1209+
}, onQueued?: (localMessageId?: number) => void): Promise<SendTonTransferResult | undefined> {
11731210
const collectible = collectibleAddress ? parseTonTransferAddress(collectibleAddress) : undefined;
11741211

11751212
if (collectible) {
@@ -1241,22 +1278,54 @@ export async function sendTonTransfer({
12411278
sendApiUpdate({
12421279
'@type': 'newMessage', id: localMessage.id, chatId: localMessage.chatId, message: localMessage,
12431280
});
1244-
onLocalMessage?.();
12451281
}
12461282

1283+
onQueued?.(localMessage?.id);
1284+
1285+
// The wait is counted from the moment the transfer is queued. Otherwise a transfer that waits for the lock could wait
1286+
// longer than its pending row stays in the history.
1287+
const waitUntil = Date.now() + MAX_PENDING_TRANSFER_WAIT;
12471288
const sending = runWalletSend<SendTonTransferResult | undefined>(ownAddress, async () => {
1248-
const state = await fetchToncenterWalletState(ownAddress);
1289+
if (Date.now() >= waitUntil) {
1290+
return { error: 'TRANSFER_PENDING' };
1291+
}
1292+
1293+
let state = await fetchToncenterWalletState(ownAddress);
1294+
let failedPolls = 0;
1295+
1296+
// The previous transfer holds the seqno until the chain takes it or it expires, as in the iOS app
1297+
while (!state || isTransferPending(ownAddress, state.seqno, pendingSeqno, pendingValidUntil)) {
1298+
if (Date.now() >= waitUntil || (state && isTransferUnanswered(ownAddress, state.seqno))) {
1299+
return { error: 'TRANSFER_PENDING' };
1300+
}
1301+
1302+
failedPolls = state ? 0 : failedPolls + 1;
1303+
if (failedPolls > MAX_FAILED_PENDING_POLLS) {
1304+
return undefined;
1305+
}
1306+
1307+
await pause(PENDING_TRANSFER_POLL_INTERVAL);
1308+
state = await fetchToncenterWalletState(ownAddress);
1309+
}
1310+
1311+
if (isTransferUnanswered(ownAddress, state.seqno)) {
1312+
return { error: 'TRANSFER_PENDING' };
1313+
}
1314+
1315+
// The user may have replaced the wallet while this transfer waited for the lock or for the previous transfer. The
1316+
// key of the old wallet must not send it.
1317+
const current = await fetchWalletState();
12491318

1250-
if (!state) {
1319+
if (!current) {
12511320
return undefined;
12521321
}
12531322

1254-
if (!state.isSupportedContract) {
1255-
return { error: 'UNSUPPORTED_WALLET' };
1323+
if (current.state?.address !== ownAddress) {
1324+
return { error: 'WALLET_CHANGED' };
12561325
}
12571326

1258-
if (isTransferPending(ownAddress, state.seqno, pendingSeqno, pendingValidUntil)) {
1259-
return { error: 'TRANSFER_PENDING' };
1327+
if (!state.isSupportedContract) {
1328+
return { error: 'UNSUPPORTED_WALLET' };
12601329
}
12611330

12621331
// Checked under the lock: another tab may have handed the item over meanwhile
@@ -1322,15 +1391,23 @@ export async function sendTonTransfer({
13221391
const dataGasless = state.isDeployed && !collectible && !isSelfTransfer
13231392
? await buildSignedGaslessTransfer({ ...transfer, ownAddress: own })
13241393
: undefined;
1394+
const bodyHashes = (dataGasless ? [dataNormal, dataGasless] : [dataNormal])
1395+
.map(({ bodyHash }) => bufferToBase64(bodyHash));
13251396

13261397
const validUntil = transfer.now + TRANSFER_TTL_SECONDS;
13271398
await rememberSentTransfer(ownAddress, transfer.seqno, validUntil);
13281399

13291400
try {
1330-
const result = await sendTransferData(dataNormal, dataGasless, randomId, toUser, localMessage);
1401+
const result = await sendTransferData(dataNormal.boc, dataGasless?.boc, randomId, toUser, localMessage);
13311402
const sent = buildApiWalletSentTransfer(result, { seqno: transfer.seqno, validUntil });
1403+
if (!sent) {
1404+
await updateSentTransfer(ownAddress, transfer.seqno, { isUnanswered: true });
1405+
return { error: 'SEND_FAILED', isSent: true, bodyHashes };
1406+
}
1407+
1408+
await markSentTransferAccepted(ownAddress, transfer.seqno);
13321409

1333-
return sent ? { ...sent, encryptedComment: encryptedCommentBase64 } : { error: 'SEND_FAILED' };
1410+
return { ...sent, encryptedComment: encryptedCommentBase64 };
13341411
} catch (err: unknown) {
13351412
if (DEBUG) {
13361413
// eslint-disable-next-line no-console
@@ -1339,7 +1416,7 @@ export async function sendTonTransfer({
13391416

13401417
forgetRefusedTransfer(ownAddress, transfer.seqno, err);
13411418

1342-
if (isGasless && isRelayerRefusal(err, dataGasless)) {
1419+
if (isGasless && isRelayerRefusal(err, dataGasless?.boc)) {
13431420
return { error: 'GASLESS_UNAVAILABLE' };
13441421
}
13451422

@@ -1348,7 +1425,7 @@ export async function sendTonTransfer({
13481425
return { error: 'KEY_MISMATCH' };
13491426
}
13501427

1351-
return { error: 'SEND_FAILED' };
1428+
return getIsServerRefusal(err) ? { error: 'SEND_FAILED' } : { error: 'SEND_FAILED', isSent: true, bodyHashes };
13521429
}
13531430
});
13541431

@@ -1357,7 +1434,8 @@ export async function sendTonTransfer({
13571434
try {
13581435
outcome = await sending;
13591436
} finally {
1360-
if (localMessage && (!outcome || 'error' in outcome)) {
1437+
// A transfer that may still execute keeps its message pending, as in the iOS app
1438+
if (localMessage && (!outcome || ('error' in outcome && !outcome.isSent))) {
13611439
sendApiUpdate({
13621440
'@type': 'updateMessageSendFailed',
13631441
chatId: localMessage.chatId,
@@ -1477,11 +1555,28 @@ export async function rememberSentTransfer(
14771555
await WALLET_IDB_STORE.set(`${SENT_TRANSFER_KEY_PREFIX}${rawAddress}`, sent).catch(() => undefined);
14781556
}
14791557

1480-
// Only the server's own refusal means the message never went out; a timeout or a lost connection
1481-
// leaves it unknown, and the record stays until it expires. The refusal says nothing of the rotation
1482-
// the message replaced, so that record comes back.
1558+
export function markSentTransferAccepted(ownAddress: string, seqno: number) {
1559+
return updateSentTransfer(ownAddress, seqno, { isAccepted: true });
1560+
}
1561+
1562+
async function updateSentTransfer(ownAddress: string, seqno: number, update: Partial<SentTransfer>) {
1563+
const rawAddress = toRawAddress(ownAddress)!;
1564+
const sent = sentTransfers.get(rawAddress);
1565+
if (sent?.seqno !== seqno) return;
1566+
1567+
const updated = { ...sent, ...update };
1568+
sentTransfers.set(rawAddress, updated);
1569+
await WALLET_IDB_STORE.set(`${SENT_TRANSFER_KEY_PREFIX}${rawAddress}`, updated).catch(() => undefined);
1570+
}
1571+
1572+
// Removes the record only when the server refused the transfer: then the message was never sent. After a timeout or a
1573+
// lost connection nobody knows if it was sent, so the record stays until it expires and blocks the next transfers. A
1574+
// refusal says nothing about the key rotation that this message replaced, so the rotation record is restored.
14831575
export function forgetRefusedTransfer(ownAddress: string, seqno: number, err: unknown) {
1484-
if (!(err instanceof RPCError)) return;
1576+
if (!getIsServerRefusal(err)) {
1577+
void updateSentTransfer(ownAddress, seqno, { isUnanswered: true });
1578+
return;
1579+
}
14851580

14861581
const rawAddress = toRawAddress(ownAddress)!;
14871582
const sent = sentTransfers.get(rawAddress);
@@ -1510,6 +1605,21 @@ export function isTransferPending(
15101605
|| (seqno === sent?.seqno && now < sent.validUntil);
15111606
}
15121607

1608+
// A transfer without an answer from the server may still go through. If the user repeats a payment that looked lost,
1609+
// the new transfer would pay a second time. So such a transfer blocks the next ones until it expires, even after the
1610+
// seqno moves on: the chain may have already taken it.
1611+
export function isTransferUnanswered(ownAddress: string, seqno: number) {
1612+
const sent = sentTransfers.get(toRawAddress(ownAddress)!);
1613+
if (!sent || getServerTime() >= sent.validUntil) return false;
1614+
1615+
return sent.isUnanswered || (seqno === sent.seqno && !sent.isAccepted);
1616+
}
1617+
1618+
// A timeout means the server stopped waiting, not that it refused: the request may still have gone through
1619+
function getIsServerRefusal(err: unknown) {
1620+
return err instanceof RPCError && !(err instanceof TimedOutError);
1621+
}
1622+
15131623
// The server broadcasts whichever message of the pair it chooses
15141624
export async function sendTransferData(
15151625
dataNormal: Uint8Array<ArrayBuffer>, dataGasless: Uint8Array<ArrayBuffer> | undefined, randomId: bigint,

‎src/api/types/wallet.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ export interface ApiWalletTransaction {
108108
id: string;
109109
// Set for a transfer sent from this device, see `ApiWalletSentTransfer`
110110
msgHash?: string;
111+
// Set instead of `msgHash` for a transfer that the server did not answer, see `SendTonTransferResult`
112+
bodyHashes?: string[];
113+
// The local id of the chat card sent with the transfer. The card finds this row by it.
114+
localMessageId?: number;
115+
// A transfer sent from this device keeps it from the moment it is queued, while `id` changes as the server
116+
// takes it and the chain confirms it
117+
localId?: string;
111118
seqno?: number;
112119
validUntil?: number;
113120
isIncoming: boolean;
@@ -281,4 +288,7 @@ export type SendTonTransferError =
281288
| 'NFT_NOT_OWNED' | 'NFT_ON_SALE' | 'NFT_NOT_INITIALIZED' | 'NFT_UNAVAILABLE'
282289
// The key was changed elsewhere since the transfer was signed
283290
| 'KEY_MISMATCH' | ExportSecretPhraseError;
284-
export type SendTonTransferResult = ApiWalletSentTransfer | { error: SendTonTransferError };
291+
// `isSent` means the error happened after the transfer was sent, so the transfer may still go through. The chain then
292+
// shows it under one of `bodyHashes`: base64 hashes of the message bodies that the server may have sent.
293+
export type SendTonTransferResult = ApiWalletSentTransfer
294+
| { error: SendTonTransferError; isSent?: true; bodyHashes?: string[] };

‎src/assets/localization/fallback.strings‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3197,6 +3197,10 @@
31973197
"WalletSendRecipient" = "Address or name";
31983198
"WalletSendRecent" = "Recent";
31993199
"WalletSendAddressLinked" = "This TON Blockchain address is linked to **{name}** on Telegram.";
3200+
"WalletSendRecipientWallet" = "{name}’s wallet";
3201+
"WalletSendUnlinkedWallet" = "Unlinked wallet";
3202+
"WalletSendSwitchCurrency" = "Switch currency";
3203+
"WalletRecipientGramWallet" = "Gram Wallet";
32003204
"WalletSendAmount" = "Amount";
32013205
"WalletSendBalance" = "Balance: {amount}";
32023206
"WalletSendNetworkFee" = "Network fee: {amount}";
@@ -3214,6 +3218,7 @@
32143218
"WalletCommentTooLong" = "Encrypted comment is too long";
32153219
"WalletCommentEncryptionUnavailable" = "This wallet can't receive encrypted comments yet";
32163220
"WalletSendContinue" = "Continue";
3221+
"WalletSendAmountButton" = "Send {amount}";
32173222
"WalletFeatureInstantTitle" = "Send Money Instantly";
32183223
"WalletFeatureInstantText" = "Send Grams in any chat, just like sharing a photo.";
32193224
"WalletFeatureNoFeesTitle" = "No Fees";
@@ -3267,6 +3272,7 @@
32673272
"WalletSendSuccess" = "Grams sent";
32683273
"WalletSendSuccessGasless" = "Grams sent, no fee charged";
32693274
"WalletSendError" = "Failed to send Grams. Please try again.";
3275+
"WalletSendPending" = "The transfer may have been sent. Don’t send it again while its status is being checked.";
32703276
"WalletExportError" = "Failed to export the recovery phrase. Please try again.";
32713277
"WalletBackupDisabledError" = "Backup is disabled for this wallet";
32723278
"WalletBackupFailedError" = "Failed to update the wallet backup";
-256 Bytes
Binary file not shown.
-12 Bytes
Binary file not shown.

‎src/assets/models/diamond/facetProjection.bin‎

Lines changed: 0 additions & 1 deletion
This file was deleted.
-236 KB
Binary file not shown.

0 commit comments

Comments
 (0)