diff --git a/packages/walletkit-android-bridge/src/adapters/AndroidAPIClientAdapter.ts b/packages/walletkit-android-bridge/src/adapters/AndroidAPIClientAdapter.ts new file mode 100644 index 000000000..2b38723ee --- /dev/null +++ b/packages/walletkit-android-bridge/src/adapters/AndroidAPIClientAdapter.ts @@ -0,0 +1,205 @@ +/** + * 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 type { + ApiClient, + Base64String, + UserFriendlyAddress, + RawStackItem, + GetMethodResult, + NFTsRequest, + NFTsResponse, + UserNFTsRequest, + TokenAmount, + TransactionsResponse, + JettonsResponse, + FullAccountState, + ToncenterEmulationResult, + ToncenterResponseJettonMasters, + ToncenterTracesResponse, + TransactionsByAddressRequest, + GetTransactionByHashRequest, + GetPendingTransactionsRequest, + GetTraceRequest, + GetPendingTraceRequest, + GetJettonsByOwnerRequest, + GetJettonsByAddressRequest, + GetEventsRequest, + GetEventsResponse, + Network, +} from '@ton/walletkit'; + +import { log, error } from '../utils/logger'; + +type AndroidAPIClientBridge = { + apiGetNetworks: () => string; + apiSendBoc: (networkJson: string, boc: string) => string; + apiRunGetMethod: ( + networkJson: string, + address: string, + method: string, + stackJson: string | null, + seqno: number, + ) => string; + apiGetBalance: (networkJson: string, address: string, seqno: number) => string; +}; + +type AndroidWindow = Window & { + WalletKitNative?: AndroidAPIClientBridge; +}; + +/** + * Android native API client adapter. + * Uses Android's JavascriptInterface methods for API calls. + * Similar to SwiftAPIClientAdapter for iOS. + */ +export class AndroidAPIClientAdapter implements ApiClient { + private androidBridge: AndroidAPIClientBridge; + private network: Network; + + constructor(network: Network) { + const androidWindow = window as AndroidWindow; + if (!androidWindow.WalletKitNative) { + throw new Error('WalletKitNative bridge not available'); + } + this.androidBridge = androidWindow.WalletKitNative; + this.network = network; + } + + /** + * Check if native API clients are available. + */ + static isAvailable(): boolean { + const androidWindow = window as AndroidWindow; + return typeof androidWindow.WalletKitNative?.apiGetNetworks === 'function'; + } + + /** + * Get all networks that have native API clients configured. + */ + static getAvailableNetworks(): Network[] { + const androidWindow = window as AndroidWindow; + if (!androidWindow.WalletKitNative?.apiGetNetworks) { + return []; + } + try { + const networksJson = androidWindow.WalletKitNative.apiGetNetworks(); + return JSON.parse(networksJson) as Network[]; + } catch (err) { + error('[AndroidAPIClientAdapter] Failed to get available networks:', err); + return []; + } + } + + async sendBoc(boc: Base64String): Promise { + log('[AndroidAPIClientAdapter] sendBoc:', boc.substring(0, 50) + '...'); + try { + const networkJson = JSON.stringify(this.network); + const result = this.androidBridge.apiSendBoc(networkJson, boc); + log('[AndroidAPIClientAdapter] sendBoc result:', result); + return result; + } catch (err) { + error('[AndroidAPIClientAdapter] sendBoc failed:', err); + throw err; + } + } + + async runGetMethod( + address: UserFriendlyAddress, + method: string, + stack?: RawStackItem[], + seqno?: number, + ): Promise { + log('[AndroidAPIClientAdapter] runGetMethod:', address, method); + try { + const networkJson = JSON.stringify(this.network); + const stackJson = stack ? JSON.stringify(stack) : null; + const seqnoArg = seqno ?? -1; // Use -1 to represent null + const resultJson = this.androidBridge.apiRunGetMethod(networkJson, address, method, stackJson, seqnoArg); + const result = JSON.parse(resultJson) as GetMethodResult; + log('[AndroidAPIClientAdapter] runGetMethod result:', result); + return result; + } catch (err) { + error('[AndroidAPIClientAdapter] runGetMethod failed:', err); + throw err; + } + } + + // Methods not implemented - will throw if called + // These are optional for mobile usage + + async nftItemsByAddress(_request: NFTsRequest): Promise { + throw new Error('nftItemsByAddress is not implemented yet'); + } + + async nftItemsByOwner(_request: UserNFTsRequest): Promise { + throw new Error('nftItemsByOwner is not implemented yet'); + } + + async fetchEmulation(_messageBoc: Base64String, _ignoreSignature?: boolean): Promise { + throw new Error('fetchEmulation is not implemented yet'); + } + + async getAccountState(_address: UserFriendlyAddress, _seqno?: number): Promise { + throw new Error('getAccountState is not implemented yet'); + } + + async getBalance(address: UserFriendlyAddress, seqno?: number): Promise { + log('[AndroidAPIClientAdapter] getBalance:', address); + try { + const networkJson = JSON.stringify(this.network); + const seqnoArg = seqno ?? -1; // Use -1 to represent null + const result = this.androidBridge.apiGetBalance(networkJson, address, seqnoArg); + log('[AndroidAPIClientAdapter] getBalance result:', result); + return result; + } catch (err) { + error('[AndroidAPIClientAdapter] getBalance failed:', err); + throw err; + } + } + + async getAccountTransactions(_request: TransactionsByAddressRequest): Promise { + throw new Error('getAccountTransactions is not implemented yet'); + } + + async getTransactionsByHash(_request: GetTransactionByHashRequest): Promise { + throw new Error('getTransactionsByHash is not implemented yet'); + } + + async getPendingTransactions(_request: GetPendingTransactionsRequest): Promise { + throw new Error('getPendingTransactions is not implemented yet'); + } + + async getTrace(_request: GetTraceRequest): Promise { + throw new Error('getTrace is not implemented yet'); + } + + async getPendingTrace(_request: GetPendingTraceRequest): Promise { + throw new Error('getPendingTrace is not implemented yet'); + } + + async resolveDnsWallet(_domain: string): Promise { + throw new Error('resolveDnsWallet is not implemented yet'); + } + + async backResolveDnsWallet(_address: UserFriendlyAddress): Promise { + throw new Error('backResolveDnsWallet is not implemented yet'); + } + + async jettonsByAddress(_request: GetJettonsByAddressRequest): Promise { + throw new Error('jettonsByAddress is not implemented yet'); + } + + async jettonsByOwnerAddress(_request: GetJettonsByOwnerRequest): Promise { + throw new Error('jettonsByOwnerAddress is not implemented yet'); + } + + async getEvents(_request: GetEventsRequest): Promise { + throw new Error('getEvents is not implemented yet'); + } +} diff --git a/packages/walletkit-android-bridge/src/api/eventListeners.ts b/packages/walletkit-android-bridge/src/api/eventListeners.ts index 81efb23de..25be9bb6f 100644 --- a/packages/walletkit-android-bridge/src/api/eventListeners.ts +++ b/packages/walletkit-android-bridge/src/api/eventListeners.ts @@ -15,6 +15,7 @@ export const eventListeners = { onConnectListener: null as BridgeEventListener, onTransactionListener: null as BridgeEventListener, onSignDataListener: null as BridgeEventListener, + onSignMessageListener: null as BridgeEventListener, onDisconnectListener: null as BridgeEventListener, onErrorListener: null as BridgeEventListener, }; diff --git a/packages/walletkit-android-bridge/src/api/index.ts b/packages/walletkit-android-bridge/src/api/index.ts index 52207f0fb..0a806e556 100644 --- a/packages/walletkit-android-bridge/src/api/index.ts +++ b/packages/walletkit-android-bridge/src/api/index.ts @@ -60,6 +60,8 @@ const apiImpl: WalletKitBridgeApi = { rejectTransactionRequest: requests.rejectTransactionRequest, approveSignDataRequest: requests.approveSignDataRequest, rejectSignDataRequest: requests.rejectSignDataRequest, + approveSignMessageRequest: requests.approveSignMessageRequest, + rejectSignMessageRequest: requests.rejectSignMessageRequest, // TonConnect & sessions handleTonConnectUrl: tonconnect.handleTonConnectUrl, diff --git a/packages/walletkit-android-bridge/src/api/initialization.ts b/packages/walletkit-android-bridge/src/api/initialization.ts index a790f75da..08919fb61 100644 --- a/packages/walletkit-android-bridge/src/api/initialization.ts +++ b/packages/walletkit-android-bridge/src/api/initialization.ts @@ -75,6 +75,17 @@ export function setEventsListeners(args?: SetEventsListenersArgs): { ok: true } kit.onSignDataRequest(eventListeners.onSignDataListener); + // Register signMessage listener for gasless transactions + if (eventListeners.onSignMessageListener) { + kit.removeSignMessageRequestCallback?.(); + } + + eventListeners.onSignMessageListener = (event: unknown) => { + callback('signMessage', event); + }; + + kit.onSignMessageRequest?.(eventListeners.onSignMessageListener); + if (eventListeners.onDisconnectListener) { kit.removeDisconnectCallback(); } @@ -120,6 +131,11 @@ export function removeEventListeners(): { ok: true } { eventListeners.onSignDataListener = null; } + if (eventListeners.onSignMessageListener) { + kit.removeSignMessageRequestCallback?.(); + eventListeners.onSignMessageListener = null; + } + if (eventListeners.onDisconnectListener) { kit.removeDisconnectCallback(); eventListeners.onDisconnectListener = null; diff --git a/packages/walletkit-android-bridge/src/api/requests.ts b/packages/walletkit-android-bridge/src/api/requests.ts index 414ce8156..4c1112c90 100644 --- a/packages/walletkit-android-bridge/src/api/requests.ts +++ b/packages/walletkit-android-bridge/src/api/requests.ts @@ -20,6 +20,8 @@ import type { RejectTransactionRequestArgs, ApproveSignDataRequestArgs, RejectSignDataRequestArgs, + ApproveSignMessageRequestArgs, + RejectSignMessageRequestArgs, } from '../types'; import { callBridge } from '../utils/bridgeWrapper'; import { log } from '../utils/logger'; @@ -108,3 +110,34 @@ export async function rejectSignDataRequest(args: RejectSignDataRequestArgs) { return result ?? { success: true }; }); } + +/** + * Approves a signMessage request (for gasless transactions). + * Returns a signed internal message BOC that can be sent to a gasless provider. + */ +export async function approveSignMessageRequest(args: ApproveSignMessageRequestArgs) { + return callBridge('approveSignMessageRequest', async (kit) => { + // Enrich event with walletId (same pattern as approveTransactionRequest) + if (args.walletId) { + args.event.walletId = args.walletId; + } + + return await kit.approveSignMessageRequest(args.event); + }); +} + +/** + * Rejects a signMessage request. + */ +export async function rejectSignMessageRequest(args: RejectSignMessageRequestArgs) { + return callBridge('rejectSignMessageRequest', async (kit) => { + // If errorCode is provided, pass it as an error object; otherwise just pass the reason string + const reason = + args.errorCode !== undefined + ? { code: args.errorCode, message: args.reason || 'SignMessage rejected' } + : args.reason; + + const result = await kit.rejectSignMessageRequest(args.event, reason); + return result ?? { success: true }; + }); +} diff --git a/packages/walletkit-android-bridge/src/core/initialization.ts b/packages/walletkit-android-bridge/src/core/initialization.ts index 22efa0f69..002540d36 100644 --- a/packages/walletkit-android-bridge/src/core/initialization.ts +++ b/packages/walletkit-android-bridge/src/core/initialization.ts @@ -21,6 +21,7 @@ import { hasAndroidSessionManager, AndroidTONConnectSessionsManager, } from '../adapters/AndroidTONConnectSessionsManager'; +import { AndroidAPIClientAdapter } from '../adapters/AndroidAPIClientAdapter'; export interface InitTonWalletKitDeps { emit: (type: WalletKitBridgeEvent['type'], data?: WalletKitBridgeEvent['data']) => void; @@ -75,10 +76,24 @@ export async function initTonWalletKit( // Build networks config - the new SDK requires networks as an object keyed by chain ID const apiClientConfig = clientEndpoint ? { url: clientEndpoint } : undefined; - const networksConfig: Record = { + const networksConfig: Record = { [network]: { apiClient: apiClientConfig }, }; + // Check if native API clients are available and use them if so + if (AndroidAPIClientAdapter.isAvailable()) { + log('[walletkitBridge] Native API clients available, checking for configured networks'); + const availableNetworks = AndroidAPIClientAdapter.getAvailableNetworks(); + log('[walletkitBridge] Available native API networks:', JSON.stringify(availableNetworks)); + + for (const nativeNetwork of availableNetworks) { + log('[walletkitBridge] Using native API client for network:', nativeNetwork.chainId); + networksConfig[nativeNetwork.chainId] = { + apiClient: new AndroidAPIClientAdapter(nativeNetwork), + }; + } + } + const kitOptions: Record = { network, networks: networksConfig, diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index 1e5d09536..4295d1b69 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -135,6 +135,23 @@ export interface RejectSignDataRequestArgs { errorCode?: number; } +/** + * Args for approving a signMessage request (gasless transactions). + */ +export interface ApproveSignMessageRequestArgs { + event: TonConnectRequestEvent; + walletId?: string; +} + +/** + * Args for rejecting a signMessage request. + */ +export interface RejectSignMessageRequestArgs { + event: TonConnectRequestEvent; + reason?: string; + errorCode?: number; +} + export interface DisconnectSessionArgs { sessionId?: string; } @@ -272,6 +289,9 @@ export interface WalletKitBridgeApi { rejectTransactionRequest(args: RejectTransactionRequestArgs): PromiseOrValue; approveSignDataRequest(args: ApproveSignDataRequestArgs): PromiseOrValue; rejectSignDataRequest(args: RejectSignDataRequestArgs): PromiseOrValue; + // SignMessage methods for gasless transactions + approveSignMessageRequest(args: ApproveSignMessageRequestArgs): PromiseOrValue; + rejectSignMessageRequest(args: RejectSignMessageRequestArgs): PromiseOrValue; listSessions(): PromiseOrValue; disconnectSession(args?: DisconnectSessionArgs): PromiseOrValue; getNfts(args: GetNftsArgs): PromiseOrValue; diff --git a/packages/walletkit-android-bridge/src/types/events.ts b/packages/walletkit-android-bridge/src/types/events.ts index 9d2424018..3abd4611b 100644 --- a/packages/walletkit-android-bridge/src/types/events.ts +++ b/packages/walletkit-android-bridge/src/types/events.ts @@ -14,6 +14,7 @@ export type WalletKitBridgeEventType = | 'connectRequest' | 'transactionRequest' | 'signDataRequest' + | 'signMessage' | 'disconnect' | 'requestError' | 'browserPageStarted' diff --git a/packages/walletkit-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index 703e4595f..e78d1b8d2 100644 --- a/packages/walletkit-android-bridge/src/types/walletkit.ts +++ b/packages/walletkit-android-bridge/src/types/walletkit.ts @@ -94,4 +94,9 @@ export interface WalletKitInstance { rejectTransactionRequest(event: unknown, reason?: string | { code: number; message: string }): Promise; approveSignDataRequest(event: unknown): Promise; rejectSignDataRequest(event: unknown, reason?: string | { code: number; message: string }): Promise; + // SignMessage methods for gasless transactions + onSignMessageRequest?(callback: (event: unknown) => void): void; + removeSignMessageRequestCallback?(): void; + approveSignMessageRequest(event: unknown): Promise; + rejectSignMessageRequest(event: unknown, reason?: string | { code: number; message: string }): Promise; } diff --git a/packages/walletkit/src/api/interfaces/WalletAdapter.ts b/packages/walletkit/src/api/interfaces/WalletAdapter.ts index 922e84445..1a8725d6a 100644 --- a/packages/walletkit/src/api/interfaces/WalletAdapter.ts +++ b/packages/walletkit/src/api/interfaces/WalletAdapter.ts @@ -36,13 +36,27 @@ export interface WalletAdapter { /** Get state init for wallet deployment base64 encoded boc */ getStateInit(): Promise; - /** Get the signed send transaction */ + /** Get the signed send transaction (external message) */ getSignedSendTransaction( input: TransactionRequest, options?: { fakeSignature: boolean; }, ): Promise; + + /** + * Get signed internal message for gasless transactions (V5+ only). + * Returns a signed internal message BOC that can be sent to a gasless provider. + * Unlike getSignedSendTransaction which creates an external message, + * this creates an internal message that gasless providers can wrap and send. + */ + getSignedInternalMessage?( + input: TransactionRequest, + options?: { + fakeSignature: boolean; + }, + ): Promise; + getSignedSignData( input: PreparedSignData, options?: { diff --git a/packages/walletkit/src/api/models/bridge/SignMessageRequestEvent.ts b/packages/walletkit/src/api/models/bridge/SignMessageRequestEvent.ts new file mode 100644 index 000000000..41415ad1a --- /dev/null +++ b/packages/walletkit/src/api/models/bridge/SignMessageRequestEvent.ts @@ -0,0 +1,51 @@ +/** + * 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 type { TransactionEmulatedPreview } from '../transactions/emulation/TransactionEmulatedPreview'; +import type { TransactionRequest } from '../transactions/TransactionRequest'; +import type { BridgeEvent } from './BridgeEvent'; + +/** + * Event containing a signMessage request from a dApp via TON Connect. + * This is used for gasless transactions where the wallet signs an internal message + * that a gasless provider will wrap and send to the network. + * + * The request structure is identical to sendTransaction, but the result + * is a signed internal message BOC instead of an external message. + */ +export interface SignMessageRequestEvent extends BridgeEvent { + /** + * Preview information for UI display + */ + preview: SignMessageRequestEventPreview; + /** + * Raw transaction request data (same structure as sendTransaction) + */ + request: TransactionRequest; +} + +/** + * Preview data for displaying signMessage request in the wallet UI. + */ +export interface SignMessageRequestEventPreview { + /** + * Emulated transaction preview with actions and traces + */ + data?: TransactionEmulatedPreview; +} + +/** + * Response from approving a signMessage request. + */ +export interface SignMessageApprovalResponse { + /** + * The signed internal message BOC (Base64 encoded). + * This can be sent to a gasless provider for execution. + */ + signedInternalBoc: string; +} diff --git a/packages/walletkit/src/api/models/index.ts b/packages/walletkit/src/api/models/index.ts index 623ddb23d..c1c90f5b7 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -37,6 +37,11 @@ export type { SignDataApprovalResponse } from './bridge/SignDataApprovalResponse export type { SignDataRequestEvent, SignDataRequestEventPreview, SignDataPreview } from './bridge/SignDataRequestEvent'; export type { TransactionApprovalResponse } from './bridge/TransactionApprovalResponse'; export type { TransactionRequestEvent, TransactionRequestEventPreview } from './bridge/TransactionRequestEvent'; +export type { + SignMessageRequestEvent, + SignMessageRequestEventPreview, + SignMessageApprovalResponse, +} from './bridge/SignMessageRequestEvent'; export type { RequestErrorEvent } from './bridge/RequestErrorEvent'; export type { TONConnectSession } from './bridge/TONConnectSession'; diff --git a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts index f37ba3610..6d37d2954 100644 --- a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts +++ b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts @@ -9,7 +9,17 @@ // WalletV5R1 adapter that implements WalletInterface import type { StateInit } from '@ton/core'; -import { Address, beginCell, Cell, Dictionary, loadStateInit, SendMode, storeMessage, storeStateInit } from '@ton/core'; +import { + Address, + beginCell, + Cell, + Dictionary, + loadStateInit, + SendMode, + storeMessage, + storeMessageRelaxed, + storeStateInit, +} from '@ton/core'; import { external, internal } from '@ton/core'; import { WalletV5, WalletV5R1Id } from './WalletV5R1'; @@ -234,7 +244,10 @@ export class WalletV5R1Adapter implements WalletAdapter { throw new Error('Failed to get seqno or walletId'); } - const transfer = await this.createBodyV5(seqno, walletId, actions, createBodyOptions); + const transfer = await this.createBodyV5(seqno, walletId, actions, { + ...createBodyOptions, + internalMessage: false, // Use external opcode (0x7369676e) for regular transactions + }); const ext = external({ to: this.walletContract.address, @@ -244,6 +257,115 @@ export class WalletV5R1Adapter implements WalletAdapter { return beginCell().store(storeMessage(ext)).endCell().toBoc().toString('base64') as Base64String; } + /** + * Get signed internal message for gasless transactions. + * Creates a signed internal message BOC that can be sent to a gasless provider. + * The gasless provider will wrap this in an external message and pay for gas. + * + * This is used for V5 wallets where the wallet signs the action body, + * and a gasless provider can then send this as an internal message. + */ + async getSignedInternalMessage( + input: TransactionRequest, + options: { fakeSignature: boolean }, + ): Promise { + const actions = packActionsList( + input.messages.map((m) => { + let bounce = true; + const parsedAddress = Address.parseFriendly(m.address); + if (parsedAddress.isBounceable === false) { + bounce = false; + } + + const msg = internal({ + to: m.address, + value: BigInt(m.amount), + bounce: bounce, + extracurrency: m.extraCurrency + ? Object.fromEntries(Object.entries(m.extraCurrency).map(([k, v]) => [Number(k), BigInt(v)])) + : undefined, + }); + + if (m.payload) { + try { + msg.body = Cell.fromBase64(m.payload); + } catch (error) { + log.warn('Failed to load payload for internal message', { error }); + throw WalletKitError.fromError( + ERROR_CODES.CONTRACT_VALIDATION_FAILED, + 'Failed to parse transaction payload', + error, + ); + } + } + + if (m.stateInit) { + try { + msg.init = loadStateInit(Cell.fromBase64(m.stateInit).asSlice()); + } catch (error) { + log.warn('Failed to load state init for internal message', { error }); + throw WalletKitError.fromError( + ERROR_CODES.CONTRACT_VALIDATION_FAILED, + 'Failed to parse state init', + error, + ); + } + } + return new ActionSendMsg(SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS, msg); + }), + ); + + const createBodyOptions: { validUntil: number | undefined; fakeSignature: boolean } = { + ...options, + validUntil: undefined, + }; + // add valid until + 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 { + seqno = await CallForSuccess(async () => this.getSeqno(), 5, 1000); + } catch (_) { + // + } + const walletId = (await this.walletContract.walletId).serialized; + if (!walletId) { + throw new Error('Failed to get seqno or walletId'); + } + + // Create the signed body with internal opcode for gasless transactions + const signedBody = await this.createBodyV5(seqno, walletId, actions, { + ...createBodyOptions, + internalMessage: true, // Use internal opcode (0x73696e74) for gasless + }); + + // Create internal message with signed body to the wallet's own address + // This is what the gasless provider will use to execute the transaction + const internalMsg = internal({ + to: this.walletContract.address, + value: BigInt(0), // Gasless provider will set the actual value + bounce: true, + body: signedBody, + }); + + return beginCell().store(storeMessageRelaxed(internalMsg)).endCell().toBoc().toString('base64') as Base64String; + } + /** * Get state init for wallet deployment */ @@ -309,15 +431,17 @@ export class WalletV5R1Adapter implements WalletAdapter { seqno: number, walletId: bigint, actionsList: Cell, - options: { validUntil: number | undefined; fakeSignature: boolean }, + options: { validUntil: number | undefined; fakeSignature: boolean; internalMessage?: boolean }, ) { const Opcodes = { - auth_signed: 0x7369676e, + auth_signed_external: 0x7369676e, // "sign" in ASCII - for external messages + auth_signed_internal: 0x73696e74, // "sint" in ASCII - for internal messages (gasless) }; + const opcode = options.internalMessage ? Opcodes.auth_signed_internal : Opcodes.auth_signed_external; const expireAt = options.validUntil ?? Math.floor(Date.now() / 1000) + 300; const payload = beginCell() - .storeUint(Opcodes.auth_signed, 32) + .storeUint(opcode, 32) .storeUint(walletId, 32) .storeUint(expireAt, 32) .storeUint(seqno, 32) // seqno diff --git a/packages/walletkit/src/core/EventRouter.ts b/packages/walletkit/src/core/EventRouter.ts index 921e5f85f..689d6389f 100644 --- a/packages/walletkit/src/core/EventRouter.ts +++ b/packages/walletkit/src/core/EventRouter.ts @@ -12,6 +12,7 @@ import type { RawBridgeEvent, EventHandler, EventCallback, EventType } from '../ import { ConnectHandler } from '../handlers/ConnectHandler'; import { TransactionHandler } from '../handlers/TransactionHandler'; import { SignDataHandler } from '../handlers/SignDataHandler'; +import { SignMessageHandler } from '../handlers/SignMessageHandler'; import { DisconnectHandler } from '../handlers/DisconnectHandler'; import { validateBridgeEvent } from '../validation/events'; import { globalLogger } from './Logger'; @@ -26,6 +27,7 @@ import type { RequestErrorEvent, DisconnectionEvent, SignDataRequestEvent, + SignMessageRequestEvent, ConnectionRequestEvent, } from '../api/models'; import type { TonWalletKitOptions } from '../types/config'; @@ -40,6 +42,7 @@ export class EventRouter { private connectRequestCallback: EventCallback | undefined = undefined; private transactionRequestCallback: EventCallback | undefined = undefined; private signDataRequestCallback: EventCallback | undefined = undefined; + private signMessageRequestCallback: EventCallback | undefined = undefined; private disconnectCallback: EventCallback | undefined = undefined; private errorCallback: EventCallback | undefined = undefined; @@ -107,6 +110,10 @@ export class EventRouter { this.signDataRequestCallback = callback; } + onSignMessageRequest(callback: EventCallback): void { + this.signMessageRequestCallback = callback; + } + onDisconnect(callback: EventCallback): void { this.disconnectCallback = callback; } @@ -130,6 +137,10 @@ export class EventRouter { this.signDataRequestCallback = undefined; } + removeSignMessageRequestCallback(): void { + this.signMessageRequestCallback = undefined; + } + removeDisconnectCallback(): void { this.disconnectCallback = undefined; } @@ -145,6 +156,7 @@ export class EventRouter { this.connectRequestCallback = undefined; this.transactionRequestCallback = undefined; this.signDataRequestCallback = undefined; + this.signMessageRequestCallback = undefined; this.disconnectCallback = undefined; this.errorCallback = undefined; } @@ -169,6 +181,14 @@ export class EventRouter { this.sessionManager, this.analyticsManager, ), + new SignMessageHandler( + this.notifySignMessageRequestCallbacks.bind(this), + this.config, + this.eventEmitter, + this.walletManager, + this.sessionManager, + this.analyticsManager, + ), new DisconnectHandler(this.notifyDisconnectCallbacks.bind(this), this.sessionManager), ]; } @@ -194,6 +214,13 @@ export class EventRouter { return await this.signDataRequestCallback?.(event); } + /** + * Notify sign message request callbacks (gasless transactions) + */ + private async notifySignMessageRequestCallbacks(event: SignMessageRequestEvent): Promise { + return await this.signMessageRequestCallback?.(event); + } + /** * Notify disconnect callbacks */ @@ -223,6 +250,9 @@ export class EventRouter { if (this.signDataRequestCallback) { enabledTypes.push('signData'); } + if (this.signMessageRequestCallback) { + enabledTypes.push('signMessage'); + } if (this.disconnectCallback) { enabledTypes.push('disconnect'); } diff --git a/packages/walletkit/src/core/EventStore.ts b/packages/walletkit/src/core/EventStore.ts index c66b4c6fd..15f7f96a2 100644 --- a/packages/walletkit/src/core/EventStore.ts +++ b/packages/walletkit/src/core/EventStore.ts @@ -398,6 +398,8 @@ export class StorageEventStore implements EventStore { return 'sendTransaction'; case 'signData': return 'signData'; + case 'signMessage': + return 'signMessage'; case 'disconnect': return 'disconnect'; case 'restoreConnection': diff --git a/packages/walletkit/src/core/RequestProcessor.ts b/packages/walletkit/src/core/RequestProcessor.ts index 7d77dffa2..7fdf60f1f 100644 --- a/packages/walletkit/src/core/RequestProcessor.ts +++ b/packages/walletkit/src/core/RequestProcessor.ts @@ -28,7 +28,7 @@ import { } from '@tonconnect/protocol'; import { getSecureRandomBytes } from '@ton/crypto'; -import type { EventSignDataApproval, TonWalletKitOptions } from '../types'; +import type { EventSignDataApproval, TonWalletKitOptions, EventSignMessageApproval } from '../types'; import type { TONConnectSessionManager } from '../api/interfaces/TONConnectSessionManager'; import type { BridgeManager } from './BridgeManager'; import { globalLogger } from './Logger'; @@ -44,6 +44,8 @@ import type { SignDataPayload, TransactionRequestEvent, SignDataRequestEvent, + SignMessageRequestEvent, + SignMessageApprovalResponse, ConnectionRequestEvent, TransactionApprovalResponse, SignDataApprovalResponse, @@ -459,6 +461,166 @@ export class RequestProcessor { } } + /** + * Process signMessage request approval (for gasless transactions). + * Signs the transaction and returns a signed internal message BOC. + * Does NOT send to network - the gasless provider will do that. + */ + async approveSignMessageRequest( + event: SignMessageRequestEvent | EventSignMessageApproval, + ): Promise { + try { + if ('result' in event) { + // Already signed - just send response + const response: SendTransactionRpcResponseSuccess = { + result: event.result.signedInternalBoc, + id: event.id || '', + }; + + await this.bridgeManager.sendResponse(event, response); + this.sendSignMessageAnalytics(event, event.result.signedInternalBoc); + return { signedInternalBoc: event.result.signedInternalBoc }; + } else { + // Sign the internal message + const signedInternalBoc = await this.signInternalMessage(event); + + // NOTE: We do NOT send to network - gasless provider will do that + + // Send approval response (same format as sendTransaction) + const response: SendTransactionRpcResponseSuccess = { + result: signedInternalBoc, + id: event.id || '', + }; + + await this.bridgeManager.sendResponse(event, response); + this.sendSignMessageAnalytics(event, signedInternalBoc); + return { signedInternalBoc }; + } + } catch (error) { + log.error('Failed to approve signMessage request', { error }); + + if (error instanceof WalletKitError) { + throw error; + } + if ((error as { message: string })?.message?.includes('Ledger device')) { + throw new WalletKitError(ERROR_CODES.LEDGER_DEVICE_ERROR, 'Ledger device error', error as Error); + } + throw error; + } + } + + /** + * Send signMessage analytics events + */ + private sendSignMessageAnalytics( + event: SignMessageRequestEvent | EventSignMessageApproval, + signedBoc: string, + ): void { + if (!this.analytics) return; + + const wallet = this.getWalletFromEvent(event); + + // Use same analytics event as transaction (but could be extended for gasless-specific tracking) + this.analytics.emitWalletTransactionSent({ + trace_id: event.traceId, + network_id: wallet?.getNetwork().chainId, + client_id: event.from, + signed_boc: signedBoc, + }); + } + + /** + * Process signMessage request rejection + */ + async rejectSignMessageRequest( + event: SignMessageRequestEvent, + reason?: string | SendTransactionRpcResponseError['error'], + ): Promise { + try { + const response: SendTransactionRpcResponseError = + typeof reason === 'string' || typeof reason === 'undefined' + ? { + error: { + code: SEND_TRANSACTION_ERROR_CODES.USER_REJECTS_ERROR, + message: reason || 'User rejected signMessage', + }, + id: event.id, + } + : { + error: reason, + id: event.id, + }; + + await this.bridgeManager.sendResponse(event, response); + const wallet = this.getWalletFromEvent(event); + + if (this.analytics) { + const sessionData = event.from ? await this.sessionManager.getSession(event.from) : undefined; + + this.analytics.emitWalletTransactionDeclined({ + wallet_id: sessionData?.publicKey, + trace_id: event.traceId, + dapp_name: event.dAppInfo?.name, + origin_url: event.dAppInfo?.url, + network_id: wallet?.getNetwork().chainId, + client_id: event.from, + decline_reason: typeof reason === 'string' ? reason : reason?.message, + }); + } + + return; + } catch (error) { + log.error('Failed to reject signMessage request', { error }); + throw error; + } + } + + /** + * Sign internal message for gasless transactions + */ + private async signInternalMessage(event: SignMessageRequestEvent): Promise { + const walletId = event.walletId; + const walletAddress = this.getWalletAddressFromEvent(event); + + if (!walletId && !walletAddress) { + throw new WalletKitError(ERROR_CODES.WALLET_REQUIRED, 'Wallet ID is required for signMessage', undefined, { + eventId: event.id, + }); + } + const wallet = this.getWalletFromEvent(event); + if (!wallet) { + throw new WalletKitError(ERROR_CODES.WALLET_NOT_FOUND, 'Wallet not found for signMessage', undefined, { + walletId, + walletAddress, + eventId: event.id, + }); + } + + if (!wallet.getSignedInternalMessage) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Wallet does not support signMessage (requires V5 wallet)', + undefined, + { walletId, eventId: event.id }, + ); + } + + 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 }, + ); + } + } + + return await wallet.getSignedInternalMessage(event.request, { fakeSignature: false }); + } + /** * Process sign data request approval */ diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index e23f84e7b..65a81abcf 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -54,6 +54,8 @@ import type { RequestErrorEvent, DisconnectionEvent, SignDataRequestEvent, + SignMessageRequestEvent, + SignMessageApprovalResponse, ConnectionRequestEvent, TransactionApprovalResponse, SignDataApprovalResponse, @@ -442,6 +444,16 @@ export class TonWalletKit implements ITonWalletKit { } } + onSignMessageRequest(cb: (event: SignMessageRequestEvent) => void): void { + if (this.eventRouter) { + this.eventRouter.onSignMessageRequest(cb); + } else { + this.ensureInitialized().then(() => { + this.eventRouter.onSignMessageRequest(cb); + }); + } + } + onDisconnect(cb: (event: DisconnectionEvent) => void): void { if (this.eventRouter) { this.eventRouter.onDisconnect(cb); @@ -464,6 +476,10 @@ export class TonWalletKit implements ITonWalletKit { this.eventRouter.removeSignDataRequestCallback(); } + removeSignMessageRequestCallback(): void { + this.eventRouter.removeSignMessageRequestCallback(); + } + removeDisconnectCallback(): void { this.eventRouter.removeDisconnectCallback(); } @@ -641,6 +657,19 @@ export class TonWalletKit implements ITonWalletKit { return this.requestProcessor.rejectTransactionRequest(event, reason); } + async approveSignMessageRequest(event: SignMessageRequestEvent): Promise { + await this.ensureInitialized(); + return this.requestProcessor.approveSignMessageRequest(event); + } + + async rejectSignMessageRequest( + event: SignMessageRequestEvent, + reason?: string | SendTransactionRpcResponseError['error'], + ): Promise { + await this.ensureInitialized(); + return this.requestProcessor.rejectSignMessageRequest(event, reason); + } + async approveSignDataRequest(event: SignDataRequestEvent): Promise { await this.ensureInitialized(); return this.requestProcessor.approveSignDataRequest(event); diff --git a/packages/walletkit/src/handlers/SignMessageHandler.ts b/packages/walletkit/src/handlers/SignMessageHandler.ts new file mode 100644 index 000000000..cab6c331e --- /dev/null +++ b/packages/walletkit/src/handlers/SignMessageHandler.ts @@ -0,0 +1,314 @@ +/** + * 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. + * + */ + +/** + * SignMessageHandler - handles signMessage requests for gasless transactions. + * + * This handler works similarly to TransactionHandler but: + * 1. Returns a signed internal message BOC (not external) + * 2. Does NOT send the message to the network (gasless provider does that) + * 3. Only works with V5 wallets that support getSignedInternalMessage + */ + +import { Address } from '@ton/core'; +import type { WalletResponseTemplateError } from '@tonconnect/protocol'; +import { CHAIN, SEND_TRANSACTION_ERROR_CODES } from '@tonconnect/protocol'; + +import type { TonWalletKitOptions, ValidationResult } from '../types'; +import { toTransactionRequest } from '../types/internal'; +import type { + RawBridgeEvent, + EventHandler, + RawBridgeEventSignMessage, + ConnectTransactionParamContent, +} from '../types/internal'; +import { validateTransactionMessages as validateTonConnectTransactionMessages } from '../validation/transaction'; +import { globalLogger } from '../core/Logger'; +import { isValidAddress } from '../utils/address'; +import { createTransactionPreview as createTransactionPreviewHelper } from '../utils/toncenterEmulation'; +import { BasicHandler } from './BasicHandler'; +import { CallForSuccess } from '../utils/retry'; +import type { EventEmitter } from '../core/EventEmitter'; +import type { WalletManager } from '../core/WalletManager'; +import type { ReturnWithValidationResult } from '../validation/types'; +import { WalletKitError, ERROR_CODES } from '../errors'; +import type { Wallet } from '../api/interfaces'; +import type { TransactionEmulatedPreview, TransactionRequest } from '../api/models'; +import type { SignMessageRequestEvent } from '../api/models'; +import { Result } from '../api/models'; +import type { Analytics, AnalyticsManager } from '../analytics'; +import type { TONConnectSessionManager } from '../api/interfaces/TONConnectSessionManager'; + +const log = globalLogger.createChild('SignMessageHandler'); + +/** + * Handles signMessage requests for gasless transactions. + * The signMessage method works like sendTransaction but returns + * a signed internal message that can be sent to a gasless provider. + */ +export class SignMessageHandler + extends BasicHandler + implements EventHandler +{ + private eventEmitter: EventEmitter; + private analytics?: Analytics; + + constructor( + notify: (event: SignMessageRequestEvent) => void, + private readonly config: TonWalletKitOptions, + eventEmitter: EventEmitter, + private readonly walletManager: WalletManager, + private readonly sessionManager: TONConnectSessionManager, + analyticsManager?: AnalyticsManager, + ) { + super(notify); + this.eventEmitter = eventEmitter; + this.sessionManager = sessionManager; + this.analytics = analyticsManager?.scoped(); + } + + canHandle(event: RawBridgeEvent): event is RawBridgeEventSignMessage { + return event.method === 'signMessage'; + } + + async handle(event: RawBridgeEventSignMessage): Promise { + const walletId = event.walletId; + const walletAddress = event.walletAddress; + + if (!walletId && !walletAddress) { + log.error('Wallet ID not found', { event }); + return { + error: { + code: SEND_TRANSACTION_ERROR_CODES.UNKNOWN_APP_ERROR, + message: 'Wallet ID not found', + }, + id: event.id, + }; + } + + const wallet = walletId ? this.walletManager.getWallet(walletId) : undefined; + if (!wallet) { + log.error('Wallet not found', { event, walletId, walletAddress }); + return { + error: { + code: SEND_TRANSACTION_ERROR_CODES.UNKNOWN_APP_ERROR, + message: 'Wallet not found', + }, + id: event.id, + }; + } + + // Check if wallet supports signMessage (V5+ only) + if (!wallet.getSignedInternalMessage) { + log.error('Wallet does not support signMessage', { event, walletId }); + return { + error: { + code: SEND_TRANSACTION_ERROR_CODES.UNKNOWN_APP_ERROR, + message: 'Wallet does not support signMessage (requires V5 wallet)', + }, + id: event.id, + }; + } + + const requestValidation = this.parseSignMessageRequest(event, wallet); + if (!requestValidation.result || !requestValidation?.validation?.isValid) { + log.error('Failed to parse signMessage request', { + errors: requestValidation?.validation?.errors, + eventId: event.id, + }); + this.eventEmitter.emit('event:error', event); + + return { + error: { + code: SEND_TRANSACTION_ERROR_CODES.BAD_REQUEST_ERROR, + message: `Failed to parse signMessage request: ${requestValidation?.validation?.errors?.join(', ') || 'unknown error'}`, + }, + id: event.id, + }; + } + const request = requestValidation.result; + + let preview: TransactionEmulatedPreview | undefined; + if (!this.config.eventProcessor?.disableTransactionEmulation) { + try { + preview = await CallForSuccess(() => createTransactionPreviewHelper(wallet.client, request, wallet)); + if (preview.result === Result.success && preview.trace) { + try { + this.eventEmitter.emit('emulation:result', preview.trace); + } catch (error) { + log.warn('Error emitting emulation result event', { error }); + } + } + } catch (error) { + log.error('Failed to create transaction preview', { error }); + preview = { + error: { + code: ERROR_CODES.UNKNOWN_EMULATION_ERROR, + message: 'Unknown emulation error', + }, + result: Result.failure, + }; + } + } + + const signMessageEvent: SignMessageRequestEvent = { + ...event, + request, + preview: { + data: preview, + }, + dAppInfo: event.dAppInfo ?? {}, + walletId: walletId ?? this.walletManager.getWalletId(wallet), + walletAddress: walletAddress ?? wallet.getAddress(), + }; + + if (this.analytics) { + const sessionData = event.from ? await this.sessionManager.getSession(event.from) : undefined; + + this.analytics?.emitWalletTransactionRequestReceived({ + trace_id: event.traceId, + client_id: event.from, + wallet_id: sessionData?.publicKey, + dapp_name: event.dAppInfo?.name, + network_id: wallet.getNetwork().chainId, + origin_url: event.dAppInfo?.url, + }); + } + + return signMessageEvent; + } + + /** + * Parse signMessage request from bridge event + */ + private parseSignMessageRequest( + event: RawBridgeEventSignMessage, + wallet: Wallet, + ): { + result: TransactionRequest | undefined; + validation: ValidationResult; + } { + let errors: string[] = []; + try { + if (!event.params || event.params.length !== 1) { + throw new WalletKitError( + ERROR_CODES.INVALID_REQUEST_EVENT, + 'Invalid signMessage request - expected exactly 1 parameter', + undefined, + { paramCount: event.params?.length, eventId: event.id }, + ); + } + + const params = JSON.parse(event.params[0]) as ConnectTransactionParamContent; + + const validUntilValidation = this.validateValidUntil(params.valid_until); + if (!validUntilValidation.isValid) { + errors = errors.concat(validUntilValidation.errors); + } else { + params.valid_until = validUntilValidation.result; + } + + const networkValidation = this.validateNetwork(params.network, wallet); + if (!networkValidation.isValid) { + errors = errors.concat(networkValidation.errors); + } else { + params.network = networkValidation.result; + } + + const fromValidation = this.validateFrom(params.from, wallet); + if (!fromValidation.isValid) { + errors = errors.concat(fromValidation.errors); + } else { + params.from = fromValidation.result; + } + + const isTonConnect = !event.isLocal; + const messagesValidation = validateTonConnectTransactionMessages(params.messages, isTonConnect); + if (!messagesValidation.isValid) { + errors = errors.concat(messagesValidation.errors); + } + + return { + result: toTransactionRequest(params), + validation: { isValid: errors.length === 0, errors: errors }, + }; + } catch (error) { + log.error('Failed to parse signMessage request', { error }); + errors.push('Failed to parse signMessage request'); + return { + result: undefined, + validation: { isValid: errors.length === 0, errors: errors }, + }; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private validateNetwork(network: any, wallet: Wallet): ReturnWithValidationResult { + const errors: string[] = []; + if (typeof network === 'string') { + if (network === '-3' || network === '-239') { + const chain = network === '-3' ? CHAIN.TESTNET : CHAIN.MAINNET; + const walletNetwork = wallet.getNetwork(); + if (chain !== walletNetwork.chainId) { + errors.push('Invalid network not equal to wallet network'); + } else { + return { result: chain, isValid: errors.length === 0, errors: errors }; + } + } else { + errors.push('Invalid network not a valid network'); + } + } else { + errors.push('Invalid network not a string'); + } + + return { result: undefined, isValid: errors.length === 0, errors: errors }; + } + + private validateFrom(from: unknown, wallet: Wallet): ReturnWithValidationResult { + const errors: string[] = []; + + if (typeof from !== 'string') { + errors.push('Invalid from address not a string'); + return { result: '', isValid: errors.length === 0, errors: errors }; + } + + if (!isValidAddress(from)) { + errors.push('Invalid from address'); + return { result: '', isValid: errors.length === 0, errors: errors }; + } + + const fromAddress = Address.parse(from); + const walletAddress = Address.parse(wallet.getAddress()); + if (!fromAddress.equals(walletAddress)) { + errors.push('Invalid from address not equal to wallet address'); + return { result: '', isValid: errors.length === 0, errors: errors }; + } + + return { result: from, isValid: errors.length === 0, errors: errors }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private validateValidUntil(validUntil: any): ReturnWithValidationResult { + const 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) { + errors.push('Invalid validUntil timestamp'); + return { result: 0, isValid: errors.length === 0, errors: errors }; + } + + return { result: validUntil, isValid: errors.length === 0, errors: errors }; + } +} diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index ff16607f8..dd9b47a67 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -28,6 +28,7 @@ export { StorageEventProcessor } from './core/EventProcessor'; export { ConnectHandler } from './handlers/ConnectHandler'; export { TransactionHandler } from './handlers/TransactionHandler'; export { SignDataHandler } from './handlers/SignDataHandler'; +export { SignMessageHandler } from './handlers/SignMessageHandler'; export { DisconnectHandler } from './handlers/DisconnectHandler'; export { WalletV5, WalletV5R1Id, Opcodes } from './contracts/w5/WalletV5R1'; export type { WalletV5Config } from './contracts/w5/WalletV5R1'; diff --git a/packages/walletkit/src/types/events.ts b/packages/walletkit/src/types/events.ts index 56c9068b1..3e928261a 100644 --- a/packages/walletkit/src/types/events.ts +++ b/packages/walletkit/src/types/events.ts @@ -47,3 +47,18 @@ export interface SignDataApproval { domain: string; payload: SignDataPayload; } + +/** + * Approval event for signMessage requests (gasless transactions) + */ +export interface EventSignMessageApproval extends EventApprovalBase { + result: SignMessageApproval; +} + +/** + * Approval data for signMessage (gasless) requests + */ +export interface SignMessageApproval { + /** The signed internal message BOC (Base64 encoded) */ + signedInternalBoc: Base64String; +} diff --git a/packages/walletkit/src/types/index.ts b/packages/walletkit/src/types/index.ts index 0266bfba4..f4e25289a 100644 --- a/packages/walletkit/src/types/index.ts +++ b/packages/walletkit/src/types/index.ts @@ -13,7 +13,7 @@ export type { HumanReadableTx } from '../validation/transaction'; export type { ValidationResult } from '../validation/types'; // Event types -export type { EventTransactionApproval, EventSignDataApproval } from './events'; +export type { EventTransactionApproval, EventSignDataApproval, EventSignMessageApproval } from './events'; // Configuration types export type { TonWalletKitOptions, NetworkConfig, NetworkAdapters, ApiClientConfig } from './config'; diff --git a/packages/walletkit/src/types/internal.ts b/packages/walletkit/src/types/internal.ts index fbbd2bceb..b213ff1b9 100644 --- a/packages/walletkit/src/types/internal.ts +++ b/packages/walletkit/src/types/internal.ts @@ -174,6 +174,17 @@ export function toConnectTransactionParamContent(request: TransactionRequest): C export type RawBridgeEventTransaction = BridgeEvent & SendTransactionRpcRequest; export type RawBridgeEventSignData = BridgeEvent & SignDataRpcRequest; +/** + * Raw bridge event for signMessage requests (gasless transactions). + * Uses the same structure as sendTransaction but with 'signMessage' method. + */ +export interface RawBridgeEventSignMessage extends BridgeEvent { + id: string; + method: 'signMessage'; + params: [string]; // JSON stringified ConnectTransactionParamContent (same as sendTransaction) + timestamp?: number; +} + export interface RawBridgeEventDisconnect extends BridgeEvent { id: string; method: 'disconnect'; @@ -189,10 +200,11 @@ export type RawBridgeEvent = | RawBridgeEventRestoreConnection | RawBridgeEventTransaction | RawBridgeEventSignData + | RawBridgeEventSignMessage | RawBridgeEventDisconnect; // Internal event routing types -export type EventType = 'connect' | 'sendTransaction' | 'signData' | 'disconnect' | 'restoreConnection'; +export type EventType = 'connect' | 'sendTransaction' | 'signData' | 'signMessage' | 'disconnect' | 'restoreConnection'; export interface EventHandler { canHandle(event: RawBridgeEvent): event is V; diff --git a/packages/walletkit/src/types/kit.ts b/packages/walletkit/src/types/kit.ts index 14c51f66e..83f1d5e16 100644 --- a/packages/walletkit/src/types/kit.ts +++ b/packages/walletkit/src/types/kit.ts @@ -21,6 +21,7 @@ import type { RequestErrorEvent, DisconnectionEvent, SignDataRequestEvent, + SignMessageRequestEvent, ConnectionRequestEvent, TransactionApprovalResponse, SignDataApprovalResponse, @@ -118,6 +119,9 @@ export interface ITonWalletKit { /** Register sign data request handler */ onSignDataRequest(cb: (event: SignDataRequestEvent) => void): void; + /** Register sign message request handler (gasless transactions) */ + onSignMessageRequest(cb: (event: SignMessageRequestEvent) => void): void; + /** Register disconnect handler */ onDisconnect(cb: (event: DisconnectionEvent) => void): void; @@ -128,6 +132,7 @@ export interface ITonWalletKit { removeConnectRequestCallback(cb: (event: ConnectionRequestEvent) => void): void; removeTransactionRequestCallback(cb: (event: TransactionRequestEvent) => void): void; removeSignDataRequestCallback(cb: (event: SignDataRequestEvent) => void): void; + removeSignMessageRequestCallback(cb: (event: SignMessageRequestEvent) => void): void; removeDisconnectCallback(cb: (event: DisconnectionEvent) => void): void; removeErrorCallback(cb: (event: RequestErrorEvent) => void): void;