diff --git a/apps/demo-wallet/src/utils/walletManifest.ts b/apps/demo-wallet/src/utils/walletManifest.ts index de8175dd9..b4999b65a 100644 --- a/apps/demo-wallet/src/utils/walletManifest.ts +++ b/apps/demo-wallet/src/utils/walletManifest.ts @@ -50,5 +50,24 @@ export function getTonConnectFeatures(): Feature[] { name: 'SignData', types: ['text', 'binary', 'cell'], }, - ]; + // Intent features (new in protocol — cast until @tonconnect/protocol types are updated) + { + 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-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..e0c066425 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, + approveTransactionDraft: intents.approveTransactionDraft, + approveSignDataIntent: intents.approveSignDataIntent, + approveActionDraft: intents.approveActionDraft, + approveBatchedIntent: intents.approveBatchedIntent, + rejectIntent: intents.rejectIntent, + intentItemsToTransactionRequest: intents.intentItemsToTransactionRequest, } 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..82c3aa439 --- /dev/null +++ b/packages/walletkit-android-bridge/src/api/intents.ts @@ -0,0 +1,45 @@ +/** + * 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 { kit } from '../utils/bridge'; + +export async function isIntentUrl(args: unknown[]) { + return kit('isIntentUrl', ...args); +} + +export async function handleIntentUrl(args: unknown[]) { + return kit('handleIntentUrl', ...args); +} + +export async function approveTransactionDraft(args: unknown[]) { + return kit('approveTransactionDraft', ...args); +} + +export async function approveSignDataIntent(args: unknown[]) { + return kit('approveSignDataIntent', ...args); +} + +export async function approveActionDraft(args: unknown[]) { + return kit('approveActionDraft', ...args); +} + +export async function approveBatchedIntent(args: unknown[]) { + return kit('approveBatchedIntent', ...args); +} + +export async function rejectIntent(args: unknown[]) { + return kit('rejectIntent', ...args); +} + +export async function intentItemsToTransactionRequest(args: unknown[]) { + return kit('intentItemsToTransactionRequest', ...args); +} diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index a76086ccc..2354584ef 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -23,6 +23,15 @@ import type { TransactionRequest, Wallet, WalletResponse, + IntentRequestEvent, + BatchedIntentEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentActionItem, } from '@ton/walletkit'; /** @@ -260,6 +269,56 @@ export interface HandleTonConnectUrlArgs { url: string; } +// === Intent Args === + +export interface HandleIntentUrlArgs { + url: string; + walletId: string; +} + +export interface IsIntentUrlArgs { + url: string; +} + +export interface ApproveTransactionDraftArgs { + event: TransactionIntentRequestEvent; + walletId: string; +} + +export interface ApproveSignDataDraftArgs { + event: SignDataIntentRequestEvent; + walletId: string; +} + +export interface ApproveActionDraftArgs { + event: ActionIntentRequestEvent; + walletId: string; +} + +export interface ApproveBatchedIntentArgs { + batch: BatchedIntentEvent; + walletId: string; +} + +export interface RejectIntentArgs { + event: IntentRequestEvent | BatchedIntentEvent; + reason?: string; + errorCode?: number; +} + +export interface IntentItemsToTransactionRequestArgs { + items: IntentActionItem[]; + walletId: string; +} + +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 +370,17 @@ 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; + approveTransactionDraft(args: ApproveTransactionDraftArgs): PromiseOrValue; + approveSignDataIntent(args: ApproveSignDataDraftArgs): PromiseOrValue; + approveActionDraft( + args: ApproveActionDraftArgs, + ): PromiseOrValue; + approveBatchedIntent( + args: ApproveBatchedIntentArgs, + ): PromiseOrValue; + rejectIntent(args: RejectIntentArgs): PromiseOrValue; + intentItemsToTransactionRequest(args: IntentItemsToTransactionRequestArgs): 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-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index 9266e24aa..b6dd1cb15 100644 --- a/packages/walletkit-android-bridge/src/types/walletkit.ts +++ b/packages/walletkit-android-bridge/src/types/walletkit.ts @@ -8,19 +8,28 @@ import type { ApiClient, + BatchedIntentEvent, BridgeEventMessageInfo, 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 +123,25 @@ 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; + approveTransactionDraft(event: TransactionIntentRequestEvent, walletId: string): Promise; + approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise; + approveActionDraft( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise; + approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + ): Promise; + rejectIntent( + event: IntentRequestEvent | BatchedIntentEvent, + reason?: string, + errorCode?: number, + ): Promise; + intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise; } 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/api/models/index.ts b/packages/walletkit/src/api/models/index.ts index e038205db..29be2a0b3 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, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + ConnectIntentRequestEvent, + 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..3e323749a --- /dev/null +++ b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts @@ -0,0 +1,29 @@ +/** + * 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 { + /** Discriminant — always 'batched' */ + type: 'batched'; + /** How the batch reached the wallet */ + origin: IntentOrigin; + /** Client public key for response routing */ + clientId?: string; + /** The intent requests in this batch */ + intents: IntentRequestEvent[]; +} 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..7ff4cb219 --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentActionItem.ts @@ -0,0 +1,84 @@ +/** + * 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, UserFriendlyAddress } from '../core/Primitives'; +import type { TokenAmount } from '../core/TokenAmount'; +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 */ + amount: TokenAmount; + /** Cell payload (Base64 BoC) */ + payload?: Base64String; + /** Contract deploy stateInit (Base64 BoC) */ + stateInit?: Base64String; + /** Extra currencies */ + extraCurrency?: ExtraCurrencies; +} + +/** + * Jetton transfer action (TEP-74). + */ +export interface SendJettonAction { + type: 'sendJetton'; + /** Jetton master contract address */ + jettonMasterAddress: UserFriendlyAddress; + /** Transfer amount in jetton elementary units */ + jettonAmount: TokenAmount; + /** Recipient address */ + destination: UserFriendlyAddress; + /** Response destination (defaults to sender) */ + responseDestination?: UserFriendlyAddress; + /** Custom payload (Base64 BoC) */ + customPayload?: Base64String; + /** Forward TON amount (nanotons) */ + forwardTonAmount?: TokenAmount; + /** Forward payload (Base64 BoC) */ + forwardPayload?: Base64String; + /** + * Query ID + * @format int + */ + queryId?: number; +} + +/** + * NFT transfer action (TEP-62). + */ +export interface SendNftAction { + type: 'sendNft'; + /** NFT item address */ + nftAddress: UserFriendlyAddress; + /** New owner address */ + newOwnerAddress: UserFriendlyAddress; + /** Response destination (defaults to sender) */ + responseDestination?: UserFriendlyAddress; + /** Custom payload (Base64 BoC) */ + customPayload?: Base64String; + /** Forward TON amount (nanotons) */ + forwardTonAmount?: TokenAmount; + /** Forward payload (Base64 BoC) */ + forwardPayload?: Base64String; + /** + * Query ID + * @format int + */ + queryId?: number; +} + +/** + * Union of all intent action items, discriminated by `type`. + * @discriminator 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..ee7c41f99 --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts @@ -0,0 +1,121 @@ +/** + * 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 { ConnectionRequestEvent } from '../bridge/ConnectionRequestEvent'; +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'; + +/** + * Origin of the intent request. + */ +export type IntentOrigin = 'deepLink' | 'objectStorage' | 'bridge' | 'jsBridge' | 'connectedBridge'; + +/** + * 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; +} + +/** + * Transaction intent request event. + * + * Covers both `txDraft` (send) and `signMsgDraft` (signOnly) from the spec. + * 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 */ + 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?: TransactionEmulatedPreview; +} + +/** + * Sign data intent request event. + */ +export interface SignDataIntentRequestEvent extends IntentRequestBase { + type: 'signData'; + /** Network for sign data */ + network?: Network; + /** + * Manifest URL (for domain binding) + * @format url + */ + 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 { + type: 'action'; + /** + * Action URL to fetch + * @format url + */ + actionUrl: string; + /** + * Optional action type. + */ + actionType?: 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 = + | TransactionIntentRequestEvent + | SignDataIntentRequestEvent + | ActionIntentRequestEvent + | ConnectIntentRequestEvent; 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..63e8896e0 --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentResponse.ts @@ -0,0 +1,67 @@ +/** + * 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, UserFriendlyAddress } from '../core/Primitives'; +import type { SignDataPayload } from '../core/PreparedSignData'; + +/** + * Successful response for transaction intent. + */ +export interface IntentTransactionResponse { + type: 'transaction'; + /** Signed BoC (base64) */ + boc: Base64String; +} + +/** + * Successful response for sign data intent. + */ +export interface IntentSignDataResponse { + type: 'signData'; + /** Signature (base64) */ + signature: Base64String; + /** Signer address */ + address: UserFriendlyAddress; + /** + * UNIX timestamp (seconds, UTC) + * @format timestamp + */ + timestamp: number; + /** App domain */ + domain: string; + /** Echoed payload from the request */ + payload: SignDataPayload; +} + +/** + * Error response for any intent. + */ +export interface IntentErrorResponse { + type: '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, discriminated by `type`. + * @discriminator type + */ +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/contracts/v4r2/WalletV4R2Adapter.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts index acc3cc298..34c00f70a 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'); } @@ -274,14 +277,12 @@ export class WalletV4R2Adapter implements WalletAdapter { getSupportedFeatures(): Feature[] | undefined { return [ - { - name: 'SendTransaction', - maxMessages: 4, - }, - { - name: 'SignData', - types: ['binary', 'cell', 'text'], - }, - ]; + { 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 e1f516ca2..a315a8f0d 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 @@ -346,14 +354,12 @@ export class WalletV5R1Adapter implements WalletAdapter { getSupportedFeatures(): Feature[] | undefined { return [ - { - name: 'SendTransaction', - maxMessages: 255, - }, - { - name: 'SignData', - types: ['binary', 'cell', 'text'], - }, - ]; + { 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/BridgeManager.ts b/packages/walletkit/src/core/BridgeManager.ts index 70b1beb5b..62002e6a6 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, traceId?: string): 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, { traceId }); + log.debug('Intent response sent', { clientId, traceId }); + } 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, @@ -655,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 2f7eb742a..49e87afab 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, @@ -49,6 +50,8 @@ 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 { IntentParser } from '../handlers/IntentParser'; import type { Network, TransactionRequest, @@ -62,6 +65,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 +104,7 @@ export class TonWalletKit implements ITonWalletKit { private initializer: Initializer; private eventProcessor!: StorageEventProcessor; private bridgeManager!: BridgeManager; + private intentHandler!: IntentHandler; private config: TonWalletKitOptions; @@ -130,6 +144,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'); @@ -250,6 +276,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, + ); } /** @@ -522,6 +554,86 @@ export class TonWalletKit implements ITonWalletKit { this.eventRouter.removeErrorCallback(); } + // === Intent API === + + private static readonly intentParserInstance = new IntentParser(); + + isIntentUrl(url: string): boolean { + return TonWalletKit.intentParserInstance.isIntentUrl(url); + } + + 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 { + if (this.intentHandler) { + this.intentHandler.removeIntentRequestCallback(cb); + } else { + this.ensureInitialized().then(() => this.intentHandler.removeIntentRequestCallback(cb)); + } + } + + async approveTransactionDraft( + event: TransactionIntentRequestEvent, + walletId: string, + ): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveTransactionDraft(event, walletId); + } + + async approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveSignDataIntent(event, walletId); + } + + async approveActionDraft( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveActionDraft(event, walletId); + } + + async approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + proof?: ConnectionApprovalProof, + ): Promise { + await this.ensureInitialized(); + + for (const item of batch.intents) { + if (item.type === 'connect') { + await this.requestProcessor.approveConnectRequest({ ...item, walletId }, proof ? { proof } : undefined); + } + } + + 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); + } + + async intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise { + await this.ensureInitialized(); + return this.intentHandler.intentItemsToTransactionRequest(items, walletId); + } + // === URL Processing API === /** @@ -532,6 +644,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.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts new file mode 100644 index 000000000..9cfa9c387 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -0,0 +1,534 @@ +/** + * 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, + TransactionDraftRequestEvent, + SignDataIntentRequestEvent, + SignDataPayload, + BatchedIntentEvent, +} 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); + }); + + // ── approveTransactionDraft ───────────────────────────────────────────── + + describe('approveTransactionDraft', () => { + /** Helper to build an event with resolvedTransaction so IntentResolver is bypassed. */ + function txEvent(overrides: Partial = {}): TransactionDraftRequestEvent { + return { + type: 'transaction', + id: 'tx-1', + origin: 'deepLink', + clientId: 'client-1', + deliveryMode: 'send', + items: [{ type: 'sendTon' as const, 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.approveTransactionDraft(txEvent(), 'wallet-1'); + + expect(result.boc).toBe('signed-boc-base64'); + expect(mockWallet.getSignedSendTransaction).toHaveBeenCalled(); + 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.approveTransactionDraft( + txEvent({ id: 'tx-2', deliveryMode: 'signOnly' }), + 'wallet-1', + ); + + expect(result.boc).toBe('signed-boc-base64'); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).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.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.approveTransactionDraft(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 = { + type: 'signData', + id: 'sd-1', + origin: 'deepLink', + clientId: 'client-1', + 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 = { + type: 'signData', + id: 'sd-2', + origin: 'deepLink', + clientId: 'client-1', + 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', + id: 'tx-r1', + origin: 'deepLink', + clientId: 'client-1', + 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', + 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); + + expect(result.error.code).toBe(400); + expect(result.error.message).toBe('Not supported'); + }); + + 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', + 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 + 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'); + }); + }); + + // ── handleIntentUrl batching ──────────────────────────────────────────── + + describe('handleIntentUrl batching', () => { + it('emits BatchedIntentEvent for multi-item txDraft', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + const url = buildInlineUrl('c-batch', { + id: 'tx-batch', + method: 'txDraft', + params: { + 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].id).toBe('tx-batch_0'); + 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 TransactionDraftRequestEvent).items).toHaveLength(1); + }); + + it('emits regular IntentRequestEvent for single-item txDraft', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + const url = buildInlineUrl('c-single', { + id: 'tx-single', + method: 'txDraft', + params: { 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('emits connect as first item in batch when connect request present', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + 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'); + + const batch = emitted as BatchedIntentEvent; + // 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); + }); + }); + + // ── approveBatchedIntent ──────────────────────────────────────────────── + + describe('approveBatchedIntent', () => { + function makeBatch(overrides: Partial = {}): BatchedIntentEvent { + return { + type: 'batched', + id: 'batch-1', + origin: 'deepLink', + clientId: 'client-b', + intents: [ + { + 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' 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, + }; + } + + 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] as TransactionDraftRequestEvent).deliveryMode = 'signOnly'; + + await handler.approveBatchedIntent(batch, 'wallet-1'); + + // Should NOT send boc + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).not.toHaveBeenCalled(); + }); + + it('signs data when batch contains only signData items', async () => { + const signDataBatch = makeBatch({ + intents: [ + { + 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' } } }, + }, + ], + }); + + 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' as const, + id: 'c-1', + from: 'client-b', + requestedItems: [], + preview: { permissions: [] }, + } as unknown as IntentRequestEvent, + ], + }); + + await expect(handler.approveBatchedIntent(emptyBatch, 'wallet-1')).rejects.toThrow( + 'Batched intent contains no actionable 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 { + type: 'batched', + id: 'batch-r', + origin: 'deepLink', + clientId: 'client-br', + intents: [ + { + 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' }], + }, + ], + }; + } + + 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('rejects a batch that includes a connect item', async () => { + const batch: BatchedIntentEvent = { + type: 'batched', + id: 'batch-pcr', + origin: 'deepLink', + clientId: 'cr', + intents: [ + { + type: 'connect' as const, + id: 'batch-pcr', + from: 'cr', + requestedItems: [], + preview: { permissions: [] }, + }, + ], + }; + const result = await handler.rejectIntent(batch); + expect(result.error.code).toBe(300); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + }); + + // ── 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: TransactionDraftRequestEvent = { + type: 'transaction', + id: 'tx-nw', + origin: 'deepLink', + clientId: 'c1', + deliveryMode: 'send', + items: [{ type: 'sendTon' as const, address: 'EQ1', amount: '100' }], + resolvedTransaction: { + messages: [{ address: 'EQ1', amount: '100' }], + fromAddress: 'UQ1', + }, + }; + + await expect(h.approveTransactionDraft(event, 'missing-wallet')).rejects.toThrow('Wallet not found'); + }); + }); +}); + +/** + * Helper: Build a tc://?m=intent URL from a spec-format request object. + */ +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/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts new file mode 100644 index 000000000..e240705ad --- /dev/null +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -0,0 +1,567 @@ +/** + * 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 { 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 { ConnectHandler } from './ConnectHandler'; +import type { BridgeManager } from '../core/BridgeManager'; +import type { WalletManager } from '../core/WalletManager'; +import type { Wallet } from '../api/interfaces'; +import type { RawBridgeEvent, RawBridgeEventConnect } from '../types/internal'; +import type { AnalyticsManager } from '../analytics'; +import type { + IntentRequestEvent, + IntentRequestBase, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentResponseResult, + IntentActionItem, + BatchedIntentEvent, + BridgeEvent, + ConnectionRequestEvent, + TransactionRequest, + SignDataPayload, + Base64String, + UserFriendlyAddress, +} 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[] = []; + + constructor( + private walletKitOptions: TonWalletKitOptions, + private bridgeManager: BridgeManager, + private walletManager: WalletManager, + private analyticsManager?: AnalyticsManager, + ) {} + + // -- Public: Parsing ------------------------------------------------------ + + isIntentUrl(url: string): boolean { + return this.parser.isIntentUrl(url); + } + + /** + * Parse an intent URL, resolve items, emulate preview, and emit the event. + * + * 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 ConnectIntentRequestEvent if present + let connectItem: IntentRequestEvent | undefined; + if (connectRequest) { + const connectionEvent = await this.resolveConnectRequest(connectRequest, event); + connectItem = { ...connectionEvent, type: 'connect' as const }; + } + + if (event.type === 'transaction') { + if (connectItem || event.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 { + if (connectItem) { + // Batch: connect + single non-tx intent + const batch: BatchedIntentEvent = { + type: 'batched', + id: event.id, + origin: event.origin, + clientId: event.clientId, + traceId: event.traceId, + returnStrategy: event.returnStrategy, + intents: [connectItem, event], + }; + this.emit(batch); + } else { + this.emit(event); + } + } + } + + /** + * 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 { + this.callbacks.push(callback); + } + + removeIntentRequestCallback(callback: IntentCallback): void { + this.callbacks = this.callbacks.filter((cb) => cb !== callback); + } + + // -- Public: Approval ----------------------------------------------------- + + async approveTransactionDraft( + event: TransactionIntentRequestEvent, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + const result = await this.signAndSendTransaction(event, wallet); + await this.sendResponse(event, result); + return result; + } + + /** + * Approve a batched intent event. + * + * 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, + 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.items); + if (intent.deliveryMode === 'signOnly') { + deliveryMode = 'signOnly'; + } + } + } + + if (allItems.length > 0) { + const firstTx = batch.intents.find((i) => i.type === 'transaction'); + const combinedEvent: TransactionIntentRequestEvent = { + type: 'transaction', + 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, + }; + const result = await this.signAndSendTransaction(combinedEvent, wallet); + await this.sendBatchResponse(batch, result, deliveryMode); + return result; + } + + const signDataIntent = batch.intents.find((i) => i.type === 'signData'); + if (signDataIntent?.type === 'signData') { + const result = await this.signSignData(signDataIntent, wallet); + await this.sendBatchResponse(batch, result); + return result; + } + + const actionIntent = batch.intents.find((i) => i.type === 'action'); + 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 actionable items'); + } + + async approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise { + const wallet = this.getWallet(walletId); + const result = await this.signSignData(event, wallet); + await this.sendResponse(event, result); + return result; + } + + async approveActionDraft( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + const result = await this.resolveAndApproveAction(event, wallet); + await this.sendResponse(event, result); + return result; + } + + // -- Public: Rejection ---------------------------------------------------- + + async rejectIntent( + event: IntentRequestEvent | BatchedIntentEvent, + reason?: string, + errorCode?: number, + ): Promise { + const result: IntentErrorResponse = { + type: 'error', + error: { + code: errorCode ?? INTENT_ERROR_CODES.USER_DECLINED, + message: reason || 'User declined the request', + }, + }; + + const isBatched = 'intents' in event; + if (isBatched) { + await this.sendBatchResponse(event, result); + } else if (event.type !== 'connect') { + await this.sendResponse(event, result); + } + return result; + } + + // -- Public: Utilities ---------------------------------------------------- + + async intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise { + const wallet = this.getWallet(walletId); + return this.resolver.intentItemsToTransactionRequest(items, wallet); + } + + // -- Private: Resolution & Emulation -------------------------------------- + + private async resolveAndEmitTransaction( + event: Extract, + 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 = preview; + } catch (error) { + log.warn('Failed to emulate transaction preview', { error }); + event.preview = undefined; + } + + 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.clientId || '', + id: event.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 wallet = this.getWallet(walletId); + + const perItemEvents: IntentRequestEvent[] = []; + + for (let i = 0; i < event.items.length; i++) { + const item = event.items[i]; + const itemEvent: TransactionIntentRequestEvent = { + 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], + }; + + 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(itemEvent); + } + + const intents: IntentRequestEvent[] = []; + if (connectItem) intents.push(connectItem); + intents.push(...perItemEvents); + + const batch: BatchedIntentEvent = { + type: 'batched', + id: event.id, + origin: event.origin, + clientId: event.clientId, + traceId: event.traceId, + returnStrategy: event.returnStrategy, + intents, + }; + + this.emit(batch); + } + + private async resolveTransaction( + event: TransactionIntentRequestEvent, + wallet: Wallet, + ): Promise { + 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 { + 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 { + if (!event.clientId) { + log.debug('No clientId on intent event, skipping response send'); + return; + } + + const wireResponse = this.toWireResponse(event.id, result, event); + + try { + // 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 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 }); + } + } + + 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, undefined, deliveryMode); + + 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 (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, + event?: IntentRequestBase, + deliveryMode?: 'send' | 'signOnly', + ): Record { + if (result.type === 'error') { + return { + error: { code: result.error.code, message: result.error.message }, + id: eventId, + }; + } + + if (result.type === 'transaction') { + const txEvent = event as Extract | undefined; + const isSignOnly = deliveryMode === 'signOnly' || txEvent?.deliveryMode === 'signOnly'; + if (isSignOnly) { + return { result: { internal_boc: result.boc }, id: eventId }; + } + return { result: result.boc, id: eventId }; + } + + return { + result: { + signature: result.signature, + address: result.address, + timestamp: result.timestamp, + domain: result.domain, + payload: this.signDataPayloadToWire(result.payload), + }, + id: eventId, + }; + } + + /** + * 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 { + 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.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts new file mode 100644 index 000000000..8df76416e --- /dev/null +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -0,0 +1,564 @@ +/** + * 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 nacl from 'tweetnacl'; + +import { IntentParser } from './IntentParser'; + +/** + * 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; 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; +} + +describe('IntentParser', () => { + let parser: IntentParser; + + beforeEach(() => { + parser = new IntentParser(); + }); + + // ── isIntentUrl ────────────────────────────────────────────────────────── + + describe('isIntentUrl', () => { + it('returns true for m=intent URLs', () => { + expect(parser.isIntentUrl('tc://?m=intent&id=abc&mp=data')).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://?M=INTENT&id=abc')).toBe(true); + expect(parser.isIntentUrl(' TC://?M=INTENT_REMOTE&id=abc ')).toBe(true); + }); + + it('accepts https universal link scheme with m=intent', () => { + 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', () => { + expect(parser.isIntentUrl('https://example.com')).toBe(false); + expect(parser.isIntentUrl('tc://?m=connect&id=abc')).toBe(false); + expect(parser.isIntentUrl('')).toBe(false); + }); + }); + + // ── parse – inline txDraft ────────────────────────────────────────────── + + describe('parse – txDraft (inline)', () => { + it('parses a transaction intent with TON items', async () => { + const url = buildInlineUrl('client-123', { + id: 'tx-1', + 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); + + expect(connectRequest).toBeUndefined(); + expect(event.type).toBe('transaction'); + + if (event.type !== 'transaction') throw new Error('unexpected'); + const tx = event; + + expect(tx.id).toBe('tx-1'); + expect(tx.origin).toBe('deepLink'); + expect(tx.clientId).toBe('client-123'); + 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].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].payload).toBe('payload-b64'); + } + }); + + it('parses signMsgDraft as signOnly delivery mode', async () => { + const url = buildInlineUrl('c1', { + id: 'sm-1', + method: 'signMsgDraft', + params: { + i: [{ t: 'ton', a: 'EQ1', am: '100' }], + }, + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + expect(event.deliveryMode).toBe('signOnly'); + } + }); + + it('parses jetton items', async () => { + const url = buildInlineUrl('c1', { + id: 'j-1', + method: 'txDraft', + params: { + 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.items[0]; + expect(item.type).toBe('sendJetton'); + if (item.type === 'sendJetton') { + 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); + } + } + }); + + it('parses NFT items', async () => { + const url = buildInlineUrl('c1', { + id: 'n-1', + method: 'txDraft', + params: { + 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.items[0]; + expect(item.type).toBe('sendNft'); + if (item.type === 'sendNft') { + expect(item.nftAddress).toBe('EQNftAddr'); + expect(item.newOwnerAddress).toBe('EQNewOwner'); + expect(item.responseDestination).toBe('EQResp'); + } + } + }); + }); + + // ── parse – inline signData ──────────────────────────────────────────── + + describe('parse – signData (inline)', () => { + it('parses a text sign data intent', async () => { + 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); + + expect(event.type).toBe('signData'); + if (event.type === 'signData') { + 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'); + } + } + }); + + it('parses a binary sign data intent', async () => { + 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') { + expect(event.payload.data.type).toBe('binary'); + if (event.payload.data.type === 'binary') { + expect(event.payload.data.value.content).toBe('AQID'); + } + } + }); + + it('parses a cell sign data intent', async () => { + 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') { + 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'); + } + } + }); + + 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' })], + }, + { connectRequest: { manifestUrl: 'https://dapp.com/manifest.json', items: [{ name: 'ton_addr' }] } }, + ); + + const { event, connectRequest } = await parser.parse(url); + expect(connectRequest).toBeDefined(); + if (event.type === 'signData') { + expect(event.manifestUrl).toBe('https://dapp.com/manifest.json'); + } + }); + }); + + // ── parse – inline actionDraft ────────────────────────────────────────── + + describe('parse – actionDraft (inline)', () => { + it('parses an action intent', async () => { + const url = buildInlineUrl('c1', { + id: 'a-1', + method: 'actionDraft', + params: { url: 'https://api.example.com/action' }, + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('action'); + if (event.type === 'action') { + expect(event.id).toBe('a-1'); + expect(event.actionUrl).toBe('https://api.example.com/action'); + } + }); + }); + + // ── parse – validation errors ──────────────────────────────────────────── + + describe('parse – validation', () => { + it('allows inline URL without client ID (fire-and-forget)', async () => { + 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}`; + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + expect(event.clientId).toBeUndefined(); + }); + + it('rejects object storage URL without client ID', async () => { + 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('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'); + }); + + it('rejects unknown intent method', async () => { + 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', 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', 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', + 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', + 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', + method: 'txDraft', + params: { 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', + method: 'txDraft', + params: { 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', + method: 'txDraft', + params: { 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', + method: 'txDraft', + params: { 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', + method: 'txDraft', + params: { i: [{ t: 'nft', na: 'NA' }] }, + }); + await expect(parser.parse(url)).rejects.toThrow('missing new owner'); + }); + + it('rejects signData without payload', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + method: 'signData', + }); + await expect(parser.parse(url)).rejects.toThrow('missing payload'); + }); + + it('rejects actionDraft without action URL', async () => { + 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', { + 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', + method: 'signData', + params: [JSON.stringify({ type: 'unsupported' })], + }); + await expect(parser.parse(url)).rejects.toThrow('Unsupported sign data type'); + }); + }); + + // ── parseActionResponse ────────────────────────────────────────────────── + + describe('parseActionResponse', () => { + const baseActionEvent = { + type: 'action' as const, + id: 'a-1', + origin: 'deepLink' as const, + clientId: 'c1', + 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.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' }); + } + }); + + 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.manifestUrl).toBe('https://api.example.com/action'); + expect(event.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'); + }); + }); +}); diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts new file mode 100644 index 000000000..214b2e732 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -0,0 +1,724 @@ +/** + * 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 nacl from 'tweetnacl'; + +import { WalletKitError, ERROR_CODES } from '../errors'; +import type { RawBridgeEvent } from '../types/internal'; +import type { + IntentActionItem, + IntentOrigin, + IntentRequestEvent, + IntentRequestBase, + ActionIntentRequestEvent, + IntentDeliveryMode, + TransactionRequest, + SignDataPayload, + SignData, + Base64String, + Network, +} from '../api/models'; + +const VALID_METHODS = ['txDraft', 'signMsgDraft', 'signData', 'actionDraft'] as const; + +/** + * 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; +} + +interface TxDraftParams { + vu?: number; + f?: string; + n?: string; + i: WireIntentItem[]; +} + +type SignDataParams = [string]; + +interface ActionDraftParams { + url: string; +} + +/** + * 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 SpecIntentRequest { + id: string; + method: 'txDraft' | 'signMsgDraft' | 'signData' | 'actionDraft'; + params: TxDraftParams | SignDataParams | ActionDraftParams; +} + +/** + * Parsed intent URL — intermediate result before event creation. + */ +export interface ParsedIntentUrl { + clientId?: string; + /** Raw sender ID for connectedBridge events (used for session crypto lookup) */ + from?: string; + request: SpecIntentRequest; + connectRequest?: ConnectRequest; + origin: IntentOrigin; + traceId?: string; +} + +/** + * 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]; + +/** + * Parsing layer for intent deep links. + * + * Responsibility: URL parsing, payload decoding (inline + object storage), + * NaCl decryption, wire→model mapping, validation. + */ +export class IntentParser { + /** + * Check if a URL is a TonConnect intent deep link. + */ + isIntentUrl(url: string): boolean { + 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 `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); + 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, + from: rawEvent.from, + request, + connectRequest: undefined, + origin: 'connectedBridge', + traceId: rawEvent.traceId, + }; + const { event } = this.toIntentEvent(parsed); + return event; + } + + // -- URL parsing ---------------------------------------------------------- + + private async parseUrl(url: string): Promise { + try { + const parsedUrl = new URL(url); + const clientId = parsedUrl.searchParams.get('id') || undefined; + + const methodKey = Array.from(parsedUrl.searchParams.keys()).find((k) => k.toLowerCase() === 'm'); + const method = methodKey ? parsedUrl.searchParams.get(methodKey)?.toLowerCase() : null; + + if (method === 'intent') { + return this.parseInlinePayload(parsedUrl, clientId); + } + + if (method === 'intent_remote') { + 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); + } + + 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); + } + } + + private parseInlinePayload(parsedUrl: URL, clientId: string | undefined): ParsedIntentUrl { + const encoded = parsedUrl.searchParams.get('mp'); + if (!encoded) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing payload (mp) in intent URL'); + } + 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: SpecIntentRequest; + try { + 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, connectRequest, 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'); + } + + 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, walletPrivateKey); + + let request: SpecIntentRequest; + try { + request = JSON.parse(json) as SpecIntentRequest; + } 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, connectRequest, origin: 'objectStorage', traceId }; + } + + /** + * Fetch encrypted payload from object storage URL. + * + * 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 { + 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 (contentType.includes('text')) { + const text = new TextDecoder().decode(raw).trim(); + if (/^[A-Za-z0-9+/=_-]+$/.test(text) && text.length > 24) { + try { + return this.base64ToBytes(text); + } catch { + // Not valid base64, fall through to raw bytes + } + } + } + + 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 + * + * 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, walletPrivateKeyHex: string): string { + if (encrypted.length <= 24) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Encrypted payload too short (${encrypted.length} bytes, need >24)`, + ); + } + + const walletPrivateKey = this.hexToBytes(walletPrivateKeyHex); + + if (walletPrivateKey.length !== 32) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Invalid wallet private key length: ${walletPrivateKey.length} (expected 32)`, + ); + } + + // 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, walletPublicKey, 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 ----------------------------------------------------- + + 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: SpecIntentRequest): void { + if (!request.id) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Intent request missing id'); + } + if (!request.method || !VALID_METHODS.includes(request.method)) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Invalid intent method: ${request.method}`); + } + + switch (request.method) { + case 'txDraft': + case 'signMsgDraft': + this.validateTransactionItems(request); + break; + case 'signData': + this.validateSignData(request); + break; + case 'actionDraft': + this.validateAction(request); + break; + } + } + + 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 params.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: 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: SpecIntentRequest): void { + const params = request.params as ActionDraftParams; + if (!params?.url) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent missing url'); + } + } + + /** + * Parse an action URL response payload into a typed intent event. + * + * 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 { action_type, action } = payload as { action_type?: string; action?: Record }; + + if (!action_type || !action) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action URL response missing action_type or action'); + } + + const base: IntentRequestBase = { + id: sourceEvent.id, + origin: sourceEvent.origin, + clientId: sourceEvent.clientId, + }; + + 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 URL returned unsupported action_type: ${action_type}`, + ); + } + } + + 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'); + } + + 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 network: Network | undefined = action.network ? { chainId: action.network as string } : undefined; + + const resolvedTransaction: TransactionRequest = { + messages, + network, + validUntil: (action.valid_until ?? action.validUntil) as number | undefined, + }; + + return { + type: 'transaction' as const, + ...base, + deliveryMode: 'send' as IntentDeliveryMode, + network, + validUntil: resolvedTransaction.validUntil, + items: [], + resolvedTransaction, + }; + } + + private parseActionSignData( + base: IntentRequestBase, + action: Record, + actionUrl: string, + ): IntentRequestEvent { + 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 { + type: 'signData' as const, + ...base, + network: action.network ? { chainId: action.network as string } : undefined, + manifestUrl: actionUrl, + payload: this.wirePayloadToSignDataPayload(wirePayload), + }; + } + + // -- Wire → Model mapping ------------------------------------------------- + + private toIntentEvent(parsed: ParsedIntentUrl): { event: IntentRequestEvent; connectRequest?: ConnectRequest } { + const { clientId, from, request, connectRequest, origin, traceId } = parsed; + + const base: IntentRequestBase = { + id: request.id, + origin, + clientId, + from, + traceId, + returnStrategy: undefined, + }; + + let event: IntentRequestEvent; + + switch (request.method) { + case 'txDraft': + case 'signMsgDraft': { + const params = request.params as TxDraftParams; + const deliveryMode: IntentDeliveryMode = request.method === 'txDraft' ? 'send' : 'signOnly'; + event = { + type: 'transaction' as const, + ...base, + deliveryMode, + network: params.n ? { chainId: params.n } : undefined, + validUntil: params.vu, + items: this.mapItems(params.i), + }; + break; + } + case 'signData': { + 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: 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: params.url, + }; + break; + } + default: + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Unhandled intent method: ${request.method}`); + } + + return { event, connectRequest }; + } + + 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' 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' 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' 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, + }; + } + } + + /** + * 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..3822e65a8 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentResolver.ts @@ -0,0 +1,180 @@ +/** + * 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, + Network, +} 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?: Network, + validUntil?: number, + ): Promise { + const messages: TransactionRequestMessage[] = []; + + for (const item of items) { + const message = await this.resolveItem(item, wallet); + messages.push(message); + } + + return { + messages, + 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}address=${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}`, + ); + } + + const rawBody = await response.text(); + try { + return JSON.parse(rawBody); + } catch (error) { + if (rawBody.trim().startsWith('<')) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `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}`, + error as Error, + ); + } + } + + // -- 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'; 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 */ 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);