11import { Api as GramJs } from '../../../lib/gramjs' ;
2- import { RPCError } from '../../../lib/gramjs/errors' ;
2+ import { RPCError , TimedOutError } from '../../../lib/gramjs/errors' ;
33import { generateRandomBigInt } from '../../../lib/gramjs/Helpers' ;
44
55import 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+
116120type WalletKeys = { seed : Uint8Array ; publicKey : Uint8Array } ;
117121
118122type WalletKeyChange = {
@@ -142,6 +146,14 @@ const PUBLIC_KEY_LENGTH = 32;
142146// Nanotons: below this balance the chain bounces the rotation for the fee
143147const KEY_ROTATION_MIN_BALANCE = 10_000_000n ;
144148const 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
147159const KEY_ROTATION_EXPIRY_MARGIN = 120 ;
@@ -150,7 +162,15 @@ const KEY_CHANGES_PAGE_SIZE = 100;
150162const 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+ } ;
154174const sentTransfers = new Map < string , SentTransfer > ( ) ;
155175let sentTransfersRestored : Promise < void > | undefined ;
156176const 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+
220257function 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.
14831575export 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
15141624export async function sendTransferData (
15151625 dataNormal : Uint8Array < ArrayBuffer > , dataGasless : Uint8Array < ArrayBuffer > | undefined , randomId : bigint ,
0 commit comments