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..18fc1f97c 100644 --- a/packages/walletkit-android-bridge/src/api/index.ts +++ b/packages/walletkit-android-bridge/src/api/index.ts @@ -19,6 +19,7 @@ import * as tonconnect from './tonconnect'; import * as nft from './nft'; import * as jettons from './jettons'; import * as browser from './browser'; +import * as intents from './intents'; import { eventListeners } from './eventListeners'; export { eventListeners }; @@ -89,4 +90,14 @@ export const api: WalletKitBridgeApi = { emitBrowserPageFinished: browser.emitBrowserPageFinished, emitBrowserError: browser.emitBrowserError, emitBrowserBridgeRequest: browser.emitBrowserBridgeRequest, + + // Intents + isIntentUrl: intents.isIntentUrl, + handleIntentUrl: intents.handleIntentUrl, + approveTransactionIntent: intents.approveTransactionIntent, + approveSignDataIntent: intents.approveSignDataIntent, + approveActionIntent: intents.approveActionIntent, + 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..d6dfb656a --- /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 approveTransactionIntent(args: unknown[]) { + return kit('approveTransactionIntent', ...args); +} + +export async function approveSignDataIntent(args: unknown[]) { + return kit('approveSignDataIntent', ...args); +} + +export async function approveActionIntent(args: unknown[]) { + return kit('approveActionIntent', ...args); +} + +export async function approveBatchedIntent(args: unknown[]) { + return kit('approveBatchedIntent', ...args); +} + +export async function rejectIntent(args: unknown[]) { + return kit('rejectIntent', ...args); +} + +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..82ac771d3 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 ApproveTransactionIntentArgs { + event: TransactionIntentRequestEvent; + walletId: string; +} + +export interface ApproveSignDataIntentArgs { + event: SignDataIntentRequestEvent; + walletId: string; +} + +export interface ApproveActionIntentArgs { + 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,15 @@ export interface WalletKitBridgeApi { emitBrowserPageFinished(args: EmitBrowserPageArgs): PromiseOrValue<{ success: boolean }>; emitBrowserError(args: EmitBrowserErrorArgs): PromiseOrValue<{ success: boolean }>; emitBrowserBridgeRequest(args: EmitBrowserBridgeRequestArgs): PromiseOrValue<{ success: boolean }>; + // Intent API + isIntentUrl(args: IsIntentUrlArgs): PromiseOrValue; + handleIntentUrl(args: HandleIntentUrlArgs): PromiseOrValue; + approveTransactionIntent(args: ApproveTransactionIntentArgs): PromiseOrValue; + approveSignDataIntent(args: ApproveSignDataIntentArgs): PromiseOrValue; + approveActionIntent( + args: ApproveActionIntentArgs, + ): PromiseOrValue; + 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..02e454f96 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; + approveTransactionIntent( + event: TransactionIntentRequestEvent, + walletId: string, + ): Promise; + approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise; + approveActionIntent( + 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 643b3b04a..3a3facd57 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -92,5 +92,25 @@ 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, + 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..a2615205a --- /dev/null +++ b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts @@ -0,0 +1,27 @@ +/** + * 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 { + /** 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..c8b9f56da --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentActionItem.ts @@ -0,0 +1,83 @@ +/** + * 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 { + /** 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 { + /** 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 { + /** 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`. + */ +export type IntentActionItem = + | { type: 'sendTon'; value: SendTonAction } + | { type: 'sendJetton'; value: SendJettonAction } + | { type: 'sendNft'; value: SendNftAction }; diff --git a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts new file mode 100644 index 000000000..f459e389b --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts @@ -0,0 +1,105 @@ +/** + * 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'; + +/** + * 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 `txIntent` (send) and `signMsg` (signOnly) from the spec. + * The `deliveryMode` field distinguishes them. + */ +export interface TransactionIntentRequestEvent extends IntentRequestBase { + /** 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 { + /** 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 { + /** + * Action URL to fetch + * @format url + */ + actionUrl: string; +} + +/** + * Union of all intent request events, discriminated by `type`. + * + * The `connect` variant is used when an intent URL carries a connect request. + * It appears as the first item in a {@link BatchedIntentEvent} so the wallet + * can display it alongside the transaction/sign-data items. + */ +export type IntentRequestEvent = + | { type: 'transaction'; value: TransactionIntentRequestEvent } + | { type: 'signData'; value: SignDataIntentRequestEvent } + | { type: 'action'; value: ActionIntentRequestEvent } + | { type: 'connect'; value: ConnectionRequestEvent }; 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..221ed2544 --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentResponse.ts @@ -0,0 +1,66 @@ +/** + * 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 { + /** Signed BoC (base64) */ + boc: Base64String; +} + +/** + * Successful response for sign data intent. + */ +export interface IntentSignDataResponse { + /** 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 { + /** 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`. + */ +export type IntentResponseResult = + | { type: 'transaction'; value: IntentTransactionResponse } + | { type: 'signData'; value: IntentSignDataResponse } + | { type: 'error'; value: IntentErrorResponse }; diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts index acc3cc298..909b3b9d3 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts @@ -137,8 +137,11 @@ export class WalletV4R2Adapter implements WalletAdapter { async getSignedSendTransaction( input: TransactionRequest, - _options: { fakeSignature: boolean }, + options?: { fakeSignature?: boolean; internal?: boolean }, ): Promise { + if (options?.internal) { + throw new Error('WalletV4R2 does not support internal message signing (gasless). Use WalletV5R1.'); + } if (input.messages.length === 0) { throw new Error('Ledger does not support empty messages'); } diff --git a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts index e1f516ca2..c85820ba8 100644 --- a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts +++ b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts @@ -153,7 +153,7 @@ export class WalletV5R1Adapter implements WalletAdapter { async getSignedSendTransaction( input: TransactionRequest, - options: { fakeSignature: boolean }, + options?: { fakeSignature?: boolean; internal?: boolean }, ): Promise { const actions = packActionsList( input.messages.map((m) => { @@ -201,7 +201,7 @@ export class WalletV5R1Adapter implements WalletAdapter { }), ); - const createBodyOptions: { validUntil: number | undefined; fakeSignature: boolean } = { + const createBodyOptions: { validUntil: number | undefined; fakeSignature?: boolean; internal?: boolean } = { ...options, validUntil: undefined, }; @@ -309,15 +309,23 @@ export class WalletV5R1Adapter implements WalletAdapter { seqno: number, walletId: bigint, actionsList: Cell, - options: { validUntil: number | undefined; fakeSignature: boolean }, + options: { validUntil: number | undefined; fakeSignature?: boolean; internal?: boolean }, ) { const Opcodes = { auth_signed: 0x7369676e, + auth_signed_internal: 0x73696e74, }; + // Use internal opcode for gasless relaying (signOnly / signMsg intent) + const opcode = options.internal ? Opcodes.auth_signed_internal : Opcodes.auth_signed; + log.debug('createBodyV5 signing with opcode', { + internal: options.internal, + opcode: `0x${opcode.toString(16)}`, + }); + const expireAt = options.validUntil ?? Math.floor(Date.now() / 1000) + 300; const payload = beginCell() - .storeUint(Opcodes.auth_signed, 32) + .storeUint(opcode, 32) .storeUint(walletId, 32) .storeUint(expireAt, 32) .storeUint(seqno, 32) // seqno diff --git a/packages/walletkit/src/core/BridgeManager.ts b/packages/walletkit/src/core/BridgeManager.ts index 70b1beb5b..ff7a0158e 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, diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 2f7eb742a..5184d0d24 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -49,6 +49,7 @@ import type { NetworkManager } from './NetworkManager'; import { KitNetworkManager } from './NetworkManager'; import type { WalletId } from '../utils/walletId'; import type { Wallet, WalletAdapter } from '../api/interfaces'; +import { IntentHandler } from '../handlers/IntentHandler'; import type { Network, TransactionRequest, @@ -62,6 +63,16 @@ import type { SignDataApprovalResponse, TONConnectSession, ConnectionApprovalResponse, + IntentRequestEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentActionItem, + BatchedIntentEvent, + ConnectionApprovalProof, } from '../api/models'; import { asAddressFriendly } from '../utils'; @@ -91,6 +102,7 @@ export class TonWalletKit implements ITonWalletKit { private initializer: Initializer; private eventProcessor!: StorageEventProcessor; private bridgeManager!: BridgeManager; + private intentHandler!: IntentHandler; private config: TonWalletKitOptions; @@ -250,6 +262,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 +540,86 @@ export class TonWalletKit implements ITonWalletKit { this.eventRouter.removeErrorCallback(); } + // === Intent API === + + isIntentUrl(url: string): boolean { + const normalized = url.trim().toLowerCase(); + return normalized.startsWith('tc://intent_inline') || normalized.startsWith('tc://intent'); + } + + async handleIntentUrl(url: string, walletId: string): Promise { + await this.ensureInitialized(); + return this.intentHandler.handleIntentUrl(url, walletId); + } + + onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void { + if (this.intentHandler) { + this.intentHandler.onIntentRequest(cb); + } else { + this.ensureInitialized().then(() => { + this.intentHandler.onIntentRequest(cb); + }); + } + } + + removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void { + this.intentHandler.removeIntentRequestCallback(cb); + } + + async approveTransactionIntent( + event: TransactionIntentRequestEvent, + walletId: string, + ): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveTransactionIntent(event, walletId); + } + + async approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveSignDataIntent(event, walletId); + } + + async approveActionIntent( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveActionIntent(event, walletId); + } + + async approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + proof?: ConnectionApprovalProof, + ): Promise { + await this.ensureInitialized(); + + // Process connect items first — create session before sending tx + const connectItems = batch.intents.filter((i) => i.type === 'connect'); + for (const item of connectItems) { + if (item.type === 'connect') { + item.value.walletId = walletId; + await this.requestProcessor.approveConnectRequest(item.value, proof ? { proof } : undefined); + } + } + + return this.intentHandler.approveBatchedIntent(batch, walletId); + } + + 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 +630,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..c14f9fc89 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -0,0 +1,530 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { IntentHandler } from './IntentHandler'; +import type { BridgeManager } from '../core/BridgeManager'; +import type { WalletManager } from '../core/WalletManager'; +import type { Wallet } from '../api/interfaces'; +import type { TonWalletKitOptions } from '../types'; +import type { + IntentRequestEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + SignDataPayload, + 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); + }); + + // ── approveTransactionIntent ───────────────────────────────────────────── + + describe('approveTransactionIntent', () => { + /** Helper to build an event with resolvedTransaction so IntentResolver is bypassed. */ + function txEvent(overrides: Partial = {}): TransactionIntentRequestEvent { + return { + id: 'tx-1', + origin: 'deepLink', + clientId: 'client-1', + deliveryMode: 'send', + items: [{ type: 'sendTon', value: { address: 'EQAddr', amount: '1000000000' } }], + resolvedTransaction: { + messages: [{ address: 'EQAddr', amount: '1000000000' }], + fromAddress: 'UQTestAddr', + }, + ...overrides, + }; + } + + it('signs and sends a transaction, returns boc', async () => { + const result = await handler.approveTransactionIntent(txEvent(), 'wallet-1'); + + expect(result.boc).toBe('signed-boc-base64'); + expect(mockWallet.getSignedSendTransaction).toHaveBeenCalled(); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).toHaveBeenCalledWith('signed-boc-base64'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('does not send boc when deliveryMode is signOnly', async () => { + const result = await handler.approveTransactionIntent( + txEvent({ id: 'tx-2', deliveryMode: 'signOnly' }), + 'wallet-1', + ); + + 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.approveTransactionIntent(txEvent({ id: 'tx-3' }), 'wallet-1'); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).not.toHaveBeenCalled(); + }); + + it('skips bridge send when clientId is absent', async () => { + await handler.approveTransactionIntent(txEvent({ id: 'tx-4', clientId: '' }), 'wallet-1'); + expect(bridgeManager.sendIntentResponse).not.toHaveBeenCalled(); + }); + }); + + // ── approveSignDataIntent ──────────────────────────────────────────────── + + describe('approveSignDataIntent', () => { + const signPayload: SignDataPayload = { + data: { type: 'text', value: { content: 'Sign this' } }, + }; + + it('signs data and returns result', async () => { + const event: SignDataIntentRequestEvent = { + id: 'sd-1', + origin: 'deepLink', + clientId: 'client-1', + manifestUrl: 'https://example.com/manifest.json', + payload: signPayload, + }; + + const result = await handler.approveSignDataIntent(event, 'wallet-1'); + + expect(result.signature).toBeDefined(); + expect(result.address).toBe(VALID_TON_ADDRESS); + expect(result.timestamp).toBeDefined(); + expect(result.domain).toBe('example.com'); + expect(mockWallet.getSignedSignData).toHaveBeenCalled(); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('falls back to raw manifestUrl for domain on invalid URL', async () => { + const event: SignDataIntentRequestEvent = { + id: 'sd-2', + origin: 'deepLink', + clientId: 'client-1', + manifestUrl: 'not-a-valid-url', + payload: signPayload, + }; + + const result = await handler.approveSignDataIntent(event, 'wallet-1'); + expect(result.domain).toBe('not-a-valid-url'); + }); + }); + + // ── rejectIntent ───────────────────────────────────────────────────────── + + describe('rejectIntent', () => { + it('sends error response with user declined code by default', async () => { + const event: IntentRequestEvent = { + type: 'transaction', + value: { + id: 'tx-r1', + origin: 'deepLink', + clientId: 'client-1', + deliveryMode: 'send', + items: [], + }, + }; + + const result = await handler.rejectIntent(event); + + expect(result.error.code).toBe(300); // USER_DECLINED + expect(result.error.message).toBe('User declined the request'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('uses custom reason and error code', async () => { + const event: IntentRequestEvent = { + type: 'signData', + value: { + id: 'sd-r1', + origin: 'deepLink', + clientId: 'client-1', + 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', + m: 'txIntent', + i: [{ t: 'ton', a: 'EQAddr', am: '100' }], + c: { manifestUrl: 'https://dapp.com/m.json', items: [{ name: 'ton_addr' }] }, + }); + await handler.handleIntentUrl(url, 'wallet-1'); + + // Should 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 txIntent', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + const url = buildInlineUrl('c-batch', { + id: 'tx-batch', + m: 'txIntent', + i: [ + { t: 'ton', a: 'EQAddr1', am: '100' }, + { t: 'ton', a: 'EQAddr2', am: '200' }, + ], + }); + + await handler.handleIntentUrl(url, 'wallet-1'); + + expect(emitted).toBeDefined(); + // BatchedIntentEvent has `intents` array + expect('intents' in emitted!).toBe(true); + + const batch = emitted as BatchedIntentEvent; + expect(batch.id).toBe('tx-batch'); + expect(batch.origin).toBe('deepLink'); + expect(batch.clientId).toBe('c-batch'); + expect(batch.intents).toHaveLength(2); + + // Each inner event is a transaction with one item + expect(batch.intents[0].type).toBe('transaction'); + expect(batch.intents[0].value.id).toBe('tx-batch_0'); + expect(batch.intents[0].value.items).toHaveLength(1); + + expect(batch.intents[1].type).toBe('transaction'); + expect(batch.intents[1].value.id).toBe('tx-batch_1'); + expect(batch.intents[1].value.items).toHaveLength(1); + }); + + it('emits regular IntentRequestEvent for single-item txIntent', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + const url = buildInlineUrl('c-single', { + id: 'tx-single', + m: 'txIntent', + i: [{ t: 'ton', a: 'EQAddr1', am: '100' }], + }); + + await handler.handleIntentUrl(url, 'wallet-1'); + + expect(emitted).toBeDefined(); + // Regular event does NOT have `intents` + expect('intents' in emitted!).toBe(false); + expect((emitted as IntentRequestEvent).type).toBe('transaction'); + }); + + it('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', + m: 'txIntent', + i: [ + { t: 'ton', a: 'EQAddr1', am: '100' }, + { t: 'ton', a: 'EQAddr2', am: '200' }, + ], + c: { manifestUrl: 'https://dapp.com/m.json', items: [{ name: 'ton_addr' }] }, + }); + + await handler.handleIntentUrl(url, 'wallet-1'); + + const batch = emitted as BatchedIntentEvent; + // 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 { + id: 'batch-1', + origin: 'deepLink', + clientId: 'client-b', + intents: [ + { + type: 'transaction', + value: { + id: 'batch-1_0', + origin: 'deepLink', + clientId: 'client-b', + deliveryMode: 'send', + items: [{ type: 'sendTon', value: { address: 'EQAddr1', amount: '100' } }], + }, + }, + { + type: 'transaction', + value: { + id: 'batch-1_1', + origin: 'deepLink', + clientId: 'client-b', + deliveryMode: 'send', + items: [{ type: 'sendTon', value: { address: 'EQAddr2', amount: '200' } }], + }, + }, + ], + ...overrides, + }; + } + + it('signs and sends a combined transaction', async () => { + const result = await handler.approveBatchedIntent(makeBatch(), 'wallet-1'); + + expect(result.boc).toBe('signed-boc-base64'); + expect(mockWallet.getSignedSendTransaction).toHaveBeenCalledTimes(1); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).toHaveBeenCalledWith('signed-boc-base64'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('uses signOnly when any inner event has signOnly delivery', async () => { + const batch = makeBatch(); + (batch.intents[1].value as TransactionIntentRequestEvent).deliveryMode = 'signOnly'; + + await handler.approveBatchedIntent(batch, 'wallet-1'); + + // Should NOT send boc + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).not.toHaveBeenCalled(); + }); + + it('signs data when batch contains only signData items', async () => { + const signDataBatch = makeBatch({ + intents: [ + { + type: 'signData', + value: { + id: 'sd-1', + origin: 'deepLink', + clientId: 'client-b', + manifestUrl: 'https://example.com', + payload: { data: { type: 'text', value: { content: 'x' } } }, + }, + }, + ], + }); + + const result = await handler.approveBatchedIntent(signDataBatch, 'wallet-1'); + expect('signature' in result).toBe(true); + expect(mockWallet.getSignedSignData).toHaveBeenCalled(); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('throws when batch contains no transaction or signData items', async () => { + const emptyBatch = makeBatch({ + intents: [ + { + type: 'connect', + value: { + from: 'client-b', + id: 'c-1', + method: 'connect', + params: { manifest: { url: 'https://example.com' }, items: [] }, + timestamp: Date.now(), + domain: '', + }, + } as unknown as IntentRequestEvent, + ], + }); + + await expect(handler.approveBatchedIntent(emptyBatch, 'wallet-1')).rejects.toThrow( + 'Batched intent contains no transaction or signData items', + ); + }); + + it('skips bridge send when batch has no clientId', async () => { + const batch = makeBatch({ clientId: undefined }); + await handler.approveBatchedIntent(batch, 'wallet-1'); + expect(bridgeManager.sendIntentResponse).not.toHaveBeenCalled(); + }); + }); + + // ── rejectIntent (batched) ────────────────────────────────────────────── + + describe('rejectIntent (batched)', () => { + function makeBatch(): BatchedIntentEvent { + return { + id: 'batch-r', + origin: 'deepLink', + clientId: 'client-br', + intents: [ + { + type: 'transaction', + value: { + id: 'batch-r_0', + origin: 'deepLink', + clientId: 'client-br', + deliveryMode: 'send', + items: [{ type: 'sendTon', value: { address: 'EQ1', amount: '100' } }], + }, + }, + ], + }; + } + + it('rejects a batched intent with default error', async () => { + const result = await handler.rejectIntent(makeBatch()); + + expect(result.error.code).toBe(300); + expect(result.error.message).toBe('User declined the request'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('rejects a batched intent with custom reason', async () => { + const result = await handler.rejectIntent(makeBatch(), 'Batch rejected', 500); + + expect(result.error.code).toBe(500); + expect(result.error.message).toBe('Batch rejected'); + }); + + it('rejects a batch that includes a connect item', async () => { + const batch: BatchedIntentEvent = { + id: 'batch-pcr', + origin: 'deepLink', + clientId: 'cr', + intents: [ + { + type: 'connect', + value: { + 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: TransactionIntentRequestEvent = { + id: 'tx-nw', + origin: 'deepLink', + clientId: 'c1', + deliveryMode: 'send', + items: [{ type: 'sendTon', value: { address: 'EQ1', amount: '100' } }], + resolvedTransaction: { + messages: [{ address: 'EQ1', amount: '100' }], + fromAddress: 'UQ1', + }, + }; + + await expect(h.approveTransactionIntent(event, 'missing-wallet')).rejects.toThrow('Wallet not found'); + }); + }); +}); + +/** + * Helper: Build a tc://intent_inline URL from a wire request object. + */ +function buildInlineUrl(clientId: string, request: Record, opts?: { traceId?: string }): string { + const json = JSON.stringify(request); + const b64 = Buffer.from(json, 'utf-8').toString('base64url'); + let url = `tc://intent_inline?id=${clientId}&r=${b64}`; + if (opts?.traceId) url += `&trace_id=${opts.traceId}`; + return url; +} diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts new file mode 100644 index 000000000..1d88ee5eb --- /dev/null +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -0,0 +1,550 @@ +/** + * 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 { RawBridgeEventConnect } from '../types/internal'; +import type { AnalyticsManager } from '../analytics'; +import type { + IntentRequestEvent, + IntentRequestBase, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentResponseResult, + IntentActionItem, + BatchedIntentEvent, + 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 ConnectionRequestEvent if present + let connectItem: IntentRequestEvent | undefined; + if (connectRequest) { + const connectionEvent = await this.resolveConnectRequest(connectRequest, event); + connectItem = { type: 'connect', value: connectionEvent }; + } + + if (event.type === 'transaction') { + if (connectItem || event.value.items.length > 1) { + // Batch when there's a connect or multiple tx items + await this.resolveAndEmitBatchedTransaction(event, walletId, connectItem); + } else { + await this.resolveAndEmitTransaction(event, walletId); + } + } else { + if (connectItem) { + // Batch: connect + single non-tx intent + const batch: BatchedIntentEvent = { + id: event.value.id, + origin: event.value.origin, + clientId: event.value.clientId, + traceId: event.value.traceId, + returnStrategy: event.value.returnStrategy, + intents: [connectItem, event], + }; + this.emit(batch); + } else { + this.emit(event); + } + } + } + + // -- Public: Callbacks ---------------------------------------------------- + + onIntentRequest(callback: IntentCallback): void { + this.callbacks.push(callback); + } + + removeIntentRequestCallback(callback: IntentCallback): void { + this.callbacks = this.callbacks.filter((cb) => cb !== callback); + } + + // -- Public: Approval ----------------------------------------------------- + + async approveTransactionIntent( + event: TransactionIntentRequestEvent, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + + const transactionRequest = event.resolvedTransaction ?? (await this.resolveTransaction(event, wallet)); + + // signOnly (signMsg) uses internal opcode (0x73696e74) for gasless relaying + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest, { + internal: event.deliveryMode === 'signOnly', + }); + + if (event.deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { + await CallForSuccess(() => wallet.getClient().sendBoc(signedBoc)); + } + + const result: IntentTransactionResponse = { + boc: signedBoc as Base64String, + }; + + await this.sendResponse(event, { type: 'transaction', value: result }); + return result; + } + + /** + * Approve a batched intent event. + * + * Collects all items from the inner transaction events, builds a single + * combined {@link TransactionRequest}, signs it as one transaction, and + * sends a single response back to the dApp. + */ + async approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + + // Collect all items from inner transaction events + const allItems: IntentActionItem[] = []; + let deliveryMode: 'send' | 'signOnly' = 'send'; + for (const intent of batch.intents) { + if (intent.type === 'transaction') { + allItems.push(...intent.value.items); + if (intent.value.deliveryMode === 'signOnly') { + deliveryMode = 'signOnly'; + } + } + } + + // If the batch contains transaction items, process them + if (allItems.length > 0) { + // Find network/validUntil from first transaction event + const firstTx = batch.intents.find((i) => i.type === 'transaction'); + const network = firstTx?.type === 'transaction' ? firstTx.value.network : undefined; + const validUntil = firstTx?.type === 'transaction' ? firstTx.value.validUntil : undefined; + + // Build combined transaction + const transactionRequest = await this.resolver.intentItemsToTransactionRequest( + allItems, + wallet, + network, + validUntil, + ); + + // signOnly (signMsg) uses internal opcode (0x73696e74) for gasless relaying + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest, { + internal: deliveryMode === 'signOnly', + }); + + if (deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { + await CallForSuccess(() => wallet.getClient().sendBoc(signedBoc)); + } + + const result: IntentTransactionResponse = { + boc: signedBoc as Base64String, + }; + + // Send one response using the batch's identity + await this.sendBatchResponse(batch, { type: 'transaction', value: result }); + return result; + } + + // Check for signData intents + const signDataIntent = batch.intents.find((i) => i.type === 'signData'); + if (signDataIntent && signDataIntent.type === 'signData') { + const event = signDataIntent.value; + + let domain = event.manifestUrl; + try { + domain = new URL(event.manifestUrl).host; + } catch { + // use as-is + } + + const signData = PrepareSignData({ + payload: event.payload, + domain, + address: wallet.getAddress(), + }); + + const signature = await wallet.getSignedSignData(signData); + const signatureBase64 = HexToBase64(signature); + + const result: IntentSignDataResponse = { + signature: signatureBase64 as Base64String, + address: wallet.getAddress() as UserFriendlyAddress, + timestamp: signData.timestamp, + domain: signData.domain, + payload: event.payload, + }; + + await this.sendBatchResponse(batch, { type: 'signData', value: result }); + return result; + } + + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Batched intent contains no transaction or signData items', + ); + } + + async approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise { + const wallet = this.getWallet(walletId); + + let domain = event.manifestUrl; + try { + domain = new URL(event.manifestUrl).host; + } catch { + // use as-is + } + + const signData = PrepareSignData({ + payload: event.payload, + domain, + address: wallet.getAddress(), + }); + + const signature = await wallet.getSignedSignData(signData); + const signatureBase64 = HexToBase64(signature); + + const result: IntentSignDataResponse = { + signature: signatureBase64 as Base64String, + address: wallet.getAddress() as UserFriendlyAddress, + timestamp: signData.timestamp, + domain: signData.domain, + payload: event.payload, + }; + + await this.sendResponse(event, { type: 'signData', value: result }); + return result; + } + + async approveActionIntent( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + + const actionResponse = await this.resolver.fetchActionUrl(event.actionUrl, wallet.getAddress()); + const resolvedEvent = this.parser.parseActionResponse(actionResponse, event); + + if (resolvedEvent.type === 'transaction') { + if (resolvedEvent.value.resolvedTransaction) { + resolvedEvent.value.resolvedTransaction.fromAddress = wallet.getAddress(); + } + return this.approveTransactionIntent(resolvedEvent.value, walletId); + } else if (resolvedEvent.type === 'signData') { + return this.approveSignDataIntent(resolvedEvent.value, walletId); + } + + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Action URL resolved to unsupported intent type: ${resolvedEvent.type}`, + ); + } + + // -- Public: Rejection ---------------------------------------------------- + + async rejectIntent( + event: IntentRequestEvent | BatchedIntentEvent, + reason?: string, + errorCode?: number, + ): Promise { + const result: IntentErrorResponse = { + 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, { type: 'error', value: result }); + } else if (event.type !== 'connect') { + await this.sendResponse(event.value, { type: 'error', value: 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 txEvent = event.value; + const wallet = this.getWallet(walletId); + + const transactionRequest = await this.resolveTransaction(txEvent, wallet); + txEvent.resolvedTransaction = transactionRequest; + + try { + const preview = await wallet.getTransactionPreview(transactionRequest); + txEvent.preview = preview; + } catch (error) { + log.warn('Failed to emulate transaction preview', { error }); + txEvent.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.value.clientId || '', + id: event.value.id, + method: 'connect', + params: { + manifest: { url: connectRequest.manifestUrl }, + items: connectRequest.items, + }, + timestamp: Date.now(), + domain: '', + }; + + const connectHandler = new ConnectHandler(() => {}, this.walletKitOptions, this.analyticsManager); + return connectHandler.handle(bridgeEvent); + } + + /** + * Split a multi-item transaction intent into per-item events, + * resolve and emulate each, then emit as a {@link BatchedIntentEvent}. + * + * If `connectItem` is provided it is prepended to the batch so the + * wallet can display the connect alongside the transaction items. + */ + private async resolveAndEmitBatchedTransaction( + event: Extract, + walletId: string, + connectItem?: IntentRequestEvent, + ): Promise { + const txEvent = event.value; + const wallet = this.getWallet(walletId); + + const perItemEvents: IntentRequestEvent[] = []; + + for (let i = 0; i < txEvent.items.length; i++) { + const item = txEvent.items[i]; + const itemEvent: TransactionIntentRequestEvent = { + id: `${txEvent.id}_${i}`, + origin: txEvent.origin, + clientId: txEvent.clientId, + traceId: txEvent.traceId, + returnStrategy: txEvent.returnStrategy, + deliveryMode: txEvent.deliveryMode, + network: txEvent.network, + validUntil: txEvent.validUntil, + items: [item], + }; + + try { + const resolved = await this.resolveTransaction(itemEvent, wallet); + itemEvent.resolvedTransaction = resolved; + const preview = await wallet.getTransactionPreview(resolved); + itemEvent.preview = preview; + } catch (error) { + log.warn('Failed to resolve/emulate batched item', { error, index: i }); + } + + perItemEvents.push({ type: 'transaction', value: itemEvent }); + } + + const intents: IntentRequestEvent[] = []; + if (connectItem) intents.push(connectItem); + intents.push(...perItemEvents); + + const batch: BatchedIntentEvent = { + id: txEvent.id, + origin: txEvent.origin, + clientId: txEvent.clientId, + traceId: txEvent.traceId, + returnStrategy: txEvent.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: 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); + + try { + 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): Promise { + if (!batch.clientId) { + log.debug('No clientId on batched intent, skipping response send'); + return; + } + + const wireResponse = this.toWireResponse(batch.id, result); + + try { + await this.bridgeManager.sendIntentResponse(batch.clientId, wireResponse, batch.traceId); + } catch (error) { + log.error('Failed to send batched intent response', { error, batchId: batch.id }); + } + } + + /** + * Convert SDK response model to TonConnect wire format. + * - Transaction: `{ result: "", id }` + * - SignData: `{ result: { signature, address, timestamp, domain, payload }, id }` + * - Error: `{ error: { code, message }, id }` + */ + private toWireResponse(eventId: string, result: IntentResponseResult): Record { + if (result.type === 'error') { + return { + error: { code: result.value.error.code, message: result.value.error.message }, + id: eventId, + }; + } + + if (result.type === 'transaction') { + return { result: result.value.boc, id: eventId }; + } + + return { + result: { + signature: result.value.signature, + address: result.value.address, + timestamp: result.value.timestamp, + domain: result.value.domain, + payload: this.signDataPayloadToWire(result.value.payload), + }, + 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..01f9549c7 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -0,0 +1,482 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { IntentParser } from './IntentParser'; + +/** + * Helper: Build a tc://intent_inline URL from a wire request object. + * Encodes the request as base64url in the `r` parameter. + */ +function buildInlineUrl(clientId: string, request: Record, opts?: { traceId?: string }): string { + const json = JSON.stringify(request); + // base64url encode + const b64 = Buffer.from(json, 'utf-8').toString('base64url'); + let url = `tc://intent_inline?id=${clientId}&r=${b64}`; + if (opts?.traceId) url += `&trace_id=${opts.traceId}`; + return url; +} + +describe('IntentParser', () => { + let parser: IntentParser; + + beforeEach(() => { + parser = new IntentParser(); + }); + + // ── isIntentUrl ────────────────────────────────────────────────────────── + + describe('isIntentUrl', () => { + it('returns true for tc://intent_inline URLs', () => { + expect(parser.isIntentUrl('tc://intent_inline?id=abc&r=data')).toBe(true); + }); + + it('returns true for tc://intent URLs', () => { + expect(parser.isIntentUrl('tc://intent?id=abc&pk=key&get_url=http://example.com')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(parser.isIntentUrl('TC://INTENT_INLINE?id=abc')).toBe(true); + expect(parser.isIntentUrl(' TC://INTENT?id=abc ')).toBe(true); + }); + + it('returns false for non-intent URLs', () => { + expect(parser.isIntentUrl('https://example.com')).toBe(false); + expect(parser.isIntentUrl('tc://connect?id=abc')).toBe(false); + expect(parser.isIntentUrl('')).toBe(false); + }); + }); + + // ── parse – inline txIntent ────────────────────────────────────────────── + + describe('parse – txIntent (inline)', () => { + it('parses a transaction intent with TON items', async () => { + const url = buildInlineUrl('client-123', { + id: 'tx-1', + m: 'txIntent', + i: [ + { t: 'ton', a: 'EQAddr1', am: '1000000000' }, + { t: 'ton', a: 'EQAddr2', am: '2000000000', p: 'payload-b64' }, + ], + vu: 1700000000, + n: '-239', + }); + + const { event, connectRequest } = await parser.parse(url); + + expect(connectRequest).toBeUndefined(); + expect(event.type).toBe('transaction'); + + if (event.type !== 'transaction') throw new Error('unexpected'); + const tx = event.value; + + expect(tx.id).toBe('tx-1'); + expect(tx.origin).toBe('deepLink'); + expect(tx.clientId).toBe('client-123'); + expect(tx.deliveryMode).toBe('send'); + expect(tx.network).toEqual({ chainId: '-239' }); + expect(tx.validUntil).toBe(1700000000); + expect(tx.items).toHaveLength(2); + + expect(tx.items[0].type).toBe('sendTon'); + if (tx.items[0].type === 'sendTon') { + expect(tx.items[0].value.address).toBe('EQAddr1'); + expect(tx.items[0].value.amount).toBe('1000000000'); + } + + expect(tx.items[1].type).toBe('sendTon'); + if (tx.items[1].type === 'sendTon') { + expect(tx.items[1].value.payload).toBe('payload-b64'); + } + }); + + it('parses signMsg as signOnly delivery mode', async () => { + const url = buildInlineUrl('c1', { + id: 'sm-1', + m: 'signMsg', + i: [{ t: 'ton', a: 'EQ1', am: '100' }], + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + expect(event.value.deliveryMode).toBe('signOnly'); + } + }); + + it('parses jetton items', async () => { + const url = buildInlineUrl('c1', { + id: 'j-1', + m: 'txIntent', + i: [ + { + t: 'jetton', + ma: 'EQJettonMaster', + ja: '5000000', + d: 'EQDest', + rd: 'EQResp', + fta: '10000', + qi: 42, + }, + ], + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + const item = event.value.items[0]; + expect(item.type).toBe('sendJetton'); + if (item.type === 'sendJetton') { + expect(item.value.jettonMasterAddress).toBe('EQJettonMaster'); + expect(item.value.jettonAmount).toBe('5000000'); + expect(item.value.destination).toBe('EQDest'); + expect(item.value.responseDestination).toBe('EQResp'); + expect(item.value.forwardTonAmount).toBe('10000'); + expect(item.value.queryId).toBe(42); + } + } + }); + + it('parses NFT items', async () => { + const url = buildInlineUrl('c1', { + id: 'n-1', + m: 'txIntent', + i: [ + { + t: 'nft', + na: 'EQNftAddr', + no: 'EQNewOwner', + rd: 'EQResp', + }, + ], + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + const item = event.value.items[0]; + expect(item.type).toBe('sendNft'); + if (item.type === 'sendNft') { + expect(item.value.nftAddress).toBe('EQNftAddr'); + expect(item.value.newOwnerAddress).toBe('EQNewOwner'); + expect(item.value.responseDestination).toBe('EQResp'); + } + } + }); + }); + + // ── parse – inline signIntent ──────────────────────────────────────────── + + describe('parse – signIntent (inline)', () => { + it('parses a text sign data intent', async () => { + const url = buildInlineUrl('c1', { + id: 'si-1', + m: 'signIntent', + mu: 'https://example.com/manifest.json', + p: { type: 'text', text: 'Hello world' }, + }); + + const { event } = await parser.parse(url); + + expect(event.type).toBe('signData'); + if (event.type === 'signData') { + expect(event.value.id).toBe('si-1'); + expect(event.value.manifestUrl).toBe('https://example.com/manifest.json'); + expect(event.value.payload.data.type).toBe('text'); + if (event.value.payload.data.type === 'text') { + expect(event.value.payload.data.value.content).toBe('Hello world'); + } + } + }); + + it('parses a binary sign data intent', async () => { + const url = buildInlineUrl('c1', { + id: 'si-2', + m: 'signIntent', + mu: 'https://example.com/manifest.json', + p: { type: 'binary', bytes: 'AQID' }, + }); + + const { event } = await parser.parse(url); + if (event.type === 'signData') { + expect(event.value.payload.data.type).toBe('binary'); + if (event.value.payload.data.type === 'binary') { + expect(event.value.payload.data.value.content).toBe('AQID'); + } + } + }); + + it('parses a cell sign data intent', async () => { + const url = buildInlineUrl('c1', { + id: 'si-3', + m: 'signIntent', + mu: 'https://example.com/manifest.json', + p: { type: 'cell', cell: 'te6cckEBAQEA', schema: 'MySchema' }, + }); + + const { event } = await parser.parse(url); + if (event.type === 'signData') { + expect(event.value.payload.data.type).toBe('cell'); + if (event.value.payload.data.type === 'cell') { + expect(event.value.payload.data.value.content).toBe('te6cckEBAQEA'); + expect(event.value.payload.data.value.schema).toBe('MySchema'); + } + } + }); + + it('uses manifestUrl from connect request when mu is absent', async () => { + const url = buildInlineUrl('c1', { + id: 'si-4', + m: 'signIntent', + c: { + manifestUrl: 'https://dapp.com/manifest.json', + items: [{ name: 'ton_addr' }], + }, + p: { type: 'text', text: 'Sign this' }, + }); + + const { event, connectRequest } = await parser.parse(url); + expect(connectRequest).toBeDefined(); + if (event.type === 'signData') { + expect(event.value.manifestUrl).toBe('https://dapp.com/manifest.json'); + } + }); + }); + + // ── parse – inline actionIntent ────────────────────────────────────────── + + describe('parse – actionIntent (inline)', () => { + it('parses an action intent', async () => { + const url = buildInlineUrl('c1', { + id: 'a-1', + m: 'actionIntent', + a: 'https://api.example.com/action', + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('action'); + if (event.type === 'action') { + expect(event.value.id).toBe('a-1'); + expect(event.value.actionUrl).toBe('https://api.example.com/action'); + } + }); + }); + + // ── parse – validation errors ──────────────────────────────────────────── + + describe('parse – validation', () => { + it('allows inline URL without client ID (fire-and-forget)', async () => { + const json = JSON.stringify({ id: 'x', m: 'txIntent', i: [{ t: 'ton', a: 'A', am: '1' }] }); + const b64 = Buffer.from(json).toString('base64url'); + const url = `tc://intent_inline?r=${b64}`; + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + expect(event.value.clientId).toBeUndefined(); + }); + + it('rejects object storage URL without client ID', async () => { + const url = 'tc://intent?pk=abc123&get_url=https%3A%2F%2Fexample.com%2Fpayload'; + await expect(parser.parse(url)).rejects.toThrow('Missing client ID'); + }); + + it('rejects URL without payload', async () => { + const url = 'tc://intent_inline?id=c1'; + await expect(parser.parse(url)).rejects.toThrow('Missing payload'); + }); + + it('rejects unknown intent method', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'badMethod' }); + await expect(parser.parse(url)).rejects.toThrow('Invalid intent method'); + }); + + it('rejects txIntent without items', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'txIntent' }); + await expect(parser.parse(url)).rejects.toThrow('missing items'); + }); + + it('rejects txIntent with invalid item type', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'txIntent', i: [{ t: 'unknown' }] }); + await expect(parser.parse(url)).rejects.toThrow('Invalid intent item type'); + }); + + it('rejects ton item missing address', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'txIntent', i: [{ t: 'ton', am: '100' }] }); + await expect(parser.parse(url)).rejects.toThrow('missing address'); + }); + + it('rejects ton item missing amount', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'txIntent', i: [{ t: 'ton', a: 'A' }] }); + await expect(parser.parse(url)).rejects.toThrow('missing amount'); + }); + + it('rejects jetton item missing master address', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'txIntent', + i: [{ t: 'jetton', ja: '100', d: 'D' }], + }); + await expect(parser.parse(url)).rejects.toThrow('missing master address'); + }); + + it('rejects jetton item missing amount', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'txIntent', + i: [{ t: 'jetton', ma: 'MA', d: 'D' }], + }); + await expect(parser.parse(url)).rejects.toThrow('missing amount'); + }); + + it('rejects jetton item missing destination', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'txIntent', + i: [{ t: 'jetton', ma: 'MA', ja: '100' }], + }); + await expect(parser.parse(url)).rejects.toThrow('missing destination'); + }); + + it('rejects NFT item missing address', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'txIntent', + i: [{ t: 'nft', no: 'NO' }], + }); + await expect(parser.parse(url)).rejects.toThrow('missing address'); + }); + + it('rejects NFT item missing new owner', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'txIntent', + i: [{ t: 'nft', na: 'NA' }], + }); + await expect(parser.parse(url)).rejects.toThrow('missing new owner'); + }); + + it('rejects signIntent without manifest URL', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'signIntent', + p: { type: 'text', text: 'hello' }, + }); + await expect(parser.parse(url)).rejects.toThrow('missing manifest URL'); + }); + + it('rejects signIntent without payload', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'signIntent', + mu: 'https://example.com/m.json', + }); + await expect(parser.parse(url)).rejects.toThrow('missing payload'); + }); + + it('rejects actionIntent without action URL', async () => { + const url = buildInlineUrl('c1', { id: 'x', m: 'actionIntent' }); + await expect(parser.parse(url)).rejects.toThrow('missing action URL'); + }); + + it('rejects request without id', async () => { + const url = buildInlineUrl('c1', { m: 'txIntent', i: [{ t: 'ton', a: 'A', am: '1' }] }); + await expect(parser.parse(url)).rejects.toThrow('missing id'); + }); + + it('rejects unsupported sign data type', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + m: 'signIntent', + mu: 'https://example.com/m.json', + p: { type: 'unsupported' }, + }); + await expect(parser.parse(url)).rejects.toThrow('Unsupported sign data type'); + }); + }); + + // ── parseActionResponse ────────────────────────────────────────────────── + + describe('parseActionResponse', () => { + const baseActionEvent = { + id: 'a-1', + origin: 'deepLink' as const, + clientId: 'c1', + actionUrl: 'https://api.example.com/action', + }; + + it('parses sendTransaction action response', () => { + const payload = { + action_type: 'sendTransaction', + action: { + messages: [{ address: 'EQAddr', amount: '500', payload: 'abc123' }], + valid_until: 1700000000, + network: '-239', + }, + }; + + const event = parser.parseActionResponse(payload, baseActionEvent); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + expect(event.value.resolvedTransaction).toBeDefined(); + expect(event.value.resolvedTransaction!.messages).toHaveLength(1); + expect(event.value.resolvedTransaction!.messages[0].address).toBe('EQAddr'); + expect(event.value.resolvedTransaction!.messages[0].amount).toBe('500'); + expect(event.value.resolvedTransaction!.network).toEqual({ chainId: '-239' }); + } + }); + + it('parses signData action response', () => { + const payload = { + action_type: 'signData', + action: { + type: 'text', + text: 'Sign this message', + }, + }; + + const event = parser.parseActionResponse(payload, baseActionEvent); + expect(event.type).toBe('signData'); + if (event.type === 'signData') { + expect(event.value.manifestUrl).toBe('https://api.example.com/action'); + expect(event.value.payload.data.type).toBe('text'); + } + }); + + it('rejects missing action_type', () => { + expect(() => parser.parseActionResponse({ action: {} }, baseActionEvent)).toThrow('missing action_type'); + }); + + it('rejects missing action', () => { + expect(() => parser.parseActionResponse({ action_type: 'sendTransaction' }, baseActionEvent)).toThrow( + 'missing action_type or action', + ); + }); + + it('rejects unsupported action_type', () => { + expect(() => parser.parseActionResponse({ action_type: 'unknown', action: {} }, baseActionEvent)).toThrow( + 'unsupported action_type', + ); + }); + + it('rejects sendTransaction without messages', () => { + expect(() => + parser.parseActionResponse( + { action_type: 'sendTransaction', action: { messages: [] } }, + baseActionEvent, + ), + ).toThrow('missing messages'); + }); + + it('rejects signData without type', () => { + expect(() => + parser.parseActionResponse({ action_type: 'signData', action: { text: 'hello' } }, baseActionEvent), + ).toThrow('missing type'); + }); + }); +}); diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts new file mode 100644 index 000000000..7d76a1330 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -0,0 +1,665 @@ +/** + * 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 { + IntentActionItem, + IntentOrigin, + IntentRequestEvent, + IntentRequestBase, + ActionIntentRequestEvent, + IntentDeliveryMode, + TransactionRequest, + SignDataPayload, + SignData, + Base64String, + Network, +} from '../api/models'; + +const INTENT_INLINE_SCHEME = 'tc://intent_inline'; +const INTENT_SCHEME = 'tc://intent'; + +/** + * Wire-format intent method identifiers from the TonConnect spec. + */ +type WireIntentMethod = 'txIntent' | 'signMsg' | 'signIntent' | 'actionIntent'; + +const VALID_METHODS: WireIntentMethod[] = ['txIntent', 'signMsg', 'signIntent', 'actionIntent']; + +/** + * Wire-format intent item types. + */ +type WireItemType = 'ton' | 'jetton' | 'nft'; + +/** + * Wire-format intent item (short field names from spec). + */ +interface WireIntentItem { + t: WireItemType; + // ton + a?: string; + am?: string; + p?: string; + si?: string; + ec?: Record; + // jetton + ma?: string; + ja?: string; + d?: string; + rd?: string; + cp?: string; + fta?: string; + fp?: string; + qi?: number; + // nft + na?: string; + no?: string; +} + +/** + * Wire-format intent request payload. + */ +interface WireIntentRequest { + id: string; + m: WireIntentMethod; + c?: ConnectRequest; + // txIntent / signMsg + i?: WireIntentItem[]; + vu?: number; + n?: string; + // signIntent + mu?: string; + p?: { type: string; text?: string; bytes?: string; schema?: string; cell?: string }; + // actionIntent + a?: string; +} + +/** + * Parsed intent URL — intermediate result before event creation. + */ +export interface ParsedIntentUrl { + clientId?: string; + request: WireIntentRequest; + 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 { + const normalized = url.trim().toLowerCase(); + return normalized.startsWith(INTENT_INLINE_SCHEME) || normalized.startsWith(INTENT_SCHEME); + } + + /** + * Parse an intent URL into a typed IntentRequestEvent. + * Supports both `tc://intent_inline` (URL-embedded) and `tc://intent` (object storage). + */ + async parse(url: string): Promise<{ event: IntentRequestEvent; connectRequest?: ConnectRequest }> { + const parsed = await this.parseUrl(url); + return this.toIntentEvent(parsed); + } + + // -- URL parsing ---------------------------------------------------------- + + private async parseUrl(url: string): Promise { + try { + const parsedUrl = new URL(url); + const clientId = parsedUrl.searchParams.get('id') || undefined; + + const normalized = url.trim().toLowerCase(); + + if (normalized.startsWith(INTENT_INLINE_SCHEME)) { + return this.parseInlinePayload(parsedUrl, clientId); + } + + if (normalized.startsWith(INTENT_SCHEME)) { + if (!clientId) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Missing client ID (id) in object storage intent URL (required for decryption)', + ); + } + return this.parseObjectStoragePayload(parsedUrl, clientId); + } + + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Unknown intent URL scheme'); + } catch (error) { + if (error instanceof WalletKitError) throw error; + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid intent URL format', error as Error); + } + } + + private parseInlinePayload(parsedUrl: URL, clientId: string | undefined): ParsedIntentUrl { + const encoded = parsedUrl.searchParams.get('r'); + if (!encoded) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing payload (r) in intent URL'); + } + const traceId = parsedUrl.searchParams.get('trace_id') || undefined; + + const json = this.decodePayload(encoded); + let request: WireIntentRequest; + try { + request = JSON.parse(json) as WireIntentRequest; + } catch (error) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid JSON in intent payload', error as Error); + } + + this.validateRequest(request); + return { clientId, request, origin: 'deepLink', traceId }; + } + + /** + * Parse an object storage intent URL. + * Fetches encrypted payload from `get_url`, decrypts with NaCl using + * the provided wallet private key and client public key. + */ + private async parseObjectStoragePayload(parsedUrl: URL, clientId: string): Promise { + const walletPrivateKey = parsedUrl.searchParams.get('pk'); + const getUrl = parsedUrl.searchParams.get('get_url'); + const traceId = parsedUrl.searchParams.get('trace_id') || undefined; + + if (!walletPrivateKey) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing wallet private key (pk) in intent URL'); + } + if (!getUrl) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing get_url in intent URL'); + } + + const encryptedPayload = await this.fetchObjectStoragePayload(getUrl); + const json = this.decryptPayload(encryptedPayload, clientId, walletPrivateKey); + + let request: WireIntentRequest; + try { + request = JSON.parse(json) as WireIntentRequest; + } catch (error) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Invalid JSON in decrypted intent payload: ${json.substring(0, 100)}`, + error as Error, + ); + } + + this.validateRequest(request); + return { clientId, request, origin: 'objectStorage', traceId }; + } + + /** + * Fetch encrypted payload from object storage URL. + * Handles both raw binary and base64-encoded text responses. + */ + private async fetchObjectStoragePayload(getUrl: string): Promise { + try { + const response = await fetch(getUrl); + if (!response.ok) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Object storage fetch failed: ${response.status} ${response.statusText}`, + ); + } + + const contentType = response.headers.get('content-type') || ''; + const buffer = await response.arrayBuffer(); + const raw = new Uint8Array(buffer); + + // If the response is text (base64-encoded), decode it + if (contentType.includes('text') || contentType.includes('json')) { + const text = new TextDecoder().decode(raw).trim(); + + // Try to parse as JSON that contains base64 data + try { + const jsonResp = JSON.parse(text); + const b64Data = jsonResp.data || jsonResp.payload || jsonResp.body; + if (typeof b64Data === 'string') { + return this.base64ToBytes(b64Data); + } + } catch { + // Not JSON, try as plain base64 + } + + // Try as plain base64 string + if (/^[A-Za-z0-9+/=_-]+$/.test(text) && text.length > 24) { + return this.base64ToBytes(text); + } + } + + return raw; + } catch (error) { + if (error instanceof WalletKitError) throw error; + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Failed to fetch intent payload from object storage: ${(error as Error).message}`, + error as Error, + ); + } + } + + private base64ToBytes(b64: string): Uint8Array { + // Handle base64url encoding + let base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); + const padding = base64.length % 4; + if (padding) base64 += '='.repeat(4 - padding); + + if (typeof atob === 'function') { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + return new Uint8Array(Buffer.from(base64, 'base64')); + } + + /** + * Decrypt an object storage payload using NaCl crypto_box. + * Format: nonce (24 bytes) || ciphertext + */ + private decryptPayload(encrypted: Uint8Array, clientPubKeyHex: string, walletPrivateKeyHex: string): string { + if (encrypted.length <= 24) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Encrypted payload too short (${encrypted.length} bytes, need >24)`, + ); + } + + const clientPubKey = this.hexToBytes(clientPubKeyHex); + const walletPrivateKey = this.hexToBytes(walletPrivateKeyHex); + + if (clientPubKey.length !== 32) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Invalid client public key length: ${clientPubKey.length} (expected 32)`, + ); + } + if (walletPrivateKey.length !== 32) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Invalid wallet private key length: ${walletPrivateKey.length} (expected 32)`, + ); + } + + const nonce = encrypted.slice(0, 24); + const ciphertext = encrypted.slice(24); + const decrypted = nacl.box.open(ciphertext, nonce, clientPubKey, walletPrivateKey); + if (!decrypted) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Failed to decrypt intent payload'); + } + + return new TextDecoder().decode(decrypted); + } + + private hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; + } + + // -- Payload decoding ----------------------------------------------------- + + private decodePayload(encoded: string): string { + if (encoded.startsWith('%7B') || encoded.startsWith('%257B') || encoded.startsWith('{')) { + let decoded = decodeURIComponent(encoded); + if (decoded.startsWith('%7B') || decoded.startsWith('%')) { + decoded = decodeURIComponent(decoded); + } + return decoded; + } + return this.decodeBase64Url(encoded); + } + + private decodeBase64Url(encoded: string): string { + let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + const padding = base64.length % 4; + if (padding) base64 += '='.repeat(4 - padding); + + if (typeof atob === 'function') { + return atob(base64); + } + return Buffer.from(base64, 'base64').toString('utf-8'); + } + + // -- Validation ----------------------------------------------------------- + + private validateRequest(request: WireIntentRequest): void { + if (!request.id) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Intent request missing id'); + } + if (!request.m || !VALID_METHODS.includes(request.m)) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Invalid intent method: ${request.m}`); + } + + switch (request.m) { + case 'txIntent': + case 'signMsg': + this.validateTransactionItems(request); + break; + case 'signIntent': + this.validateSignData(request); + break; + case 'actionIntent': + this.validateAction(request); + break; + } + } + + private validateTransactionItems(request: WireIntentRequest): void { + if (!request.i || !Array.isArray(request.i) || request.i.length === 0) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Transaction intent missing items (i)'); + } + for (const item of request.i) { + this.validateItem(item); + } + } + + private validateItem(item: WireIntentItem): void { + const validTypes: WireItemType[] = ['ton', 'jetton', 'nft']; + if (!item.t || !validTypes.includes(item.t)) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Invalid intent item type: ${item.t}`); + } + + switch (item.t) { + case 'ton': + if (!item.a) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'TON item missing address (a)'); + if (!item.am) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'TON item missing amount (am)'); + break; + case 'jetton': + if (!item.ma) + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Jetton item missing master address (ma)'); + if (!item.ja) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Jetton item missing amount (ja)'); + if (!item.d) + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Jetton item missing destination (d)'); + break; + case 'nft': + if (!item.na) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'NFT item missing address (na)'); + if (!item.no) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'NFT item missing new owner (no)'); + break; + } + } + + private validateSignData(request: WireIntentRequest): void { + const manifestUrl = request.mu || request.c?.manifestUrl; + if (!manifestUrl) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Sign data intent missing manifest URL'); + } + if (!request.p || !request.p.type) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Sign data intent missing payload'); + } + } + + private validateAction(request: WireIntentRequest): void { + if (!request.a) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent missing action URL (a)'); + } + } + + /** + * Parse an action URL response payload into a typed intent event. + * + * 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', + value: { + ...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', + value: { + ...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, request, origin, traceId } = parsed; + + const base: IntentRequestBase = { + id: request.id, + origin, + clientId, + traceId, + returnStrategy: undefined, + }; + + let event: IntentRequestEvent; + + switch (request.m) { + case 'txIntent': + case 'signMsg': { + const deliveryMode: IntentDeliveryMode = request.m === 'txIntent' ? 'send' : 'signOnly'; + event = { + type: 'transaction', + value: { + ...base, + deliveryMode, + network: request.n ? { chainId: request.n } : undefined, + validUntil: request.vu, + items: this.mapItems(request.i!), + }, + }; + break; + } + case 'signIntent': { + const manifestUrl = request.mu || request.c?.manifestUrl || ''; + event = { + type: 'signData', + value: { + ...base, + network: request.n ? { chainId: request.n } : undefined, + manifestUrl, + payload: this.wirePayloadToSignDataPayload(request.p!), + }, + }; + break; + } + case 'actionIntent': { + event = { + type: 'action', + value: { + ...base, + actionUrl: request.a!, + }, + }; + break; + } + } + + return { event, connectRequest: request.c }; + } + + private mapItems(wireItems: WireIntentItem[]): IntentActionItem[] { + return wireItems.map((item) => this.mapItem(item)); + } + + private mapItem(item: WireIntentItem): IntentActionItem { + switch (item.t) { + case 'ton': + return { + type: 'sendTon', + value: { + address: item.a!, + amount: item.am!, + payload: item.p as Base64String | undefined, + stateInit: item.si as Base64String | undefined, + extraCurrency: item.ec, + }, + }; + case 'jetton': + return { + type: 'sendJetton', + value: { + jettonMasterAddress: item.ma!, + jettonAmount: item.ja!, + destination: item.d!, + responseDestination: item.rd, + customPayload: item.cp as Base64String | undefined, + forwardTonAmount: item.fta, + forwardPayload: item.fp as Base64String | undefined, + queryId: item.qi, + }, + }; + case 'nft': + return { + type: 'sendNft', + value: { + nftAddress: item.na!, + newOwnerAddress: item.no!, + responseDestination: item.rd, + customPayload: item.cp as Base64String | undefined, + forwardTonAmount: item.fta, + forwardPayload: item.fp as Base64String | undefined, + queryId: item.qi, + }, + }; + } + } + + /** + * 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..7a238cfb8 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentResolver.ts @@ -0,0 +1,164 @@ +/** + * 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}`, + ); + } + + return response.json(); + } + + // -- Item resolution ------------------------------------------------------ + + private async resolveItem(item: IntentActionItem, wallet: Wallet): Promise { + switch (item.type) { + case 'sendTon': + return this.resolveTonItem(item.value); + case 'sendJetton': + return this.resolveJettonItem(item.value, wallet); + case 'sendNft': + return this.resolveNftItem(item.value, 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 6adca8903..739f6570c 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -33,6 +33,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';