diff --git a/demo/v4ledger-adapter/src/WalletV4R2LedgerAdapter.ts b/demo/v4ledger-adapter/src/WalletV4R2LedgerAdapter.ts index 194a23e0c..874c937dd 100644 --- a/demo/v4ledger-adapter/src/WalletV4R2LedgerAdapter.ts +++ b/demo/v4ledger-adapter/src/WalletV4R2LedgerAdapter.ts @@ -112,7 +112,8 @@ export class WalletV4R2LedgerAdapter implements WalletAdapter { * Get wallet's TON address */ getAddress(options?: { testnet?: boolean }): UserFriendlyAddress { - return formatWalletAddress(this.walletContract.address, options?.testnet); + // Convert to string to avoid Address type version mismatch between @ton/core versions + return formatWalletAddress(this.walletContract.address.toString(), options?.testnet); } getWalletId(): WalletId { diff --git a/packages/walletkit-android-bridge/src/adapters/AndroidStorageAdapter.ts b/packages/walletkit-android-bridge/src/adapters/AndroidStorageAdapter.ts index 7b953b6d0..7f2eb846d 100644 --- a/packages/walletkit-android-bridge/src/adapters/AndroidStorageAdapter.ts +++ b/packages/walletkit-android-bridge/src/adapters/AndroidStorageAdapter.ts @@ -39,7 +39,6 @@ export class AndroidStorageAdapter implements StorageAdapter { async get(key: string): Promise { try { const value = this.androidBridge.storageGet(key); - log('[AndroidStorageAdapter] get:', key, '=', value ? `${value.substring(0, 100)}...` : 'null'); if (!value) { return null; } @@ -53,7 +52,6 @@ export class AndroidStorageAdapter implements StorageAdapter { async set(key: string, value: T): Promise { try { const serialized = JSON.stringify(value); - log('[AndroidStorageAdapter] set:', key, '=', serialized.substring(0, 100) + '...'); this.androidBridge.storageSet(key, serialized); } catch (err) { error('[AndroidStorageAdapter] Failed to set key:', key, err); diff --git a/packages/walletkit-android-bridge/src/api/browser.ts b/packages/walletkit-android-bridge/src/api/browser.ts index ad39bf08d..f0e17afba 100644 --- a/packages/walletkit-android-bridge/src/api/browser.ts +++ b/packages/walletkit-android-bridge/src/api/browser.ts @@ -9,49 +9,24 @@ /** * Internal browser events dispatched back to the native layer. */ -import type { EmitBrowserPageArgs, EmitBrowserErrorArgs, EmitBrowserBridgeRequestArgs } from '../types'; import { emit } from '../transport/messaging'; -/** - * Signals that the internal browser started loading a page. - * - * @param args - Page metadata. - */ -export function emitBrowserPageStarted(args: EmitBrowserPageArgs) { - emit('browserPageStarted', { url: args.url }); +export function emitBrowserPageStarted(args: { url: string }) { + emit('browserPageStarted', args); return { success: true }; } -/** - * Signals that the internal browser finished loading a page. - * - * @param args - Page metadata. - */ -export function emitBrowserPageFinished(args: EmitBrowserPageArgs) { - emit('browserPageFinished', { url: args.url }); +export function emitBrowserPageFinished(args: { url: string }) { + emit('browserPageFinished', args); return { success: true }; } -/** - * Reports a browser error to the native layer. - * - * @param args - Error details. - */ -export function emitBrowserError(args: EmitBrowserErrorArgs) { - emit('browserError', { message: args.message }); +export function emitBrowserError(args: { message: string }) { + emit('browserError', args); return { success: true }; } -/** - * Emits a TonConnect bridge request originating in the internal browser. - * - * @param args - Request metadata for analytics/UI. - */ -export function emitBrowserBridgeRequest(args: EmitBrowserBridgeRequestArgs) { - emit('browserBridgeRequest', { - messageId: args.messageId, - method: args.method, - request: args.request, - }); +export function emitBrowserBridgeRequest(args: { messageId: string; method: string; request: unknown }) { + emit('browserBridgeRequest', args); return { success: true }; } diff --git a/packages/walletkit-android-bridge/src/api/cryptography.ts b/packages/walletkit-android-bridge/src/api/cryptography.ts index c1c487b30..57a767b0c 100644 --- a/packages/walletkit-android-bridge/src/api/cryptography.ts +++ b/packages/walletkit-android-bridge/src/api/cryptography.ts @@ -8,64 +8,33 @@ /** * Cryptographic helpers backed by WalletKit and custom signer coordination. - * - * Note: Array.from() conversions for Uint8Array → number[] are necessary glue code. - * JSON.stringify cannot serialize Uint8Array, so we must convert to number arrays - * for RPC communication between JS and Kotlin layers. */ import type { Hex } from '@ton/walletkit'; -import type { MnemonicToKeyPairArgs, SignArgs, CreateTonMnemonicArgs } from '../types'; import { CreateTonMnemonic, MnemonicToKeyPair, DefaultSignature } from '../core/moduleLoader'; -import { callBridge } from '../utils/bridgeWrapper'; -/** - * Signs data using a custom signer stored in Kotlin. - * This is called by custom signer wrappers created in createAdapter. - */ export async function signWithCustomSigner(signerId: string, bytes: Uint8Array): Promise { - const result = await callBridge('signWithCustomSigner', async () => { - // Call back to Kotlin's SignerManager - return window.WalletKitNative?.signWithCustomSigner?.(signerId, Array.from(bytes)); - }); + const result = await window.WalletKitNative?.signWithCustomSigner?.(signerId, Array.from(bytes)); return result as Hex; } -/** - * Converts a mnemonic phrase to a key pair (public + secret keys). - * Returns raw keyPair object - Kotlin handles Uint8Array to ByteArray conversion. - * - * @param args - Mnemonic words and optional type ('ton' or 'bip39'). - */ -export async function mnemonicToKeyPair(args: MnemonicToKeyPairArgs) { - return callBridge('mnemonicToKeyPair', async () => { - return await MnemonicToKeyPair!(args.mnemonic, args.mnemonicType ?? 'ton'); - }); +export async function mnemonicToKeyPair(args: { mnemonic: string[]; mnemonicType?: string }) { + if (!MnemonicToKeyPair) { + throw new Error('MnemonicToKeyPair module not loaded'); + } + return MnemonicToKeyPair(args.mnemonic, args.mnemonicType ?? 'ton'); } -/** - * Signs arbitrary data using a secret key. - * Returns signature hex string directly. - * - * @param args - Data bytes and secret key bytes. - */ -export async function sign(args: SignArgs) { - return callBridge('sign', async () => { - const dataBytes = Uint8Array.from(args.data); - const secretKeyBytes = Uint8Array.from(args.secretKey); - return DefaultSignature!(dataBytes, secretKeyBytes); - }); +export async function sign(args: { data: number[]; secretKey: number[] }) { + if (!DefaultSignature) { + throw new Error('DefaultSignature module not loaded'); + } + return DefaultSignature(Uint8Array.from(args.data), Uint8Array.from(args.secretKey)); } -/** - * Generates a TON mnemonic phrase. - * Returns array of words directly. - * - * @param _args - Optional generation parameters. - */ -export async function createTonMnemonic(_args: CreateTonMnemonicArgs = { count: 24 }) { - return callBridge('createTonMnemonic', async () => { - const mnemonicResult = await CreateTonMnemonic!(); - return Array.isArray(mnemonicResult) ? mnemonicResult : `${mnemonicResult}`.split(' ').filter(Boolean); - }); +export async function createTonMnemonic() { + if (!CreateTonMnemonic) { + throw new Error('CreateTonMnemonic module not loaded'); + } + return CreateTonMnemonic(); } diff --git a/packages/walletkit-android-bridge/src/api/eventListeners.ts b/packages/walletkit-android-bridge/src/api/eventListeners.ts index 81efb23de..236cbe480 100644 --- a/packages/walletkit-android-bridge/src/api/eventListeners.ts +++ b/packages/walletkit-android-bridge/src/api/eventListeners.ts @@ -6,15 +6,41 @@ * */ +import type { + ConnectionRequestEvent, + DisconnectionEvent, + RequestErrorEvent, + SendTransactionRequestEvent, + SignDataRequestEvent, + IntentEvent, +} from '@ton/walletkit'; + /** * Shared event listener references used to manage WalletKit callbacks. */ -export type BridgeEventListener = ((event: unknown) => void) | null; +export type ConnectEventListener = ((event: ConnectionRequestEvent) => void) | null; +export type TransactionEventListener = ((event: SendTransactionRequestEvent) => void) | null; +export type SignDataEventListener = ((event: SignDataRequestEvent) => void) | null; +export type DisconnectEventListener = ((event: DisconnectionEvent) => void) | null; +export type ErrorEventListener = ((event: RequestErrorEvent) => void) | null; +export type IntentEventListener = ((event: IntentEvent) => void) | null; + +/** + * Union type for all bridge event listeners. + */ +export type BridgeEventListener = + | ConnectEventListener + | TransactionEventListener + | SignDataEventListener + | DisconnectEventListener + | ErrorEventListener + | IntentEventListener; export const eventListeners = { - onConnectListener: null as BridgeEventListener, - onTransactionListener: null as BridgeEventListener, - onSignDataListener: null as BridgeEventListener, - onDisconnectListener: null as BridgeEventListener, - onErrorListener: null as BridgeEventListener, + onConnectListener: null as ConnectEventListener, + onTransactionListener: null as TransactionEventListener, + onSignDataListener: null as SignDataEventListener, + onDisconnectListener: null as DisconnectEventListener, + onErrorListener: null as ErrorEventListener, + onIntentListener: null as IntentEventListener, }; diff --git a/packages/walletkit-android-bridge/src/api/index.ts b/packages/walletkit-android-bridge/src/api/index.ts index 52207f0fb..5bdbdd2ac 100644 --- a/packages/walletkit-android-bridge/src/api/index.ts +++ b/packages/walletkit-android-bridge/src/api/index.ts @@ -16,6 +16,7 @@ import * as wallets from './wallets'; import * as transactions from './transactions'; import * as requests from './requests'; import * as tonconnect from './tonconnect'; +import * as intents from './intents'; import * as nft from './nft'; import * as jettons from './jettons'; import * as browser from './browser'; @@ -23,7 +24,7 @@ import { eventListeners } from './eventListeners'; export { eventListeners }; -const apiImpl: WalletKitBridgeApi = { +export const api: WalletKitBridgeApi = { // Initialization init: initialization.init, setEventsListeners: initialization.setEventsListeners, @@ -40,7 +41,7 @@ const apiImpl: WalletKitBridgeApi = { getAdapterAddress: wallets.getAdapterAddress, addWallet: wallets.addWallet, getWallets: wallets.getWallets, - getWallet: wallets.getWallet, + getWallet: wallets.getWalletById, getWalletAddress: wallets.getWalletAddress, removeWallet: wallets.removeWallet, getBalance: wallets.getBalance, @@ -67,6 +68,16 @@ const apiImpl: WalletKitBridgeApi = { disconnectSession: tonconnect.disconnectSession, processInternalBrowserRequest: tonconnect.processInternalBrowserRequest, + // Intents + handleIntentUrl: intents.handleIntentUrl, + isIntentUrl: intents.isIntentUrl, + intentItemsToTransactionRequest: intents.intentItemsToTransactionRequest, + approveTransactionIntent: intents.approveTransactionIntent, + approveSignDataIntent: intents.approveSignDataIntent, + approveActionIntent: intents.approveActionIntent, + rejectIntent: intents.rejectIntent, + processConnectAfterIntent: intents.processConnectAfterIntent, + // NFTs getNfts: nft.getNfts, getNft: nft.getNft, @@ -84,8 +95,6 @@ const apiImpl: WalletKitBridgeApi = { emitBrowserPageFinished: browser.emitBrowserPageFinished, emitBrowserError: browser.emitBrowserError, emitBrowserBridgeRequest: browser.emitBrowserBridgeRequest, -}; - -export const api = apiImpl; +} as unknown as WalletKitBridgeApi; export type { BridgeEventListener } from './eventListeners'; diff --git a/packages/walletkit-android-bridge/src/api/initialization.ts b/packages/walletkit-android-bridge/src/api/initialization.ts index a790f75da..4f119c9ca 100644 --- a/packages/walletkit-android-bridge/src/api/initialization.ts +++ b/packages/walletkit-android-bridge/src/api/initialization.ts @@ -12,9 +12,19 @@ * Simplified bridge for WalletKit initialization and event listener management. */ +import type { + ConnectionRequestEvent, + DisconnectionEvent, + IntentEvent, + RequestErrorEvent, + SendTransactionRequestEvent, + SignDataRequestEvent, +} from '@ton/walletkit'; + import type { WalletKitBridgeInitConfig, SetEventsListenersArgs, WalletKitBridgeEventCallback } from '../types'; import { ensureWalletKitLoaded } from '../core/moduleLoader'; -import { initTonWalletKit, requireWalletKit } from '../core/initialization'; +import { initTonWalletKit } from '../core/initialization'; +import { getKit } from '../utils/bridge'; import { emit } from '../transport/messaging'; import { postToNative } from '../transport/nativeBridge'; import { eventListeners } from './eventListeners'; @@ -36,8 +46,8 @@ export async function init(config?: WalletKitBridgeInitConfig) { /** * Registers bridge event listeners, proxying WalletKit events to the native layer. */ -export function setEventsListeners(args?: SetEventsListenersArgs): { ok: true } { - const kit = requireWalletKit(); +export async function setEventsListeners(args?: SetEventsListenersArgs): Promise<{ ok: true }> { + const kit = await getKit(); const callback: WalletKitBridgeEventCallback = args?.callback ?? @@ -49,7 +59,7 @@ export function setEventsListeners(args?: SetEventsListenersArgs): { ok: true } kit.removeConnectRequestCallback(); } - eventListeners.onConnectListener = (event: unknown) => { + eventListeners.onConnectListener = (event: ConnectionRequestEvent) => { callback('connectRequest', event); }; @@ -59,7 +69,7 @@ export function setEventsListeners(args?: SetEventsListenersArgs): { ok: true } kit.removeTransactionRequestCallback(); } - eventListeners.onTransactionListener = (event: unknown) => { + eventListeners.onTransactionListener = (event: SendTransactionRequestEvent) => { callback('transactionRequest', event); }; @@ -69,7 +79,7 @@ export function setEventsListeners(args?: SetEventsListenersArgs): { ok: true } kit.removeSignDataRequestCallback(); } - eventListeners.onSignDataListener = (event: unknown) => { + eventListeners.onSignDataListener = (event: SignDataRequestEvent) => { callback('signDataRequest', event); }; @@ -79,7 +89,7 @@ export function setEventsListeners(args?: SetEventsListenersArgs): { ok: true } kit.removeDisconnectCallback(); } - eventListeners.onDisconnectListener = (event: unknown) => { + eventListeners.onDisconnectListener = (event: DisconnectionEvent) => { callback('disconnect', event); }; @@ -90,20 +100,31 @@ export function setEventsListeners(args?: SetEventsListenersArgs): { ok: true } kit.removeErrorCallback(); } - eventListeners.onErrorListener = (event: unknown) => { + eventListeners.onErrorListener = (event: RequestErrorEvent) => { callback('requestError', event); }; kit.onRequestError(eventListeners.onErrorListener); + // Register intent listener - forwards IntentEvent for wallet UI + if (eventListeners.onIntentListener) { + kit.removeIntentRequestCallback?.(); + } + + eventListeners.onIntentListener = (event: IntentEvent) => { + callback('intentRequest', event); + }; + + kit.onIntentRequest?.(eventListeners.onIntentListener); + return { ok: true }; } /** * Removes all previously registered bridge event listeners. */ -export function removeEventListeners(): { ok: true } { - const kit = requireWalletKit(); +export async function removeEventListeners(): Promise<{ ok: true }> { + const kit = await getKit(); if (eventListeners.onConnectListener) { kit.removeConnectRequestCallback(); @@ -130,5 +151,10 @@ export function removeEventListeners(): { ok: true } { eventListeners.onErrorListener = null; } + if (eventListeners.onIntentListener) { + kit.removeIntentRequestCallback?.(); + eventListeners.onIntentListener = null; + } + return { ok: true }; } diff --git a/packages/walletkit-android-bridge/src/api/intents.ts b/packages/walletkit-android-bridge/src/api/intents.ts new file mode 100644 index 000000000..fda3b23b2 --- /dev/null +++ b/packages/walletkit-android-bridge/src/api/intents.ts @@ -0,0 +1,37 @@ +/** + * 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. + * + */ + +/** + * intents.ts – Intent URL handling operations + * + * Handles TonConnect intent deep links (tc://intent_inline?...) that allow + * dApps to request actions without a prior TonConnect session. + */ + +import type { + HandleIntentUrlArgs, + IsIntentUrlArgs, + IntentItemsToTransactionRequestArgs, + ApproveTransactionIntentArgs, + ApproveSignDataIntentArgs, + ApproveActionIntentArgs, + ProcessConnectAfterIntentArgs, + RejectIntentArgs, +} from '../types'; +import { kit } from '../utils/bridge'; + +export const isIntentUrl = (args: IsIntentUrlArgs) => kit('isIntentUrl', args); +export const handleIntentUrl = (args: HandleIntentUrlArgs) => kit('handleIntentUrl', args); +export const intentItemsToTransactionRequest = (args: IntentItemsToTransactionRequestArgs) => + kit('intentItemsToTransactionRequest', args); +export const approveTransactionIntent = (args: ApproveTransactionIntentArgs) => kit('approveTransactionIntent', args); +export const approveSignDataIntent = (args: ApproveSignDataIntentArgs) => kit('approveSignDataIntent', args); +export const rejectIntent = (args: RejectIntentArgs) => kit('rejectIntent', args); +export const approveActionIntent = (args: ApproveActionIntentArgs) => kit('approveActionIntent', args); +export const processConnectAfterIntent = (args: ProcessConnectAfterIntentArgs) => + kit('processConnectAfterIntent', args); diff --git a/packages/walletkit-android-bridge/src/api/jettons.ts b/packages/walletkit-android-bridge/src/api/jettons.ts index 7ccc2e416..17bb430f0 100644 --- a/packages/walletkit-android-bridge/src/api/jettons.ts +++ b/packages/walletkit-android-bridge/src/api/jettons.ts @@ -9,56 +9,13 @@ /** * jettons.ts – Jetton operations * - * Simplified bridge for jetton balance queries and transfer transactions. + * Minimal bridge for jetton operations. */ -import type { - GetJettonsArgs, - CreateTransferJettonTransactionArgs, - GetJettonBalanceArgs, - GetJettonWalletAddressArgs, -} from '../types'; -import { callBridge, callOnWalletBridge } from '../utils/bridgeWrapper'; +import { walletCall } from '../utils/bridge'; -/** - * Fetches jetton balances for a wallet with optional pagination. - */ -export async function getJettons(args: GetJettonsArgs) { - return callBridge('getJettons', async () => { - return await callOnWalletBridge(args.walletId, 'getJettons', { - pagination: args.pagination, - }); - }); -} - -/** - * Builds a jetton transfer transaction. - */ -export async function createTransferJettonTransaction(args: CreateTransferJettonTransactionArgs) { - return callBridge('createTransferJettonTransaction', async () => { - return await callOnWalletBridge(args.walletId, 'createTransferJettonTransaction', { - jettonAddress: args.jettonAddress, - amount: args.amount, - toAddress: args.toAddress, - comment: args.comment, - }); - }); -} - -/** - * Retrieves a jetton balance for the specified wallet. - */ -export async function getJettonBalance(args: GetJettonBalanceArgs) { - return callBridge('getJettonBalance', async () => { - return await callOnWalletBridge(args.walletId, 'getJettonBalance', args.jettonAddress); - }); -} - -/** - * Resolves the jetton wallet address for a specific jetton contract. - */ -export async function getJettonWalletAddress(args: GetJettonWalletAddressArgs) { - return callBridge('getJettonWalletAddress', async () => { - return await callOnWalletBridge(args.walletId, 'getJettonWalletAddress', args.jettonAddress); - }); -} +export const getJettons = (args: { walletId: string }) => walletCall('getJettons', args); +export const createTransferJettonTransaction = (args: { walletId: string }) => + walletCall('createTransferJettonTransaction', args); +export const getJettonBalance = (args: { walletId: string }) => walletCall('getJettonBalance', args); +export const getJettonWalletAddress = (args: { walletId: string }) => walletCall('getJettonWalletAddress', args); diff --git a/packages/walletkit-android-bridge/src/api/nft.ts b/packages/walletkit-android-bridge/src/api/nft.ts index 561cd0535..b504973ce 100644 --- a/packages/walletkit-android-bridge/src/api/nft.ts +++ b/packages/walletkit-android-bridge/src/api/nft.ts @@ -9,62 +9,14 @@ /** * nft.ts – NFT operations * - * Simplified bridge for NFT listing and transfer transactions. + * Minimal bridge for NFT operations. */ -import type { - GetNftsArgs, - GetNftArgs, - CreateTransferNftTransactionArgs, - CreateTransferNftRawTransactionArgs, -} from '../types'; -import { callBridge, callOnWalletBridge } from '../utils/bridgeWrapper'; +import { walletCall } from '../utils/bridge'; -/** - * Fetches NFTs owned by a wallet with optional pagination. - */ -export async function getNfts(args: GetNftsArgs) { - return callBridge('getNfts', async () => { - return await callOnWalletBridge(args.walletId, 'getNfts', { - pagination: args.pagination, - collectionAddress: args.collectionAddress, - indirectOwnership: args.indirectOwnership, - }); - }); -} - -/** - * Fetches details for a single NFT by address. - */ -export async function getNft(args: GetNftArgs) { - return callBridge('getNft', async () => { - return await callOnWalletBridge(args.walletId, 'getNft', args.nftAddress); - }); -} - -/** - * Builds an NFT transfer transaction (human-readable parameters). - */ -export async function createTransferNftTransaction(args: CreateTransferNftTransactionArgs) { - return callBridge('createTransferNftTransaction', async () => { - return await callOnWalletBridge(args.walletId, 'createTransferNftTransaction', { - nftAddress: args.nftAddress, - toAddress: args.toAddress, - transferAmount: args.transferAmount, - comment: args.comment, - }); - }); -} - -/** - * Builds an NFT transfer transaction (raw message parameters). - */ -export async function createTransferNftRawTransaction(args: CreateTransferNftRawTransactionArgs) { - return callBridge('createTransferNftRawTransaction', async () => { - return await callOnWalletBridge(args.walletId, 'createTransferNftRawTransaction', { - nftAddress: args.nftAddress, - transferAmount: args.transferAmount, - transferMessage: args.transferMessage, - }); - }); -} +export const getNfts = (args: { walletId: string }) => walletCall('getNfts', args); +export const getNft = (args: { walletId: string }) => walletCall('getNft', args); +export const createTransferNftTransaction = (args: { walletId: string }) => + walletCall('createTransferNftTransaction', args); +export const createTransferNftRawTransaction = (args: { walletId: string }) => + walletCall('createTransferNftRawTransaction', args); diff --git a/packages/walletkit-android-bridge/src/api/requests.ts b/packages/walletkit-android-bridge/src/api/requests.ts index 3581b777e..c5512ae2c 100644 --- a/packages/walletkit-android-bridge/src/api/requests.ts +++ b/packages/walletkit-android-bridge/src/api/requests.ts @@ -6,154 +6,28 @@ * */ -/** - * requests.ts – Request approval handlers - * - * Bridge for connect, transaction, and sign-data request approvals/rejections. - * - * The Android SDK sends the full event object along with the response when approving requests. - * The event is stored in args.event and passed directly to TonWalletKit. - */ - -import type { - ApproveConnectRequestArgs, - RejectConnectRequestArgs, - ApproveTransactionRequestArgs, - RejectTransactionRequestArgs, - ApproveSignDataRequestArgs, - RejectSignDataRequestArgs, -} from '../types'; -import { callBridge } from '../utils/bridgeWrapper'; -import { log } from '../utils/logger'; - -/** - * Approves a connect request. - */ -export async function approveConnectRequest(args: ApproveConnectRequestArgs) { - return callBridge('approveConnectRequest', async (kit) => { - log('approveConnectRequest walletId:', args.walletId); - - const event = args.event as { walletId?: string; id?: string }; - if (!event) { - throw new Error('Event is required for connect request approval'); - } - - // Set walletId on the event (wallet lookup not needed - wallet is managed by Kotlin) - event.walletId = args.walletId; +import { kit } from '../utils/bridge'; - // Pass event and response as separate parameters (new API) - const result = await kit.approveConnectRequest(event, args.response); - - return result; - }); +export async function approveConnectRequest(args: unknown[]) { + return kit('approveConnectRequest', ...args); } -/** - * Rejects a connect request. - */ -export async function rejectConnectRequest(args: RejectConnectRequestArgs) { - return callBridge('rejectConnectRequest', async (kit) => { - const event = args.event as { id?: string }; - if (!event) { - throw new Error('Event is required for connect request rejection'); - } - - const result = await kit.rejectConnectRequest(event, args.reason, args.errorCode); - - return result ?? { success: true }; - }); +export async function rejectConnectRequest(args: unknown[]) { + return kit('rejectConnectRequest', ...args); } -/** - * Approves a transaction request. - */ -export async function approveTransactionRequest(args: ApproveTransactionRequestArgs) { - return callBridge('approveTransactionRequest', async (kit) => { - const event = args.event as { walletId?: string; id?: string }; - if (!event) { - throw new Error('Event is required for transaction request approval'); - } - - // Set walletId on the event - if (args.walletId) { - event.walletId = args.walletId; - } - - // Pass event and response as separate parameters (new API) - const result = await kit.approveTransactionRequest(event, args.response); - - return result; - }); +export async function approveTransactionRequest(args: unknown[]) { + return kit('approveTransactionRequest', ...args); } -/** - * Rejects a transaction request. - */ -export async function rejectTransactionRequest(args: RejectTransactionRequestArgs) { - return callBridge('rejectTransactionRequest', async (kit) => { - const event = args.event as { id?: string }; - if (!event) { - throw new Error('Event is required for transaction request rejection'); - } - - // 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 || 'Transaction rejected' } - : args.reason; - - const result = await kit.rejectTransactionRequest(event, reason); - - return result ?? { success: true }; - }); +export async function rejectTransactionRequest(args: unknown[]) { + return kit('rejectTransactionRequest', ...args); } -/** - * Approves a sign-data request. - */ -export async function approveSignDataRequest(args: ApproveSignDataRequestArgs) { - return callBridge('approveSignDataRequest', async (kit) => { - log('approveSignDataRequest args:', args); - - const event = args.event as { walletId?: string; id?: string }; - if (!event) { - throw new Error('Event is required for sign-data request approval'); - } - - log('approveSignDataRequest event:', event); - - // Set walletId on the event - if (args.walletId) { - event.walletId = args.walletId; - } - - // Pass event and response as separate parameters (new API) - log('approveSignDataRequest calling kit.approveSignDataRequest with event:', event, 'response:', args.response); - const result = await kit.approveSignDataRequest(event, args.response); - log('approveSignDataRequest result:', result); - - return result; - }); +export async function approveSignDataRequest(args: unknown[]) { + return kit('approveSignDataRequest', ...args); } -/** - * Rejects a sign-data request. - */ -export async function rejectSignDataRequest(args: RejectSignDataRequestArgs) { - return callBridge('rejectSignDataRequest', async (kit) => { - const event = args.event as { id?: string }; - if (!event) { - throw new Error('Event is required for sign-data request rejection'); - } - - // 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 || 'Sign data rejected' } - : args.reason; - - const result = await kit.rejectSignDataRequest(event, reason); - - return result ?? { success: true }; - }); +export async function rejectSignDataRequest(args: unknown[]) { + return kit('rejectSignDataRequest', ...args); } diff --git a/packages/walletkit-android-bridge/src/api/tonconnect.ts b/packages/walletkit-android-bridge/src/api/tonconnect.ts index d0898d8ef..6462533c8 100644 --- a/packages/walletkit-android-bridge/src/api/tonconnect.ts +++ b/packages/walletkit-android-bridge/src/api/tonconnect.ts @@ -6,98 +6,61 @@ * */ -/** - * tonconnect.ts – TonConnect operations - * - * Simplified bridge for TonConnect URL handling, session management, and internal browser requests. - * Session transformation handled by Kotlin SessionResponseParser. - */ - -import type { HandleTonConnectUrlArgs, DisconnectSessionArgs, ProcessInternalBrowserRequestArgs } from '../types'; -import { callBridge } from '../utils/bridgeWrapper'; +import { kit } from '../utils/bridge'; import { ensureInternalBrowserResolverMap } from '../utils/internalBrowserResolvers'; -/** - * Handles TonConnect URLs from deep links or QR codes. - */ -export async function handleTonConnectUrl(args: HandleTonConnectUrlArgs) { - return callBridge('handleTonConnectUrl', async (kit) => { - return await kit.handleTonConnectUrl(args.url); - }); +export async function handleTonConnectUrl(args: string) { + return kit('handleTonConnectUrl', args); } -/** - * Retrieves active TonConnect sessions. - * Session transformation handled by Kotlin SessionResponseParser. - */ export async function listSessions() { - return callBridge('listSessions', async (kit) => { - const fetchedSessions = kit.listSessions ? await kit.listSessions() : []; - const sessions = Array.isArray(fetchedSessions) ? fetchedSessions : []; - return { items: sessions }; - }); + return kit('listSessions'); } -/** - * Disconnects a TonConnect session. - */ -export async function disconnectSession(args?: DisconnectSessionArgs) { - return callBridge('disconnectSession', async (kit) => { - if (kit.disconnect) { - await kit.disconnect(args?.sessionId); - } - return { ok: true }; - }); +export async function disconnectSession(args?: string) { + return kit('disconnect', args); } /** - * Processes requests from the in-app browser TonConnect bridge. - * Domain resolution and request preparation handled by Kotlin InternalBrowserRequestProcessor. + * Processes internal browser TonConnect requests. + * args: [messageInfo, request] where messageInfo has { messageId, tabId, domain } + * + * This function calls processInjectedBridgeRequest and then waits for the response + * to come back via jsBridgeTransport (which resolves the promise via the resolver map). */ -export async function processInternalBrowserRequest(args: ProcessInternalBrowserRequestArgs) { - return callBridge('processInternalBrowserRequest', async (kit) => { - // Extract origin (with scheme) from URL - SessionManager.getSessionByDomain expects a parseable URL - const domain = args.url ? new URL(args.url).origin : 'internal-browser'; - - const messageInfo = { - messageId: args.messageId, - tabId: args.messageId, - domain, - }; +export async function processInternalBrowserRequest(args: unknown[]) { + // Extract messageId from messageInfo (first element of args array) + const messageInfo = args[0] as { messageId?: string } | undefined; + const messageId = messageInfo?.messageId; - const request: Record = { - id: args.messageId, - method: args.method, - params: args.params, - }; + if (!messageId) { + throw new Error('processInternalBrowserRequest: messageId is required in messageInfo'); + } - if (kit.processInjectedBridgeRequest) { - await kit.processInjectedBridgeRequest(messageInfo, request); - } else { - throw new Error('processInjectedBridgeRequest not available'); - } + // Call processInjectedBridgeRequest - this queues the event but doesn't return the response + await kit('processInjectedBridgeRequest', ...args); - // Wait for response from jsBridgeTransport (via initialization.ts) - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(`Request timeout: ${args.messageId}`)); - }, 60000); // 60 second timeout + // Wait for response from jsBridgeTransport (via initialization.ts) + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(`Request timeout: ${messageId}`)); + }, 60000); // 60 second timeout - const resolverMap = ensureInternalBrowserResolverMap(); - resolverMap.set(args.messageId, { - resolve: (response: unknown) => { - clearTimeout(timeoutId); - if (response && typeof response === 'object' && 'payload' in response) { - resolve((response as { payload?: unknown }).payload ?? response); - } else { - resolve(response); - } - }, - reject: (error: unknown) => { - clearTimeout(timeoutId); - reject(error); - }, - }); + const resolverMap = ensureInternalBrowserResolverMap(); + resolverMap.set(messageId, { + resolve: (response: unknown) => { + clearTimeout(timeoutId); + // Extract payload if present + if (response && typeof response === 'object' && 'payload' in response) { + resolve((response as { payload?: unknown }).payload ?? response); + } else { + resolve(response); + } + }, + reject: (error: unknown) => { + clearTimeout(timeoutId); + reject(error instanceof Error ? error : new Error(String(error))); + }, }); }); } diff --git a/packages/walletkit-android-bridge/src/api/transactions.ts b/packages/walletkit-android-bridge/src/api/transactions.ts index f35dab00a..13286bd08 100644 --- a/packages/walletkit-android-bridge/src/api/transactions.ts +++ b/packages/walletkit-android-bridge/src/api/transactions.ts @@ -9,144 +9,23 @@ /** * transactions.ts – TON transaction operations * - * Simplified bridge that passes requests directly to WalletKit. - * All validation, transformation, and formatting happens in Kotlin. + * Minimal bridge - just forwards calls to WalletKit. */ -import type { - GetRecentTransactionsArgs, - CreateTransferTonTransactionArgs, - CreateTransferMultiTonTransactionArgs, - TransactionContentArgs, -} from '../types'; -import { callBridge } from '../utils/bridgeWrapper'; -import { warn } from '../utils/logger'; +import type { TransactionRequest } from '@ton/walletkit'; -/** - * Retrieves recent transactions for a wallet. - * Returns raw WalletKit response - transformation happens in Kotlin TransactionResponseParser. - */ -export async function getRecentTransactions(args: GetRecentTransactionsArgs): Promise { - return callBridge('getRecentTransactions', async (kit) => { - const wallet = kit.getWallet?.(args.walletId); - - // Extract address from walletId (format: "{chainId}:{address}") - const address = wallet?.getAddress?.() ?? args.walletId.split(':')[1]; - - const response = await wallet!.getClient().getAccountTransactions({ - address: [address], - limit: args.limit || 10, - }); - - return response?.transactions || []; - }); -} - -/** - * Creates a single-recipient TON transfer transaction. - * Returns raw transaction and optional preview - Kotlin handles structure. - */ -export async function createTransferTonTransaction(args: CreateTransferTonTransactionArgs) { - return callBridge('createTransferTonTransaction', async (kit) => { - const wallet = kit.getWallet?.(args.walletId); - if (!wallet) { - throw new Error(`Wallet not found: ${args.walletId}`); - } - - const transaction = await wallet.createTransferTonTransaction(args); - - if (wallet.getTransactionPreview) { - try { - const preview = await wallet.getTransactionPreview(transaction); - return { transaction, preview }; - } catch (err) { - warn('[walletkitBridge] getTransactionPreview failed', err); - } - } - - return { transaction }; - }); -} - -/** - * Creates a multi-recipient TON transfer transaction. - * Returns raw transaction and optional preview - Kotlin handles structure. - */ -export async function createTransferMultiTonTransaction(args: CreateTransferMultiTonTransactionArgs) { - return callBridge('createTransferMultiTonTransaction', async (kit) => { - const wallet = kit.getWallet?.(args.walletId); - if (!wallet) { - throw new Error(`Wallet not found: ${args.walletId}`); - } - - const transaction = await wallet.createTransferMultiTonTransaction(args); - - if (wallet.getTransactionPreview) { - try { - const preview = await wallet.getTransactionPreview(transaction); - return { transaction, preview }; - } catch (err) { - warn('[walletkitBridge] getTransactionPreview failed', err); - } - } +import { walletCall, clientCall, getKit, getWallet } from '../utils/bridge'; - return { transaction }; - }); -} - -/** - * Gets transaction preview (fee estimation). - */ -export async function getTransactionPreview(args: TransactionContentArgs) { - return callBridge('getTransactionPreview', async (kit) => { - const wallet = kit.getWallet?.(args.walletId); - if (!wallet) { - throw new Error(`Wallet not found: ${args.walletId}`); - } - - // Accept object directly (preferred) or parse string (legacy) - const transaction = - typeof args.transactionContent === 'string' ? JSON.parse(args.transactionContent) : args.transactionContent; - - if (!wallet.getTransactionPreview) { - throw new Error('getTransactionPreview not available on wallet'); - } - return await wallet.getTransactionPreview(transaction); - }); -} - -/** - * Handles new transaction (triggers confirmation flow). - */ -export async function handleNewTransaction(args: TransactionContentArgs) { - return callBridge('handleNewTransaction', async (kit) => { - const wallet = kit.getWallet?.(args.walletId); - if (!wallet) { - throw new Error(`Wallet not found: ${args.walletId}`); - } - - const transaction = - typeof args.transactionContent === 'string' ? JSON.parse(args.transactionContent) : args.transactionContent; - - await kit.handleNewTransaction(wallet, transaction); - - return { success: true }; - }); -} - -/** - * Sends a transaction to the network. - * Returns raw result object with signedBoc. - */ -export async function sendTransaction(args: TransactionContentArgs) { - return callBridge('sendTransaction', async (kit) => { - const wallet = kit.getWallet?.(args.walletId); - if (!wallet) { - throw new Error(`Wallet not found: ${args.walletId}`); - } +export const createTransferTonTransaction = (args: { walletId: string }) => + walletCall('createTransferTonTransaction', args); +export const createTransferMultiTonTransaction = (args: { walletId: string }) => + walletCall('createTransferMultiTonTransaction', args); +export const getTransactionPreview = (args: { walletId: string }) => walletCall('getTransactionPreview', args); +export const sendTransaction = (args: { walletId: string }) => walletCall('sendTransaction', args); +export const getRecentTransactions = (args: { walletId: string }) => clientCall('getAccountTransactions', args); - const transaction = - typeof args.transactionContent === 'string' ? JSON.parse(args.transactionContent) : args.transactionContent; - return await wallet.sendTransaction(transaction); - }); +export async function handleNewTransaction(args: [string, unknown]) { + const k = await getKit(); + const w = await getWallet(args[0]); + return k.handleNewTransaction(w, args[1] as TransactionRequest); } diff --git a/packages/walletkit-android-bridge/src/api/wallets.ts b/packages/walletkit-android-bridge/src/api/wallets.ts index b4b354b2e..e9f32c221 100644 --- a/packages/walletkit-android-bridge/src/api/wallets.ts +++ b/packages/walletkit-android-bridge/src/api/wallets.ts @@ -13,93 +13,67 @@ * Kotlin is responsible for adapting to whatever JS returns. */ -import type { Hex } from '@ton/walletkit'; - -import type { - RemoveWalletArgs, - GetBalanceArgs, - CreateSignerArgs, - CreateAdapterArgs, - AddWalletArgs, - WalletKitSigner, - WalletKitAdapter, -} from '../types'; +import type { Hex, Network, WalletAdapter } from '@ton/walletkit'; + import { Signer, WalletV4R2Adapter, WalletV5R1Adapter } from '../core/moduleLoader'; -import { callBridge } from '../utils/bridgeWrapper'; +import { kit, wallet, getKit } from '../utils/bridge'; import { signWithCustomSigner } from './cryptography'; -type SignerInstance = WalletKitSigner; -type AdapterInstance = WalletKitAdapter; +type SignerInstance = { sign: (bytes: Iterable) => Promise; publicKey: Hex }; /** * Lists all wallets. - * Returns walletId with each wallet since network can't be inferred from wallet properties. */ export async function getWallets() { - return callBridge('getWallets', async (kit) => { - const wallets = kit.getWallets?.() ?? []; - // Include walletId since getNetwork()/getAddress() are methods that don't serialize - return wallets.map((w) => ({ - walletId: w.getWalletId?.(), - wallet: w, - })); - }); + const wallets = (await kit('getWallets')) as { getWalletId?: () => string }[]; + return wallets.map((w) => ({ walletId: w.getWalletId?.(), wallet: w })); } /** * Get a single wallet by walletId. - * Returns walletId with wallet since network can't be inferred from wallet properties. */ -export async function getWallet(args: { walletId: string }) { - return callBridge('getWallet', async (kit) => { - const w = kit.getWallet?.(args.walletId); - if (!w) return null; - return { walletId: w.getWalletId?.(), wallet: w }; - }); +export async function getWalletById(args: { walletId: string }) { + const w = await kit('getWallet', args.walletId); + if (!w) return null; + return { walletId: (w as { getWalletId?: () => string }).getWalletId?.(), wallet: w }; } -/** - * Gets the address of a wallet. - * Returns raw result - Kotlin adapts to the response. - */ export async function getWalletAddress(args: { walletId: string }) { - return callBridge('getWalletAddress', async (kit) => { - const wallet = kit.getWallet?.(args.walletId); - return wallet?.getAddress?.() ?? null; - }); + return wallet(args.walletId, 'getAddress'); } -/** - * Removes a wallet from storage. - * Returns raw result - Kotlin adapts to the response. - */ -export async function removeWallet(args: RemoveWalletArgs) { - return callBridge('removeWallet', async (kit) => { - return await kit.removeWallet?.(args.walletId); - }); +export async function removeWallet(args: { walletId: string }) { + return kit('removeWallet', args.walletId); } -/** - * Fetches wallet balance. - * Returns raw balance - Kotlin adapts to the response. - */ -export async function getBalance(args: GetBalanceArgs) { - return callBridge('getBalance', async (kit) => { - const wallet = kit.getWallet?.(args.walletId); - return await wallet?.getBalance?.(); - }); +export async function getBalance(args: { walletId: string }) { + return wallet(args.walletId, 'getBalance'); } -// Store for signers and adapters const signerStore = new Map(); -const adapterStore = new Map(); +const adapterStore = new Map(); + +type CreateAdapterArgs = { + signerId: string; + isCustom?: boolean; + publicKey?: string; + walletVersion?: string; + network: string; + workchain: number; + walletId?: string; +}; + +type CreateSignerArgs = { + mnemonic?: string[]; + secretKey?: string; + mnemonicType?: string; +}; + +type AddWalletArgs = { + adapterId: string; +}; -/** - * Retrieves or creates a signer instance based on the arguments. - * Handles both custom signers (hardware wallets) and regular signers. - */ async function getSigner(args: CreateAdapterArgs): Promise { - // Handle custom signers (hardware wallets) that live in Kotlin if (args.isCustom && args.publicKey) { return { sign: async (bytes: Iterable): Promise => { @@ -109,7 +83,6 @@ async function getSigner(args: CreateAdapterArgs): Promise { }; } - // Handle regular signers stored in JavaScript const storedSigner = signerStore.get(args.signerId); if (!storedSigner) { throw new Error(`Signer not found: ${args.signerId}`); @@ -117,81 +90,63 @@ async function getSigner(args: CreateAdapterArgs): Promise { return storedSigner; } -/** - * Creates a signer from mnemonic or secret key. - * Returns raw signer object - Kotlin generates signerId and extracts publicKey. - */ export async function createSigner(args: CreateSignerArgs) { - return callBridge('createSigner', async (_kit) => { - const signer = - args.mnemonic && args.mnemonic.length > 0 - ? ((await Signer!.fromMnemonic(args.mnemonic, { type: args.mnemonicType || 'ton' })) as SignerInstance) - : ((await Signer!.fromPrivateKey(args.secretKey!)) as SignerInstance); + if (!Signer) { + throw new Error('Signer module not loaded'); + } + if (!args.mnemonic?.length && !args.secretKey) { + throw new Error('Either mnemonic or secretKey is required'); + } + const signer = + args.mnemonic && args.mnemonic.length > 0 + ? ((await Signer.fromMnemonic(args.mnemonic, { type: args.mnemonicType || 'ton' })) as SignerInstance) + : ((await Signer.fromPrivateKey(args.secretKey as string)) as SignerInstance); - // Store signer with temp ID for Kotlin to retrieve - const tempId = `signer_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - signerStore.set(tempId, signer); + const tempId = `signer_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + signerStore.set(tempId, signer); - return { _tempId: tempId, signer }; - }); + return { _tempId: tempId, signer }; } -/** - * Creates a wallet adapter from a signer. - * Supports both regular signers (from mnemonic/secretKey) and custom signers (hardware wallets). - * Returns adapter ID - Kotlin is responsible for all mapping and transformation. - */ export async function createAdapter(args: CreateAdapterArgs) { - return callBridge('createAdapter', async (kit) => { - const signer = await getSigner(args); - const AdapterClass = args.walletVersion === 'v5r1' ? WalletV5R1Adapter : WalletV4R2Adapter; - const adapter = (await AdapterClass!.create(signer, { - client: kit.getApiClient(args.network), - network: args.network, - workchain: args.workchain, - walletId: args.walletId, - })) as AdapterInstance; - - // Store adapter with temp ID for Kotlin to retrieve - const tempId = `adapter_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - adapterStore.set(tempId, adapter); - - // Return only the temp ID and the raw adapter object - // Kotlin is responsible for extracting any needed properties - return { _tempId: tempId, adapter }; + const instance = await getKit(); + const signer = await getSigner(args); + const AdapterClass = args.walletVersion === 'v5r1' ? WalletV5R1Adapter : WalletV4R2Adapter; + if (!AdapterClass) { + throw new Error(`WalletAdapter module not loaded`); + } + const network = args.network as unknown as Network; + const adapter = await AdapterClass.create(signer, { + client: instance.getApiClient(network), + network, + workchain: args.workchain, + walletId: args.walletId, }); + + const tempId = `adapter_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + adapterStore.set(tempId, adapter); + + return { _tempId: tempId, adapter }; } -/** - * Gets the address from a stored adapter. - * Returns raw address - Kotlin adapts to the response. - */ + export async function getAdapterAddress(args: { adapterId: string }) { - return callBridge('getAdapterAddress', async (_kit) => { - const adapter = adapterStore.get(args.adapterId); - if (!adapter) { - throw new Error(`Adapter not found: ${args.adapterId}`); - } - return adapter.getAddress(); - }); + const adapter = adapterStore.get(args.adapterId) as WalletAdapter | undefined; + if (!adapter) { + throw new Error(`Adapter not found: ${args.adapterId}`); + } + return adapter.getAddress(); } -/** - * Adds a wallet to WalletKit using an adapter. - * Returns walletId with wallet since getWalletId() is a method that doesn't serialize. - */ export async function addWallet(args: AddWalletArgs) { - return callBridge('addWallet', async (kit) => { - const adapter = adapterStore.get(args.adapterId); - if (!adapter) { - throw new Error(`Adapter not found: ${args.adapterId}`); - } - - const wallet = await kit.addWallet(adapter); + const instance = await getKit(); + const adapter = adapterStore.get(args.adapterId); + if (!adapter) { + throw new Error(`Adapter not found: ${args.adapterId}`); + } - // Clean up the adapter from store after use - adapterStore.delete(args.adapterId); + const w = await instance.addWallet(adapter as Parameters[0]); + adapterStore.delete(args.adapterId); - if (!wallet) return null; - return { walletId: wallet.getWalletId?.(), wallet }; - }); + if (!w) return null; + return { walletId: w.getWalletId?.(), wallet: w }; } diff --git a/packages/walletkit-android-bridge/src/core/initialization.ts b/packages/walletkit-android-bridge/src/core/initialization.ts index 778d768fd..013f355ed 100644 --- a/packages/walletkit-android-bridge/src/core/initialization.ts +++ b/packages/walletkit-android-bridge/src/core/initialization.ts @@ -9,10 +9,17 @@ /** * WalletKit initialization helpers used by the bridge entry point. */ +import type { BridgeResponse, BridgeEvent } from '@ton/walletkit'; import { TONCONNECT_BRIDGE_EVENT } from '@ton/walletkit'; import { TONCONNECT_BRIDGE_RESPONSE } from '@ton/walletkit/bridge'; -import type { WalletKitBridgeInitConfig, BridgePayload, WalletKitBridgeEvent, WalletKitInstance } from '../types'; +import type { + WalletKitBridgeInitConfig, + BridgePayload, + WalletKitBridgeEvent, + WalletKitInstance, + JsBridgeTransportMessage, +} from '../types'; import { log, warn } from '../utils/logger'; import { walletKit, setWalletKit } from './state'; import { ensureWalletKitLoaded, TonWalletKit } from './moduleLoader'; @@ -29,18 +36,6 @@ export interface InitTonWalletKitDeps { AndroidStorageAdapter: new () => unknown; } -type JsBridgeMessage = { - type: string; - messageId?: string; - payload?: { - event?: string; - [key: string]: unknown; - }; - source?: unknown; - traceId?: string; - [key: string]: unknown; -}; - type NativeStorageBridge = { storageGet: (key: string) => string | null; storageSet: (key: string, value: string) => void; @@ -115,43 +110,54 @@ export async function initTonWalletKit( if (config?.bridgeUrl) { kitOptions.bridge = { bridgeUrl: config.bridgeUrl, - jsBridgeTransport: async (sessionId: string, message: JsBridgeMessage) => { + jsBridgeTransport: async (sessionId: string, message: unknown) => { + // Cast to our transport message type (walletkit types this as unknown) + const typedMessage = message as JsBridgeTransportMessage; + log('[walletkitBridge] 📤 jsBridgeTransport called:', { sessionId, - messageType: message.type, - messageId: message.messageId, - hasPayload: !!message.payload, - payloadEvent: message.payload?.event, + messageType: typedMessage.type, + hasPayload: 'payload' in typedMessage, }); - log('[walletkitBridge] 📤 Full message:', JSON.stringify(message, null, 2)); - - let bridgeMessage: JsBridgeMessage = message; - - if ( - bridgeMessage.type === TONCONNECT_BRIDGE_RESPONSE && - bridgeMessage.payload?.event === 'disconnect' && - !bridgeMessage.messageId - ) { - log('[walletkitBridge] 🔄 Transforming disconnect response to event'); - bridgeMessage = { - type: TONCONNECT_BRIDGE_EVENT, - source: bridgeMessage.source, - event: bridgeMessage.payload, - traceId: bridgeMessage.traceId, - }; - log('[walletkitBridge] 🔄 Transformed message:', JSON.stringify(bridgeMessage, null, 2)); + log('[walletkitBridge] 📤 Full message:', JSON.stringify(typedMessage, null, 2)); + + let bridgeMessage: JsBridgeTransportMessage = typedMessage; + + // Handle disconnect responses that need to be transformed to events + const DISCONNECT_EVENT = 'disconnect'; + if (bridgeMessage.type === TONCONNECT_BRIDGE_RESPONSE) { + const responseMsg = bridgeMessage as BridgeResponse; + // BridgeResponse has 'result' field, not 'payload' + const result = responseMsg.result as { event?: string; id?: number } | undefined; + + if (result?.event === DISCONNECT_EVENT && !responseMsg.messageId) { + log('[walletkitBridge] 🔄 Transforming disconnect response to event'); + bridgeMessage = { + type: TONCONNECT_BRIDGE_EVENT, + source: responseMsg.source, + event: { + event: 'disconnect', + id: result.id ?? 0, + payload: {}, + }, + } as BridgeEvent; + log('[walletkitBridge] 🔄 Transformed message:', JSON.stringify(bridgeMessage, null, 2)); + } } - if (bridgeMessage.messageId) { + // Handle responses with messageId (internal browser requests) + if (bridgeMessage.type === TONCONNECT_BRIDGE_RESPONSE && bridgeMessage.messageId) { log('[walletkitBridge] 🔵 Message has messageId, checking for pending promise'); const resolvers = getInternalBrowserResolverMap(); - const resolver = resolvers?.get(bridgeMessage.messageId); + // messageId is a number in BridgeResponse, convert to string for resolver map + const messageIdStr = String(bridgeMessage.messageId); + const resolver = resolvers?.get(messageIdStr); if (resolver) { - log('[walletkitBridge] ✅ Resolving response promise for messageId:', bridgeMessage.messageId); - resolvers?.delete(bridgeMessage.messageId); + log('[walletkitBridge] ✅ Resolving response promise for messageId:', messageIdStr); + resolvers?.delete(messageIdStr); resolver.resolve(bridgeMessage); } else { - warn('[walletkitBridge] ⚠️ No pending promise for messageId:', bridgeMessage.messageId); + warn('[walletkitBridge] ⚠️ No pending promise for messageId:', messageIdStr); } } @@ -202,16 +208,3 @@ export async function initTonWalletKit( log('[walletkitBridge] WalletKit ready'); return { ok: true }; } - -/** - * Ensures WalletKit has been initialized before performing an operation. - * Returns the initialized WalletKit instance for type-safe usage. - * - * @throws If WalletKit is not yet ready. - */ -export function requireWalletKit(): NonNullable { - if (!walletKit) { - throw new Error('WalletKit not initialized'); - } - return walletKit; -} diff --git a/packages/walletkit-android-bridge/src/core/moduleLoader.ts b/packages/walletkit-android-bridge/src/core/moduleLoader.ts index ce84504b4..fea8b8e42 100644 --- a/packages/walletkit-android-bridge/src/core/moduleLoader.ts +++ b/packages/walletkit-android-bridge/src/core/moduleLoader.ts @@ -26,7 +26,7 @@ type AdapterFactory = { type WalletKitModule = { TonWalletKit: TonWalletKitConstructor; - CreateTonMnemonic?: () => Promise; + CreateTonMnemonic?: () => Promise; MnemonicToKeyPair?: ( mnemonic: string[], type: string, @@ -41,7 +41,7 @@ type WalletKitModule = { }; export let TonWalletKit: TonWalletKitConstructor | null = null; -export let CreateTonMnemonic: (() => Promise) | null = null; +export let CreateTonMnemonic: (() => Promise) | null = null; export let MnemonicToKeyPair: | ((mnemonic: string[], type: string) => Promise<{ publicKey: Uint8Array; secretKey: Uint8Array }>) | null = null; @@ -52,6 +52,7 @@ export let WalletV5R1Adapter: AdapterFactory | null = null; /** * Ensures WalletKit and TON core modules are loaded once and cached. + * @throws Error if required modules fail to load */ export async function ensureWalletKitLoaded(): Promise { if (TonWalletKit && Signer && MnemonicToKeyPair && DefaultSignature && WalletV4R2Adapter && WalletV5R1Adapter) { @@ -67,6 +68,9 @@ export async function ensureWalletKitLoaded(): Promise { !WalletV5R1Adapter ) { const module = (await walletKitModulePromise) as unknown as WalletKitModule; + if (!module.TonWalletKit) { + throw new Error('Failed to load TonWalletKit module'); + } TonWalletKit = module.TonWalletKit; CreateTonMnemonic = module.CreateTonMnemonic ?? CreateTonMnemonic; MnemonicToKeyPair = module.MnemonicToKeyPair ?? MnemonicToKeyPair; diff --git a/packages/walletkit-android-bridge/src/inject.ts b/packages/walletkit-android-bridge/src/inject.ts index 19f61d1d4..867369b92 100644 --- a/packages/walletkit-android-bridge/src/inject.ts +++ b/packages/walletkit-android-bridge/src/inject.ts @@ -8,7 +8,7 @@ // Bridge injection for Android internal browser import { injectBridgeCode, TONCONNECT_BRIDGE_EVENT, TONCONNECT_BRIDGE_REQUEST } from '@ton/walletkit/bridge'; -import type { InjectedToExtensionBridgeRequestPayload, JSBridgeInjectOptions } from '@ton/walletkit'; +import type { BridgeEvent, InjectedToExtensionBridgeRequestPayload, JSBridgeInjectOptions } from '@ton/walletkit'; import type { Transport } from '@ton/walletkit'; import { error } from './utils/logger'; @@ -44,7 +44,7 @@ const tonWindow = window as TonConnectWindow; const frameId = tonWindow.__tonconnect_frameId || (tonWindow.__tonconnect_frameId = - window === window.top ? 'main' : `frame-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`); + window === window.top ? 'main' : `frame-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`); const isAndroidWebView = typeof tonWindow.AndroidTonConnect !== 'undefined'; @@ -55,9 +55,13 @@ const isAndroidWebView = typeof tonWindow.AndroidTonConnect !== 'undefined'; class AndroidWebViewTransport implements Transport { private pendingRequests = new Map< string, - { resolve: (value: unknown) => void; reject: (error: Error) => void; timeout: ReturnType } + { + resolve: (value: BridgeEvent) => void; + reject: (error: Error) => void; + timeout: ReturnType; + } >(); - private eventCallbacks: Array<(event: unknown) => void> = []; + private eventCallbacks: Array<(event: BridgeEvent) => void> = []; constructor() { // Set up notification handlers and postMessage relay @@ -163,11 +167,12 @@ class AndroidWebViewTransport implements Transport { while (bridge.hasEvent(frameId)) { const eventStr = bridge.pullEvent(frameId); if (eventStr) { - const data = JSON.parse(eventStr) as { type?: string; event?: unknown }; + const data = JSON.parse(eventStr) as { type?: string; event?: BridgeEvent }; if (data.type === TONCONNECT_BRIDGE_EVENT && data.event) { + const event = data.event; this.eventCallbacks.forEach((callback) => { try { - callback(data.event); + callback(event); } catch (err) { error('[AndroidTransport] Event callback error:', err); } @@ -180,7 +185,7 @@ class AndroidWebViewTransport implements Transport { } } - async send(request: Omit): Promise { + async send(request: Omit): Promise { const messageId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const bridge = tonWindow.AndroidTonConnect; if (!bridge?.postMessage) { @@ -207,7 +212,7 @@ class AndroidWebViewTransport implements Transport { }); } - onEvent(callback: (event: unknown) => void): void { + onEvent(callback: (event: BridgeEvent) => void): void { this.eventCallbacks.push(callback); } diff --git a/packages/walletkit-android-bridge/src/transport/index.ts b/packages/walletkit-android-bridge/src/transport/index.ts deleted file mode 100644 index e51e3d334..000000000 --- a/packages/walletkit-android-bridge/src/transport/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * 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. - * - */ - -export * from './nativeBridge'; -export * from './diagnostics'; -export * from './messaging'; diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index 8b00b54db..de4819de1 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -6,6 +6,40 @@ * */ +import type { + BridgeEvent, + ConnectionApprovalProof, + ConnectionRequestEventPreview, + ConnectEvent, + ConnectEventError, + DAppInfo, + DisconnectEvent, + IntentEvent, + IntentResponse, + IntentResponseError, + IntentSignDataResponseSuccess, + IntentTransactionResponseSuccess, + JettonsResponse, + NFT, + NFTsResponse, + SendTransactionResponse, + SignDataIntentEvent, + TONConnectSession, + Transaction, + TransactionEmulatedPreview, + TransactionIntentEvent, + TransactionRequest, + ActionIntentEvent, + Wallet, + WalletAdapter, + WalletResponse, + WalletSigner, +} from '@ton/walletkit'; + +/** + * TonConnect event payload types that can be returned from processInternalBrowserRequest. + */ +export type TonConnectEventPayload = ConnectEvent | ConnectEventError | WalletResponse | DisconnectEvent; import type { WalletKitBridgeEventCallback } from './events'; import type { WalletKitBridgeInitConfig } from './walletkit'; @@ -64,16 +98,16 @@ export interface GetRecentTransactionsArgs { export interface CreateTransferTonTransactionArgs { walletId: string; - toAddress: string; - amount: string; + recipientAddress: string; + transferAmount: string; comment?: string; body?: string; stateInit?: string; } export interface MultiTransferMessage { - toAddress: string; - amount: string; + recipientAddress: string; + transferAmount: string; comment?: string; body?: string; stateInit?: string; @@ -86,16 +120,14 @@ export interface CreateTransferMultiTonTransactionArgs { export interface TransactionContentArgs { walletId: string; - transactionContent: unknown; // Can be object (from Kotlin) or string (legacy) + transactionContent: TransactionRequest | string; // Can be object (from Kotlin) or string (legacy) } -export interface TonConnectRequestEvent extends Record { - id?: string; - wallet?: unknown; - walletAddress?: string; - request?: Record & { from?: string }; - preview?: Record & { manifest?: { url?: string } }; - dAppInfo?: Record & { url?: string }; +export interface TonConnectRequestEvent extends BridgeEvent { + wallet?: Wallet; + request?: BridgeEvent & { from?: string }; + preview?: ConnectionRequestEventPreview; + dAppInfo?: DAppInfo; domain?: string; isJsBridge?: boolean; tabId?: string; @@ -104,7 +136,6 @@ export interface TonConnectRequestEvent extends Record { export interface ApproveConnectRequestArgs { event: TonConnectRequestEvent; - walletId: string; response?: { proof: { signature: string; @@ -126,7 +157,6 @@ export interface RejectConnectRequestArgs { export interface ApproveTransactionRequestArgs { event: TonConnectRequestEvent; - walletId?: string; response?: { signedBoc: string; }; @@ -134,13 +164,11 @@ export interface ApproveTransactionRequestArgs { export interface RejectTransactionRequestArgs { event: TonConnectRequestEvent; - reason?: string; - errorCode?: number; + reason?: string | { code: number; message: string }; } export interface ApproveSignDataRequestArgs { event: TonConnectRequestEvent; - walletId?: string; response?: { signature: string; timestamp: number; @@ -151,7 +179,6 @@ export interface ApproveSignDataRequestArgs { export interface RejectSignDataRequestArgs { event: TonConnectRequestEvent; reason?: string; - errorCode?: number; } export interface DisconnectSessionArgs { @@ -173,8 +200,8 @@ export interface GetNftArgs { export interface CreateTransferNftTransactionArgs { walletId: string; nftAddress: string; - transferAmount: string; - toAddress: string; + transferAmount?: string; + recipientAddress: string; comment?: string; } @@ -182,7 +209,7 @@ export interface CreateTransferNftRawTransactionArgs { walletId: string; nftAddress: string; transferAmount: string; - transferMessage: unknown; + message: TransactionRequest; } export interface GetJettonsArgs { @@ -193,8 +220,8 @@ export interface GetJettonsArgs { export interface CreateTransferJettonTransactionArgs { walletId: string; jettonAddress: string; - amount: string; - toAddress: string; + transferAmount: string; + recipientAddress: string; comment?: string; } @@ -211,7 +238,7 @@ export interface GetJettonWalletAddressArgs { export interface ProcessInternalBrowserRequestArgs { messageId: string; method: string; - params?: unknown; + params?: Record; from?: string; url?: string; manifestUrl?: string; @@ -235,6 +262,68 @@ export interface HandleTonConnectUrlArgs { url: string; } +export interface HandleIntentUrlArgs { + url: string; +} + +export interface IsIntentUrlArgs { + url: string; +} + +export interface IntentItemsToTransactionRequestArgs { + /** The transaction intent event - Android sends this in walletkit format */ + event: TransactionIntentEvent; + /** The wallet ID to use for jetton/NFT address resolution */ + walletId: string; +} + +/** Arguments for approving a transaction intent (txIntent or signMsg) */ +export interface ApproveTransactionIntentArgs { + /** The full transaction intent event - Android sends this in walletkit format */ + event: TransactionIntentEvent; + /** The wallet ID to use for signing */ + walletId: string; +} + +/** Arguments for approving a sign data intent (signIntent) */ +export interface ApproveSignDataIntentArgs { + /** The full sign data intent event - Android sends this in walletkit format */ + event: SignDataIntentEvent; + /** The wallet ID to use for signing */ + walletId: string; +} + +/** Arguments for rejecting any intent */ +export interface RejectIntentArgs { + /** The intent event to reject */ + event: { + id: string; + clientId: string; + }; + /** Optional rejection reason */ + reason?: string; + /** Optional error code */ + errorCode?: number; +} + +/** Arguments for approving an action intent (actionIntent) */ +export interface ApproveActionIntentArgs { + /** The action intent event - Android sends this in walletkit format */ + event: ActionIntentEvent; + /** The wallet ID to use for signing */ + walletId: string; +} + +/** Arguments for processing connect request after intent approval */ +export interface ProcessConnectAfterIntentArgs { + /** The intent event with connect request - Android sends this in walletkit format */ + event: IntentEvent; + /** The wallet ID to use for the connection */ + walletId: string; + /** Optional proof */ + proof?: ConnectionApprovalProof; +} + export interface WalletDescriptor { address: string; publicKey: string; @@ -244,7 +333,7 @@ export interface WalletDescriptor { } export interface WalletKitBridgeApi { - init(config?: WalletKitBridgeInitConfig): PromiseOrValue; + init(config?: WalletKitBridgeInitConfig): PromiseOrValue<{ ok: true }>; setEventsListeners(args?: SetEventsListenersArgs): PromiseOrValue<{ ok: true }>; removeEventListeners(): PromiseOrValue<{ ok: true }>; // Returns raw keyPair with Uint8Array - Kotlin handles conversion @@ -253,55 +342,59 @@ export interface WalletKitBridgeApi { sign(args: SignArgs): PromiseOrValue; // Returns mnemonic words array directly createTonMnemonic(args?: CreateTonMnemonicArgs): PromiseOrValue; - // Returns temp ID and raw signer - Kotlin extracts signerId and publicKey - createSigner(args: CreateSignerArgs): PromiseOrValue<{ _tempId: string; signer: unknown }>; - // Returns temp ID and raw adapter - Kotlin extracts adapterId and address - createAdapter(args: CreateAdapterArgs): PromiseOrValue<{ _tempId: string; adapter: unknown }>; + // Returns temp ID and signer - Kotlin extracts signerId and publicKey + createSigner(args: CreateSignerArgs): PromiseOrValue<{ _tempId: string; signer: WalletSigner }>; + // Returns temp ID and adapter - Kotlin extracts adapterId and address + createAdapter(args: CreateAdapterArgs): PromiseOrValue<{ _tempId: string; adapter: WalletAdapter }>; // Returns address string directly getAdapterAddress(args: { adapterId: string }): PromiseOrValue; // Returns walletId with wallet object, or null - addWallet(args: AddWalletArgs): PromiseOrValue<{ walletId: string | undefined; wallet: unknown } | null>; + addWallet(args: AddWalletArgs): PromiseOrValue<{ walletId: string | undefined; wallet: Wallet } | null>; // Returns array of walletId with wallet objects - getWallets(): PromiseOrValue<{ walletId: string | undefined; wallet: unknown }[]>; + getWallets(): PromiseOrValue<{ walletId: string | undefined; wallet: Wallet }[]>; // Takes walletId, returns walletId with wallet object or null - getWallet(args: { walletId: string }): PromiseOrValue<{ walletId: string | undefined; wallet: unknown } | null>; + getWallet(args: { walletId: string }): PromiseOrValue<{ walletId: string | undefined; wallet: Wallet } | null>; // Returns address string or null directly getWalletAddress(args: { walletId: string }): PromiseOrValue; // Returns void removeWallet(args: RemoveWalletArgs): PromiseOrValue; - // Returns balance - type is unknown since wallet.getBalance() returns Promise - getBalance(args: GetBalanceArgs): PromiseOrValue; + // Returns balance as string or undefined + getBalance(args: GetBalanceArgs): PromiseOrValue; // Returns transactions array directly - getRecentTransactions(args: GetRecentTransactionsArgs): PromiseOrValue; - handleTonConnectUrl(args: HandleTonConnectUrlArgs): PromiseOrValue; - // Returns transaction and optional preview - createTransferTonTransaction( - args: CreateTransferTonTransactionArgs, - ): PromiseOrValue<{ transaction: unknown; preview?: unknown }>; - createTransferMultiTonTransaction( - args: CreateTransferMultiTonTransactionArgs, - ): PromiseOrValue<{ transaction: unknown; preview?: unknown }>; - getTransactionPreview(args: TransactionContentArgs): PromiseOrValue; + getRecentTransactions(args: GetRecentTransactionsArgs): PromiseOrValue; + handleTonConnectUrl(args: HandleTonConnectUrlArgs): PromiseOrValue; + // Intent URL handling + handleIntentUrl(args: HandleIntentUrlArgs): PromiseOrValue; + isIntentUrl(args: IsIntentUrlArgs): PromiseOrValue; + intentItemsToTransactionRequest(args: IntentItemsToTransactionRequestArgs): PromiseOrValue; + approveTransactionIntent(args: ApproveTransactionIntentArgs): PromiseOrValue; + approveSignDataIntent(args: ApproveSignDataIntentArgs): PromiseOrValue; + rejectIntent(args: RejectIntentArgs): PromiseOrValue; + approveActionIntent(args: ApproveActionIntentArgs): PromiseOrValue; + processConnectAfterIntent(args: ProcessConnectAfterIntentArgs): PromiseOrValue; + createTransferTonTransaction(args: CreateTransferTonTransactionArgs): PromiseOrValue; + createTransferMultiTonTransaction(args: CreateTransferMultiTonTransactionArgs): PromiseOrValue; + getTransactionPreview(args: TransactionContentArgs): PromiseOrValue; handleNewTransaction(args: TransactionContentArgs): PromiseOrValue<{ success: boolean }>; - // Returns raw result from wallet.sendTransaction - sendTransaction(args: TransactionContentArgs): PromiseOrValue; - approveConnectRequest(args: ApproveConnectRequestArgs): PromiseOrValue; - rejectConnectRequest(args: RejectConnectRequestArgs): PromiseOrValue; - approveTransactionRequest(args: ApproveTransactionRequestArgs): PromiseOrValue; - rejectTransactionRequest(args: RejectTransactionRequestArgs): PromiseOrValue; - approveSignDataRequest(args: ApproveSignDataRequestArgs): PromiseOrValue; - rejectSignDataRequest(args: RejectSignDataRequestArgs): PromiseOrValue; - listSessions(): PromiseOrValue; - disconnectSession(args?: DisconnectSessionArgs): PromiseOrValue; - getNfts(args: GetNftsArgs): PromiseOrValue; - getNft(args: GetNftArgs): PromiseOrValue; - createTransferNftTransaction(args: CreateTransferNftTransactionArgs): PromiseOrValue; - createTransferNftRawTransaction(args: CreateTransferNftRawTransactionArgs): PromiseOrValue; - getJettons(args: GetJettonsArgs): PromiseOrValue; - createTransferJettonTransaction(args: CreateTransferJettonTransactionArgs): PromiseOrValue; - getJettonBalance(args: GetJettonBalanceArgs): PromiseOrValue; - getJettonWalletAddress(args: GetJettonWalletAddressArgs): PromiseOrValue; - processInternalBrowserRequest(args: ProcessInternalBrowserRequestArgs): PromiseOrValue; + // Returns result from wallet.sendTransaction + sendTransaction(args: TransactionContentArgs): PromiseOrValue; + approveConnectRequest(args: ApproveConnectRequestArgs): PromiseOrValue; + rejectConnectRequest(args: RejectConnectRequestArgs): PromiseOrValue<{ success: boolean }>; + approveTransactionRequest(args: ApproveTransactionRequestArgs): PromiseOrValue<{ signedBoc: string }>; + rejectTransactionRequest(args: RejectTransactionRequestArgs): PromiseOrValue<{ success: boolean }>; + approveSignDataRequest(args: ApproveSignDataRequestArgs): PromiseOrValue<{ signature: string; timestamp: number }>; + rejectSignDataRequest(args: RejectSignDataRequestArgs): PromiseOrValue<{ success: boolean }>; + listSessions(): PromiseOrValue<{ items: TONConnectSession[] }>; + disconnectSession(args?: DisconnectSessionArgs): PromiseOrValue<{ ok: boolean }>; + getNfts(args: GetNftsArgs): PromiseOrValue; + getNft(args: GetNftArgs): PromiseOrValue; + createTransferNftTransaction(args: CreateTransferNftTransactionArgs): PromiseOrValue; + createTransferNftRawTransaction(args: CreateTransferNftRawTransactionArgs): PromiseOrValue; + getJettons(args: GetJettonsArgs): PromiseOrValue; + createTransferJettonTransaction(args: CreateTransferJettonTransactionArgs): PromiseOrValue; + getJettonBalance(args: GetJettonBalanceArgs): PromiseOrValue; + getJettonWalletAddress(args: GetJettonWalletAddressArgs): PromiseOrValue; + processInternalBrowserRequest(args: ProcessInternalBrowserRequestArgs): PromiseOrValue; emitBrowserPageStarted(args: EmitBrowserPageArgs): PromiseOrValue<{ success: boolean }>; emitBrowserPageFinished(args: EmitBrowserPageArgs): PromiseOrValue<{ success: boolean }>; emitBrowserError(args: EmitBrowserErrorArgs): PromiseOrValue<{ success: boolean }>; diff --git a/packages/walletkit-android-bridge/src/types/bridge.ts b/packages/walletkit-android-bridge/src/types/bridge.ts index 9990d9d2d..34d4b532e 100644 --- a/packages/walletkit-android-bridge/src/types/bridge.ts +++ b/packages/walletkit-android-bridge/src/types/bridge.ts @@ -6,6 +6,8 @@ * */ +import type { BridgeResponse, BridgeEvent } from '@ton/walletkit'; + import type { WalletKitBridgeEvent } from './events'; import type { WalletKitBridgeApi } from './api'; @@ -13,6 +15,11 @@ export type WalletKitApiMethod = keyof WalletKitBridgeApi; export type DiagnosticStage = 'start' | 'checkpoint' | 'success' | 'error'; +/** + * Union type for all messages passed through jsBridgeTransport from walletkit. + */ +export type JsBridgeTransportMessage = BridgeResponse | BridgeEvent; + export type BridgePayload = | { kind: 'response'; id: string; result?: unknown; error?: { message: string } } | { kind: 'event'; event: WalletKitBridgeEvent } @@ -32,7 +39,7 @@ export type BridgePayload = timestamp: number; message?: string; } - | { kind: 'jsBridgeEvent'; sessionId: string; event: unknown }; + | { kind: 'jsBridgeEvent'; sessionId: string; event: JsBridgeTransportMessage }; export interface CallContext { id: string; diff --git a/packages/walletkit-android-bridge/src/types/events.ts b/packages/walletkit-android-bridge/src/types/events.ts index 9d2424018..7b5b74281 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' + | 'intentRequest' | 'disconnect' | 'requestError' | 'browserPageStarted' diff --git a/packages/walletkit-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index eb5e1c548..472f58d6d 100644 --- a/packages/walletkit-android-bridge/src/types/walletkit.ts +++ b/packages/walletkit-android-bridge/src/types/walletkit.ts @@ -6,7 +6,37 @@ * */ -import type { WalletAdapter, WalletSigner, Network } from '@ton/walletkit'; +import type { + ActionIntentEvent, + ApiClient, + BridgeEventMessageInfo, + ConnectionApprovalProof, + ConnectionApprovalResponse, + ConnectionRequestEvent, + DeviceInfo, + DisconnectionEvent, + InjectedToExtensionBridgeRequestPayload, + IntentEvent, + IntentResponse, + IntentTransactionResponseSuccess, + IntentSignDataResponseSuccess, + IntentResponseError, + Network, + RequestErrorEvent, + SendTransactionApprovalResponse, + SendTransactionRequestEvent, + SignDataApprovalResponse, + SignDataIntentEvent, + SignDataRequestEvent, + TONConnectSession, + TransactionIntentEvent, + TransactionRequest, + Wallet, + WalletAdapter, + WalletInfo, + WalletSigner, +} from '@ton/walletkit'; +import type { CONNECT_EVENT_ERROR_CODES, SendTransactionRpcResponseError } from '@tonconnect/protocol'; /** * Configuration and bridge-facing types for Ton WalletKit. @@ -15,8 +45,8 @@ export interface WalletKitBridgeInitConfig { bridgeUrl?: string; bridgeName?: string; allowMemoryStorage?: boolean; - walletManifest?: unknown; - deviceInfo?: unknown; + walletManifest?: WalletInfo; + deviceInfo?: DeviceInfo; disableNetworkSend?: boolean; /** * Network configurations matching native SDK format. @@ -40,64 +70,74 @@ export interface WalletKitNativeBridgeType { signWithCustomSigner?(signerId: string, bytes: number[]): Promise; } -/** - * Loose wallet type for bridge pass-through. - * Uses unknown/any for methods since Kotlin handles the actual data. - */ -export interface WalletKitWallet { - getWalletId?(): string; - getAddress?(): string; - getBalance?(): Promise; - getClient(): { getAccountTransactions(params: unknown): Promise<{ transactions?: unknown[] }> }; - createTransferTonTransaction(params: unknown): Promise; - createTransferMultiTonTransaction(params: unknown): Promise; - getTransactionPreview?(transaction: unknown): Promise; - sendTransaction(transaction: unknown): Promise; - // NFT methods - getNfts?(params: unknown): Promise; - getNft?(address: string): Promise; - createTransferNftTransaction?(params: unknown): Promise; - createTransferNftRawTransaction?(params: unknown): Promise; - // Jetton methods - getJettons?(params: unknown): Promise; - createTransferJettonTransaction?(params: unknown): Promise; - getJettonBalance?(address: string): Promise; - getJettonWalletAddress?(address: string): Promise; -} export type WalletKitAdapter = WalletAdapter; export type WalletKitSigner = WalletSigner; export interface WalletKitInstance { ensureInitialized?: () => Promise; - getWallets: () => WalletKitWallet[]; - getWallet(walletId: string): WalletKitWallet | undefined; + getWallets: () => Wallet[]; + getWallet(walletId: string): Wallet | undefined; getNetwork?: () => string; removeWallet(walletId: string): Promise; - getApiClient(network?: Network): unknown; - addWallet(adapter: unknown): Promise; - handleNewTransaction(wallet: WalletKitWallet, transaction: unknown): Promise; - handleTonConnectUrl(url: string): Promise; - listSessions?(): Promise; + getApiClient(network?: Network): ApiClient; + addWallet(adapter: WalletAdapter): Promise; + handleNewTransaction(wallet: Wallet, transaction: TransactionRequest): Promise; + handleTonConnectUrl(url: string): Promise; + // Intent URL handling + isIntentUrl(url: string): boolean; + handleIntentUrl(url: string): Promise; + intentItemsToTransactionRequest(event: TransactionIntentEvent, wallet: Wallet): Promise; + approveTransactionIntent?( + event: TransactionIntentEvent, + walletId: string, + ): Promise; + approveSignDataIntent?(event: SignDataIntentEvent, walletId: string): Promise; + approveActionIntent?(event: ActionIntentEvent, walletId: string): Promise; + processConnectAfterIntent?(event: IntentEvent, walletId: string, proof?: ConnectionApprovalProof): Promise; + rejectIntent?( + event: IntentEvent | { id: string; clientId: string }, + reason?: string, + errorCode?: number, + ): Promise; + onIntentRequest?(callback: (event: IntentEvent) => void): void; + removeIntentRequestCallback?(): void; + listSessions?(): Promise; disconnect?(sessionId?: string): Promise; processInjectedBridgeRequest?( - messageInfo: Record, - request: Record, - ): Promise; - onConnectRequest(callback: (event: unknown) => void): void; + messageInfo: BridgeEventMessageInfo, + request: InjectedToExtensionBridgeRequestPayload, + ): Promise; + onConnectRequest(callback: (event: ConnectionRequestEvent) => void): void; removeConnectRequestCallback(): void; - onTransactionRequest(callback: (event: unknown) => void): void; + onTransactionRequest(callback: (event: SendTransactionRequestEvent) => void): void; removeTransactionRequestCallback(): void; - onSignDataRequest(callback: (event: unknown) => void): void; + onSignDataRequest(callback: (event: SignDataRequestEvent) => void): void; removeSignDataRequestCallback(): void; - onDisconnect(callback: (event: unknown) => void): void; + onDisconnect(callback: (event: DisconnectionEvent) => void): void; removeDisconnectCallback(): void; - onRequestError(callback: (event: unknown) => void): void; + onRequestError(callback: (event: RequestErrorEvent) => void): void; removeErrorCallback(): void; // Request approval methods - event and response are separate parameters - approveConnectRequest(event: unknown, response?: unknown): Promise; - rejectConnectRequest(event: unknown, reason?: string, errorCode?: number): Promise; - approveTransactionRequest(event: unknown, response?: unknown): Promise; - rejectTransactionRequest(event: unknown, reason?: string | { code: number; message: string }): Promise; - approveSignDataRequest(event: unknown, response?: unknown): Promise; - rejectSignDataRequest(event: unknown, reason?: string | { code: number; message: string }): Promise; + approveConnectRequest(event: ConnectionRequestEvent, response?: ConnectionApprovalResponse): Promise; + rejectConnectRequest( + event: ConnectionRequestEvent, + reason?: string, + errorCode?: CONNECT_EVENT_ERROR_CODES, + ): Promise; + approveTransactionRequest( + event: SendTransactionRequestEvent, + response?: SendTransactionApprovalResponse, + ): Promise; + rejectTransactionRequest( + event: SendTransactionRequestEvent, + reason?: string | SendTransactionRpcResponseError['error'], + ): Promise; + approveSignDataRequest( + event: SignDataRequestEvent, + response?: SignDataApprovalResponse, + ): Promise; + rejectSignDataRequest( + event: SignDataRequestEvent, + reason?: string | SendTransactionRpcResponseError['error'], + ): Promise; } diff --git a/packages/walletkit-android-bridge/src/utils/bridge.ts b/packages/walletkit-android-bridge/src/utils/bridge.ts new file mode 100644 index 000000000..7cb99cdee --- /dev/null +++ b/packages/walletkit-android-bridge/src/utils/bridge.ts @@ -0,0 +1,133 @@ +/** + * 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. + * + */ + +/** + * bridge.ts - Minimal unified bridge for all operations + * + * Single entry point for calling WalletKit, Wallet, and Client methods. + * Handles initialization, wallet lookup, and error handling in one place. + */ + +import type { Wallet } from '@ton/walletkit'; + +import type { WalletKitInstance } from '../core/state'; +import { walletKit } from '../core/state'; + +// Re-export for external use +export type { WalletKitInstance }; + +/** + * Ensures WalletKit is initialized and ready. + */ +async function ensureReady(): Promise { + if (!walletKit) { + throw new Error('WalletKit not initialized'); + } + await walletKit.ensureInitialized?.(); + return walletKit; +} + +/** + * Gets a wallet by ID, throwing if not found. + */ +function getWalletOrThrow(kit: WalletKitInstance, walletId: string): Wallet { + const wallet = kit.getWallet(walletId); + if (!wallet) { + throw new Error(`Wallet not found: ${walletId}`); + } + return wallet; +} + +/** + * Calls a method on WalletKit instance. + * + * @example + * await kit('removeWallet', walletId); + * await kit('handleTonConnectUrl', url); + * await kit('listSessions'); + */ +export async function kit(method: M, ...args: unknown[]): Promise { + const instance = await ensureReady(); + const fn = instance[method]; + if (typeof fn !== 'function') { + throw new Error(`Method '${method}' not found on WalletKit`); + } + return (fn as (...a: unknown[]) => unknown).apply(instance, args); +} + +/** + * Calls a method on a Wallet instance. + * + * @example + * await wallet(walletId, 'getBalance'); + * await wallet(walletId, 'getJettons', { pagination }); + * await wallet(walletId, 'createTransferTonTransaction', request); + */ +export async function wallet( + walletId: string, + method: M, + ...args: unknown[] +): Promise { + const instance = await ensureReady(); + const w = getWalletOrThrow(instance, walletId); + const fn = w[method]; + if (typeof fn !== 'function') { + throw new Error(`Method '${method}' not found on Wallet`); + } + return (fn as (...a: unknown[]) => unknown).apply(w, args); +} + +/** + * Get the raw WalletKit instance (for complex operations). + * Use sparingly - prefer kit(), wallet() for type safety. + */ +export async function getKit(): Promise { + return ensureReady(); +} + +/** + * Get a wallet instance (for complex operations). + * Use sparingly - prefer wallet() for type safety. + */ +export async function getWallet(walletId: string): Promise { + const instance = await ensureReady(); + return getWalletOrThrow(instance, walletId); +} + +/** + * Calls a method on a Wallet instance. Extracts walletId from args.walletId. + */ +export async function walletCall( + method: string, + args: { walletId: string; [k: string]: unknown }, +): Promise { + const instance = await ensureReady(); + const w = getWalletOrThrow(instance, args.walletId); + const fn = (w as unknown as Record)[method]; + if (typeof fn !== 'function') { + throw new Error(`Method '${method}' not found on Wallet`); + } + return (fn as (args?: unknown) => Promise).call(w, args); +} + +/** + * Calls a method on a Wallet's ApiClient. Extracts walletId from args.walletId. + */ +export async function clientCall( + method: string, + args: { walletId: string; [k: string]: unknown }, +): Promise { + const instance = await ensureReady(); + const w = getWalletOrThrow(instance, args.walletId); + const apiClient = w.getClient(); + const fn = (apiClient as unknown as Record)[method]; + if (typeof fn !== 'function') { + throw new Error(`Method '${method}' not found on ApiClient`); + } + return (fn as (args?: unknown) => Promise).call(apiClient, args); +} diff --git a/packages/walletkit-android-bridge/src/utils/bridgeWrapper.ts b/packages/walletkit-android-bridge/src/utils/bridgeWrapper.ts deleted file mode 100644 index 45e7fdeeb..000000000 --- a/packages/walletkit-android-bridge/src/utils/bridgeWrapper.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * 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. - * - */ - -/** - * bridgeWrapper.ts - Unified bridge operation wrappers - * - * Provides consistent initialization and error handling for all bridge operations. - */ - -import type { WalletKitInstance } from '../core/state'; -import { walletKit } from '../core/state'; - -/** - * Unified wrapper for all bridge operations. - * Handles initialization and ensures WalletKit is ready before executing operation. - * Passes the initialized walletKit instance to the callback to avoid null assertions. - * - * @param _method - Operation name for error logging (unused, kept for API consistency) - * @param operation - Function receiving the initialized walletKit instance - * @returns Result of the operation - */ -export async function callBridge(_method: string, operation: (kit: WalletKitInstance) => Promise): Promise { - if (!walletKit) { - throw new Error('WalletKit not initialized'); - } - if (walletKit.ensureInitialized) { - await walletKit.ensureInitialized(); - } - return await operation(walletKit); -} - -/** - * Wrapper for wallet-specific operations. - * Gets a wallet by walletId and calls a method on it. - * - * @param walletId - Wallet ID (format: "{chainId}:{address}") - * @param method - Wallet method name to call - * @param args - Optional arguments to pass to the method - * @returns Result of the wallet method - */ -export async function callOnWalletBridge(walletId: string, method: string, args?: unknown): Promise { - return callBridge(`wallet.${method}`, async (kit) => { - const wallet = kit.getWallet?.(walletId); - const methodRef = (wallet as unknown as Record)?.[method]; - return (await (methodRef as (args?: unknown) => Promise).call(wallet, args)) as T; - }); -} diff --git a/packages/walletkit-android-bridge/src/utils/index.ts b/packages/walletkit-android-bridge/src/utils/index.ts deleted file mode 100644 index d81a5d09c..000000000 --- a/packages/walletkit-android-bridge/src/utils/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * 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. - * - */ - -export * from './serialization'; diff --git a/packages/walletkit-android-bridge/src/utils/internalBrowserResolvers.ts b/packages/walletkit-android-bridge/src/utils/internalBrowserResolvers.ts index e80398815..cc66f63b7 100644 --- a/packages/walletkit-android-bridge/src/utils/internalBrowserResolvers.ts +++ b/packages/walletkit-android-bridge/src/utils/internalBrowserResolvers.ts @@ -6,9 +6,11 @@ * */ +import type { JsBridgeTransportMessage } from '../types/bridge'; + export type InternalBrowserResponseResolver = { - resolve: (response: unknown) => void; - reject: (error: unknown) => void; + resolve: (response: JsBridgeTransportMessage) => void; + reject: (error: Error) => void; }; export type InternalBrowserResolverRegistry = Map; diff --git a/packages/walletkit/src/api/interfaces/WalletAdapter.ts b/packages/walletkit/src/api/interfaces/WalletAdapter.ts index 922e84445..f81f4c379 100644 --- a/packages/walletkit/src/api/interfaces/WalletAdapter.ts +++ b/packages/walletkit/src/api/interfaces/WalletAdapter.ts @@ -40,7 +40,9 @@ export interface WalletAdapter { getSignedSendTransaction( input: TransactionRequest, options?: { - fakeSignature: boolean; + fakeSignature?: boolean; + /** Use internal message opcode (0x73696e74) instead of external (0x7369676e) for gasless relaying */ + internal?: boolean; }, ): Promise; getSignedSignData( diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts index 03ce31853..7eec71a00 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts @@ -137,8 +137,11 @@ export class WalletV4R2Adapter implements WalletAdapter { async getSignedSendTransaction( input: TransactionRequest, - _options: { fakeSignature: boolean }, + options?: { fakeSignature?: boolean; internal?: boolean }, ): Promise { + if (options?.internal) { + throw new Error('WalletV4R2 does not support internal message signing (gasless). Use WalletV5R1.'); + } if (input.messages.length === 0) { throw new Error('Ledger does not support empty messages'); } diff --git a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts index f37ba3610..ff48371ea 100644 --- a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts +++ b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts @@ -153,7 +153,7 @@ export class WalletV5R1Adapter implements WalletAdapter { async getSignedSendTransaction( input: TransactionRequest, - options: { fakeSignature: boolean }, + options?: { fakeSignature?: boolean; internal?: boolean }, ): Promise { const actions = packActionsList( input.messages.map((m) => { @@ -201,7 +201,7 @@ export class WalletV5R1Adapter implements WalletAdapter { }), ); - const createBodyOptions: { validUntil: number | undefined; fakeSignature: boolean } = { + const createBodyOptions: { validUntil: number | undefined; fakeSignature?: boolean; internal?: boolean } = { ...options, validUntil: undefined, }; @@ -309,15 +309,24 @@ export class WalletV5R1Adapter implements WalletAdapter { seqno: number, walletId: bigint, actionsList: Cell, - options: { validUntil: number | undefined; fakeSignature: boolean }, + options: { validUntil: number | undefined; fakeSignature?: boolean; internal?: boolean }, ) { const Opcodes = { auth_signed: 0x7369676e, + auth_signed_internal: 0x73696e74, }; + // Use internal opcode for gasless relaying (signMsg intent) + const opcode = options.internal ? Opcodes.auth_signed_internal : Opcodes.auth_signed; + log.debug('createBodyV5 signing with opcode', { + internal: options.internal, + opcode: `0x${opcode.toString(16)}`, + opcodeAscii: Buffer.from(opcode.toString(16), 'hex').toString('ascii'), + }); + 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/BridgeManager.ts b/packages/walletkit/src/core/BridgeManager.ts index 7b75e68c8..0633313e4 100644 --- a/packages/walletkit/src/core/BridgeManager.ts +++ b/packages/walletkit/src/core/BridgeManager.ts @@ -259,6 +259,49 @@ export class BridgeManager { } } + /** + * Send an intent response to a dApp's clientId + * Used for TonConnect intents where there's no pre-existing session + * + * @param clientId - The dApp's public key (hex string from intent URL) + * @param response - The response payload to send + * @param sessionCrypto - The wallet's session crypto for encryption + * @param traceId - Optional trace ID for tracking + */ + async sendIntentResponse( + clientId: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + response: any, + sessionCrypto: SessionCrypto, + traceId?: string, + ): Promise { + if (!this.bridgeProvider) { + throw new WalletKitError( + ERROR_CODES.BRIDGE_NOT_INITIALIZED, + 'Bridge not initialized for sending intent response', + ); + } + + try { + await this.bridgeProvider.send(response, sessionCrypto, clientId, { + traceId, + }); + + log.debug('Intent response sent successfully', { clientId: clientId.slice(0, 16) + '...' }); + } catch (error) { + log.error('Failed to send intent response through bridge', { + clientId: clientId.slice(0, 16) + '...', + error, + }); + throw WalletKitError.fromError( + ERROR_CODES.BRIDGE_RESPONSE_SEND_FAILED, + 'Failed to send intent response through bridge', + error, + { clientId }, + ); + } + } + async sendJsBridgeResponse( sessionId: string, _isJsBridge: boolean, diff --git a/packages/walletkit/src/core/IntentHandler.ts b/packages/walletkit/src/core/IntentHandler.ts new file mode 100644 index 000000000..b3ad57977 --- /dev/null +++ b/packages/walletkit/src/core/IntentHandler.ts @@ -0,0 +1,1016 @@ +/** + * 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. + * + */ + +/** + * IntentHandler - Handles TonConnect intent deep links + * + * Parses and processes intent URLs (tc://intent_inline?...) that allow + * dApps to request actions without a prior TonConnect session. + */ + +import { Address } from '@ton/core'; +import { sha256_sync } from '@ton/crypto'; +import { SessionCrypto } from '@tonconnect/protocol'; + +import { globalLogger } from './Logger'; +import type { BridgeManager } from './BridgeManager'; +import type { WalletManager } from './WalletManager'; +import type { EventEmitter } from './EventEmitter'; +import type { RequestProcessor } from './RequestProcessor'; +import { WalletKitError, ERROR_CODES } from '../errors'; +import type { Wallet } from '../api/interfaces'; +import type { + IntentRequest, + IntentMethod, + ParsedIntentUrl, + IntentEvent, + TransactionIntentEvent, + SignDataIntentEvent, + ActionIntentEvent, + SendTransactionIntentRequest, + SignMessageIntentRequest, + SignDataIntentRequest, + SendActionIntentRequest, + IntentItem, + SendTonIntentItem, + SendJettonIntentItem, + SendNftIntentItem, + IntentTransactionResponseSuccess, + IntentSignDataResponseSuccess, + IntentResponseError, + SignDataIntentPayload, + ActionUrlResponse, + SendTransactionAction, + SignDataAction, + ActionTransactionMessage, +} from '../types/intents'; +import type { TransactionRequest, TransactionRequestMessage, PreparedSignData, SignData } from '../api/models'; +import type { Base64String, Hex } from '../api/models/core/Primitives'; +import type { + ConnectionRequestEvent, + ConnectionRequestEventRequestedItem, + ConnectionRequestEventPreviewPermission, +} from '../api/models/bridge/ConnectionRequestEvent'; +import type { ConnectionApprovalProof } from '../api/models/bridge/ConnectionApprovalResponse'; + +const log = globalLogger.createChild('IntentHandler'); + +/** + * Intent URL schemes + */ +const INTENT_INLINE_SCHEME = 'tc://intent_inline'; +const INTENT_SCHEME = 'tc://intent'; + +/** + * Intent error codes (from spec) + */ +export const INTENT_ERROR_CODES = { + UNKNOWN: 0, + BAD_REQUEST: 1, + UNKNOWN_APP: 100, + ACTION_URL_UNREACHABLE: 200, + USER_DECLINED: 300, + METHOD_NOT_SUPPORTED: 400, +} as const; + +export type IntentErrorCode = (typeof INTENT_ERROR_CODES)[keyof typeof INTENT_ERROR_CODES]; + +/** + * Handles TonConnect intent deep links + */ +export class IntentHandler { + private bridgeManager: BridgeManager | null = null; + + constructor( + private walletManager: WalletManager, + private eventEmitter: EventEmitter, + private requestProcessor: RequestProcessor, + ) {} + + /** + * Set the bridge manager reference + * Called after initialization since BridgeManager is created later + */ + setBridgeManager(bridgeManager: BridgeManager): void { + this.bridgeManager = bridgeManager; + } + + /** + * Send an intent response to the dApp through the bridge + * @param clientId - The dApp's public key (from intent URL) + * @param response - The response to send + * @param traceId - Optional trace ID for tracking + */ + private async sendIntentResponse( + clientId: string, + response: IntentTransactionResponseSuccess | IntentSignDataResponseSuccess | IntentResponseError, + traceId?: string, + ): Promise { + if (!this.bridgeManager) { + log.warn('Bridge manager not available, cannot send intent response'); + return; + } + + try { + // Create a new session crypto for this intent response + const sessionCrypto = new SessionCrypto(); + + // Send the response through the bridge to the dApp's clientId + await this.bridgeManager.sendIntentResponse(clientId, response, sessionCrypto, traceId); + log.info('Intent response sent to dApp', { + clientId: clientId.slice(0, 16) + '...', + responseId: response.id, + }); + } catch (error) { + log.error('Failed to send intent response', { clientId: clientId.slice(0, 16) + '...', error }); + // Don't throw - the signing was successful, sending is best-effort + } + } + + // ======================================================================== + // URL Parsing + // ======================================================================== + + /** + * Check if a URL is an intent URL + */ + isIntentUrl(url: string): boolean { + const normalizedUrl = url.trim().toLowerCase(); + return normalizedUrl.startsWith(INTENT_INLINE_SCHEME) || normalizedUrl.startsWith(INTENT_SCHEME); + } + + /** + * Parse an intent URL and extract the request payload + */ + parseIntentUrl(url: string): ParsedIntentUrl { + log.debug('Parsing intent URL', { url }); + + try { + const parsedUrl = new URL(url); + + // Get client ID (public key) + const clientId = parsedUrl.searchParams.get('id'); + if (!clientId) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing client ID (id) in intent URL'); + } + + // Check if it's inline (Approach 2) or storage-based (Approach 1) + if (url.toLowerCase().startsWith(INTENT_INLINE_SCHEME)) { + return this.parseInlineIntent(parsedUrl, clientId); + } else { + // Approach 1 with object storage - not implemented yet + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Object storage intents (Approach 1) are not yet supported. Use intent_inline instead.', + ); + } + } catch (error) { + if (error instanceof WalletKitError) { + throw error; + } + log.error('Failed to parse intent URL', { error, url }); + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid intent URL format', error as Error, { + url, + }); + } + } + + /** + * Parse inline intent (Approach 2: URL-Embedded Data) + * + * The `r` parameter can be encoded in two ways: + * 1. base64url(json.stringify(payload)) - standard spec format + * 2. encodeURIComponent(json.stringify(payload)) - URL-encoded JSON + * + * We detect the format by checking if the payload starts with characters + * that look like JSON (after URL decoding) or base64url. + */ + private parseInlineIntent(parsedUrl: URL, clientId: string): ParsedIntentUrl { + const encodedPayload = parsedUrl.searchParams.get('r'); + if (!encodedPayload) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing payload (r) in intent URL'); + } + + // Try to decode the payload - detect format automatically + const jsonPayload = this.decodeIntentPayload(encodedPayload); + log.debug('Decoded intent payload', { jsonPayload }); + + // Parse JSON to IntentRequest + let request: IntentRequest; + try { + request = JSON.parse(jsonPayload) as IntentRequest; + } catch (error) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid JSON in intent payload', error as Error); + } + + // Validate the request + this.validateIntentRequest(request); + + return { + clientId, + request, + }; + } + + /** + * Decode intent payload - handles both URL-encoded JSON and base64url formats + * + * Detection logic: + * - If the string starts with `%7B` or `{`, it's URL-encoded JSON + * - Otherwise, try base64url decoding + */ + private decodeIntentPayload(encoded: string): string { + // Check if it's URL-encoded JSON (starts with %7B which is `{` URL-encoded) + // or already decoded JSON (starts with `{`) + if (encoded.startsWith('%7B') || encoded.startsWith('%257B') || encoded.startsWith('{')) { + // URL-encoded JSON - decode it + // Handle double-encoding (%25 = %) + let decoded = decodeURIComponent(encoded); + // Check if still URL-encoded (double-encoding case) + if (decoded.startsWith('%7B') || decoded.startsWith('%')) { + decoded = decodeURIComponent(decoded); + } + return decoded; + } + + // Try base64url decoding + return this.decodeBase64Url(encoded); + } + + /** + * Decode base64url to string + */ + private decodeBase64Url(encoded: string): string { + // Replace URL-safe characters with standard base64 characters + let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + + // Add padding if necessary + const padding = base64.length % 4; + if (padding) { + base64 += '='.repeat(4 - padding); + } + + // Decode + if (typeof atob === 'function') { + return atob(base64); + } else { + // Node.js environment + return Buffer.from(base64, 'base64').toString('utf-8'); + } + } + + /** + * Validate an intent request + */ + private validateIntentRequest(request: IntentRequest): void { + if (!request.id) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Intent request missing id'); + } + + if (!request.m) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Intent request missing method (m)'); + } + + const validMethods: IntentMethod[] = ['txIntent', 'signMsg', 'signIntent', 'actionIntent']; + if (!validMethods.includes(request.m)) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Unknown intent method: ${request.m}`); + } + + // Validate specific intent types + switch (request.m) { + case 'txIntent': + case 'signMsg': + this.validateTransactionIntent(request); + break; + case 'signIntent': + this.validateSignDataIntent(request); + break; + case 'actionIntent': + this.validateActionIntent(request); + break; + } + } + + private validateTransactionIntent(request: SendTransactionIntentRequest | SignMessageIntentRequest): void { + if (!request.i || !Array.isArray(request.i) || request.i.length === 0) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Intent missing items (i)'); + } + + // Validate each item + for (const item of request.i) { + if (!item.t) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Intent item missing type (t)'); + } + + const validTypes = ['ton', 'jetton', 'nft']; + if (!validTypes.includes(item.t)) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Unknown intent item type: ${item.t}`); + } + + this.validateIntentItem(item); + } + } + + private validateIntentItem(item: IntentItem): void { + switch (item.t) { + case 'ton': { + const tonItem = item as SendTonIntentItem; + if (!tonItem.a) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'TON intent item missing address (a)'); + } + if (!tonItem.am) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'TON intent item missing amount (am)'); + } + break; + } + case 'jetton': { + const jettonItem = item as SendJettonIntentItem; + if (!jettonItem.ma) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Jetton intent item missing master address (ma)', + ); + } + if (!jettonItem.ja) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Jetton intent item missing amount (ja)'); + } + if (!jettonItem.d) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Jetton intent item missing destination (d)', + ); + } + break; + } + case 'nft': { + const nftItem = item as SendNftIntentItem; + if (!nftItem.na) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'NFT intent item missing NFT address (na)'); + } + if (!nftItem.no) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'NFT intent item missing new owner (no)'); + } + break; + } + } + } + + private validateSignDataIntent(request: SignDataIntentRequest): void { + // Manifest URL can be in `mu` or `c.manifestUrl` + const manifestUrl = request.mu || request.c?.manifestUrl; + if (!manifestUrl) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Sign data intent missing manifest URL (mu or c.manifestUrl)', + ); + } + if (!request.p) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Sign data intent missing payload (p)'); + } + if (!request.p.type) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Sign data payload missing type'); + } + } + + private validateActionIntent(request: SendActionIntentRequest): void { + if (!request.a) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent missing action URL (a)'); + } + } + + // ======================================================================== + // Intent Processing + // ======================================================================== + + /** + * Handle an intent URL + * Parses the URL and emits an intent event for the wallet UI + */ + async handleIntentUrl(url: string): Promise { + log.info('Handling intent URL', { url }); + + const parsed = this.parseIntentUrl(url); + const event = this.createIntentEvent(parsed); + + // Emit the intent event for wallet UI to handle + this.eventEmitter.emit('intent', event); + + log.info('Intent event emitted', { type: event.type, id: event.id }); + } + + /** + * Create an intent event from parsed URL + */ + private createIntentEvent(parsed: ParsedIntentUrl): IntentEvent { + const { clientId, request } = parsed; + const hasConnectRequest = !!request.c; + + const baseEvent = { + id: request.id, + clientId, + hasConnectRequest, + connectRequest: request.c, + }; + + switch (request.m) { + case 'txIntent': + case 'signMsg': { + const txRequest = request as SendTransactionIntentRequest | SignMessageIntentRequest; + return { + ...baseEvent, + type: request.m, + network: txRequest.n, + validUntil: txRequest.vu, + items: txRequest.i, + } as TransactionIntentEvent; + } + case 'signIntent': { + const signRequest = request as SignDataIntentRequest; + // Manifest URL can be in `mu` or `c.manifestUrl` + const manifestUrl = signRequest.mu || signRequest.c?.manifestUrl || ''; + return { + ...baseEvent, + type: 'signIntent', + network: signRequest.n, + manifestUrl, + payload: signRequest.p, + } as SignDataIntentEvent; + } + case 'actionIntent': { + const actionRequest = request as SendActionIntentRequest; + return { + ...baseEvent, + type: 'actionIntent', + actionUrl: actionRequest.a, + } as ActionIntentEvent; + } + } + } + + // ======================================================================== + // Intent Approval/Rejection + // ======================================================================== + + /** + * Approve a transaction intent (txIntent) + * + * Signs and sends the transaction to the blockchain, + * then returns the signed BoC for the dApp to verify. + * + * @param event - The transaction intent event + * @param walletId - The wallet to use for signing + * @returns The approval response with signed BoC + */ + async approveTransactionIntent( + event: TransactionIntentEvent, + walletId: string, + ): Promise { + log.info('Approving transaction intent', { id: event.id, walletId, type: event.type }); + + const wallet = this.walletManager.getWallet(walletId); + if (!wallet) { + throw new WalletKitError(ERROR_CODES.WALLET_NOT_FOUND, `Wallet not found: ${walletId}`); + } + + // Convert intent items to transaction request + const transactionRequest = await this.intentItemsToTransactionRequest( + event.items, + wallet, + event.network, + event.validUntil, + ); + + // Sign the transaction + // signMsg uses internal opcode (0x73696e74) for gasless relaying + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest, { + internal: event.type === 'signMsg', + }); + + // For txIntent, send to blockchain + if (event.type === 'txIntent') { + // Use the wallet's API client + await wallet.client.sendBoc(signedBoc); + log.info('Transaction sent to blockchain', { id: event.id }); + } + + // Note: signMsg type does NOT send to blockchain - that's the key difference + // The dApp will use the signed BoC for gasless transaction relaying + + const response: IntentTransactionResponseSuccess = { + result: signedBoc, + id: event.id, + }; + + // Send response to dApp through bridge + await this.sendIntentResponse(event.clientId, response); + + log.info('Intent approved successfully', { id: event.id, type: event.type }); + return response; + } + + /** + * Approve a sign data intent (signIntent) + * + * Signs the data and returns the signature. + * + * @param event - The sign data intent event + * @param walletId - The wallet to use for signing + * @returns The approval response with signature + */ + async approveSignDataIntent(event: SignDataIntentEvent, walletId: string): Promise { + log.info('Approving sign data intent', { id: event.id, walletId }); + + const wallet = this.walletManager.getWallet(walletId); + if (!wallet) { + throw new WalletKitError(ERROR_CODES.WALLET_NOT_FOUND, `Wallet not found: ${walletId}`); + } + + // Get wallet address + const address = wallet.getAddress().toString(); + const timestamp = Math.floor(Date.now() / 1000); + + // Extract domain from manifest URL + const domain = new URL(event.manifestUrl).hostname; + + // Convert intent payload to SignData format + let signData: SignData; + switch (event.payload.type) { + case 'text': + signData = { type: 'text', value: { content: event.payload.text } }; + break; + case 'binary': + signData = { type: 'binary', value: { content: event.payload.bytes as Base64String } }; + break; + case 'cell': + signData = { + type: 'cell', + value: { schema: event.payload.schema, content: event.payload.cell as Base64String }, + }; + break; + } + + // Build PreparedSignData + const dataToHash = JSON.stringify({ address, timestamp, domain, payload: signData }); + const dataHash = sha256_sync(dataToHash).toString('hex'); + + const preparedSignData: PreparedSignData = { + address, + timestamp, + domain, + payload: { + network: event.network ? { chainId: event.network } : undefined, + data: signData, + }, + hash: `0x${dataHash}` as Hex, + }; + + // Sign the data + const signature = await wallet.getSignedSignData(preparedSignData); + + const response: IntentSignDataResponseSuccess = { + result: { + signature, + address, + timestamp, + domain, + payload: event.payload, + }, + id: event.id, + }; + + // Send response to dApp through bridge + await this.sendIntentResponse(event.clientId, response); + + log.info('Sign data intent approved', { id: event.id }); + return response; + } + + /** + * Approve an action intent (actionIntent) + * + * Fetches action details from the action URL, then executes the appropriate action. + * The action URL should return a JSON object with action_type and action fields. + * + * @param event - The action intent event + * @param walletId - The wallet to use for the action + * @returns The approval response (either transaction or sign data response) + */ + async approveActionIntent( + event: ActionIntentEvent, + walletId: string, + ): Promise { + log.info('Approving action intent', { id: event.id, walletId, actionUrl: event.actionUrl }); + + const wallet = this.walletManager.getWallet(walletId); + if (!wallet) { + throw new WalletKitError(ERROR_CODES.WALLET_NOT_FOUND, `Wallet not found: ${walletId}`); + } + + // Build the action URL with address parameter + const walletAddress = wallet.getAddress().toString(); + const actionUrlWithAddress = new URL(event.actionUrl); + actionUrlWithAddress.searchParams.set('address', walletAddress); + + // Fetch action details from the URL + let actionResponse: ActionUrlResponse; + try { + const response = await fetch(actionUrlWithAddress.toString()); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + actionResponse = (await response.json()) as ActionUrlResponse; + } catch (error) { + throw new WalletKitError( + ERROR_CODES.NETWORK_ERROR, + `Failed to fetch action from URL: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + + // Validate action response + if (!actionResponse.action_type || !actionResponse.action) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Invalid action response: missing action_type or action', + ); + } + + // Execute based on action type + switch (actionResponse.action_type) { + case 'sendTransaction': { + // Build transaction request from action + const txAction = actionResponse.action as SendTransactionAction; + const transactionRequest: TransactionRequest = { + messages: txAction.messages.map((msg: ActionTransactionMessage) => ({ + address: msg.address, + amount: msg.amount, + payload: msg.payload as Base64String | undefined, + stateInit: msg.stateInit as Base64String | undefined, + extraCurrency: msg.extra_currency, + })), + network: txAction.network ? { chainId: txAction.network } : undefined, + validUntil: txAction.valid_until, + }; + + // Sign and send + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest); + await wallet.client.sendBoc(signedBoc); + + log.info('Action intent (sendTransaction) approved', { id: event.id }); + return { result: signedBoc, id: event.id }; + } + + case 'signData': { + // Build sign data event from action + const signAction = actionResponse.action as SignDataAction; + + // Determine the manifest URL for domain binding: + // 1. Use from connectRequest if available + // 2. Otherwise use action URL origin as fallback + let manifestUrl = event.actionUrl; + if (event.connectRequest?.manifestUrl) { + manifestUrl = event.connectRequest.manifestUrl; + } else { + // Use action URL origin as the manifest URL (proper domain binding) + try { + const actionOrigin = new URL(event.actionUrl); + manifestUrl = `${actionOrigin.origin}/tonconnect-manifest.json`; + } catch (_) { + // Keep original action URL if parsing fails + } + } + + // Create a synthetic sign data event + const signDataEvent: SignDataIntentEvent = { + id: event.id, + clientId: event.clientId, + hasConnectRequest: event.hasConnectRequest, + connectRequest: event.connectRequest, + type: 'signIntent', + network: signAction.network, + manifestUrl, + payload: signAction as SignDataIntentPayload, + }; + + return this.approveSignDataIntent(signDataEvent, walletId); + } + + default: + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Unknown action type: ${actionResponse.action_type}`, + ); + } + } + + /** + * Reject an intent request + * + * @param event - The intent event to reject + * @param reason - Optional rejection reason + * @param errorCode - Optional error code (defaults to USER_DECLINED) + * @returns The rejection response + */ + async rejectIntent(event: IntentEvent, reason?: string, errorCode?: IntentErrorCode): Promise { + log.info('Rejecting intent', { id: event.id, reason }); + + const response: IntentResponseError = { + error: { + code: errorCode ?? INTENT_ERROR_CODES.USER_DECLINED, + message: reason ?? 'User declined the request', + }, + id: event.id, + }; + + // Send rejection response to dApp through bridge + await this.sendIntentResponse(event.clientId, response); + + return response; + } + + /** + * Process connect request after intent approval + * + * This creates a proper ConnectionRequestEvent from the intent's ConnectRequest + * and uses the existing connection infrastructure to establish a session. + * + * @param event - The intent event with connect request + * @param walletId - The wallet to use for the connection + * @param proof - Optional proof response (signature, timestamp, domain, payload) + */ + async processConnectAfterIntent( + event: IntentEvent, + walletId: string, + proof?: ConnectionApprovalProof, + ): Promise { + if (!event.hasConnectRequest || !event.connectRequest) { + log.info('No connect request to process', { id: event.id }); + return; + } + + log.info('Processing connect request after intent', { + id: event.id, + walletId, + manifestUrl: event.connectRequest.manifestUrl, + }); + + const wallet = this.walletManager.getWallet(walletId); + if (!wallet) { + throw new WalletKitError(ERROR_CODES.WALLET_NOT_FOUND, `Wallet not found: ${walletId}`); + } + + // Create ConnectionRequestEvent from the ConnectRequest + const connectRequest = event.connectRequest; + + // Build requested items + const requestedItems: ConnectionRequestEventRequestedItem[] = []; + if (connectRequest.items) { + for (const item of connectRequest.items) { + if (item.name === 'ton_addr') { + requestedItems.push({ type: 'ton_addr' }); + } else if (item.name === 'ton_proof' && 'payload' in item) { + requestedItems.push({ + type: 'ton_proof', + value: { payload: item.payload as string }, + }); + } + } + } + + // Fetch manifest for dApp info + let manifest: { name?: string; url?: string; iconUrl?: string; description?: string } | null = null; + const manifestUrl = connectRequest.manifestUrl; + + if (manifestUrl) { + try { + const response = await fetch(manifestUrl); + if (response.ok) { + manifest = await response.json(); + } + } catch (error) { + log.warn('Failed to fetch manifest for intent connect', { error, manifestUrl }); + } + } + + // Extract domain from manifest URL + let domain = ''; + if (manifestUrl) { + try { + domain = new URL(manifestUrl).hostname; + } catch (_) { + // ignore + } + } + + // Build permissions + const permissions: ConnectionRequestEventPreviewPermission[] = []; + if (requestedItems.some((item) => item.type === 'ton_addr')) { + permissions.push({ + name: 'ton_addr', + title: 'TON Address', + description: 'Gives dApp information about your TON address', + }); + } + if (requestedItems.some((item) => item.type === 'ton_proof')) { + permissions.push({ + name: 'ton_proof', + title: 'TON Proof', + description: 'Gives dApp signature that can be used to verify your access to private key', + }); + } + + // Create the ConnectionRequestEvent + const connectionRequestEvent: ConnectionRequestEvent = { + id: `intent-connect-${event.id}`, + from: event.clientId, + walletId: walletId, + walletAddress: wallet.getAddress(), + domain: domain, + isJsBridge: false, + requestedItems, + preview: { + permissions, + dAppInfo: { + url: manifest?.url || domain, + name: manifest?.name || domain, + description: manifest?.description, + iconUrl: manifest?.iconUrl, + manifestUrl: manifestUrl, + }, + }, + dAppInfo: { + url: manifest?.url || domain, + name: manifest?.name || domain, + description: manifest?.description, + iconUrl: manifest?.iconUrl, + manifestUrl: manifestUrl, + }, + }; + + // Use the RequestProcessor to approve the connect request + // This will create a session and send the response via the bridge + await this.requestProcessor.approveConnectRequest(connectionRequestEvent, proof ? { proof } : undefined); + + log.info('Connect request processed after intent', { id: event.id }); + } + + // ======================================================================== + // Intent Item Conversion + // ======================================================================== + + /** + * Convert intent items to transaction request messages + * @param items - Intent items to convert + * @param wallet - Wallet to use for jetton wallet address resolution + * @param network - Optional network chain ID + * @param validUntil - Optional validity timestamp + */ + async intentItemsToTransactionRequest( + items: IntentItem[], + wallet: Wallet, + network?: string, + validUntil?: number, + ): Promise { + const messages: TransactionRequestMessage[] = []; + const walletAddress = wallet.getAddress().toString(); + + for (const item of items) { + const message = await this.intentItemToMessage(item, wallet, walletAddress); + messages.push(message); + } + + return { + messages, + network: network ? { chainId: network } : undefined, + validUntil: validUntil ?? Math.floor(Date.now() / 1000) + 300, + }; + } + + /** + * Convert a single intent item to a transaction message + */ + private async intentItemToMessage( + item: IntentItem, + wallet: Wallet, + walletAddress: string, + ): Promise { + switch (item.t) { + case 'ton': + return this.tonIntentToMessage(item); + case 'jetton': + return this.jettonIntentToMessage(item, wallet, walletAddress); + case 'nft': + return this.nftIntentToMessage(item, walletAddress); + default: + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Unknown intent item type: ${(item as IntentItem).t}`, + ); + } + } + + /** + * Convert TON intent item to message + */ + private tonIntentToMessage(item: SendTonIntentItem): TransactionRequestMessage { + return { + address: item.a, + amount: item.am, + payload: item.p as Base64String | undefined, + stateInit: item.si as Base64String | undefined, + extraCurrency: item.ec ? (item.ec as Record) : undefined, + }; + } + + /** + * Convert Jetton intent item to message + * Builds the jetton transfer message body + */ + private async jettonIntentToMessage( + item: SendJettonIntentItem, + wallet: Wallet, + walletAddress: string, + ): Promise { + const { beginCell, Cell } = await import('@ton/core'); + + log.info('jettonIntentToMessage v2 - using Cell.fromBase64', { + hasFp: !!item.fp, + hasCp: !!item.cp, + fpLength: item.fp?.length ?? 0, + }); + + // Build jetton transfer body according to TEP-74 + // Note: fp and cp are base64-encoded BoC (Bag of Cells), not raw bytes + const forwardPayloadCell = item.fp ? Cell.fromBase64(item.fp) : null; + const customPayloadCell = item.cp ? Cell.fromBase64(item.cp) : null; + + log.info('Payload cells created', { + fpBits: forwardPayloadCell?.bits.length ?? 0, + cpBits: customPayloadCell?.bits.length ?? 0, + }); + + const body = beginCell() + .storeUint(0xf8a7ea5, 32) // op: transfer + .storeUint(item.qi ?? 0, 64) // query_id + .storeCoins(BigInt(item.ja)) // amount + .storeAddress(Address.parse(item.d)) // destination + .storeAddress(item.rd ? Address.parse(item.rd) : Address.parse(walletAddress)) // response_destination + .storeMaybeRef(customPayloadCell) // custom_payload (already a Cell) + .storeCoins(BigInt(item.fta ?? '0')) // forward_ton_amount + .storeMaybeRef(forwardPayloadCell) // forward_payload (already a Cell) + .endCell(); + + log.info('Jetton transfer body built', { bits: body.bits.length, refs: body.refs.length }); + + // Get jetton wallet address for the user using the wallet's method + const jettonWalletAddress = await wallet.getJettonWalletAddress(item.ma); + + // Default amount for jetton transfer fee + const feeAmount = item.fta ? BigInt(item.fta) + BigInt(50000000) : BigInt(50000000); // 0.05 TON + forward + + return { + address: jettonWalletAddress, + amount: feeAmount.toString(), + payload: body.toBoc().toString('base64') as Base64String, + }; + } + + /** + * Convert NFT intent item to message + * Builds the NFT transfer message body + */ + private async nftIntentToMessage( + item: SendNftIntentItem, + walletAddress: string, + ): Promise { + const { beginCell, Cell } = await import('@ton/core'); + + // Build NFT transfer body according to TEP-62 + // Note: fp and cp are base64-encoded BoC (Bag of Cells), not raw bytes + const forwardPayloadCell = item.fp ? Cell.fromBase64(item.fp) : null; + const customPayloadCell = item.cp ? Cell.fromBase64(item.cp) : null; + + const body = beginCell() + .storeUint(0x5fcc3d14, 32) // op: transfer + .storeUint(item.qi ?? 0, 64) // query_id + .storeAddress(Address.parse(item.no)) // new_owner + .storeAddress(item.rd ? Address.parse(item.rd) : Address.parse(walletAddress)) // response_destination + .storeMaybeRef(customPayloadCell) // custom_payload (already a Cell) + .storeCoins(BigInt(item.fta ?? '0')) // forward_amount + .storeMaybeRef(forwardPayloadCell) // forward_payload (already a Cell) + .endCell(); + + // Default amount for NFT transfer fee + const feeAmount = BigInt(50000000); // 0.05 TON + + return { + address: item.na, + amount: feeAmount.toString(), + payload: body.toBoc().toString('base64') as Base64String, + }; + } +} diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index e8947d3a6..a7ea10925 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -27,7 +27,19 @@ import type { TONConnectSessionManager } from '../api/interfaces/TONConnectSessi import type { EventRouter } from './EventRouter'; import type { RequestProcessor } from './RequestProcessor'; import { JettonsManager } from './JettonsManager'; +import { IntentHandler } from './IntentHandler'; import type { JettonsAPI } from '../types/jettons'; +import type { + IntentEvent, + TransactionIntentEvent, + SignDataIntentEvent, + ActionIntentEvent, + IntentTransactionResponseSuccess, + IntentSignDataResponseSuccess, + IntentResponseError, + IntentResponse, +} from '../types/intents'; +import type { IntentErrorCode } from './IntentHandler'; import type { RawBridgeEventConnect, RawBridgeEventRestoreConnection, @@ -59,6 +71,7 @@ import type { SignDataApprovalResponse, TONConnectSession, ConnectionApprovalResponse, + ConnectionApprovalProof, } from '../api/models'; import { asAddressFriendly } from '../utils'; @@ -84,6 +97,7 @@ export class TonWalletKit implements ITonWalletKit { // private responseHandler!: ResponseHandler; private networkManager: NetworkManager; private jettonsManager!: JettonsManager; + private intentHandler!: IntentHandler; private initializer: Initializer; private eventProcessor!: StorageEventProcessor; private bridgeManager!: BridgeManager; @@ -215,6 +229,11 @@ export class TonWalletKit implements ITonWalletKit { this.requestProcessor = components.requestProcessor; this.eventProcessor = components.eventProcessor; this.bridgeManager = components.bridgeManager; + + // Initialize IntentHandler after we have all components + this.intentHandler = new IntentHandler(this.walletManager, this.eventEmitter, this.requestProcessor); + // Set bridge manager reference for sending intent responses + this.intentHandler.setBridgeManager(this.bridgeManager); } /** @@ -487,6 +506,14 @@ export class TonWalletKit implements ITonWalletKit { this.eventRouter.removeErrorCallback(); } + onIntentRequest(cb: (event: IntentEvent) => void): void { + this.eventEmitter.on('intent', cb); + } + + removeIntentRequestCallback(cb: (event: IntentEvent) => void): void { + this.eventEmitter.off('intent', cb); + } + // === URL Processing API === /** @@ -543,6 +570,128 @@ export class TonWalletKit implements ITonWalletKit { await this.eventRouter.routeEvent(bridgeEvent); } + // === Intent Processing API === + + /** + * Check if a URL is an intent URL (tc://intent_inline?... or tc://intent?...) + */ + isIntentUrl(args: { url: string }): boolean { + return this.intentHandler?.isIntentUrl(args.url) ?? false; + } + + /** + * Handle an intent URL + * Parses the URL and emits an intent event for the wallet UI + */ + async handleIntentUrl(args: { url: string }): Promise { + await this.ensureInitialized(); + return this.intentHandler.handleIntentUrl(args.url); + } + + /** + * Convert intent items to a transaction request + * Used when approving an intent to build the actual transaction + */ + async intentItemsToTransactionRequest(args: { + event: TransactionIntentEvent; + walletId: string; + }): Promise { + await this.ensureInitialized(); + const wallet = this.getWallet(args.walletId); + if (!wallet) { + throw new Error(`Wallet not found: ${args.walletId}`); + } + return this.intentHandler.intentItemsToTransactionRequest( + args.event.items, + wallet, + args.event.network, + args.event.validUntil, + ); + } + + /** + * Approve a transaction intent (txIntent or signMsg) + * + * For txIntent: Signs and sends the transaction to the blockchain + * For signMsg: Signs but does NOT send (for gasless transactions) + * + * @param args.event - The transaction intent event + * @param args.walletId - The wallet ID to use for signing + * @returns The approval response with signed BoC + */ + async approveTransactionIntent(args: { + event: TransactionIntentEvent; + walletId: string; + }): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveTransactionIntent(args.event, args.walletId); + } + + /** + * Approve a sign data intent (signIntent) + * + * Signs the data and returns the signature. + * + * @param args.event - The sign data intent event + * @param args.walletId - The wallet ID to use for signing + * @returns The approval response with signature + */ + async approveSignDataIntent(args: { + event: SignDataIntentEvent; + walletId: string; + }): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveSignDataIntent(args.event, args.walletId); + } + + /** + * Approve an action intent (actionIntent) + * + * Fetches action details from URL and executes the action. + * + * @param args.event - The action intent event + * @param args.walletId - The wallet ID to use for signing + * @returns The approval response (transaction or sign data) + */ + async approveActionIntent(args: { event: ActionIntentEvent; walletId: string }): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveActionIntent(args.event, args.walletId); + } + + /** + * Process connect request after intent approval + * + * Creates a proper session for the dApp after intent approval. + * + * @param args.event - The intent event with connect request + * @param args.walletId - The wallet to use for the connection + * @param args.proof - Optional proof response + */ + async processConnectAfterIntent(args: { + event: IntentEvent; + walletId: string; + proof?: ConnectionApprovalProof; + }): Promise { + await this.ensureInitialized(); + return this.intentHandler.processConnectAfterIntent(args.event, args.walletId, args.proof); + } + + /** + * Reject an intent request + * + * @param args.event - The intent event to reject + * @param args.reason - Optional rejection reason + * @param args.errorCode - Optional error code (defaults to USER_DECLINED) + * @returns The rejection response + */ + async rejectIntent(args: { + event: IntentEvent; + reason?: string; + errorCode?: IntentErrorCode; + }): Promise { + return this.intentHandler.rejectIntent(args.event, args.reason, args.errorCode); + } + /** * Parse TON Connect URL to extract connection parameters */ diff --git a/packages/walletkit/src/core/wallet/extensions/jetton.ts b/packages/walletkit/src/core/wallet/extensions/jetton.ts index b482d86f5..3c1f9f4fc 100644 --- a/packages/walletkit/src/core/wallet/extensions/jetton.ts +++ b/packages/walletkit/src/core/wallet/extensions/jetton.ts @@ -148,7 +148,11 @@ export class WalletJettonClass implements WalletJettonInterface { ); const parsedStack = ParseStack(result.stack); + // Extract the jetton wallet address from the result + if (!parsedStack || parsedStack.length === 0 || !parsedStack[0]) { + throw new Error('Empty response from jetton master contract - jetton may not exist'); + } const jettonWalletAddress = parsedStack[0].type === 'slice' || parsedStack[0].type === 'cell' ? parsedStack[0].cell.asSlice().loadAddress() diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index ff16607f8..5a7594369 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -103,6 +103,35 @@ export type { AnalyticsAppInfo, AnalyticsManagerOptions } from './analytics'; export { isValidAddress } from './utils/address'; export type { ToncenterEmulationResult } from './utils/toncenterEmulation'; +// Intent types +export { IntentHandler, INTENT_ERROR_CODES } from './core/IntentHandler'; +export type { IntentErrorCode } from './core/IntentHandler'; +export type { + IntentRequest, + IntentMethod, + IntentResponse, + IntentEvent, + TransactionIntentEvent, + SignDataIntentEvent, + ActionIntentEvent, + ParsedIntentUrl, + IntentItem, + SendTonIntentItem, + SendJettonIntentItem, + SendNftIntentItem, + SendTransactionIntentRequest, + SignMessageIntentRequest, + SignDataIntentRequest, + SendActionIntentRequest, + SignDataIntentPayload, + TextSignDataPayload, + BinarySignDataPayload, + CellSignDataPayload, + IntentTransactionResponseSuccess, + IntentSignDataResponseSuccess, + IntentResponseError, +} from './types/intents'; + // API Client types (ApiClient is exported above) export type { TransactionsByAddressRequest, diff --git a/packages/walletkit/src/types/intents.ts b/packages/walletkit/src/types/intents.ts new file mode 100644 index 000000000..ae7c73074 --- /dev/null +++ b/packages/walletkit/src/types/intents.ts @@ -0,0 +1,374 @@ +/** + * 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. + * + */ + +/** + * Intent types for TonConnect deep-link flows. + * + * Intents allow dApps to prepare actions and hand them to wallets + * without requiring a prior TonConnect session. + * + * @see https://github.com/the-ton-tech/ton-connect/blob/feature/instant-transactions/requests-responses.md#intents + */ + +import type { ConnectRequest } from '@tonconnect/protocol'; + +// ============================================================================ +// Intent Item Types (shared across txIntent and signMsg) +// ============================================================================ + +/** + * TON transfer intent item + */ +export interface SendTonIntentItem { + /** Intent type */ + t: 'ton'; + /** Message destination in user-friendly format */ + a: string; + /** Number of nanocoins to send as a decimal string */ + am: string; + /** Raw one-cell BoC encoded in Base64 (payload) */ + p?: string; + /** Raw one-cell BoC encoded in Base64 (stateInit) */ + si?: string; + /** Extra currencies to send with the message */ + ec?: Record; +} + +/** + * Jetton transfer intent item + */ +export interface SendJettonIntentItem { + /** Intent type */ + t: 'jetton'; + /** Jetton master contract address */ + ma: string; + /** Arbitrary request number (query_id) */ + qi?: number; + /** Amount of transferring jettons in elementary units */ + ja: string; + /** Address of the new owner of the jettons (destination) */ + d: string; + /** Response destination address (defaults to user's address) */ + rd?: string; + /** Custom payload BoC (Base64) */ + cp?: string; + /** Forward TON amount in nanotons */ + fta?: string; + /** Forward payload BoC (Base64) */ + fp?: string; +} + +/** + * NFT transfer intent item + */ +export interface SendNftIntentItem { + /** Intent type */ + t: 'nft'; + /** Address of the NFT item to transfer */ + na: string; + /** Arbitrary request number (query_id) */ + qi?: number; + /** Address of the new owner of the NFT */ + no: string; + /** Response destination address (defaults to user's address) */ + rd?: string; + /** Custom payload BoC (Base64) */ + cp?: string; + /** Forward TON amount in nanotons */ + fta?: string; + /** Forward payload BoC (Base64) */ + fp?: string; +} + +/** + * Union type for all intent items + */ +export type IntentItem = SendTonIntentItem | SendJettonIntentItem | SendNftIntentItem; + +// ============================================================================ +// Sign Data Payload Types (for signIntent) +// ============================================================================ + +/** + * Text sign data payload + */ +export interface TextSignDataPayload { + type: 'text'; + text: string; +} + +/** + * Binary sign data payload + */ +export interface BinarySignDataPayload { + type: 'binary'; + bytes: string; // base64 encoded +} + +/** + * Cell sign data payload + */ +export interface CellSignDataPayload { + type: 'cell'; + schema: string; // TL-B schema + cell: string; // base64 encoded BoC +} + +/** + * Union type for sign data payloads + */ +export type SignDataIntentPayload = TextSignDataPayload | BinarySignDataPayload | CellSignDataPayload; + +// ============================================================================ +// Intent Request Types +// ============================================================================ + +/** + * Base interface for all intent requests + */ +export interface BaseIntentRequest { + /** Request ID */ + id: string; + /** Optional connect request to establish connection after intent */ + c?: ConnectRequest; +} + +/** + * Send Transaction Intent request + * Sends the transaction to the blockchain + */ +export interface SendTransactionIntentRequest extends BaseIntentRequest { + /** Intent method */ + m: 'txIntent'; + /** Valid until (unix timestamp) */ + vu?: number; + /** Target network ("-239" for mainnet, "-3" for testnet) */ + n?: string; + /** Ordered list of intent items */ + i: IntentItem[]; +} + +/** + * Sign Message Intent request + * Signs the message but does NOT send to blockchain + * Returns the signed BoC for gasless transactions + */ +export interface SignMessageIntentRequest extends BaseIntentRequest { + /** Intent method */ + m: 'signMsg'; + /** Valid until (unix timestamp) */ + vu?: number; + /** Target network ("-239" for mainnet, "-3" for testnet) */ + n?: string; + /** Ordered list of intent items */ + i: IntentItem[]; +} + +/** + * Sign Data Intent request + */ +export interface SignDataIntentRequest extends BaseIntentRequest { + /** Intent method */ + m: 'signIntent'; + /** Target network */ + n?: string; + /** Manifest URL for domain binding (optional if c.manifestUrl is present) */ + mu?: string; + /** Sign data payload (text, binary, or cell) */ + p: SignDataIntentPayload; +} + +/** + * Send Action Intent request + * Fetches action details from a URL + */ +export interface SendActionIntentRequest extends BaseIntentRequest { + /** Intent method */ + m: 'actionIntent'; + /** Action URL that wallet will call to get action details */ + a: string; +} + +/** + * Union type for all intent requests + */ +export type IntentRequest = + | SendTransactionIntentRequest + | SignMessageIntentRequest + | SignDataIntentRequest + | SendActionIntentRequest; + +/** + * Intent method types + */ +export type IntentMethod = 'txIntent' | 'signMsg' | 'signIntent' | 'actionIntent'; + +// ============================================================================ +// Intent Response Types +// ============================================================================ + +/** + * Success response for transaction/signMessage intents + */ +export interface IntentTransactionResponseSuccess { + /** Signed message BoC (base64 encoded) */ + result: string; + id: string; +} + +/** + * Error response for intents + */ +export interface IntentResponseError { + error: { + code: number; + message: string; + }; + id: string; +} + +/** + * Success response for sign data intent + * Matches spec: SignDataResponseSuccess + */ +export interface IntentSignDataResponseSuccess { + result: { + /** Base64 encoded signature */ + signature: string; + /** Wallet address in raw format (0:hex) */ + address: string; + /** UNIX timestamp in seconds (UTC) */ + timestamp: number; + /** App domain name (as url part, without encoding) */ + domain: string; + /** Payload from the request */ + payload: SignDataIntentPayload; + }; + id: string; +} + +/** + * Union type for intent responses + */ +export type IntentResponse = IntentTransactionResponseSuccess | IntentSignDataResponseSuccess | IntentResponseError; + +// ============================================================================ +// Parsed Intent URL +// ============================================================================ + +/** + * Parsed intent URL parameters + */ +export interface ParsedIntentUrl { + /** Client public key (hex) */ + clientId: string; + /** Intent request payload */ + request: IntentRequest; +} + +// ============================================================================ +// Intent Request Events (for wallet UI) +// ============================================================================ + +/** + * Base intent event with common fields + */ +export interface BaseIntentEvent { + /** Unique event ID */ + id: string; + /** Client public key */ + clientId: string; + /** Whether a connect flow should follow */ + hasConnectRequest: boolean; + /** The raw connect request if present */ + connectRequest?: ConnectRequest; +} + +/** + * Transaction/SignMessage intent event for wallet UI + */ +export interface TransactionIntentEvent extends BaseIntentEvent { + /** Intent type */ + type: 'txIntent' | 'signMsg'; + /** Network chain ID */ + network?: string; + /** Valid until timestamp */ + validUntil?: number; + /** Intent items to display */ + items: IntentItem[]; +} + +/** + * Sign data intent event for wallet UI + */ +export interface SignDataIntentEvent extends BaseIntentEvent { + /** Intent type */ + type: 'signIntent'; + /** Network chain ID */ + network?: string; + /** Manifest URL */ + manifestUrl: string; + /** Sign data payload */ + payload: SignDataIntentPayload; +} + +/** + * Action intent event for wallet UI + */ +export interface ActionIntentEvent extends BaseIntentEvent { + /** Intent type */ + type: 'actionIntent'; + /** Action URL */ + actionUrl: string; +} + +/** + * Union type for all intent events + */ +export type IntentEvent = TransactionIntentEvent | SignDataIntentEvent | ActionIntentEvent; + +// ============================================================================ +// Action URL Response Types (for actionIntent) +// ============================================================================ + +/** + * Message structure for sendTransaction action + */ +export interface ActionTransactionMessage { + address: string; + amount: string; + payload?: string; + stateInit?: string; + extra_currency?: Record; +} + +/** + * Send transaction action details + */ +export interface SendTransactionAction { + valid_until?: number; + network?: string; + from?: string; + messages: ActionTransactionMessage[]; +} + +/** + * Sign data action (uses same payload types as signIntent) + */ +export type SignDataAction = SignDataIntentPayload & { + network?: string; + from?: string; +}; + +/** + * Response from action URL + */ +export interface ActionUrlResponse { + action_type: 'sendTransaction' | 'signData'; + action: SendTransactionAction | SignDataAction; +} diff --git a/packages/walletkit/src/types/kit.ts b/packages/walletkit/src/types/kit.ts index 1e77674e7..840f6d863 100644 --- a/packages/walletkit/src/types/kit.ts +++ b/packages/walletkit/src/types/kit.ts @@ -13,6 +13,7 @@ import type { CONNECT_EVENT_ERROR_CODES, SendTransactionRpcResponseError } from import type { JettonsAPI } from './jettons'; import type { ApiClient } from './toncenter/ApiClient'; import type { Wallet, WalletAdapter } from '../api/interfaces'; +import type { IntentEvent, TransactionIntentEvent } from './intents'; import type { Network } from '../api/models/core/Network'; import type { WalletId } from '../utils/walletId'; import type { @@ -79,9 +80,21 @@ export interface ITonWalletKit { /** Handle pasted TON Connect URL/link */ handleTonConnectUrl(url: string): Promise; + /** Handle intent URL (tc://intent_inline?...) */ + handleIntentUrl(args: { url: string }): Promise; + + /** Check if URL is an intent URL */ + isIntentUrl(args: { url: string }): boolean; + /** Handle new transaction */ handleNewTransaction(wallet: Wallet, data: TransactionRequest): Promise; + /** Convert intent items to transaction request */ + intentItemsToTransactionRequest(args: { + event: TransactionIntentEvent; + walletId: string; + }): Promise; + // === Request Processing === /** Approve a connect request */ @@ -131,6 +144,9 @@ export interface ITonWalletKit { /** Register error handler */ onRequestError(cb: (event: RequestErrorEvent) => void): void; + /** Register intent request handler */ + onIntentRequest(cb: (event: IntentEvent) => void): void; + /** Remove request handlers */ removeConnectRequestCallback(cb: (event: ConnectionRequestEvent) => void): void; removeTransactionRequestCallback(cb: (event: SendTransactionRequestEvent) => void): void; diff --git a/packages/walletkit/src/utils/tvmStack.ts b/packages/walletkit/src/utils/tvmStack.ts index d480b0c53..015d54487 100644 --- a/packages/walletkit/src/utils/tvmStack.ts +++ b/packages/walletkit/src/utils/tvmStack.ts @@ -24,6 +24,12 @@ function ParseStackItem(item: RawStackItem): TupleItem { return { type: 'null' }; case 'cell': return { type: 'cell', cell: Cell.fromBoc(Buffer.from(item.value, 'base64'))[0] }; + case 'slice': + // Slice is returned as base64 BOC - parse it as a slice + return { type: 'slice', cell: Cell.fromBoc(Buffer.from(item.value, 'base64'))[0] }; + case 'builder': + // Builder is returned as base64 BOC - parse it as a builder + return { type: 'builder', cell: Cell.fromBoc(Buffer.from(item.value, 'base64'))[0] }; case 'tuple': case 'list': if (item.value.length === 0) {