From 94f4cee18caa7e6a248d96453eb95aa92104479c Mon Sep 17 00:00:00 2001 From: "V. K." Date: Tue, 6 Jan 2026 11:44:36 +0400 Subject: [PATCH 1/3] feat(custom-time): rework validUntil validation logic for transaction events --- packages/walletkit/GLOBALOPTIONS.md | 37 ++++ packages/walletkit/README.md | 20 ++ .../src/contracts/v4r2/WalletV4R2.ts | 7 +- .../src/contracts/v4r2/WalletV4R2Adapter.ts | 6 +- .../src/contracts/w5/WalletV5R1Adapter.ts | 36 ++-- .../walletkit/src/core/KitGlobalOptions.ts | 23 +++ .../walletkit/src/core/RequestProcessor.ts | 20 +- .../walletkit/src/core/TonWalletKit.spec.ts | 183 +++++++++++++++++- packages/walletkit/src/core/TonWalletKit.ts | 3 +- .../src/core/wallet/extensions/ton.ts | 11 ++ .../src/handlers/TransactionHandler.ts | 22 +-- packages/walletkit/src/index.ts | 1 + packages/walletkit/src/types/config.ts | 5 - packages/walletkit/src/types/internal.ts | 3 - packages/walletkit/src/utils/time.ts | 8 + 15 files changed, 319 insertions(+), 66 deletions(-) create mode 100644 packages/walletkit/GLOBALOPTIONS.md create mode 100644 packages/walletkit/src/core/KitGlobalOptions.ts diff --git a/packages/walletkit/GLOBALOPTIONS.md b/packages/walletkit/GLOBALOPTIONS.md new file mode 100644 index 000000000..0e7d1ad98 --- /dev/null +++ b/packages/walletkit/GLOBALOPTIONS.md @@ -0,0 +1,37 @@ +# KitGlobalOptions + +`KitGlobalOptions` is a static class for global WalletKit configuration that affects all instances. Currently provides time synchronization functionality, with more global settings planned for future releases. + +## Time Provider + +Configure how WalletKit obtains current time for transaction validation. Useful for avoiding clock skew issues and ensuring accurate `validUntil` validation. + +### API + +```typescript +class KitGlobalOptions { + static setGetCurrentTime(fn: () => Promise | number): void; + static getCurrentTime(): Promise; +} +``` + +### Usage + +```typescript +import { KitGlobalOptions } from '@ton/walletkit'; + +// Set custom time provider (optional, before creating TonWalletKit) +KitGlobalOptions.setGetCurrentTime(async () => { + const response = await fetch('https://your-api.com/time'); + const { timestamp } = await response.json(); + return timestamp; // Unix timestamp in seconds +}); +``` + +**Default behavior**: Uses `Math.floor(Date.now() / 1000)` if not configured. + +## Notes + +- **Global scope**: Affects all `TonWalletKit` instances +- **Time format**: Unix timestamp in seconds (not milliseconds) +- **Set once**: Configure at app initialization diff --git a/packages/walletkit/README.md b/packages/walletkit/README.md index 8af7598de..75ffeb21d 100644 --- a/packages/walletkit/README.md +++ b/packages/walletkit/README.md @@ -24,6 +24,7 @@ A production-ready wallet-side integration layer for TON Connect, designed for b - **[Browser Extension Build](/apps/demo-wallet/EXTENSION.md)** - How to build and load the demo wallet as a Chrome extension - **[JS Bridge Usage](/packages/walletkit/examples/js-bridge-usage.md)** - Implementing TonConnect JS Bridge for browser extension wallets +- **[KitGlobalOptions](/packages/walletkit/GLOBALOPTIONS.md)** - Configure global time provider for accurate transaction validation - **[iOS WalletKit](https://github.com/ton-connect/kit-ios)** - Swift Package providing TON wallet capabilities for iOS and macOS - **[Android WalletKit](https://github.com/ton-connect/kit-android)** - Kotlin/Java Package providing TON wallet capabilities for Android @@ -298,6 +299,25 @@ const info = kit.jettons.getJettonInfo(jettonAddress); // info?.name, info?.symbol, info?.image ``` +## Advanced Configuration + +### Custom Time Provider + +For production wallets, it's recommended to use server-synchronized time instead of device time to avoid issues with clock skew and timezone differences: + +```ts +import { KitGlobalOptions } from '@ton/walletkit'; + +// Set custom time provider before creating TonWalletKit +KitGlobalOptions.setGetCurrentTime(async () => { + const response = await fetch('https://your-api.com/time'); + const { timestamp } = await response.json(); + return timestamp; // Unix timestamp in seconds +}); +``` + +This ensures accurate `validUntil` validation for transactions. See [KitGlobalOptions documentation](/packages/walletkit/GLOBALOPTIONS.md) for detailed usage and best practices. + ## Sending assets programmatically You can create transactions from your wallet app (not from dApps) and feed them into the regular approval flow via `handleNewTransaction`. This triggers your `onTransactionRequest` callback, allowing the same UI confirmation flow for both dApp and wallet-initiated transactions. diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts index 8ffbfa630..e27a2e6fd 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts @@ -143,15 +143,16 @@ export class WalletV4R2 implements Contract { * Create transfer message body */ createTransfer(args: { seqno: number; sendMode: SendMode; messages: MessageRelaxed[]; timeout?: number }): Cell { - const timeout = args.timeout ?? Math.floor(Date.now() / 1000) + 60; - let body = beginCell() .storeUint(this.subwalletId, 32) - .storeUint(timeout, 32) .storeUint(args.seqno, 32) .storeUint(0, 8) // Simple transfer .storeUint(args.sendMode, 8); + if (args.timeout) { + body.storeUint(args.timeout, 32); + } + for (const message of args.messages) { body = body.storeRef(beginCell().store(storeMessageRelaxed(message))); } diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts index f7b967287..5ea34f57a 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts @@ -152,10 +152,6 @@ export class WalletV4R2Adapter implements WalletAdapter { // } - const timeout = input.validUntil - ? Math.min(input.validUntil, Math.floor(Date.now() / 1000) + 600) - : Math.floor(Date.now() / 1000) + 60; - try { const messages: MessageRelaxed[] = input.messages.map((m) => { let bounce = true; @@ -179,7 +175,7 @@ export class WalletV4R2Adapter implements WalletAdapter { seqno: seqno, sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS, messages, - timeout: timeout, + timeout: input.validUntil, }); const signature = await this.sign(Uint8Array.from(data.hash())); diff --git a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts index dbc911924..1347c7e21 100644 --- a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts +++ b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts @@ -202,25 +202,8 @@ export class WalletV5R1Adapter implements WalletAdapter { const createBodyOptions: { validUntil: number | undefined; fakeSignature: boolean } = { ...options, - validUntil: undefined, + validUntil: input.validUntil, }; - // add valid untill - if (input.validUntil) { - const now = Math.floor(Date.now() / 1000); - const maxValidUntil = now + 600; - if (input.validUntil < now) { - throw new WalletKitError( - ERROR_CODES.VALIDATION_ERROR, - 'Transaction valid_until timestamp is in the past', - undefined, - { validUntil: input.validUntil, currentTime: now }, - ); - } else if (input.validUntil > maxValidUntil) { - createBodyOptions.validUntil = maxValidUntil; - } else { - createBodyOptions.validUntil = input.validUntil; - } - } let seqno = 0; try { @@ -314,19 +297,22 @@ export class WalletV5R1Adapter implements WalletAdapter { auth_signed: 0x7369676e, }; - const expireAt = options.validUntil ?? Math.floor(Date.now() / 1000) + 300; - const payload = beginCell() + const builder = beginCell() .storeUint(Opcodes.auth_signed, 32) .storeUint(walletId, 32) - .storeUint(expireAt, 32) .storeUint(seqno, 32) // seqno - .storeSlice(actionsList.beginParse()) - .endCell(); + .storeSlice(actionsList.beginParse()); + + if (options.validUntil) { + builder.storeUint(options.validUntil, 32); + } + + const cell = builder.endCell(); - const signingData = payload.hash(); + const signingData = cell.hash(); const signature = options.fakeSignature ? FakeSignature(signingData) : await this.sign(signingData); return beginCell() - .storeSlice(payload.beginParse()) + .storeSlice(cell.beginParse()) .storeBuffer(Buffer.from(HexToUint8Array(signature))) .endCell(); } diff --git a/packages/walletkit/src/core/KitGlobalOptions.ts b/packages/walletkit/src/core/KitGlobalOptions.ts new file mode 100644 index 000000000..46163cec5 --- /dev/null +++ b/packages/walletkit/src/core/KitGlobalOptions.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { getUnixtime } from '../utils'; + +type GetCurrentTimeFunc = () => Promise | number; + +export class KitGlobalOptions { + private static getCurrentTimeImpl: GetCurrentTimeFunc = getUnixtime; + + static setGetCurrentTime(fn: GetCurrentTimeFunc): void { + KitGlobalOptions.getCurrentTimeImpl = fn; + } + + static async getCurrentTime(): Promise { + return KitGlobalOptions.getCurrentTimeImpl(); + } +} diff --git a/packages/walletkit/src/core/RequestProcessor.ts b/packages/walletkit/src/core/RequestProcessor.ts index 83175e71b..2f4996049 100644 --- a/packages/walletkit/src/core/RequestProcessor.ts +++ b/packages/walletkit/src/core/RequestProcessor.ts @@ -27,7 +27,9 @@ import { } from '@tonconnect/protocol'; import { getSecureRandomBytes } from '@ton/crypto'; +import { KitGlobalOptions } from '../core/KitGlobalOptions'; import type { EventSignDataApproval, TonWalletKitOptions } from '../types'; +import { isBefore } from '../utils/time'; import type { SessionManager } from './SessionManager'; import type { BridgeManager } from './BridgeManager'; import { globalLogger } from './Logger'; @@ -862,6 +864,7 @@ export class RequestProcessor { { eventId: event.id }, ); } + const wallet = this.getWalletFromEvent(event); if (!wallet) { throw new WalletKitError( @@ -873,16 +876,13 @@ export class RequestProcessor { } const validUntil = event.request.validUntil; - if (validUntil) { - const now = Math.floor(Date.now() / 1000); - if (validUntil < now) { - throw new WalletKitError( - ERROR_CODES.VALIDATION_ERROR, - 'Transaction valid_until timestamp is in the past', - undefined, - { validUntil, currentTime: now }, - ); - } + if (validUntil && (await isBefore(validUntil))) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Transaction expired: valid_until timestamp is in the past', + undefined, + { validUntil, currentTime: await KitGlobalOptions.getCurrentTime() }, + ); } return await signTransactionInternal(wallet, event.request); diff --git a/packages/walletkit/src/core/TonWalletKit.spec.ts b/packages/walletkit/src/core/TonWalletKit.spec.ts index 769d3e38f..828e1443d 100644 --- a/packages/walletkit/src/core/TonWalletKit.spec.ts +++ b/packages/walletkit/src/core/TonWalletKit.spec.ts @@ -7,14 +7,17 @@ */ import { CHAIN } from '@tonconnect/protocol'; -import { Address } from '@ton/core'; +import { Address, toNano } from '@ton/core'; +import { vi } from 'vitest'; import { mockFn, mocked, useFakeTimers, useRealTimers } from '../../mock.config'; import { TonWalletKit } from './TonWalletKit'; +import { KitGlobalOptions } from './KitGlobalOptions'; import type { TonWalletKitOptions } from '../types'; import { createDummyWallet, createMockApiClient } from '../contracts/w5/WalletV5R1.fixture'; import type { InjectedToExtensionBridgeRequest, InjectedToExtensionBridgeRequestPayload } from '../types/jsBridge'; -import type { TONTransferRequest } from '../api/models'; +import type { TONTransferRequest, TransactionRequest } from '../api/models'; +import { getUnixtime } from '../utils'; const mockApiClient = createMockApiClient(); @@ -31,6 +34,7 @@ describe('TonWalletKit', () => { afterEach(() => { useRealTimers(); + KitGlobalOptions.setGetCurrentTime(getUnixtime); }); const createKit = async () => { @@ -132,4 +136,179 @@ describe('TonWalletKit', () => { await kit.close(); }); + + describe('validUntil validation', () => { + it('should accept transaction with future validUntil', async () => { + const kit = await createKit(); + const wallet = await kit.addWallet(await createDummyWallet(1n)); + + expect(wallet).toBeDefined(); + + if (!wallet) { + throw new Error('Wallet not created'); + } + + // Set current time to 1000ms (1 second) + vi.setSystemTime(1000); + + // Sync KitGlobalOptions with fake timer + KitGlobalOptions.setGetCurrentTime(() => Math.floor(Date.now() / 1000)); + + kit.onTransactionRequest(() => { + // + }); + + const request: TransactionRequest = { + messages: [ + { + address: wallet.getAddress(), + amount: toNano('1').toString(), + }, + ], + validUntil: Math.floor((Date.now() + 10000) / 1000), // 10 seconds in future (11 seconds total) + }; + + // Should not throw + await expect(kit.handleNewTransaction(wallet, request)).resolves.not.toThrow(); + + await kit.close(); + }); + + it('should reject transaction with past validUntil', async () => { + const kit = await createKit(); + const wallet = await kit.addWallet(await createDummyWallet(1n)); + + expect(wallet).toBeDefined(); + + if (!wallet) { + throw new Error('Wallet not created'); + } + + // Set current time to 10000ms (10 seconds) + vi.setSystemTime(10000); + + // Sync KitGlobalOptions with fake timer + KitGlobalOptions.setGetCurrentTime(() => Math.floor(Date.now() / 1000)); + + let errorReceived = false; + let errorMessage = ''; + + const errorPromise = new Promise((resolve) => { + kit.onRequestError((event) => { + errorReceived = true; + errorMessage = event.error.message || ''; + resolve(); + }); + }); + + kit.onTransactionRequest(() => { + // Should not be called for invalid transactions + throw new Error('onTransactionRequest should not be called for invalid transactions'); + }); + + const request: TransactionRequest = { + messages: [ + { + address: wallet.getAddress(), + amount: toNano('1').toString(), + }, + ], + validUntil: Math.floor((Date.now() - 5000) / 1000), // 5 seconds in past (5 seconds) + }; + + // handleNewTransaction should not throw, but error callback should be called + await kit.handleNewTransaction(wallet, request); + + // Wait for error callback with timeout + await Promise.race([ + errorPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Error callback not called')), 1000)), + ]); + + expect(errorReceived).toBe(true); + expect(errorMessage).toBe('Failed to parse transaction request'); + + await kit.close(); + }); + + it('should accept transaction without validUntil', async () => { + const kit = await createKit(); + const wallet = await kit.addWallet(await createDummyWallet(1n)); + + expect(wallet).toBeDefined(); + + if (!wallet) { + throw new Error('Wallet not created'); + } + + kit.onTransactionRequest((event) => { + expect(event.walletId).toBe(wallet.getWalletId()); + }); + + const request: TransactionRequest = { + messages: [ + { + address: wallet.getAddress(), + amount: toNano('1').toString(), + }, + ], + // No validUntil + }; + + // Should not throw without validUntil + await expect(kit.handleNewTransaction(wallet, request)).resolves.not.toThrow(); + + await kit.close(); + }); + + it('should use custom getCurrentTime function when provided', async () => { + // Set fake time to 5000ms (5 seconds) + vi.setSystemTime(5000); + + const customTime = Math.floor(Date.now() / 1000) + 1000; // 1000 seconds in future from fake time (1005 seconds) + + // Set custom time provider + KitGlobalOptions.setGetCurrentTime(() => { + return customTime; + }); + + const kit = new TonWalletKit({ + networks: { [CHAIN.MAINNET]: {} }, + storage: { + get: mockFn().mockResolvedValue(null), + set: mockFn().mockResolvedValue(undefined), + remove: mockFn().mockResolvedValue(undefined), + clear: mockFn().mockResolvedValue(undefined), + }, + }); + await kit.waitForReady(); + + const wallet = await kit.addWallet(await createDummyWallet(1n)); + + expect(wallet).toBeDefined(); + + if (!wallet) { + throw new Error('Wallet not created'); + } + + kit.onTransactionRequest((event) => { + expect(event.walletId).toBe(wallet.getWalletId()); + }); + + const request: TransactionRequest = { + messages: [ + { + address: wallet.getAddress(), + amount: toNano('1').toString(), + }, + ], + validUntil: customTime + 500, // 500 seconds after custom time (1505 seconds) + }; + + // Should not throw because validUntil is in the future relative to custom time + await expect(kit.handleNewTransaction(wallet, request)).resolves.not.toThrow(); + + await kit.close(); + }); + }); }); diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 466e6f993..a5506997b 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -61,6 +61,7 @@ import type { SignDataApprovalResponse, } from '../api/models'; import { asAddressFriendly } from '../utils'; +import { KitGlobalOptions } from './KitGlobalOptions'; const log = globalLogger.createChild('TonWalletKit'); @@ -519,7 +520,7 @@ export class TonWalletKit implements ITonWalletKit { async handleNewTransaction(wallet: Wallet, data: TransactionRequest): Promise { await this.ensureInitialized(); - data.validUntil ??= Math.floor(Date.now() / 1000) + 300; + data.validUntil ??= (await KitGlobalOptions.getCurrentTime()) + 300; data.network ??= wallet.getNetwork(); const walletId = wallet.getWalletId(); diff --git a/packages/walletkit/src/core/wallet/extensions/ton.ts b/packages/walletkit/src/core/wallet/extensions/ton.ts index e7c655abe..ae881f003 100644 --- a/packages/walletkit/src/core/wallet/extensions/ton.ts +++ b/packages/walletkit/src/core/wallet/extensions/ton.ts @@ -24,6 +24,8 @@ import type { Base64String, } from '../../../api/models'; import type { Wallet, WalletTonInterface } from '../../../api/interfaces'; +import { KitGlobalOptions } from '../../KitGlobalOptions'; +import { isBefore } from '../../../utils/time'; const log = globalLogger.createChild('WalletTonClass'); @@ -124,6 +126,15 @@ export class WalletTonClass implements WalletTonInterface { async sendTransaction(this: Wallet, request: TransactionRequest): Promise { try { + if (request.validUntil !== undefined && (await isBefore(request.validUntil))) { + throw new WalletKitError( + ERROR_CODES.INVALID_REQUEST_EVENT, + 'Transaction validUntil has expired', + undefined, + { validUntil: request.validUntil, currentTime: await KitGlobalOptions.getCurrentTime() }, + ); + } + const boc = await this.getSignedSendTransaction(request); await CallForSuccess(() => this.getClient().sendBoc(boc)); diff --git a/packages/walletkit/src/handlers/TransactionHandler.ts b/packages/walletkit/src/handlers/TransactionHandler.ts index c064eb81a..9349e6016 100644 --- a/packages/walletkit/src/handlers/TransactionHandler.ts +++ b/packages/walletkit/src/handlers/TransactionHandler.ts @@ -11,6 +11,7 @@ import type { SendTransactionRpcResponseError, WalletResponseTemplateError } fro import { CHAIN, SEND_TRANSACTION_ERROR_CODES } from '@tonconnect/protocol'; import type { ValidationResult, TonWalletKitOptions } from '../types'; +import { isBefore } from '../utils/time'; import { toTransactionRequest } from '../types/internal'; import type { RawBridgeEvent, @@ -93,7 +94,7 @@ export class TransactionHandler } as SendTransactionRpcResponseError; } - const requestValidation = this.parseTonConnectTransactionRequest(event, wallet); + const requestValidation = await this.parseTonConnectTransactionRequest(event, wallet); if (!requestValidation.result || !requestValidation?.validation?.isValid) { log.error('Failed to parse transaction request', { event, requestValidation }); this.eventEmitter.emit('event:error', event); @@ -171,14 +172,13 @@ export class TransactionHandler /** * Parse raw transaction request from bridge event */ - - private parseTonConnectTransactionRequest( + private async parseTonConnectTransactionRequest( event: RawBridgeEventTransaction, wallet: Wallet, - ): { + ): Promise<{ result: TransactionRequest | undefined; validation: ValidationResult; - } { + }> { let errors: string[] = []; try { if (event.params.length !== 1) { @@ -191,7 +191,7 @@ export class TransactionHandler } const params = JSON.parse(event.params[0]) as ConnectTransactionParamContent; - const validUntilValidation = this.validateValidUntil(params.valid_until); + const validUntilValidation = await this.validateValidUntil(params.valid_until); if (!validUntilValidation.isValid) { errors = errors.concat(validUntilValidation.errors); } else { @@ -235,8 +235,7 @@ export class TransactionHandler /** * Parse network from various possible formats */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private validateNetwork(network: any, wallet: Wallet): ReturnWithValidationResult { + private validateNetwork(network: unknown, wallet: Wallet): ReturnWithValidationResult { let errors: string[] = []; if (typeof network === 'string') { if (network === '-3' || network === '-239') { @@ -283,19 +282,18 @@ export class TransactionHandler /** * Parse validUntil timestamp */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private validateValidUntil(validUntil: any): ReturnWithValidationResult { + private async validateValidUntil(validUntil: unknown): Promise> { let errors: string[] = []; if (typeof validUntil === 'undefined') { return { result: 0, isValid: errors.length === 0, errors: errors }; } + if (typeof validUntil !== 'number' || isNaN(validUntil)) { errors.push('Invalid validUntil timestamp not a number'); return { result: 0, isValid: errors.length === 0, errors: errors }; } - const now = Math.floor(Date.now() / 1000); - if (validUntil < now) { + if (await isBefore(validUntil)) { errors.push('Invalid validUntil timestamp'); return { result: 0, isValid: errors.length === 0, errors: errors }; } diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index 94f80e466..df6ff6681 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -12,6 +12,7 @@ export { TonWalletKit } from './core/TonWalletKit'; export * from './types'; export type * from './types/internal'; export * from './errors'; +export { KitGlobalOptions } from './core/KitGlobalOptions'; export { WalletManager } from './core/WalletManager'; export { SessionManager } from './core/SessionManager'; export { BridgeManager } from './core/BridgeManager'; diff --git a/packages/walletkit/src/types/config.ts b/packages/walletkit/src/types/config.ts index 54c4956c5..7e23ed494 100644 --- a/packages/walletkit/src/types/config.ts +++ b/packages/walletkit/src/types/config.ts @@ -54,11 +54,6 @@ export interface TonWalletKitOptions { bridge?: BridgeConfig; /** Storage settings */ storage?: StorageConfig | StorageAdapter; - /** Validation settings */ - validation?: { - strictMode?: boolean; - allowUnknownWalletVersions?: boolean; - }; /** Event processor settings */ eventProcessor?: EventProcessorConfig; diff --git a/packages/walletkit/src/types/internal.ts b/packages/walletkit/src/types/internal.ts index 3ad87da38..cb8dc6fbd 100644 --- a/packages/walletkit/src/types/internal.ts +++ b/packages/walletkit/src/types/internal.ts @@ -14,7 +14,6 @@ import type { SignDataRpcRequest, WalletResponseTemplateError, } from '@tonconnect/protocol'; -import { WalletResponseError as _WalletResponseError } from '@tonconnect/protocol'; import type { JSBridgeTransportFunction } from './jsBridge'; import type { WalletId } from '../utils/walletId'; @@ -29,8 +28,6 @@ import type { import { SendModeFromValue, SendModeToValue } from '../api/models'; import { asAddressFriendly } from '../utils/address'; -// import type { WalletInterface } from './wallet'; - export interface SessionData { sessionId: string; diff --git a/packages/walletkit/src/utils/time.ts b/packages/walletkit/src/utils/time.ts index a095ecd9f..be0d1259e 100644 --- a/packages/walletkit/src/utils/time.ts +++ b/packages/walletkit/src/utils/time.ts @@ -6,6 +6,14 @@ * */ +import { KitGlobalOptions } from '../core/KitGlobalOptions'; + export function getUnixtime(): number { return Math.floor(Date.now() / 1000); } + +export async function isBefore(timestamp1: number, timestamp2?: number): Promise { + const calculated = timestamp2 ?? (await KitGlobalOptions.getCurrentTime()); + + return timestamp1 < calculated; +} From 7cd14cd802635dcfda3cd66bbdf162ce03ee2458 Mon Sep 17 00:00:00 2001 From: "V. K." Date: Tue, 6 Jan 2026 15:31:00 +0400 Subject: [PATCH 2/3] feat(custom-time): return validUntil logic back in adapters --- .../src/contracts/v4r2/WalletV4R2.ts | 13 ++++++- .../src/contracts/v4r2/WalletV4R2Adapter.ts | 2 +- .../src/contracts/w5/WalletV5R1Adapter.ts | 38 +++++++++++++------ .../walletkit/src/core/KitGlobalOptions.ts | 2 +- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts index e27a2e6fd..83547184b 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts @@ -12,6 +12,7 @@ import type { Address, Cell, Contract, ContractProvider, Sender, MessageRelaxed import { beginCell, contractAddress, SendMode, storeMessageRelaxed } from '@ton/core'; import type { Maybe } from '@ton/core/dist/utils/maybe'; +import { KitGlobalOptions } from '../../core/KitGlobalOptions'; import type { ApiClient } from '../../types/toncenter/ApiClient'; import { ParseStack } from '../../utils/tvmStack'; import { asAddressFriendly } from '../../utils'; @@ -142,9 +143,17 @@ export class WalletV4R2 implements Contract { /** * Create transfer message body */ - createTransfer(args: { seqno: number; sendMode: SendMode; messages: MessageRelaxed[]; timeout?: number }): Cell { + async createTransfer(args: { + seqno: number; + sendMode: SendMode; + messages: MessageRelaxed[]; + timeout?: number; + }): Promise { + const timeout = args.timeout ?? (await KitGlobalOptions.getCurrentTime()) + 60; + let body = beginCell() .storeUint(this.subwalletId, 32) + .storeUint(timeout, 32) .storeUint(args.seqno, 32) .storeUint(0, 8) // Simple transfer .storeUint(args.sendMode, 8); @@ -174,7 +183,7 @@ export class WalletV4R2 implements Contract { timeout?: number; }, ): Promise { - const transfer = this.createTransfer(args); + const transfer = await this.createTransfer(args); await provider.internal(via, { sendMode: SendMode.PAY_GAS_SEPARATELY, body: transfer, diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts index 5ea34f57a..1370edaaf 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts @@ -171,7 +171,7 @@ export class WalletV4R2Adapter implements WalletAdapter { init: m.stateInit ? loadStateInit(Cell.fromBase64(m.stateInit).asSlice()) : undefined, }); }); - const data = this.walletContract.createTransfer({ + const data = await this.walletContract.createTransfer({ seqno: seqno, sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS, messages, diff --git a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts index 1347c7e21..5aa25d6b4 100644 --- a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts +++ b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts @@ -35,6 +35,7 @@ import type { Hex, Base64String, } from '../../api/models'; +import { KitGlobalOptions } from '../../core/KitGlobalOptions'; const log = globalLogger.createChild('WalletV5R1Adapter'); @@ -202,9 +203,27 @@ export class WalletV5R1Adapter implements WalletAdapter { const createBodyOptions: { validUntil: number | undefined; fakeSignature: boolean } = { ...options, - validUntil: input.validUntil, + validUntil: undefined, }; + // add validUntil + if (input.validUntil) { + const now = await KitGlobalOptions.getCurrentTime(); + const maxValidUntil = now + 600; + if (input.validUntil < now) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Transaction valid_until timestamp is in the past', + undefined, + { validUntil: input.validUntil, currentTime: now }, + ); + } else if (input.validUntil > maxValidUntil) { + createBodyOptions.validUntil = maxValidUntil; + } else { + createBodyOptions.validUntil = input.validUntil; + } + } + let seqno = 0; try { seqno = await CallForSuccess(async () => this.getSeqno(), 5, 1000); @@ -297,22 +316,19 @@ export class WalletV5R1Adapter implements WalletAdapter { auth_signed: 0x7369676e, }; - const builder = beginCell() + const expireAt = options.validUntil ?? (await KitGlobalOptions.getCurrentTime()) + 300; + const payload = beginCell() .storeUint(Opcodes.auth_signed, 32) .storeUint(walletId, 32) + .storeUint(expireAt, 32) .storeUint(seqno, 32) // seqno - .storeSlice(actionsList.beginParse()); - - if (options.validUntil) { - builder.storeUint(options.validUntil, 32); - } - - const cell = builder.endCell(); + .storeSlice(actionsList.beginParse()) + .endCell(); - const signingData = cell.hash(); + const signingData = payload.hash(); const signature = options.fakeSignature ? FakeSignature(signingData) : await this.sign(signingData); return beginCell() - .storeSlice(cell.beginParse()) + .storeSlice(payload.beginParse()) .storeBuffer(Buffer.from(HexToUint8Array(signature))) .endCell(); } diff --git a/packages/walletkit/src/core/KitGlobalOptions.ts b/packages/walletkit/src/core/KitGlobalOptions.ts index 46163cec5..b198161d4 100644 --- a/packages/walletkit/src/core/KitGlobalOptions.ts +++ b/packages/walletkit/src/core/KitGlobalOptions.ts @@ -6,7 +6,7 @@ * */ -import { getUnixtime } from '../utils'; +import { getUnixtime } from '../utils/time'; type GetCurrentTimeFunc = () => Promise | number; From 57f3ea0f50ae4bb6a22f79ae977cde65c1c7daa7 Mon Sep 17 00:00:00 2001 From: "V. K." Date: Tue, 6 Jan 2026 15:55:30 +0400 Subject: [PATCH 3/3] feat(custom-time): use new time logic in validation and emulation --- packages/walletkit/package.json | 1 + packages/walletkit/src/contracts/v4r2/WalletV4R2.ts | 4 ---- packages/walletkit/src/core/ApiClientToncenter.ts | 3 ++- packages/walletkit/src/core/BridgeManager.ts | 3 ++- packages/walletkit/src/utils/toncenterEmulation.ts | 9 +++++---- packages/walletkit/src/validation/events.ts | 5 +++-- packages/walletkit/src/validation/transaction.ts | 10 +++++++--- 7 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/walletkit/package.json b/packages/walletkit/package.json index 9324e9455..26361d3cf 100644 --- a/packages/walletkit/package.json +++ b/packages/walletkit/package.json @@ -41,6 +41,7 @@ "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --max-warnings 0 --fix", "clean": "git clean -xdf dist node_modules .turbo", + "typecheck": "tsc --noEmit", "generate-openapi-spec": "src/api/scripts/generate-openapi-spec.sh" }, "keywords": [ diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts index 83547184b..3657280af 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2.ts @@ -158,10 +158,6 @@ export class WalletV4R2 implements Contract { .storeUint(0, 8) // Simple transfer .storeUint(args.sendMode, 8); - if (args.timeout) { - body.storeUint(args.timeout, 32); - } - for (const message of args.messages) { body = body.storeRef(beginCell().store(storeMessageRelaxed(message))); } diff --git a/packages/walletkit/src/core/ApiClientToncenter.ts b/packages/walletkit/src/core/ApiClientToncenter.ts index 53ee3369a..005cef562 100644 --- a/packages/walletkit/src/core/ApiClientToncenter.ts +++ b/packages/walletkit/src/core/ApiClientToncenter.ts @@ -10,6 +10,7 @@ import type { ExtraCurrency, AccountStatus } from '@ton/core'; import { Address } from '@ton/core'; import { CHAIN } from '@tonconnect/protocol'; +import { KitGlobalOptions } from '../core/KitGlobalOptions'; import { Base64ToBigInt, Base64Normalize, Base64ToHex } from '../utils/base64'; import type { FullAccountState, GetResult, TransactionId } from '../types/toncenter/api'; import type { JettonInfo, ToncenterEmulationResponse } from '../types'; @@ -139,7 +140,7 @@ export class ApiClientToncenter implements ApiClient { ): Promise { const props: Record = { from: address, - valid_until: Math.floor(Date.now() / 1000) + 60, + valid_until: (await KitGlobalOptions.getCurrentTime()) + 60, include_code_data: true, include_address_book: true, include_metadata: true, diff --git a/packages/walletkit/src/core/BridgeManager.ts b/packages/walletkit/src/core/BridgeManager.ts index d9abb1b31..43aaac7a2 100644 --- a/packages/walletkit/src/core/BridgeManager.ts +++ b/packages/walletkit/src/core/BridgeManager.ts @@ -34,6 +34,7 @@ import { getEventsSubsystem, getVersion } from '../utils/version'; import { TONCONNECT_BRIDGE_RESPONSE } from '../bridge/JSBridgeInjector'; import { getAddressFromWalletId } from '../utils/walletId'; import type { BridgeEvent } from '../api/models'; +import { KitGlobalOptions } from '../core/KitGlobalOptions'; const log = globalLogger.createChild('BridgeManager'); @@ -606,7 +607,7 @@ export class BridgeManager { method: event.method || 'unknown', params: event.params || event, // sessionId: event.from, - timestamp: Date.now(), + timestamp: await KitGlobalOptions.getCurrentTime(), from: event?.from, domain: event?.domain, isJsBridge: event?.isJsBridge, diff --git a/packages/walletkit/src/utils/toncenterEmulation.ts b/packages/walletkit/src/utils/toncenterEmulation.ts index 74afbbb88..b59ff26a6 100644 --- a/packages/walletkit/src/utils/toncenterEmulation.ts +++ b/packages/walletkit/src/utils/toncenterEmulation.ts @@ -23,6 +23,7 @@ import type { import { Result, SendModeToValue, AssetType } from '../api/models'; import type { Wallet } from '../api/interfaces'; import { asAddressFriendly, asMaybeAddressFriendly } from './address'; +import { KitGlobalOptions } from '../core/KitGlobalOptions'; // import { ConnectMessageTransactionMessage } from '@/types/connect'; @@ -52,10 +53,10 @@ const TON_PROXY_ADDRESSES = [ /** * Creates a toncenter message payload for emulation */ -export function createToncenterMessage( +export async function createToncenterMessage( walletAddress: string | undefined, messages: TransactionRequest['messages'], -): ToncenterMessage { +): Promise { return { method: 'POST', headers: { @@ -63,7 +64,7 @@ export function createToncenterMessage( }, body: JSON.stringify({ from: walletAddress, - valid_until: Math.floor(Date.now() / 1000) + 60, + valid_until: (await KitGlobalOptions.getCurrentTime()) + 60, include_code_data: true, include_address_book: true, include_metadata: true, @@ -262,7 +263,7 @@ export async function createTransactionPreview( request: TransactionRequest, wallet?: Wallet, ): Promise { - const message = createToncenterMessage(wallet?.getAddress(), request.messages); + const message = await createToncenterMessage(wallet?.getAddress(), request.messages); let emulationResult: ToncenterEmulationResponse; try { diff --git a/packages/walletkit/src/validation/events.ts b/packages/walletkit/src/validation/events.ts index 74d631e62..93799792e 100644 --- a/packages/walletkit/src/validation/events.ts +++ b/packages/walletkit/src/validation/events.ts @@ -9,6 +9,7 @@ // Bridge event validation logic import type { ValidationResult, ValidationContext } from './types'; +import { KitGlobalOptions } from '../core/KitGlobalOptions'; /** * Validate bridge event structure @@ -88,7 +89,7 @@ export function validateConnectEventParams(params: any): ValidationResult { * Validate transaction event parameters */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function validateTransactionEventParams(params: any): ValidationResult { +export async function validateTransactionEventParams(params: any): Promise { const errors: string[] = []; if (!params || typeof params !== 'object') { @@ -109,7 +110,7 @@ export function validateTransactionEventParams(params: any): ValidationResult { if (params.validUntil && typeof params.validUntil !== 'number') { errors.push('validUntil must be a number if provided'); - } else if (params.validUntil && params.validUntil <= Date.now() / 1000) { + } else if (params.validUntil && params.validUntil <= (await KitGlobalOptions.getCurrentTime())) { errors.push('validUntil must be a future timestamp'); } diff --git a/packages/walletkit/src/validation/transaction.ts b/packages/walletkit/src/validation/transaction.ts index 6509020e7..bbef5007f 100644 --- a/packages/walletkit/src/validation/transaction.ts +++ b/packages/walletkit/src/validation/transaction.ts @@ -8,6 +8,7 @@ import { Cell } from '@ton/core'; +import { KitGlobalOptions } from '../core/KitGlobalOptions'; import type { ValidationResult } from './types'; import { validateTonAddress } from './address'; import { isFriendlyTonAddress } from '../utils/address'; @@ -147,8 +148,11 @@ export function validateMessageObject(message: any): ValidationResult { /** * Validate transaction request structure */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function validateTransactionRequest(request: any, isTonConnect: boolean = true): ValidationResult { +export async function validateTransactionRequest( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: any, + isTonConnect: boolean = true, +): Promise { const errors: string[] = []; if (!request || typeof request !== 'object') { @@ -173,7 +177,7 @@ export function validateTransactionRequest(request: any, isTonConnect: boolean = if (request.validUntil) { if (typeof request.validUntil !== 'number') { errors.push('validUntil must be a number'); - } else if (request.validUntil <= Math.floor(Date.now() / 1000)) { + } else if (request.validUntil <= (await KitGlobalOptions.getCurrentTime())) { errors.push('validUntil must be a future timestamp'); } }