From db683a50cb49c79ccc21e712269df10310107c63 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 23 Feb 2026 14:29:55 +0530 Subject: [PATCH 01/46] 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 e038205db..df2ef1578 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -93,5 +93,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 23479bc3a..f96456dad 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 807f176a768e36aaaf0e81a2890de3436174f8ec Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 23 Feb 2026 14:49:47 +0530 Subject: [PATCH 02/46] 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 dc32a9e591610f869b575ae04ce163cea923e378 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Wed, 25 Feb 2026 08:58:35 +0530 Subject: [PATCH 03/46] 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 d1eeb77c9dd59c2c0ac5d636affb0ef2d3a0d627 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Wed, 25 Feb 2026 20:02:15 +0530 Subject: [PATCH 04/46] 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 df2ef1578..6d29c2b7d 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -100,7 +100,6 @@ export type { IntentDeliveryMode, IntentRequestBase, TransactionIntentRequestEvent, - TransactionIntentPreview, SignDataIntentRequestEvent, ActionIntentRequestEvent, IntentRequestEvent, @@ -114,5 +113,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 d8408b29e409a328121abf532e0ddc2120d75c0f Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Wed, 25 Feb 2026 21:00:22 +0530 Subject: [PATCH 05/46] 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 d4ee71a5c5e72e0b324c53e63f16cdefe70dcd35 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 10:44:01 +0530 Subject: [PATCH 06/46] 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 630a66398e22f1dc40d9e287acfdc98cd89e2374 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 11:11:20 +0530 Subject: [PATCH 07/46] 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 6d29c2b7d..1ab05aa9e 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -113,6 +113,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 9911d089812b7cd4c0c6c5baf7782303ba4f0935 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 11:30:44 +0530 Subject: [PATCH 08/46] 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 5b6b794c1ce928759878c15f3fa2e81b404ab596 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 11:59:13 +0530 Subject: [PATCH 09/46] 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 c502e4094d88b0756fafbe0c571b697580a09118 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 12:05:06 +0530 Subject: [PATCH 10/46] 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 0c5b9f2ac1ece216f9caae74a7028581b37e6ba6 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 14:30:56 +0530 Subject: [PATCH 11/46] 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 16952ca0f16859d33e9e5f2c8a10dadd4fafc5bf Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Thu, 26 Feb 2026 20:04:02 +0530 Subject: [PATCH 12/46] 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 ed14b6caac2fc188f71416bcfc166f2bb6179956 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 27 Feb 2026 10:08:52 +0530 Subject: [PATCH 13/46] 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 dce87ec2744018bb5b200bb824f72e1b0be3b9fa Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 27 Feb 2026 10:57:11 +0530 Subject: [PATCH 14/46] 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 6d63d3e7aa355e0af5ec466b9312e49fc2c2554f Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 27 Feb 2026 11:00:40 +0530 Subject: [PATCH 15/46] 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); } From e1c7d1eec329f1c3b6a521800a68ce8dc249b408 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 6 Mar 2026 14:36:31 +0530 Subject: [PATCH 16/46] feat: refactor intent models to flat interface unions --- packages/walletkit/src/api/models/index.ts | 1 + .../api/models/intents/IntentActionItem.ts | 9 +- .../api/models/intents/IntentRequestEvent.ts | 20 ++- .../src/api/models/intents/IntentResponse.ts | 9 +- .../src/api/scripts/generate-json-schema.js | 7 + packages/walletkit/src/core/TonWalletKit.ts | 4 +- .../src/handlers/IntentHandler.spec.ts | 124 ++++++++---------- .../walletkit/src/handlers/IntentHandler.ts | 112 ++++++++-------- .../src/handlers/IntentParser.spec.ts | 79 +++++------ .../walletkit/src/handlers/IntentParser.ts | 114 +++++++--------- .../walletkit/src/handlers/IntentResolver.ts | 6 +- 11 files changed, 241 insertions(+), 244 deletions(-) diff --git a/packages/walletkit/src/api/models/index.ts b/packages/walletkit/src/api/models/index.ts index 1ab05aa9e..29be2a0b3 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -102,6 +102,7 @@ export type { TransactionIntentRequestEvent, SignDataIntentRequestEvent, ActionIntentRequestEvent, + ConnectIntentRequestEvent, IntentRequestEvent, } from './intents/IntentRequestEvent'; export type { diff --git a/packages/walletkit/src/api/models/intents/IntentActionItem.ts b/packages/walletkit/src/api/models/intents/IntentActionItem.ts index c8b9f56da..7ff4cb219 100644 --- a/packages/walletkit/src/api/models/intents/IntentActionItem.ts +++ b/packages/walletkit/src/api/models/intents/IntentActionItem.ts @@ -14,6 +14,7 @@ import type { ExtraCurrencies } from '../core/ExtraCurrencies'; * TON native coin transfer action. */ export interface SendTonAction { + type: 'sendTon'; /** Destination address (user-friendly) */ address: UserFriendlyAddress; /** Amount in nanotons */ @@ -30,6 +31,7 @@ export interface SendTonAction { * Jetton transfer action (TEP-74). */ export interface SendJettonAction { + type: 'sendJetton'; /** Jetton master contract address */ jettonMasterAddress: UserFriendlyAddress; /** Transfer amount in jetton elementary units */ @@ -55,6 +57,7 @@ export interface SendJettonAction { * NFT transfer action (TEP-62). */ export interface SendNftAction { + type: 'sendNft'; /** NFT item address */ nftAddress: UserFriendlyAddress; /** New owner address */ @@ -76,8 +79,6 @@ export interface SendNftAction { /** * Union of all intent action items, discriminated by `type`. + * @discriminator type */ -export type IntentActionItem = - | { type: 'sendTon'; value: SendTonAction } - | { type: 'sendJetton'; value: SendJettonAction } - | { type: 'sendNft'; value: SendNftAction }; +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 index f459e389b..3f36dd373 100644 --- a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts +++ b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts @@ -42,6 +42,7 @@ export interface IntentRequestBase extends BridgeEvent { * The `deliveryMode` field distinguishes them. */ export interface TransactionIntentRequestEvent extends IntentRequestBase { + type: 'transaction'; /** Whether to send on-chain or return signed BoC */ deliveryMode: IntentDeliveryMode; /** Network for the transaction */ @@ -63,6 +64,7 @@ export interface TransactionIntentRequestEvent extends IntentRequestBase { * Sign data intent request event. */ export interface SignDataIntentRequestEvent extends IntentRequestBase { + type: 'signData'; /** Network for sign data */ network?: Network; /** @@ -84,6 +86,7 @@ export interface SignDataIntentRequestEvent extends IntentRequestBase { * to a TransactionIntentRequestEvent or SignDataIntentRequestEvent. */ export interface ActionIntentRequestEvent extends IntentRequestBase { + type: 'action'; /** * Action URL to fetch * @format url @@ -91,15 +94,24 @@ export interface ActionIntentRequestEvent extends IntentRequestBase { actionUrl: string; } +/** + * Connect intent request event, wrapping a ConnectionRequestEvent + * when an intent URL also carries a connect request. + */ +export interface ConnectIntentRequestEvent extends ConnectionRequestEvent { + type: 'connect'; +} + /** * 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. + * @discriminator type */ export type IntentRequestEvent = - | { type: 'transaction'; value: TransactionIntentRequestEvent } - | { type: 'signData'; value: SignDataIntentRequestEvent } - | { type: 'action'; value: ActionIntentRequestEvent } - | { type: 'connect'; value: ConnectionRequestEvent }; + | TransactionIntentRequestEvent + | SignDataIntentRequestEvent + | ActionIntentRequestEvent + | ConnectIntentRequestEvent; diff --git a/packages/walletkit/src/api/models/intents/IntentResponse.ts b/packages/walletkit/src/api/models/intents/IntentResponse.ts index 221ed2544..63e8896e0 100644 --- a/packages/walletkit/src/api/models/intents/IntentResponse.ts +++ b/packages/walletkit/src/api/models/intents/IntentResponse.ts @@ -13,6 +13,7 @@ import type { SignDataPayload } from '../core/PreparedSignData'; * Successful response for transaction intent. */ export interface IntentTransactionResponse { + type: 'transaction'; /** Signed BoC (base64) */ boc: Base64String; } @@ -21,6 +22,7 @@ export interface IntentTransactionResponse { * Successful response for sign data intent. */ export interface IntentSignDataResponse { + type: 'signData'; /** Signature (base64) */ signature: Base64String; /** Signer address */ @@ -40,6 +42,7 @@ export interface IntentSignDataResponse { * Error response for any intent. */ export interface IntentErrorResponse { + type: 'error'; /** Error details */ error: IntentError; } @@ -59,8 +62,6 @@ export interface IntentError { /** * Union of all intent responses, discriminated by `type`. + * @discriminator type */ -export type IntentResponseResult = - | { type: 'transaction'; value: IntentTransactionResponse } - | { type: 'signData'; value: IntentSignDataResponse } - | { type: 'error'; value: IntentErrorResponse }; +export type IntentResponseResult = IntentTransactionResponse | IntentSignDataResponse | IntentErrorResponse; diff --git a/packages/walletkit/src/api/scripts/generate-json-schema.js b/packages/walletkit/src/api/scripts/generate-json-schema.js index a11f099c3..a1cda7d19 100644 --- a/packages/walletkit/src/api/scripts/generate-json-schema.js +++ b/packages/walletkit/src/api/scripts/generate-json-schema.js @@ -614,6 +614,13 @@ class DiscriminatedUnionTypeFormatter { if (this.hasRecursiveReference(type)) { return false; } + // Interface unions (named types without a `value` wrapper) are handled + // by ts-json-schema-generator's default allOf[if/then] output, then + // transformed by postProcessDiscriminatedUnions. Don't claim them here. + const hasValueWrapper = type.getTypes().every((variant) => this.getAssociatedValueType(variant) !== null); + if (!hasValueWrapper) { + return false; + } return true; } diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 5184d0d24..ee57bf3b0 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -598,8 +598,8 @@ export class TonWalletKit implements ITonWalletKit { 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); + item.walletId = walletId; + await this.requestProcessor.approveConnectRequest(item, proof ? { proof } : undefined); } } diff --git a/packages/walletkit/src/handlers/IntentHandler.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts index c14f9fc89..4e1b09af1 100644 --- a/packages/walletkit/src/handlers/IntentHandler.spec.ts +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -77,11 +77,12 @@ describe('IntentHandler', () => { /** Helper to build an event with resolvedTransaction so IntentResolver is bypassed. */ function txEvent(overrides: Partial = {}): TransactionIntentRequestEvent { return { + type: 'transaction', id: 'tx-1', origin: 'deepLink', clientId: 'client-1', deliveryMode: 'send', - items: [{ type: 'sendTon', value: { address: 'EQAddr', amount: '1000000000' } }], + items: [{ type: 'sendTon' as const, address: 'EQAddr', amount: '1000000000' }], resolvedTransaction: { messages: [{ address: 'EQAddr', amount: '1000000000' }], fromAddress: 'UQTestAddr', @@ -141,6 +142,7 @@ describe('IntentHandler', () => { it('signs data and returns result', async () => { const event: SignDataIntentRequestEvent = { + type: 'signData', id: 'sd-1', origin: 'deepLink', clientId: 'client-1', @@ -160,6 +162,7 @@ describe('IntentHandler', () => { it('falls back to raw manifestUrl for domain on invalid URL', async () => { const event: SignDataIntentRequestEvent = { + type: 'signData', id: 'sd-2', origin: 'deepLink', clientId: 'client-1', @@ -178,13 +181,11 @@ describe('IntentHandler', () => { 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', - deliveryMode: 'send', - items: [], - }, + id: 'tx-r1', + origin: 'deepLink', + clientId: 'client-1', + deliveryMode: 'send', + items: [], }; const result = await handler.rejectIntent(event); @@ -197,13 +198,11 @@ describe('IntentHandler', () => { it('uses custom reason and error code', async () => { const event: IntentRequestEvent = { type: 'signData', - value: { - id: 'sd-r1', - origin: 'deepLink', - clientId: 'client-1', - manifestUrl: 'https://example.com', - payload: { data: { type: 'text', value: { content: 'test' } } }, - }, + id: 'sd-r1', + origin: 'deepLink', + clientId: 'client-1', + manifestUrl: 'https://example.com', + payload: { data: { type: 'text', value: { content: 'test' } } }, }; const result = await handler.rejectIntent(event, 'Not supported', 400); @@ -267,12 +266,12 @@ describe('IntentHandler', () => { // 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[0].id).toBe('tx-batch_0'); + expect((batch.intents[0] as TransactionIntentRequestEvent).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); + expect(batch.intents[1].id).toBe('tx-batch_1'); + expect((batch.intents[1] as TransactionIntentRequestEvent).items).toHaveLength(1); }); it('emits regular IntentRequestEvent for single-item txIntent', async () => { @@ -333,24 +332,20 @@ describe('IntentHandler', () => { clientId: 'client-b', intents: [ { - type: 'transaction', - value: { - id: 'batch-1_0', - origin: 'deepLink', - clientId: 'client-b', - deliveryMode: 'send', - items: [{ type: 'sendTon', value: { address: 'EQAddr1', amount: '100' } }], - }, + type: 'transaction' as const, + id: 'batch-1_0', + origin: 'deepLink' as const, + clientId: 'client-b', + deliveryMode: 'send' as const, + items: [{ type: 'sendTon' as const, address: 'EQAddr1', amount: '100' }], }, { - type: 'transaction', - value: { - id: 'batch-1_1', - origin: 'deepLink', - clientId: 'client-b', - deliveryMode: 'send', - items: [{ type: 'sendTon', value: { address: 'EQAddr2', amount: '200' } }], - }, + type: 'transaction' as const, + id: 'batch-1_1', + origin: 'deepLink' as const, + clientId: 'client-b', + deliveryMode: 'send' as const, + items: [{ type: 'sendTon' as const, address: 'EQAddr2', amount: '200' }], }, ], ...overrides, @@ -370,7 +365,7 @@ describe('IntentHandler', () => { it('uses signOnly when any inner event has signOnly delivery', async () => { const batch = makeBatch(); - (batch.intents[1].value as TransactionIntentRequestEvent).deliveryMode = 'signOnly'; + (batch.intents[1] as TransactionIntentRequestEvent).deliveryMode = 'signOnly'; await handler.approveBatchedIntent(batch, 'wallet-1'); @@ -384,14 +379,12 @@ describe('IntentHandler', () => { const signDataBatch = makeBatch({ intents: [ { - type: 'signData', - value: { - id: 'sd-1', - origin: 'deepLink', - clientId: 'client-b', - manifestUrl: 'https://example.com', - payload: { data: { type: 'text', value: { content: 'x' } } }, - }, + type: 'signData' as const, + id: 'sd-1', + origin: 'deepLink' as const, + clientId: 'client-b', + manifestUrl: 'https://example.com', + payload: { data: { type: 'text', value: { content: 'x' } } }, }, ], }); @@ -406,15 +399,11 @@ describe('IntentHandler', () => { 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: '', - }, + type: 'connect' as const, + id: 'c-1', + from: 'client-b', + requestedItems: [], + preview: { permissions: [] }, } as unknown as IntentRequestEvent, ], }); @@ -441,14 +430,12 @@ describe('IntentHandler', () => { clientId: 'client-br', intents: [ { - type: 'transaction', - value: { - id: 'batch-r_0', - origin: 'deepLink', - clientId: 'client-br', - deliveryMode: 'send', - items: [{ type: 'sendTon', value: { address: 'EQ1', amount: '100' } }], - }, + type: 'transaction' as const, + id: 'batch-r_0', + origin: 'deepLink' as const, + clientId: 'client-br', + deliveryMode: 'send' as const, + items: [{ type: 'sendTon' as const, address: 'EQ1', amount: '100' }], }, ], }; @@ -476,13 +463,11 @@ describe('IntentHandler', () => { clientId: 'cr', intents: [ { - type: 'connect', - value: { - id: 'batch-pcr', - from: 'cr', - requestedItems: [], - preview: { permissions: [] }, - }, + type: 'connect' as const, + id: 'batch-pcr', + from: 'cr', + requestedItems: [], + preview: { permissions: [] }, }, ], }; @@ -502,11 +487,12 @@ describe('IntentHandler', () => { const h = new IntentHandler(defaultOptions, bridgeManager, noWalletManager); const event: TransactionIntentRequestEvent = { + type: 'transaction', id: 'tx-nw', origin: 'deepLink', clientId: 'c1', deliveryMode: 'send', - items: [{ type: 'sendTon', value: { address: 'EQ1', amount: '100' } }], + items: [{ type: 'sendTon' as const, address: 'EQ1', amount: '100' }], resolvedTransaction: { messages: [{ address: 'EQ1', amount: '100' }], fromAddress: 'UQ1', diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index 1d88ee5eb..a8a16c041 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -82,15 +82,15 @@ export class IntentHandler { // parser.parse() never returns connect events if (event.type === 'connect') return; - // Resolve connect request into a ConnectionRequestEvent if present + // Resolve connect request into a ConnectIntentRequestEvent if present let connectItem: IntentRequestEvent | undefined; if (connectRequest) { const connectionEvent = await this.resolveConnectRequest(connectRequest, event); - connectItem = { type: 'connect', value: connectionEvent }; + connectItem = { ...connectionEvent, type: 'connect' as const }; } if (event.type === 'transaction') { - if (connectItem || event.value.items.length > 1) { + if (connectItem || event.items.length > 1) { // Batch when there's a connect or multiple tx items await this.resolveAndEmitBatchedTransaction(event, walletId, connectItem); } else { @@ -100,11 +100,11 @@ export class IntentHandler { 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, + id: event.id, + origin: event.origin, + clientId: event.clientId, + traceId: event.traceId, + returnStrategy: event.returnStrategy, intents: [connectItem, event], }; this.emit(batch); @@ -144,10 +144,11 @@ export class IntentHandler { } const result: IntentTransactionResponse = { + type: 'transaction', boc: signedBoc as Base64String, }; - await this.sendResponse(event, { type: 'transaction', value: result }); + await this.sendResponse(event, result); return result; } @@ -169,8 +170,8 @@ export class IntentHandler { 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') { + allItems.push(...intent.items); + if (intent.deliveryMode === 'signOnly') { deliveryMode = 'signOnly'; } } @@ -180,8 +181,8 @@ export class IntentHandler { 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; + const network = firstTx?.type === 'transaction' ? firstTx.network : undefined; + const validUntil = firstTx?.type === 'transaction' ? firstTx.validUntil : undefined; // Build combined transaction const transactionRequest = await this.resolver.intentItemsToTransactionRequest( @@ -201,18 +202,19 @@ export class IntentHandler { } const result: IntentTransactionResponse = { + type: 'transaction', boc: signedBoc as Base64String, }; // Send one response using the batch's identity - await this.sendBatchResponse(batch, { type: 'transaction', value: result }); + await this.sendBatchResponse(batch, result); return result; } // Check for signData intents const signDataIntent = batch.intents.find((i) => i.type === 'signData'); if (signDataIntent && signDataIntent.type === 'signData') { - const event = signDataIntent.value; + const event = signDataIntent; let domain = event.manifestUrl; try { @@ -231,6 +233,7 @@ export class IntentHandler { const signatureBase64 = HexToBase64(signature); const result: IntentSignDataResponse = { + type: 'signData', signature: signatureBase64 as Base64String, address: wallet.getAddress() as UserFriendlyAddress, timestamp: signData.timestamp, @@ -238,7 +241,7 @@ export class IntentHandler { payload: event.payload, }; - await this.sendBatchResponse(batch, { type: 'signData', value: result }); + await this.sendBatchResponse(batch, result); return result; } @@ -268,6 +271,7 @@ export class IntentHandler { const signatureBase64 = HexToBase64(signature); const result: IntentSignDataResponse = { + type: 'signData', signature: signatureBase64 as Base64String, address: wallet.getAddress() as UserFriendlyAddress, timestamp: signData.timestamp, @@ -275,7 +279,7 @@ export class IntentHandler { payload: event.payload, }; - await this.sendResponse(event, { type: 'signData', value: result }); + await this.sendResponse(event, result); return result; } @@ -289,12 +293,12 @@ export class IntentHandler { const resolvedEvent = this.parser.parseActionResponse(actionResponse, event); if (resolvedEvent.type === 'transaction') { - if (resolvedEvent.value.resolvedTransaction) { - resolvedEvent.value.resolvedTransaction.fromAddress = wallet.getAddress(); + if (resolvedEvent.resolvedTransaction) { + resolvedEvent.resolvedTransaction.fromAddress = wallet.getAddress(); } - return this.approveTransactionIntent(resolvedEvent.value, walletId); + return this.approveTransactionIntent(resolvedEvent, walletId); } else if (resolvedEvent.type === 'signData') { - return this.approveSignDataIntent(resolvedEvent.value, walletId); + return this.approveSignDataIntent(resolvedEvent, walletId); } throw new WalletKitError( @@ -311,6 +315,7 @@ export class IntentHandler { errorCode?: number, ): Promise { const result: IntentErrorResponse = { + type: 'error', error: { code: errorCode ?? INTENT_ERROR_CODES.USER_DECLINED, message: reason || 'User declined the request', @@ -319,9 +324,9 @@ export class IntentHandler { const isBatched = 'intents' in event; if (isBatched) { - await this.sendBatchResponse(event, { type: 'error', value: result }); + await this.sendBatchResponse(event, result); } else if (event.type !== 'connect') { - await this.sendResponse(event.value, { type: 'error', value: result }); + await this.sendResponse(event, result); } return result; } @@ -339,18 +344,17 @@ export class IntentHandler { event: Extract, walletId: string, ): Promise { - const txEvent = event.value; const wallet = this.getWallet(walletId); - const transactionRequest = await this.resolveTransaction(txEvent, wallet); - txEvent.resolvedTransaction = transactionRequest; + const transactionRequest = await this.resolveTransaction(event, wallet); + event.resolvedTransaction = transactionRequest; try { const preview = await wallet.getTransactionPreview(transactionRequest); - txEvent.preview = preview; + event.preview = preview; } catch (error) { log.warn('Failed to emulate transaction preview', { error }); - txEvent.preview = undefined; + event.preview = undefined; } this.emit(event); @@ -365,8 +369,8 @@ export class IntentHandler { event: Exclude, ): Promise { const bridgeEvent: RawBridgeEventConnect = { - from: event.value.clientId || '', - id: event.value.id, + from: event.clientId || '', + id: event.id, method: 'connect', params: { manifest: { url: connectRequest.manifestUrl }, @@ -392,22 +396,22 @@ export class IntentHandler { walletId: string, connectItem?: IntentRequestEvent, ): 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]; + for (let i = 0; i < event.items.length; i++) { + const item = event.items[i]; const itemEvent: TransactionIntentRequestEvent = { - id: `${txEvent.id}_${i}`, - origin: txEvent.origin, - clientId: txEvent.clientId, - traceId: txEvent.traceId, - returnStrategy: txEvent.returnStrategy, - deliveryMode: txEvent.deliveryMode, - network: txEvent.network, - validUntil: txEvent.validUntil, + type: 'transaction', + id: `${event.id}_${i}`, + origin: event.origin, + clientId: event.clientId, + traceId: event.traceId, + returnStrategy: event.returnStrategy, + deliveryMode: event.deliveryMode, + network: event.network, + validUntil: event.validUntil, items: [item], }; @@ -420,7 +424,7 @@ export class IntentHandler { log.warn('Failed to resolve/emulate batched item', { error, index: i }); } - perItemEvents.push({ type: 'transaction', value: itemEvent }); + perItemEvents.push(itemEvent); } const intents: IntentRequestEvent[] = []; @@ -428,11 +432,11 @@ export class IntentHandler { intents.push(...perItemEvents); const batch: BatchedIntentEvent = { - id: txEvent.id, - origin: txEvent.origin, - clientId: txEvent.clientId, - traceId: txEvent.traceId, - returnStrategy: txEvent.returnStrategy, + id: event.id, + origin: event.origin, + clientId: event.clientId, + traceId: event.traceId, + returnStrategy: event.returnStrategy, intents, }; @@ -487,22 +491,22 @@ export class IntentHandler { private toWireResponse(eventId: string, result: IntentResponseResult): Record { if (result.type === 'error') { return { - error: { code: result.value.error.code, message: result.value.error.message }, + error: { code: result.error.code, message: result.error.message }, id: eventId, }; } if (result.type === 'transaction') { - return { result: result.value.boc, id: eventId }; + return { result: result.boc, id: eventId }; } return { result: { - signature: result.value.signature, - address: result.value.address, - timestamp: result.value.timestamp, - domain: result.value.domain, - payload: this.signDataPayloadToWire(result.value.payload), + signature: result.signature, + address: result.address, + timestamp: result.timestamp, + domain: result.domain, + payload: this.signDataPayloadToWire(result.payload), }, id: eventId, }; diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts index 01f9549c7..15703eedc 100644 --- a/packages/walletkit/src/handlers/IntentParser.spec.ts +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -74,7 +74,7 @@ describe('IntentParser', () => { expect(event.type).toBe('transaction'); if (event.type !== 'transaction') throw new Error('unexpected'); - const tx = event.value; + const tx = event; expect(tx.id).toBe('tx-1'); expect(tx.origin).toBe('deepLink'); @@ -86,13 +86,13 @@ describe('IntentParser', () => { 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[0].address).toBe('EQAddr1'); + expect(tx.items[0].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'); + expect(tx.items[1].payload).toBe('payload-b64'); } }); @@ -106,7 +106,7 @@ describe('IntentParser', () => { const { event } = await parser.parse(url); expect(event.type).toBe('transaction'); if (event.type === 'transaction') { - expect(event.value.deliveryMode).toBe('signOnly'); + expect(event.deliveryMode).toBe('signOnly'); } }); @@ -130,15 +130,15 @@ describe('IntentParser', () => { const { event } = await parser.parse(url); expect(event.type).toBe('transaction'); if (event.type === 'transaction') { - const item = event.value.items[0]; + const item = event.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); + expect(item.jettonMasterAddress).toBe('EQJettonMaster'); + expect(item.jettonAmount).toBe('5000000'); + expect(item.destination).toBe('EQDest'); + expect(item.responseDestination).toBe('EQResp'); + expect(item.forwardTonAmount).toBe('10000'); + expect(item.queryId).toBe(42); } } }); @@ -160,12 +160,12 @@ describe('IntentParser', () => { const { event } = await parser.parse(url); expect(event.type).toBe('transaction'); if (event.type === 'transaction') { - const item = event.value.items[0]; + const item = event.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'); + expect(item.nftAddress).toBe('EQNftAddr'); + expect(item.newOwnerAddress).toBe('EQNewOwner'); + expect(item.responseDestination).toBe('EQResp'); } } }); @@ -186,11 +186,11 @@ describe('IntentParser', () => { 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'); + expect(event.id).toBe('si-1'); + expect(event.manifestUrl).toBe('https://example.com/manifest.json'); + expect(event.payload.data.type).toBe('text'); + if (event.payload.data.type === 'text') { + expect(event.payload.data.value.content).toBe('Hello world'); } } }); @@ -205,9 +205,9 @@ describe('IntentParser', () => { 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'); + expect(event.payload.data.type).toBe('binary'); + if (event.payload.data.type === 'binary') { + expect(event.payload.data.value.content).toBe('AQID'); } } }); @@ -222,10 +222,10 @@ describe('IntentParser', () => { 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'); + expect(event.payload.data.type).toBe('cell'); + if (event.payload.data.type === 'cell') { + expect(event.payload.data.value.content).toBe('te6cckEBAQEA'); + expect(event.payload.data.value.schema).toBe('MySchema'); } } }); @@ -244,7 +244,7 @@ describe('IntentParser', () => { 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.manifestUrl).toBe('https://dapp.com/manifest.json'); } }); }); @@ -262,8 +262,8 @@ describe('IntentParser', () => { 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'); + expect(event.id).toBe('a-1'); + expect(event.actionUrl).toBe('https://api.example.com/action'); } }); }); @@ -278,7 +278,7 @@ describe('IntentParser', () => { const { event } = await parser.parse(url); expect(event.type).toBe('transaction'); - expect(event.value.clientId).toBeUndefined(); + expect(event.clientId).toBeUndefined(); }); it('rejects object storage URL without client ID', async () => { @@ -404,6 +404,7 @@ describe('IntentParser', () => { describe('parseActionResponse', () => { const baseActionEvent = { + type: 'action' as const, id: 'a-1', origin: 'deepLink' as const, clientId: 'c1', @@ -423,11 +424,11 @@ describe('IntentParser', () => { 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' }); + expect(event.resolvedTransaction).toBeDefined(); + expect(event.resolvedTransaction!.messages).toHaveLength(1); + expect(event.resolvedTransaction!.messages[0].address).toBe('EQAddr'); + expect(event.resolvedTransaction!.messages[0].amount).toBe('500'); + expect(event.resolvedTransaction!.network).toEqual({ chainId: '-239' }); } }); @@ -443,8 +444,8 @@ describe('IntentParser', () => { 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'); + expect(event.manifestUrl).toBe('https://api.example.com/action'); + expect(event.payload.data.type).toBe('text'); } }); diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index 7d76a1330..c61055945 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -485,15 +485,13 @@ export class IntentParser { }; return { - type: 'transaction', - value: { - ...base, - deliveryMode: 'send' as IntentDeliveryMode, - network, - validUntil: resolvedTransaction.validUntil, - items: [], - resolvedTransaction, - }, + type: 'transaction' as const, + ...base, + deliveryMode: 'send' as IntentDeliveryMode, + network, + validUntil: resolvedTransaction.validUntil, + items: [], + resolvedTransaction, }; } @@ -515,13 +513,11 @@ export class IntentParser { } return { - type: 'signData', - value: { - ...base, - network: action.network ? { chainId: action.network as string } : undefined, - manifestUrl: actionUrl, - payload: this.wirePayloadToSignDataPayload(wirePayload), - }, + type: 'signData' as const, + ...base, + network: action.network ? { chainId: action.network as string } : undefined, + manifestUrl: actionUrl, + payload: this.wirePayloadToSignDataPayload(wirePayload), }; } @@ -545,37 +541,31 @@ export class IntentParser { case 'signMsg': { const deliveryMode: IntentDeliveryMode = request.m === 'txIntent' ? 'send' : 'signOnly'; event = { - type: 'transaction', - value: { - ...base, - deliveryMode, - network: request.n ? { chainId: request.n } : undefined, - validUntil: request.vu, - items: this.mapItems(request.i!), - }, + type: 'transaction' as const, + ...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 = { - type: 'signData', - value: { - ...base, - network: request.n ? { chainId: request.n } : undefined, - manifestUrl, - payload: this.wirePayloadToSignDataPayload(request.p!), - }, + type: 'signData' as const, + ...base, + network: request.n ? { chainId: request.n } : undefined, + manifestUrl, + payload: this.wirePayloadToSignDataPayload(request.p!), }; break; } case 'actionIntent': { event = { - type: 'action', - value: { - ...base, - actionUrl: request.a!, - }, + type: 'action' as const, + ...base, + actionUrl: request.a!, }; break; } @@ -592,41 +582,35 @@ export class IntentParser { switch (item.t) { case 'ton': return { - type: 'sendTon', - value: { - address: item.a!, - amount: item.am!, - payload: item.p as Base64String | undefined, - stateInit: item.si as Base64String | undefined, - extraCurrency: item.ec, - }, + type: 'sendTon' as const, + 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', - 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, - }, + type: 'sendJetton' as const, + 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', - 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, - }, + type: 'sendNft' as const, + 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 7a238cfb8..ca443188b 100644 --- a/packages/walletkit/src/handlers/IntentResolver.ts +++ b/packages/walletkit/src/handlers/IntentResolver.ts @@ -89,11 +89,11 @@ export class IntentResolver { private async resolveItem(item: IntentActionItem, wallet: Wallet): Promise { switch (item.type) { case 'sendTon': - return this.resolveTonItem(item.value); + return this.resolveTonItem(item); case 'sendJetton': - return this.resolveJettonItem(item.value, wallet); + return this.resolveJettonItem(item, wallet); case 'sendNft': - return this.resolveNftItem(item.value, wallet); + return this.resolveNftItem(item, wallet); } } From 51492c8a834ac8c1e3b95631062cf1fd920a47af Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 06:26:28 +0530 Subject: [PATCH 17/46] feat: update intent API to use 'txDraft' and 'signMsgDraft' terminology, add validation for missing actionUrl --- .../api/models/intents/IntentRequestEvent.ts | 8 +- .../src/handlers/IntentHandler.spec.ts | 42 ++++---- .../walletkit/src/handlers/IntentHandler.ts | 13 ++- .../src/handlers/IntentParser.spec.ts | 102 +++++++++--------- .../walletkit/src/handlers/IntentParser.ts | 70 +++++++----- 5 files changed, 130 insertions(+), 105 deletions(-) diff --git a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts index 3f36dd373..1b628f91d 100644 --- a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts +++ b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts @@ -38,7 +38,7 @@ export interface IntentRequestBase extends BridgeEvent { /** * Transaction intent request event. * - * Covers both `txIntent` (send) and `signMsg` (signOnly) from the spec. + * Covers both `txDraft` (send) and `signMsgDraft` (signOnly) from the spec. * The `deliveryMode` field distinguishes them. */ export interface TransactionIntentRequestEvent extends IntentRequestBase { @@ -91,7 +91,11 @@ export interface ActionIntentRequestEvent extends IntentRequestBase { * Action URL to fetch * @format url */ - actionUrl: string; + actionUrl?: string; + /** + * Optional action type. + */ + actionType?: string; } /** diff --git a/packages/walletkit/src/handlers/IntentHandler.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts index 4e1b09af1..b0c2bf567 100644 --- a/packages/walletkit/src/handlers/IntentHandler.spec.ts +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -15,7 +15,7 @@ import type { Wallet } from '../api/interfaces'; import type { TonWalletKitOptions } from '../types'; import type { IntentRequestEvent, - TransactionIntentRequestEvent, + TransactionDraftRequestEvent, SignDataIntentRequestEvent, SignDataPayload, BatchedIntentEvent, @@ -71,11 +71,11 @@ describe('IntentHandler', () => { handler = new IntentHandler(defaultOptions, bridgeManager, walletManager); }); - // ── approveTransactionIntent ───────────────────────────────────────────── + // ── approveTransactionDraft ───────────────────────────────────────────── - describe('approveTransactionIntent', () => { + describe('approveTransactionDraft', () => { /** Helper to build an event with resolvedTransaction so IntentResolver is bypassed. */ - function txEvent(overrides: Partial = {}): TransactionIntentRequestEvent { + function txEvent(overrides: Partial = {}): TransactionDraftRequestEvent { return { type: 'transaction', id: 'tx-1', @@ -92,7 +92,7 @@ describe('IntentHandler', () => { } it('signs and sends a transaction, returns boc', async () => { - const result = await handler.approveTransactionIntent(txEvent(), 'wallet-1'); + const result = await handler.approveTransactionDraft(txEvent(), 'wallet-1'); expect(result.boc).toBe('signed-boc-base64'); expect(mockWallet.getSignedSendTransaction).toHaveBeenCalled(); @@ -103,7 +103,7 @@ describe('IntentHandler', () => { }); it('does not send boc when deliveryMode is signOnly', async () => { - const result = await handler.approveTransactionIntent( + const result = await handler.approveTransactionDraft( txEvent({ id: 'tx-2', deliveryMode: 'signOnly' }), 'wallet-1', ); @@ -121,14 +121,14 @@ describe('IntentHandler', () => { walletManager, ); - await devHandler.approveTransactionIntent(txEvent({ id: 'tx-3' }), 'wallet-1'); + await devHandler.approveTransactionDraft(txEvent({ id: 'tx-3' }), 'wallet-1'); expect( (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, ).not.toHaveBeenCalled(); }); it('skips bridge send when clientId is absent', async () => { - await handler.approveTransactionIntent(txEvent({ id: 'tx-4', clientId: '' }), 'wallet-1'); + await handler.approveTransactionDraft(txEvent({ id: 'tx-4', clientId: '' }), 'wallet-1'); expect(bridgeManager.sendIntentResponse).not.toHaveBeenCalled(); }); }); @@ -219,7 +219,7 @@ describe('IntentHandler', () => { const url = buildInlineUrl('c1', { id: 'tx-pcr', - m: 'txIntent', + m: 'txDraft', i: [{ t: 'ton', a: 'EQAddr', am: '100' }], c: { manifestUrl: 'https://dapp.com/m.json', items: [{ name: 'ton_addr' }] }, }); @@ -237,7 +237,7 @@ describe('IntentHandler', () => { // ── handleIntentUrl batching ──────────────────────────────────────────── describe('handleIntentUrl batching', () => { - it('emits BatchedIntentEvent for multi-item txIntent', async () => { + it('emits BatchedIntentEvent for multi-item txDraft', async () => { let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; handler.onIntentRequest((e) => { emitted = e; @@ -245,7 +245,7 @@ describe('IntentHandler', () => { const url = buildInlineUrl('c-batch', { id: 'tx-batch', - m: 'txIntent', + m: 'txDraft', i: [ { t: 'ton', a: 'EQAddr1', am: '100' }, { t: 'ton', a: 'EQAddr2', am: '200' }, @@ -267,14 +267,14 @@ describe('IntentHandler', () => { // Each inner event is a transaction with one item expect(batch.intents[0].type).toBe('transaction'); expect(batch.intents[0].id).toBe('tx-batch_0'); - expect((batch.intents[0] as TransactionIntentRequestEvent).items).toHaveLength(1); + expect((batch.intents[0] as TransactionDraftRequestEvent).items).toHaveLength(1); expect(batch.intents[1].type).toBe('transaction'); expect(batch.intents[1].id).toBe('tx-batch_1'); - expect((batch.intents[1] as TransactionIntentRequestEvent).items).toHaveLength(1); + expect((batch.intents[1] as TransactionDraftRequestEvent).items).toHaveLength(1); }); - it('emits regular IntentRequestEvent for single-item txIntent', async () => { + it('emits regular IntentRequestEvent for single-item txDraft', async () => { let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; handler.onIntentRequest((e) => { emitted = e; @@ -282,7 +282,7 @@ describe('IntentHandler', () => { const url = buildInlineUrl('c-single', { id: 'tx-single', - m: 'txIntent', + m: 'txDraft', i: [{ t: 'ton', a: 'EQAddr1', am: '100' }], }); @@ -302,7 +302,7 @@ describe('IntentHandler', () => { const url = buildInlineUrl('c-conn', { id: 'tx-conn', - m: 'txIntent', + m: 'txDraft', i: [ { t: 'ton', a: 'EQAddr1', am: '100' }, { t: 'ton', a: 'EQAddr2', am: '200' }, @@ -365,7 +365,7 @@ describe('IntentHandler', () => { it('uses signOnly when any inner event has signOnly delivery', async () => { const batch = makeBatch(); - (batch.intents[1] as TransactionIntentRequestEvent).deliveryMode = 'signOnly'; + (batch.intents[1] as TransactionDraftRequestEvent).deliveryMode = 'signOnly'; await handler.approveBatchedIntent(batch, 'wallet-1'); @@ -486,7 +486,7 @@ describe('IntentHandler', () => { } as unknown as WalletManager; const h = new IntentHandler(defaultOptions, bridgeManager, noWalletManager); - const event: TransactionIntentRequestEvent = { + const event: TransactionDraftRequestEvent = { type: 'transaction', id: 'tx-nw', origin: 'deepLink', @@ -499,18 +499,18 @@ describe('IntentHandler', () => { }, }; - await expect(h.approveTransactionIntent(event, 'missing-wallet')).rejects.toThrow('Wallet not found'); + await expect(h.approveTransactionDraft(event, 'missing-wallet')).rejects.toThrow('Wallet not found'); }); }); }); /** - * Helper: Build a tc://intent_inline URL from a wire request object. + * Helper: Build a tc://?m=intent 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}`; + let url = `tc://?m=intent&id=${clientId}&mp=${b64}`; if (opts?.traceId) url += `&trace_id=${opts.traceId}`; return url; } diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index a8a16c041..4a1775c3e 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -126,7 +126,7 @@ export class IntentHandler { // -- Public: Approval ----------------------------------------------------- - async approveTransactionIntent( + async approveTransactionDraft( event: TransactionIntentRequestEvent, walletId: string, ): Promise { @@ -283,12 +283,19 @@ export class IntentHandler { return result; } - async approveActionIntent( + async approveActionDraft( event: ActionIntentRequestEvent, walletId: string, ): Promise { const wallet = this.getWallet(walletId); + if (!event.actionUrl) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Action intent missing actionUrl, cannot fetch action response.', + ); + } + const actionResponse = await this.resolver.fetchActionUrl(event.actionUrl, wallet.getAddress()); const resolvedEvent = this.parser.parseActionResponse(actionResponse, event); @@ -296,7 +303,7 @@ export class IntentHandler { if (resolvedEvent.resolvedTransaction) { resolvedEvent.resolvedTransaction.fromAddress = wallet.getAddress(); } - return this.approveTransactionIntent(resolvedEvent, walletId); + return this.approveTransactionDraft(resolvedEvent, walletId); } else if (resolvedEvent.type === 'signData') { return this.approveSignDataIntent(resolvedEvent, walletId); } diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts index 15703eedc..dceed3ac9 100644 --- a/packages/walletkit/src/handlers/IntentParser.spec.ts +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -11,14 +11,14 @@ 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. + * Helper: Build a tc://?m=intent URL from a wire request object. + * Encodes the request as base64url in the `mp` 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}`; + let url = `tc://?m=intent&id=${clientId}&mp=${b64}`; if (opts?.traceId) url += `&trace_id=${opts.traceId}`; return url; } @@ -33,33 +33,33 @@ describe('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 m=intent URLs', () => { + expect(parser.isIntentUrl('tc://?m=intent&id=abc&mp=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('returns true for m=intent_remote URLs', () => { + expect(parser.isIntentUrl('tc://?m=intent_remote&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); + expect(parser.isIntentUrl('TC://?M=INTENT&id=abc')).toBe(true); + expect(parser.isIntentUrl(' TC://?M=INTENT_REMOTE&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('tc://?m=connect&id=abc')).toBe(false); expect(parser.isIntentUrl('')).toBe(false); }); }); - // ── parse – inline txIntent ────────────────────────────────────────────── + // ── parse – inline txDraft ────────────────────────────────────────────── - describe('parse – txIntent (inline)', () => { + describe('parse – txDraft (inline)', () => { it('parses a transaction intent with TON items', async () => { const url = buildInlineUrl('client-123', { id: 'tx-1', - m: 'txIntent', + m: 'txDraft', i: [ { t: 'ton', a: 'EQAddr1', am: '1000000000' }, { t: 'ton', a: 'EQAddr2', am: '2000000000', p: 'payload-b64' }, @@ -96,10 +96,10 @@ describe('IntentParser', () => { } }); - it('parses signMsg as signOnly delivery mode', async () => { + it('parses signMsgDraft as signOnly delivery mode', async () => { const url = buildInlineUrl('c1', { id: 'sm-1', - m: 'signMsg', + m: 'signMsgDraft', i: [{ t: 'ton', a: 'EQ1', am: '100' }], }); @@ -113,7 +113,7 @@ describe('IntentParser', () => { it('parses jetton items', async () => { const url = buildInlineUrl('c1', { id: 'j-1', - m: 'txIntent', + m: 'txDraft', i: [ { t: 'jetton', @@ -146,7 +146,7 @@ describe('IntentParser', () => { it('parses NFT items', async () => { const url = buildInlineUrl('c1', { id: 'n-1', - m: 'txIntent', + m: 'txDraft', i: [ { t: 'nft', @@ -171,13 +171,13 @@ describe('IntentParser', () => { }); }); - // ── parse – inline signIntent ──────────────────────────────────────────── + // ── parse – inline signData ──────────────────────────────────────────── - describe('parse – signIntent (inline)', () => { + describe('parse – signData (inline)', () => { it('parses a text sign data intent', async () => { const url = buildInlineUrl('c1', { id: 'si-1', - m: 'signIntent', + m: 'signData', mu: 'https://example.com/manifest.json', p: { type: 'text', text: 'Hello world' }, }); @@ -198,7 +198,7 @@ describe('IntentParser', () => { it('parses a binary sign data intent', async () => { const url = buildInlineUrl('c1', { id: 'si-2', - m: 'signIntent', + m: 'signData', mu: 'https://example.com/manifest.json', p: { type: 'binary', bytes: 'AQID' }, }); @@ -215,7 +215,7 @@ describe('IntentParser', () => { it('parses a cell sign data intent', async () => { const url = buildInlineUrl('c1', { id: 'si-3', - m: 'signIntent', + m: 'signData', mu: 'https://example.com/manifest.json', p: { type: 'cell', cell: 'te6cckEBAQEA', schema: 'MySchema' }, }); @@ -233,7 +233,7 @@ describe('IntentParser', () => { it('uses manifestUrl from connect request when mu is absent', async () => { const url = buildInlineUrl('c1', { id: 'si-4', - m: 'signIntent', + m: 'signData', c: { manifestUrl: 'https://dapp.com/manifest.json', items: [{ name: 'ton_addr' }], @@ -249,14 +249,14 @@ describe('IntentParser', () => { }); }); - // ── parse – inline actionIntent ────────────────────────────────────────── + // ── parse – inline actionDraft ────────────────────────────────────────── - describe('parse – actionIntent (inline)', () => { + describe('parse – actionDraft (inline)', () => { it('parses an action intent', async () => { const url = buildInlineUrl('c1', { id: 'a-1', - m: 'actionIntent', - a: 'https://api.example.com/action', + m: 'actionDraft', + url: 'https://api.example.com/action', }); const { event } = await parser.parse(url); @@ -272,9 +272,9 @@ describe('IntentParser', () => { describe('parse – validation', () => { 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 json = JSON.stringify({ id: 'x', m: 'txDraft', i: [{ t: 'ton', a: 'A', am: '1' }] }); const b64 = Buffer.from(json).toString('base64url'); - const url = `tc://intent_inline?r=${b64}`; + const url = `tc://?m=intent&mp=${b64}`; const { event } = await parser.parse(url); expect(event.type).toBe('transaction'); @@ -282,12 +282,12 @@ describe('IntentParser', () => { }); it('rejects object storage URL without client ID', async () => { - const url = 'tc://intent?pk=abc123&get_url=https%3A%2F%2Fexample.com%2Fpayload'; + const url = 'tc://?m=intent_remote&pk=abc123&get_url=https%3A%2F%2Fexample.com%2Fpayload'; await expect(parser.parse(url)).rejects.toThrow('Missing client ID'); }); it('rejects URL without payload', async () => { - const url = 'tc://intent_inline?id=c1'; + const url = 'tc://?m=intent&id=c1'; await expect(parser.parse(url)).rejects.toThrow('Missing payload'); }); @@ -296,30 +296,30 @@ describe('IntentParser', () => { await expect(parser.parse(url)).rejects.toThrow('Invalid intent method'); }); - it('rejects txIntent without items', async () => { - const url = buildInlineUrl('c1', { id: 'x', m: 'txIntent' }); + it('rejects txDraft without items', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'txDraft' }); 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' }] }); + it('rejects txDraft with invalid item type', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'txDraft', 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' }] }); + const url = buildInlineUrl('c1', { id: 'x', m: 'txDraft', 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' }] }); + const url = buildInlineUrl('c1', { id: 'x', m: 'txDraft', 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', + m: 'txDraft', i: [{ t: 'jetton', ja: '100', d: 'D' }], }); await expect(parser.parse(url)).rejects.toThrow('missing master address'); @@ -328,7 +328,7 @@ describe('IntentParser', () => { it('rejects jetton item missing amount', async () => { const url = buildInlineUrl('c1', { id: 'x', - m: 'txIntent', + m: 'txDraft', i: [{ t: 'jetton', ma: 'MA', d: 'D' }], }); await expect(parser.parse(url)).rejects.toThrow('missing amount'); @@ -337,7 +337,7 @@ describe('IntentParser', () => { it('rejects jetton item missing destination', async () => { const url = buildInlineUrl('c1', { id: 'x', - m: 'txIntent', + m: 'txDraft', i: [{ t: 'jetton', ma: 'MA', ja: '100' }], }); await expect(parser.parse(url)).rejects.toThrow('missing destination'); @@ -346,7 +346,7 @@ describe('IntentParser', () => { it('rejects NFT item missing address', async () => { const url = buildInlineUrl('c1', { id: 'x', - m: 'txIntent', + m: 'txDraft', i: [{ t: 'nft', no: 'NO' }], }); await expect(parser.parse(url)).rejects.toThrow('missing address'); @@ -355,44 +355,44 @@ describe('IntentParser', () => { it('rejects NFT item missing new owner', async () => { const url = buildInlineUrl('c1', { id: 'x', - m: 'txIntent', + m: 'txDraft', i: [{ t: 'nft', na: 'NA' }], }); await expect(parser.parse(url)).rejects.toThrow('missing new owner'); }); - it('rejects signIntent without manifest URL', async () => { + it('rejects signData without manifest URL', async () => { const url = buildInlineUrl('c1', { id: 'x', - m: 'signIntent', + m: 'signData', p: { type: 'text', text: 'hello' }, }); await expect(parser.parse(url)).rejects.toThrow('missing manifest URL'); }); - it('rejects signIntent without payload', async () => { + it('rejects signData without payload', async () => { const url = buildInlineUrl('c1', { id: 'x', - m: 'signIntent', + m: 'signData', 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 actionDraft without action URL', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'actionDraft' }); + await expect(parser.parse(url)).rejects.toThrow('missing url or action_type'); }); it('rejects request without id', async () => { - const url = buildInlineUrl('c1', { m: 'txIntent', i: [{ t: 'ton', a: 'A', am: '1' }] }); + const url = buildInlineUrl('c1', { m: 'txDraft', 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', + m: 'signData', mu: 'https://example.com/m.json', p: { type: 'unsupported' }, }); diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index c61055945..c6b74baad 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -24,15 +24,14 @@ import type { Network, } from '../api/models'; -const INTENT_INLINE_SCHEME = 'tc://intent_inline'; -const INTENT_SCHEME = 'tc://intent'; +const TC_SCHEME = 'tc://'; /** * Wire-format intent method identifiers from the TonConnect spec. */ -type WireIntentMethod = 'txIntent' | 'signMsg' | 'signIntent' | 'actionIntent'; +type WireIntentMethod = 'txDraft' | 'signMsgDraft' | 'signData' | 'actionDraft'; -const VALID_METHODS: WireIntentMethod[] = ['txIntent', 'signMsg', 'signIntent', 'actionIntent']; +const VALID_METHODS: WireIntentMethod[] = ['txDraft', 'signMsgDraft', 'signData', 'actionDraft']; /** * Wire-format intent item types. @@ -71,15 +70,16 @@ interface WireIntentRequest { id: string; m: WireIntentMethod; c?: ConnectRequest; - // txIntent / signMsg + // txDraft / signMsgDraft i?: WireIntentItem[]; vu?: number; n?: string; - // signIntent + // signData mu?: string; p?: { type: string; text?: string; bytes?: string; schema?: string; cell?: string }; - // actionIntent - a?: string; + // actionDraft + action_type?: string; + url?: string; } /** @@ -118,12 +118,19 @@ export class IntentParser { */ isIntentUrl(url: string): boolean { const normalized = url.trim().toLowerCase(); - return normalized.startsWith(INTENT_INLINE_SCHEME) || normalized.startsWith(INTENT_SCHEME); + if (!normalized.startsWith(TC_SCHEME)) return false; + try { + const parsedUrl = new URL(url); + const method = parsedUrl.searchParams.get('m') || parsedUrl.searchParams.get('M'); + return method?.toLowerCase() === 'intent' || method?.toLowerCase() === 'intent_remote'; + } catch { + return false; + } } /** * Parse an intent URL into a typed IntentRequestEvent. - * Supports both `tc://intent_inline` (URL-embedded) and `tc://intent` (object storage). + * Supports both `m=intent` (URL-embedded) and `m=intent_remote` (object storage). */ async parse(url: string): Promise<{ event: IntentRequestEvent; connectRequest?: ConnectRequest }> { const parsed = await this.parseUrl(url); @@ -138,12 +145,18 @@ export class IntentParser { const clientId = parsedUrl.searchParams.get('id') || undefined; const normalized = url.trim().toLowerCase(); + if (!normalized.startsWith(TC_SCHEME)) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Unknown intent URL scheme'); + } + + const methodKey = Array.from(parsedUrl.searchParams.keys()).find((k) => k.toLowerCase() === 'm'); + const method = methodKey ? parsedUrl.searchParams.get(methodKey)?.toLowerCase() : null; - if (normalized.startsWith(INTENT_INLINE_SCHEME)) { + if (method === 'intent') { return this.parseInlinePayload(parsedUrl, clientId); } - if (normalized.startsWith(INTENT_SCHEME)) { + if (method === 'intent_remote') { if (!clientId) { throw new WalletKitError( ERROR_CODES.VALIDATION_ERROR, @@ -153,7 +166,7 @@ export class IntentParser { return this.parseObjectStoragePayload(parsedUrl, clientId); } - throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Unknown intent URL scheme'); + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Unknown intent URL method'); } catch (error) { if (error instanceof WalletKitError) throw error; throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid intent URL format', error as Error); @@ -161,9 +174,9 @@ export class IntentParser { } private parseInlinePayload(parsedUrl: URL, clientId: string | undefined): ParsedIntentUrl { - const encoded = parsedUrl.searchParams.get('r'); + const encoded = parsedUrl.searchParams.get('mp'); if (!encoded) { - throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing payload (r) in intent URL'); + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing payload (mp) in intent URL'); } const traceId = parsedUrl.searchParams.get('trace_id') || undefined; @@ -362,14 +375,14 @@ export class IntentParser { } switch (request.m) { - case 'txIntent': - case 'signMsg': + case 'txDraft': + case 'signMsgDraft': this.validateTransactionItems(request); break; - case 'signIntent': + case 'signData': this.validateSignData(request); break; - case 'actionIntent': + case 'actionDraft': this.validateAction(request); break; } @@ -420,8 +433,8 @@ export class IntentParser { } private validateAction(request: WireIntentRequest): void { - if (!request.a) { - throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent missing action URL (a)'); + if (!request.url && !request.action_type) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent missing url or action_type'); } } @@ -453,7 +466,7 @@ export class IntentParser { case 'sendTransaction': return this.parseActionTransaction(base, action); case 'signData': - return this.parseActionSignData(base, action, sourceEvent.actionUrl); + return this.parseActionSignData(base, action, sourceEvent.actionUrl || ''); default: throw new WalletKitError( ERROR_CODES.VALIDATION_ERROR, @@ -537,9 +550,9 @@ export class IntentParser { let event: IntentRequestEvent; switch (request.m) { - case 'txIntent': - case 'signMsg': { - const deliveryMode: IntentDeliveryMode = request.m === 'txIntent' ? 'send' : 'signOnly'; + case 'txDraft': + case 'signMsgDraft': { + const deliveryMode: IntentDeliveryMode = request.m === 'txDraft' ? 'send' : 'signOnly'; event = { type: 'transaction' as const, ...base, @@ -550,7 +563,7 @@ export class IntentParser { }; break; } - case 'signIntent': { + case 'signData': { const manifestUrl = request.mu || request.c?.manifestUrl || ''; event = { type: 'signData' as const, @@ -561,11 +574,12 @@ export class IntentParser { }; break; } - case 'actionIntent': { + case 'actionDraft': { event = { type: 'action' as const, ...base, - actionUrl: request.a!, + actionUrl: request.url || '', + actionType: request.action_type, }; break; } From 74266fbf8e38ed1dbe7cc9cb0607be951ba0dd97 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 11:34:10 +0530 Subject: [PATCH 18/46] feat: rename intent approval methods to use 'Draft' terminology for consistency --- packages/walletkit/src/core/TonWalletKit.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index ee57bf3b0..159bd00ca 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -566,12 +566,12 @@ export class TonWalletKit implements ITonWalletKit { this.intentHandler.removeIntentRequestCallback(cb); } - async approveTransactionIntent( + async approveTransactionDraft( event: TransactionIntentRequestEvent, walletId: string, ): Promise { await this.ensureInitialized(); - return this.intentHandler.approveTransactionIntent(event, walletId); + return this.intentHandler.approveTransactionDraft(event, walletId); } async approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise { @@ -579,12 +579,12 @@ export class TonWalletKit implements ITonWalletKit { return this.intentHandler.approveSignDataIntent(event, walletId); } - async approveActionIntent( + async approveActionDraft( event: ActionIntentRequestEvent, walletId: string, ): Promise { await this.ensureInitialized(); - return this.intentHandler.approveActionIntent(event, walletId); + return this.intentHandler.approveActionDraft(event, walletId); } async approveBatchedIntent( From 6d2313f5e8aac04af78223f7f57cc381ee489aaa Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 13:46:04 +0530 Subject: [PATCH 19/46] feat: enhance isIntentUrl method to support new URL format and pre-init fallback --- packages/walletkit/src/core/TonWalletKit.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 159bd00ca..f3fc225f1 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -543,8 +543,20 @@ export class TonWalletKit implements ITonWalletKit { // === Intent API === isIntentUrl(url: string): boolean { + if (this.intentHandler) { + return this.intentHandler.isIntentUrl(url); + } + // Pre-init fallback: mirror IntentParser.isIntentUrl logic for the new URL format + // New format: tc://?m=intent&... or tc://?m=intent_remote&... const normalized = url.trim().toLowerCase(); - return normalized.startsWith('tc://intent_inline') || normalized.startsWith('tc://intent'); + if (!normalized.startsWith('tc://')) return false; + try { + const parsedUrl = new URL(url); + const method = parsedUrl.searchParams.get('m') || parsedUrl.searchParams.get('M'); + return method?.toLowerCase() === 'intent' || method?.toLowerCase() === 'intent_remote'; + } catch { + return false; + } } async handleIntentUrl(url: string, walletId: string): Promise { From 6209fc8297369f5bc4ffca254266cefb3883be28 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 15:58:44 +0530 Subject: [PATCH 20/46] feat: refactor intent request types and validation --- .../walletkit/src/handlers/IntentParser.ts | 142 +++++++++++------- 1 file changed, 86 insertions(+), 56 deletions(-) diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index c6b74baad..33a977372 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -26,12 +26,7 @@ import type { const TC_SCHEME = 'tc://'; -/** - * Wire-format intent method identifiers from the TonConnect spec. - */ -type WireIntentMethod = 'txDraft' | 'signMsgDraft' | 'signData' | 'actionDraft'; - -const VALID_METHODS: WireIntentMethod[] = ['txDraft', 'signMsgDraft', 'signData', 'actionDraft']; +const VALID_METHODS = ['txDraft', 'signMsgDraft', 'signData', 'actionDraft'] as const; /** * Wire-format intent item types. @@ -63,23 +58,28 @@ interface WireIntentItem { no?: string; } +interface TxDraftParams { + vu?: number; + f?: string; + n?: string; + i: WireIntentItem[]; +} + +type SignDataParams = [string]; + +interface ActionDraftParams { + url: string; +} + /** - * Wire-format intent request payload. + * Spec-compliant intent request payload (PR #103). + * method names match the spec: txDraft | signMsgDraft | signData | actionDraft. + * params is nested (not flat) and ConnectRequest lives in the URL r param, not here. */ -interface WireIntentRequest { +interface SpecIntentRequest { id: string; - m: WireIntentMethod; - c?: ConnectRequest; - // txDraft / signMsgDraft - i?: WireIntentItem[]; - vu?: number; - n?: string; - // signData - mu?: string; - p?: { type: string; text?: string; bytes?: string; schema?: string; cell?: string }; - // actionDraft - action_type?: string; - url?: string; + method: 'txDraft' | 'signMsgDraft' | 'signData' | 'actionDraft'; + params: TxDraftParams | SignDataParams | ActionDraftParams; } /** @@ -87,7 +87,8 @@ interface WireIntentRequest { */ export interface ParsedIntentUrl { clientId?: string; - request: WireIntentRequest; + request: SpecIntentRequest; + connectRequest?: ConnectRequest; origin: IntentOrigin; traceId?: string; } @@ -180,16 +181,22 @@ export class IntentParser { } const traceId = parsedUrl.searchParams.get('trace_id') || undefined; + let connectRequest: ConnectRequest | undefined; + const rParam = parsedUrl.searchParams.get('r'); + if (rParam) { + try { connectRequest = JSON.parse(rParam) as ConnectRequest; } catch { /* optional */ } + } + const json = this.decodePayload(encoded); - let request: WireIntentRequest; + let request: SpecIntentRequest; try { - request = JSON.parse(json) as WireIntentRequest; + request = JSON.parse(json) as SpecIntentRequest; } catch (error) { throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid JSON in intent payload', error as Error); } this.validateRequest(request); - return { clientId, request, origin: 'deepLink', traceId }; + return { clientId, request, connectRequest, origin: 'deepLink', traceId }; } /** @@ -209,12 +216,18 @@ export class IntentParser { throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing get_url in intent URL'); } + let connectRequest: ConnectRequest | undefined; + const rParam = parsedUrl.searchParams.get('r'); + if (rParam) { + try { connectRequest = JSON.parse(rParam) as ConnectRequest; } catch { /* optional */ } + } + const encryptedPayload = await this.fetchObjectStoragePayload(getUrl); const json = this.decryptPayload(encryptedPayload, clientId, walletPrivateKey); - let request: WireIntentRequest; + let request: SpecIntentRequest; try { - request = JSON.parse(json) as WireIntentRequest; + request = JSON.parse(json) as SpecIntentRequest; } catch (error) { throw new WalletKitError( ERROR_CODES.VALIDATION_ERROR, @@ -224,7 +237,7 @@ export class IntentParser { } this.validateRequest(request); - return { clientId, request, origin: 'objectStorage', traceId }; + return { clientId, request, connectRequest, origin: 'objectStorage', traceId }; } /** @@ -366,15 +379,15 @@ export class IntentParser { // -- Validation ----------------------------------------------------------- - private validateRequest(request: WireIntentRequest): void { + private validateRequest(request: SpecIntentRequest): 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}`); + if (!request.method || !VALID_METHODS.includes(request.method)) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Invalid intent method: ${request.method}`); } - switch (request.m) { + switch (request.method) { case 'txDraft': case 'signMsgDraft': this.validateTransactionItems(request); @@ -388,11 +401,12 @@ export class IntentParser { } } - private validateTransactionItems(request: WireIntentRequest): void { - if (!request.i || !Array.isArray(request.i) || request.i.length === 0) { + private validateTransactionItems(request: SpecIntentRequest): void { + const params = request.params as TxDraftParams; + if (!params?.i || !Array.isArray(params.i) || params.i.length === 0) { throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Transaction intent missing items (i)'); } - for (const item of request.i) { + for (const item of params.i) { this.validateItem(item); } } @@ -422,19 +436,26 @@ export class IntentParser { } } - 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) { + private validateSignData(request: SpecIntentRequest): void { + const params = request.params as SignDataParams; + if (!Array.isArray(params) || !params[0]) { throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Sign data intent missing payload'); } + let raw: Record; + try { + raw = JSON.parse(params[0]) as Record; + } catch { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid JSON in sign data payload'); + } + if (!raw.type) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Sign data intent missing type'); + } } - private validateAction(request: WireIntentRequest): void { - if (!request.url && !request.action_type) { - throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent missing url or action_type'); + private validateAction(request: SpecIntentRequest): void { + const params = request.params as ActionDraftParams; + if (!params?.url) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent missing url'); } } @@ -537,7 +558,7 @@ export class IntentParser { // -- Wire → Model mapping ------------------------------------------------- private toIntentEvent(parsed: ParsedIntentUrl): { event: IntentRequestEvent; connectRequest?: ConnectRequest } { - const { clientId, request, origin, traceId } = parsed; + const { clientId, request, connectRequest, origin, traceId } = parsed; const base: IntentRequestBase = { id: request.id, @@ -549,43 +570,52 @@ export class IntentParser { let event: IntentRequestEvent; - switch (request.m) { + switch (request.method) { case 'txDraft': case 'signMsgDraft': { - const deliveryMode: IntentDeliveryMode = request.m === 'txDraft' ? 'send' : 'signOnly'; + const params = request.params as TxDraftParams; + const deliveryMode: IntentDeliveryMode = request.method === 'txDraft' ? 'send' : 'signOnly'; event = { type: 'transaction' as const, ...base, deliveryMode, - network: request.n ? { chainId: request.n } : undefined, - validUntil: request.vu, - items: this.mapItems(request.i!), + network: params.n ? { chainId: params.n } : undefined, + validUntil: params.vu, + items: this.mapItems(params.i), }; break; } case 'signData': { - const manifestUrl = request.mu || request.c?.manifestUrl || ''; + const params = request.params as SignDataParams; + let raw: Record = {}; + try { raw = JSON.parse(params[0]) as Record; } catch { /* validated earlier */ } event = { type: 'signData' as const, ...base, - network: request.n ? { chainId: request.n } : undefined, - manifestUrl, - payload: this.wirePayloadToSignDataPayload(request.p!), + network: raw.network ? { chainId: raw.network as string } : undefined, + manifestUrl: connectRequest?.manifestUrl || '', + payload: this.wirePayloadToSignDataPayload({ + type: raw.type as string, + text: raw.text as string | undefined, + bytes: raw.bytes as string | undefined, + cell: raw.cell as string | undefined, + schema: raw.schema as string | undefined, + }), }; break; } case 'actionDraft': { + const params = request.params as ActionDraftParams; event = { type: 'action' as const, ...base, - actionUrl: request.url || '', - actionType: request.action_type, + actionUrl: params.url, }; break; } } - return { event, connectRequest: request.c }; + return { event: event!, connectRequest }; } private mapItems(wireItems: WireIntentItem[]): IntentActionItem[] { From 5da4e79dce66ea633dc57eef991f160bdbd28dbe Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 16:08:36 +0530 Subject: [PATCH 21/46] fix: lint --- .../walletkit/src/handlers/IntentParser.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index 33a977372..4e0f2199d 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -184,7 +184,11 @@ export class IntentParser { let connectRequest: ConnectRequest | undefined; const rParam = parsedUrl.searchParams.get('r'); if (rParam) { - try { connectRequest = JSON.parse(rParam) as ConnectRequest; } catch { /* optional */ } + try { + connectRequest = JSON.parse(rParam) as ConnectRequest; + } catch { + /* optional */ + } } const json = this.decodePayload(encoded); @@ -219,7 +223,11 @@ export class IntentParser { let connectRequest: ConnectRequest | undefined; const rParam = parsedUrl.searchParams.get('r'); if (rParam) { - try { connectRequest = JSON.parse(rParam) as ConnectRequest; } catch { /* optional */ } + try { + connectRequest = JSON.parse(rParam) as ConnectRequest; + } catch { + /* optional */ + } } const encryptedPayload = await this.fetchObjectStoragePayload(getUrl); @@ -588,7 +596,11 @@ export class IntentParser { case 'signData': { const params = request.params as SignDataParams; let raw: Record = {}; - try { raw = JSON.parse(params[0]) as Record; } catch { /* validated earlier */ } + try { + raw = JSON.parse(params[0]) as Record; + } catch { + /* validated earlier */ + } event = { type: 'signData' as const, ...base, From b607ff87be8b1df1f87b326828a4e2a22311a4e5 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 16:15:09 +0530 Subject: [PATCH 22/46] refactor: update buildInlineUrl to use method and params structure in intent requests --- .../src/handlers/IntentHandler.spec.ts | 63 +++-- .../src/handlers/IntentParser.spec.ts | 216 ++++++++++-------- 2 files changed, 160 insertions(+), 119 deletions(-) diff --git a/packages/walletkit/src/handlers/IntentHandler.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts index b0c2bf567..5498b0d3e 100644 --- a/packages/walletkit/src/handlers/IntentHandler.spec.ts +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -217,12 +217,15 @@ describe('IntentHandler', () => { emitted = e; }); - const url = buildInlineUrl('c1', { - id: 'tx-pcr', - m: 'txDraft', - i: [{ t: 'ton', a: 'EQAddr', am: '100' }], - c: { manifestUrl: 'https://dapp.com/m.json', items: [{ name: 'ton_addr' }] }, - }); + const url = buildInlineUrl( + 'c1', + { + id: 'tx-pcr', + method: 'txDraft', + params: { i: [{ t: 'ton', a: 'EQAddr', am: '100' }] }, + }, + { connectRequest: { manifestUrl: 'https://dapp.com/m.json', items: [{ name: 'ton_addr' }] } }, + ); await handler.handleIntentUrl(url, 'wallet-1'); // Should be a batch because of connect @@ -245,11 +248,13 @@ describe('IntentHandler', () => { const url = buildInlineUrl('c-batch', { id: 'tx-batch', - m: 'txDraft', - i: [ - { t: 'ton', a: 'EQAddr1', am: '100' }, - { t: 'ton', a: 'EQAddr2', am: '200' }, - ], + method: 'txDraft', + params: { + i: [ + { t: 'ton', a: 'EQAddr1', am: '100' }, + { t: 'ton', a: 'EQAddr2', am: '200' }, + ], + }, }); await handler.handleIntentUrl(url, 'wallet-1'); @@ -282,8 +287,8 @@ describe('IntentHandler', () => { const url = buildInlineUrl('c-single', { id: 'tx-single', - m: 'txDraft', - i: [{ t: 'ton', a: 'EQAddr1', am: '100' }], + method: 'txDraft', + params: { i: [{ t: 'ton', a: 'EQAddr1', am: '100' }] }, }); await handler.handleIntentUrl(url, 'wallet-1'); @@ -300,15 +305,20 @@ describe('IntentHandler', () => { emitted = e; }); - const url = buildInlineUrl('c-conn', { - id: 'tx-conn', - m: 'txDraft', - i: [ - { t: 'ton', a: 'EQAddr1', am: '100' }, - { t: 'ton', a: 'EQAddr2', am: '200' }, - ], - c: { manifestUrl: 'https://dapp.com/m.json', items: [{ name: 'ton_addr' }] }, - }); + const url = buildInlineUrl( + 'c-conn', + { + id: 'tx-conn', + method: 'txDraft', + params: { + i: [ + { t: 'ton', a: 'EQAddr1', am: '100' }, + { t: 'ton', a: 'EQAddr2', am: '200' }, + ], + }, + }, + { connectRequest: { manifestUrl: 'https://dapp.com/m.json', items: [{ name: 'ton_addr' }] } }, + ); await handler.handleIntentUrl(url, 'wallet-1'); @@ -505,12 +515,17 @@ describe('IntentHandler', () => { }); /** - * Helper: Build a tc://?m=intent URL from a wire request object. + * Helper: Build a tc://?m=intent URL from a spec-format request object. */ -function buildInlineUrl(clientId: string, request: Record, opts?: { traceId?: string }): string { +function buildInlineUrl( + clientId: string, + request: Record, + opts?: { traceId?: string; connectRequest?: Record }, +): string { const json = JSON.stringify(request); const b64 = Buffer.from(json, 'utf-8').toString('base64url'); let url = `tc://?m=intent&id=${clientId}&mp=${b64}`; if (opts?.traceId) url += `&trace_id=${opts.traceId}`; + if (opts?.connectRequest) url += `&r=${encodeURIComponent(JSON.stringify(opts.connectRequest))}`; return url; } diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts index dceed3ac9..b1816addb 100644 --- a/packages/walletkit/src/handlers/IntentParser.spec.ts +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -11,15 +11,20 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { IntentParser } from './IntentParser'; /** - * Helper: Build a tc://?m=intent URL from a wire request object. + * Helper: Build a tc://?m=intent URL from a spec-format request object. * Encodes the request as base64url in the `mp` parameter. + * Pass `connectRequest` to add it as the `r` URL parameter. */ -function buildInlineUrl(clientId: string, request: Record, opts?: { traceId?: string }): string { +function buildInlineUrl( + clientId: string, + request: Record, + opts?: { traceId?: string; connectRequest?: Record }, +): string { const json = JSON.stringify(request); - // base64url encode const b64 = Buffer.from(json, 'utf-8').toString('base64url'); let url = `tc://?m=intent&id=${clientId}&mp=${b64}`; if (opts?.traceId) url += `&trace_id=${opts.traceId}`; + if (opts?.connectRequest) url += `&r=${encodeURIComponent(JSON.stringify(opts.connectRequest))}`; return url; } @@ -59,13 +64,15 @@ describe('IntentParser', () => { it('parses a transaction intent with TON items', async () => { const url = buildInlineUrl('client-123', { id: 'tx-1', - m: 'txDraft', - i: [ - { t: 'ton', a: 'EQAddr1', am: '1000000000' }, - { t: 'ton', a: 'EQAddr2', am: '2000000000', p: 'payload-b64' }, - ], - vu: 1700000000, - n: '-239', + method: 'txDraft', + params: { + vu: 1700000000, + n: '-239', + i: [ + { t: 'ton', a: 'EQAddr1', am: '1000000000' }, + { t: 'ton', a: 'EQAddr2', am: '2000000000', p: 'payload-b64' }, + ], + }, }); const { event, connectRequest } = await parser.parse(url); @@ -99,8 +106,10 @@ describe('IntentParser', () => { it('parses signMsgDraft as signOnly delivery mode', async () => { const url = buildInlineUrl('c1', { id: 'sm-1', - m: 'signMsgDraft', - i: [{ t: 'ton', a: 'EQ1', am: '100' }], + method: 'signMsgDraft', + params: { + i: [{ t: 'ton', a: 'EQ1', am: '100' }], + }, }); const { event } = await parser.parse(url); @@ -113,18 +122,20 @@ describe('IntentParser', () => { it('parses jetton items', async () => { const url = buildInlineUrl('c1', { id: 'j-1', - m: 'txDraft', - i: [ - { - t: 'jetton', - ma: 'EQJettonMaster', - ja: '5000000', - d: 'EQDest', - rd: 'EQResp', - fta: '10000', - qi: 42, - }, - ], + method: 'txDraft', + params: { + i: [ + { + t: 'jetton', + ma: 'EQJettonMaster', + ja: '5000000', + d: 'EQDest', + rd: 'EQResp', + fta: '10000', + qi: 42, + }, + ], + }, }); const { event } = await parser.parse(url); @@ -146,15 +157,17 @@ describe('IntentParser', () => { it('parses NFT items', async () => { const url = buildInlineUrl('c1', { id: 'n-1', - m: 'txDraft', - i: [ - { - t: 'nft', - na: 'EQNftAddr', - no: 'EQNewOwner', - rd: 'EQResp', - }, - ], + method: 'txDraft', + params: { + i: [ + { + t: 'nft', + na: 'EQNftAddr', + no: 'EQNewOwner', + rd: 'EQResp', + }, + ], + }, }); const { event } = await parser.parse(url); @@ -175,12 +188,15 @@ describe('IntentParser', () => { describe('parse – signData (inline)', () => { it('parses a text sign data intent', async () => { - const url = buildInlineUrl('c1', { - id: 'si-1', - m: 'signData', - mu: 'https://example.com/manifest.json', - p: { type: 'text', text: 'Hello world' }, - }); + const url = buildInlineUrl( + 'c1', + { + id: 'si-1', + method: 'signData', + params: [JSON.stringify({ type: 'text', text: 'Hello world' })], + }, + { connectRequest: { manifestUrl: 'https://example.com/manifest.json', items: [] } }, + ); const { event } = await parser.parse(url); @@ -196,12 +212,15 @@ describe('IntentParser', () => { }); it('parses a binary sign data intent', async () => { - const url = buildInlineUrl('c1', { - id: 'si-2', - m: 'signData', - mu: 'https://example.com/manifest.json', - p: { type: 'binary', bytes: 'AQID' }, - }); + const url = buildInlineUrl( + 'c1', + { + id: 'si-2', + method: 'signData', + params: [JSON.stringify({ type: 'binary', bytes: 'AQID' })], + }, + { connectRequest: { manifestUrl: 'https://example.com/manifest.json', items: [] } }, + ); const { event } = await parser.parse(url); if (event.type === 'signData') { @@ -213,12 +232,15 @@ describe('IntentParser', () => { }); it('parses a cell sign data intent', async () => { - const url = buildInlineUrl('c1', { - id: 'si-3', - m: 'signData', - mu: 'https://example.com/manifest.json', - p: { type: 'cell', cell: 'te6cckEBAQEA', schema: 'MySchema' }, - }); + const url = buildInlineUrl( + 'c1', + { + id: 'si-3', + method: 'signData', + params: [JSON.stringify({ type: 'cell', cell: 'te6cckEBAQEA', schema: 'MySchema' })], + }, + { connectRequest: { manifestUrl: 'https://example.com/manifest.json', items: [] } }, + ); const { event } = await parser.parse(url); if (event.type === 'signData') { @@ -230,16 +252,16 @@ describe('IntentParser', () => { } }); - it('uses manifestUrl from connect request when mu is absent', async () => { - const url = buildInlineUrl('c1', { - id: 'si-4', - m: 'signData', - c: { - manifestUrl: 'https://dapp.com/manifest.json', - items: [{ name: 'ton_addr' }], + it('uses manifestUrl from connect request when present', async () => { + const url = buildInlineUrl( + 'c1', + { + id: 'si-4', + method: 'signData', + params: [JSON.stringify({ type: 'text', text: 'Sign this' })], }, - p: { type: 'text', text: 'Sign this' }, - }); + { connectRequest: { manifestUrl: 'https://dapp.com/manifest.json', items: [{ name: 'ton_addr' }] } }, + ); const { event, connectRequest } = await parser.parse(url); expect(connectRequest).toBeDefined(); @@ -255,8 +277,8 @@ describe('IntentParser', () => { it('parses an action intent', async () => { const url = buildInlineUrl('c1', { id: 'a-1', - m: 'actionDraft', - url: 'https://api.example.com/action', + method: 'actionDraft', + params: { url: 'https://api.example.com/action' }, }); const { event } = await parser.parse(url); @@ -272,7 +294,11 @@ describe('IntentParser', () => { describe('parse – validation', () => { it('allows inline URL without client ID (fire-and-forget)', async () => { - const json = JSON.stringify({ id: 'x', m: 'txDraft', i: [{ t: 'ton', a: 'A', am: '1' }] }); + const json = JSON.stringify({ + id: 'x', + method: 'txDraft', + params: { i: [{ t: 'ton', a: 'A', am: '1' }] }, + }); const b64 = Buffer.from(json).toString('base64url'); const url = `tc://?m=intent&mp=${b64}`; @@ -292,35 +318,43 @@ describe('IntentParser', () => { }); it('rejects unknown intent method', async () => { - const url = buildInlineUrl('c1', { id: 'x', m: 'badMethod' }); + const url = buildInlineUrl('c1', { id: 'x', method: 'badMethod' }); await expect(parser.parse(url)).rejects.toThrow('Invalid intent method'); }); it('rejects txDraft without items', async () => { - const url = buildInlineUrl('c1', { id: 'x', m: 'txDraft' }); + const url = buildInlineUrl('c1', { id: 'x', method: 'txDraft', params: {} }); await expect(parser.parse(url)).rejects.toThrow('missing items'); }); it('rejects txDraft with invalid item type', async () => { - const url = buildInlineUrl('c1', { id: 'x', m: 'txDraft', i: [{ t: 'unknown' }] }); + const url = buildInlineUrl('c1', { id: 'x', method: 'txDraft', params: { 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: 'txDraft', i: [{ t: 'ton', am: '100' }] }); + const url = buildInlineUrl('c1', { + id: 'x', + method: 'txDraft', + params: { 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: 'txDraft', i: [{ t: 'ton', a: 'A' }] }); + const url = buildInlineUrl('c1', { + id: 'x', + method: 'txDraft', + params: { 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: 'txDraft', - i: [{ t: 'jetton', ja: '100', d: 'D' }], + method: 'txDraft', + params: { i: [{ t: 'jetton', ja: '100', d: 'D' }] }, }); await expect(parser.parse(url)).rejects.toThrow('missing master address'); }); @@ -328,8 +362,8 @@ describe('IntentParser', () => { it('rejects jetton item missing amount', async () => { const url = buildInlineUrl('c1', { id: 'x', - m: 'txDraft', - i: [{ t: 'jetton', ma: 'MA', d: 'D' }], + method: 'txDraft', + params: { i: [{ t: 'jetton', ma: 'MA', d: 'D' }] }, }); await expect(parser.parse(url)).rejects.toThrow('missing amount'); }); @@ -337,8 +371,8 @@ describe('IntentParser', () => { it('rejects jetton item missing destination', async () => { const url = buildInlineUrl('c1', { id: 'x', - m: 'txDraft', - i: [{ t: 'jetton', ma: 'MA', ja: '100' }], + method: 'txDraft', + params: { i: [{ t: 'jetton', ma: 'MA', ja: '100' }] }, }); await expect(parser.parse(url)).rejects.toThrow('missing destination'); }); @@ -346,8 +380,8 @@ describe('IntentParser', () => { it('rejects NFT item missing address', async () => { const url = buildInlineUrl('c1', { id: 'x', - m: 'txDraft', - i: [{ t: 'nft', no: 'NO' }], + method: 'txDraft', + params: { i: [{ t: 'nft', no: 'NO' }] }, }); await expect(parser.parse(url)).rejects.toThrow('missing address'); }); @@ -355,46 +389,38 @@ describe('IntentParser', () => { it('rejects NFT item missing new owner', async () => { const url = buildInlineUrl('c1', { id: 'x', - m: 'txDraft', - i: [{ t: 'nft', na: 'NA' }], + method: 'txDraft', + params: { i: [{ t: 'nft', na: 'NA' }] }, }); await expect(parser.parse(url)).rejects.toThrow('missing new owner'); }); - it('rejects signData without manifest URL', async () => { - const url = buildInlineUrl('c1', { - id: 'x', - m: 'signData', - p: { type: 'text', text: 'hello' }, - }); - await expect(parser.parse(url)).rejects.toThrow('missing manifest URL'); - }); - it('rejects signData without payload', async () => { const url = buildInlineUrl('c1', { id: 'x', - m: 'signData', - mu: 'https://example.com/m.json', + method: 'signData', }); await expect(parser.parse(url)).rejects.toThrow('missing payload'); }); it('rejects actionDraft without action URL', async () => { - const url = buildInlineUrl('c1', { id: 'x', m: 'actionDraft' }); - await expect(parser.parse(url)).rejects.toThrow('missing url or action_type'); + const url = buildInlineUrl('c1', { id: 'x', method: 'actionDraft', params: {} }); + await expect(parser.parse(url)).rejects.toThrow('missing url'); }); it('rejects request without id', async () => { - const url = buildInlineUrl('c1', { m: 'txDraft', i: [{ t: 'ton', a: 'A', am: '1' }] }); + const url = buildInlineUrl('c1', { + method: 'txDraft', + params: { 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: 'signData', - mu: 'https://example.com/m.json', - p: { type: 'unsupported' }, + method: 'signData', + params: [JSON.stringify({ type: 'unsupported' })], }); await expect(parser.parse(url)).rejects.toThrow('Unsupported sign data type'); }); From b081f9781f483a53d54c6ab60cdd2807a5d11614 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 16:39:55 +0530 Subject: [PATCH 23/46] feat: implement action intent handling with transaction and signData responses --- .../walletkit/src/handlers/IntentHandler.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index 4a1775c3e..34bfa9c6a 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -245,6 +245,72 @@ export class IntentHandler { return result; } + // Check for action intents — fetch the action URL, resolve to tx/signData, + // sign the result, then respond using the batch's identity. + const actionIntent = batch.intents.find((i) => i.type === 'action'); + if (actionIntent && actionIntent.type === 'action') { + if (!actionIntent.actionUrl) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Action intent missing actionUrl, cannot fetch action response.', + ); + } + + const actionResponse = await this.resolver.fetchActionUrl(actionIntent.actionUrl, wallet.getAddress()); + const resolvedEvent = this.parser.parseActionResponse(actionResponse, actionIntent); + + if (resolvedEvent.type === 'transaction') { + if (resolvedEvent.resolvedTransaction) { + resolvedEvent.resolvedTransaction.fromAddress = wallet.getAddress(); + } + const transactionRequest = + resolvedEvent.resolvedTransaction ?? (await this.resolveTransaction(resolvedEvent, wallet)); + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest, { + internal: resolvedEvent.deliveryMode === 'signOnly', + }); + if (resolvedEvent.deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { + await CallForSuccess(() => wallet.getClient().sendBoc(signedBoc)); + } + const txResult: IntentTransactionResponse = { + type: 'transaction', + boc: signedBoc as Base64String, + }; + await this.sendBatchResponse(batch, txResult); + return txResult; + } + + if (resolvedEvent.type === 'signData') { + let domain = resolvedEvent.manifestUrl; + try { + domain = new URL(resolvedEvent.manifestUrl).host; + } catch { + // use as-is + } + const signData = PrepareSignData({ + payload: resolvedEvent.payload, + domain, + address: wallet.getAddress(), + }); + const signature = await wallet.getSignedSignData(signData); + const signatureBase64 = HexToBase64(signature); + const sdResult: IntentSignDataResponse = { + type: 'signData', + signature: signatureBase64 as Base64String, + address: wallet.getAddress() as UserFriendlyAddress, + timestamp: signData.timestamp, + domain: signData.domain, + payload: resolvedEvent.payload, + }; + await this.sendBatchResponse(batch, sdResult); + return sdResult; + } + + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Action URL resolved to unsupported intent type: ${resolvedEvent.type}`, + ); + } + throw new WalletKitError( ERROR_CODES.VALIDATION_ERROR, 'Batched intent contains no transaction or signData items', From 95438ea9e7f8525765467d4008fe198e2465d3af Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 16:56:17 +0530 Subject: [PATCH 24/46] fix: enhance error handling for action URL response to ensure valid JSON --- .../walletkit/src/handlers/IntentResolver.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/walletkit/src/handlers/IntentResolver.ts b/packages/walletkit/src/handlers/IntentResolver.ts index ca443188b..02f0dde05 100644 --- a/packages/walletkit/src/handlers/IntentResolver.ts +++ b/packages/walletkit/src/handlers/IntentResolver.ts @@ -81,7 +81,23 @@ export class IntentResolver { ); } - return response.json(); + try { + return await response.json(); + } catch (error) { + const rawBody = await response.text().catch(() => ''); + if (rawBody.trim().startsWith('<')) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Action URL returned HTML instead of JSON. Ensure the Action dApp endpoint returns a valid JSON payload.`, + error as Error, + ); + } + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Action URL returned invalid JSON. ${(error as Error).message}`, + error as Error, + ); + } } // -- Item resolution ------------------------------------------------------ From 31595141af82c32cad7d06aac16d1757a690a032 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 17:45:32 +0530 Subject: [PATCH 25/46] feat: implement payload decryption using NaCl crypto_box with ephemeral keypair --- .../src/handlers/IntentParser.spec.ts | 49 ++++++++++++++++++- .../walletkit/src/handlers/IntentParser.ts | 18 +++---- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts index b1816addb..faa999e44 100644 --- a/packages/walletkit/src/handlers/IntentParser.spec.ts +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -6,7 +6,8 @@ * */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import nacl from 'tweetnacl'; import { IntentParser } from './IntentParser'; @@ -312,6 +313,52 @@ describe('IntentParser', () => { await expect(parser.parse(url)).rejects.toThrow('Missing client ID'); }); + it('decrypts object storage payload using SDK self-encryption scheme', async () => { + // Reproduce the SDK's encryption scheme: + // const sessionCrypto = new SessionCrypto(); + // sessionCrypto.encrypt(payload, sessionCrypto.publicKey) + // which is: nacl.box(payload, randomNonce, ownPub, ownSec) || nonce prepended + const ephemeral = nacl.box.keyPair(); + const toHex = (b: Uint8Array) => Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join(''); + + const payload = { + id: 'os-1', + method: 'txDraft', + params: { i: [{ t: 'ton', a: 'EQAddr1', am: '500000000' }] }, + }; + const nonce = nacl.randomBytes(24); + const ciphertext = nacl.box( + new TextEncoder().encode(JSON.stringify(payload)), + nonce, + ephemeral.publicKey, // self-encrypt: receiverPub = own pub + ephemeral.secretKey, + ); + const encrypted = new Uint8Array(nonce.length + ciphertext.length); + encrypted.set(nonce); + encrypted.set(ciphertext, nonce.length); + + const encryptedB64 = Buffer.from(encrypted).toString('base64'); + const getUrl = 'https://storage.example.com/payload'; + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'text/plain' }, + arrayBuffer: async () => new TextEncoder().encode(encryptedB64).buffer, + }), + ); + + const clientId = toHex(nacl.box.keyPair().publicKey); // existing session id — not used for decrypt + const pk = toHex(ephemeral.secretKey); + const url = `tc://?m=intent_remote&id=${clientId}&pk=${pk}&get_url=${encodeURIComponent(getUrl)}`; + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + + vi.unstubAllGlobals(); + }); + it('rejects URL without payload', async () => { const url = 'tc://?m=intent&id=c1'; await expect(parser.parse(url)).rejects.toThrow('Missing payload'); diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index 4e0f2199d..5551297a2 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -318,8 +318,12 @@ export class IntentParser { /** * Decrypt an object storage payload using NaCl crypto_box. * Format: nonce (24 bytes) || ciphertext + * + * The SDK self-encrypts using the ephemeral keypair it puts in `pk`: + * nacl.box(payload, nonce, ephemeralPub, ephemeralSec) + * So we derive the public key from `pk` and open with the same keypair. */ - private decryptPayload(encrypted: Uint8Array, clientPubKeyHex: string, walletPrivateKeyHex: string): string { + private decryptPayload(encrypted: Uint8Array, _clientPubKeyHex: string, walletPrivateKeyHex: string): string { if (encrypted.length <= 24) { throw new WalletKitError( ERROR_CODES.VALIDATION_ERROR, @@ -327,15 +331,8 @@ export class IntentParser { ); } - 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, @@ -343,9 +340,12 @@ export class IntentParser { ); } + // Derive the public key from the private key — the SDK encrypted for this same keypair + const walletPublicKey = nacl.box.keyPair.fromSecretKey(walletPrivateKey).publicKey; + const nonce = encrypted.slice(0, 24); const ciphertext = encrypted.slice(24); - const decrypted = nacl.box.open(ciphertext, nonce, clientPubKey, walletPrivateKey); + const decrypted = nacl.box.open(ciphertext, nonce, walletPublicKey, walletPrivateKey); if (!decrypted) { throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Failed to decrypt intent payload'); } From b1f5b1c5c4d1a3fd927f0f428758727ba517cfe1 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 17:53:02 +0530 Subject: [PATCH 26/46] fix: lint --- packages/walletkit/src/handlers/IntentParser.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts index faa999e44..7b7795cc7 100644 --- a/packages/walletkit/src/handlers/IntentParser.spec.ts +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -319,7 +319,10 @@ describe('IntentParser', () => { // sessionCrypto.encrypt(payload, sessionCrypto.publicKey) // which is: nacl.box(payload, randomNonce, ownPub, ownSec) || nonce prepended const ephemeral = nacl.box.keyPair(); - const toHex = (b: Uint8Array) => Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join(''); + const toHex = (b: Uint8Array) => + Array.from(b) + .map((x) => x.toString(16).padStart(2, '0')) + .join(''); const payload = { id: 'os-1', @@ -330,7 +333,7 @@ describe('IntentParser', () => { const ciphertext = nacl.box( new TextEncoder().encode(JSON.stringify(payload)), nonce, - ephemeral.publicKey, // self-encrypt: receiverPub = own pub + ephemeral.publicKey, // self-encrypt: receiverPub = own pub ephemeral.secretKey, ); const encrypted = new Uint8Array(nonce.length + ciphertext.length); From 7d54d815f9cd10426c75e0664289cb8172717e0b Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 18:10:13 +0530 Subject: [PATCH 27/46] fix: remove redundant retry mechanism in transaction emulation --- packages/walletkit/src/utils/toncenterEmulation.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/walletkit/src/utils/toncenterEmulation.ts b/packages/walletkit/src/utils/toncenterEmulation.ts index 9df9eecf3..a5d073934 100644 --- a/packages/walletkit/src/utils/toncenterEmulation.ts +++ b/packages/walletkit/src/utils/toncenterEmulation.ts @@ -13,7 +13,6 @@ import type { EmulationTokenInfoWallets, ToncenterEmulationResponse } from '../t import { toTransactionEmulatedTrace } from '../types/toncenter/emulation'; import type { ErrorInfo } from '../errors/WalletKitError'; import { ERROR_CODES } from '../errors/codes'; -import { CallForSuccess } from './retry'; import type { TransactionEmulatedPreview, TransactionTraceMoneyFlow, @@ -297,7 +296,7 @@ export async function createTransactionPreview( let emulationResult: ToncenterEmulationResponse; try { - const emulatedResult = await CallForSuccess(() => client.fetchEmulation(txData, true)); + const emulatedResult = await client.fetchEmulation(txData, true); if (emulatedResult.result === 'success') { emulationResult = emulatedResult.emulationResult; } else { From 3fd48c192cc704c48eb48a7881f0e264256702dd Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 18:20:02 +0530 Subject: [PATCH 28/46] Revert "fix: remove redundant retry mechanism in transaction emulation" This reverts commit 7d54d815f9cd10426c75e0664289cb8172717e0b. --- packages/walletkit/src/utils/toncenterEmulation.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/walletkit/src/utils/toncenterEmulation.ts b/packages/walletkit/src/utils/toncenterEmulation.ts index a5d073934..9df9eecf3 100644 --- a/packages/walletkit/src/utils/toncenterEmulation.ts +++ b/packages/walletkit/src/utils/toncenterEmulation.ts @@ -13,6 +13,7 @@ import type { EmulationTokenInfoWallets, ToncenterEmulationResponse } from '../t import { toTransactionEmulatedTrace } from '../types/toncenter/emulation'; import type { ErrorInfo } from '../errors/WalletKitError'; import { ERROR_CODES } from '../errors/codes'; +import { CallForSuccess } from './retry'; import type { TransactionEmulatedPreview, TransactionTraceMoneyFlow, @@ -296,7 +297,7 @@ export async function createTransactionPreview( let emulationResult: ToncenterEmulationResponse; try { - const emulatedResult = await client.fetchEmulation(txData, true); + const emulatedResult = await CallForSuccess(() => client.fetchEmulation(txData, true)); if (emulatedResult.result === 'success') { emulationResult = emulatedResult.emulationResult; } else { From b84d3cb934bb020fdf8491f84e7ed0e8db6f2651 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 18:55:35 +0530 Subject: [PATCH 29/46] feat: add support for https universal link scheme in IntentParser --- packages/walletkit/src/handlers/IntentParser.spec.ts | 6 ++++++ packages/walletkit/src/handlers/IntentParser.ts | 8 -------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts index 7b7795cc7..99d43a52b 100644 --- a/packages/walletkit/src/handlers/IntentParser.spec.ts +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -52,6 +52,12 @@ describe('IntentParser', () => { expect(parser.isIntentUrl(' TC://?M=INTENT_REMOTE&id=abc ')).toBe(true); }); + it('accepts https universal link scheme with m=intent', () => { + expect( + parser.isIntentUrl('https://wallet.example.com/ton-connect?v=2&id=abc&m=intent&mp=data'), + ).toBe(true); + }); + it('returns false for non-intent URLs', () => { expect(parser.isIntentUrl('https://example.com')).toBe(false); expect(parser.isIntentUrl('tc://?m=connect&id=abc')).toBe(false); diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index 5551297a2..1b1954ec7 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -24,7 +24,6 @@ import type { Network, } from '../api/models'; -const TC_SCHEME = 'tc://'; const VALID_METHODS = ['txDraft', 'signMsgDraft', 'signData', 'actionDraft'] as const; @@ -118,8 +117,6 @@ export class IntentParser { * Check if a URL is a TonConnect intent deep link. */ isIntentUrl(url: string): boolean { - const normalized = url.trim().toLowerCase(); - if (!normalized.startsWith(TC_SCHEME)) return false; try { const parsedUrl = new URL(url); const method = parsedUrl.searchParams.get('m') || parsedUrl.searchParams.get('M'); @@ -145,11 +142,6 @@ export class IntentParser { const parsedUrl = new URL(url); const clientId = parsedUrl.searchParams.get('id') || undefined; - const normalized = url.trim().toLowerCase(); - if (!normalized.startsWith(TC_SCHEME)) { - throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Unknown intent URL scheme'); - } - const methodKey = Array.from(parsedUrl.searchParams.keys()).find((k) => k.toLowerCase() === 'm'); const method = methodKey ? parsedUrl.searchParams.get(methodKey)?.toLowerCase() : null; From 7a7c6e48852b7b082f244dfa5a702e188c991074 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 19:31:29 +0530 Subject: [PATCH 30/46] feat: add new event types and draft intents to EventStore and wallet manifest --- apps/demo-wallet/src/utils/walletManifest.ts | 15 +++++++++++++++ packages/walletkit/src/core/EventStore.ts | 7 +++++++ packages/walletkit/src/types/internal.ts | 10 +++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/demo-wallet/src/utils/walletManifest.ts b/apps/demo-wallet/src/utils/walletManifest.ts index de8175dd9..48bb23c98 100644 --- a/apps/demo-wallet/src/utils/walletManifest.ts +++ b/apps/demo-wallet/src/utils/walletManifest.ts @@ -50,5 +50,20 @@ export function getTonConnectFeatures(): Feature[] { name: 'SignData', types: ['text', 'binary', 'cell'], }, + { + name: 'SendTransactionDraft', + types: ['ton', 'jetton', 'nft'], + }, + { + name: 'SignMessageDraft', + types: ['ton', 'jetton', 'nft'], + }, + { + name: 'ActionDraft', + }, + { + name: 'Intents', + types: ['txDraft', 'signMsgDraft', 'actionDraft', 'signData'], + }, ]; } diff --git a/packages/walletkit/src/core/EventStore.ts b/packages/walletkit/src/core/EventStore.ts index c66b4c6fd..b245410c3 100644 --- a/packages/walletkit/src/core/EventStore.ts +++ b/packages/walletkit/src/core/EventStore.ts @@ -402,6 +402,13 @@ export class StorageEventStore implements EventStore { return 'disconnect'; case 'restoreConnection': return 'restoreConnection'; + // Draft intent methods delivered via bridge when already connected + case 'txDraft': + return 'txDraft'; + case 'signMsg': + return 'signMsg'; + case 'actionDraft': + return 'actionDraft'; default: throw new Error(`Unknown event method: ${method}`); } diff --git a/packages/walletkit/src/types/internal.ts b/packages/walletkit/src/types/internal.ts index 4ac6dcf99..fd7cfae0b 100644 --- a/packages/walletkit/src/types/internal.ts +++ b/packages/walletkit/src/types/internal.ts @@ -209,7 +209,15 @@ export type RawBridgeEvent = | RawBridgeEventDisconnect; // Internal event routing types -export type EventType = 'connect' | 'sendTransaction' | 'signData' | 'disconnect' | 'restoreConnection'; +export type EventType = + | 'connect' + | 'sendTransaction' + | 'signData' + | 'disconnect' + | 'restoreConnection' + | 'txDraft' + | 'signMsg' + | 'actionDraft'; export interface EventHandler { canHandle(event: RawBridgeEvent): event is V; From c1291f3b6e17b0a5f0935a7c6cfdb700f00ab815 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 19:36:06 +0530 Subject: [PATCH 31/46] feat: update intent origin and event types to include 'connectedBridge' and 'signMsgDraft' --- .../walletkit/src/api/models/intents/IntentRequestEvent.ts | 2 +- packages/walletkit/src/core/EventStore.ts | 4 ++-- packages/walletkit/src/types/internal.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts index 1b628f91d..6d0c647af 100644 --- a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts +++ b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts @@ -18,7 +18,7 @@ import type { IntentActionItem } from './IntentActionItem'; /** * Origin of the intent request. */ -export type IntentOrigin = 'deepLink' | 'objectStorage' | 'bridge' | 'jsBridge'; +export type IntentOrigin = 'deepLink' | 'objectStorage' | 'bridge' | 'jsBridge' | 'connectedBridge'; /** * Delivery mode for the signed transaction. diff --git a/packages/walletkit/src/core/EventStore.ts b/packages/walletkit/src/core/EventStore.ts index b245410c3..dcdd8d219 100644 --- a/packages/walletkit/src/core/EventStore.ts +++ b/packages/walletkit/src/core/EventStore.ts @@ -405,8 +405,8 @@ export class StorageEventStore implements EventStore { // Draft intent methods delivered via bridge when already connected case 'txDraft': return 'txDraft'; - case 'signMsg': - return 'signMsg'; + case 'signMsgDraft': + return 'signMsgDraft'; case 'actionDraft': return 'actionDraft'; default: diff --git a/packages/walletkit/src/types/internal.ts b/packages/walletkit/src/types/internal.ts index fd7cfae0b..aa7a81816 100644 --- a/packages/walletkit/src/types/internal.ts +++ b/packages/walletkit/src/types/internal.ts @@ -216,7 +216,7 @@ export type EventType = | 'disconnect' | 'restoreConnection' | 'txDraft' - | 'signMsg' + | 'signMsgDraft' | 'actionDraft'; export interface EventHandler { From 8edad0a1498b6272b45c451d02fdf0786230f2f7 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 19:39:51 +0530 Subject: [PATCH 32/46] feat: add handling for bridge-delivered draft events and enhance IntentParser for bridge session integration --- .../walletkit/src/handlers/IntentHandler.ts | 30 +++++++++++++++++-- .../walletkit/src/handlers/IntentParser.ts | 27 +++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index 34bfa9c6a..632bc631f 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -19,7 +19,7 @@ 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 { RawBridgeEvent, RawBridgeEventConnect } from '../types/internal'; import type { AnalyticsManager } from '../analytics'; import type { IntentRequestEvent, @@ -114,6 +114,26 @@ export class IntentHandler { } } + /** + * Parse and emit a draft intent event received via the existing bridge session. + * Called when txDraft/signMsgDraft/actionDraft arrives while already connected. + */ + async handleBridgeDraftEvent(rawEvent: RawBridgeEvent, walletId: string): Promise { + try { + const event = this.parser.parseBridgeDraftPayload(rawEvent); + + if (event.type === 'connect') return; + + if (event.type === 'transaction') { + await this.resolveAndEmitTransaction(event, walletId); + } else { + this.emit(event); + } + } catch (error) { + log.error('Failed to handle bridge draft event', { error, eventId: rawEvent.id }); + } + } + // -- Public: Callbacks ---------------------------------------------------- onIntentRequest(callback: IntentCallback): void { @@ -534,7 +554,13 @@ export class IntentHandler { const wireResponse = this.toWireResponse(event.id, result); try { - await this.bridgeManager.sendIntentResponse(event.clientId, wireResponse, event.traceId); + // For intents delivered via an existing bridge session, respond using the + // existing session crypto (sendResponse) so the SDK's pendingRequests resolves. + if (event.origin === 'connectedBridge') { + await this.bridgeManager.sendResponse(event as import('../api/models').BridgeEvent, wireResponse); + } else { + 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 1b1954ec7..a4255cb95 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -10,6 +10,7 @@ import type { ConnectRequest } from '@tonconnect/protocol'; import nacl from 'tweetnacl'; import { WalletKitError, ERROR_CODES } from '../errors'; +import type { RawBridgeEvent } from '../types/internal'; import type { IntentActionItem, IntentOrigin, @@ -135,6 +136,32 @@ export class IntentParser { return this.toIntentEvent(parsed); } + /** + * Parse a bridge-delivered draft RPC event into a typed IntentRequestEvent. + * Used when the wallet is already connected and receives txDraft/signMsgDraft/actionDraft + * via the existing bridge session (sendRequest path). + */ + parseBridgeDraftPayload(rawEvent: RawBridgeEvent): IntentRequestEvent { + const request: SpecIntentRequest = { + id: rawEvent.id, + method: rawEvent.method as 'txDraft' | 'signMsgDraft' | 'actionDraft', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: ((rawEvent.params as any)?.[0] ?? rawEvent.params ?? {}) as TxDraftParams | SignDataParams | ActionDraftParams, + }; + this.validateRequest(request); + const parsed: ParsedIntentUrl = { + clientId: rawEvent.from, + request, + connectRequest: undefined, + origin: 'connectedBridge', + traceId: rawEvent.traceId, + }; + const { event } = this.toIntentEvent(parsed); + // Carry `from` so bridgeManager.sendResponse can look up the existing session + event.from = rawEvent.from; + return event; + } + // -- URL parsing ---------------------------------------------------------- private async parseUrl(url: string): Promise { From b2046cc359af30e5d99ca8fff2a9204fbb0687ba Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 19:40:09 +0530 Subject: [PATCH 33/46] feat: route draft events directly via event emitter for existing sessions --- packages/walletkit/src/core/BridgeManager.ts | 15 +++++++++++++++ packages/walletkit/src/core/TonWalletKit.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/walletkit/src/core/BridgeManager.ts b/packages/walletkit/src/core/BridgeManager.ts index ff7a0158e..62002e6a6 100644 --- a/packages/walletkit/src/core/BridgeManager.ts +++ b/packages/walletkit/src/core/BridgeManager.ts @@ -684,6 +684,21 @@ export class BridgeManager { } } + // Draft events from an already-connected session are ephemeral RPC calls. + // Route them directly via the event emitter so IntentHandler can respond + // using the existing session crypto (not the durable event pipeline). + const DRAFT_METHODS = ['txDraft', 'signMsgDraft', 'actionDraft']; + if (DRAFT_METHODS.includes(rawEvent.method)) { + log.info('Bridge draft event received, routing directly', { + eventId: rawEvent.id, + method: rawEvent.method, + }); + if (this.eventEmitter) { + this.eventEmitter.emit('bridge-draft-intent', rawEvent); + } + return; + } + // Store event durably if enabled if (!this.eventStore) { throw new WalletKitError(ERROR_CODES.EVENT_STORE_NOT_INITIALIZED, 'Event store is not initialized'); diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index f3fc225f1..55503215c 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -142,6 +142,18 @@ export class TonWalletKit implements ITonWalletKit { // Initialize SwapManager this.swapManager = new SwapManager(); + this.eventEmitter.on('bridge-draft-intent', async (event: RawBridgeEvent) => { + const walletId = event.walletId; + if (!walletId) { + log.error('bridge-draft-intent received without walletId', { eventId: event.id }); + return; + } + await this.ensureInitialized(); + if (this.intentHandler) { + await this.intentHandler.handleBridgeDraftEvent(event, walletId); + } + }); + this.eventEmitter.on('restoreConnection', async (event: RawBridgeEventRestoreConnection) => { if (!event.domain) { log.error('Domain is required for restore connection'); From b173e8da0b68f93adf42c7b8fd9782d13a122c4d Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 19:40:59 +0530 Subject: [PATCH 34/46] feat: add RawBridgeEvent type import to TonWalletKit --- packages/walletkit/src/core/TonWalletKit.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 55503215c..5a35d1b3f 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -31,6 +31,7 @@ import { JettonsManager } from './JettonsManager'; import type { JettonsAPI } from '../types/jettons'; import { SwapManager } from '../defi/swap'; import type { + RawBridgeEvent, RawBridgeEventConnect, RawBridgeEventRestoreConnection, RawBridgeEventTransaction, From cbad60292d466e1c6f780ec51cb22d863afddffc Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 19:58:26 +0530 Subject: [PATCH 35/46] feat: add SignMessage and related intent features to WalletV4R2 and WalletV5R1 adapters --- apps/demo-wallet/src/utils/walletManifest.ts | 3 +++ .../walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts | 10 ++++++++++ .../walletkit/src/contracts/w5/WalletV5R1Adapter.ts | 10 ++++++++++ 3 files changed, 23 insertions(+) diff --git a/apps/demo-wallet/src/utils/walletManifest.ts b/apps/demo-wallet/src/utils/walletManifest.ts index 48bb23c98..3ef4dafa1 100644 --- a/apps/demo-wallet/src/utils/walletManifest.ts +++ b/apps/demo-wallet/src/utils/walletManifest.ts @@ -50,6 +50,9 @@ export function getTonConnectFeatures(): Feature[] { name: 'SignData', types: ['text', 'binary', 'cell'], }, + { + name: 'SignMessage', + }, { name: 'SendTransactionDraft', types: ['ton', 'jetton', 'nft'], diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts index 909b3b9d3..b41b00570 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts @@ -285,6 +285,16 @@ export class WalletV4R2Adapter implements WalletAdapter { name: 'SignData', types: ['binary', 'cell', 'text'], }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { name: 'SignMessage' } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { name: 'SendTransactionDraft', types: ['ton', 'jetton', 'nft'] } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { name: 'SignMessageDraft', types: ['ton', 'jetton', 'nft'] } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { name: 'ActionDraft' } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { name: 'Intents', types: ['txDraft', 'signMsgDraft', 'actionDraft', 'signData'] } as any, ]; } } diff --git a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts index c85820ba8..39c9193e1 100644 --- a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts +++ b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts @@ -362,6 +362,16 @@ export class WalletV5R1Adapter implements WalletAdapter { name: 'SignData', types: ['binary', 'cell', 'text'], }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { name: 'SignMessage' } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { name: 'SendTransactionDraft', types: ['ton', 'jetton', 'nft'] } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { name: 'SignMessageDraft', types: ['ton', 'jetton', 'nft'] } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { name: 'ActionDraft' } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { name: 'Intents', types: ['txDraft', 'signMsgDraft', 'actionDraft', 'signData'] } as any, ]; } } From a6069611344fb9725aa0ec1e7bf5f401b8d37ca7 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 16 Mar 2026 03:01:08 +0530 Subject: [PATCH 36/46] fix: use correct wire response format for signMsgDraft (internal_boc) For signOnly (signMsgDraft) delivery mode, the TonConnect spec expects { result: { internal_boc: "" } } instead of { result: "" } so that the SDK's signMessageDraftParser can deserialize it correctly. Co-Authored-By: Claude Sonnet 4.6 --- packages/walletkit/src/handlers/IntentHandler.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index 632bc631f..944ff0c38 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -551,7 +551,7 @@ export class IntentHandler { return; } - const wireResponse = this.toWireResponse(event.id, result); + const wireResponse = this.toWireResponse(event.id, result, event); try { // For intents delivered via an existing bridge session, respond using the @@ -583,11 +583,12 @@ export class IntentHandler { /** * Convert SDK response model to TonConnect wire format. - * - Transaction: `{ result: "", id }` + * - Transaction (send): `{ result: "", id }` + * - Transaction (signOnly/signMsgDraft): `{ result: { internal_boc: "" }, id }` * - SignData: `{ result: { signature, address, timestamp, domain, payload }, id }` * - Error: `{ error: { code, message }, id }` */ - private toWireResponse(eventId: string, result: IntentResponseResult): Record { + private toWireResponse(eventId: string, result: IntentResponseResult, event?: IntentRequestBase): Record { if (result.type === 'error') { return { error: { code: result.error.code, message: result.error.message }, @@ -596,6 +597,10 @@ export class IntentHandler { } if (result.type === 'transaction') { + const txEvent = event as Extract | undefined; + if (txEvent?.deliveryMode === 'signOnly') { + return { result: { internal_boc: result.boc }, id: eventId }; + } return { result: result.boc, id: eventId }; } From 44a59fb5600e9019588db89a381668b1b4c41b07 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 16 Mar 2026 03:29:53 +0530 Subject: [PATCH 37/46] fix: pass deliveryMode to sendBatchResponse so signMsgDraft returns internal_boc format --- packages/walletkit/src/handlers/IntentHandler.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index 944ff0c38..e57eac4f2 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -227,7 +227,7 @@ export class IntentHandler { }; // Send one response using the batch's identity - await this.sendBatchResponse(batch, result); + await this.sendBatchResponse(batch, result, deliveryMode); return result; } @@ -295,7 +295,7 @@ export class IntentHandler { type: 'transaction', boc: signedBoc as Base64String, }; - await this.sendBatchResponse(batch, txResult); + await this.sendBatchResponse(batch, txResult, resolvedEvent.deliveryMode); return txResult; } @@ -566,13 +566,13 @@ export class IntentHandler { } } - private async sendBatchResponse(batch: BatchedIntentEvent, result: IntentResponseResult): Promise { + private async sendBatchResponse(batch: BatchedIntentEvent, result: IntentResponseResult, deliveryMode?: 'send' | 'signOnly'): Promise { if (!batch.clientId) { log.debug('No clientId on batched intent, skipping response send'); return; } - const wireResponse = this.toWireResponse(batch.id, result); + const wireResponse = this.toWireResponse(batch.id, result, undefined, deliveryMode); try { await this.bridgeManager.sendIntentResponse(batch.clientId, wireResponse, batch.traceId); @@ -588,7 +588,7 @@ export class IntentHandler { * - SignData: `{ result: { signature, address, timestamp, domain, payload }, id }` * - Error: `{ error: { code, message }, id }` */ - private toWireResponse(eventId: string, result: IntentResponseResult, event?: IntentRequestBase): Record { + private toWireResponse(eventId: string, result: IntentResponseResult, event?: IntentRequestBase, deliveryMode?: 'send' | 'signOnly'): Record { if (result.type === 'error') { return { error: { code: result.error.code, message: result.error.message }, @@ -598,7 +598,8 @@ export class IntentHandler { if (result.type === 'transaction') { const txEvent = event as Extract | undefined; - if (txEvent?.deliveryMode === 'signOnly') { + const isSignOnly = deliveryMode === 'signOnly' || txEvent?.deliveryMode === 'signOnly'; + if (isSignOnly) { return { result: { internal_boc: result.boc }, id: eventId }; } return { result: result.boc, id: eventId }; From ee1b5a128bff954a16b5fb103c840d06f3f9d4fc Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 12:01:30 +0530 Subject: [PATCH 38/46] fix: fix quality --- packages/walletkit-android-bridge/src/types/api.ts | 4 ++-- packages/walletkit-android-bridge/src/types/walletkit.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index 82ac771d3..41ddef3c7 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -280,7 +280,7 @@ export interface IsIntentUrlArgs { url: string; } -export interface ApproveTransactionIntentArgs { +export interface ApproveTransactionDraftArgs { event: TransactionIntentRequestEvent; walletId: string; } @@ -373,7 +373,7 @@ export interface WalletKitBridgeApi { // Intent API isIntentUrl(args: IsIntentUrlArgs): PromiseOrValue; handleIntentUrl(args: HandleIntentUrlArgs): PromiseOrValue; - approveTransactionIntent(args: ApproveTransactionIntentArgs): PromiseOrValue; + approveTransactionIntent(args: ApproveTransactionDraftArgs): PromiseOrValue; approveSignDataIntent(args: ApproveSignDataIntentArgs): PromiseOrValue; approveActionIntent( args: ApproveActionIntentArgs, diff --git a/packages/walletkit-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index 02e454f96..0b31d8df4 100644 --- a/packages/walletkit-android-bridge/src/types/walletkit.ts +++ b/packages/walletkit-android-bridge/src/types/walletkit.ts @@ -128,10 +128,7 @@ export interface WalletKitInstance { 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; + approveTransactionDraft(event: TransactionIntentRequestEvent, walletId: string): Promise; approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise; approveActionIntent( event: ActionIntentRequestEvent, From 2fbf8c0ab661436046a21591fd8f8d0979b81a87 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 16 Mar 2026 16:14:47 +0530 Subject: [PATCH 39/46] refactor: rename approveTransactionIntent/approveActionIntent to Draft naming Co-Authored-By: Claude Opus 4.6 --- packages/walletkit-android-bridge/src/api/index.ts | 4 ++-- packages/walletkit-android-bridge/src/api/intents.ts | 8 ++++---- packages/walletkit-android-bridge/src/types/api.ts | 4 ++-- packages/walletkit-android-bridge/src/types/walletkit.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/walletkit-android-bridge/src/api/index.ts b/packages/walletkit-android-bridge/src/api/index.ts index 18fc1f97c..e0c066425 100644 --- a/packages/walletkit-android-bridge/src/api/index.ts +++ b/packages/walletkit-android-bridge/src/api/index.ts @@ -94,9 +94,9 @@ export const api: WalletKitBridgeApi = { // Intents isIntentUrl: intents.isIntentUrl, handleIntentUrl: intents.handleIntentUrl, - approveTransactionIntent: intents.approveTransactionIntent, + approveTransactionDraft: intents.approveTransactionDraft, approveSignDataIntent: intents.approveSignDataIntent, - approveActionIntent: intents.approveActionIntent, + approveActionDraft: intents.approveActionDraft, approveBatchedIntent: intents.approveBatchedIntent, rejectIntent: intents.rejectIntent, intentItemsToTransactionRequest: intents.intentItemsToTransactionRequest, diff --git a/packages/walletkit-android-bridge/src/api/intents.ts b/packages/walletkit-android-bridge/src/api/intents.ts index d6dfb656a..82c3aa439 100644 --- a/packages/walletkit-android-bridge/src/api/intents.ts +++ b/packages/walletkit-android-bridge/src/api/intents.ts @@ -20,16 +20,16 @@ export async function handleIntentUrl(args: unknown[]) { return kit('handleIntentUrl', ...args); } -export async function approveTransactionIntent(args: unknown[]) { - return kit('approveTransactionIntent', ...args); +export async function approveTransactionDraft(args: unknown[]) { + return kit('approveTransactionDraft', ...args); } export async function approveSignDataIntent(args: unknown[]) { return kit('approveSignDataIntent', ...args); } -export async function approveActionIntent(args: unknown[]) { - return kit('approveActionIntent', ...args); +export async function approveActionDraft(args: unknown[]) { + return kit('approveActionDraft', ...args); } export async function approveBatchedIntent(args: unknown[]) { diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index 41ddef3c7..e66958d0a 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -373,9 +373,9 @@ export interface WalletKitBridgeApi { // Intent API isIntentUrl(args: IsIntentUrlArgs): PromiseOrValue; handleIntentUrl(args: HandleIntentUrlArgs): PromiseOrValue; - approveTransactionIntent(args: ApproveTransactionDraftArgs): PromiseOrValue; + approveTransactionDraft(args: ApproveTransactionDraftArgs): PromiseOrValue; approveSignDataIntent(args: ApproveSignDataIntentArgs): PromiseOrValue; - approveActionIntent( + approveActionDraft( args: ApproveActionIntentArgs, ): PromiseOrValue; approveBatchedIntent(args: ApproveBatchedIntentArgs): PromiseOrValue; diff --git a/packages/walletkit-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index 0b31d8df4..021bcdbeb 100644 --- a/packages/walletkit-android-bridge/src/types/walletkit.ts +++ b/packages/walletkit-android-bridge/src/types/walletkit.ts @@ -130,7 +130,7 @@ export interface WalletKitInstance { removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void; approveTransactionDraft(event: TransactionIntentRequestEvent, walletId: string): Promise; approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise; - approveActionIntent( + approveActionDraft( event: ActionIntentRequestEvent, walletId: string, ): Promise; From 21cddde90ef41c8a13aa1addbe86e571ff704f71 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 16 Mar 2026 16:25:50 +0530 Subject: [PATCH 40/46] fix: cast intent features to bypass outdated protocol types Co-Authored-By: Claude Opus 4.6 --- apps/demo-wallet/src/utils/walletManifest.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/demo-wallet/src/utils/walletManifest.ts b/apps/demo-wallet/src/utils/walletManifest.ts index 3ef4dafa1..b4999b65a 100644 --- a/apps/demo-wallet/src/utils/walletManifest.ts +++ b/apps/demo-wallet/src/utils/walletManifest.ts @@ -50,6 +50,7 @@ export function getTonConnectFeatures(): Feature[] { name: 'SignData', types: ['text', 'binary', 'cell'], }, + // Intent features (new in protocol — cast until @tonconnect/protocol types are updated) { name: 'SignMessage', }, @@ -68,5 +69,5 @@ export function getTonConnectFeatures(): Feature[] { name: 'Intents', types: ['txDraft', 'signMsgDraft', 'actionDraft', 'signData'], }, - ]; + ] as unknown as Feature[]; } From a4b515e046b250e7bb33e56277702bc6dbbecb69 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 16 Mar 2026 17:15:10 +0530 Subject: [PATCH 41/46] =?UTF-8?q?refactor:=20clean=20up=20intent=20API=20?= =?UTF-8?q?=E2=80=94=20remove=20dead=20code,=20fix=20bugs,=20deduplicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix fetchActionUrl body stream double-consume bug - Remove dead EventStore draft cases and unused removeEvent - Remove draft types from EventType (never stored) - Use static IntentParser instance for isIntentUrl pre-init - Make onIntentRequest/removeIntentRequestCallback async - Stop mutating input events in approveBatchedIntent - Extract signAndSendTransaction/signSignData/resolveAndApproveAction helpers - Rewrite approveBatchedIntent to reuse helpers (156→40 lines) - Replace per-item as any casts with single as unknown as Feature[] - Consistent Draft naming for bridge args types - Add default case to IntentParser switch - Remove unused _clientPubKeyHex param from decryptPayload --- .../walletkit-android-bridge/src/types/api.ts | 8 +- .../src/types/walletkit.ts | 4 +- .../src/contracts/v4r2/WalletV4R2Adapter.ts | 27 +- .../src/contracts/w5/WalletV5R1Adapter.ts | 27 +- packages/walletkit/src/core/EventStore.ts | 15 - packages/walletkit/src/core/TonWalletKit.ts | 39 +-- .../src/handlers/IntentHandler.spec.ts | 2 +- .../walletkit/src/handlers/IntentHandler.ts | 300 ++++++------------ .../walletkit/src/handlers/IntentParser.ts | 14 +- .../walletkit/src/handlers/IntentResolver.ts | 8 +- packages/walletkit/src/types/internal.ts | 10 +- 11 files changed, 153 insertions(+), 301 deletions(-) diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index e66958d0a..88833fd93 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -285,12 +285,12 @@ export interface ApproveTransactionDraftArgs { walletId: string; } -export interface ApproveSignDataIntentArgs { +export interface ApproveSignDataDraftArgs { event: SignDataIntentRequestEvent; walletId: string; } -export interface ApproveActionIntentArgs { +export interface ApproveActionDraftArgs { event: ActionIntentRequestEvent; walletId: string; } @@ -374,9 +374,9 @@ export interface WalletKitBridgeApi { isIntentUrl(args: IsIntentUrlArgs): PromiseOrValue; handleIntentUrl(args: HandleIntentUrlArgs): PromiseOrValue; approveTransactionDraft(args: ApproveTransactionDraftArgs): PromiseOrValue; - approveSignDataIntent(args: ApproveSignDataIntentArgs): PromiseOrValue; + approveSignDataIntent(args: ApproveSignDataDraftArgs): PromiseOrValue; approveActionDraft( - args: ApproveActionIntentArgs, + args: ApproveActionDraftArgs, ): PromiseOrValue; approveBatchedIntent(args: ApproveBatchedIntentArgs): PromiseOrValue; rejectIntent(args: RejectIntentArgs): PromiseOrValue; diff --git a/packages/walletkit-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index 021bcdbeb..2e42d8315 100644 --- a/packages/walletkit-android-bridge/src/types/walletkit.ts +++ b/packages/walletkit-android-bridge/src/types/walletkit.ts @@ -126,8 +126,8 @@ export interface WalletKitInstance { // 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; + onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): Promise; + removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): Promise; approveTransactionDraft(event: TransactionIntentRequestEvent, walletId: string): Promise; approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise; approveActionDraft( diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts index b41b00570..0084410ee 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts @@ -277,24 +277,13 @@ export class WalletV4R2Adapter implements WalletAdapter { getSupportedFeatures(): Feature[] | undefined { return [ - { - name: 'SendTransaction', - maxMessages: 4, - }, - { - name: 'SignData', - types: ['binary', 'cell', 'text'], - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { name: 'SignMessage' } as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { name: 'SendTransactionDraft', types: ['ton', 'jetton', 'nft'] } as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { name: 'SignMessageDraft', types: ['ton', 'jetton', 'nft'] } as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { name: 'ActionDraft' } as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { name: 'Intents', types: ['txDraft', 'signMsgDraft', 'actionDraft', 'signData'] } as any, - ]; + { name: 'SendTransaction', maxMessages: 4 }, + { name: 'SignData', types: ['binary', 'cell', 'text'] }, + { name: 'SignMessage' }, + { name: 'SendTransactionDraft', types: ['ton', 'jetton', 'nft'] }, + { name: 'SignMessageDraft', types: ['ton', 'jetton', 'nft'] }, + { name: 'ActionDraft' }, + { name: 'Intents', types: ['txDraft', 'signMsgDraft', 'actionDraft', 'signData'] }, + ] as unknown as Feature[]; } } diff --git a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts index 39c9193e1..9064c9fc7 100644 --- a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts +++ b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts @@ -354,24 +354,13 @@ export class WalletV5R1Adapter implements WalletAdapter { getSupportedFeatures(): Feature[] | undefined { return [ - { - name: 'SendTransaction', - maxMessages: 255, - }, - { - name: 'SignData', - types: ['binary', 'cell', 'text'], - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { name: 'SignMessage' } as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { name: 'SendTransactionDraft', types: ['ton', 'jetton', 'nft'] } as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { name: 'SignMessageDraft', types: ['ton', 'jetton', 'nft'] } as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { name: 'ActionDraft' } as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { name: 'Intents', types: ['txDraft', 'signMsgDraft', 'actionDraft', 'signData'] } as any, - ]; + { name: 'SendTransaction', maxMessages: 255 }, + { name: 'SignData', types: ['binary', 'cell', 'text'] }, + { name: 'SignMessage' }, + { name: 'SendTransactionDraft', types: ['ton', 'jetton', 'nft'] }, + { name: 'SignMessageDraft', types: ['ton', 'jetton', 'nft'] }, + { name: 'ActionDraft' }, + { name: 'Intents', types: ['txDraft', 'signMsgDraft', 'actionDraft', 'signData'] }, + ] as unknown as Feature[]; } } diff --git a/packages/walletkit/src/core/EventStore.ts b/packages/walletkit/src/core/EventStore.ts index dcdd8d219..53c6ab083 100644 --- a/packages/walletkit/src/core/EventStore.ts +++ b/packages/walletkit/src/core/EventStore.ts @@ -382,14 +382,6 @@ export class StorageEventStore implements EventStore { }); } - private async removeEvent(eventId: string): Promise { - return this.withLock('storage', async () => { - const allEvents = await this.getAllEventsFromStorage(); - delete allEvents[eventId]; - await this.storage.set(this.storageKey, allEvents); - }); - } - private extractEventType(method: string): EventType { switch (method) { case 'connect': @@ -402,13 +394,6 @@ export class StorageEventStore implements EventStore { return 'disconnect'; case 'restoreConnection': return 'restoreConnection'; - // Draft intent methods delivered via bridge when already connected - case 'txDraft': - return 'txDraft'; - case 'signMsgDraft': - return 'signMsgDraft'; - case 'actionDraft': - return 'actionDraft'; default: throw new Error(`Unknown event method: ${method}`); } diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 5a35d1b3f..53f504d26 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -51,6 +51,7 @@ import { KitNetworkManager } from './NetworkManager'; import type { WalletId } from '../utils/walletId'; import type { Wallet, WalletAdapter } from '../api/interfaces'; import { IntentHandler } from '../handlers/IntentHandler'; +import { IntentParser } from '../handlers/IntentParser'; import type { Network, TransactionRequest, @@ -555,21 +556,10 @@ export class TonWalletKit implements ITonWalletKit { // === Intent API === + private static readonly intentParserInstance = new IntentParser(); + isIntentUrl(url: string): boolean { - if (this.intentHandler) { - return this.intentHandler.isIntentUrl(url); - } - // Pre-init fallback: mirror IntentParser.isIntentUrl logic for the new URL format - // New format: tc://?m=intent&... or tc://?m=intent_remote&... - const normalized = url.trim().toLowerCase(); - if (!normalized.startsWith('tc://')) return false; - try { - const parsedUrl = new URL(url); - const method = parsedUrl.searchParams.get('m') || parsedUrl.searchParams.get('M'); - return method?.toLowerCase() === 'intent' || method?.toLowerCase() === 'intent_remote'; - } catch { - return false; - } + return TonWalletKit.intentParserInstance.isIntentUrl(url); } async handleIntentUrl(url: string, walletId: string): Promise { @@ -577,17 +567,13 @@ export class TonWalletKit implements ITonWalletKit { 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); - }); - } + async onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): Promise { + await this.ensureInitialized(); + this.intentHandler.onIntentRequest(cb); } - removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void { + async removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): Promise { + await this.ensureInitialized(); this.intentHandler.removeIntentRequestCallback(cb); } @@ -619,12 +605,9 @@ export class TonWalletKit implements ITonWalletKit { ): 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) { + for (const item of batch.intents) { if (item.type === 'connect') { - item.walletId = walletId; - await this.requestProcessor.approveConnectRequest(item, proof ? { proof } : undefined); + await this.requestProcessor.approveConnectRequest({ ...item, walletId }, proof ? { proof } : undefined); } } diff --git a/packages/walletkit/src/handlers/IntentHandler.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts index 5498b0d3e..9477f1d96 100644 --- a/packages/walletkit/src/handlers/IntentHandler.spec.ts +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -419,7 +419,7 @@ describe('IntentHandler', () => { }); await expect(handler.approveBatchedIntent(emptyBatch, 'wallet-1')).rejects.toThrow( - 'Batched intent contains no transaction or signData items', + 'Batched intent contains no actionable items', ); }); diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index e57eac4f2..6ea4b6fc7 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -33,6 +33,7 @@ import type { IntentResponseResult, IntentActionItem, BatchedIntentEvent, + BridgeEvent, ConnectionRequestEvent, TransactionRequest, SignDataPayload, @@ -151,23 +152,7 @@ export class IntentHandler { walletId: string, ): Promise { const wallet = this.getWallet(walletId); - - const transactionRequest = event.resolvedTransaction ?? (await this.resolveTransaction(event, wallet)); - - // 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)); - } - - const result: IntentTransactionResponse = { - type: 'transaction', - boc: signedBoc as Base64String, - }; - + const result = await this.signAndSendTransaction(event, wallet); await this.sendResponse(event, result); return result; } @@ -175,9 +160,9 @@ export class IntentHandler { /** * 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. + * Finds the first actionable intent (transaction > signData > action), + * delegates to the corresponding single-item approval method, and + * sends one response back using the batch's identity. */ async approveBatchedIntent( batch: BatchedIntentEvent, @@ -197,174 +182,43 @@ export class IntentHandler { } } - // 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.network : undefined; - const validUntil = firstTx?.type === 'transaction' ? firstTx.validUntil : undefined; - - // Build combined transaction - const transactionRequest = await this.resolver.intentItemsToTransactionRequest( - allItems, - wallet, - network, - validUntil, - ); - - // 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)); - } - - const result: IntentTransactionResponse = { + const combinedEvent: TransactionIntentRequestEvent = { type: 'transaction', - boc: signedBoc as Base64String, + id: batch.id, + origin: batch.origin, + clientId: batch.clientId, + deliveryMode, + network: firstTx?.type === 'transaction' ? firstTx.network : undefined, + validUntil: firstTx?.type === 'transaction' ? firstTx.validUntil : undefined, + items: allItems, }; - - // Send one response using the batch's identity + const result = await this.signAndSendTransaction(combinedEvent, wallet); await this.sendBatchResponse(batch, result, deliveryMode); return result; } - // Check for signData intents const signDataIntent = batch.intents.find((i) => i.type === 'signData'); - if (signDataIntent && signDataIntent.type === 'signData') { - const event = signDataIntent; - - 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 = { - type: 'signData', - signature: signatureBase64 as Base64String, - address: wallet.getAddress() as UserFriendlyAddress, - timestamp: signData.timestamp, - domain: signData.domain, - payload: event.payload, - }; - + if (signDataIntent?.type === 'signData') { + const result = await this.signSignData(signDataIntent, wallet); await this.sendBatchResponse(batch, result); return result; } - // Check for action intents — fetch the action URL, resolve to tx/signData, - // sign the result, then respond using the batch's identity. const actionIntent = batch.intents.find((i) => i.type === 'action'); - if (actionIntent && actionIntent.type === 'action') { - if (!actionIntent.actionUrl) { - throw new WalletKitError( - ERROR_CODES.VALIDATION_ERROR, - 'Action intent missing actionUrl, cannot fetch action response.', - ); - } - - const actionResponse = await this.resolver.fetchActionUrl(actionIntent.actionUrl, wallet.getAddress()); - const resolvedEvent = this.parser.parseActionResponse(actionResponse, actionIntent); - - if (resolvedEvent.type === 'transaction') { - if (resolvedEvent.resolvedTransaction) { - resolvedEvent.resolvedTransaction.fromAddress = wallet.getAddress(); - } - const transactionRequest = - resolvedEvent.resolvedTransaction ?? (await this.resolveTransaction(resolvedEvent, wallet)); - const signedBoc = await wallet.getSignedSendTransaction(transactionRequest, { - internal: resolvedEvent.deliveryMode === 'signOnly', - }); - if (resolvedEvent.deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { - await CallForSuccess(() => wallet.getClient().sendBoc(signedBoc)); - } - const txResult: IntentTransactionResponse = { - type: 'transaction', - boc: signedBoc as Base64String, - }; - await this.sendBatchResponse(batch, txResult, resolvedEvent.deliveryMode); - return txResult; - } - - if (resolvedEvent.type === 'signData') { - let domain = resolvedEvent.manifestUrl; - try { - domain = new URL(resolvedEvent.manifestUrl).host; - } catch { - // use as-is - } - const signData = PrepareSignData({ - payload: resolvedEvent.payload, - domain, - address: wallet.getAddress(), - }); - const signature = await wallet.getSignedSignData(signData); - const signatureBase64 = HexToBase64(signature); - const sdResult: IntentSignDataResponse = { - type: 'signData', - signature: signatureBase64 as Base64String, - address: wallet.getAddress() as UserFriendlyAddress, - timestamp: signData.timestamp, - domain: signData.domain, - payload: resolvedEvent.payload, - }; - await this.sendBatchResponse(batch, sdResult); - return sdResult; - } - - throw new WalletKitError( - ERROR_CODES.VALIDATION_ERROR, - `Action URL resolved to unsupported intent type: ${resolvedEvent.type}`, - ); + if (actionIntent?.type === 'action') { + const result = await this.resolveAndApproveAction(actionIntent, wallet); + await this.sendBatchResponse(batch, result, result.type === 'transaction' ? deliveryMode : undefined); + return result; } - throw new WalletKitError( - ERROR_CODES.VALIDATION_ERROR, - 'Batched intent contains no transaction or signData items', - ); + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Batched intent contains no actionable items'); } 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 = { - type: 'signData', - signature: signatureBase64 as Base64String, - address: wallet.getAddress() as UserFriendlyAddress, - timestamp: signData.timestamp, - domain: signData.domain, - payload: event.payload, - }; - + const result = await this.signSignData(event, wallet); await this.sendResponse(event, result); return result; } @@ -374,30 +228,9 @@ export class IntentHandler { walletId: string, ): Promise { const wallet = this.getWallet(walletId); - - if (!event.actionUrl) { - throw new WalletKitError( - ERROR_CODES.VALIDATION_ERROR, - 'Action intent missing actionUrl, cannot fetch action response.', - ); - } - - const actionResponse = await this.resolver.fetchActionUrl(event.actionUrl, wallet.getAddress()); - const resolvedEvent = this.parser.parseActionResponse(actionResponse, event); - - if (resolvedEvent.type === 'transaction') { - if (resolvedEvent.resolvedTransaction) { - resolvedEvent.resolvedTransaction.fromAddress = wallet.getAddress(); - } - return this.approveTransactionDraft(resolvedEvent, walletId); - } else if (resolvedEvent.type === 'signData') { - return this.approveSignDataIntent(resolvedEvent, walletId); - } - - throw new WalletKitError( - ERROR_CODES.VALIDATION_ERROR, - `Action URL resolved to unsupported intent type: ${resolvedEvent.type}`, - ); + const result = await this.resolveAndApproveAction(event, wallet); + await this.sendResponse(event, result); + return result; } // -- Public: Rejection ---------------------------------------------------- @@ -543,6 +376,74 @@ export class IntentHandler { return this.resolver.intentItemsToTransactionRequest(event.items, wallet, event.network, event.validUntil); } + private async signAndSendTransaction( + event: TransactionIntentRequestEvent, + wallet: Wallet, + ): Promise { + const transactionRequest = event.resolvedTransaction ?? (await this.resolveTransaction(event, wallet)); + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest, { + internal: event.deliveryMode === 'signOnly', + }); + + if (event.deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { + await CallForSuccess(() => wallet.getClient().sendBoc(signedBoc)); + } + + return { type: 'transaction', boc: signedBoc as Base64String }; + } + + private async signSignData(event: SignDataIntentRequestEvent, wallet: Wallet): Promise { + 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); + return { + type: 'signData', + signature: HexToBase64(signature) as Base64String, + address: wallet.getAddress() as UserFriendlyAddress, + timestamp: signData.timestamp, + domain: signData.domain, + payload: event.payload, + }; + } + + private async resolveAndApproveAction( + event: ActionIntentRequestEvent, + wallet: Wallet, + ): Promise { + if (!event.actionUrl) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent missing actionUrl'); + } + + const actionResponse = await this.resolver.fetchActionUrl(event.actionUrl, wallet.getAddress()); + const resolvedEvent = this.parser.parseActionResponse(actionResponse, event); + + if (resolvedEvent.type === 'transaction') { + if (resolvedEvent.resolvedTransaction) { + resolvedEvent.resolvedTransaction.fromAddress = wallet.getAddress(); + } + return this.signAndSendTransaction(resolvedEvent, wallet); + } + if (resolvedEvent.type === 'signData') { + return this.signSignData(resolvedEvent, wallet); + } + + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Action URL resolved to unsupported type: ${resolvedEvent.type}`, + ); + } + // -- Private: Response sending -------------------------------------------- private async sendResponse(event: IntentRequestBase, result: IntentResponseResult): Promise { @@ -557,7 +458,7 @@ export class IntentHandler { // For intents delivered via an existing bridge session, respond using the // existing session crypto (sendResponse) so the SDK's pendingRequests resolves. if (event.origin === 'connectedBridge') { - await this.bridgeManager.sendResponse(event as import('../api/models').BridgeEvent, wireResponse); + await this.bridgeManager.sendResponse(event as BridgeEvent, wireResponse); } else { await this.bridgeManager.sendIntentResponse(event.clientId, wireResponse, event.traceId); } @@ -566,7 +467,11 @@ export class IntentHandler { } } - private async sendBatchResponse(batch: BatchedIntentEvent, result: IntentResponseResult, deliveryMode?: 'send' | 'signOnly'): Promise { + private async sendBatchResponse( + batch: BatchedIntentEvent, + result: IntentResponseResult, + deliveryMode?: 'send' | 'signOnly', + ): Promise { if (!batch.clientId) { log.debug('No clientId on batched intent, skipping response send'); return; @@ -588,7 +493,12 @@ export class IntentHandler { * - SignData: `{ result: { signature, address, timestamp, domain, payload }, id }` * - Error: `{ error: { code, message }, id }` */ - private toWireResponse(eventId: string, result: IntentResponseResult, event?: IntentRequestBase, deliveryMode?: 'send' | 'signOnly'): Record { + private toWireResponse( + eventId: string, + result: IntentResponseResult, + event?: IntentRequestBase, + deliveryMode?: 'send' | 'signOnly', + ): Record { if (result.type === 'error') { return { error: { code: result.error.code, message: result.error.message }, diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index a4255cb95..223a4dfd9 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -25,7 +25,6 @@ import type { Network, } from '../api/models'; - const VALID_METHODS = ['txDraft', 'signMsgDraft', 'signData', 'actionDraft'] as const; /** @@ -146,7 +145,10 @@ export class IntentParser { id: rawEvent.id, method: rawEvent.method as 'txDraft' | 'signMsgDraft' | 'actionDraft', // eslint-disable-next-line @typescript-eslint/no-explicit-any - params: ((rawEvent.params as any)?.[0] ?? rawEvent.params ?? {}) as TxDraftParams | SignDataParams | ActionDraftParams, + params: ((rawEvent.params as any)?.[0] ?? rawEvent.params ?? {}) as + | TxDraftParams + | SignDataParams + | ActionDraftParams, }; this.validateRequest(request); const parsed: ParsedIntentUrl = { @@ -250,7 +252,7 @@ export class IntentParser { } const encryptedPayload = await this.fetchObjectStoragePayload(getUrl); - const json = this.decryptPayload(encryptedPayload, clientId, walletPrivateKey); + const json = this.decryptPayload(encryptedPayload, walletPrivateKey); let request: SpecIntentRequest; try { @@ -342,7 +344,7 @@ export class IntentParser { * nacl.box(payload, nonce, ephemeralPub, ephemeralSec) * So we derive the public key from `pk` and open with the same keypair. */ - private decryptPayload(encrypted: Uint8Array, _clientPubKeyHex: string, walletPrivateKeyHex: string): string { + private decryptPayload(encrypted: Uint8Array, walletPrivateKeyHex: string): string { if (encrypted.length <= 24) { throw new WalletKitError( ERROR_CODES.VALIDATION_ERROR, @@ -644,9 +646,11 @@ export class IntentParser { }; break; } + default: + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Unhandled intent method: ${request.method}`); } - return { event: event!, connectRequest }; + return { event, connectRequest }; } private mapItems(wireItems: WireIntentItem[]): IntentActionItem[] { diff --git a/packages/walletkit/src/handlers/IntentResolver.ts b/packages/walletkit/src/handlers/IntentResolver.ts index 02f0dde05..3822e65a8 100644 --- a/packages/walletkit/src/handlers/IntentResolver.ts +++ b/packages/walletkit/src/handlers/IntentResolver.ts @@ -81,20 +81,20 @@ export class IntentResolver { ); } + const rawBody = await response.text(); try { - return await response.json(); + return JSON.parse(rawBody); } catch (error) { - const rawBody = await response.text().catch(() => ''); if (rawBody.trim().startsWith('<')) { throw new WalletKitError( ERROR_CODES.VALIDATION_ERROR, - `Action URL returned HTML instead of JSON. Ensure the Action dApp endpoint returns a valid JSON payload.`, + `Action URL returned HTML instead of JSON`, error as Error, ); } throw new WalletKitError( ERROR_CODES.VALIDATION_ERROR, - `Action URL returned invalid JSON. ${(error as Error).message}`, + `Action URL returned invalid JSON: ${(error as Error).message}`, error as Error, ); } diff --git a/packages/walletkit/src/types/internal.ts b/packages/walletkit/src/types/internal.ts index aa7a81816..4ac6dcf99 100644 --- a/packages/walletkit/src/types/internal.ts +++ b/packages/walletkit/src/types/internal.ts @@ -209,15 +209,7 @@ export type RawBridgeEvent = | RawBridgeEventDisconnect; // Internal event routing types -export type EventType = - | 'connect' - | 'sendTransaction' - | 'signData' - | 'disconnect' - | 'restoreConnection' - | 'txDraft' - | 'signMsgDraft' - | 'actionDraft'; +export type EventType = 'connect' | 'sendTransaction' | 'signData' | 'disconnect' | 'restoreConnection'; export interface EventHandler { canHandle(event: RawBridgeEvent): event is V; From 8e6ce7a63135fad15433fa379d750adbb293d78d Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 16 Mar 2026 18:03:48 +0530 Subject: [PATCH 42/46] fix: restore removeEvent, fix IntentParser.spec.ts formatting --- packages/walletkit/src/core/EventStore.ts | 8 ++++++++ packages/walletkit/src/handlers/IntentParser.spec.ts | 5 ++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/walletkit/src/core/EventStore.ts b/packages/walletkit/src/core/EventStore.ts index 53c6ab083..c66b4c6fd 100644 --- a/packages/walletkit/src/core/EventStore.ts +++ b/packages/walletkit/src/core/EventStore.ts @@ -382,6 +382,14 @@ export class StorageEventStore implements EventStore { }); } + private async removeEvent(eventId: string): Promise { + return this.withLock('storage', async () => { + const allEvents = await this.getAllEventsFromStorage(); + delete allEvents[eventId]; + await this.storage.set(this.storageKey, allEvents); + }); + } + private extractEventType(method: string): EventType { switch (method) { case 'connect': diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts index 99d43a52b..8df76416e 100644 --- a/packages/walletkit/src/handlers/IntentParser.spec.ts +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -53,9 +53,8 @@ describe('IntentParser', () => { }); it('accepts https universal link scheme with m=intent', () => { - expect( - parser.isIntentUrl('https://wallet.example.com/ton-connect?v=2&id=abc&m=intent&mp=data'), - ).toBe(true); + const url = 'https://wallet.example.com/ton-connect?v=2&id=abc&m=intent&mp=data'; + expect(parser.isIntentUrl(url)).toBe(true); }); it('returns false for non-intent URLs', () => { From 48b09a9829e356f03dcb6e154836ff767fa90786 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 16 Mar 2026 18:51:33 +0530 Subject: [PATCH 43/46] fix: remove intent features from adapters to fix e2e connect validation Intent features are advertised via walletManifest/deviceInfo config, not hardcoded in adapters. The external e2e test dApp validates features against @tonconnect/protocol which doesn't know intent feature names yet. --- packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts | 7 +------ packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts index 0084410ee..6f3aa8f37 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts @@ -279,11 +279,6 @@ export class WalletV4R2Adapter implements WalletAdapter { return [ { name: 'SendTransaction', maxMessages: 4 }, { name: 'SignData', types: ['binary', 'cell', 'text'] }, - { name: 'SignMessage' }, - { name: 'SendTransactionDraft', types: ['ton', 'jetton', 'nft'] }, - { name: 'SignMessageDraft', types: ['ton', 'jetton', 'nft'] }, - { name: 'ActionDraft' }, - { name: 'Intents', types: ['txDraft', 'signMsgDraft', 'actionDraft', 'signData'] }, - ] as unknown as Feature[]; + ]; } } diff --git a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts index 9064c9fc7..20d04fd4a 100644 --- a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts +++ b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts @@ -356,11 +356,6 @@ export class WalletV5R1Adapter implements WalletAdapter { return [ { name: 'SendTransaction', maxMessages: 255 }, { name: 'SignData', types: ['binary', 'cell', 'text'] }, - { name: 'SignMessage' }, - { name: 'SendTransactionDraft', types: ['ton', 'jetton', 'nft'] }, - { name: 'SignMessageDraft', types: ['ton', 'jetton', 'nft'] }, - { name: 'ActionDraft' }, - { name: 'Intents', types: ['txDraft', 'signMsgDraft', 'actionDraft', 'signData'] }, - ] as unknown as Feature[]; + ]; } } From 0499ad3ca466122d58a7958dafbfaf823db0d32f Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Tue, 17 Mar 2026 01:28:07 +0530 Subject: [PATCH 44/46] refactor: update intent API types and methods for consistency --- .../walletkit-android-bridge/src/types/api.ts | 4 +- .../src/types/walletkit.ts | 9 ++-- .../api/models/intents/IntentRequestEvent.ts | 2 +- .../src/contracts/v4r2/WalletV4R2Adapter.ts | 6 ++- .../src/contracts/w5/WalletV5R1Adapter.ts | 6 ++- packages/walletkit/src/core/TonWalletKit.ts | 18 ++++--- .../walletkit/src/handlers/IntentHandler.ts | 4 -- .../walletkit/src/handlers/IntentParser.ts | 38 ++++++------- packages/walletkit/src/types/kit.ts | 53 +++++++++++++++++++ 9 files changed, 102 insertions(+), 38 deletions(-) diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index 88833fd93..2354584ef 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -378,7 +378,9 @@ export interface WalletKitBridgeApi { approveActionDraft( args: ApproveActionDraftArgs, ): PromiseOrValue; - approveBatchedIntent(args: ApproveBatchedIntentArgs): PromiseOrValue; + approveBatchedIntent( + args: ApproveBatchedIntentArgs, + ): PromiseOrValue; rejectIntent(args: RejectIntentArgs): PromiseOrValue; intentItemsToTransactionRequest(args: IntentItemsToTransactionRequestArgs): PromiseOrValue; } diff --git a/packages/walletkit-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index 2e42d8315..b6dd1cb15 100644 --- a/packages/walletkit-android-bridge/src/types/walletkit.ts +++ b/packages/walletkit-android-bridge/src/types/walletkit.ts @@ -126,15 +126,18 @@ export interface WalletKitInstance { // Intent API isIntentUrl(url: string): boolean; handleIntentUrl(url: string, walletId: string): Promise; - onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): Promise; - removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): Promise; + onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void; + removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void; approveTransactionDraft(event: TransactionIntentRequestEvent, walletId: string): Promise; approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise; approveActionDraft( event: ActionIntentRequestEvent, walletId: string, ): Promise; - approveBatchedIntent(batch: BatchedIntentEvent, walletId: string): Promise; + approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + ): Promise; rejectIntent( event: IntentRequestEvent | BatchedIntentEvent, reason?: string, diff --git a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts index 6d0c647af..ee7c41f99 100644 --- a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts +++ b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts @@ -91,7 +91,7 @@ export interface ActionIntentRequestEvent extends IntentRequestBase { * Action URL to fetch * @format url */ - actionUrl?: string; + actionUrl: string; /** * Optional action type. */ diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts index 6f3aa8f37..34c00f70a 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts @@ -279,6 +279,10 @@ export class WalletV4R2Adapter implements WalletAdapter { return [ { name: 'SendTransaction', maxMessages: 4 }, { name: 'SignData', types: ['binary', 'cell', 'text'] }, - ]; + // Intent features — type cast needed until @tonconnect/protocol exports these names + { name: 'SendTransactionDraft' }, + { name: 'SendActionDraft' }, + // SignMessageDraft (gasless) requires internal opcode — W5R1 only + ] as unknown as Feature[]; } } diff --git a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts index 20d04fd4a..a315a8f0d 100644 --- a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts +++ b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts @@ -356,6 +356,10 @@ export class WalletV5R1Adapter implements WalletAdapter { return [ { name: 'SendTransaction', maxMessages: 255 }, { name: 'SignData', types: ['binary', 'cell', 'text'] }, - ]; + // Intent features — type cast needed until @tonconnect/protocol exports these names + { name: 'SendTransactionDraft' }, + { name: 'SignMessageDraft' }, + { name: 'SendActionDraft' }, + ] as unknown as Feature[]; } } diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 53f504d26..49e87afab 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -567,14 +567,20 @@ export class TonWalletKit implements ITonWalletKit { return this.intentHandler.handleIntentUrl(url, walletId); } - async onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): Promise { - await this.ensureInitialized(); - this.intentHandler.onIntentRequest(cb); + onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void { + if (this.intentHandler) { + this.intentHandler.onIntentRequest(cb); + } else { + this.ensureInitialized().then(() => this.intentHandler.onIntentRequest(cb)); + } } - async removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): Promise { - await this.ensureInitialized(); - this.intentHandler.removeIntentRequestCallback(cb); + removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void { + if (this.intentHandler) { + this.intentHandler.removeIntentRequestCallback(cb); + } else { + this.ensureInitialized().then(() => this.intentHandler.removeIntentRequestCallback(cb)); + } } async approveTransactionDraft( diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index 6ea4b6fc7..6ecd515a2 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -421,10 +421,6 @@ export class IntentHandler { event: ActionIntentRequestEvent, wallet: Wallet, ): Promise { - if (!event.actionUrl) { - throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent missing actionUrl'); - } - const actionResponse = await this.resolver.fetchActionUrl(event.actionUrl, wallet.getAddress()); const resolvedEvent = this.parser.parseActionResponse(actionResponse, event); diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts index 223a4dfd9..214b2e732 100644 --- a/packages/walletkit/src/handlers/IntentParser.ts +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -86,6 +86,8 @@ interface SpecIntentRequest { */ export interface ParsedIntentUrl { clientId?: string; + /** Raw sender ID for connectedBridge events (used for session crypto lookup) */ + from?: string; request: SpecIntentRequest; connectRequest?: ConnectRequest; origin: IntentOrigin; @@ -153,14 +155,13 @@ export class IntentParser { this.validateRequest(request); const parsed: ParsedIntentUrl = { clientId: rawEvent.from, + from: rawEvent.from, request, connectRequest: undefined, origin: 'connectedBridge', traceId: rawEvent.traceId, }; const { event } = this.toIntentEvent(parsed); - // Carry `from` so bridgeManager.sendResponse can look up the existing session - event.from = rawEvent.from; return event; } @@ -271,7 +272,11 @@ export class IntentParser { /** * Fetch encrypted payload from object storage URL. - * Handles both raw binary and base64-encoded text responses. + * + * The SDK stores the payload as raw bytes with Content-Type: text/plain. + * Some object storage providers base64-encode binary content when returning + * it as text, so we attempt base64 decode for text responses before falling + * back to raw bytes. */ private async fetchObjectStoragePayload(getUrl: string): Promise { try { @@ -287,24 +292,14 @@ export class IntentParser { 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')) { + if (contentType.includes('text')) { 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); + try { + return this.base64ToBytes(text); + } catch { + // Not valid base64, fall through to raw bytes + } } } @@ -516,7 +511,7 @@ export class IntentParser { case 'sendTransaction': return this.parseActionTransaction(base, action); case 'signData': - return this.parseActionSignData(base, action, sourceEvent.actionUrl || ''); + return this.parseActionSignData(base, action, sourceEvent.actionUrl); default: throw new WalletKitError( ERROR_CODES.VALIDATION_ERROR, @@ -587,12 +582,13 @@ export class IntentParser { // -- Wire → Model mapping ------------------------------------------------- private toIntentEvent(parsed: ParsedIntentUrl): { event: IntentRequestEvent; connectRequest?: ConnectRequest } { - const { clientId, request, connectRequest, origin, traceId } = parsed; + const { clientId, from, request, connectRequest, origin, traceId } = parsed; const base: IntentRequestBase = { id: request.id, origin, clientId, + from, traceId, returnStrategy: undefined, }; diff --git a/packages/walletkit/src/types/kit.ts b/packages/walletkit/src/types/kit.ts index 6159043e2..6252832eb 100644 --- a/packages/walletkit/src/types/kit.ts +++ b/packages/walletkit/src/types/kit.ts @@ -26,6 +26,16 @@ import type { TONConnectSession, SendTransactionApprovalResponse, ConnectionApprovalResponse, + IntentRequestEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentActionItem, + BatchedIntentEvent, + ConnectionApprovalProof, } from '../api/models'; import type { SwapAPI } from '../api/interfaces'; @@ -139,6 +149,49 @@ export interface ITonWalletKit { removeDisconnectCallback(cb: (event: DisconnectionEvent) => void): void; removeErrorCallback(cb: (event: RequestErrorEvent) => void): void; + // === Intent API === + + /** Check if a URL is a TonConnect intent deep link */ + isIntentUrl(url: string): boolean; + + /** Handle a TonConnect intent URL for the given wallet */ + handleIntentUrl(url: string, walletId: string): Promise; + + /** Register intent request handler */ + onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void; + + /** Remove intent request handler */ + removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void; + + /** Approve a transaction draft intent */ + approveTransactionDraft(event: TransactionIntentRequestEvent, walletId: string): Promise; + + /** Approve a sign data intent */ + approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise; + + /** Approve an action draft intent */ + approveActionDraft( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise; + + /** Approve a batched intent (connect + transaction/signData/action) */ + approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + proof?: ConnectionApprovalProof, + ): Promise; + + /** Reject any intent request */ + rejectIntent( + event: IntentRequestEvent | BatchedIntentEvent, + reason?: string, + errorCode?: number, + ): Promise; + + /** Convert intent action items to a TransactionRequest for preview */ + intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise; + // === Jettons API === /** Jettons API access */ From e59f703b1f7b1ba2797b27fe2b378a4803d7a296 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Tue, 17 Mar 2026 02:27:13 +0530 Subject: [PATCH 45/46] Add type:'batched' discriminant to BatchedIntentEvent Adds a required `type: 'batched'` field so callers can distinguish BatchedIntentEvent from IntentRequestEvent via a proper discriminated union instead of duck-typing ('intents' in event). Populates the field in both BatchedIntentEvent construction sites in IntentHandler and updates spec helpers accordingly. --- .../walletkit/src/api/models/intents/BatchedIntentEvent.ts | 2 ++ packages/walletkit/src/handlers/IntentHandler.spec.ts | 3 +++ packages/walletkit/src/handlers/IntentHandler.ts | 2 ++ 3 files changed, 7 insertions(+) diff --git a/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts index a2615205a..3e323749a 100644 --- a/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts +++ b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts @@ -18,6 +18,8 @@ import type { IntentRequestEvent, IntentOrigin } from './IntentRequestEvent'; * - action intent that resolves to multiple steps */ export interface BatchedIntentEvent extends BridgeEvent { + /** Discriminant — always 'batched' */ + type: 'batched'; /** How the batch reached the wallet */ origin: IntentOrigin; /** Client public key for response routing */ diff --git a/packages/walletkit/src/handlers/IntentHandler.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts index 9477f1d96..9cfa9c387 100644 --- a/packages/walletkit/src/handlers/IntentHandler.spec.ts +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -337,6 +337,7 @@ describe('IntentHandler', () => { describe('approveBatchedIntent', () => { function makeBatch(overrides: Partial = {}): BatchedIntentEvent { return { + type: 'batched', id: 'batch-1', origin: 'deepLink', clientId: 'client-b', @@ -435,6 +436,7 @@ describe('IntentHandler', () => { describe('rejectIntent (batched)', () => { function makeBatch(): BatchedIntentEvent { return { + type: 'batched', id: 'batch-r', origin: 'deepLink', clientId: 'client-br', @@ -468,6 +470,7 @@ describe('IntentHandler', () => { it('rejects a batch that includes a connect item', async () => { const batch: BatchedIntentEvent = { + type: 'batched', id: 'batch-pcr', origin: 'deepLink', clientId: 'cr', diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts index 6ecd515a2..e240705ad 100644 --- a/packages/walletkit/src/handlers/IntentHandler.ts +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -101,6 +101,7 @@ export class IntentHandler { if (connectItem) { // Batch: connect + single non-tx intent const batch: BatchedIntentEvent = { + type: 'batched', id: event.id, origin: event.origin, clientId: event.clientId, @@ -358,6 +359,7 @@ export class IntentHandler { intents.push(...perItemEvents); const batch: BatchedIntentEvent = { + type: 'batched', id: event.id, origin: event.origin, clientId: event.clientId, From 4b486746971ccd99c8bf9058dc025806a9c9498e Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Tue, 17 Mar 2026 10:16:36 +0530 Subject: [PATCH 46/46] Filter intent features from DeviceInfo in TonConnect connect handshake getSupportedFeatures() can return intent feature names (SendTransactionDraft, SignMessageDraft, SendActionDraft) for internal intent discovery. However DeviceInfo.features in the TonConnect connect response must only contain standard protocol feature names (SendTransaction, SignData), as dApps validate against the @tonconnect/protocol spec. getDeviceInfoForWallet() now filters walletSupportedFeatures to only standard TonConnect feature names before placing them in DeviceInfo. --- packages/walletkit/src/utils/getDefaultWalletConfig.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/walletkit/src/utils/getDefaultWalletConfig.ts b/packages/walletkit/src/utils/getDefaultWalletConfig.ts index b288cd672..41152b171 100644 --- a/packages/walletkit/src/utils/getDefaultWalletConfig.ts +++ b/packages/walletkit/src/utils/getDefaultWalletConfig.ts @@ -117,9 +117,17 @@ export function getDeviceInfoForWallet( const walletSupportedFeatures = walletAdapter?.getSupportedFeatures(); if (walletSupportedFeatures) { + // Only include standard TonConnect protocol feature names in DeviceInfo. + // Intent feature names (SendTransactionDraft, SignMessageDraft, SendActionDraft) + // are not yet in @tonconnect/protocol spec and would be rejected by compliant dApps. + const tcFeatures = walletSupportedFeatures.filter( + (f) => + f === 'SendTransaction' || + (typeof f === 'object' && (f.name === 'SendTransaction' || f.name === 'SignData')), + ); const deviceInfo = { ...baseDeviceInfo, - features: walletSupportedFeatures, + features: tcFeatures, }; return addLegacySendTransactionFeature(deviceInfo);