From 2b5c65d5d5bea9d3af586bc499d2aeed6c7a70d3 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 23 Feb 2026 14:29:55 +0530 Subject: [PATCH 01/15] feat: implement intent API for WalletKit Add intent URL parsing, resolution, and handling pipeline: Models: - IntentActionItem: SendTon, SendJetton, SendNft action types - IntentRequestEvent: Transaction, SignData, Action intent events - IntentResponse: Transaction, SignData, and error response types - BatchedIntentEvent: Multiple intents in a single event Handlers: - IntentParser: URL parsing, validation, wire-to-model mapping - IntentResolver: Action items to TransactionRequest conversion, action URL fetching with jetton/NFT message building - IntentHandler: Orchestrator with parse-resolve-emulate-emit flow, approval/rejection methods, pending connect request management Integration: - TonWalletKit: 10 public intent API methods - BridgeManager: sendIntentResponse with ephemeral SessionCrypto - Android bridge: Full intent API surface with event listeners --- .../src/api/eventListeners.ts | 28 +- .../walletkit-android-bridge/src/api/index.ts | 11 + .../src/api/initialization.ts | 18 + .../src/api/intents.ts | 63 +++ .../walletkit-android-bridge/src/types/api.ts | 72 +++ .../src/types/events.ts | 1 + packages/walletkit/src/api/models/index.ts | 21 + .../api/models/intents/BatchedIntentEvent.ts | 31 ++ .../api/models/intents/IntentActionItem.ts | 78 +++ .../api/models/intents/IntentRequestEvent.ts | 102 ++++ .../src/api/models/intents/IntentResponse.ts | 61 +++ packages/walletkit/src/core/BridgeManager.ts | 29 ++ packages/walletkit/src/core/TonWalletKit.ts | 101 ++++ .../walletkit/src/handlers/IntentHandler.ts | 265 +++++++++++ .../walletkit/src/handlers/IntentParser.ts | 445 ++++++++++++++++++ .../walletkit/src/handlers/IntentResolver.ts | 163 +++++++ packages/walletkit/src/index.ts | 4 + 17 files changed, 1488 insertions(+), 5 deletions(-) create mode 100644 packages/walletkit-android-bridge/src/api/intents.ts create mode 100644 packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts create mode 100644 packages/walletkit/src/api/models/intents/IntentActionItem.ts create mode 100644 packages/walletkit/src/api/models/intents/IntentRequestEvent.ts create mode 100644 packages/walletkit/src/api/models/intents/IntentResponse.ts create mode 100644 packages/walletkit/src/handlers/IntentHandler.ts create mode 100644 packages/walletkit/src/handlers/IntentParser.ts create mode 100644 packages/walletkit/src/handlers/IntentResolver.ts diff --git a/packages/walletkit-android-bridge/src/api/eventListeners.ts b/packages/walletkit-android-bridge/src/api/eventListeners.ts index 08414c752..8b0b14ff5 100644 --- a/packages/walletkit-android-bridge/src/api/eventListeners.ts +++ b/packages/walletkit-android-bridge/src/api/eventListeners.ts @@ -12,13 +12,30 @@ import type { RequestErrorEvent, SendTransactionRequestEvent, SignDataRequestEvent, + IntentRequestEvent, + BatchedIntentEvent, } from '@ton/walletkit'; -type ConnectEventListener = ((event: ConnectionRequestEvent) => void) | null; -type TransactionEventListener = ((event: SendTransactionRequestEvent) => void) | null; -type SignDataEventListener = ((event: SignDataRequestEvent) => void) | null; -type DisconnectEventListener = ((event: DisconnectionEvent) => void) | null; -type ErrorEventListener = ((event: RequestErrorEvent) => void) | null; +/** + * Shared event listener references used to manage WalletKit callbacks. + */ +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: IntentRequestEvent | BatchedIntentEvent) => void) | null; + +/** + * Union type for all bridge event listeners. + */ +export type BridgeEventListener = + | ConnectEventListener + | TransactionEventListener + | SignDataEventListener + | DisconnectEventListener + | ErrorEventListener + | IntentEventListener; export const eventListeners = { onConnectListener: null as ConnectEventListener, @@ -26,4 +43,5 @@ export const eventListeners = { 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 ad0f16fe5..d083403dd 100644 --- a/packages/walletkit-android-bridge/src/api/index.ts +++ b/packages/walletkit-android-bridge/src/api/index.ts @@ -19,6 +19,7 @@ import * as tonconnect from './tonconnect'; import * as nft from './nft'; import * as jettons from './jettons'; import * as browser from './browser'; +import * as intents from './intents'; import { eventListeners } from './eventListeners'; export { eventListeners }; @@ -89,4 +90,14 @@ export const api: WalletKitBridgeApi = { emitBrowserPageFinished: browser.emitBrowserPageFinished, emitBrowserError: browser.emitBrowserError, emitBrowserBridgeRequest: browser.emitBrowserBridgeRequest, + + // Intents + isIntentUrl: intents.isIntentUrl, + handleIntentUrl: intents.handleIntentUrl, + approveTransactionIntent: intents.approveTransactionIntent, + approveSignDataIntent: intents.approveSignDataIntent, + approveActionIntent: intents.approveActionIntent, + rejectIntent: intents.rejectIntent, + intentItemsToTransactionRequest: intents.intentItemsToTransactionRequest, + processConnectAfterIntent: intents.processConnectAfterIntent, } as unknown as WalletKitBridgeApi; diff --git a/packages/walletkit-android-bridge/src/api/initialization.ts b/packages/walletkit-android-bridge/src/api/initialization.ts index 0266f3772..0daad62b6 100644 --- a/packages/walletkit-android-bridge/src/api/initialization.ts +++ b/packages/walletkit-android-bridge/src/api/initialization.ts @@ -18,6 +18,8 @@ import type { RequestErrorEvent, SendTransactionRequestEvent, SignDataRequestEvent, + IntentRequestEvent, + BatchedIntentEvent, } from '@ton/walletkit'; import type { WalletKitBridgeInitConfig, SetEventsListenersArgs, WalletKitBridgeEventCallback } from '../types'; @@ -105,6 +107,17 @@ export async function setEventsListeners(args?: SetEventsListenersArgs): Promise kit.onRequestError(eventListeners.onErrorListener); + // Register intent listener + if (eventListeners.onIntentListener) { + kit.removeIntentRequestCallback(eventListeners.onIntentListener); + } + + eventListeners.onIntentListener = (event: IntentRequestEvent | BatchedIntentEvent) => { + callback('intentRequest', event); + }; + + kit.onIntentRequest(eventListeners.onIntentListener); + return { ok: true }; } @@ -139,5 +152,10 @@ export async function removeEventListeners(): Promise<{ ok: true }> { eventListeners.onErrorListener = null; } + if (eventListeners.onIntentListener) { + kit.removeIntentRequestCallback(eventListeners.onIntentListener); + 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..b9bc8b91d --- /dev/null +++ b/packages/walletkit-android-bridge/src/api/intents.ts @@ -0,0 +1,63 @@ +/** + * 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 – Bridge API for intent operations + */ + +import { getKit } from '../utils/bridge'; +import type { + HandleIntentUrlArgs, + IsIntentUrlArgs, + ApproveTransactionIntentArgs, + ApproveSignDataIntentArgs, + ApproveActionIntentArgs, + RejectIntentArgs, + IntentItemsToTransactionRequestArgs, + ProcessConnectAfterIntentArgs, +} from '../types'; + +export async function isIntentUrl(args: IsIntentUrlArgs) { + const kit = await getKit(); + return kit.isIntentUrl(args.url); +} + +export async function handleIntentUrl(args: HandleIntentUrlArgs) { + const kit = await getKit(); + return kit.handleIntentUrl(args.url, args.walletId); +} + +export async function approveTransactionIntent(args: ApproveTransactionIntentArgs) { + const kit = await getKit(); + return kit.approveTransactionIntent(args.event, args.walletId); +} + +export async function approveSignDataIntent(args: ApproveSignDataIntentArgs) { + const kit = await getKit(); + return kit.approveSignDataIntent(args.event, args.walletId); +} + +export async function approveActionIntent(args: ApproveActionIntentArgs) { + const kit = await getKit(); + return kit.approveActionIntent(args.event, args.walletId); +} + +export async function rejectIntent(args: RejectIntentArgs) { + const kit = await getKit(); + return kit.rejectIntent(args.event, args.reason, args.errorCode); +} + +export async function intentItemsToTransactionRequest(args: IntentItemsToTransactionRequestArgs) { + const kit = await getKit(); + return kit.intentItemsToTransactionRequest(args.items, args.walletId); +} + +export async function processConnectAfterIntent(args: ProcessConnectAfterIntentArgs) { + const kit = await getKit(); + return kit.processConnectAfterIntent(args.event, args.walletId, args.proof); +} diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index a76086ccc..4705e15b9 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -23,6 +23,16 @@ import type { TransactionRequest, Wallet, WalletResponse, + IntentRequestEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentActionItem, + BatchedIntentEvent, + ConnectionApprovalProof, } from '@ton/walletkit'; /** @@ -260,6 +270,57 @@ export interface HandleTonConnectUrlArgs { url: string; } +// === Intent Args === + +export interface HandleIntentUrlArgs { + url: string; + walletId: string; +} + +export interface IsIntentUrlArgs { + url: string; +} + +export interface ApproveTransactionIntentArgs { + event: TransactionIntentRequestEvent; + walletId: string; +} + +export interface ApproveSignDataIntentArgs { + event: SignDataIntentRequestEvent; + walletId: string; +} + +export interface ApproveActionIntentArgs { + event: ActionIntentRequestEvent; + walletId: string; +} + +export interface RejectIntentArgs { + event: IntentRequestEvent; + reason?: string; + errorCode?: number; +} + +export interface IntentItemsToTransactionRequestArgs { + items: IntentActionItem[]; + walletId: string; +} + +export interface ProcessConnectAfterIntentArgs { + event: IntentRequestEvent | BatchedIntentEvent; + walletId: string; + proof?: ConnectionApprovalProof; +} + +export interface WalletDescriptor { + address: string; + publicKey: string; + version: string; + index: number; + network: string; +} + export interface WalletKitBridgeApi { init(config?: WalletKitBridgeInitConfig): PromiseOrValue<{ ok: true }>; setEventsListeners(args?: SetEventsListenersArgs): PromiseOrValue<{ ok: true }>; @@ -311,4 +372,15 @@ export interface WalletKitBridgeApi { emitBrowserPageFinished(args: EmitBrowserPageArgs): PromiseOrValue<{ success: boolean }>; emitBrowserError(args: EmitBrowserErrorArgs): PromiseOrValue<{ success: boolean }>; emitBrowserBridgeRequest(args: EmitBrowserBridgeRequestArgs): PromiseOrValue<{ success: boolean }>; + // Intent API + isIntentUrl(args: IsIntentUrlArgs): PromiseOrValue; + handleIntentUrl(args: HandleIntentUrlArgs): PromiseOrValue; + approveTransactionIntent(args: ApproveTransactionIntentArgs): PromiseOrValue; + approveSignDataIntent(args: ApproveSignDataIntentArgs): PromiseOrValue; + approveActionIntent( + args: ApproveActionIntentArgs, + ): PromiseOrValue; + rejectIntent(args: RejectIntentArgs): PromiseOrValue; + intentItemsToTransactionRequest(args: IntentItemsToTransactionRequestArgs): PromiseOrValue; + processConnectAfterIntent(args: ProcessConnectAfterIntentArgs): PromiseOrValue; } diff --git a/packages/walletkit-android-bridge/src/types/events.ts b/packages/walletkit-android-bridge/src/types/events.ts index d8af5d678..c2efbd341 100644 --- a/packages/walletkit-android-bridge/src/types/events.ts +++ b/packages/walletkit-android-bridge/src/types/events.ts @@ -16,6 +16,7 @@ export type WalletKitBridgeEventType = | 'signDataRequest' | 'disconnect' | 'requestError' + | 'intentRequest' | 'browserPageStarted' | 'browserPageFinished' | 'browserError' diff --git a/packages/walletkit/src/api/models/index.ts b/packages/walletkit/src/api/models/index.ts index 643b3b04a..95997b82a 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -92,5 +92,26 @@ export type { TransactionTraceMoneyFlowItem, } from './transactions/TransactionTraceMoneyFlow'; +// Intent models +export type { SendTonAction, SendJettonAction, SendNftAction, IntentActionItem } from './intents/IntentActionItem'; +export type { + IntentOrigin, + IntentDeliveryMode, + IntentRequestBase, + TransactionIntentRequestEvent, + TransactionIntentPreview, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentRequestEvent, +} from './intents/IntentRequestEvent'; +export type { + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentError, + IntentResponseResult, +} from './intents/IntentResponse'; +export type { BatchedIntentEvent } from './intents/BatchedIntentEvent'; + // RPC models export type { GetMethodResult } from './rpc/GetMethodResult'; diff --git a/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts new file mode 100644 index 000000000..ad3d7bb0e --- /dev/null +++ b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { BridgeEvent } from '../bridge/BridgeEvent'; +import type { IntentRequestEvent, IntentOrigin } from './IntentRequestEvent'; + +/** + * A batched intent event containing multiple intent items + * that should be processed as a group. + * + * Use cases: + * - send TON + connect (intent with connect request) + * - action intent that resolves to multiple steps + */ +export interface BatchedIntentEvent extends BridgeEvent { + /** Fixed identifier for batched events */ + eventType: 'batchedIntent'; + /** How the batch reached the wallet */ + origin: IntentOrigin; + /** Client public key for response routing */ + clientId?: string; + /** The intent requests in this batch */ + intents: IntentRequestEvent[]; + /** Whether a connect flow should follow after all intents are approved */ + hasConnectRequest: boolean; +} diff --git a/packages/walletkit/src/api/models/intents/IntentActionItem.ts b/packages/walletkit/src/api/models/intents/IntentActionItem.ts new file mode 100644 index 000000000..f6f3043be --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentActionItem.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Base64String } from '../core/Primitives'; + +/** + * TON native coin transfer action. + */ +export interface SendTonAction { + /** Action type discriminator */ + type: 'sendTon'; + /** Destination address (user-friendly) */ + address: string; + /** Amount in nanotons */ + amount: string; + /** Cell payload (Base64 BoC) */ + payload?: Base64String; + /** Contract deploy stateInit (Base64 BoC) */ + stateInit?: Base64String; + /** Extra currencies */ + extraCurrency?: Record; +} + +/** + * Jetton transfer action (TEP-74). + */ +export interface SendJettonAction { + /** Action type discriminator */ + type: 'sendJetton'; + /** Jetton master contract address */ + jettonMasterAddress: string; + /** Transfer amount in jetton elementary units */ + jettonAmount: string; + /** Recipient address */ + destination: string; + /** Response destination (defaults to sender) */ + responseDestination?: string; + /** Custom payload (Base64 BoC) */ + customPayload?: Base64String; + /** Forward TON amount (nanotons) */ + forwardTonAmount?: string; + /** Forward payload (Base64 BoC) */ + forwardPayload?: Base64String; + /** Query ID */ + queryId?: number; +} + +/** + * NFT transfer action (TEP-62). + */ +export interface SendNftAction { + /** Action type discriminator */ + type: 'sendNft'; + /** NFT item address */ + nftAddress: string; + /** New owner address */ + newOwnerAddress: string; + /** Response destination (defaults to sender) */ + responseDestination?: string; + /** Custom payload (Base64 BoC) */ + customPayload?: Base64String; + /** Forward TON amount (nanotons) */ + forwardTonAmount?: string; + /** Forward payload (Base64 BoC) */ + forwardPayload?: Base64String; + /** Query ID */ + queryId?: number; +} + +/** + * Union of all intent action items, discriminated by `type`. + */ +export type IntentActionItem = SendTonAction | SendJettonAction | SendNftAction; diff --git a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts new file mode 100644 index 000000000..5d396ac42 --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { BridgeEvent } from '../bridge/BridgeEvent'; +import type { TransactionRequest } from '../transactions/TransactionRequest'; +import type { TransactionEmulatedPreview } from '../transactions/emulation/TransactionEmulatedPreview'; +import type { SignDataPayload } from '../core/PreparedSignData'; +import type { DAppInfo } from '../core/DAppInfo'; +import type { IntentActionItem } from './IntentActionItem'; + +/** + * Origin of the intent request. + */ +export type IntentOrigin = 'deepLink' | 'bridge' | 'jsBridge'; + +/** + * Delivery mode for the signed transaction. + */ +export type IntentDeliveryMode = 'send' | 'signOnly'; + +/** + * Base fields common to all intent request events. + */ +export interface IntentRequestBase extends BridgeEvent { + /** How the request reached the wallet */ + origin: IntentOrigin; + /** Client public key (for response encryption) */ + clientId?: string; + /** Whether a connect flow should follow after intent approval */ + hasConnectRequest: boolean; +} + +/** + * Transaction intent request event. + * + * Covers both `txIntent` (send) and `signMsg` (signOnly) from the spec. + * The `deliveryMode` field distinguishes them. + */ +export interface TransactionIntentRequestEvent extends IntentRequestBase { + /** Event type discriminator */ + intentType: 'transaction'; + /** Whether to send on-chain or return signed BoC */ + deliveryMode: IntentDeliveryMode; + /** Network chain ID ("-239" = mainnet, "-3" = testnet) */ + network?: string; + /** Transaction validity deadline (unix timestamp) */ + validUntil?: number; + /** Original intent action items (for display / re-conversion) */ + items: IntentActionItem[]; + /** Resolved transaction request (items converted to messages) */ + resolvedTransaction?: TransactionRequest; + /** Emulated preview for display */ + preview?: TransactionIntentPreview; +} + +/** + * Preview data for transaction intent. + */ +export interface TransactionIntentPreview { + /** Emulated transaction data */ + data?: TransactionEmulatedPreview; +} + +/** + * Sign data intent request event. + */ +export interface SignDataIntentRequestEvent extends IntentRequestBase { + /** Event type discriminator */ + intentType: 'signData'; + /** Network chain ID */ + network?: string; + /** Manifest URL (for domain binding) */ + manifestUrl: string; + /** The data to sign */ + payload: SignDataPayload; + /** dApp information resolved from manifest */ + dAppInfo?: DAppInfo; +} + +/** + * Action intent request event. + * + * The wallet fetches the action URL, which returns either a transaction + * or sign-data action. This is an intermediate step before resolving + * to a TransactionIntentRequestEvent or SignDataIntentRequestEvent. + */ +export interface ActionIntentRequestEvent extends IntentRequestBase { + /** Event type discriminator */ + intentType: 'action'; + /** Action URL to fetch */ + actionUrl: string; +} + +/** + * Union of all intent request events, discriminated by `intentType`. + */ +export type IntentRequestEvent = TransactionIntentRequestEvent | SignDataIntentRequestEvent | ActionIntentRequestEvent; diff --git a/packages/walletkit/src/api/models/intents/IntentResponse.ts b/packages/walletkit/src/api/models/intents/IntentResponse.ts new file mode 100644 index 000000000..ac21cd905 --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentResponse.ts @@ -0,0 +1,61 @@ +/** + * 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. + * + */ + +/** + * Successful response for transaction intent. + */ +export interface IntentTransactionResponse { + /** Result type discriminator */ + resultType: 'transaction'; + /** Signed BoC (base64) */ + boc: string; +} + +/** + * Successful response for sign data intent. + */ +export interface IntentSignDataResponse { + /** Result type discriminator */ + resultType: 'signData'; + /** Signature (base64) */ + signature: string; + /** Signer address (raw format: 0:hex) */ + address: string; + /** UNIX timestamp (seconds, UTC) */ + timestamp: number; + /** App domain */ + domain: string; +} + +/** + * Error response for any intent. + */ +export interface IntentErrorResponse { + /** Result type discriminator */ + resultType: 'error'; + /** Error details */ + error: IntentError; +} + +/** + * Intent error details. + */ +export interface IntentError { + /** + * Error code + * @format int + */ + code: number; + /** Human-readable message */ + message: string; +} + +/** + * Union of all intent responses. + */ +export type IntentResponseResult = IntentTransactionResponse | IntentSignDataResponse | IntentErrorResponse; diff --git a/packages/walletkit/src/core/BridgeManager.ts b/packages/walletkit/src/core/BridgeManager.ts index 70b1beb5b..1450c8f9a 100644 --- a/packages/walletkit/src/core/BridgeManager.ts +++ b/packages/walletkit/src/core/BridgeManager.ts @@ -259,6 +259,35 @@ export class BridgeManager { } } + /** + * Send an intent response to a client identified by clientId. + * Creates a new ephemeral SessionCrypto for one-way intent responses. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async sendIntentResponse(clientId: string, response: any): Promise { + if (!this.bridgeProvider) { + throw new WalletKitError( + ERROR_CODES.BRIDGE_NOT_INITIALIZED, + 'Bridge not initialized for sending intent response', + ); + } + + const sessionCrypto = new SessionCrypto(); + + try { + await this.bridgeProvider.send(response, sessionCrypto, clientId, {}); + log.debug('Intent response sent', { clientId }); + } catch (error) { + log.error('Failed to send intent response', { clientId, 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/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 2f7eb742a..8841eb2c4 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -49,6 +49,7 @@ import type { NetworkManager } from './NetworkManager'; import { KitNetworkManager } from './NetworkManager'; import type { WalletId } from '../utils/walletId'; import type { Wallet, WalletAdapter } from '../api/interfaces'; +import { IntentHandler } from '../handlers/IntentHandler'; import type { Network, TransactionRequest, @@ -62,6 +63,16 @@ import type { SignDataApprovalResponse, TONConnectSession, ConnectionApprovalResponse, + IntentRequestEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentActionItem, + BatchedIntentEvent, + ConnectionApprovalProof, } from '../api/models'; import { asAddressFriendly } from '../utils'; @@ -91,6 +102,7 @@ export class TonWalletKit implements ITonWalletKit { private initializer: Initializer; private eventProcessor!: StorageEventProcessor; private bridgeManager!: BridgeManager; + private intentHandler!: IntentHandler; private config: TonWalletKitOptions; @@ -250,6 +262,7 @@ export class TonWalletKit implements ITonWalletKit { this.requestProcessor = components.requestProcessor; this.eventProcessor = components.eventProcessor; this.bridgeManager = components.bridgeManager; + this.intentHandler = new IntentHandler(this.config, this.bridgeManager, this.walletManager); } /** @@ -522,6 +535,94 @@ export class TonWalletKit implements ITonWalletKit { this.eventRouter.removeErrorCallback(); } + // === Intent API === + + isIntentUrl(url: string): boolean { + return this.intentHandler?.isIntentUrl(url) ?? false; + } + + async handleIntentUrl(url: string, walletId: string): Promise { + await this.ensureInitialized(); + return this.intentHandler.handleIntentUrl(url, walletId); + } + + onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void { + if (this.intentHandler) { + this.intentHandler.onIntentRequest(cb); + } else { + this.ensureInitialized().then(() => { + this.intentHandler.onIntentRequest(cb); + }); + } + } + + removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void { + this.intentHandler.removeIntentRequestCallback(cb); + } + + async approveTransactionIntent( + event: TransactionIntentRequestEvent, + walletId: string, + ): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveTransactionIntent(event, walletId); + } + + async approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveSignDataIntent(event, walletId); + } + + async approveActionIntent( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveActionIntent(event, walletId); + } + + async rejectIntent(event: IntentRequestEvent, reason?: string, errorCode?: number): Promise { + await this.ensureInitialized(); + return this.intentHandler.rejectIntent(event, reason, errorCode); + } + + async intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise { + await this.ensureInitialized(); + return this.intentHandler.intentItemsToTransactionRequest(items, walletId); + } + + async processConnectAfterIntent( + event: IntentRequestEvent | BatchedIntentEvent, + _walletId: string, + _proof?: ConnectionApprovalProof, + ): Promise { + await this.ensureInitialized(); + + const eventId = event.id; + const connectRequest = this.intentHandler.getPendingConnectRequest(eventId); + if (!connectRequest) { + log.warn('No pending connect request for intent', { eventId }); + return; + } + + this.intentHandler.removePendingConnectRequest(eventId); + + // Create a bridge event from the connect request and route it through the connect flow + const bridgeEvent: RawBridgeEventConnect = { + from: event.clientId || '', + id: eventId, + method: 'connect', + params: { + manifest: { url: connectRequest.manifestUrl }, + items: connectRequest.items, + }, + timestamp: Date.now(), + domain: '', + }; + + await this.eventRouter.routeEvent(bridgeEvent); + } + // === URL Processing API === /** diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts new file mode 100644 index 000000000..e0f2e22d8 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -0,0 +1,265 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { Address } from '@ton/core'; +import type { ConnectRequest } from '@tonconnect/protocol'; + +import { globalLogger } from '../core/Logger'; +import { WalletKitError, ERROR_CODES } from '../errors'; +import { CallForSuccess } from '../utils/retry'; +import { PrepareSignData } from '../utils/signData/sign'; +import { HexToBase64 } from '../utils/base64'; +import { IntentParser, INTENT_ERROR_CODES } from './IntentParser'; +import { IntentResolver } from './IntentResolver'; +import type { BridgeManager } from '../core/BridgeManager'; +import type { WalletManager } from '../core/WalletManager'; +import type { Wallet } from '../api/interfaces'; +import type { + IntentRequestEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentResponseResult, + IntentActionItem, + BatchedIntentEvent, + TransactionRequest, +} from '../api/models'; +import type { TonWalletKitOptions } from '../types'; + +const log = globalLogger.createChild('IntentHandler'); + +type IntentCallback = (event: IntentRequestEvent | BatchedIntentEvent) => void; + +/** + * Orchestrates intent processing: parse → resolve → emulate → emit. + * + * Delegates URL parsing to IntentParser, item resolution to IntentResolver, + * and reuses existing wallet signing/sending utilities for approval. + */ +export class IntentHandler { + private parser = new IntentParser(); + private resolver = new IntentResolver(); + private callbacks: IntentCallback[] = []; + private pendingConnectRequests = new Map(); + + constructor( + private walletKitOptions: TonWalletKitOptions, + private bridgeManager: BridgeManager, + private walletManager: WalletManager, + ) {} + + // -- Public: Parsing ------------------------------------------------------ + + isIntentUrl(url: string): boolean { + return this.parser.isIntentUrl(url); + } + + /** + * Parse an intent URL, resolve items, emulate preview, and emit the event. + */ + async handleIntentUrl(url: string, walletId: string): Promise { + const { event, connectRequest } = this.parser.parse(url); + + if (connectRequest) { + this.pendingConnectRequests.set(event.id, connectRequest); + } + + if (event.intentType === 'transaction') { + await this.resolveAndEmitTransaction(event, walletId); + } else { + this.emit(event); + } + } + + // -- Public: Callbacks ---------------------------------------------------- + + onIntentRequest(callback: IntentCallback): void { + this.callbacks.push(callback); + } + + removeIntentRequestCallback(callback: IntentCallback): void { + this.callbacks = this.callbacks.filter((cb) => cb !== callback); + } + + // -- Public: Approval ----------------------------------------------------- + + async approveTransactionIntent( + event: TransactionIntentRequestEvent, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + + const transactionRequest = event.resolvedTransaction ?? (await this.resolveTransaction(event, wallet)); + + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest); + + if (event.deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { + await CallForSuccess(() => wallet.getClient().sendBoc(signedBoc)); + } + + const result: IntentTransactionResponse = { + resultType: 'transaction', + boc: signedBoc, + }; + + await this.sendResponse(event, result); + return result; + } + + async approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise { + const wallet = this.getWallet(walletId); + + let domain = event.manifestUrl; + try { + domain = new URL(event.manifestUrl).host; + } catch { + // use as-is + } + + const signData = PrepareSignData({ + payload: event.payload, + domain, + address: wallet.getAddress(), + }); + + const signature = await wallet.getSignedSignData(signData); + const signatureBase64 = HexToBase64(signature); + + const result: IntentSignDataResponse = { + resultType: 'signData', + signature: signatureBase64, + address: Address.parse(wallet.getAddress()).toRawString(), + timestamp: signData.timestamp, + domain: signData.domain, + }; + + await this.sendResponse(event, result); + return result; + } + + async approveActionIntent( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + + const actionResponse = await this.resolver.fetchActionUrl(event.actionUrl, wallet.getAddress()); + const resolvedEvent = this.parser.parseActionResponse(actionResponse, event); + + if (resolvedEvent.intentType === 'transaction') { + return this.approveTransactionIntent(resolvedEvent, walletId); + } else if (resolvedEvent.intentType === 'signData') { + return this.approveSignDataIntent(resolvedEvent, walletId); + } + + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Action URL resolved to unsupported intent type: ${resolvedEvent.intentType}`, + ); + } + + // -- Public: Rejection ---------------------------------------------------- + + async rejectIntent(event: IntentRequestEvent, reason?: string, errorCode?: number): Promise { + const result: IntentErrorResponse = { + resultType: 'error', + error: { + code: errorCode ?? INTENT_ERROR_CODES.USER_DECLINED, + message: reason || 'User declined the request', + }, + }; + + await this.sendResponse(event, result); + this.pendingConnectRequests.delete(event.id); + return result; + } + + // -- Public: Utilities ---------------------------------------------------- + + async intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise { + const wallet = this.getWallet(walletId); + return this.resolver.intentItemsToTransactionRequest(items, wallet); + } + + getPendingConnectRequest(eventId: string): ConnectRequest | undefined { + return this.pendingConnectRequests.get(eventId); + } + + removePendingConnectRequest(eventId: string): void { + this.pendingConnectRequests.delete(eventId); + } + + // -- Private: Resolution & Emulation -------------------------------------- + + private async resolveAndEmitTransaction(event: TransactionIntentRequestEvent, walletId: string): Promise { + const wallet = this.getWallet(walletId); + + const transactionRequest = await this.resolveTransaction(event, wallet); + event.resolvedTransaction = transactionRequest; + + try { + const preview = await wallet.getTransactionPreview(transactionRequest); + event.preview = { data: preview }; + } catch (error) { + log.warn('Failed to emulate transaction preview', { error }); + event.preview = { data: undefined }; + } + + this.emit(event); + } + + private async resolveTransaction( + event: TransactionIntentRequestEvent, + wallet: Wallet, + ): Promise { + return this.resolver.intentItemsToTransactionRequest(event.items, wallet, event.network, event.validUntil); + } + + // -- Private: Response sending -------------------------------------------- + + private async sendResponse(event: IntentRequestEvent, result: IntentResponseResult): Promise { + if (!event.clientId) { + log.debug('No clientId on intent event, skipping response send'); + return; + } + + try { + await this.bridgeManager.sendIntentResponse(event.clientId, result); + } catch (error) { + log.error('Failed to send intent response', { error, eventId: event.id }); + } + } + + // -- Private: Helpers ----------------------------------------------------- + + private getWallet(walletId: string): Wallet { + const wallet = this.walletManager.getWallet(walletId); + if (!wallet) { + throw new WalletKitError( + ERROR_CODES.WALLET_NOT_FOUND, + 'Wallet not found for intent processing', + undefined, + { walletId }, + ); + } + return wallet; + } + + private emit(event: IntentRequestEvent | BatchedIntentEvent): void { + for (const callback of this.callbacks) { + try { + callback(event); + } catch (error) { + log.error('Intent callback error', { error }); + } + } + } +} diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts new file mode 100644 index 000000000..c9d7200fb --- /dev/null +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -0,0 +1,445 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { ConnectRequest } from '@tonconnect/protocol'; + +import { WalletKitError, ERROR_CODES } from '../errors'; +import type { + IntentActionItem, + IntentRequestEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentDeliveryMode, + SignDataPayload, + SignData, + Base64String, +} from '../api/models'; + +const INTENT_INLINE_SCHEME = 'tc://intent_inline'; +const INTENT_SCHEME = 'tc://intent'; + +/** + * Wire-format intent method identifiers from the TonConnect spec. + */ +type WireIntentMethod = 'txIntent' | 'signMsg' | 'signIntent' | 'actionIntent'; + +const VALID_METHODS: WireIntentMethod[] = ['txIntent', 'signMsg', 'signIntent', 'actionIntent']; + +/** + * Wire-format intent item types. + */ +type WireItemType = 'ton' | 'jetton' | 'nft'; + +/** + * Wire-format intent item (short field names from spec). + */ +interface WireIntentItem { + t: WireItemType; + // ton + a?: string; + am?: string; + p?: string; + si?: string; + ec?: Record; + // jetton + ma?: string; + ja?: string; + d?: string; + rd?: string; + cp?: string; + fta?: string; + fp?: string; + qi?: number; + // nft + na?: string; + no?: string; +} + +/** + * Wire-format intent request payload. + */ +interface WireIntentRequest { + id: string; + m: WireIntentMethod; + c?: ConnectRequest; + // txIntent / signMsg + i?: WireIntentItem[]; + vu?: number; + n?: string; + // signIntent + mu?: string; + p?: { type: string; text?: string; bytes?: string; schema?: string; cell?: string }; + // actionIntent + a?: string; +} + +/** + * Parsed intent URL — intermediate result before event creation. + */ +export interface ParsedIntentUrl { + clientId: string; + request: WireIntentRequest; +} + +/** + * Intent error codes from the TonConnect 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]; + +/** + * Pure parsing layer for intent deep links. + * + * Responsibility: URL parsing, payload decoding, wire→model mapping, validation. + * No side effects, no I/O, no crypto. + */ +export class IntentParser { + /** + * Check if a URL is a TonConnect intent deep link. + */ + isIntentUrl(url: string): boolean { + const normalized = url.trim().toLowerCase(); + return normalized.startsWith(INTENT_INLINE_SCHEME) || normalized.startsWith(INTENT_SCHEME); + } + + /** + * Parse an intent URL into a typed IntentRequestEvent. + */ + parse(url: string): { event: IntentRequestEvent; connectRequest?: ConnectRequest } { + const parsed = this.parseUrl(url); + return this.toIntentEvent(parsed); + } + + // -- URL parsing ---------------------------------------------------------- + + private parseUrl(url: string): ParsedIntentUrl { + try { + const parsedUrl = new URL(url); + const clientId = parsedUrl.searchParams.get('id'); + if (!clientId) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing client ID (id) in intent URL'); + } + + if (!url.toLowerCase().startsWith(INTENT_INLINE_SCHEME)) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Only inline intents (tc://intent_inline) are supported', + ); + } + + return this.parseInlinePayload(parsedUrl, clientId); + } catch (error) { + if (error instanceof WalletKitError) throw error; + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid intent URL format', error as Error); + } + } + + private parseInlinePayload(parsedUrl: URL, clientId: string): ParsedIntentUrl { + const encoded = parsedUrl.searchParams.get('r'); + if (!encoded) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing payload (r) in intent URL'); + } + + const json = this.decodePayload(encoded); + let request: WireIntentRequest; + try { + request = JSON.parse(json) as WireIntentRequest; + } catch (error) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid JSON in intent payload', error as Error); + } + + this.validateRequest(request); + return { clientId, request }; + } + + // -- Payload decoding ----------------------------------------------------- + + private decodePayload(encoded: string): string { + if (encoded.startsWith('%7B') || encoded.startsWith('%257B') || encoded.startsWith('{')) { + let decoded = decodeURIComponent(encoded); + if (decoded.startsWith('%7B') || decoded.startsWith('%')) { + decoded = decodeURIComponent(decoded); + } + return decoded; + } + return this.decodeBase64Url(encoded); + } + + private decodeBase64Url(encoded: string): string { + let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + const padding = base64.length % 4; + if (padding) base64 += '='.repeat(4 - padding); + + if (typeof atob === 'function') { + return atob(base64); + } + return Buffer.from(base64, 'base64').toString('utf-8'); + } + + // -- Validation ----------------------------------------------------------- + + private validateRequest(request: WireIntentRequest): void { + if (!request.id) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Intent request missing id'); + } + if (!request.m || !VALID_METHODS.includes(request.m)) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Invalid intent method: ${request.m}`); + } + + switch (request.m) { + case 'txIntent': + case 'signMsg': + this.validateTransactionItems(request); + break; + case 'signIntent': + this.validateSignData(request); + break; + case 'actionIntent': + this.validateAction(request); + break; + } + } + + private validateTransactionItems(request: WireIntentRequest): void { + if (!request.i || !Array.isArray(request.i) || request.i.length === 0) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Transaction intent missing items (i)'); + } + for (const item of request.i) { + this.validateItem(item); + } + } + + private validateItem(item: WireIntentItem): void { + const validTypes: WireItemType[] = ['ton', 'jetton', 'nft']; + if (!item.t || !validTypes.includes(item.t)) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Invalid intent item type: ${item.t}`); + } + + switch (item.t) { + case 'ton': + if (!item.a) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'TON item missing address (a)'); + if (!item.am) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'TON item missing amount (am)'); + break; + case 'jetton': + if (!item.ma) + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Jetton item missing master address (ma)'); + if (!item.ja) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Jetton item missing amount (ja)'); + if (!item.d) + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Jetton item missing destination (d)'); + break; + case 'nft': + if (!item.na) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'NFT item missing address (na)'); + if (!item.no) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'NFT item missing new owner (no)'); + break; + } + } + + private validateSignData(request: WireIntentRequest): void { + const manifestUrl = request.mu || request.c?.manifestUrl; + if (!manifestUrl) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Sign data intent missing manifest URL'); + } + if (!request.p || !request.p.type) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Sign data intent missing payload'); + } + } + + private validateAction(request: WireIntentRequest): void { + if (!request.a) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent missing action URL (a)'); + } + } + + /** + * Parse an action URL response payload into a typed intent event. + * Used when an actionIntent URL returns a transaction or sign-data payload. + */ + parseActionResponse( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, + sourceEvent: ActionIntentRequestEvent, + ): IntentRequestEvent { + const wire = payload as WireIntentRequest; + wire.id = wire.id || sourceEvent.id; + + if (!wire.m) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action response missing method (m)'); + } + + this.validateRequest(wire); + + const base = { + id: sourceEvent.id, + origin: sourceEvent.origin, + clientId: sourceEvent.clientId, + hasConnectRequest: sourceEvent.hasConnectRequest, + }; + + switch (wire.m) { + case 'txIntent': + case 'signMsg': + return { + ...base, + intentType: 'transaction', + deliveryMode: wire.m === 'txIntent' ? 'send' : 'signOnly', + network: wire.n, + validUntil: wire.vu, + items: this.mapItems(wire.i!), + } as TransactionIntentRequestEvent; + case 'signIntent': + return { + ...base, + intentType: 'signData', + network: wire.n, + manifestUrl: wire.mu || '', + payload: this.wirePayloadToSignDataPayload(wire.p!), + } as SignDataIntentRequestEvent; + default: + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Action response returned unsupported method: ${wire.m}`, + ); + } + } + + // -- Wire → Model mapping ------------------------------------------------- + + private toIntentEvent(parsed: ParsedIntentUrl): { event: IntentRequestEvent; connectRequest?: ConnectRequest } { + const { clientId, request } = parsed; + const hasConnectRequest = !!request.c; + + const base = { + id: request.id, + origin: 'deepLink' as const, + clientId, + hasConnectRequest, + returnStrategy: undefined, + }; + + let event: IntentRequestEvent; + + switch (request.m) { + case 'txIntent': + case 'signMsg': { + const deliveryMode: IntentDeliveryMode = request.m === 'txIntent' ? 'send' : 'signOnly'; + event = { + ...base, + intentType: 'transaction', + deliveryMode, + network: request.n, + validUntil: request.vu, + items: this.mapItems(request.i!), + } as TransactionIntentRequestEvent; + break; + } + case 'signIntent': { + const manifestUrl = request.mu || request.c?.manifestUrl || ''; + event = { + ...base, + intentType: 'signData', + network: request.n, + manifestUrl, + payload: this.wirePayloadToSignDataPayload(request.p!), + } as SignDataIntentRequestEvent; + break; + } + case 'actionIntent': { + event = { + ...base, + intentType: 'action', + actionUrl: request.a!, + } as ActionIntentRequestEvent; + break; + } + } + + return { event, connectRequest: request.c }; + } + + private mapItems(wireItems: WireIntentItem[]): IntentActionItem[] { + return wireItems.map((item) => this.mapItem(item)); + } + + private mapItem(item: WireIntentItem): IntentActionItem { + switch (item.t) { + case 'ton': + return { + type: 'sendTon', + address: item.a!, + amount: item.am!, + payload: item.p as Base64String | undefined, + stateInit: item.si as Base64String | undefined, + extraCurrency: item.ec, + }; + case 'jetton': + return { + type: 'sendJetton', + jettonMasterAddress: item.ma!, + jettonAmount: item.ja!, + destination: item.d!, + responseDestination: item.rd, + customPayload: item.cp as Base64String | undefined, + forwardTonAmount: item.fta, + forwardPayload: item.fp as Base64String | undefined, + queryId: item.qi, + }; + case 'nft': + return { + type: 'sendNft', + nftAddress: item.na!, + newOwnerAddress: item.no!, + responseDestination: item.rd, + customPayload: item.cp as Base64String | undefined, + forwardTonAmount: item.fta, + forwardPayload: item.fp as Base64String | undefined, + queryId: item.qi, + }; + } + } + + /** + * Convert a wire-format sign data payload to SignDataPayload model. + */ + private wirePayloadToSignDataPayload(wire: { + type: string; + text?: string; + bytes?: string; + schema?: string; + cell?: string; + }): SignDataPayload { + let data: SignData; + + switch (wire.type) { + case 'text': + data = { type: 'text', value: { content: wire.text || '' } }; + break; + case 'binary': + data = { type: 'binary', value: { content: (wire.bytes || '') as Base64String } }; + break; + case 'cell': + data = { + type: 'cell', + value: { schema: wire.schema || '', content: (wire.cell || '') as Base64String }, + }; + break; + default: + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Unsupported sign data type: ${wire.type}`); + } + + return { data }; + } +} diff --git a/packages/walletkit/src/handlers/IntentResolver.ts b/packages/walletkit/src/handlers/IntentResolver.ts new file mode 100644 index 000000000..3e0835cb3 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentResolver.ts @@ -0,0 +1,163 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { Address, beginCell, Cell } from '@ton/core'; + +import { globalLogger } from '../core/Logger'; +import { ERROR_CODES, WalletKitError } from '../errors'; +import { + DEFAULT_JETTON_GAS_FEE, + DEFAULT_NFT_GAS_FEE, + DEFAULT_FORWARD_AMOUNT, + storeJettonTransferMessage, + storeNftTransferMessage, +} from '../utils/messageBuilders'; +import type { Wallet } from '../api/interfaces'; +import type { + TransactionRequest, + TransactionRequestMessage, + IntentActionItem, + SendTonAction, + SendJettonAction, + SendNftAction, + Base64String, +} from '../api/models'; + +const log = globalLogger.createChild('IntentResolver'); + +/** + * Resolves intent action items into concrete transaction messages. + * + * Responsibilities: + * - Convert IntentActionItem[] → TransactionRequest (with jetton/NFT body building) + * - Fetch action URLs and return their resolved payloads + */ +export class IntentResolver { + /** + * Convert intent action items into a TransactionRequest. + * Resolves jetton wallet addresses and builds TEP-74 / TEP-62 message bodies. + */ + async intentItemsToTransactionRequest( + items: IntentActionItem[], + wallet: Wallet, + network?: string, + validUntil?: number, + ): Promise { + const messages: TransactionRequestMessage[] = []; + + for (const item of items) { + const message = await this.resolveItem(item, wallet); + messages.push(message); + } + + return { + messages, + network: network as TransactionRequest['network'], + validUntil, + fromAddress: wallet.getAddress(), + }; + } + + /** + * Fetch an action URL and return the raw response. + */ + async fetchActionUrl(actionUrl: string, walletAddress: string): Promise { + const separator = actionUrl.includes('?') ? '&' : '?'; + const url = `${actionUrl}${separator}wallet=${encodeURIComponent(walletAddress)}`; + + log.info('Fetching action URL', { url }); + + const response = await fetch(url); + if (!response.ok) { + throw new WalletKitError( + ERROR_CODES.NETWORK_ERROR, + `Action URL returned ${response.status}: ${response.statusText}`, + ); + } + + return response.json(); + } + + // -- Item resolution ------------------------------------------------------ + + private async resolveItem(item: IntentActionItem, wallet: Wallet): Promise { + switch (item.type) { + case 'sendTon': + return this.resolveTonItem(item); + case 'sendJetton': + return this.resolveJettonItem(item, wallet); + case 'sendNft': + return this.resolveNftItem(item, wallet); + } + } + + private resolveTonItem(item: SendTonAction): TransactionRequestMessage { + return { + address: item.address, + amount: item.amount, + payload: item.payload as Base64String | undefined, + stateInit: item.stateInit as Base64String | undefined, + extraCurrency: item.extraCurrency, + }; + } + + private async resolveJettonItem(item: SendJettonAction, wallet: Wallet): Promise { + const jettonWalletAddress = await wallet.getJettonWalletAddress(item.jettonMasterAddress); + + const forwardPayload = item.forwardPayload ? Cell.fromBase64(item.forwardPayload) : null; + const customPayload = item.customPayload ? Cell.fromBase64(item.customPayload) : null; + + const body = beginCell() + .store( + storeJettonTransferMessage({ + queryId: BigInt(item.queryId ?? 0), + amount: BigInt(item.jettonAmount), + destination: Address.parse(item.destination), + responseDestination: item.responseDestination + ? Address.parse(item.responseDestination) + : Address.parse(wallet.getAddress()), + customPayload, + forwardAmount: item.forwardTonAmount ? BigInt(item.forwardTonAmount) : DEFAULT_FORWARD_AMOUNT, + forwardPayload, + }), + ) + .endCell(); + + return { + address: jettonWalletAddress, + amount: DEFAULT_JETTON_GAS_FEE, + payload: body.toBoc().toString('base64') as Base64String, + }; + } + + private async resolveNftItem(item: SendNftAction, wallet: Wallet): Promise { + const forwardPayload = item.forwardPayload ? Cell.fromBase64(item.forwardPayload) : null; + const customPayload = item.customPayload ? Cell.fromBase64(item.customPayload) : null; + + const body = beginCell() + .store( + storeNftTransferMessage({ + queryId: BigInt(item.queryId ?? 0), + newOwner: Address.parse(item.newOwnerAddress), + responseDestination: item.responseDestination + ? Address.parse(item.responseDestination) + : Address.parse(wallet.getAddress()), + customPayload, + forwardAmount: item.forwardTonAmount ? BigInt(item.forwardTonAmount) : DEFAULT_FORWARD_AMOUNT, + forwardPayload, + }), + ) + .endCell(); + + return { + address: item.nftAddress, + amount: DEFAULT_NFT_GAS_FEE, + payload: body.toBoc().toString('base64') as Base64String, + }; + } +} diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index ead7c6f87..37e972ff7 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -32,6 +32,10 @@ export { ConnectHandler } from './handlers/ConnectHandler'; export { TransactionHandler } from './handlers/TransactionHandler'; export { SignDataHandler } from './handlers/SignDataHandler'; export { DisconnectHandler } from './handlers/DisconnectHandler'; +export { IntentParser, INTENT_ERROR_CODES } from './handlers/IntentParser'; +export type { ParsedIntentUrl, IntentErrorCode } from './handlers/IntentParser'; +export { IntentResolver } from './handlers/IntentResolver'; +export { IntentHandler } from './handlers/IntentHandler'; export { WalletV5, WalletV5R1Id, Opcodes } from './contracts/w5/WalletV5R1'; export type { WalletV5Config } from './contracts/w5/WalletV5R1'; export { WalletV5R1CodeCell, WalletV5R1CodeBoc } from './contracts/w5/WalletV5R1.source'; From 4f25ea185ec2f8ec06223b5778b63a81f58c71a3 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 23 Feb 2026 14:49:47 +0530 Subject: [PATCH 02/15] fix: align intent API with TonConnect spec 1. Action URL response parsing: handle { action_type, action } format per spec instead of wire intent format. sendTransaction actions return standard messages array, signData returns typed payload. 2. Query param: use 'address' instead of 'wallet' when appending wallet address to action URL (spec requirement). 3. Wire response format: convert SDK response models to spec format before sending via bridge: - txIntent/signMsg: { result: '', id } - signIntent: { result: { signature, address, timestamp, domain, payload }, id } - error: { error: { code, message }, id } 4. SignData response: add echoed payload field per spec requirement (MakeSignDataIntentResponseSuccess = SignDataResponseSuccess). --- .../src/api/models/intents/IntentResponse.ts | 4 + .../walletkit/src/handlers/IntentHandler.ts | 54 +++++++++- .../walletkit/src/handlers/IntentParser.ts | 101 +++++++++++++----- .../walletkit/src/handlers/IntentResolver.ts | 2 +- 4 files changed, 132 insertions(+), 29 deletions(-) diff --git a/packages/walletkit/src/api/models/intents/IntentResponse.ts b/packages/walletkit/src/api/models/intents/IntentResponse.ts index ac21cd905..85f4e5936 100644 --- a/packages/walletkit/src/api/models/intents/IntentResponse.ts +++ b/packages/walletkit/src/api/models/intents/IntentResponse.ts @@ -6,6 +6,8 @@ * */ +import type { SignDataPayload } from '../core/PreparedSignData'; + /** * Successful response for transaction intent. */ @@ -30,6 +32,8 @@ export interface IntentSignDataResponse { timestamp: number; /** App domain */ domain: string; + /** Echoed payload from the request */ + payload: SignDataPayload; } /** diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index e0f2e22d8..24ff48272 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -31,6 +31,7 @@ import type { IntentActionItem, BatchedIntentEvent, TransactionRequest, + SignDataPayload, } from '../api/models'; import type { TonWalletKitOptions } from '../types'; @@ -139,6 +140,7 @@ export class IntentHandler { address: Address.parse(wallet.getAddress()).toRawString(), timestamp: signData.timestamp, domain: signData.domain, + payload: event.payload, }; await this.sendResponse(event, result); @@ -155,6 +157,9 @@ export class IntentHandler { const resolvedEvent = this.parser.parseActionResponse(actionResponse, event); if (resolvedEvent.intentType === 'transaction') { + if (resolvedEvent.resolvedTransaction) { + resolvedEvent.resolvedTransaction.fromAddress = wallet.getAddress(); + } return this.approveTransactionIntent(resolvedEvent, walletId); } else if (resolvedEvent.intentType === 'signData') { return this.approveSignDataIntent(resolvedEvent, walletId); @@ -231,13 +236,60 @@ export class IntentHandler { return; } + const wireResponse = this.toWireResponse(event, result); + try { - await this.bridgeManager.sendIntentResponse(event.clientId, result); + await this.bridgeManager.sendIntentResponse(event.clientId, wireResponse); } catch (error) { log.error('Failed to send intent response', { error, eventId: event.id }); } } + /** + * Convert SDK response model to TonConnect wire format. + * - Transaction: `{ result: "", id }` + * - SignData: `{ result: { signature, address, timestamp, domain, payload }, id }` + * - Error: `{ error: { code, message }, id }` + */ + private toWireResponse(event: IntentRequestEvent, result: IntentResponseResult): Record { + if (result.resultType === 'error') { + return { + error: { code: result.error.code, message: result.error.message }, + id: event.id, + }; + } + + if (result.resultType === 'transaction') { + return { result: result.boc, id: event.id }; + } + + return { + result: { + signature: result.signature, + address: result.address, + timestamp: result.timestamp, + domain: result.domain, + payload: this.signDataPayloadToWire(result.payload), + }, + id: event.id, + }; + } + + /** + * Convert SignDataPayload model back to wire format. + */ + private signDataPayloadToWire(payload: SignDataPayload): Record { + const { data } = payload; + switch (data.type) { + case 'text': + return { type: 'text', text: data.value.content }; + case 'binary': + return { type: 'binary', bytes: data.value.content }; + case 'cell': + return { type: 'cell', cell: data.value.content, schema: data.value.schema }; + } + } + // -- Private: Helpers ----------------------------------------------------- private getWallet(walletId: string): Wallet { diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index c9d7200fb..a4afad17f 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -16,6 +16,7 @@ import type { SignDataIntentRequestEvent, ActionIntentRequestEvent, IntentDeliveryMode, + TransactionRequest, SignDataPayload, SignData, Base64String, @@ -266,22 +267,22 @@ export class IntentParser { /** * Parse an action URL response payload into a typed intent event. - * Used when an actionIntent URL returns a transaction or sign-data payload. + * + * Action URLs return standard TonConnect payloads: + * - `{ action_type: 'sendTransaction', action: { messages, valid_until?, network? } }` + * - `{ action_type: 'signData', action: { type, text?|bytes?|cell?, schema? } }` */ parseActionResponse( // eslint-disable-next-line @typescript-eslint/no-explicit-any payload: any, sourceEvent: ActionIntentRequestEvent, ): IntentRequestEvent { - const wire = payload as WireIntentRequest; - wire.id = wire.id || sourceEvent.id; + const { action_type, action } = payload as { action_type?: string; action?: Record }; - if (!wire.m) { - throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action response missing method (m)'); + if (!action_type || !action) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action URL response missing action_type or action'); } - this.validateRequest(wire); - const base = { id: sourceEvent.id, origin: sourceEvent.origin, @@ -289,33 +290,79 @@ export class IntentParser { hasConnectRequest: sourceEvent.hasConnectRequest, }; - switch (wire.m) { - case 'txIntent': - case 'signMsg': - return { - ...base, - intentType: 'transaction', - deliveryMode: wire.m === 'txIntent' ? 'send' : 'signOnly', - network: wire.n, - validUntil: wire.vu, - items: this.mapItems(wire.i!), - } as TransactionIntentRequestEvent; - case 'signIntent': - return { - ...base, - intentType: 'signData', - network: wire.n, - manifestUrl: wire.mu || '', - payload: this.wirePayloadToSignDataPayload(wire.p!), - } as SignDataIntentRequestEvent; + switch (action_type) { + case 'sendTransaction': + return this.parseActionTransaction(base, action); + case 'signData': + return this.parseActionSignData(base, action, sourceEvent.actionUrl); default: throw new WalletKitError( ERROR_CODES.VALIDATION_ERROR, - `Action response returned unsupported method: ${wire.m}`, + `Action URL returned unsupported action_type: ${action_type}`, ); } } + private parseActionTransaction( + base: { id: string; origin: string; clientId?: string; hasConnectRequest: boolean }, + action: Record, + ): TransactionIntentRequestEvent { + const rawMessages = action.messages as Array> | undefined; + if (!rawMessages || !Array.isArray(rawMessages) || rawMessages.length === 0) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action sendTransaction missing messages'); + } + + const messages = rawMessages.map((msg) => ({ + address: msg.address as string, + amount: msg.amount as string, + payload: msg.payload as Base64String | undefined, + stateInit: (msg.stateInit ?? msg.state_init) as Base64String | undefined, + extraCurrency: (msg.extraCurrency ?? msg.extra_currency) as Record | undefined, + })); + + const resolvedTransaction: TransactionRequest = { + messages, + network: action.network as TransactionRequest['network'], + validUntil: (action.valid_until ?? action.validUntil) as number | undefined, + }; + + return { + ...base, + intentType: 'transaction', + deliveryMode: 'send', + network: action.network as string | undefined, + validUntil: resolvedTransaction.validUntil, + items: [], + resolvedTransaction, + } as TransactionIntentRequestEvent; + } + + private parseActionSignData( + base: { id: string; origin: string; clientId?: string; hasConnectRequest: boolean }, + action: Record, + actionUrl: string, + ): SignDataIntentRequestEvent { + const wirePayload = { + type: action.type as string, + text: action.text as string | undefined, + bytes: action.bytes as string | undefined, + cell: action.cell as string | undefined, + schema: action.schema as string | undefined, + }; + + if (!wirePayload.type) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action signData missing type'); + } + + return { + ...base, + intentType: 'signData', + network: action.network as string | undefined, + manifestUrl: actionUrl, + payload: this.wirePayloadToSignDataPayload(wirePayload), + } as SignDataIntentRequestEvent; + } + // -- Wire → Model mapping ------------------------------------------------- private toIntentEvent(parsed: ParsedIntentUrl): { event: IntentRequestEvent; connectRequest?: ConnectRequest } { diff --git a/packages/walletkit/src/handlers/IntentResolver.ts b/packages/walletkit/src/handlers/IntentResolver.ts index 3e0835cb3..bfbb098bc 100644 --- a/packages/walletkit/src/handlers/IntentResolver.ts +++ b/packages/walletkit/src/handlers/IntentResolver.ts @@ -68,7 +68,7 @@ export class IntentResolver { */ async fetchActionUrl(actionUrl: string, walletAddress: string): Promise { const separator = actionUrl.includes('?') ? '&' : '?'; - const url = `${actionUrl}${separator}wallet=${encodeURIComponent(walletAddress)}`; + const url = `${actionUrl}${separator}address=${encodeURIComponent(walletAddress)}`; log.info('Fetching action URL', { url }); From 9d1b6e900d9774338543c976d80ab72184220c39 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Wed, 25 Feb 2026 08:58:35 +0530 Subject: [PATCH 03/15] feat: add support for object storage and trace ID propagation --- .../api/models/intents/IntentRequestEvent.ts | 2 +- packages/walletkit/src/core/BridgeManager.ts | 6 +- packages/walletkit/src/core/TonWalletKit.ts | 34 +++- .../walletkit/src/handlers/IntentHandler.ts | 4 +- .../walletkit/src/handlers/IntentParser.ts | 191 ++++++++++++++++-- 5 files changed, 211 insertions(+), 26 deletions(-) diff --git a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts index 5d396ac42..7662bd476 100644 --- a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts +++ b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts @@ -16,7 +16,7 @@ import type { IntentActionItem } from './IntentActionItem'; /** * Origin of the intent request. */ -export type IntentOrigin = 'deepLink' | 'bridge' | 'jsBridge'; +export type IntentOrigin = 'deepLink' | 'objectStorage' | 'bridge' | 'jsBridge'; /** * Delivery mode for the signed transaction. diff --git a/packages/walletkit/src/core/BridgeManager.ts b/packages/walletkit/src/core/BridgeManager.ts index 1450c8f9a..ff7a0158e 100644 --- a/packages/walletkit/src/core/BridgeManager.ts +++ b/packages/walletkit/src/core/BridgeManager.ts @@ -264,7 +264,7 @@ export class BridgeManager { * Creates a new ephemeral SessionCrypto for one-way intent responses. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - async sendIntentResponse(clientId: string, response: any): Promise { + async sendIntentResponse(clientId: string, response: any, traceId?: string): Promise { if (!this.bridgeProvider) { throw new WalletKitError( ERROR_CODES.BRIDGE_NOT_INITIALIZED, @@ -275,8 +275,8 @@ export class BridgeManager { const sessionCrypto = new SessionCrypto(); try { - await this.bridgeProvider.send(response, sessionCrypto, clientId, {}); - log.debug('Intent response sent', { clientId }); + await this.bridgeProvider.send(response, sessionCrypto, clientId, { traceId }); + log.debug('Intent response sent', { clientId, traceId }); } catch (error) { log.error('Failed to send intent response', { clientId, error }); throw WalletKitError.fromError( diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 8841eb2c4..dbbaed118 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -50,6 +50,7 @@ import { KitNetworkManager } from './NetworkManager'; import type { WalletId } from '../utils/walletId'; import type { Wallet, WalletAdapter } from '../api/interfaces'; import { IntentHandler } from '../handlers/IntentHandler'; +import { ConnectHandler } from '../handlers/ConnectHandler'; import type { Network, TransactionRequest, @@ -538,7 +539,8 @@ export class TonWalletKit implements ITonWalletKit { // === Intent API === isIntentUrl(url: string): boolean { - return this.intentHandler?.isIntentUrl(url) ?? false; + const normalized = url.trim().toLowerCase(); + return normalized.startsWith('tc://intent_inline') || normalized.startsWith('tc://intent'); } async handleIntentUrl(url: string, walletId: string): Promise { @@ -593,8 +595,8 @@ export class TonWalletKit implements ITonWalletKit { async processConnectAfterIntent( event: IntentRequestEvent | BatchedIntentEvent, - _walletId: string, - _proof?: ConnectionApprovalProof, + walletId: string, + proof?: ConnectionApprovalProof, ): Promise { await this.ensureInitialized(); @@ -607,7 +609,7 @@ export class TonWalletKit implements ITonWalletKit { this.intentHandler.removePendingConnectRequest(eventId); - // Create a bridge event from the connect request and route it through the connect flow + // Create a bridge event from the connect request const bridgeEvent: RawBridgeEventConnect = { from: event.clientId || '', id: eventId, @@ -620,7 +622,19 @@ export class TonWalletKit implements ITonWalletKit { domain: '', }; - await this.eventRouter.routeEvent(bridgeEvent); + // Process through ConnectHandler to fetch manifest and build ConnectionRequestEvent + // (no-op notify — we auto-approve instead of showing UI) + const connectHandler = new ConnectHandler(() => {}, this.config, this.analyticsManager); + const connectionEvent = await connectHandler.handle(bridgeEvent); + + // Set walletId for approval + connectionEvent.walletId = walletId; + + // Auto-approve: create session and send ConnectEvent response to dApp + await this.requestProcessor.approveConnectRequest( + connectionEvent, + proof ? { proof } : undefined, + ); } // === URL Processing API === @@ -633,6 +647,16 @@ export class TonWalletKit implements ITonWalletKit { await this.ensureInitialized(); try { + // Reject intent URLs — they must go through handleIntentUrl(url, walletId) + if (this.isIntentUrl(url)) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'This is an intent URL. Use handleIntentUrl(url, walletId) instead of handleTonConnectUrl(url).', + undefined, + { url }, + ); + } + // Parse and validate the TON Connect URL const parsedUrl = this.parseTonConnectUrl(url); if (!parsedUrl) { diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index 24ff48272..1c96fdf75 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -67,7 +67,7 @@ export class IntentHandler { * Parse an intent URL, resolve items, emulate preview, and emit the event. */ async handleIntentUrl(url: string, walletId: string): Promise { - const { event, connectRequest } = this.parser.parse(url); + const { event, connectRequest } = await this.parser.parse(url); if (connectRequest) { this.pendingConnectRequests.set(event.id, connectRequest); @@ -239,7 +239,7 @@ export class IntentHandler { const wireResponse = this.toWireResponse(event, result); try { - await this.bridgeManager.sendIntentResponse(event.clientId, wireResponse); + await this.bridgeManager.sendIntentResponse(event.clientId, wireResponse, event.traceId); } catch (error) { log.error('Failed to send intent response', { error, eventId: event.id }); } diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index a4afad17f..1324fdf36 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -7,10 +7,12 @@ */ import type { ConnectRequest } from '@tonconnect/protocol'; +import nacl from 'tweetnacl'; import { WalletKitError, ERROR_CODES } from '../errors'; import type { IntentActionItem, + IntentOrigin, IntentRequestEvent, TransactionIntentRequestEvent, SignDataIntentRequestEvent, @@ -86,6 +88,8 @@ interface WireIntentRequest { export interface ParsedIntentUrl { clientId: string; request: WireIntentRequest; + origin: IntentOrigin; + traceId?: string; } /** @@ -103,10 +107,10 @@ export const INTENT_ERROR_CODES = { export type IntentErrorCode = (typeof INTENT_ERROR_CODES)[keyof typeof INTENT_ERROR_CODES]; /** - * Pure parsing layer for intent deep links. + * Parsing layer for intent deep links. * - * Responsibility: URL parsing, payload decoding, wire→model mapping, validation. - * No side effects, no I/O, no crypto. + * Responsibility: URL parsing, payload decoding (inline + object storage), + * NaCl decryption, wire→model mapping, validation. */ export class IntentParser { /** @@ -119,15 +123,16 @@ export class IntentParser { /** * Parse an intent URL into a typed IntentRequestEvent. + * Supports both `tc://intent_inline` (URL-embedded) and `tc://intent` (object storage). */ - parse(url: string): { event: IntentRequestEvent; connectRequest?: ConnectRequest } { - const parsed = this.parseUrl(url); + async parse(url: string): Promise<{ event: IntentRequestEvent; connectRequest?: ConnectRequest }> { + const parsed = await this.parseUrl(url); return this.toIntentEvent(parsed); } // -- URL parsing ---------------------------------------------------------- - private parseUrl(url: string): ParsedIntentUrl { + private async parseUrl(url: string): Promise { try { const parsedUrl = new URL(url); const clientId = parsedUrl.searchParams.get('id'); @@ -135,14 +140,17 @@ export class IntentParser { throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing client ID (id) in intent URL'); } - if (!url.toLowerCase().startsWith(INTENT_INLINE_SCHEME)) { - throw new WalletKitError( - ERROR_CODES.VALIDATION_ERROR, - 'Only inline intents (tc://intent_inline) are supported', - ); + const normalized = url.trim().toLowerCase(); + + if (normalized.startsWith(INTENT_INLINE_SCHEME)) { + return this.parseInlinePayload(parsedUrl, clientId); } - return this.parseInlinePayload(parsedUrl, clientId); + if (normalized.startsWith(INTENT_SCHEME)) { + return this.parseObjectStoragePayload(parsedUrl, clientId); + } + + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Unknown intent URL scheme'); } catch (error) { if (error instanceof WalletKitError) throw error; throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid intent URL format', error as Error); @@ -154,6 +162,7 @@ export class IntentParser { if (!encoded) { throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing payload (r) in intent URL'); } + const traceId = parsedUrl.searchParams.get('trace_id') || undefined; const json = this.decodePayload(encoded); let request: WireIntentRequest; @@ -164,7 +173,158 @@ export class IntentParser { } this.validateRequest(request); - return { clientId, request }; + return { clientId, request, origin: 'deepLink', traceId }; + } + + /** + * Parse an object storage intent URL. + * Fetches encrypted payload from `get_url`, decrypts with NaCl using + * the provided wallet private key and client public key. + */ + private async parseObjectStoragePayload(parsedUrl: URL, clientId: string): Promise { + const walletPrivateKey = parsedUrl.searchParams.get('pk'); + const getUrl = parsedUrl.searchParams.get('get_url'); + const traceId = parsedUrl.searchParams.get('trace_id') || undefined; + + if (!walletPrivateKey) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing wallet private key (pk) in intent URL'); + } + if (!getUrl) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing get_url in intent URL'); + } + + const encryptedPayload = await this.fetchObjectStoragePayload(getUrl); + const json = this.decryptPayload(encryptedPayload, clientId, walletPrivateKey); + + let request: WireIntentRequest; + try { + request = JSON.parse(json) as WireIntentRequest; + } catch (error) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Invalid JSON in decrypted intent payload: ${json.substring(0, 100)}`, + error as Error, + ); + } + + this.validateRequest(request); + return { clientId, request, origin: 'objectStorage', traceId }; + } + + /** + * Fetch encrypted payload from object storage URL. + * Handles both raw binary and base64-encoded text responses. + */ + private async fetchObjectStoragePayload(getUrl: string): Promise { + try { + const response = await fetch(getUrl); + if (!response.ok) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Object storage fetch failed: ${response.status} ${response.statusText}`, + ); + } + + const contentType = response.headers.get('content-type') || ''; + const buffer = await response.arrayBuffer(); + const raw = new Uint8Array(buffer); + + // If the response is text (base64-encoded), decode it + if (contentType.includes('text') || contentType.includes('json')) { + const text = new TextDecoder().decode(raw).trim(); + + // Try to parse as JSON that contains base64 data + try { + const jsonResp = JSON.parse(text); + const b64Data = jsonResp.data || jsonResp.payload || jsonResp.body; + if (typeof b64Data === 'string') { + return this.base64ToBytes(b64Data); + } + } catch { + // Not JSON, try as plain base64 + } + + // Try as plain base64 string + if (/^[A-Za-z0-9+/=_-]+$/.test(text) && text.length > 24) { + return this.base64ToBytes(text); + } + } + + return raw; + } catch (error) { + if (error instanceof WalletKitError) throw error; + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Failed to fetch intent payload from object storage: ${(error as Error).message}`, + error as Error, + ); + } + } + + private base64ToBytes(b64: string): Uint8Array { + // Handle base64url encoding + let base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); + const padding = base64.length % 4; + if (padding) base64 += '='.repeat(4 - padding); + + if (typeof atob === 'function') { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + return new Uint8Array(Buffer.from(base64, 'base64')); + } + + /** + * Decrypt an object storage payload using NaCl crypto_box. + * Format: nonce (24 bytes) || ciphertext + */ + private decryptPayload(encrypted: Uint8Array, clientPubKeyHex: string, walletPrivateKeyHex: string): string { + if (encrypted.length <= 24) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Encrypted payload too short (${encrypted.length} bytes, need >24)`, + ); + } + + const clientPubKey = this.hexToBytes(clientPubKeyHex); + const walletPrivateKey = this.hexToBytes(walletPrivateKeyHex); + + if (clientPubKey.length !== 32) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Invalid client public key length: ${clientPubKey.length} (expected 32)`, + ); + } + if (walletPrivateKey.length !== 32) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Invalid wallet private key length: ${walletPrivateKey.length} (expected 32)`, + ); + } + + const nonce = encrypted.slice(0, 24); + const ciphertext = encrypted.slice(24); + const decrypted = nacl.box.open(ciphertext, nonce, clientPubKey, walletPrivateKey); + if (!decrypted) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Failed to decrypt intent payload', + ); + } + + return new TextDecoder().decode(decrypted); + } + + private hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; } // -- Payload decoding ----------------------------------------------------- @@ -366,14 +526,15 @@ export class IntentParser { // -- Wire → Model mapping ------------------------------------------------- private toIntentEvent(parsed: ParsedIntentUrl): { event: IntentRequestEvent; connectRequest?: ConnectRequest } { - const { clientId, request } = parsed; + const { clientId, request, origin, traceId } = parsed; const hasConnectRequest = !!request.c; const base = { id: request.id, - origin: 'deepLink' as const, + origin, clientId, hasConnectRequest, + traceId, returnStrategy: undefined, }; From e03bfdfa7d1ece6d6312859b96c863366de834cf Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Wed, 25 Feb 2026 20:02:15 +0530 Subject: [PATCH 04/15] feat: improve type definitions & model generation --- .../walletkit-android-bridge/src/types/api.ts | 2 +- packages/walletkit/src/api/models/index.ts | 2 +- .../api/models/intents/BatchedIntentEvent.ts | 2 - .../api/models/intents/IntentActionItem.ts | 49 ++++--- .../api/models/intents/IntentRequestEvent.ts | 47 +++--- .../src/api/models/intents/IntentResponse.ts | 23 +-- packages/walletkit/src/core/TonWalletKit.ts | 7 +- .../walletkit/src/handlers/IntentHandler.ts | 78 +++++----- .../walletkit/src/handlers/IntentParser.ts | 136 ++++++++++-------- .../walletkit/src/handlers/IntentResolver.ts | 11 +- 10 files changed, 194 insertions(+), 163 deletions(-) diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index 4705e15b9..84b64d8a8 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -24,6 +24,7 @@ import type { Wallet, WalletResponse, IntentRequestEvent, + BatchedIntentEvent, TransactionIntentRequestEvent, SignDataIntentRequestEvent, ActionIntentRequestEvent, @@ -31,7 +32,6 @@ import type { IntentSignDataResponse, IntentErrorResponse, IntentActionItem, - BatchedIntentEvent, ConnectionApprovalProof, } from '@ton/walletkit'; diff --git a/packages/walletkit/src/api/models/index.ts b/packages/walletkit/src/api/models/index.ts index 95997b82a..4ce036073 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -99,7 +99,6 @@ export type { IntentDeliveryMode, IntentRequestBase, TransactionIntentRequestEvent, - TransactionIntentPreview, SignDataIntentRequestEvent, ActionIntentRequestEvent, IntentRequestEvent, @@ -113,5 +112,6 @@ export type { } from './intents/IntentResponse'; export type { BatchedIntentEvent } from './intents/BatchedIntentEvent'; + // RPC models export type { GetMethodResult } from './rpc/GetMethodResult'; diff --git a/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts index ad3d7bb0e..5d1a2acf7 100644 --- a/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts +++ b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts @@ -18,8 +18,6 @@ import type { IntentRequestEvent, IntentOrigin } from './IntentRequestEvent'; * - action intent that resolves to multiple steps */ export interface BatchedIntentEvent extends BridgeEvent { - /** Fixed identifier for batched events */ - eventType: 'batchedIntent'; /** How the batch reached the wallet */ origin: IntentOrigin; /** Client public key for response routing */ diff --git a/packages/walletkit/src/api/models/intents/IntentActionItem.ts b/packages/walletkit/src/api/models/intents/IntentActionItem.ts index f6f3043be..c8b9f56da 100644 --- a/packages/walletkit/src/api/models/intents/IntentActionItem.ts +++ b/packages/walletkit/src/api/models/intents/IntentActionItem.ts @@ -6,47 +6,48 @@ * */ -import type { Base64String } from '../core/Primitives'; +import type { Base64String, UserFriendlyAddress } from '../core/Primitives'; +import type { TokenAmount } from '../core/TokenAmount'; +import type { ExtraCurrencies } from '../core/ExtraCurrencies'; /** * TON native coin transfer action. */ export interface SendTonAction { - /** Action type discriminator */ - type: 'sendTon'; /** Destination address (user-friendly) */ - address: string; + address: UserFriendlyAddress; /** Amount in nanotons */ - amount: string; + amount: TokenAmount; /** Cell payload (Base64 BoC) */ payload?: Base64String; /** Contract deploy stateInit (Base64 BoC) */ stateInit?: Base64String; /** Extra currencies */ - extraCurrency?: Record; + extraCurrency?: ExtraCurrencies; } /** * Jetton transfer action (TEP-74). */ export interface SendJettonAction { - /** Action type discriminator */ - type: 'sendJetton'; /** Jetton master contract address */ - jettonMasterAddress: string; + jettonMasterAddress: UserFriendlyAddress; /** Transfer amount in jetton elementary units */ - jettonAmount: string; + jettonAmount: TokenAmount; /** Recipient address */ - destination: string; + destination: UserFriendlyAddress; /** Response destination (defaults to sender) */ - responseDestination?: string; + responseDestination?: UserFriendlyAddress; /** Custom payload (Base64 BoC) */ customPayload?: Base64String; /** Forward TON amount (nanotons) */ - forwardTonAmount?: string; + forwardTonAmount?: TokenAmount; /** Forward payload (Base64 BoC) */ forwardPayload?: Base64String; - /** Query ID */ + /** + * Query ID + * @format int + */ queryId?: number; } @@ -54,25 +55,29 @@ export interface SendJettonAction { * NFT transfer action (TEP-62). */ export interface SendNftAction { - /** Action type discriminator */ - type: 'sendNft'; /** NFT item address */ - nftAddress: string; + nftAddress: UserFriendlyAddress; /** New owner address */ - newOwnerAddress: string; + newOwnerAddress: UserFriendlyAddress; /** Response destination (defaults to sender) */ - responseDestination?: string; + responseDestination?: UserFriendlyAddress; /** Custom payload (Base64 BoC) */ customPayload?: Base64String; /** Forward TON amount (nanotons) */ - forwardTonAmount?: string; + forwardTonAmount?: TokenAmount; /** Forward payload (Base64 BoC) */ forwardPayload?: Base64String; - /** Query ID */ + /** + * Query ID + * @format int + */ queryId?: number; } /** * Union of all intent action items, discriminated by `type`. */ -export type IntentActionItem = SendTonAction | SendJettonAction | SendNftAction; +export type IntentActionItem = + | { type: 'sendTon'; value: SendTonAction } + | { type: 'sendJetton'; value: SendJettonAction } + | { type: 'sendNft'; value: SendNftAction }; diff --git a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts index 7662bd476..ef9bdfd36 100644 --- a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts +++ b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts @@ -11,6 +11,7 @@ import type { TransactionRequest } from '../transactions/TransactionRequest'; import type { TransactionEmulatedPreview } from '../transactions/emulation/TransactionEmulatedPreview'; import type { SignDataPayload } from '../core/PreparedSignData'; import type { DAppInfo } from '../core/DAppInfo'; +import type { Network } from '../core/Network'; import type { IntentActionItem } from './IntentActionItem'; /** @@ -42,39 +43,33 @@ export interface IntentRequestBase extends BridgeEvent { * The `deliveryMode` field distinguishes them. */ export interface TransactionIntentRequestEvent extends IntentRequestBase { - /** Event type discriminator */ - intentType: 'transaction'; /** Whether to send on-chain or return signed BoC */ deliveryMode: IntentDeliveryMode; - /** Network chain ID ("-239" = mainnet, "-3" = testnet) */ - network?: string; - /** Transaction validity deadline (unix timestamp) */ + /** Network for the transaction */ + network?: Network; + /** + * Transaction validity deadline (unix timestamp) + * @format timestamp + */ validUntil?: number; /** Original intent action items (for display / re-conversion) */ items: IntentActionItem[]; /** Resolved transaction request (items converted to messages) */ resolvedTransaction?: TransactionRequest; /** Emulated preview for display */ - preview?: TransactionIntentPreview; -} - -/** - * Preview data for transaction intent. - */ -export interface TransactionIntentPreview { - /** Emulated transaction data */ - data?: TransactionEmulatedPreview; + preview?: TransactionEmulatedPreview; } /** * Sign data intent request event. */ export interface SignDataIntentRequestEvent extends IntentRequestBase { - /** Event type discriminator */ - intentType: 'signData'; - /** Network chain ID */ - network?: string; - /** Manifest URL (for domain binding) */ + /** Network for sign data */ + network?: Network; + /** + * Manifest URL (for domain binding) + * @format url + */ manifestUrl: string; /** The data to sign */ payload: SignDataPayload; @@ -90,13 +85,17 @@ export interface SignDataIntentRequestEvent extends IntentRequestBase { * to a TransactionIntentRequestEvent or SignDataIntentRequestEvent. */ export interface ActionIntentRequestEvent extends IntentRequestBase { - /** Event type discriminator */ - intentType: 'action'; - /** Action URL to fetch */ + /** + * Action URL to fetch + * @format url + */ actionUrl: string; } /** - * Union of all intent request events, discriminated by `intentType`. + * Union of all intent request events, discriminated by `type`. */ -export type IntentRequestEvent = TransactionIntentRequestEvent | SignDataIntentRequestEvent | ActionIntentRequestEvent; +export type IntentRequestEvent = + | { type: 'transaction'; value: TransactionIntentRequestEvent } + | { type: 'signData'; value: SignDataIntentRequestEvent } + | { type: 'action'; value: ActionIntentRequestEvent }; diff --git a/packages/walletkit/src/api/models/intents/IntentResponse.ts b/packages/walletkit/src/api/models/intents/IntentResponse.ts index 85f4e5936..9d09c4392 100644 --- a/packages/walletkit/src/api/models/intents/IntentResponse.ts +++ b/packages/walletkit/src/api/models/intents/IntentResponse.ts @@ -6,29 +6,29 @@ * */ +import type { Base64String } from '../core/Primitives'; import type { SignDataPayload } from '../core/PreparedSignData'; /** * Successful response for transaction intent. */ export interface IntentTransactionResponse { - /** Result type discriminator */ - resultType: 'transaction'; /** Signed BoC (base64) */ - boc: string; + boc: Base64String; } /** * Successful response for sign data intent. */ export interface IntentSignDataResponse { - /** Result type discriminator */ - resultType: 'signData'; /** Signature (base64) */ - signature: string; + signature: Base64String; /** Signer address (raw format: 0:hex) */ address: string; - /** UNIX timestamp (seconds, UTC) */ + /** + * UNIX timestamp (seconds, UTC) + * @format timestamp + */ timestamp: number; /** App domain */ domain: string; @@ -40,8 +40,6 @@ export interface IntentSignDataResponse { * Error response for any intent. */ export interface IntentErrorResponse { - /** Result type discriminator */ - resultType: 'error'; /** Error details */ error: IntentError; } @@ -60,6 +58,9 @@ export interface IntentError { } /** - * Union of all intent responses. + * Union of all intent responses, discriminated by `type`. */ -export type IntentResponseResult = IntentTransactionResponse | IntentSignDataResponse | IntentErrorResponse; +export type IntentResponseResult = + | { type: 'transaction'; value: IntentTransactionResponse } + | { type: 'signData'; value: IntentSignDataResponse } + | { type: 'error'; value: IntentErrorResponse }; diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index dbbaed118..59b750256 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -600,7 +600,10 @@ export class TonWalletKit implements ITonWalletKit { ): Promise { await this.ensureInitialized(); - const eventId = event.id; + const isBatched = 'intents' in event; + const eventId = isBatched ? event.id : event.value.id; + const clientId = isBatched ? event.clientId : event.value.clientId; + const connectRequest = this.intentHandler.getPendingConnectRequest(eventId); if (!connectRequest) { log.warn('No pending connect request for intent', { eventId }); @@ -611,7 +614,7 @@ export class TonWalletKit implements ITonWalletKit { // Create a bridge event from the connect request const bridgeEvent: RawBridgeEventConnect = { - from: event.clientId || '', + from: clientId || '', id: eventId, method: 'connect', params: { diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index 1c96fdf75..4743edef3 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -21,6 +21,7 @@ import type { WalletManager } from '../core/WalletManager'; import type { Wallet } from '../api/interfaces'; import type { IntentRequestEvent, + IntentRequestBase, TransactionIntentRequestEvent, SignDataIntentRequestEvent, ActionIntentRequestEvent, @@ -32,6 +33,8 @@ import type { BatchedIntentEvent, TransactionRequest, SignDataPayload, + Base64String, + Network, } from '../api/models'; import type { TonWalletKitOptions } from '../types'; @@ -70,10 +73,10 @@ export class IntentHandler { const { event, connectRequest } = await this.parser.parse(url); if (connectRequest) { - this.pendingConnectRequests.set(event.id, connectRequest); + this.pendingConnectRequests.set(event.value.id, connectRequest); } - if (event.intentType === 'transaction') { + if (event.type === 'transaction') { await this.resolveAndEmitTransaction(event, walletId); } else { this.emit(event); @@ -107,11 +110,10 @@ export class IntentHandler { } const result: IntentTransactionResponse = { - resultType: 'transaction', - boc: signedBoc, + boc: signedBoc as Base64String, }; - await this.sendResponse(event, result); + await this.sendResponse(event, { type: 'transaction', value: result }); return result; } @@ -135,15 +137,14 @@ export class IntentHandler { const signatureBase64 = HexToBase64(signature); const result: IntentSignDataResponse = { - resultType: 'signData', - signature: signatureBase64, + signature: signatureBase64 as Base64String, address: Address.parse(wallet.getAddress()).toRawString(), timestamp: signData.timestamp, domain: signData.domain, payload: event.payload, }; - await this.sendResponse(event, result); + await this.sendResponse(event, { type: 'signData', value: result }); return result; } @@ -156,18 +157,18 @@ export class IntentHandler { const actionResponse = await this.resolver.fetchActionUrl(event.actionUrl, wallet.getAddress()); const resolvedEvent = this.parser.parseActionResponse(actionResponse, event); - if (resolvedEvent.intentType === 'transaction') { - if (resolvedEvent.resolvedTransaction) { - resolvedEvent.resolvedTransaction.fromAddress = wallet.getAddress(); + if (resolvedEvent.type === 'transaction') { + if (resolvedEvent.value.resolvedTransaction) { + resolvedEvent.value.resolvedTransaction.fromAddress = wallet.getAddress(); } - return this.approveTransactionIntent(resolvedEvent, walletId); - } else if (resolvedEvent.intentType === 'signData') { - return this.approveSignDataIntent(resolvedEvent, walletId); + return this.approveTransactionIntent(resolvedEvent.value, walletId); + } else if (resolvedEvent.type === 'signData') { + return this.approveSignDataIntent(resolvedEvent.value, walletId); } throw new WalletKitError( ERROR_CODES.VALIDATION_ERROR, - `Action URL resolved to unsupported intent type: ${resolvedEvent.intentType}`, + `Action URL resolved to unsupported intent type: ${resolvedEvent.type}`, ); } @@ -175,15 +176,14 @@ export class IntentHandler { async rejectIntent(event: IntentRequestEvent, reason?: string, errorCode?: number): Promise { const result: IntentErrorResponse = { - resultType: 'error', error: { code: errorCode ?? INTENT_ERROR_CODES.USER_DECLINED, message: reason || 'User declined the request', }, }; - await this.sendResponse(event, result); - this.pendingConnectRequests.delete(event.id); + await this.sendResponse(event.value, { type: 'error', value: result }); + this.pendingConnectRequests.delete(event.value.id); return result; } @@ -204,18 +204,22 @@ export class IntentHandler { // -- Private: Resolution & Emulation -------------------------------------- - private async resolveAndEmitTransaction(event: TransactionIntentRequestEvent, walletId: string): Promise { + private async resolveAndEmitTransaction( + event: Extract, + walletId: string, + ): Promise { + const txEvent = event.value; const wallet = this.getWallet(walletId); - const transactionRequest = await this.resolveTransaction(event, wallet); - event.resolvedTransaction = transactionRequest; + const transactionRequest = await this.resolveTransaction(txEvent, wallet); + txEvent.resolvedTransaction = transactionRequest; try { const preview = await wallet.getTransactionPreview(transactionRequest); - event.preview = { data: preview }; + txEvent.preview = preview; } catch (error) { log.warn('Failed to emulate transaction preview', { error }); - event.preview = { data: undefined }; + txEvent.preview = undefined; } this.emit(event); @@ -230,13 +234,13 @@ export class IntentHandler { // -- Private: Response sending -------------------------------------------- - private async sendResponse(event: IntentRequestEvent, result: IntentResponseResult): Promise { + private async sendResponse(event: IntentRequestBase, result: IntentResponseResult): Promise { if (!event.clientId) { log.debug('No clientId on intent event, skipping response send'); return; } - const wireResponse = this.toWireResponse(event, result); + const wireResponse = this.toWireResponse(event.id, result); try { await this.bridgeManager.sendIntentResponse(event.clientId, wireResponse, event.traceId); @@ -251,27 +255,27 @@ export class IntentHandler { * - SignData: `{ result: { signature, address, timestamp, domain, payload }, id }` * - Error: `{ error: { code, message }, id }` */ - private toWireResponse(event: IntentRequestEvent, result: IntentResponseResult): Record { - if (result.resultType === 'error') { + private toWireResponse(eventId: string, result: IntentResponseResult): Record { + if (result.type === 'error') { return { - error: { code: result.error.code, message: result.error.message }, - id: event.id, + error: { code: result.value.error.code, message: result.value.error.message }, + id: eventId, }; } - if (result.resultType === 'transaction') { - return { result: result.boc, id: event.id }; + if (result.type === 'transaction') { + return { result: result.value.boc, id: eventId }; } return { result: { - signature: result.signature, - address: result.address, - timestamp: result.timestamp, - domain: result.domain, - payload: this.signDataPayloadToWire(result.payload), + signature: result.value.signature, + address: result.value.address, + timestamp: result.value.timestamp, + domain: result.value.domain, + payload: this.signDataPayloadToWire(result.value.payload), }, - id: event.id, + id: eventId, }; } diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index 1324fdf36..6107dc7a1 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -14,6 +14,7 @@ import type { IntentActionItem, IntentOrigin, IntentRequestEvent, + IntentRequestBase, TransactionIntentRequestEvent, SignDataIntentRequestEvent, ActionIntentRequestEvent, @@ -22,6 +23,7 @@ import type { SignDataPayload, SignData, Base64String, + Network, } from '../api/models'; const INTENT_INLINE_SCHEME = 'tc://intent_inline'; @@ -443,7 +445,7 @@ export class IntentParser { throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action URL response missing action_type or action'); } - const base = { + const base: IntentRequestBase = { id: sourceEvent.id, origin: sourceEvent.origin, clientId: sourceEvent.clientId, @@ -464,9 +466,9 @@ export class IntentParser { } private parseActionTransaction( - base: { id: string; origin: string; clientId?: string; hasConnectRequest: boolean }, + base: IntentRequestBase, action: Record, - ): TransactionIntentRequestEvent { + ): IntentRequestEvent { const rawMessages = action.messages as Array> | undefined; if (!rawMessages || !Array.isArray(rawMessages) || rawMessages.length === 0) { throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action sendTransaction missing messages'); @@ -480,28 +482,32 @@ export class IntentParser { extraCurrency: (msg.extraCurrency ?? msg.extra_currency) as Record | undefined, })); + const network: Network | undefined = action.network ? { chainId: action.network as string } : undefined; + const resolvedTransaction: TransactionRequest = { messages, - network: action.network as TransactionRequest['network'], + network, validUntil: (action.valid_until ?? action.validUntil) as number | undefined, }; return { - ...base, - intentType: 'transaction', - deliveryMode: 'send', - network: action.network as string | undefined, - validUntil: resolvedTransaction.validUntil, - items: [], - resolvedTransaction, - } as TransactionIntentRequestEvent; + type: 'transaction', + value: { + ...base, + deliveryMode: 'send' as IntentDeliveryMode, + network, + validUntil: resolvedTransaction.validUntil, + items: [], + resolvedTransaction, + }, + }; } private parseActionSignData( - base: { id: string; origin: string; clientId?: string; hasConnectRequest: boolean }, + base: IntentRequestBase, action: Record, actionUrl: string, - ): SignDataIntentRequestEvent { + ): IntentRequestEvent { const wirePayload = { type: action.type as string, text: action.text as string | undefined, @@ -515,12 +521,14 @@ export class IntentParser { } return { - ...base, - intentType: 'signData', - network: action.network as string | undefined, - manifestUrl: actionUrl, - payload: this.wirePayloadToSignDataPayload(wirePayload), - } as SignDataIntentRequestEvent; + type: 'signData', + value: { + ...base, + network: action.network ? { chainId: action.network as string } : undefined, + manifestUrl: actionUrl, + payload: this.wirePayloadToSignDataPayload(wirePayload), + }, + }; } // -- Wire → Model mapping ------------------------------------------------- @@ -529,7 +537,7 @@ export class IntentParser { const { clientId, request, origin, traceId } = parsed; const hasConnectRequest = !!request.c; - const base = { + const base: IntentRequestBase = { id: request.id, origin, clientId, @@ -545,32 +553,38 @@ export class IntentParser { case 'signMsg': { const deliveryMode: IntentDeliveryMode = request.m === 'txIntent' ? 'send' : 'signOnly'; event = { - ...base, - intentType: 'transaction', - deliveryMode, - network: request.n, - validUntil: request.vu, - items: this.mapItems(request.i!), - } as TransactionIntentRequestEvent; + type: 'transaction', + value: { + ...base, + deliveryMode, + network: request.n ? { chainId: request.n } : undefined, + validUntil: request.vu, + items: this.mapItems(request.i!), + }, + }; break; } case 'signIntent': { const manifestUrl = request.mu || request.c?.manifestUrl || ''; event = { - ...base, - intentType: 'signData', - network: request.n, - manifestUrl, - payload: this.wirePayloadToSignDataPayload(request.p!), - } as SignDataIntentRequestEvent; + type: 'signData', + value: { + ...base, + network: request.n ? { chainId: request.n } : undefined, + manifestUrl, + payload: this.wirePayloadToSignDataPayload(request.p!), + }, + }; break; } case 'actionIntent': { event = { - ...base, - intentType: 'action', - actionUrl: request.a!, - } as ActionIntentRequestEvent; + type: 'action', + value: { + ...base, + actionUrl: request.a!, + }, + }; break; } } @@ -587,34 +601,40 @@ export class IntentParser { case 'ton': return { type: 'sendTon', - address: item.a!, - amount: item.am!, - payload: item.p as Base64String | undefined, - stateInit: item.si as Base64String | undefined, - extraCurrency: item.ec, + value: { + address: item.a!, + amount: item.am!, + payload: item.p as Base64String | undefined, + stateInit: item.si as Base64String | undefined, + extraCurrency: item.ec, + }, }; case 'jetton': return { type: 'sendJetton', - jettonMasterAddress: item.ma!, - jettonAmount: item.ja!, - destination: item.d!, - responseDestination: item.rd, - customPayload: item.cp as Base64String | undefined, - forwardTonAmount: item.fta, - forwardPayload: item.fp as Base64String | undefined, - queryId: item.qi, + value: { + jettonMasterAddress: item.ma!, + jettonAmount: item.ja!, + destination: item.d!, + responseDestination: item.rd, + customPayload: item.cp as Base64String | undefined, + forwardTonAmount: item.fta, + forwardPayload: item.fp as Base64String | undefined, + queryId: item.qi, + }, }; case 'nft': return { type: 'sendNft', - nftAddress: item.na!, - newOwnerAddress: item.no!, - responseDestination: item.rd, - customPayload: item.cp as Base64String | undefined, - forwardTonAmount: item.fta, - forwardPayload: item.fp as Base64String | undefined, - queryId: item.qi, + value: { + nftAddress: item.na!, + newOwnerAddress: item.no!, + responseDestination: item.rd, + customPayload: item.cp as Base64String | undefined, + forwardTonAmount: item.fta, + forwardPayload: item.fp as Base64String | undefined, + queryId: item.qi, + }, }; } } diff --git a/packages/walletkit/src/handlers/IntentResolver.ts b/packages/walletkit/src/handlers/IntentResolver.ts index bfbb098bc..7a238cfb8 100644 --- a/packages/walletkit/src/handlers/IntentResolver.ts +++ b/packages/walletkit/src/handlers/IntentResolver.ts @@ -26,6 +26,7 @@ import type { SendJettonAction, SendNftAction, Base64String, + Network, } from '../api/models'; const log = globalLogger.createChild('IntentResolver'); @@ -45,7 +46,7 @@ export class IntentResolver { async intentItemsToTransactionRequest( items: IntentActionItem[], wallet: Wallet, - network?: string, + network?: Network, validUntil?: number, ): Promise { const messages: TransactionRequestMessage[] = []; @@ -57,7 +58,7 @@ export class IntentResolver { return { messages, - network: network as TransactionRequest['network'], + network, validUntil, fromAddress: wallet.getAddress(), }; @@ -88,11 +89,11 @@ export class IntentResolver { private async resolveItem(item: IntentActionItem, wallet: Wallet): Promise { switch (item.type) { case 'sendTon': - return this.resolveTonItem(item); + return this.resolveTonItem(item.value); case 'sendJetton': - return this.resolveJettonItem(item, wallet); + return this.resolveJettonItem(item.value, wallet); case 'sendNft': - return this.resolveNftItem(item, wallet); + return this.resolveNftItem(item.value, wallet); } } From 399276093ab9c8a38203e8335e99dabe29e0cd1d Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Wed, 25 Feb 2026 21:00:22 +0530 Subject: [PATCH 05/15] fix: unwrap { type, value } event wrapper in bridge approve/reject methods --- packages/walletkit-android-bridge/src/api/intents.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/walletkit-android-bridge/src/api/intents.ts b/packages/walletkit-android-bridge/src/api/intents.ts index b9bc8b91d..2234de625 100644 --- a/packages/walletkit-android-bridge/src/api/intents.ts +++ b/packages/walletkit-android-bridge/src/api/intents.ts @@ -34,22 +34,26 @@ export async function handleIntentUrl(args: HandleIntentUrlArgs) { export async function approveTransactionIntent(args: ApproveTransactionIntentArgs) { const kit = await getKit(); - return kit.approveTransactionIntent(args.event, args.walletId); + const event = (args.event as any).value ?? args.event; + return kit.approveTransactionIntent(event, args.walletId); } export async function approveSignDataIntent(args: ApproveSignDataIntentArgs) { const kit = await getKit(); - return kit.approveSignDataIntent(args.event, args.walletId); + const event = (args.event as any).value ?? args.event; + return kit.approveSignDataIntent(event, args.walletId); } export async function approveActionIntent(args: ApproveActionIntentArgs) { const kit = await getKit(); - return kit.approveActionIntent(args.event, args.walletId); + const event = (args.event as any).value ?? args.event; + return kit.approveActionIntent(event, args.walletId); } export async function rejectIntent(args: RejectIntentArgs) { const kit = await getKit(); - return kit.rejectIntent(args.event, args.reason, args.errorCode); + const event = (args.event as any).value ?? args.event; + return kit.rejectIntent(event, args.reason, args.errorCode); } export async function intentItemsToTransactionRequest(args: IntentItemsToTransactionRequestArgs) { From 734f49003c8befa98794f426ff887a784fba5b3e Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 10:44:01 +0530 Subject: [PATCH 06/15] feat: update address type to UserFriendlyAddress in IntentSignDataResponse --- packages/walletkit/src/api/models/intents/IntentResponse.ts | 6 +++--- packages/walletkit/src/handlers/IntentHandler.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/walletkit/src/api/models/intents/IntentResponse.ts b/packages/walletkit/src/api/models/intents/IntentResponse.ts index 9d09c4392..221ed2544 100644 --- a/packages/walletkit/src/api/models/intents/IntentResponse.ts +++ b/packages/walletkit/src/api/models/intents/IntentResponse.ts @@ -6,7 +6,7 @@ * */ -import type { Base64String } from '../core/Primitives'; +import type { Base64String, UserFriendlyAddress } from '../core/Primitives'; import type { SignDataPayload } from '../core/PreparedSignData'; /** @@ -23,8 +23,8 @@ export interface IntentTransactionResponse { export interface IntentSignDataResponse { /** Signature (base64) */ signature: Base64String; - /** Signer address (raw format: 0:hex) */ - address: string; + /** Signer address */ + address: UserFriendlyAddress; /** * UNIX timestamp (seconds, UTC) * @format timestamp diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index 4743edef3..0accef4cd 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -6,7 +6,6 @@ * */ -import { Address } from '@ton/core'; import type { ConnectRequest } from '@tonconnect/protocol'; import { globalLogger } from '../core/Logger'; @@ -34,6 +33,7 @@ import type { TransactionRequest, SignDataPayload, Base64String, + UserFriendlyAddress, Network, } from '../api/models'; import type { TonWalletKitOptions } from '../types'; @@ -138,7 +138,7 @@ export class IntentHandler { const result: IntentSignDataResponse = { signature: signatureBase64 as Base64String, - address: Address.parse(wallet.getAddress()).toRawString(), + address: wallet.getAddress() as UserFriendlyAddress, timestamp: signData.timestamp, domain: signData.domain, payload: event.payload, From 91b7a767b633d32bdfb0341794f43435ec4e816c Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 11:11:20 +0530 Subject: [PATCH 07/15] refactor: simplify intent API methods and improve type handling --- .../src/api/intents.ts | 56 ++++++------------- .../src/types/walletkit.ts | 34 +++++++++++ packages/walletkit/src/api/models/index.ts | 1 - packages/walletkit/src/core/TonWalletKit.ts | 5 +- .../walletkit/src/handlers/IntentHandler.ts | 1 - .../walletkit/src/handlers/IntentParser.ts | 12 +--- 6 files changed, 54 insertions(+), 55 deletions(-) diff --git a/packages/walletkit-android-bridge/src/api/intents.ts b/packages/walletkit-android-bridge/src/api/intents.ts index 2234de625..e45935563 100644 --- a/packages/walletkit-android-bridge/src/api/intents.ts +++ b/packages/walletkit-android-bridge/src/api/intents.ts @@ -10,58 +10,36 @@ * intents.ts – Bridge API for intent operations */ -import { getKit } from '../utils/bridge'; -import type { - HandleIntentUrlArgs, - IsIntentUrlArgs, - ApproveTransactionIntentArgs, - ApproveSignDataIntentArgs, - ApproveActionIntentArgs, - RejectIntentArgs, - IntentItemsToTransactionRequestArgs, - ProcessConnectAfterIntentArgs, -} from '../types'; +import { kit } from '../utils/bridge'; -export async function isIntentUrl(args: IsIntentUrlArgs) { - const kit = await getKit(); - return kit.isIntentUrl(args.url); +export async function isIntentUrl(args: unknown[]) { + return kit('isIntentUrl', ...args); } -export async function handleIntentUrl(args: HandleIntentUrlArgs) { - const kit = await getKit(); - return kit.handleIntentUrl(args.url, args.walletId); +export async function handleIntentUrl(args: unknown[]) { + return kit('handleIntentUrl', ...args); } -export async function approveTransactionIntent(args: ApproveTransactionIntentArgs) { - const kit = await getKit(); - const event = (args.event as any).value ?? args.event; - return kit.approveTransactionIntent(event, args.walletId); +export async function approveTransactionIntent(args: unknown[]) { + return kit('approveTransactionIntent', ...args); } -export async function approveSignDataIntent(args: ApproveSignDataIntentArgs) { - const kit = await getKit(); - const event = (args.event as any).value ?? args.event; - return kit.approveSignDataIntent(event, args.walletId); +export async function approveSignDataIntent(args: unknown[]) { + return kit('approveSignDataIntent', ...args); } -export async function approveActionIntent(args: ApproveActionIntentArgs) { - const kit = await getKit(); - const event = (args.event as any).value ?? args.event; - return kit.approveActionIntent(event, args.walletId); +export async function approveActionIntent(args: unknown[]) { + return kit('approveActionIntent', ...args); } -export async function rejectIntent(args: RejectIntentArgs) { - const kit = await getKit(); - const event = (args.event as any).value ?? args.event; - return kit.rejectIntent(event, args.reason, args.errorCode); +export async function rejectIntent(args: unknown[]) { + return kit('rejectIntent', ...args); } -export async function intentItemsToTransactionRequest(args: IntentItemsToTransactionRequestArgs) { - const kit = await getKit(); - return kit.intentItemsToTransactionRequest(args.items, args.walletId); +export async function intentItemsToTransactionRequest(args: unknown[]) { + return kit('intentItemsToTransactionRequest', ...args); } -export async function processConnectAfterIntent(args: ProcessConnectAfterIntentArgs) { - const kit = await getKit(); - return kit.processConnectAfterIntent(args.event, args.walletId, args.proof); +export async function processConnectAfterIntent(args: unknown[]) { + return kit('processConnectAfterIntent', ...args); } diff --git a/packages/walletkit-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index 9266e24aa..cd6eb47c5 100644 --- a/packages/walletkit-android-bridge/src/types/walletkit.ts +++ b/packages/walletkit-android-bridge/src/types/walletkit.ts @@ -8,19 +8,29 @@ import type { ApiClient, + BatchedIntentEvent, BridgeEventMessageInfo, + ConnectionApprovalProof, ConnectionApprovalResponse, ConnectionRequestEvent, DeviceInfo, DisconnectionEvent, InjectedToExtensionBridgeRequestPayload, + IntentActionItem, + IntentErrorResponse, + IntentRequestEvent, + IntentSignDataResponse, + IntentTransactionResponse, + ActionIntentRequestEvent, Network, RequestErrorEvent, SendTransactionApprovalResponse, SendTransactionRequestEvent, SignDataApprovalResponse, + SignDataIntentRequestEvent, SignDataRequestEvent, TONConnectSession, + TransactionIntentRequestEvent, TransactionRequest, Wallet, WalletAdapter, @@ -114,4 +124,28 @@ export interface WalletKitInstance { event: SignDataRequestEvent, reason?: string | SendTransactionRpcResponseError['error'], ): Promise; + // Intent API + isIntentUrl(url: string): boolean; + handleIntentUrl(url: string, walletId: string): Promise; + onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void; + removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void; + approveTransactionIntent( + event: TransactionIntentRequestEvent, + walletId: string, + ): Promise; + approveSignDataIntent( + event: SignDataIntentRequestEvent, + walletId: string, + ): Promise; + approveActionIntent( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise; + rejectIntent(event: IntentRequestEvent, reason?: string, errorCode?: number): Promise; + intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise; + processConnectAfterIntent( + event: IntentRequestEvent | BatchedIntentEvent, + walletId: string, + proof?: ConnectionApprovalProof, + ): Promise; } diff --git a/packages/walletkit/src/api/models/index.ts b/packages/walletkit/src/api/models/index.ts index 4ce036073..3a3facd57 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -112,6 +112,5 @@ export type { } from './intents/IntentResponse'; export type { BatchedIntentEvent } from './intents/BatchedIntentEvent'; - // RPC models export type { GetMethodResult } from './rpc/GetMethodResult'; diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 59b750256..c0a4d3b00 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -634,10 +634,7 @@ export class TonWalletKit implements ITonWalletKit { connectionEvent.walletId = walletId; // Auto-approve: create session and send ConnectEvent response to dApp - await this.requestProcessor.approveConnectRequest( - connectionEvent, - proof ? { proof } : undefined, - ); + await this.requestProcessor.approveConnectRequest(connectionEvent, proof ? { proof } : undefined); } // === URL Processing API === diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index 0accef4cd..c5fec823d 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -34,7 +34,6 @@ import type { SignDataPayload, Base64String, UserFriendlyAddress, - Network, } from '../api/models'; import type { TonWalletKitOptions } from '../types'; diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index 6107dc7a1..2561efd38 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -15,8 +15,6 @@ import type { IntentOrigin, IntentRequestEvent, IntentRequestBase, - TransactionIntentRequestEvent, - SignDataIntentRequestEvent, ActionIntentRequestEvent, IntentDeliveryMode, TransactionRequest, @@ -312,10 +310,7 @@ export class IntentParser { const ciphertext = encrypted.slice(24); const decrypted = nacl.box.open(ciphertext, nonce, clientPubKey, walletPrivateKey); if (!decrypted) { - throw new WalletKitError( - ERROR_CODES.VALIDATION_ERROR, - 'Failed to decrypt intent payload', - ); + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Failed to decrypt intent payload'); } return new TextDecoder().decode(decrypted); @@ -465,10 +460,7 @@ export class IntentParser { } } - private parseActionTransaction( - base: IntentRequestBase, - action: Record, - ): IntentRequestEvent { + private parseActionTransaction(base: IntentRequestBase, action: Record): IntentRequestEvent { const rawMessages = action.messages as Array> | undefined; if (!rawMessages || !Array.isArray(rawMessages) || rawMessages.length === 0) { throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action sendTransaction missing messages'); From 13c3b9101be48364fbca3bdf5e71fd42d805e940 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 11:30:44 +0530 Subject: [PATCH 08/15] refactor: fix lint --- packages/walletkit-android-bridge/src/types/walletkit.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/walletkit-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index cd6eb47c5..49b543cf8 100644 --- a/packages/walletkit-android-bridge/src/types/walletkit.ts +++ b/packages/walletkit-android-bridge/src/types/walletkit.ts @@ -133,10 +133,7 @@ export interface WalletKitInstance { event: TransactionIntentRequestEvent, walletId: string, ): Promise; - approveSignDataIntent( - event: SignDataIntentRequestEvent, - walletId: string, - ): Promise; + approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise; approveActionIntent( event: ActionIntentRequestEvent, walletId: string, From 9ef5e47fdbba91bce3ee3991d2b66e6decd06386 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 11:59:13 +0530 Subject: [PATCH 09/15] test: add unit tests for IntentHandler and IntentParser --- .../src/handlers/IntentHandler.spec.ts | 282 ++++++++++ .../src/handlers/IntentParser.spec.ts | 494 ++++++++++++++++++ 2 files changed, 776 insertions(+) create mode 100644 packages/walletkit/src/handlers/IntentHandler.spec.ts create mode 100644 packages/walletkit/src/handlers/IntentParser.spec.ts diff --git a/packages/walletkit/src/handlers/IntentHandler.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts new file mode 100644 index 000000000..ef60d4f84 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -0,0 +1,282 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { IntentHandler } from './IntentHandler'; +import type { BridgeManager } from '../core/BridgeManager'; +import type { WalletManager } from '../core/WalletManager'; +import type { Wallet } from '../api/interfaces'; +import type { TonWalletKitOptions } from '../types'; +import type { + IntentRequestEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + SignDataPayload, +} from '../api/models'; + +/** + * Create a minimal mock wallet that satisfies IntentHandler's usage. + */ +// Real TON address required by Address.parse in PrepareSignData +const VALID_TON_ADDRESS = 'UQCdqXGvONLwOr3zCNX5FjapflorB6ZsOdcdfLrjsDLt3AF4'; + +function createMockWallet(address = VALID_TON_ADDRESS): Wallet { + return { + getAddress: vi.fn().mockReturnValue(address), + getSignedSendTransaction: vi.fn().mockResolvedValue('signed-boc-base64'), + getSignedSignData: vi.fn().mockResolvedValue('aabbccdd'), + getClient: vi.fn().mockReturnValue({ + sendBoc: vi.fn().mockResolvedValue(undefined), + }), + getJettonWalletAddress: vi.fn().mockResolvedValue('EQJettonWallet'), + getNetwork: vi.fn().mockReturnValue({ chainId: '-239' }), + getWalletId: vi.fn().mockReturnValue('wallet-1'), + getTransactionPreview: vi.fn().mockResolvedValue({ actions: [] }), + } as unknown as Wallet; +} + +function createMockBridgeManager(): BridgeManager { + return { + sendIntentResponse: vi.fn().mockResolvedValue(undefined), + } as unknown as BridgeManager; +} + +function createMockWalletManager(wallet?: Wallet): WalletManager { + return { + getWallet: vi.fn().mockReturnValue(wallet ?? createMockWallet()), + } as unknown as WalletManager; +} + +const defaultOptions: TonWalletKitOptions = { + networks: {}, +}; + +describe('IntentHandler', () => { + let bridgeManager: BridgeManager; + let walletManager: WalletManager; + let mockWallet: Wallet; + let handler: IntentHandler; + + beforeEach(() => { + bridgeManager = createMockBridgeManager(); + mockWallet = createMockWallet(); + walletManager = createMockWalletManager(mockWallet); + handler = new IntentHandler(defaultOptions, bridgeManager, walletManager); + }); + + // ── approveTransactionIntent ───────────────────────────────────────────── + + describe('approveTransactionIntent', () => { + /** Helper to build an event with resolvedTransaction so IntentResolver is bypassed. */ + function txEvent(overrides: Partial = {}): TransactionIntentRequestEvent { + return { + id: 'tx-1', + origin: 'deepLink', + clientId: 'client-1', + hasConnectRequest: false, + deliveryMode: 'send', + items: [{ type: 'sendTon', value: { address: 'EQAddr', amount: '1000000000' } }], + resolvedTransaction: { + messages: [{ address: 'EQAddr', amount: '1000000000' }], + fromAddress: 'UQTestAddr', + }, + ...overrides, + }; + } + + it('signs and sends a transaction, returns boc', async () => { + const result = await handler.approveTransactionIntent(txEvent(), 'wallet-1'); + + expect(result.boc).toBe('signed-boc-base64'); + expect(mockWallet.getSignedSendTransaction).toHaveBeenCalled(); + expect((mockWallet.getClient() as any).sendBoc).toHaveBeenCalledWith('signed-boc-base64'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('does not send boc when deliveryMode is signOnly', async () => { + const result = await handler.approveTransactionIntent(txEvent({ id: 'tx-2', deliveryMode: 'signOnly' }), 'wallet-1'); + + expect(result.boc).toBe('signed-boc-base64'); + expect((mockWallet.getClient() as any).sendBoc).not.toHaveBeenCalled(); + }); + + it('does not send boc when dev.disableNetworkSend is true', async () => { + const devHandler = new IntentHandler( + { ...defaultOptions, dev: { disableNetworkSend: true } }, + bridgeManager, + walletManager, + ); + + await devHandler.approveTransactionIntent(txEvent({ id: 'tx-3' }), 'wallet-1'); + expect((mockWallet.getClient() as any).sendBoc).not.toHaveBeenCalled(); + }); + + it('skips bridge send when clientId is absent', async () => { + await handler.approveTransactionIntent(txEvent({ id: 'tx-4', clientId: '' }), 'wallet-1'); + expect(bridgeManager.sendIntentResponse).not.toHaveBeenCalled(); + }); + }); + + // ── approveSignDataIntent ──────────────────────────────────────────────── + + describe('approveSignDataIntent', () => { + const signPayload: SignDataPayload = { + data: { type: 'text', value: { content: 'Sign this' } }, + }; + + it('signs data and returns result', async () => { + const event: SignDataIntentRequestEvent = { + id: 'sd-1', + origin: 'deepLink', + clientId: 'client-1', + hasConnectRequest: false, + manifestUrl: 'https://example.com/manifest.json', + payload: signPayload, + }; + + const result = await handler.approveSignDataIntent(event, 'wallet-1'); + + expect(result.signature).toBeDefined(); + expect(result.address).toBe(VALID_TON_ADDRESS); + expect(result.timestamp).toBeDefined(); + expect(result.domain).toBe('example.com'); + expect(mockWallet.getSignedSignData).toHaveBeenCalled(); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('falls back to raw manifestUrl for domain on invalid URL', async () => { + const event: SignDataIntentRequestEvent = { + id: 'sd-2', + origin: 'deepLink', + clientId: 'client-1', + hasConnectRequest: false, + manifestUrl: 'not-a-valid-url', + payload: signPayload, + }; + + const result = await handler.approveSignDataIntent(event, 'wallet-1'); + expect(result.domain).toBe('not-a-valid-url'); + }); + }); + + // ── rejectIntent ───────────────────────────────────────────────────────── + + describe('rejectIntent', () => { + it('sends error response with user declined code by default', async () => { + const event: IntentRequestEvent = { + type: 'transaction', + value: { + id: 'tx-r1', + origin: 'deepLink', + clientId: 'client-1', + hasConnectRequest: false, + deliveryMode: 'send', + items: [], + }, + }; + + const result = await handler.rejectIntent(event); + + expect(result.error.code).toBe(300); // USER_DECLINED + expect(result.error.message).toBe('User declined the request'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('uses custom reason and error code', async () => { + const event: IntentRequestEvent = { + type: 'signData', + value: { + id: 'sd-r1', + origin: 'deepLink', + clientId: 'client-1', + hasConnectRequest: false, + manifestUrl: 'https://example.com', + payload: { data: { type: 'text', value: { content: 'test' } } }, + }, + }; + + const result = await handler.rejectIntent(event, 'Not supported', 400); + + expect(result.error.code).toBe(400); + expect(result.error.message).toBe('Not supported'); + }); + + it('cleans up pending connect request on reject', async () => { + // Store a pending connect request + const url = buildInlineUrl('c1', { + id: 'tx-pcr', + m: 'txIntent', + i: [{ t: 'ton', a: 'EQAddr', am: '100' }], + c: { manifestUrl: 'https://dapp.com/m.json', items: [{ name: 'ton_addr' }] }, + }); + await handler.handleIntentUrl(url, 'wallet-1'); + + // Should have pending connect request + expect(handler.getPendingConnectRequest('tx-pcr')).toBeDefined(); + + // Reject + const event: IntentRequestEvent = { + type: 'transaction', + value: { + id: 'tx-pcr', + origin: 'deepLink', + clientId: 'c1', + hasConnectRequest: true, + deliveryMode: 'send', + items: [], + }, + }; + await handler.rejectIntent(event); + + // Pending connect request should be cleaned up + expect(handler.getPendingConnectRequest('tx-pcr')).toBeUndefined(); + }); + }); + + // ── getWallet error ────────────────────────────────────────────────────── + + describe('wallet not found', () => { + it('throws when wallet is not found', async () => { + const noWalletManager = { + getWallet: vi.fn().mockReturnValue(undefined), + } as unknown as WalletManager; + const h = new IntentHandler(defaultOptions, bridgeManager, noWalletManager); + + const event: TransactionIntentRequestEvent = { + id: 'tx-nw', + origin: 'deepLink', + clientId: 'c1', + hasConnectRequest: false, + deliveryMode: 'send', + items: [{ type: 'sendTon', value: { address: 'EQ1', amount: '100' } }], + resolvedTransaction: { + messages: [{ address: 'EQ1', amount: '100' }], + fromAddress: 'UQ1', + }, + }; + + await expect(h.approveTransactionIntent(event, 'missing-wallet')).rejects.toThrow('Wallet not found'); + }); + }); +}); + +/** + * Helper: Build a tc://intent_inline URL from a wire request object. + */ +function buildInlineUrl( + clientId: string, + request: Record, + opts?: { traceId?: string }, +): string { + const json = JSON.stringify(request); + const b64 = Buffer.from(json, 'utf-8').toString('base64url'); + let url = `tc://intent_inline?id=${clientId}&r=${b64}`; + if (opts?.traceId) url += `&trace_id=${opts.traceId}`; + return url; +} diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts new file mode 100644 index 000000000..6f0ca3aef --- /dev/null +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -0,0 +1,494 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { IntentParser } from './IntentParser'; + +/** + * Helper: Build a tc://intent_inline URL from a wire request object. + * Encodes the request as base64url in the `r` parameter. + */ +function buildInlineUrl( + clientId: string, + request: Record, + opts?: { traceId?: string }, +): string { + const json = JSON.stringify(request); + // base64url encode + const b64 = Buffer.from(json, 'utf-8').toString('base64url'); + let url = `tc://intent_inline?id=${clientId}&r=${b64}`; + if (opts?.traceId) url += `&trace_id=${opts.traceId}`; + return url; +} + +describe('IntentParser', () => { + let parser: IntentParser; + + beforeEach(() => { + parser = new IntentParser(); + }); + + // ── isIntentUrl ────────────────────────────────────────────────────────── + + describe('isIntentUrl', () => { + it('returns true for tc://intent_inline URLs', () => { + expect(parser.isIntentUrl('tc://intent_inline?id=abc&r=data')).toBe(true); + }); + + it('returns true for tc://intent URLs', () => { + expect(parser.isIntentUrl('tc://intent?id=abc&pk=key&get_url=http://example.com')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(parser.isIntentUrl('TC://INTENT_INLINE?id=abc')).toBe(true); + expect(parser.isIntentUrl(' TC://INTENT?id=abc ')).toBe(true); + }); + + it('returns false for non-intent URLs', () => { + expect(parser.isIntentUrl('https://example.com')).toBe(false); + expect(parser.isIntentUrl('tc://connect?id=abc')).toBe(false); + expect(parser.isIntentUrl('')).toBe(false); + }); + }); + + // ── parse – inline txIntent ────────────────────────────────────────────── + + describe('parse – txIntent (inline)', () => { + it('parses a transaction intent with TON items', async () => { + const url = buildInlineUrl('client-123', { + id: 'tx-1', + m: 'txIntent', + i: [ + { t: 'ton', a: 'EQAddr1', am: '1000000000' }, + { t: 'ton', a: 'EQAddr2', am: '2000000000', p: 'payload-b64' }, + ], + vu: 1700000000, + n: '-239', + }); + + const { event, connectRequest } = await parser.parse(url); + + expect(connectRequest).toBeUndefined(); + expect(event.type).toBe('transaction'); + + if (event.type !== 'transaction') throw new Error('unexpected'); + const tx = event.value; + + expect(tx.id).toBe('tx-1'); + expect(tx.origin).toBe('deepLink'); + expect(tx.clientId).toBe('client-123'); + expect(tx.hasConnectRequest).toBe(false); + expect(tx.deliveryMode).toBe('send'); + expect(tx.network).toEqual({ chainId: '-239' }); + expect(tx.validUntil).toBe(1700000000); + expect(tx.items).toHaveLength(2); + + expect(tx.items[0].type).toBe('sendTon'); + if (tx.items[0].type === 'sendTon') { + expect(tx.items[0].value.address).toBe('EQAddr1'); + expect(tx.items[0].value.amount).toBe('1000000000'); + } + + expect(tx.items[1].type).toBe('sendTon'); + if (tx.items[1].type === 'sendTon') { + expect(tx.items[1].value.payload).toBe('payload-b64'); + } + }); + + it('parses signMsg as signOnly delivery mode', async () => { + const url = buildInlineUrl('c1', { + id: 'sm-1', + m: 'signMsg', + i: [{ t: 'ton', a: 'EQ1', am: '100' }], + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + expect(event.value.deliveryMode).toBe('signOnly'); + } + }); + + it('parses jetton items', async () => { + const url = buildInlineUrl('c1', { + id: 'j-1', + m: 'txIntent', + i: [ + { + t: 'jetton', + ma: 'EQJettonMaster', + ja: '5000000', + d: 'EQDest', + rd: 'EQResp', + fta: '10000', + qi: 42, + }, + ], + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + const item = event.value.items[0]; + expect(item.type).toBe('sendJetton'); + if (item.type === 'sendJetton') { + expect(item.value.jettonMasterAddress).toBe('EQJettonMaster'); + expect(item.value.jettonAmount).toBe('5000000'); + expect(item.value.destination).toBe('EQDest'); + expect(item.value.responseDestination).toBe('EQResp'); + expect(item.value.forwardTonAmount).toBe('10000'); + expect(item.value.queryId).toBe(42); + } + } + }); + + it('parses NFT items', async () => { + const url = buildInlineUrl('c1', { + id: 'n-1', + m: 'txIntent', + i: [ + { + t: 'nft', + na: 'EQNftAddr', + no: 'EQNewOwner', + rd: 'EQResp', + }, + ], + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + const item = event.value.items[0]; + expect(item.type).toBe('sendNft'); + if (item.type === 'sendNft') { + expect(item.value.nftAddress).toBe('EQNftAddr'); + expect(item.value.newOwnerAddress).toBe('EQNewOwner'); + expect(item.value.responseDestination).toBe('EQResp'); + } + } + }); + + }); + + // ── parse – inline signIntent ──────────────────────────────────────────── + + describe('parse – signIntent (inline)', () => { + it('parses a text sign data intent', async () => { + const url = buildInlineUrl('c1', { + id: 'si-1', + m: 'signIntent', + mu: 'https://example.com/manifest.json', + p: { type: 'text', text: 'Hello world' }, + }); + + const { event } = await parser.parse(url); + + expect(event.type).toBe('signData'); + if (event.type === 'signData') { + expect(event.value.id).toBe('si-1'); + expect(event.value.manifestUrl).toBe('https://example.com/manifest.json'); + expect(event.value.payload.data.type).toBe('text'); + if (event.value.payload.data.type === 'text') { + expect(event.value.payload.data.value.content).toBe('Hello world'); + } + } + }); + + it('parses a binary sign data intent', async () => { + const url = buildInlineUrl('c1', { + id: 'si-2', + m: 'signIntent', + mu: 'https://example.com/manifest.json', + p: { type: 'binary', bytes: 'AQID' }, + }); + + const { event } = await parser.parse(url); + if (event.type === 'signData') { + expect(event.value.payload.data.type).toBe('binary'); + if (event.value.payload.data.type === 'binary') { + expect(event.value.payload.data.value.content).toBe('AQID'); + } + } + }); + + it('parses a cell sign data intent', async () => { + const url = buildInlineUrl('c1', { + id: 'si-3', + m: 'signIntent', + mu: 'https://example.com/manifest.json', + p: { type: 'cell', cell: 'te6cckEBAQEA', schema: 'MySchema' }, + }); + + const { event } = await parser.parse(url); + if (event.type === 'signData') { + expect(event.value.payload.data.type).toBe('cell'); + if (event.value.payload.data.type === 'cell') { + expect(event.value.payload.data.value.content).toBe('te6cckEBAQEA'); + expect(event.value.payload.data.value.schema).toBe('MySchema'); + } + } + }); + + it('uses manifestUrl from connect request when mu is absent', async () => { + const url = buildInlineUrl('c1', { + id: 'si-4', + m: 'signIntent', + c: { + manifestUrl: 'https://dapp.com/manifest.json', + items: [{ name: 'ton_addr' }], + }, + p: { type: 'text', text: 'Sign this' }, + }); + + const { event, connectRequest } = await parser.parse(url); + expect(connectRequest).toBeDefined(); + if (event.type === 'signData') { + expect(event.value.manifestUrl).toBe('https://dapp.com/manifest.json'); + expect(event.value.hasConnectRequest).toBe(true); + } + }); + }); + + // ── parse – inline actionIntent ────────────────────────────────────────── + + describe('parse – actionIntent (inline)', () => { + it('parses an action intent', async () => { + const url = buildInlineUrl('c1', { + id: 'a-1', + m: 'actionIntent', + a: 'https://api.example.com/action', + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('action'); + if (event.type === 'action') { + expect(event.value.id).toBe('a-1'); + expect(event.value.actionUrl).toBe('https://api.example.com/action'); + } + }); + }); + + // ── parse – validation errors ──────────────────────────────────────────── + + describe('parse – validation', () => { + it('rejects URL without client ID', async () => { + const json = JSON.stringify({ id: 'x', m: 'txIntent', i: [{ t: 'ton', a: 'A', am: '1' }] }); + const b64 = Buffer.from(json).toString('base64url'); + const url = `tc://intent_inline?r=${b64}`; + + await expect(parser.parse(url)).rejects.toThrow('Missing client ID'); + }); + + it('rejects URL without payload', async () => { + const url = 'tc://intent_inline?id=c1'; + await expect(parser.parse(url)).rejects.toThrow('Missing payload'); + }); + + it('rejects unknown intent method', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'badMethod' }); + await expect(parser.parse(url)).rejects.toThrow('Invalid intent method'); + }); + + it('rejects txIntent without items', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'txIntent' }); + await expect(parser.parse(url)).rejects.toThrow('missing items'); + }); + + it('rejects txIntent with invalid item type', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'txIntent', i: [{ t: 'unknown' }] }); + await expect(parser.parse(url)).rejects.toThrow('Invalid intent item type'); + }); + + it('rejects ton item missing address', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'txIntent', i: [{ t: 'ton', am: '100' }] }); + await expect(parser.parse(url)).rejects.toThrow('missing address'); + }); + + it('rejects ton item missing amount', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'txIntent', i: [{ t: 'ton', a: 'A' }] }); + await expect(parser.parse(url)).rejects.toThrow('missing amount'); + }); + + it('rejects jetton item missing master address', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'txIntent', + i: [{ t: 'jetton', ja: '100', d: 'D' }], + }); + await expect(parser.parse(url)).rejects.toThrow('missing master address'); + }); + + it('rejects jetton item missing amount', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'txIntent', + i: [{ t: 'jetton', ma: 'MA', d: 'D' }], + }); + await expect(parser.parse(url)).rejects.toThrow('missing amount'); + }); + + it('rejects jetton item missing destination', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'txIntent', + i: [{ t: 'jetton', ma: 'MA', ja: '100' }], + }); + await expect(parser.parse(url)).rejects.toThrow('missing destination'); + }); + + it('rejects NFT item missing address', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'txIntent', + i: [{ t: 'nft', no: 'NO' }], + }); + await expect(parser.parse(url)).rejects.toThrow('missing address'); + }); + + it('rejects NFT item missing new owner', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'txIntent', + i: [{ t: 'nft', na: 'NA' }], + }); + await expect(parser.parse(url)).rejects.toThrow('missing new owner'); + }); + + it('rejects signIntent without manifest URL', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'signIntent', + p: { type: 'text', text: 'hello' }, + }); + await expect(parser.parse(url)).rejects.toThrow('missing manifest URL'); + }); + + it('rejects signIntent without payload', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'signIntent', + mu: 'https://example.com/m.json', + }); + await expect(parser.parse(url)).rejects.toThrow('missing payload'); + }); + + it('rejects actionIntent without action URL', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'actionIntent' }); + await expect(parser.parse(url)).rejects.toThrow('missing action URL'); + }); + + it('rejects request without id', async () => { + const url = buildInlineUrl('c1', { m: 'txIntent', i: [{ t: 'ton', a: 'A', am: '1' }] }); + await expect(parser.parse(url)).rejects.toThrow('missing id'); + }); + + it('rejects unsupported sign data type', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'signIntent', + mu: 'https://example.com/m.json', + p: { type: 'unsupported' }, + }); + await expect(parser.parse(url)).rejects.toThrow('Unsupported sign data type'); + }); + }); + + // ── parseActionResponse ────────────────────────────────────────────────── + + describe('parseActionResponse', () => { + const baseActionEvent = { + id: 'a-1', + origin: 'deepLink' as const, + clientId: 'c1', + hasConnectRequest: false, + actionUrl: 'https://api.example.com/action', + }; + + it('parses sendTransaction action response', () => { + const payload = { + action_type: 'sendTransaction', + action: { + messages: [ + { address: 'EQAddr', amount: '500', payload: 'abc123' }, + ], + valid_until: 1700000000, + network: '-239', + }, + }; + + const event = parser.parseActionResponse(payload, baseActionEvent); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + expect(event.value.resolvedTransaction).toBeDefined(); + expect(event.value.resolvedTransaction!.messages).toHaveLength(1); + expect(event.value.resolvedTransaction!.messages[0].address).toBe('EQAddr'); + expect(event.value.resolvedTransaction!.messages[0].amount).toBe('500'); + expect(event.value.resolvedTransaction!.network).toEqual({ chainId: '-239' }); + } + }); + + it('parses signData action response', () => { + const payload = { + action_type: 'signData', + action: { + type: 'text', + text: 'Sign this message', + }, + }; + + const event = parser.parseActionResponse(payload, baseActionEvent); + expect(event.type).toBe('signData'); + if (event.type === 'signData') { + expect(event.value.manifestUrl).toBe('https://api.example.com/action'); + expect(event.value.payload.data.type).toBe('text'); + } + }); + + it('rejects missing action_type', () => { + expect(() => parser.parseActionResponse({ action: {} }, baseActionEvent)).toThrow( + 'missing action_type', + ); + }); + + it('rejects missing action', () => { + expect(() => parser.parseActionResponse({ action_type: 'sendTransaction' }, baseActionEvent)).toThrow( + 'missing action_type or action', + ); + }); + + it('rejects unsupported action_type', () => { + expect(() => + parser.parseActionResponse( + { action_type: 'unknown', action: {} }, + baseActionEvent, + ), + ).toThrow('unsupported action_type'); + }); + + it('rejects sendTransaction without messages', () => { + expect(() => + parser.parseActionResponse( + { action_type: 'sendTransaction', action: { messages: [] } }, + baseActionEvent, + ), + ).toThrow('missing messages'); + }); + + it('rejects signData without type', () => { + expect(() => + parser.parseActionResponse( + { action_type: 'signData', action: { text: 'hello' } }, + baseActionEvent, + ), + ).toThrow('missing type'); + }); + }); + +}); From a51fd6d493d9add6999b6b2dc352d0d01ab68790 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 12:05:06 +0530 Subject: [PATCH 10/15] refactor: fix lint --- .../src/handlers/IntentHandler.spec.ts | 23 ++++++++------ .../src/handlers/IntentParser.spec.ts | 30 +++++-------------- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/packages/walletkit/src/handlers/IntentHandler.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts index ef60d4f84..7f9c62ce1 100644 --- a/packages/walletkit/src/handlers/IntentHandler.spec.ts +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -95,15 +95,22 @@ describe('IntentHandler', () => { expect(result.boc).toBe('signed-boc-base64'); expect(mockWallet.getSignedSendTransaction).toHaveBeenCalled(); - expect((mockWallet.getClient() as any).sendBoc).toHaveBeenCalledWith('signed-boc-base64'); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).toHaveBeenCalledWith('signed-boc-base64'); expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); }); it('does not send boc when deliveryMode is signOnly', async () => { - const result = await handler.approveTransactionIntent(txEvent({ id: 'tx-2', deliveryMode: 'signOnly' }), 'wallet-1'); + const result = await handler.approveTransactionIntent( + txEvent({ id: 'tx-2', deliveryMode: 'signOnly' }), + 'wallet-1', + ); expect(result.boc).toBe('signed-boc-base64'); - expect((mockWallet.getClient() as any).sendBoc).not.toHaveBeenCalled(); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).not.toHaveBeenCalled(); }); it('does not send boc when dev.disableNetworkSend is true', async () => { @@ -114,7 +121,9 @@ describe('IntentHandler', () => { ); await devHandler.approveTransactionIntent(txEvent({ id: 'tx-3' }), 'wallet-1'); - expect((mockWallet.getClient() as any).sendBoc).not.toHaveBeenCalled(); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).not.toHaveBeenCalled(); }); it('skips bridge send when clientId is absent', async () => { @@ -269,11 +278,7 @@ describe('IntentHandler', () => { /** * Helper: Build a tc://intent_inline URL from a wire request object. */ -function buildInlineUrl( - clientId: string, - request: Record, - opts?: { traceId?: string }, -): string { +function buildInlineUrl(clientId: string, request: Record, opts?: { traceId?: string }): string { const json = JSON.stringify(request); const b64 = Buffer.from(json, 'utf-8').toString('base64url'); let url = `tc://intent_inline?id=${clientId}&r=${b64}`; diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts index 6f0ca3aef..5fdac5d2b 100644 --- a/packages/walletkit/src/handlers/IntentParser.spec.ts +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -14,11 +14,7 @@ import { IntentParser } from './IntentParser'; * Helper: Build a tc://intent_inline URL from a wire request object. * Encodes the request as base64url in the `r` parameter. */ -function buildInlineUrl( - clientId: string, - request: Record, - opts?: { traceId?: string }, -): string { +function buildInlineUrl(clientId: string, request: Record, opts?: { traceId?: string }): string { const json = JSON.stringify(request); // base64url encode const b64 = Buffer.from(json, 'utf-8').toString('base64url'); @@ -174,7 +170,6 @@ describe('IntentParser', () => { } } }); - }); // ── parse – inline signIntent ──────────────────────────────────────────── @@ -415,9 +410,7 @@ describe('IntentParser', () => { const payload = { action_type: 'sendTransaction', action: { - messages: [ - { address: 'EQAddr', amount: '500', payload: 'abc123' }, - ], + messages: [{ address: 'EQAddr', amount: '500', payload: 'abc123' }], valid_until: 1700000000, network: '-239', }, @@ -452,9 +445,7 @@ describe('IntentParser', () => { }); it('rejects missing action_type', () => { - expect(() => parser.parseActionResponse({ action: {} }, baseActionEvent)).toThrow( - 'missing action_type', - ); + expect(() => parser.parseActionResponse({ action: {} }, baseActionEvent)).toThrow('missing action_type'); }); it('rejects missing action', () => { @@ -464,12 +455,9 @@ describe('IntentParser', () => { }); it('rejects unsupported action_type', () => { - expect(() => - parser.parseActionResponse( - { action_type: 'unknown', action: {} }, - baseActionEvent, - ), - ).toThrow('unsupported action_type'); + expect(() => parser.parseActionResponse({ action_type: 'unknown', action: {} }, baseActionEvent)).toThrow( + 'unsupported action_type', + ); }); it('rejects sendTransaction without messages', () => { @@ -483,12 +471,8 @@ describe('IntentParser', () => { it('rejects signData without type', () => { expect(() => - parser.parseActionResponse( - { action_type: 'signData', action: { text: 'hello' } }, - baseActionEvent, - ), + parser.parseActionResponse({ action_type: 'signData', action: { text: 'hello' } }, baseActionEvent), ).toThrow('missing type'); }); }); - }); From b1a590fc0a28a7337e3538a6112ea76213b4455f Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 14:30:56 +0530 Subject: [PATCH 11/15] feat: add approveBatchedIntent and related functionality for handling multi-item intents --- .../walletkit-android-bridge/src/api/index.ts | 1 + .../src/api/intents.ts | 4 + .../walletkit-android-bridge/src/types/api.ts | 8 +- .../src/types/walletkit.ts | 7 +- packages/walletkit/src/core/TonWalletKit.ts | 14 +- .../src/handlers/IntentHandler.spec.ts | 248 ++++++++++++++++++ .../walletkit/src/handlers/IntentHandler.ts | 151 ++++++++++- .../src/handlers/IntentParser.spec.ts | 9 +- .../walletkit/src/handlers/IntentParser.ts | 15 +- 9 files changed, 443 insertions(+), 14 deletions(-) diff --git a/packages/walletkit-android-bridge/src/api/index.ts b/packages/walletkit-android-bridge/src/api/index.ts index d083403dd..8a1a77c08 100644 --- a/packages/walletkit-android-bridge/src/api/index.ts +++ b/packages/walletkit-android-bridge/src/api/index.ts @@ -97,6 +97,7 @@ export const api: WalletKitBridgeApi = { approveTransactionIntent: intents.approveTransactionIntent, approveSignDataIntent: intents.approveSignDataIntent, approveActionIntent: intents.approveActionIntent, + approveBatchedIntent: intents.approveBatchedIntent, rejectIntent: intents.rejectIntent, intentItemsToTransactionRequest: intents.intentItemsToTransactionRequest, processConnectAfterIntent: intents.processConnectAfterIntent, diff --git a/packages/walletkit-android-bridge/src/api/intents.ts b/packages/walletkit-android-bridge/src/api/intents.ts index e45935563..4e069d80a 100644 --- a/packages/walletkit-android-bridge/src/api/intents.ts +++ b/packages/walletkit-android-bridge/src/api/intents.ts @@ -32,6 +32,10 @@ export async function approveActionIntent(args: unknown[]) { return kit('approveActionIntent', ...args); } +export async function approveBatchedIntent(args: unknown[]) { + return kit('approveBatchedIntent', ...args); +} + export async function rejectIntent(args: unknown[]) { return kit('rejectIntent', ...args); } diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index 84b64d8a8..739fbf60a 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -296,8 +296,13 @@ export interface ApproveActionIntentArgs { walletId: string; } +export interface ApproveBatchedIntentArgs { + batch: BatchedIntentEvent; + walletId: string; +} + export interface RejectIntentArgs { - event: IntentRequestEvent; + event: IntentRequestEvent | BatchedIntentEvent; reason?: string; errorCode?: number; } @@ -380,6 +385,7 @@ export interface WalletKitBridgeApi { approveActionIntent( args: ApproveActionIntentArgs, ): PromiseOrValue; + approveBatchedIntent(args: ApproveBatchedIntentArgs): PromiseOrValue; rejectIntent(args: RejectIntentArgs): PromiseOrValue; intentItemsToTransactionRequest(args: IntentItemsToTransactionRequestArgs): PromiseOrValue; processConnectAfterIntent(args: ProcessConnectAfterIntentArgs): PromiseOrValue; diff --git a/packages/walletkit-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index 49b543cf8..29d115a59 100644 --- a/packages/walletkit-android-bridge/src/types/walletkit.ts +++ b/packages/walletkit-android-bridge/src/types/walletkit.ts @@ -138,7 +138,12 @@ export interface WalletKitInstance { event: ActionIntentRequestEvent, walletId: string, ): Promise; - rejectIntent(event: IntentRequestEvent, reason?: string, errorCode?: number): Promise; + approveBatchedIntent(batch: BatchedIntentEvent, walletId: string): Promise; + rejectIntent( + event: IntentRequestEvent | BatchedIntentEvent, + reason?: string, + errorCode?: number, + ): Promise; intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise; processConnectAfterIntent( event: IntentRequestEvent | BatchedIntentEvent, diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index c0a4d3b00..65c9cbff9 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -583,7 +583,19 @@ export class TonWalletKit implements ITonWalletKit { return this.intentHandler.approveActionIntent(event, walletId); } - async rejectIntent(event: IntentRequestEvent, reason?: string, errorCode?: number): Promise { + async approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + ): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveBatchedIntent(batch, walletId); + } + + async rejectIntent( + event: IntentRequestEvent | BatchedIntentEvent, + reason?: string, + errorCode?: number, + ): Promise { await this.ensureInitialized(); return this.intentHandler.rejectIntent(event, reason, errorCode); } diff --git a/packages/walletkit/src/handlers/IntentHandler.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts index 7f9c62ce1..aa8cac3dc 100644 --- a/packages/walletkit/src/handlers/IntentHandler.spec.ts +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -18,6 +18,7 @@ import type { TransactionIntentRequestEvent, SignDataIntentRequestEvent, SignDataPayload, + BatchedIntentEvent, } from '../api/models'; /** @@ -248,6 +249,253 @@ describe('IntentHandler', () => { }); }); + // ── handleIntentUrl batching ──────────────────────────────────────────── + + describe('handleIntentUrl batching', () => { + it('emits BatchedIntentEvent for multi-item txIntent', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + const url = buildInlineUrl('c-batch', { + id: 'tx-batch', + m: 'txIntent', + i: [ + { t: 'ton', a: 'EQAddr1', am: '100' }, + { t: 'ton', a: 'EQAddr2', am: '200' }, + ], + }); + + await handler.handleIntentUrl(url, 'wallet-1'); + + expect(emitted).toBeDefined(); + // BatchedIntentEvent has `intents` array + expect('intents' in emitted!).toBe(true); + + const batch = emitted as BatchedIntentEvent; + expect(batch.id).toBe('tx-batch'); + expect(batch.origin).toBe('deepLink'); + expect(batch.clientId).toBe('c-batch'); + expect(batch.intents).toHaveLength(2); + + // Each inner event is a transaction with one item + expect(batch.intents[0].type).toBe('transaction'); + expect(batch.intents[0].value.id).toBe('tx-batch_0'); + expect(batch.intents[0].value.items).toHaveLength(1); + + expect(batch.intents[1].type).toBe('transaction'); + expect(batch.intents[1].value.id).toBe('tx-batch_1'); + expect(batch.intents[1].value.items).toHaveLength(1); + }); + + it('emits regular IntentRequestEvent for single-item txIntent', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + const url = buildInlineUrl('c-single', { + id: 'tx-single', + m: 'txIntent', + i: [{ t: 'ton', a: 'EQAddr1', am: '100' }], + }); + + await handler.handleIntentUrl(url, 'wallet-1'); + + expect(emitted).toBeDefined(); + // Regular event does NOT have `intents` + expect('intents' in emitted!).toBe(false); + expect((emitted as IntentRequestEvent).type).toBe('transaction'); + }); + + it('preserves hasConnectRequest at batch level', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + const url = buildInlineUrl('c-conn', { + id: 'tx-conn', + m: 'txIntent', + i: [ + { t: 'ton', a: 'EQAddr1', am: '100' }, + { t: 'ton', a: 'EQAddr2', am: '200' }, + ], + c: { manifestUrl: 'https://dapp.com/m.json', items: [{ name: 'ton_addr' }] }, + }); + + await handler.handleIntentUrl(url, 'wallet-1'); + + const batch = emitted as BatchedIntentEvent; + expect(batch.hasConnectRequest).toBe(true); + // Inner items should NOT have hasConnectRequest + for (const intent of batch.intents) { + expect(intent.value.hasConnectRequest).toBe(false); + } + }); + }); + + // ── approveBatchedIntent ──────────────────────────────────────────────── + + describe('approveBatchedIntent', () => { + function makeBatch(overrides: Partial = {}): BatchedIntentEvent { + return { + id: 'batch-1', + origin: 'deepLink', + clientId: 'client-b', + hasConnectRequest: false, + intents: [ + { + type: 'transaction', + value: { + id: 'batch-1_0', + origin: 'deepLink', + clientId: 'client-b', + hasConnectRequest: false, + deliveryMode: 'send', + items: [{ type: 'sendTon', value: { address: 'EQAddr1', amount: '100' } }], + }, + }, + { + type: 'transaction', + value: { + id: 'batch-1_1', + origin: 'deepLink', + clientId: 'client-b', + hasConnectRequest: false, + deliveryMode: 'send', + items: [{ type: 'sendTon', value: { address: 'EQAddr2', amount: '200' } }], + }, + }, + ], + ...overrides, + }; + } + + it('signs and sends a combined transaction', async () => { + const result = await handler.approveBatchedIntent(makeBatch(), 'wallet-1'); + + expect(result.boc).toBe('signed-boc-base64'); + expect(mockWallet.getSignedSendTransaction).toHaveBeenCalledTimes(1); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).toHaveBeenCalledWith('signed-boc-base64'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('uses signOnly when any inner event has signOnly delivery', async () => { + const batch = makeBatch(); + (batch.intents[1].value as TransactionIntentRequestEvent).deliveryMode = 'signOnly'; + + await handler.approveBatchedIntent(batch, 'wallet-1'); + + // Should NOT send boc + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).not.toHaveBeenCalled(); + }); + + it('throws when batch contains no transaction items', async () => { + const emptyBatch = makeBatch({ + intents: [ + { + type: 'signData', + value: { + id: 'sd-1', + origin: 'deepLink', + clientId: 'client-b', + hasConnectRequest: false, + manifestUrl: 'https://example.com', + payload: { data: { type: 'text', value: { content: 'x' } } }, + }, + }, + ], + }); + + await expect(handler.approveBatchedIntent(emptyBatch, 'wallet-1')).rejects.toThrow( + 'Batched intent contains no transaction items', + ); + }); + + it('skips bridge send when batch has no clientId', async () => { + const batch = makeBatch({ clientId: undefined }); + await handler.approveBatchedIntent(batch, 'wallet-1'); + expect(bridgeManager.sendIntentResponse).not.toHaveBeenCalled(); + }); + }); + + // ── rejectIntent (batched) ────────────────────────────────────────────── + + describe('rejectIntent (batched)', () => { + function makeBatch(): BatchedIntentEvent { + return { + id: 'batch-r', + origin: 'deepLink', + clientId: 'client-br', + hasConnectRequest: false, + intents: [ + { + type: 'transaction', + value: { + id: 'batch-r_0', + origin: 'deepLink', + clientId: 'client-br', + hasConnectRequest: false, + deliveryMode: 'send', + items: [{ type: 'sendTon', value: { address: 'EQ1', amount: '100' } }], + }, + }, + ], + }; + } + + it('rejects a batched intent with default error', async () => { + const result = await handler.rejectIntent(makeBatch()); + + expect(result.error.code).toBe(300); + expect(result.error.message).toBe('User declined the request'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('rejects a batched intent with custom reason', async () => { + const result = await handler.rejectIntent(makeBatch(), 'Batch rejected', 500); + + expect(result.error.code).toBe(500); + expect(result.error.message).toBe('Batch rejected'); + }); + + it('cleans up pending connect request on batch reject', async () => { + // Set up a pending connect via a multi-item URL with connect request + const url = buildInlineUrl('cr', { + id: 'batch-pcr', + m: 'txIntent', + i: [ + { t: 'ton', a: 'EQ1', am: '100' }, + { t: 'ton', a: 'EQ2', am: '200' }, + ], + c: { manifestUrl: 'https://d.com/m.json', items: [{ name: 'ton_addr' }] }, + }); + + handler.onIntentRequest(() => {}); // need callback to not discard + await handler.handleIntentUrl(url, 'wallet-1'); + + expect(handler.getPendingConnectRequest('batch-pcr')).toBeDefined(); + + // Reject the batch + const batch: BatchedIntentEvent = { + id: 'batch-pcr', + origin: 'deepLink', + clientId: 'cr', + hasConnectRequest: true, + intents: [], + }; + await handler.rejectIntent(batch); + + expect(handler.getPendingConnectRequest('batch-pcr')).toBeUndefined(); + }); + }); + // ── getWallet error ────────────────────────────────────────────────────── describe('wallet not found', () => { diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index c5fec823d..d36640833 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -67,6 +67,10 @@ export class IntentHandler { /** * Parse an intent URL, resolve items, emulate preview, and emit the event. + * + * Multi-item transaction intents are split into per-item events and + * emitted as a {@link BatchedIntentEvent} so the wallet can display + * each action separately while approving/rejecting as a group. */ async handleIntentUrl(url: string, walletId: string): Promise { const { event, connectRequest } = await this.parser.parse(url); @@ -76,7 +80,11 @@ export class IntentHandler { } if (event.type === 'transaction') { - await this.resolveAndEmitTransaction(event, walletId); + if (event.value.items.length > 1) { + await this.resolveAndEmitBatchedTransaction(event, walletId); + } else { + await this.resolveAndEmitTransaction(event, walletId); + } } else { this.emit(event); } @@ -116,6 +124,63 @@ export class IntentHandler { return result; } + /** + * Approve a batched intent event. + * + * Collects all items from the inner transaction events, builds a single + * combined {@link TransactionRequest}, signs it as one transaction, and + * sends a single response back to the dApp. + */ + async approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + + // Collect all items from inner transaction events + const allItems: IntentActionItem[] = []; + let deliveryMode: 'send' | 'signOnly' = 'send'; + for (const intent of batch.intents) { + if (intent.type === 'transaction') { + allItems.push(...intent.value.items); + if (intent.value.deliveryMode === 'signOnly') { + deliveryMode = 'signOnly'; + } + } + } + + if (allItems.length === 0) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Batched intent contains no transaction items'); + } + + // Find network/validUntil from first transaction event + const firstTx = batch.intents.find((i) => i.type === 'transaction'); + const network = firstTx?.type === 'transaction' ? firstTx.value.network : undefined; + const validUntil = firstTx?.type === 'transaction' ? firstTx.value.validUntil : undefined; + + // Build combined transaction + const transactionRequest = await this.resolver.intentItemsToTransactionRequest( + allItems, + wallet, + network, + validUntil, + ); + + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest); + + if (deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { + await CallForSuccess(() => wallet.getClient().sendBoc(signedBoc)); + } + + const result: IntentTransactionResponse = { + boc: signedBoc as Base64String, + }; + + // Send one response using the batch's identity + await this.sendBatchResponse(batch, { type: 'transaction', value: result }); + return result; + } + async approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise { const wallet = this.getWallet(walletId); @@ -173,7 +238,11 @@ export class IntentHandler { // -- Public: Rejection ---------------------------------------------------- - async rejectIntent(event: IntentRequestEvent, reason?: string, errorCode?: number): Promise { + async rejectIntent( + event: IntentRequestEvent | BatchedIntentEvent, + reason?: string, + errorCode?: number, + ): Promise { const result: IntentErrorResponse = { error: { code: errorCode ?? INTENT_ERROR_CODES.USER_DECLINED, @@ -181,8 +250,14 @@ export class IntentHandler { }, }; - await this.sendResponse(event.value, { type: 'error', value: result }); - this.pendingConnectRequests.delete(event.value.id); + const isBatched = 'intents' in event; + if (isBatched) { + await this.sendBatchResponse(event, { type: 'error', value: result }); + this.pendingConnectRequests.delete(event.id); + } else { + await this.sendResponse(event.value, { type: 'error', value: result }); + this.pendingConnectRequests.delete(event.value.id); + } return result; } @@ -224,6 +299,59 @@ export class IntentHandler { this.emit(event); } + /** + * Split a multi-item transaction intent into per-item events, + * resolve and emulate each, then emit as a {@link BatchedIntentEvent}. + */ + private async resolveAndEmitBatchedTransaction( + event: Extract, + walletId: string, + ): Promise { + const txEvent = event.value; + const wallet = this.getWallet(walletId); + + const perItemEvents: IntentRequestEvent[] = []; + + for (let i = 0; i < txEvent.items.length; i++) { + const item = txEvent.items[i]; + const itemEvent: TransactionIntentRequestEvent = { + id: `${txEvent.id}_${i}`, + origin: txEvent.origin, + clientId: txEvent.clientId, + hasConnectRequest: false, // connect is tracked at batch level + traceId: txEvent.traceId, + returnStrategy: txEvent.returnStrategy, + deliveryMode: txEvent.deliveryMode, + network: txEvent.network, + validUntil: txEvent.validUntil, + items: [item], + }; + + try { + const resolved = await this.resolveTransaction(itemEvent, wallet); + itemEvent.resolvedTransaction = resolved; + const preview = await wallet.getTransactionPreview(resolved); + itemEvent.preview = preview; + } catch (error) { + log.warn('Failed to resolve/emulate batched item', { error, index: i }); + } + + perItemEvents.push({ type: 'transaction', value: itemEvent }); + } + + const batch: BatchedIntentEvent = { + id: txEvent.id, + origin: txEvent.origin, + clientId: txEvent.clientId, + hasConnectRequest: txEvent.hasConnectRequest, + traceId: txEvent.traceId, + returnStrategy: txEvent.returnStrategy, + intents: perItemEvents, + }; + + this.emit(batch); + } + private async resolveTransaction( event: TransactionIntentRequestEvent, wallet: Wallet, @@ -248,6 +376,21 @@ export class IntentHandler { } } + private async sendBatchResponse(batch: BatchedIntentEvent, result: IntentResponseResult): Promise { + if (!batch.clientId) { + log.debug('No clientId on batched intent, skipping response send'); + return; + } + + const wireResponse = this.toWireResponse(batch.id, result); + + try { + await this.bridgeManager.sendIntentResponse(batch.clientId, wireResponse, batch.traceId); + } catch (error) { + log.error('Failed to send batched intent response', { error, batchId: batch.id }); + } + } + /** * Convert SDK response model to TonConnect wire format. * - Transaction: `{ result: "", id }` diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts index 5fdac5d2b..8844b3f32 100644 --- a/packages/walletkit/src/handlers/IntentParser.spec.ts +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -273,11 +273,18 @@ describe('IntentParser', () => { // ── parse – validation errors ──────────────────────────────────────────── describe('parse – validation', () => { - it('rejects URL without client ID', async () => { + it('allows inline URL without client ID (fire-and-forget)', async () => { const json = JSON.stringify({ id: 'x', m: 'txIntent', i: [{ t: 'ton', a: 'A', am: '1' }] }); const b64 = Buffer.from(json).toString('base64url'); const url = `tc://intent_inline?r=${b64}`; + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + expect(event.value.clientId).toBeUndefined(); + }); + + it('rejects object storage URL without client ID', async () => { + const url = 'tc://intent?pk=abc123&get_url=https%3A%2F%2Fexample.com%2Fpayload'; await expect(parser.parse(url)).rejects.toThrow('Missing client ID'); }); diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index 2561efd38..699a11ae0 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -86,7 +86,7 @@ interface WireIntentRequest { * Parsed intent URL — intermediate result before event creation. */ export interface ParsedIntentUrl { - clientId: string; + clientId?: string; request: WireIntentRequest; origin: IntentOrigin; traceId?: string; @@ -135,10 +135,7 @@ export class IntentParser { private async parseUrl(url: string): Promise { try { const parsedUrl = new URL(url); - const clientId = parsedUrl.searchParams.get('id'); - if (!clientId) { - throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing client ID (id) in intent URL'); - } + const clientId = parsedUrl.searchParams.get('id') || undefined; const normalized = url.trim().toLowerCase(); @@ -147,6 +144,12 @@ export class IntentParser { } if (normalized.startsWith(INTENT_SCHEME)) { + if (!clientId) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Missing client ID (id) in object storage intent URL (required for decryption)', + ); + } return this.parseObjectStoragePayload(parsedUrl, clientId); } @@ -157,7 +160,7 @@ export class IntentParser { } } - private parseInlinePayload(parsedUrl: URL, clientId: string): ParsedIntentUrl { + private parseInlinePayload(parsedUrl: URL, clientId: string | undefined): ParsedIntentUrl { const encoded = parsedUrl.searchParams.get('r'); if (!encoded) { throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing payload (r) in intent URL'); From 74a091d737edafbd471dab8a9bdd94c3598195eb Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 20:04:02 +0530 Subject: [PATCH 12/15] refactor: remove processConnectAfterIntent and related connect request handling --- .../walletkit-android-bridge/src/api/index.ts | 1 - .../src/api/intents.ts | 4 - .../walletkit-android-bridge/src/types/api.ts | 8 -- .../src/types/walletkit.ts | 6 -- .../api/models/intents/BatchedIntentEvent.ts | 2 - .../api/models/intents/IntentRequestEvent.ts | 10 +- packages/walletkit/src/core/TonWalletKit.ts | 58 +++-------- .../src/handlers/IntentHandler.spec.ts | 96 +++++++------------ .../walletkit/src/handlers/IntentHandler.ts | 92 +++++++++++++----- .../src/handlers/IntentParser.spec.ts | 3 - .../walletkit/src/handlers/IntentParser.ts | 3 - 11 files changed, 123 insertions(+), 160 deletions(-) diff --git a/packages/walletkit-android-bridge/src/api/index.ts b/packages/walletkit-android-bridge/src/api/index.ts index 8a1a77c08..18fc1f97c 100644 --- a/packages/walletkit-android-bridge/src/api/index.ts +++ b/packages/walletkit-android-bridge/src/api/index.ts @@ -100,5 +100,4 @@ export const api: WalletKitBridgeApi = { approveBatchedIntent: intents.approveBatchedIntent, rejectIntent: intents.rejectIntent, intentItemsToTransactionRequest: intents.intentItemsToTransactionRequest, - processConnectAfterIntent: intents.processConnectAfterIntent, } as unknown as WalletKitBridgeApi; diff --git a/packages/walletkit-android-bridge/src/api/intents.ts b/packages/walletkit-android-bridge/src/api/intents.ts index 4e069d80a..d6dfb656a 100644 --- a/packages/walletkit-android-bridge/src/api/intents.ts +++ b/packages/walletkit-android-bridge/src/api/intents.ts @@ -43,7 +43,3 @@ export async function rejectIntent(args: unknown[]) { export async function intentItemsToTransactionRequest(args: unknown[]) { return kit('intentItemsToTransactionRequest', ...args); } - -export async function processConnectAfterIntent(args: unknown[]) { - return kit('processConnectAfterIntent', ...args); -} diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index 739fbf60a..82ac771d3 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -32,7 +32,6 @@ import type { IntentSignDataResponse, IntentErrorResponse, IntentActionItem, - ConnectionApprovalProof, } from '@ton/walletkit'; /** @@ -312,12 +311,6 @@ export interface IntentItemsToTransactionRequestArgs { walletId: string; } -export interface ProcessConnectAfterIntentArgs { - event: IntentRequestEvent | BatchedIntentEvent; - walletId: string; - proof?: ConnectionApprovalProof; -} - export interface WalletDescriptor { address: string; publicKey: string; @@ -388,5 +381,4 @@ export interface WalletKitBridgeApi { approveBatchedIntent(args: ApproveBatchedIntentArgs): PromiseOrValue; rejectIntent(args: RejectIntentArgs): PromiseOrValue; intentItemsToTransactionRequest(args: IntentItemsToTransactionRequestArgs): PromiseOrValue; - processConnectAfterIntent(args: ProcessConnectAfterIntentArgs): PromiseOrValue; } diff --git a/packages/walletkit-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index 29d115a59..02e454f96 100644 --- a/packages/walletkit-android-bridge/src/types/walletkit.ts +++ b/packages/walletkit-android-bridge/src/types/walletkit.ts @@ -10,7 +10,6 @@ import type { ApiClient, BatchedIntentEvent, BridgeEventMessageInfo, - ConnectionApprovalProof, ConnectionApprovalResponse, ConnectionRequestEvent, DeviceInfo, @@ -145,9 +144,4 @@ export interface WalletKitInstance { errorCode?: number, ): Promise; intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise; - processConnectAfterIntent( - event: IntentRequestEvent | BatchedIntentEvent, - walletId: string, - proof?: ConnectionApprovalProof, - ): Promise; } diff --git a/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts index 5d1a2acf7..a2615205a 100644 --- a/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts +++ b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts @@ -24,6 +24,4 @@ export interface BatchedIntentEvent extends BridgeEvent { clientId?: string; /** The intent requests in this batch */ intents: IntentRequestEvent[]; - /** Whether a connect flow should follow after all intents are approved */ - hasConnectRequest: boolean; } diff --git a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts index ef9bdfd36..f459e389b 100644 --- a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts +++ b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts @@ -7,6 +7,7 @@ */ import type { BridgeEvent } from '../bridge/BridgeEvent'; +import type { ConnectionRequestEvent } from '../bridge/ConnectionRequestEvent'; import type { TransactionRequest } from '../transactions/TransactionRequest'; import type { TransactionEmulatedPreview } from '../transactions/emulation/TransactionEmulatedPreview'; import type { SignDataPayload } from '../core/PreparedSignData'; @@ -32,8 +33,6 @@ export interface IntentRequestBase extends BridgeEvent { origin: IntentOrigin; /** Client public key (for response encryption) */ clientId?: string; - /** Whether a connect flow should follow after intent approval */ - hasConnectRequest: boolean; } /** @@ -94,8 +93,13 @@ export interface ActionIntentRequestEvent extends IntentRequestBase { /** * Union of all intent request events, discriminated by `type`. + * + * The `connect` variant is used when an intent URL carries a connect request. + * It appears as the first item in a {@link BatchedIntentEvent} so the wallet + * can display it alongside the transaction/sign-data items. */ export type IntentRequestEvent = | { type: 'transaction'; value: TransactionIntentRequestEvent } | { type: 'signData'; value: SignDataIntentRequestEvent } - | { type: 'action'; value: ActionIntentRequestEvent }; + | { type: 'action'; value: ActionIntentRequestEvent } + | { type: 'connect'; value: ConnectionRequestEvent }; diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 65c9cbff9..95b5e81b9 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -50,7 +50,6 @@ import { KitNetworkManager } from './NetworkManager'; import type { WalletId } from '../utils/walletId'; import type { Wallet, WalletAdapter } from '../api/interfaces'; import { IntentHandler } from '../handlers/IntentHandler'; -import { ConnectHandler } from '../handlers/ConnectHandler'; import type { Network, TransactionRequest, @@ -263,7 +262,7 @@ export class TonWalletKit implements ITonWalletKit { this.requestProcessor = components.requestProcessor; this.eventProcessor = components.eventProcessor; this.bridgeManager = components.bridgeManager; - this.intentHandler = new IntentHandler(this.config, this.bridgeManager, this.walletManager); + this.intentHandler = new IntentHandler(this.config, this.bridgeManager, this.walletManager, this.analyticsManager); } /** @@ -586,8 +585,19 @@ export class TonWalletKit implements ITonWalletKit { async approveBatchedIntent( batch: BatchedIntentEvent, walletId: string, + proof?: ConnectionApprovalProof, ): Promise { await this.ensureInitialized(); + + // Process connect items first — create session before sending tx + const connectItems = batch.intents.filter((i) => i.type === 'connect'); + for (const item of connectItems) { + if (item.type === 'connect') { + item.value.walletId = walletId; + await this.requestProcessor.approveConnectRequest(item.value, proof ? { proof } : undefined); + } + } + return this.intentHandler.approveBatchedIntent(batch, walletId); } @@ -605,50 +615,6 @@ export class TonWalletKit implements ITonWalletKit { return this.intentHandler.intentItemsToTransactionRequest(items, walletId); } - async processConnectAfterIntent( - event: IntentRequestEvent | BatchedIntentEvent, - walletId: string, - proof?: ConnectionApprovalProof, - ): Promise { - await this.ensureInitialized(); - - const isBatched = 'intents' in event; - const eventId = isBatched ? event.id : event.value.id; - const clientId = isBatched ? event.clientId : event.value.clientId; - - const connectRequest = this.intentHandler.getPendingConnectRequest(eventId); - if (!connectRequest) { - log.warn('No pending connect request for intent', { eventId }); - return; - } - - this.intentHandler.removePendingConnectRequest(eventId); - - // Create a bridge event from the connect request - const bridgeEvent: RawBridgeEventConnect = { - from: clientId || '', - id: eventId, - method: 'connect', - params: { - manifest: { url: connectRequest.manifestUrl }, - items: connectRequest.items, - }, - timestamp: Date.now(), - domain: '', - }; - - // Process through ConnectHandler to fetch manifest and build ConnectionRequestEvent - // (no-op notify — we auto-approve instead of showing UI) - const connectHandler = new ConnectHandler(() => {}, this.config, this.analyticsManager); - const connectionEvent = await connectHandler.handle(bridgeEvent); - - // Set walletId for approval - connectionEvent.walletId = walletId; - - // Auto-approve: create session and send ConnectEvent response to dApp - await this.requestProcessor.approveConnectRequest(connectionEvent, proof ? { proof } : undefined); - } - // === URL Processing API === /** diff --git a/packages/walletkit/src/handlers/IntentHandler.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts index aa8cac3dc..161a798fa 100644 --- a/packages/walletkit/src/handlers/IntentHandler.spec.ts +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -80,7 +80,6 @@ describe('IntentHandler', () => { id: 'tx-1', origin: 'deepLink', clientId: 'client-1', - hasConnectRequest: false, deliveryMode: 'send', items: [{ type: 'sendTon', value: { address: 'EQAddr', amount: '1000000000' } }], resolvedTransaction: { @@ -145,7 +144,6 @@ describe('IntentHandler', () => { id: 'sd-1', origin: 'deepLink', clientId: 'client-1', - hasConnectRequest: false, manifestUrl: 'https://example.com/manifest.json', payload: signPayload, }; @@ -165,7 +163,6 @@ describe('IntentHandler', () => { id: 'sd-2', origin: 'deepLink', clientId: 'client-1', - hasConnectRequest: false, manifestUrl: 'not-a-valid-url', payload: signPayload, }; @@ -185,7 +182,6 @@ describe('IntentHandler', () => { id: 'tx-r1', origin: 'deepLink', clientId: 'client-1', - hasConnectRequest: false, deliveryMode: 'send', items: [], }, @@ -205,7 +201,6 @@ describe('IntentHandler', () => { id: 'sd-r1', origin: 'deepLink', clientId: 'client-1', - hasConnectRequest: false, manifestUrl: 'https://example.com', payload: { data: { type: 'text', value: { content: 'test' } } }, }, @@ -217,8 +212,12 @@ describe('IntentHandler', () => { expect(result.error.message).toBe('Not supported'); }); - it('cleans up pending connect request on reject', async () => { - // Store a pending connect request + it('emits batch with connect item for single-item intent with connect', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + const url = buildInlineUrl('c1', { id: 'tx-pcr', m: 'txIntent', @@ -227,25 +226,12 @@ describe('IntentHandler', () => { }); await handler.handleIntentUrl(url, 'wallet-1'); - // Should have pending connect request - expect(handler.getPendingConnectRequest('tx-pcr')).toBeDefined(); - - // Reject - const event: IntentRequestEvent = { - type: 'transaction', - value: { - id: 'tx-pcr', - origin: 'deepLink', - clientId: 'c1', - hasConnectRequest: true, - deliveryMode: 'send', - items: [], - }, - }; - await handler.rejectIntent(event); - - // Pending connect request should be cleaned up - expect(handler.getPendingConnectRequest('tx-pcr')).toBeUndefined(); + // Should be a batch because of connect + expect(emitted).toBeDefined(); + expect('intents' in emitted!).toBe(true); + const batch = emitted as BatchedIntentEvent; + expect(batch.intents[0].type).toBe('connect'); + expect(batch.intents[1].type).toBe('transaction'); }); }); @@ -309,7 +295,7 @@ describe('IntentHandler', () => { expect((emitted as IntentRequestEvent).type).toBe('transaction'); }); - it('preserves hasConnectRequest at batch level', async () => { + it('emits connect as first item in batch when connect request present', async () => { let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; handler.onIntentRequest((e) => { emitted = e; @@ -328,11 +314,12 @@ describe('IntentHandler', () => { await handler.handleIntentUrl(url, 'wallet-1'); const batch = emitted as BatchedIntentEvent; - expect(batch.hasConnectRequest).toBe(true); - // Inner items should NOT have hasConnectRequest - for (const intent of batch.intents) { - expect(intent.value.hasConnectRequest).toBe(false); - } + // Connect is the first item + expect(batch.intents[0].type).toBe('connect'); + // Followed by transaction items + expect(batch.intents[1].type).toBe('transaction'); + expect(batch.intents[2].type).toBe('transaction'); + expect(batch.intents).toHaveLength(3); }); }); @@ -344,7 +331,6 @@ describe('IntentHandler', () => { id: 'batch-1', origin: 'deepLink', clientId: 'client-b', - hasConnectRequest: false, intents: [ { type: 'transaction', @@ -352,7 +338,6 @@ describe('IntentHandler', () => { id: 'batch-1_0', origin: 'deepLink', clientId: 'client-b', - hasConnectRequest: false, deliveryMode: 'send', items: [{ type: 'sendTon', value: { address: 'EQAddr1', amount: '100' } }], }, @@ -363,7 +348,6 @@ describe('IntentHandler', () => { id: 'batch-1_1', origin: 'deepLink', clientId: 'client-b', - hasConnectRequest: false, deliveryMode: 'send', items: [{ type: 'sendTon', value: { address: 'EQAddr2', amount: '200' } }], }, @@ -405,7 +389,6 @@ describe('IntentHandler', () => { id: 'sd-1', origin: 'deepLink', clientId: 'client-b', - hasConnectRequest: false, manifestUrl: 'https://example.com', payload: { data: { type: 'text', value: { content: 'x' } } }, }, @@ -433,7 +416,6 @@ describe('IntentHandler', () => { id: 'batch-r', origin: 'deepLink', clientId: 'client-br', - hasConnectRequest: false, intents: [ { type: 'transaction', @@ -441,7 +423,6 @@ describe('IntentHandler', () => { id: 'batch-r_0', origin: 'deepLink', clientId: 'client-br', - hasConnectRequest: false, deliveryMode: 'send', items: [{ type: 'sendTon', value: { address: 'EQ1', amount: '100' } }], }, @@ -465,34 +446,26 @@ describe('IntentHandler', () => { expect(result.error.message).toBe('Batch rejected'); }); - it('cleans up pending connect request on batch reject', async () => { - // Set up a pending connect via a multi-item URL with connect request - const url = buildInlineUrl('cr', { - id: 'batch-pcr', - m: 'txIntent', - i: [ - { t: 'ton', a: 'EQ1', am: '100' }, - { t: 'ton', a: 'EQ2', am: '200' }, - ], - c: { manifestUrl: 'https://d.com/m.json', items: [{ name: 'ton_addr' }] }, - }); - - handler.onIntentRequest(() => {}); // need callback to not discard - await handler.handleIntentUrl(url, 'wallet-1'); - - expect(handler.getPendingConnectRequest('batch-pcr')).toBeDefined(); - - // Reject the batch + it('rejects a batch that includes a connect item', async () => { const batch: BatchedIntentEvent = { id: 'batch-pcr', origin: 'deepLink', clientId: 'cr', - hasConnectRequest: true, - intents: [], + intents: [ + { + type: 'connect', + value: { + id: 'batch-pcr', + from: 'cr', + requestedItems: [], + preview: { permissions: [] }, + }, + }, + ], }; - await handler.rejectIntent(batch); - - expect(handler.getPendingConnectRequest('batch-pcr')).toBeUndefined(); + const result = await handler.rejectIntent(batch); + expect(result.error.code).toBe(300); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); }); }); @@ -509,7 +482,6 @@ describe('IntentHandler', () => { id: 'tx-nw', origin: 'deepLink', clientId: 'c1', - hasConnectRequest: false, deliveryMode: 'send', items: [{ type: 'sendTon', value: { address: 'EQ1', amount: '100' } }], resolvedTransaction: { diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index d36640833..b59038646 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -15,9 +15,12 @@ import { PrepareSignData } from '../utils/signData/sign'; import { HexToBase64 } from '../utils/base64'; import { IntentParser, INTENT_ERROR_CODES } from './IntentParser'; import { IntentResolver } from './IntentResolver'; +import { ConnectHandler } from './ConnectHandler'; import type { BridgeManager } from '../core/BridgeManager'; import type { WalletManager } from '../core/WalletManager'; import type { Wallet } from '../api/interfaces'; +import type { RawBridgeEventConnect } from '../types/internal'; +import type { AnalyticsManager } from '../analytics'; import type { IntentRequestEvent, IntentRequestBase, @@ -30,6 +33,7 @@ import type { IntentResponseResult, IntentActionItem, BatchedIntentEvent, + ConnectionRequestEvent, TransactionRequest, SignDataPayload, Base64String, @@ -51,12 +55,12 @@ export class IntentHandler { private parser = new IntentParser(); private resolver = new IntentResolver(); private callbacks: IntentCallback[] = []; - private pendingConnectRequests = new Map(); constructor( private walletKitOptions: TonWalletKitOptions, private bridgeManager: BridgeManager, private walletManager: WalletManager, + private analyticsManager?: AnalyticsManager, ) {} // -- Public: Parsing ------------------------------------------------------ @@ -68,25 +72,45 @@ export class IntentHandler { /** * Parse an intent URL, resolve items, emulate preview, and emit the event. * - * Multi-item transaction intents are split into per-item events and - * emitted as a {@link BatchedIntentEvent} so the wallet can display - * each action separately while approving/rejecting as a group. + * When a connect request is present, the result is always a + * {@link BatchedIntentEvent} with the connect as the first item. + * Multi-item transaction intents are also batched (one item per action). */ async handleIntentUrl(url: string, walletId: string): Promise { const { event, connectRequest } = await this.parser.parse(url); + // parser.parse() never returns connect events + if (event.type === 'connect') return; + + // Resolve connect request into a ConnectionRequestEvent if present + let connectItem: IntentRequestEvent | undefined; if (connectRequest) { - this.pendingConnectRequests.set(event.value.id, connectRequest); + const connectionEvent = await this.resolveConnectRequest(connectRequest, event); + connectItem = { type: 'connect', value: connectionEvent }; } if (event.type === 'transaction') { - if (event.value.items.length > 1) { - await this.resolveAndEmitBatchedTransaction(event, walletId); + if (connectItem || event.value.items.length > 1) { + // Batch when there's a connect or multiple tx items + await this.resolveAndEmitBatchedTransaction(event, walletId, connectItem); } else { await this.resolveAndEmitTransaction(event, walletId); } } else { - this.emit(event); + if (connectItem) { + // Batch: connect + single non-tx intent + const batch: BatchedIntentEvent = { + id: event.value.id, + origin: event.value.origin, + clientId: event.value.clientId, + traceId: event.value.traceId, + returnStrategy: event.value.returnStrategy, + intents: [connectItem, event], + }; + this.emit(batch); + } else { + this.emit(event); + } } } @@ -253,10 +277,8 @@ export class IntentHandler { const isBatched = 'intents' in event; if (isBatched) { await this.sendBatchResponse(event, { type: 'error', value: result }); - this.pendingConnectRequests.delete(event.id); - } else { + } else if (event.type !== 'connect') { await this.sendResponse(event.value, { type: 'error', value: result }); - this.pendingConnectRequests.delete(event.value.id); } return result; } @@ -268,14 +290,6 @@ export class IntentHandler { return this.resolver.intentItemsToTransactionRequest(items, wallet); } - getPendingConnectRequest(eventId: string): ConnectRequest | undefined { - return this.pendingConnectRequests.get(eventId); - } - - removePendingConnectRequest(eventId: string): void { - this.pendingConnectRequests.delete(eventId); - } - // -- Private: Resolution & Emulation -------------------------------------- private async resolveAndEmitTransaction( @@ -299,13 +313,45 @@ export class IntentHandler { this.emit(event); } + /** + * Resolve a `ConnectRequest` (manifestUrl + items) into a full + * `ConnectionRequestEvent` by fetching the manifest. + */ + private async resolveConnectRequest( + connectRequest: ConnectRequest, + event: Exclude, + ): Promise { + const bridgeEvent: RawBridgeEventConnect = { + from: event.value.clientId || '', + id: event.value.id, + method: 'connect', + params: { + manifest: { url: connectRequest.manifestUrl }, + items: connectRequest.items, + }, + timestamp: Date.now(), + domain: '', + }; + + const connectHandler = new ConnectHandler( + () => {}, + this.walletKitOptions, + this.analyticsManager, + ); + return connectHandler.handle(bridgeEvent); + } + /** * Split a multi-item transaction intent into per-item events, * resolve and emulate each, then emit as a {@link BatchedIntentEvent}. + * + * If `connectItem` is provided it is prepended to the batch so the + * wallet can display the connect alongside the transaction items. */ private async resolveAndEmitBatchedTransaction( event: Extract, walletId: string, + connectItem?: IntentRequestEvent, ): Promise { const txEvent = event.value; const wallet = this.getWallet(walletId); @@ -318,7 +364,6 @@ export class IntentHandler { id: `${txEvent.id}_${i}`, origin: txEvent.origin, clientId: txEvent.clientId, - hasConnectRequest: false, // connect is tracked at batch level traceId: txEvent.traceId, returnStrategy: txEvent.returnStrategy, deliveryMode: txEvent.deliveryMode, @@ -339,14 +384,17 @@ export class IntentHandler { perItemEvents.push({ type: 'transaction', value: itemEvent }); } + const intents: IntentRequestEvent[] = []; + if (connectItem) intents.push(connectItem); + intents.push(...perItemEvents); + const batch: BatchedIntentEvent = { id: txEvent.id, origin: txEvent.origin, clientId: txEvent.clientId, - hasConnectRequest: txEvent.hasConnectRequest, traceId: txEvent.traceId, returnStrategy: txEvent.returnStrategy, - intents: perItemEvents, + intents, }; this.emit(batch); diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts index 8844b3f32..01f9549c7 100644 --- a/packages/walletkit/src/handlers/IntentParser.spec.ts +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -79,7 +79,6 @@ describe('IntentParser', () => { expect(tx.id).toBe('tx-1'); expect(tx.origin).toBe('deepLink'); expect(tx.clientId).toBe('client-123'); - expect(tx.hasConnectRequest).toBe(false); expect(tx.deliveryMode).toBe('send'); expect(tx.network).toEqual({ chainId: '-239' }); expect(tx.validUntil).toBe(1700000000); @@ -246,7 +245,6 @@ describe('IntentParser', () => { expect(connectRequest).toBeDefined(); if (event.type === 'signData') { expect(event.value.manifestUrl).toBe('https://dapp.com/manifest.json'); - expect(event.value.hasConnectRequest).toBe(true); } }); }); @@ -409,7 +407,6 @@ describe('IntentParser', () => { id: 'a-1', origin: 'deepLink' as const, clientId: 'c1', - hasConnectRequest: false, actionUrl: 'https://api.example.com/action', }; diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index 699a11ae0..7d76a1330 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -447,7 +447,6 @@ export class IntentParser { id: sourceEvent.id, origin: sourceEvent.origin, clientId: sourceEvent.clientId, - hasConnectRequest: sourceEvent.hasConnectRequest, }; switch (action_type) { @@ -530,13 +529,11 @@ export class IntentParser { private toIntentEvent(parsed: ParsedIntentUrl): { event: IntentRequestEvent; connectRequest?: ConnectRequest } { const { clientId, request, origin, traceId } = parsed; - const hasConnectRequest = !!request.c; const base: IntentRequestBase = { id: request.id, origin, clientId, - hasConnectRequest, traceId, returnStrategy: undefined, }; From dd01e5c1819132174d15e8807c407d1d3e2c46ee Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 27 Feb 2026 10:08:52 +0530 Subject: [PATCH 13/15] feat: enhance approveBatchedIntent to handle signData items and update error messaging --- packages/walletkit/src/core/TonWalletKit.ts | 2 +- .../src/handlers/IntentHandler.spec.ts | 29 ++++++- .../walletkit/src/handlers/IntentHandler.ts | 87 +++++++++++++------ 3 files changed, 89 insertions(+), 29 deletions(-) diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 95b5e81b9..c97fde62e 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -586,7 +586,7 @@ export class TonWalletKit implements ITonWalletKit { batch: BatchedIntentEvent, walletId: string, proof?: ConnectionApprovalProof, - ): Promise { + ): Promise { await this.ensureInitialized(); // Process connect items first — create session before sending tx diff --git a/packages/walletkit/src/handlers/IntentHandler.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts index 161a798fa..c14f9fc89 100644 --- a/packages/walletkit/src/handlers/IntentHandler.spec.ts +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -380,8 +380,8 @@ describe('IntentHandler', () => { ).not.toHaveBeenCalled(); }); - it('throws when batch contains no transaction items', async () => { - const emptyBatch = makeBatch({ + it('signs data when batch contains only signData items', async () => { + const signDataBatch = makeBatch({ intents: [ { type: 'signData', @@ -396,8 +396,31 @@ describe('IntentHandler', () => { ], }); + const result = await handler.approveBatchedIntent(signDataBatch, 'wallet-1'); + expect('signature' in result).toBe(true); + expect(mockWallet.getSignedSignData).toHaveBeenCalled(); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('throws when batch contains no transaction or signData items', async () => { + const emptyBatch = makeBatch({ + intents: [ + { + type: 'connect', + value: { + from: 'client-b', + id: 'c-1', + method: 'connect', + params: { manifest: { url: 'https://example.com' }, items: [] }, + timestamp: Date.now(), + domain: '', + }, + } as unknown as IntentRequestEvent, + ], + }); + await expect(handler.approveBatchedIntent(emptyBatch, 'wallet-1')).rejects.toThrow( - 'Batched intent contains no transaction items', + 'Batched intent contains no transaction or signData items', ); }); diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index b59038646..d98f4f6d0 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -158,7 +158,7 @@ export class IntentHandler { async approveBatchedIntent( batch: BatchedIntentEvent, walletId: string, - ): Promise { + ): Promise { const wallet = this.getWallet(walletId); // Collect all items from inner transaction events @@ -173,36 +173,73 @@ export class IntentHandler { } } - if (allItems.length === 0) { - throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Batched intent contains no transaction items'); - } + // If the batch contains transaction items, process them + if (allItems.length > 0) { + // Find network/validUntil from first transaction event + const firstTx = batch.intents.find((i) => i.type === 'transaction'); + const network = firstTx?.type === 'transaction' ? firstTx.value.network : undefined; + const validUntil = firstTx?.type === 'transaction' ? firstTx.value.validUntil : undefined; + + // Build combined transaction + const transactionRequest = await this.resolver.intentItemsToTransactionRequest( + allItems, + wallet, + network, + validUntil, + ); - // Find network/validUntil from first transaction event - const firstTx = batch.intents.find((i) => i.type === 'transaction'); - const network = firstTx?.type === 'transaction' ? firstTx.value.network : undefined; - const validUntil = firstTx?.type === 'transaction' ? firstTx.value.validUntil : undefined; - - // Build combined transaction - const transactionRequest = await this.resolver.intentItemsToTransactionRequest( - allItems, - wallet, - network, - validUntil, - ); + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest); - const signedBoc = await wallet.getSignedSendTransaction(transactionRequest); + if (deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { + await CallForSuccess(() => wallet.getClient().sendBoc(signedBoc)); + } - if (deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { - await CallForSuccess(() => wallet.getClient().sendBoc(signedBoc)); + const result: IntentTransactionResponse = { + boc: signedBoc as Base64String, + }; + + // Send one response using the batch's identity + await this.sendBatchResponse(batch, { type: 'transaction', value: result }); + return result; } - const result: IntentTransactionResponse = { - boc: signedBoc as Base64String, - }; + // Check for signData intents + const signDataIntent = batch.intents.find((i) => i.type === 'signData'); + if (signDataIntent && signDataIntent.type === 'signData') { + const event = signDataIntent.value; - // Send one response using the batch's identity - await this.sendBatchResponse(batch, { type: 'transaction', value: result }); - return result; + let domain = event.manifestUrl; + try { + domain = new URL(event.manifestUrl).host; + } catch { + // use as-is + } + + const signData = PrepareSignData({ + payload: event.payload, + domain, + address: wallet.getAddress(), + }); + + const signature = await wallet.getSignedSignData(signData); + const signatureBase64 = HexToBase64(signature); + + const result: IntentSignDataResponse = { + signature: signatureBase64 as Base64String, + address: wallet.getAddress() as UserFriendlyAddress, + timestamp: signData.timestamp, + domain: signData.domain, + payload: event.payload, + }; + + await this.sendBatchResponse(batch, { type: 'signData', value: result }); + return result; + } + + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Batched intent contains no transaction or signData items', + ); } async approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise { From e148b3721da85d08d4073fcb901a7c970facb668 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 27 Feb 2026 10:57:11 +0530 Subject: [PATCH 14/15] feat: update getSignedSendTransaction to support internal message signing and adjust related logic in IntentHandler --- .../src/api/interfaces/WalletAdapter.ts | 4 +++- .../src/contracts/v4r2/WalletV4R2Adapter.ts | 5 ++++- .../src/contracts/w5/WalletV5R1Adapter.ts | 16 ++++++++++++---- packages/walletkit/src/handlers/IntentHandler.ts | 10 ++++++++-- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/walletkit/src/api/interfaces/WalletAdapter.ts b/packages/walletkit/src/api/interfaces/WalletAdapter.ts index 9a8687a03..aba371131 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 acc3cc298..909b3b9d3 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 e1f516ca2..c85820ba8 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,23 @@ 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 (signOnly / 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)}`, + }); + 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/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index d98f4f6d0..c8e0276d0 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -134,7 +134,10 @@ export class IntentHandler { const transactionRequest = event.resolvedTransaction ?? (await this.resolveTransaction(event, wallet)); - const signedBoc = await wallet.getSignedSendTransaction(transactionRequest); + // signOnly (signMsg) uses internal opcode (0x73696e74) for gasless relaying + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest, { + internal: event.deliveryMode === 'signOnly', + }); if (event.deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { await CallForSuccess(() => wallet.getClient().sendBoc(signedBoc)); @@ -188,7 +191,10 @@ export class IntentHandler { validUntil, ); - const signedBoc = await wallet.getSignedSendTransaction(transactionRequest); + // signOnly (signMsg) uses internal opcode (0x73696e74) for gasless relaying + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest, { + internal: deliveryMode === 'signOnly', + }); if (deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { await CallForSuccess(() => wallet.getClient().sendBoc(signedBoc)); From 40cfc3d3e0ffdea1c60ed8934c9384c64634fefa Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 27 Feb 2026 11:00:40 +0530 Subject: [PATCH 15/15] refactor: fix lint --- packages/walletkit/src/core/TonWalletKit.ts | 7 ++++++- packages/walletkit/src/handlers/IntentHandler.ts | 6 +----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index c97fde62e..5184d0d24 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -262,7 +262,12 @@ export class TonWalletKit implements ITonWalletKit { this.requestProcessor = components.requestProcessor; this.eventProcessor = components.eventProcessor; this.bridgeManager = components.bridgeManager; - this.intentHandler = new IntentHandler(this.config, this.bridgeManager, this.walletManager, this.analyticsManager); + this.intentHandler = new IntentHandler( + this.config, + this.bridgeManager, + this.walletManager, + this.analyticsManager, + ); } /** diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index c8e0276d0..1d88ee5eb 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -376,11 +376,7 @@ export class IntentHandler { domain: '', }; - const connectHandler = new ConnectHandler( - () => {}, - this.walletKitOptions, - this.analyticsManager, - ); + const connectHandler = new ConnectHandler(() => {}, this.walletKitOptions, this.analyticsManager); return connectHandler.handle(bridgeEvent); }