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 d083fdb40..bda037133 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 }; @@ -90,4 +91,14 @@ export const api: WalletKitBridgeApi = { emitBrowserPageFinished: browser.emitBrowserPageFinished, emitBrowserError: browser.emitBrowserError, emitBrowserBridgeRequest: browser.emitBrowserBridgeRequest, + + // Intents + isIntentUrl: intents.isIntentUrl, + handleIntentUrl: intents.handleIntentUrl, + approveTransactionDraft: intents.approveTransactionDraft, + approveSignDataIntent: intents.approveSignDataIntent, + approveActionDraft: intents.approveActionDraft, + approveBatchedIntent: intents.approveBatchedIntent, + rejectIntent: intents.rejectIntent, + intentItemsToTransactionRequest: intents.intentItemsToTransactionRequest, } as unknown as WalletKitBridgeApi; diff --git a/packages/walletkit-android-bridge/src/api/initialization.ts b/packages/walletkit-android-bridge/src/api/initialization.ts index 0266f3772..0daad62b6 100644 --- a/packages/walletkit-android-bridge/src/api/initialization.ts +++ b/packages/walletkit-android-bridge/src/api/initialization.ts @@ -18,6 +18,8 @@ import type { RequestErrorEvent, SendTransactionRequestEvent, SignDataRequestEvent, + IntentRequestEvent, + BatchedIntentEvent, } from '@ton/walletkit'; import type { WalletKitBridgeInitConfig, SetEventsListenersArgs, WalletKitBridgeEventCallback } from '../types'; @@ -105,6 +107,17 @@ export async function setEventsListeners(args?: SetEventsListenersArgs): Promise kit.onRequestError(eventListeners.onErrorListener); + // Register intent listener + if (eventListeners.onIntentListener) { + kit.removeIntentRequestCallback(eventListeners.onIntentListener); + } + + eventListeners.onIntentListener = (event: IntentRequestEvent | BatchedIntentEvent) => { + callback('intentRequest', event); + }; + + kit.onIntentRequest(eventListeners.onIntentListener); + return { ok: true }; } @@ -139,5 +152,10 @@ export async function removeEventListeners(): Promise<{ ok: true }> { eventListeners.onErrorListener = null; } + if (eventListeners.onIntentListener) { + kit.removeIntentRequestCallback(eventListeners.onIntentListener); + eventListeners.onIntentListener = null; + } + return { ok: true }; } diff --git a/packages/walletkit-android-bridge/src/api/intents.ts b/packages/walletkit-android-bridge/src/api/intents.ts new file mode 100644 index 000000000..82c3aa439 --- /dev/null +++ b/packages/walletkit-android-bridge/src/api/intents.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * intents.ts – Bridge API for intent operations + */ + +import { kit } from '../utils/bridge'; + +export async function isIntentUrl(args: unknown[]) { + return kit('isIntentUrl', ...args); +} + +export async function handleIntentUrl(args: unknown[]) { + return kit('handleIntentUrl', ...args); +} + +export async function approveTransactionDraft(args: unknown[]) { + return kit('approveTransactionDraft', ...args); +} + +export async function approveSignDataIntent(args: unknown[]) { + return kit('approveSignDataIntent', ...args); +} + +export async function approveActionDraft(args: unknown[]) { + return kit('approveActionDraft', ...args); +} + +export async function approveBatchedIntent(args: unknown[]) { + return kit('approveBatchedIntent', ...args); +} + +export async function rejectIntent(args: unknown[]) { + return kit('rejectIntent', ...args); +} + +export async function intentItemsToTransactionRequest(args: unknown[]) { + return kit('intentItemsToTransactionRequest', ...args); +} diff --git a/packages/walletkit-android-bridge/src/types/api.ts b/packages/walletkit-android-bridge/src/types/api.ts index a5a93ae77..26ac3f11e 100644 --- a/packages/walletkit-android-bridge/src/types/api.ts +++ b/packages/walletkit-android-bridge/src/types/api.ts @@ -24,6 +24,15 @@ import type { TransactionRequest, Wallet, WalletResponse, + IntentRequestEvent, + BatchedIntentEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentActionItem, } from '@ton/walletkit'; /** @@ -265,6 +274,56 @@ export interface HandleTonConnectUrlArgs { url: string; } +// === Intent Args === + +export interface HandleIntentUrlArgs { + url: string; + walletId: string; +} + +export interface IsIntentUrlArgs { + url: string; +} + +export interface ApproveTransactionDraftArgs { + event: TransactionIntentRequestEvent; + walletId: string; +} + +export interface ApproveSignDataDraftArgs { + event: SignDataIntentRequestEvent; + walletId: string; +} + +export interface ApproveActionDraftArgs { + event: ActionIntentRequestEvent; + walletId: string; +} + +export interface ApproveBatchedIntentArgs { + batch: BatchedIntentEvent; + walletId: string; +} + +export interface RejectIntentArgs { + event: IntentRequestEvent | BatchedIntentEvent; + reason?: string; + errorCode?: number; +} + +export interface IntentItemsToTransactionRequestArgs { + items: IntentActionItem[]; + walletId: string; +} + +export interface WalletDescriptor { + address: string; + publicKey: string; + version: string; + index: number; + network: string; +} + export interface WalletKitBridgeApi { init(config?: WalletKitBridgeInitConfig): PromiseOrValue<{ ok: true }>; setEventsListeners(args?: SetEventsListenersArgs): PromiseOrValue<{ ok: true }>; @@ -317,4 +376,17 @@ export interface WalletKitBridgeApi { emitBrowserPageFinished(args: EmitBrowserPageArgs): PromiseOrValue<{ success: boolean }>; emitBrowserError(args: EmitBrowserErrorArgs): PromiseOrValue<{ success: boolean }>; emitBrowserBridgeRequest(args: EmitBrowserBridgeRequestArgs): PromiseOrValue<{ success: boolean }>; + // Intent API + isIntentUrl(args: IsIntentUrlArgs): PromiseOrValue; + handleIntentUrl(args: HandleIntentUrlArgs): PromiseOrValue; + approveTransactionDraft(args: ApproveTransactionDraftArgs): PromiseOrValue; + approveSignDataIntent(args: ApproveSignDataDraftArgs): PromiseOrValue; + approveActionDraft( + args: ApproveActionDraftArgs, + ): PromiseOrValue; + approveBatchedIntent( + args: ApproveBatchedIntentArgs, + ): PromiseOrValue; + rejectIntent(args: RejectIntentArgs): PromiseOrValue; + intentItemsToTransactionRequest(args: IntentItemsToTransactionRequestArgs): PromiseOrValue; } diff --git a/packages/walletkit-android-bridge/src/types/events.ts b/packages/walletkit-android-bridge/src/types/events.ts index d8af5d678..c2efbd341 100644 --- a/packages/walletkit-android-bridge/src/types/events.ts +++ b/packages/walletkit-android-bridge/src/types/events.ts @@ -16,6 +16,7 @@ export type WalletKitBridgeEventType = | 'signDataRequest' | 'disconnect' | 'requestError' + | 'intentRequest' | 'browserPageStarted' | 'browserPageFinished' | 'browserError' diff --git a/packages/walletkit-android-bridge/src/types/walletkit.ts b/packages/walletkit-android-bridge/src/types/walletkit.ts index cec3ec40d..4776da345 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, @@ -116,4 +125,25 @@ export interface WalletKitInstance { event: SignDataRequestEvent, reason?: string | SendTransactionRpcResponseError['error'], ): Promise; + // Intent API + isIntentUrl(url: string): boolean; + handleIntentUrl(url: string, walletId: string): Promise; + onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void; + removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void; + approveTransactionDraft(event: TransactionIntentRequestEvent, walletId: string): Promise; + approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise; + approveActionDraft( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise; + approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + ): Promise; + rejectIntent( + event: IntentRequestEvent | BatchedIntentEvent, + reason?: string, + errorCode?: number, + ): Promise; + intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise; } diff --git a/packages/walletkit/src/api/interfaces/WalletAdapter.ts b/packages/walletkit/src/api/interfaces/WalletAdapter.ts index 9a8687a03..aba371131 100644 --- a/packages/walletkit/src/api/interfaces/WalletAdapter.ts +++ b/packages/walletkit/src/api/interfaces/WalletAdapter.ts @@ -40,7 +40,9 @@ export interface WalletAdapter { getSignedSendTransaction( input: TransactionRequest, options?: { - fakeSignature: boolean; + fakeSignature?: boolean; + /** Use internal message opcode (0x73696e74) instead of external (0x7369676e) for gasless relaying */ + internal?: boolean; }, ): Promise; getSignedSignData( diff --git a/packages/walletkit/src/api/models/bridge/SignMessageApprovalResponse.ts b/packages/walletkit/src/api/models/bridge/SignMessageApprovalResponse.ts new file mode 100644 index 000000000..01379e17d --- /dev/null +++ b/packages/walletkit/src/api/models/bridge/SignMessageApprovalResponse.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Base64String } from '../core/Primitives'; + +/** + * Response after user approves a sign-message request. + */ +export interface SignMessageApprovalResponse { + /** + * Signed internal message BoC (Bag of Cells) format, encoded in Base64. + * This is a signed internal message (internal opcode) that the dApp can relay via a third-party relayer. + */ + internalBoc: Base64String; +} diff --git a/packages/walletkit/src/api/models/bridge/SignMessageRequestEvent.ts b/packages/walletkit/src/api/models/bridge/SignMessageRequestEvent.ts new file mode 100644 index 000000000..297af1694 --- /dev/null +++ b/packages/walletkit/src/api/models/bridge/SignMessageRequestEvent.ts @@ -0,0 +1,22 @@ +/** + * 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 { TransactionRequest } from '../transactions/TransactionRequest'; +import type { BridgeEvent } from './BridgeEvent'; + +/** + * Event containing a sign-message (sign-only transaction) request from a dApp via TON Connect. + * The wallet signs the transaction using the internal opcode and returns the signed BoC + * without broadcasting it on-chain. + */ +export interface SignMessageRequestEvent extends BridgeEvent { + /** + * Raw transaction request data + */ + request: TransactionRequest; +} diff --git a/packages/walletkit/src/api/models/index.ts b/packages/walletkit/src/api/models/index.ts index 3c167a993..9f747c58c 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -50,6 +50,8 @@ export type { SendTransactionRequestEvent, SendTransactionRequestEventPreview, } from './bridge/SendTransactionRequestEvent'; +export type { SignMessageApprovalResponse } from './bridge/SignMessageApprovalResponse'; +export type { SignMessageRequestEvent } from './bridge/SignMessageRequestEvent'; export type { RequestErrorEvent } from './bridge/RequestErrorEvent'; export type { TONConnectSession } from './sessions/TONConnectSession'; @@ -106,6 +108,27 @@ export type { TransactionTraceMoneyFlowItem, } from './transactions/TransactionTraceMoneyFlow'; +// Intent models +export type { SendTonAction, SendJettonAction, SendNftAction, IntentActionItem } from './intents/IntentActionItem'; +export type { + IntentOrigin, + IntentDeliveryMode, + IntentRequestBase, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + ConnectIntentRequestEvent, + IntentRequestEvent, +} from './intents/IntentRequestEvent'; +export type { + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentError, + IntentResponseResult, +} from './intents/IntentResponse'; +export type { BatchedIntentEvent } from './intents/BatchedIntentEvent'; + // RPC models export type { GetMethodResult } from './rpc/GetMethodResult'; diff --git a/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts new file mode 100644 index 000000000..3e323749a --- /dev/null +++ b/packages/walletkit/src/api/models/intents/BatchedIntentEvent.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { BridgeEvent } from '../bridge/BridgeEvent'; +import type { IntentRequestEvent, IntentOrigin } from './IntentRequestEvent'; + +/** + * A batched intent event containing multiple intent items + * that should be processed as a group. + * + * Use cases: + * - send TON + connect (intent with connect request) + * - action intent that resolves to multiple steps + */ +export interface BatchedIntentEvent extends BridgeEvent { + /** Discriminant — always 'batched' */ + type: 'batched'; + /** How the batch reached the wallet */ + origin: IntentOrigin; + /** Client public key for response routing */ + clientId?: string; + /** The intent requests in this batch */ + intents: IntentRequestEvent[]; +} diff --git a/packages/walletkit/src/api/models/intents/IntentActionItem.ts b/packages/walletkit/src/api/models/intents/IntentActionItem.ts new file mode 100644 index 000000000..7ff4cb219 --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentActionItem.ts @@ -0,0 +1,84 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Base64String, UserFriendlyAddress } from '../core/Primitives'; +import type { TokenAmount } from '../core/TokenAmount'; +import type { ExtraCurrencies } from '../core/ExtraCurrencies'; + +/** + * TON native coin transfer action. + */ +export interface SendTonAction { + type: 'sendTon'; + /** Destination address (user-friendly) */ + address: UserFriendlyAddress; + /** Amount in nanotons */ + amount: TokenAmount; + /** Cell payload (Base64 BoC) */ + payload?: Base64String; + /** Contract deploy stateInit (Base64 BoC) */ + stateInit?: Base64String; + /** Extra currencies */ + extraCurrency?: ExtraCurrencies; +} + +/** + * Jetton transfer action (TEP-74). + */ +export interface SendJettonAction { + type: 'sendJetton'; + /** Jetton master contract address */ + jettonMasterAddress: UserFriendlyAddress; + /** Transfer amount in jetton elementary units */ + jettonAmount: TokenAmount; + /** Recipient address */ + destination: UserFriendlyAddress; + /** Response destination (defaults to sender) */ + responseDestination?: UserFriendlyAddress; + /** Custom payload (Base64 BoC) */ + customPayload?: Base64String; + /** Forward TON amount (nanotons) */ + forwardTonAmount?: TokenAmount; + /** Forward payload (Base64 BoC) */ + forwardPayload?: Base64String; + /** + * Query ID + * @format int + */ + queryId?: number; +} + +/** + * NFT transfer action (TEP-62). + */ +export interface SendNftAction { + type: 'sendNft'; + /** NFT item address */ + nftAddress: UserFriendlyAddress; + /** New owner address */ + newOwnerAddress: UserFriendlyAddress; + /** Response destination (defaults to sender) */ + responseDestination?: UserFriendlyAddress; + /** Custom payload (Base64 BoC) */ + customPayload?: Base64String; + /** Forward TON amount (nanotons) */ + forwardTonAmount?: TokenAmount; + /** Forward payload (Base64 BoC) */ + forwardPayload?: Base64String; + /** + * Query ID + * @format int + */ + queryId?: number; +} + +/** + * Union of all intent action items, discriminated by `type`. + * @discriminator type + */ +export type IntentActionItem = SendTonAction | SendJettonAction | SendNftAction; diff --git a/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts new file mode 100644 index 000000000..ee7c41f99 --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentRequestEvent.ts @@ -0,0 +1,121 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { BridgeEvent } from '../bridge/BridgeEvent'; +import type { ConnectionRequestEvent } from '../bridge/ConnectionRequestEvent'; +import type { TransactionRequest } from '../transactions/TransactionRequest'; +import type { TransactionEmulatedPreview } from '../transactions/emulation/TransactionEmulatedPreview'; +import type { SignDataPayload } from '../core/PreparedSignData'; +import type { DAppInfo } from '../core/DAppInfo'; +import type { Network } from '../core/Network'; +import type { IntentActionItem } from './IntentActionItem'; + +/** + * Origin of the intent request. + */ +export type IntentOrigin = 'deepLink' | 'objectStorage' | 'bridge' | 'jsBridge' | 'connectedBridge'; + +/** + * Delivery mode for the signed transaction. + */ +export type IntentDeliveryMode = 'send' | 'signOnly'; + +/** + * Base fields common to all intent request events. + */ +export interface IntentRequestBase extends BridgeEvent { + /** How the request reached the wallet */ + origin: IntentOrigin; + /** Client public key (for response encryption) */ + clientId?: string; +} + +/** + * Transaction intent request event. + * + * Covers both `txDraft` (send) and `signMsgDraft` (signOnly) from the spec. + * The `deliveryMode` field distinguishes them. + */ +export interface TransactionIntentRequestEvent extends IntentRequestBase { + type: 'transaction'; + /** Whether to send on-chain or return signed BoC */ + deliveryMode: IntentDeliveryMode; + /** Network for the transaction */ + network?: Network; + /** + * Transaction validity deadline (unix timestamp) + * @format timestamp + */ + validUntil?: number; + /** Original intent action items (for display / re-conversion) */ + items: IntentActionItem[]; + /** Resolved transaction request (items converted to messages) */ + resolvedTransaction?: TransactionRequest; + /** Emulated preview for display */ + preview?: TransactionEmulatedPreview; +} + +/** + * Sign data intent request event. + */ +export interface SignDataIntentRequestEvent extends IntentRequestBase { + type: 'signData'; + /** Network for sign data */ + network?: Network; + /** + * Manifest URL (for domain binding) + * @format url + */ + manifestUrl: string; + /** The data to sign */ + payload: SignDataPayload; + /** dApp information resolved from manifest */ + dAppInfo?: DAppInfo; +} + +/** + * Action intent request event. + * + * The wallet fetches the action URL, which returns either a transaction + * or sign-data action. This is an intermediate step before resolving + * to a TransactionIntentRequestEvent or SignDataIntentRequestEvent. + */ +export interface ActionIntentRequestEvent extends IntentRequestBase { + type: 'action'; + /** + * Action URL to fetch + * @format url + */ + actionUrl: string; + /** + * Optional action type. + */ + actionType?: string; +} + +/** + * Connect intent request event, wrapping a ConnectionRequestEvent + * when an intent URL also carries a connect request. + */ +export interface ConnectIntentRequestEvent extends ConnectionRequestEvent { + type: 'connect'; +} + +/** + * Union of all intent request events, discriminated by `type`. + * + * The `connect` variant is used when an intent URL carries a connect request. + * It appears as the first item in a {@link BatchedIntentEvent} so the wallet + * can display it alongside the transaction/sign-data items. + * @discriminator type + */ +export type IntentRequestEvent = + | TransactionIntentRequestEvent + | SignDataIntentRequestEvent + | ActionIntentRequestEvent + | ConnectIntentRequestEvent; diff --git a/packages/walletkit/src/api/models/intents/IntentResponse.ts b/packages/walletkit/src/api/models/intents/IntentResponse.ts new file mode 100644 index 000000000..63e8896e0 --- /dev/null +++ b/packages/walletkit/src/api/models/intents/IntentResponse.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Base64String, UserFriendlyAddress } from '../core/Primitives'; +import type { SignDataPayload } from '../core/PreparedSignData'; + +/** + * Successful response for transaction intent. + */ +export interface IntentTransactionResponse { + type: 'transaction'; + /** Signed BoC (base64) */ + boc: Base64String; +} + +/** + * Successful response for sign data intent. + */ +export interface IntentSignDataResponse { + type: 'signData'; + /** Signature (base64) */ + signature: Base64String; + /** Signer address */ + address: UserFriendlyAddress; + /** + * UNIX timestamp (seconds, UTC) + * @format timestamp + */ + timestamp: number; + /** App domain */ + domain: string; + /** Echoed payload from the request */ + payload: SignDataPayload; +} + +/** + * Error response for any intent. + */ +export interface IntentErrorResponse { + type: 'error'; + /** Error details */ + error: IntentError; +} + +/** + * Intent error details. + */ +export interface IntentError { + /** + * Error code + * @format int + */ + code: number; + /** Human-readable message */ + message: string; +} + +/** + * Union of all intent responses, discriminated by `type`. + * @discriminator type + */ +export type IntentResponseResult = IntentTransactionResponse | IntentSignDataResponse | IntentErrorResponse; diff --git a/packages/walletkit/src/api/scripts/generate-json-schema.js b/packages/walletkit/src/api/scripts/generate-json-schema.js index 67f181378..3980df583 100644 --- a/packages/walletkit/src/api/scripts/generate-json-schema.js +++ b/packages/walletkit/src/api/scripts/generate-json-schema.js @@ -764,6 +764,13 @@ class DiscriminatedUnionTypeFormatter { if (this.hasRecursiveReference(type)) { return false; } + // Interface unions (named types without a `value` wrapper) are handled + // by ts-json-schema-generator's default allOf[if/then] output, then + // transformed by postProcessDiscriminatedUnions. Don't claim them here. + const hasValueWrapper = type.getTypes().every((variant) => this.getAssociatedValueType(variant) !== null); + if (!hasValueWrapper) { + return false; + } return true; } diff --git a/packages/walletkit/src/bridge/core/TonConnectBridge.ts b/packages/walletkit/src/bridge/core/TonConnectBridge.ts index b643ae84e..644e13083 100644 --- a/packages/walletkit/src/bridge/core/TonConnectBridge.ts +++ b/packages/walletkit/src/bridge/core/TonConnectBridge.ts @@ -75,6 +75,20 @@ export class TonConnectBridge { }); } + /** + * Initiates connection and submits an intent in a single call. + * Returns the connect event (if a new connection was established) and the intent response. + */ + async connectWithIntent( + payload: string, + options?: { protocolVersion?: number; connectRequest?: ConnectRequest }, + ): Promise { + return this.transport.send({ + method: 'connectWithIntent', + params: { payload, ...(options ?? {}) }, + }); + } + /** * Registers a listener for events from the wallet * Returns unsubscribe function diff --git a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts index a3e6f79b7..7bdc666e6 100644 --- a/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts +++ b/packages/walletkit/src/contracts/v4r2/WalletV4R2Adapter.ts @@ -142,8 +142,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'); } @@ -165,9 +168,13 @@ export class WalletV4R2Adapter implements WalletAdapter { try { const messages: MessageRelaxed[] = input.messages.map((m) => { let bounce = true; - const parsedAddress = Address.parseFriendly(m.address); - if (parsedAddress.isBounceable === false) { - bounce = false; + try { + const parsedAddress = Address.parseFriendly(m.address); + if (parsedAddress.isBounceable === false) { + bounce = false; + } + } catch { + // raw address — no bounceable flag, keep default true } return internal({ @@ -281,14 +288,15 @@ export class WalletV4R2Adapter implements WalletAdapter { getSupportedFeatures(): Feature[] | undefined { return [ - { - name: 'SendTransaction', - maxMessages: 4, - }, - { - name: 'SignData', - types: ['binary', 'cell', 'text'], - }, - ]; + 'SendTransaction', + { name: 'SendTransaction', maxMessages: 4, extraCurrencySupported: true }, + { name: 'SignData', types: ['text', 'binary', 'cell'] }, + { name: 'SendTransactionDraft', types: ['ton', 'jetton', 'nft'] }, + { name: 'ActionDraft' }, + { name: 'Intents', types: ['txDraft', 'actionDraft', 'signData'] }, + // SignMessage and SignMessageDraft require W5R1 internal opcodes + // TODO: remove `as unknown as Feature[]` cast once @tonconnect/protocol is updated + // to include PR #103 feature names (SendTransactionDraft, ActionDraft, Intents, etc.) + ] as unknown as Feature[]; } } diff --git a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts index 10144f0c1..5fd6e0615 100644 --- a/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts +++ b/packages/walletkit/src/contracts/w5/WalletV5R1Adapter.ts @@ -18,6 +18,7 @@ import { SendMode, signatureDomainPrefix, storeMessage, + storeMessageRelaxed, storeStateInit, } from '@ton/core'; import { external, internal } from '@ton/core'; @@ -169,14 +170,18 @@ 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) => { let bounce = true; - const parsedAddress = Address.parseFriendly(m.address); - if (parsedAddress.isBounceable === false) { - bounce = false; + try { + const parsedAddress = Address.parseFriendly(m.address); + if (parsedAddress.isBounceable === false) { + bounce = false; + } + } catch { + // raw address — no bounceable flag, keep default true } const msg = internal({ @@ -217,7 +222,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, }; @@ -252,6 +257,18 @@ export class WalletV5R1Adapter implements WalletAdapter { const transfer = await this.createBodyV5(seqno, walletId, actions, createBodyOptions); + if (options?.internal) { + // For gasless relaying, the signed body (auth_signed_internal opcode) must be + // delivered to the wallet via an internal message from a relayer contract. + const msg = internal({ + to: this.walletContract.address, + value: 0n, + body: transfer, + bounce: false, + }); + return beginCell().store(storeMessageRelaxed(msg)).endCell().toBoc().toString('base64') as Base64String; + } + const ext = external({ to: this.walletContract.address, init: this.walletContract.init, @@ -325,15 +342,24 @@ export class WalletV5R1Adapter implements WalletAdapter { seqno: number, walletId: bigint, actionsList: Cell, - options: { validUntil: number | undefined; fakeSignature: boolean }, + options: { validUntil: number | undefined; fakeSignature?: boolean; internal?: boolean }, ) { + // Opcodes defined in the WalletV5R1 contract spec, confirmed in @ton/ton WalletContractV5R1.js const Opcodes = { - auth_signed: 0x7369676e, + auth_signed: 0x7369676e, // external auth ("sign") + auth_signed_internal: 0x73696e74, // internal auth ("sint") — used for gasless relaying }; + // 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 @@ -363,14 +389,16 @@ export class WalletV5R1Adapter implements WalletAdapter { getSupportedFeatures(): Feature[] | undefined { return [ - { - name: 'SendTransaction', - maxMessages: 255, - }, - { - name: 'SignData', - types: ['binary', 'cell', 'text'], - }, - ]; + 'SendTransaction', + { name: 'SendTransaction', maxMessages: 255, extraCurrencySupported: true }, + { name: 'SignData', types: ['text', 'binary', 'cell'] }, + { name: 'SignMessage', maxMessages: 255, extraCurrencySupported: true }, + { name: 'SendTransactionDraft', types: ['ton', 'jetton', 'nft'] }, + { name: 'SignMessageDraft', types: ['ton', 'jetton', 'nft'] }, + { name: 'ActionDraft' }, + { name: 'Intents', types: ['txDraft', 'signMsgDraft', 'actionDraft', 'signData'] }, + // TODO: remove `as unknown as Feature[]` cast once @tonconnect/protocol is updated + // to include PR #103 feature names (SendTransactionDraft, SignMessageDraft, ActionDraft, Intents, etc.) + ] as unknown as Feature[]; } } diff --git a/packages/walletkit/src/core/BridgeManager.ts b/packages/walletkit/src/core/BridgeManager.ts index 0aa62827b..6d665a008 100644 --- a/packages/walletkit/src/core/BridgeManager.ts +++ b/packages/walletkit/src/core/BridgeManager.ts @@ -11,6 +11,7 @@ import { SessionCrypto } from '@tonconnect/protocol'; import type { ClientConnection, WalletConsumer } from '@tonconnect/bridge-sdk'; import { BridgeProvider } from '@tonconnect/bridge-sdk'; +import type { ConnectRequest } from '@tonconnect/protocol'; import type { BridgeConfig, RawBridgeEvent } from '../types/internal'; import type { Storage } from '../storage'; @@ -259,6 +260,31 @@ export class BridgeManager { } } + // 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, @@ -535,6 +561,15 @@ export class BridgeManager { messageId: messageInfo.messageId, walletId: messageInfo.walletId, }); + } else if (event.method === 'connectWithIntent') { + this.eventQueue.push({ + ...event, + isJsBridge: true, + tabId: messageInfo.tabId, + domain: messageInfo.domain, + messageId: messageInfo.messageId, + walletId: messageInfo.walletId, + }); } // Trigger processing (don't wait for it to complete) diff --git a/packages/walletkit/src/core/EventProcessor.ts b/packages/walletkit/src/core/EventProcessor.ts index 648a33f22..1fb9edfc8 100644 --- a/packages/walletkit/src/core/EventProcessor.ts +++ b/packages/walletkit/src/core/EventProcessor.ts @@ -435,6 +435,8 @@ export class StorageEventProcessor implements IEventProcessor { */ private getNoWalletEnabledEventTypes(): EventType[] { const enabledTypes = this.eventRouter.getEnabledEventTypes(); - return enabledTypes.filter((type) => type === 'connect' || type === 'restoreConnection'); + return enabledTypes.filter( + (type) => type === 'connect' || type === 'restoreConnection' || type === 'connectWithIntent', + ); } } diff --git a/packages/walletkit/src/core/EventRouter.ts b/packages/walletkit/src/core/EventRouter.ts index 06ab8b668..8dbb7f2f3 100644 --- a/packages/walletkit/src/core/EventRouter.ts +++ b/packages/walletkit/src/core/EventRouter.ts @@ -12,6 +12,7 @@ import type { RawBridgeEvent, EventHandler, EventCallback, EventType } from '../ import { ConnectHandler } from '../handlers/ConnectHandler'; import { TransactionHandler } from '../handlers/TransactionHandler'; import { SignDataHandler } from '../handlers/SignDataHandler'; +import { SignMessageHandler } from '../handlers/SignMessageHandler'; import { DisconnectHandler } from '../handlers/DisconnectHandler'; import { validateBridgeEvent } from '../validation/events'; import { globalLogger } from './Logger'; @@ -26,9 +27,11 @@ import type { RequestErrorEvent, DisconnectionEvent, SignDataRequestEvent, + SignMessageRequestEvent, ConnectionRequestEvent, } from '../api/models'; import type { TonWalletKitOptions } from '../types/config'; +import type { ConnectRequest } from '@tonconnect/protocol'; const log = globalLogger.createChild('EventRouter'); @@ -40,6 +43,7 @@ export class EventRouter { private connectRequestCallback: EventCallback | undefined = undefined; private transactionRequestCallback: EventCallback | undefined = undefined; private signDataRequestCallback: EventCallback | undefined = undefined; + private signMessageRequestCallback: EventCallback | undefined = undefined; private disconnectCallback: EventCallback | undefined = undefined; private errorCallback: EventCallback | undefined = undefined; @@ -61,6 +65,35 @@ export class EventRouter { * Route incoming bridge event to appropriate handler */ async routeEvent(event: RawBridgeEvent): Promise { + // Intent draft events and connectWithIntent are forwarded directly to the eventEmitter. + // They are handled by IntentHandler/TonWalletKit listeners, not by the standard handlers. + const INTENT_METHODS = ['txDraft', 'signMsgDraft', 'actionDraft', 'connectWithIntent']; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const raw = event as any; + if (INTENT_METHODS.includes(raw.method)) { + if (raw.method === 'connectWithIntent') { + const params = raw.params as { payload?: string; connectRequest?: ConnectRequest; protocolVersion?: number }; + if (!params.payload) { + log.warn('connectWithIntent received without payload, ignoring'); + return; + } + this.eventEmitter.emit( + 'bridge-connect-with-intent', + { + intentUrl: params.payload, + connectRequest: params.connectRequest, + tabId: raw.tabId, + messageId: raw.messageId, + walletId: raw.walletId, + }, + 'event-router', + ); + } else { + this.eventEmitter.emit('bridge-draft-intent', event, 'event-router'); + } + return; + } + // Validate event structure const validation = validateBridgeEvent(event); if (!validation.isValid) { @@ -107,6 +140,10 @@ export class EventRouter { this.signDataRequestCallback = callback; } + onSignMessageRequest(callback: EventCallback): void { + this.signMessageRequestCallback = callback; + } + onDisconnect(callback: EventCallback): void { this.disconnectCallback = callback; } @@ -130,6 +167,10 @@ export class EventRouter { this.signDataRequestCallback = undefined; } + removeSignMessageRequestCallback(): void { + this.signMessageRequestCallback = undefined; + } + removeDisconnectCallback(): void { this.disconnectCallback = undefined; } @@ -145,6 +186,7 @@ export class EventRouter { this.connectRequestCallback = undefined; this.transactionRequestCallback = undefined; this.signDataRequestCallback = undefined; + this.signMessageRequestCallback = undefined; this.disconnectCallback = undefined; this.errorCallback = undefined; } @@ -169,6 +211,14 @@ export class EventRouter { this.sessionManager, this.analyticsManager, ), + new SignMessageHandler( + this.notifySignMessageRequestCallbacks.bind(this), + this.config, + this.eventEmitter, + this.walletManager, + this.sessionManager, + this.analyticsManager, + ), new DisconnectHandler(this.notifyDisconnectCallbacks.bind(this), this.sessionManager), ]; } @@ -194,6 +244,13 @@ export class EventRouter { return await this.signDataRequestCallback?.(event); } + /** + * Notify sign message request callbacks + */ + private async notifySignMessageRequestCallbacks(event: SignMessageRequestEvent): Promise { + return await this.signMessageRequestCallback?.(event); + } + /** * Notify disconnect callbacks */ @@ -223,10 +280,17 @@ export class EventRouter { if (this.signDataRequestCallback) { enabledTypes.push('signData'); } + if (this.signMessageRequestCallback) { + enabledTypes.push('signMessage'); + } if (this.disconnectCallback) { enabledTypes.push('disconnect'); } + // Intent draft and connectWithIntent events are always enabled — they are routed + // via eventEmitter and do not require a registered callback to be processed. + enabledTypes.push('txDraft', 'signMsgDraft', 'actionDraft', 'connectWithIntent'); + return enabledTypes; } } diff --git a/packages/walletkit/src/core/EventStore.ts b/packages/walletkit/src/core/EventStore.ts index c66b4c6fd..e0d88d2a5 100644 --- a/packages/walletkit/src/core/EventStore.ts +++ b/packages/walletkit/src/core/EventStore.ts @@ -398,10 +398,20 @@ export class StorageEventStore implements EventStore { return 'sendTransaction'; case 'signData': return 'signData'; + case 'signMessage': + return 'signMessage'; case 'disconnect': return 'disconnect'; case 'restoreConnection': return 'restoreConnection'; + case 'txDraft': + return 'txDraft'; + case 'signMsgDraft': + return 'signMsgDraft'; + case 'actionDraft': + return 'actionDraft'; + case 'connectWithIntent': + return 'connectWithIntent'; default: throw new Error(`Unknown event method: ${method}`); } diff --git a/packages/walletkit/src/core/RequestProcessor.ts b/packages/walletkit/src/core/RequestProcessor.ts index 3242061c1..05dc8112d 100644 --- a/packages/walletkit/src/core/RequestProcessor.ts +++ b/packages/walletkit/src/core/RequestProcessor.ts @@ -42,14 +42,17 @@ import type { SignDataPayload, SendTransactionRequestEvent, SignDataRequestEvent, + SignMessageRequestEvent, ConnectionRequestEvent, SendTransactionApprovalResponse, SignDataApprovalResponse, + SignMessageApprovalResponse, Base64String, ConnectionApprovalResponse, ConnectionApprovalProof, } from '../api/models'; import { PrepareSignData } from '../utils/signData/sign'; +import { validateBOC } from '../validation/transaction'; import type { Wallet } from '../api/interfaces'; import type { Analytics, AnalyticsManager } from '../analytics'; @@ -360,6 +363,72 @@ export class RequestProcessor { } } + /** + * Process sign-message (sign-only transaction) request approval. + * Signs using the internal opcode and returns the BoC without broadcasting. + */ + async approveSignMessageRequest( + event: SignMessageRequestEvent, + response?: SignMessageApprovalResponse, + ): Promise { + try { + if (response) { + const bocValidation = validateBOC(response.internalBoc); + if (!bocValidation.isValid) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Invalid internalBoc: ${bocValidation.errors.join(', ')}`, + ); + } + const tonConnectResponse = { + result: { internal_boc: response.internalBoc }, + id: event.id || '', + }; + await this.bridgeManager.sendResponse(event, tonConnectResponse); + return response; + } else { + const wallet = this.getWalletFromEvent(event); + if (!wallet) { + throw new WalletKitError( + ERROR_CODES.WALLET_NOT_FOUND, + 'Wallet not found for sign message signing', + undefined, + { eventId: event.id }, + ); + } + const internalBoc = await wallet.getSignedSendTransaction(event.request, { internal: true }); + const tonConnectResponse = { + result: { internal_boc: internalBoc }, + id: event.id || '', + }; + await this.bridgeManager.sendResponse(event, tonConnectResponse); + return { internalBoc }; + } + } catch (error) { + log.error('Failed to approve sign message request', { error }); + throw error; + } + } + + /** + * Process sign-message request rejection + */ + async rejectSignMessageRequest(event: SignMessageRequestEvent, reason?: string): Promise { + try { + const response = { + error: { + code: SEND_TRANSACTION_ERROR_CODES.USER_REJECTS_ERROR, + message: reason || 'User rejected sign message request', + }, + id: event.id, + }; + await this.bridgeManager.sendResponse(event, response); + } catch (error) { + log.error('Failed to reject sign message request', { error }); + throw error; + } + } + /** * Process sign data request approval */ diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index dad6a2fa4..8fd27a6a4 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -53,6 +53,8 @@ import type { NetworkManager } from './NetworkManager'; import { KitNetworkManager } from './NetworkManager'; import type { WalletId } from '../utils/walletId'; import type { StreamingAPI, Wallet, WalletAdapter } from '../api/interfaces'; +import { IntentHandler } from '../handlers/IntentHandler'; +import { isIntentUrl } from '../handlers/IntentParser'; import type { Network, TransactionRequest, @@ -61,11 +63,23 @@ import type { RequestErrorEvent, DisconnectionEvent, SignDataRequestEvent, + SignMessageRequestEvent, ConnectionRequestEvent, SendTransactionApprovalResponse, SignDataApprovalResponse, + SignMessageApprovalResponse, TONConnectSession, ConnectionApprovalResponse, + IntentRequestEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentActionItem, + BatchedIntentEvent, + ConnectionApprovalProof, } from '../api/models'; import { asAddressFriendly } from '../utils'; import type { ProviderFactoryContext } from '../types/factory'; @@ -98,6 +112,7 @@ export class TonWalletKit implements ITonWalletKit { private initializer: Initializer; private eventProcessor!: StorageEventProcessor; private bridgeManager!: BridgeManager; + private intentHandler!: IntentHandler; private config: TonWalletKitOptions; @@ -141,6 +156,35 @@ export class TonWalletKit implements ITonWalletKit { // Initialize StakingManager this.stakingManager = new StakingManager(() => this.createFactoryContext()); + this.eventEmitter.on('bridge-draft-intent', async ({ payload: event }) => { + const walletId = event.walletId; + if (!walletId) { + log.error('bridge-draft-intent received without walletId', { eventId: event.id }); + return; + } + await this.ensureInitialized(); + if (this.intentHandler) { + await this.intentHandler.handleBridgeDraftEvent(event, walletId); + } + }); + + this.eventEmitter.on('bridge-connect-with-intent', async ({ payload: data }) => { + await this.ensureInitialized(); + const walletId = data.walletId ?? this.walletManager.getWallets()[0]?.getWalletId(); + if (!walletId) { + log.error('bridge-connect-with-intent received without walletId'); + return; + } + if (this.intentHandler) { + await this.intentHandler.handleIntentUrl(data.intentUrl, walletId, { + isJsBridge: true, + tabId: data.tabId, + messageId: data.messageId, + connectRequest: data.connectRequest, + }); + } + }); + this.eventEmitter.on('restoreConnection', async ({ payload: event }) => { if (!event.domain) { log.error('Domain is required for restore connection'); @@ -268,6 +312,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 +572,20 @@ export class TonWalletKit implements ITonWalletKit { this.eventRouter.removeSignDataRequestCallback(); } + onSignMessageRequest(cb: (event: SignMessageRequestEvent) => void): void { + if (this.eventRouter) { + this.eventRouter.onSignMessageRequest(cb); + } else { + this.ensureInitialized().then(() => { + this.eventRouter.onSignMessageRequest(cb); + }); + } + } + + removeSignMessageRequestCallback(): void { + this.eventRouter.removeSignMessageRequestCallback(); + } + removeDisconnectCallback(): void { this.eventRouter.removeDisconnectCallback(); } @@ -540,6 +604,94 @@ export class TonWalletKit implements ITonWalletKit { this.eventRouter.removeErrorCallback(); } + // === Intent API === + + isIntentUrl(url: string): boolean { + return isIntentUrl(url); + } + + async handleIntentUrl( + url: string, + walletId: string, + jsBridgeContext?: { isJsBridge: boolean; tabId?: string; messageId?: string; connectRequest?: unknown }, + ): Promise { + await this.ensureInitialized(); + return this.intentHandler.handleIntentUrl( + url, + walletId, + jsBridgeContext as Parameters[2], + ); + } + + onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void { + if (this.intentHandler) { + this.intentHandler.onIntentRequest(cb); + } else { + this.ensureInitialized().then(() => this.intentHandler.onIntentRequest(cb)); + } + } + + removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void { + if (this.intentHandler) { + this.intentHandler.removeIntentRequestCallback(cb); + } else { + this.ensureInitialized().then(() => this.intentHandler.removeIntentRequestCallback(cb)); + } + } + + async approveTransactionDraft( + event: TransactionIntentRequestEvent, + walletId: string, + ): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveTransactionDraft(event, walletId); + } + + async approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveSignDataIntent(event, walletId); + } + + async approveActionDraft( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise { + await this.ensureInitialized(); + return this.intentHandler.approveActionDraft(event, walletId); + } + + async approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + proof?: ConnectionApprovalProof, + ): Promise { + await this.ensureInitialized(); + + const result = await this.intentHandler.approveBatchedIntent(batch, walletId); + + for (const item of batch.intents) { + if (item.type === 'connect') { + await this.requestProcessor.approveConnectRequest({ ...item, walletId }, proof ? { proof } : undefined); + } + } + + return result; + } + + 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 Parsing API === /** @@ -568,6 +720,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 }, + ); + } + const bridgeEvent = this.parseBridgeConnectEventFromUrl(url); await this.eventRouter.routeEvent(bridgeEvent); } catch (error) { @@ -739,6 +901,19 @@ export class TonWalletKit implements ITonWalletKit { return this.requestProcessor.rejectSignDataRequest(event, reason); } + async approveSignMessageRequest( + event: SignMessageRequestEvent, + response?: SignMessageApprovalResponse, + ): Promise { + await this.ensureInitialized(); + return this.requestProcessor.approveSignMessageRequest(event, response); + } + + async rejectSignMessageRequest(event: SignMessageRequestEvent, reason?: string): Promise { + await this.ensureInitialized(); + return this.requestProcessor.rejectSignMessageRequest(event, reason); + } + // === TON Client Access === /** diff --git a/packages/walletkit/src/handlers/IntentHandler.spec.ts b/packages/walletkit/src/handlers/IntentHandler.spec.ts new file mode 100644 index 000000000..50e04b69d --- /dev/null +++ b/packages/walletkit/src/handlers/IntentHandler.spec.ts @@ -0,0 +1,534 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { IntentHandler } from './IntentHandler'; +import type { BridgeManager } from '../core/BridgeManager'; +import type { WalletManager } from '../core/WalletManager'; +import type { Wallet } from '../api/interfaces'; +import type { TonWalletKitOptions } from '../types'; +import type { + IntentRequestEvent, + 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); + }); + + // ── approveTransactionDraft ───────────────────────────────────────────── + + describe('approveTransactionDraft', () => { + /** Helper to build an event with resolvedTransaction so IntentResolver is bypassed. */ + function txEvent(overrides: Partial = {}): TransactionIntentRequestEvent { + return { + type: 'transaction', + id: 'tx-1', + origin: 'deepLink', + clientId: 'client-1', + deliveryMode: 'send', + items: [{ type: 'sendTon' as const, address: 'EQAddr', amount: '1000000000' }], + resolvedTransaction: { + messages: [{ address: 'EQAddr', amount: '1000000000' }], + fromAddress: 'UQTestAddr', + }, + ...overrides, + }; + } + + it('signs and sends a transaction, returns boc', async () => { + const result = await handler.approveTransactionDraft(txEvent(), 'wallet-1'); + + expect(result.boc).toBe('signed-boc-base64'); + expect(mockWallet.getSignedSendTransaction).toHaveBeenCalled(); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).toHaveBeenCalledWith('signed-boc-base64'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('does not send boc when deliveryMode is signOnly', async () => { + const result = await handler.approveTransactionDraft( + txEvent({ id: 'tx-2', deliveryMode: 'signOnly' }), + 'wallet-1', + ); + + expect(result.boc).toBe('signed-boc-base64'); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).not.toHaveBeenCalled(); + }); + + it('does not send boc when dev.disableNetworkSend is true', async () => { + const devHandler = new IntentHandler( + { ...defaultOptions, dev: { disableNetworkSend: true } }, + bridgeManager, + walletManager, + ); + + await devHandler.approveTransactionDraft(txEvent({ id: 'tx-3' }), 'wallet-1'); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).not.toHaveBeenCalled(); + }); + + it('skips bridge send when clientId is absent', async () => { + await handler.approveTransactionDraft(txEvent({ id: 'tx-4', clientId: '' }), 'wallet-1'); + expect(bridgeManager.sendIntentResponse).not.toHaveBeenCalled(); + }); + }); + + // ── approveSignDataIntent ──────────────────────────────────────────────── + + describe('approveSignDataIntent', () => { + const signPayload: SignDataPayload = { + data: { type: 'text', value: { content: 'Sign this' } }, + }; + + it('signs data and returns result', async () => { + const event: SignDataIntentRequestEvent = { + type: 'signData', + id: 'sd-1', + origin: 'deepLink', + clientId: 'client-1', + manifestUrl: 'https://example.com/manifest.json', + payload: signPayload, + }; + + const result = await handler.approveSignDataIntent(event, 'wallet-1'); + + expect(result.signature).toBeDefined(); + expect(result.address).toBe(VALID_TON_ADDRESS); + expect(result.timestamp).toBeDefined(); + expect(result.domain).toBe('example.com'); + expect(mockWallet.getSignedSignData).toHaveBeenCalled(); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('falls back to raw manifestUrl for domain on invalid URL', async () => { + const event: SignDataIntentRequestEvent = { + type: 'signData', + id: 'sd-2', + origin: 'deepLink', + clientId: 'client-1', + manifestUrl: 'not-a-valid-url', + payload: signPayload, + }; + + const result = await handler.approveSignDataIntent(event, 'wallet-1'); + expect(result.domain).toBe('not-a-valid-url'); + }); + }); + + // ── rejectIntent ───────────────────────────────────────────────────────── + + describe('rejectIntent', () => { + it('sends error response with user declined code by default', async () => { + const event: IntentRequestEvent = { + type: 'transaction', + id: 'tx-r1', + origin: 'deepLink', + clientId: 'client-1', + deliveryMode: 'send', + items: [], + }; + + const result = await handler.rejectIntent(event); + + expect(result.error.code).toBe(300); // USER_DECLINED + expect(result.error.message).toBe('User declined the request'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('uses custom reason and error code', async () => { + const event: IntentRequestEvent = { + type: 'signData', + id: 'sd-r1', + origin: 'deepLink', + clientId: 'client-1', + manifestUrl: 'https://example.com', + payload: { data: { type: 'text', value: { content: 'test' } } }, + }; + + const result = await handler.rejectIntent(event, 'Not supported', 400); + + expect(result.error.code).toBe(400); + expect(result.error.message).toBe('Not supported'); + }); + + it('emits batch with connect item for single-item intent with connect', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + const url = buildInlineUrl( + 'c1', + { + id: 'tx-pcr', + method: 'txDraft', + params: { i: [{ t: 'ton', a: 'EQAddr', am: '100' }] }, + }, + { connectRequest: { manifestUrl: 'https://dapp.com/m.json', items: [{ name: 'ton_addr' }] } }, + ); + await handler.handleIntentUrl(url, 'wallet-1'); + + // Should be a batch because of connect + expect(emitted).toBeDefined(); + expect('intents' in emitted!).toBe(true); + const batch = emitted as BatchedIntentEvent; + expect(batch.intents[0].type).toBe('connect'); + expect(batch.intents[1].type).toBe('transaction'); + }); + }); + + // ── handleIntentUrl batching ──────────────────────────────────────────── + + describe('handleIntentUrl batching', () => { + it('emits BatchedIntentEvent for multi-item txDraft', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + const url = buildInlineUrl('c-batch', { + id: 'tx-batch', + method: 'txDraft', + params: { + i: [ + { t: 'ton', a: 'EQAddr1', am: '100' }, + { t: 'ton', a: 'EQAddr2', am: '200' }, + ], + }, + }); + + await handler.handleIntentUrl(url, 'wallet-1'); + + expect(emitted).toBeDefined(); + // BatchedIntentEvent has `intents` array + expect('intents' in emitted!).toBe(true); + + const batch = emitted as BatchedIntentEvent; + expect(batch.id).toBe('tx-batch'); + expect(batch.origin).toBe('deepLink'); + expect(batch.clientId).toBe('c-batch'); + expect(batch.intents).toHaveLength(2); + + // Each inner event is a transaction with one item + expect(batch.intents[0].type).toBe('transaction'); + expect(batch.intents[0].id).toBe('tx-batch_0'); + expect((batch.intents[0] as TransactionIntentRequestEvent).items).toHaveLength(1); + + expect(batch.intents[1].type).toBe('transaction'); + expect(batch.intents[1].id).toBe('tx-batch_1'); + expect((batch.intents[1] as TransactionIntentRequestEvent).items).toHaveLength(1); + }); + + it('emits regular IntentRequestEvent for single-item txDraft', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + const url = buildInlineUrl('c-single', { + id: 'tx-single', + method: 'txDraft', + params: { i: [{ t: 'ton', a: 'EQAddr1', am: '100' }] }, + }); + + await handler.handleIntentUrl(url, 'wallet-1'); + + expect(emitted).toBeDefined(); + // Regular event does NOT have `intents` + expect('intents' in emitted!).toBe(false); + expect((emitted as IntentRequestEvent).type).toBe('transaction'); + }); + + it('emits connect as first item in batch when connect request present', async () => { + let emitted: IntentRequestEvent | BatchedIntentEvent | undefined; + handler.onIntentRequest((e) => { + emitted = e; + }); + + const url = buildInlineUrl( + 'c-conn', + { + id: 'tx-conn', + method: 'txDraft', + params: { + i: [ + { t: 'ton', a: 'EQAddr1', am: '100' }, + { t: 'ton', a: 'EQAddr2', am: '200' }, + ], + }, + }, + { connectRequest: { manifestUrl: 'https://dapp.com/m.json', items: [{ name: 'ton_addr' }] } }, + ); + + await handler.handleIntentUrl(url, 'wallet-1'); + + const batch = emitted as BatchedIntentEvent; + // Connect is the first item + expect(batch.intents[0].type).toBe('connect'); + // Followed by transaction items + expect(batch.intents[1].type).toBe('transaction'); + expect(batch.intents[2].type).toBe('transaction'); + expect(batch.intents).toHaveLength(3); + }); + }); + + // ── approveBatchedIntent ──────────────────────────────────────────────── + + describe('approveBatchedIntent', () => { + function makeBatch(overrides: Partial = {}): BatchedIntentEvent { + return { + type: 'batched', + id: 'batch-1', + origin: 'deepLink', + clientId: 'client-b', + intents: [ + { + type: 'transaction' as const, + id: 'batch-1_0', + origin: 'deepLink' as const, + clientId: 'client-b', + deliveryMode: 'send' as const, + items: [{ type: 'sendTon' as const, address: 'EQAddr1', amount: '100' }], + }, + { + type: 'transaction' as const, + id: 'batch-1_1', + origin: 'deepLink' as const, + clientId: 'client-b', + deliveryMode: 'send' as const, + items: [{ type: 'sendTon' as const, address: 'EQAddr2', amount: '200' }], + }, + ], + ...overrides, + }; + } + + it('signs and sends a combined transaction', async () => { + const result = await handler.approveBatchedIntent(makeBatch(), 'wallet-1'); + + expect(result.boc).toBe('signed-boc-base64'); + expect(mockWallet.getSignedSendTransaction).toHaveBeenCalledTimes(1); + expect( + (mockWallet.getClient() as unknown as { sendBoc: ReturnType }).sendBoc, + ).toHaveBeenCalledWith('signed-boc-base64'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('uses signOnly when any inner event has signOnly delivery', async () => { + const batch = makeBatch(); + (batch.intents[1] as 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' as const, + id: 'sd-1', + origin: 'deepLink' as const, + clientId: 'client-b', + manifestUrl: 'https://example.com', + payload: { data: { type: 'text', value: { content: 'x' } } }, + }, + ], + }); + + const result = await handler.approveBatchedIntent(signDataBatch, 'wallet-1'); + expect('signature' in result).toBe(true); + expect(mockWallet.getSignedSignData).toHaveBeenCalled(); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('throws when batch contains no transaction or signData items', async () => { + const emptyBatch = makeBatch({ + intents: [ + { + type: 'connect' as const, + id: 'c-1', + from: 'client-b', + requestedItems: [], + preview: { permissions: [] }, + } as unknown as IntentRequestEvent, + ], + }); + + await expect(handler.approveBatchedIntent(emptyBatch, 'wallet-1')).rejects.toThrow( + 'Batched intent contains no actionable items', + ); + }); + + it('skips bridge send when batch has no clientId', async () => { + const batch = makeBatch({ clientId: undefined }); + await handler.approveBatchedIntent(batch, 'wallet-1'); + expect(bridgeManager.sendIntentResponse).not.toHaveBeenCalled(); + }); + }); + + // ── rejectIntent (batched) ────────────────────────────────────────────── + + describe('rejectIntent (batched)', () => { + function makeBatch(): BatchedIntentEvent { + return { + type: 'batched', + id: 'batch-r', + origin: 'deepLink', + clientId: 'client-br', + intents: [ + { + type: 'transaction' as const, + id: 'batch-r_0', + origin: 'deepLink' as const, + clientId: 'client-br', + deliveryMode: 'send' as const, + items: [{ type: 'sendTon' as const, address: 'EQ1', amount: '100' }], + }, + ], + }; + } + + it('rejects a batched intent with default error', async () => { + const result = await handler.rejectIntent(makeBatch()); + + expect(result.error.code).toBe(300); + expect(result.error.message).toBe('User declined the request'); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + + it('rejects a batched intent with custom reason', async () => { + const result = await handler.rejectIntent(makeBatch(), 'Batch rejected', 500); + + expect(result.error.code).toBe(500); + expect(result.error.message).toBe('Batch rejected'); + }); + + it('rejects a batch that includes a connect item', async () => { + const batch: BatchedIntentEvent = { + type: 'batched', + id: 'batch-pcr', + origin: 'deepLink', + clientId: 'cr', + intents: [ + { + type: 'connect' as const, + id: 'batch-pcr', + from: 'cr', + requestedItems: [], + preview: { permissions: [] }, + }, + ], + }; + const result = await handler.rejectIntent(batch); + expect(result.error.code).toBe(300); + expect(bridgeManager.sendIntentResponse).toHaveBeenCalled(); + }); + }); + + // ── getWallet error ────────────────────────────────────────────────────── + + describe('wallet not found', () => { + it('throws when wallet is not found', async () => { + const noWalletManager = { + getWallet: vi.fn().mockReturnValue(undefined), + } as unknown as WalletManager; + const h = new IntentHandler(defaultOptions, bridgeManager, noWalletManager); + + const event: TransactionIntentRequestEvent = { + type: 'transaction', + id: 'tx-nw', + origin: 'deepLink', + clientId: 'c1', + deliveryMode: 'send', + items: [{ type: 'sendTon' as const, address: 'EQ1', amount: '100' }], + resolvedTransaction: { + messages: [{ address: 'EQ1', amount: '100' }], + fromAddress: 'UQ1', + }, + }; + + await expect(h.approveTransactionDraft(event, 'missing-wallet')).rejects.toThrow('Wallet not found'); + }); + }); +}); + +/** + * Helper: Build a tc://?m=intent URL from a spec-format request object. + */ +function buildInlineUrl( + clientId: string, + request: Record, + opts?: { traceId?: string; connectRequest?: Record }, +): string { + const json = JSON.stringify(request); + const b64 = Buffer.from(json, 'utf-8').toString('base64url'); + let url = `tc://?m=intent&id=${clientId}&mp=${b64}`; + if (opts?.traceId) url += `&trace_id=${opts.traceId}`; + if (opts?.connectRequest) url += `&r=${encodeURIComponent(JSON.stringify(opts.connectRequest))}`; + return url; +} diff --git a/packages/walletkit/src/handlers/IntentHandler.ts b/packages/walletkit/src/handlers/IntentHandler.ts new file mode 100644 index 000000000..14f86a9c3 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentHandler.ts @@ -0,0 +1,622 @@ +/** + * 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, isIntentUrl } from './IntentParser'; +import { IntentResolver } from './IntentResolver'; +import { ConnectHandler } from './ConnectHandler'; +import type { BridgeManager } from '../core/BridgeManager'; +import type { WalletManager } from '../core/WalletManager'; +import type { Wallet } from '../api/interfaces'; +import type { RawBridgeEvent, RawBridgeEventConnect } from '../types/internal'; +import type { AnalyticsManager } from '../analytics'; +import type { + IntentRequestEvent, + IntentRequestBase, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentResponseResult, + IntentActionItem, + BatchedIntentEvent, + BridgeEvent, + ConnectionRequestEvent, + TransactionRequest, + SignDataPayload, + Base64String, + UserFriendlyAddress, +} from '../api/models'; +import type { TonWalletKitOptions } from '../types'; + +const log = globalLogger.createChild('IntentHandler'); + +type IntentCallback = (event: IntentRequestEvent | BatchedIntentEvent) => void; + +/** + * Orchestrates intent processing: parse → resolve → emulate → emit. + * + * Delegates URL parsing to IntentParser, item resolution to IntentResolver, + * and reuses existing wallet signing/sending utilities for approval. + */ +export class IntentHandler { + private parser = new IntentParser(); + private resolver = new IntentResolver(); + private callbacks: IntentCallback[] = []; + + constructor( + private walletKitOptions: TonWalletKitOptions, + private bridgeManager: BridgeManager, + private walletManager: WalletManager, + private analyticsManager?: AnalyticsManager, + ) {} + + // -- Public: Parsing ------------------------------------------------------ + + isIntentUrl(url: string): boolean { + return 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, + jsBridgeContext?: { + isJsBridge: boolean; + tabId?: string; + messageId?: string; + connectRequest?: ConnectRequest; + }, + ): Promise { + const { event, connectRequest: urlConnectRequest } = await this.parser.parse(url); + + // parser.parse() never returns connect events + if (event.type === 'connect') return; + + // JS Bridge context overrides parsed origin and attaches routing fields + if (jsBridgeContext) { + event.isJsBridge = true; + event.tabId = jsBridgeContext.tabId; + event.messageId = jsBridgeContext.messageId; + event.origin = 'jsBridge'; + } + + // JS bridge connectRequest overrides the one embedded in the URL (if any) + const connectRequest = jsBridgeContext?.connectRequest ?? urlConnectRequest; + + // Resolve connect request into a ConnectIntentRequestEvent if present + let connectItem: IntentRequestEvent | undefined; + if (connectRequest) { + const connectionEvent = await this.resolveConnectRequest(connectRequest, event); + connectItem = { ...connectionEvent, type: 'connect' as const }; + } + + if (event.type === 'transaction') { + if (connectItem || event.items.length > 1) { + // Batch when there's a connect or multiple tx items + await this.resolveAndEmitBatchedTransaction(event, walletId, connectItem); + } else { + await this.resolveAndEmitTransaction(event, walletId); + } + } else { + if (connectItem) { + // Batch: connect + single non-tx intent + const batch: BatchedIntentEvent = { + type: 'batched', + id: event.id, + origin: event.origin, + clientId: event.clientId, + traceId: event.traceId, + returnStrategy: event.returnStrategy, + isJsBridge: event.isJsBridge, + tabId: event.tabId, + messageId: event.messageId, + intents: [connectItem, event], + }; + this.emit(batch); + } else { + this.emit(event); + } + } + } + + /** + * Parse and emit a draft intent event received via the existing bridge session. + * Called when txDraft/signMsgDraft/actionDraft arrives while already connected. + */ + async handleBridgeDraftEvent(rawEvent: RawBridgeEvent, walletId: string): Promise { + try { + const event = this.parser.parseBridgeDraftPayload(rawEvent); + + if (event.type === 'connect') return; + + if (event.type === 'transaction') { + await this.resolveAndEmitTransaction(event, walletId); + } else { + this.emit(event); + } + } catch (error) { + log.error('Failed to handle bridge draft event', { error, eventId: rawEvent.id }); + } + } + + // -- Public: Callbacks ---------------------------------------------------- + + onIntentRequest(callback: IntentCallback): void { + if (!this.callbacks.includes(callback)) { + this.callbacks.push(callback); + } + } + + removeIntentRequestCallback(callback: IntentCallback): void { + this.callbacks = this.callbacks.filter((cb) => cb !== callback); + } + + // -- Public: Approval ----------------------------------------------------- + + async approveTransactionDraft( + event: TransactionIntentRequestEvent, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + const result = await this.signAndSendTransaction(event, wallet); + await this.sendResponse(event, result); + return result; + } + + /** + * Approve a batched intent event. + * + * Finds the first actionable intent (transaction > signData > action), + * delegates to the corresponding single-item approval method, and + * sends one response back using the batch's identity. + */ + async approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + + // Collect all items from inner transaction events + const allItems: IntentActionItem[] = []; + let deliveryMode: 'send' | 'signOnly' = 'send'; + for (const intent of batch.intents) { + if (intent.type === 'transaction') { + allItems.push(...intent.items); + if (intent.deliveryMode === 'signOnly') { + deliveryMode = 'signOnly'; + } + } + } + + if (allItems.length > 0) { + const firstTx = batch.intents.find((i) => i.type === 'transaction'); + const combinedEvent: TransactionIntentRequestEvent = { + type: 'transaction', + id: batch.id, + origin: batch.origin, + clientId: batch.clientId, + traceId: batch.traceId, + returnStrategy: batch.returnStrategy, + deliveryMode, + network: firstTx?.type === 'transaction' ? firstTx.network : undefined, + validUntil: firstTx?.type === 'transaction' ? firstTx.validUntil : undefined, + items: allItems, + }; + const result = await this.signAndSendTransaction(combinedEvent, wallet); + await this.sendBatchResponse(batch, result, deliveryMode); + return result; + } + + const signDataIntent = batch.intents.find((i) => i.type === 'signData'); + if (signDataIntent?.type === 'signData') { + const result = await this.signSignData(signDataIntent, wallet); + await this.sendBatchResponse(batch, result); + return result; + } + + const actionIntent = batch.intents.find((i) => i.type === 'action'); + if (actionIntent?.type === 'action') { + const { result, resolvedEvent } = await this.resolveAndApproveAction(actionIntent, wallet); + const actionDeliveryMode = resolvedEvent.type === 'transaction' ? resolvedEvent.deliveryMode : undefined; + await this.sendBatchResponse(batch, result, actionDeliveryMode); + return result; + } + + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Batched intent contains no actionable items'); + } + + async approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise { + const wallet = this.getWallet(walletId); + const result = await this.signSignData(event, wallet); + await this.sendResponse(event, result); + return result; + } + + async approveActionDraft( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + const { result, resolvedEvent } = await this.resolveAndApproveAction(event, wallet); + await this.sendResponse(resolvedEvent, result); + return result; + } + + // -- Public: Rejection ---------------------------------------------------- + + async rejectIntent( + event: IntentRequestEvent | BatchedIntentEvent, + reason?: string, + errorCode?: number, + ): Promise { + const result: IntentErrorResponse = { + type: 'error', + error: { + code: errorCode ?? INTENT_ERROR_CODES.USER_DECLINED, + message: reason || 'User declined the request', + }, + }; + + const isBatched = 'intents' in event; + if (isBatched) { + await this.sendBatchResponse(event, result); + } else if (event.type !== 'connect') { + await this.sendResponse(event, result); + } + return result; + } + + // -- Public: Utilities ---------------------------------------------------- + + async intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise { + const wallet = this.getWallet(walletId); + return this.resolver.intentItemsToTransactionRequest(items, wallet); + } + + // -- Private: Resolution & Emulation -------------------------------------- + + private async resolveAndEmitTransaction( + event: Extract, + walletId: string, + ): Promise { + const wallet = this.getWallet(walletId); + + const transactionRequest = await this.resolveTransaction(event, wallet); + event.resolvedTransaction = transactionRequest; + + try { + const preview = await wallet.getTransactionPreview(transactionRequest); + event.preview = preview; + } catch (error) { + log.warn('Failed to emulate transaction preview', { error }); + event.preview = undefined; + } + + this.emit(event); + } + + /** + * Resolve a `ConnectRequest` (manifestUrl + items) into a full + * `ConnectionRequestEvent` by fetching the manifest. + */ + private async resolveConnectRequest( + connectRequest: ConnectRequest, + event: Exclude, + ): Promise { + const bridgeEvent: RawBridgeEventConnect = { + from: event.clientId || '', + id: event.id, + method: 'connect', + params: { + manifest: { url: connectRequest.manifestUrl }, + items: connectRequest.items, + }, + timestamp: Date.now(), + domain: '', + }; + + const connectHandler = new ConnectHandler(() => {}, this.walletKitOptions, this.analyticsManager); + return connectHandler.handle(bridgeEvent); + } + + /** + * Split a multi-item transaction intent into per-item events, + * resolve and emulate each, then emit as a {@link BatchedIntentEvent}. + * + * If `connectItem` is provided it is prepended to the batch so the + * wallet can display the connect alongside the transaction items. + */ + private async resolveAndEmitBatchedTransaction( + event: Extract, + walletId: string, + connectItem?: IntentRequestEvent, + ): Promise { + const wallet = this.getWallet(walletId); + + const itemEvents: TransactionIntentRequestEvent[] = event.items.map((item, i) => ({ + type: 'transaction', + id: `${event.id}_${i}`, + origin: event.origin, + clientId: event.clientId, + traceId: event.traceId, + returnStrategy: event.returnStrategy, + deliveryMode: event.deliveryMode, + network: event.network, + validUntil: event.validUntil, + items: [item], + })); + + await Promise.all( + itemEvents.map(async (itemEvent, i) => { + 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 }); + } + }), + ); + + const perItemEvents: IntentRequestEvent[] = itemEvents; + + const intents: IntentRequestEvent[] = []; + if (connectItem) intents.push(connectItem); + intents.push(...perItemEvents); + + const batch: BatchedIntentEvent = { + type: 'batched', + id: event.id, + origin: event.origin, + clientId: event.clientId, + traceId: event.traceId, + returnStrategy: event.returnStrategy, + isJsBridge: event.isJsBridge, + tabId: event.tabId, + messageId: event.messageId, + intents, + }; + + this.emit(batch); + } + + private async resolveTransaction( + event: TransactionIntentRequestEvent, + wallet: Wallet, + ): Promise { + return this.resolver.intentItemsToTransactionRequest(event.items, wallet, event.network, event.validUntil); + } + + private async signAndSendTransaction( + event: TransactionIntentRequestEvent, + wallet: Wallet, + ): Promise { + const transactionRequest = event.resolvedTransaction ?? (await this.resolveTransaction(event, wallet)); + const signedBoc = await wallet.getSignedSendTransaction(transactionRequest, { + internal: event.deliveryMode === 'signOnly', + }); + + if (event.deliveryMode === 'send' && !this.walletKitOptions.dev?.disableNetworkSend) { + await CallForSuccess( + () => wallet.getClient().sendBoc(signedBoc), + 20, + 100, + (error) => { + // Do not retry on HTTP 4xx/5xx — those are definitive rejections + if (error instanceof Error && /HTTP [45]\d\d/.test(error.message)) return false; + return true; + }, + ); + } + + return { type: 'transaction', boc: signedBoc as Base64String }; + } + + private async signSignData(event: SignDataIntentRequestEvent, wallet: Wallet): Promise { + let domain = event.manifestUrl; + try { + domain = new URL(event.manifestUrl).host; + } catch { + // use as-is + } + + const signData = PrepareSignData({ + payload: event.payload, + domain, + address: wallet.getAddress(), + }); + + const signature = await wallet.getSignedSignData(signData); + return { + type: 'signData', + signature: HexToBase64(signature) as Base64String, + address: wallet.getAddress() as UserFriendlyAddress, + timestamp: signData.timestamp, + domain: signData.domain, + payload: event.payload, + }; + } + + private async resolveAndApproveAction( + event: ActionIntentRequestEvent, + wallet: Wallet, + ): Promise<{ + result: IntentTransactionResponse | IntentSignDataResponse; + resolvedEvent: + | Extract + | Extract; + }> { + const actionResponse = await this.resolver.fetchActionUrl(event.actionUrl, wallet.getAddress()); + const resolvedEvent = this.parser.parseActionResponse(actionResponse, event); + + if (resolvedEvent.type === 'transaction') { + if (resolvedEvent.resolvedTransaction) { + resolvedEvent.resolvedTransaction.fromAddress = wallet.getAddress(); + } + const result = await this.signAndSendTransaction(resolvedEvent, wallet); + return { result, resolvedEvent }; + } + if (resolvedEvent.type === 'signData') { + const result = await this.signSignData(resolvedEvent, wallet); + return { result, resolvedEvent }; + } + + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Action URL resolved to unsupported type: ${resolvedEvent.type}`, + ); + } + + // -- Private: Response sending -------------------------------------------- + + private async sendResponse(event: IntentRequestBase, result: IntentResponseResult): Promise { + if (!event.clientId && !event.isJsBridge) { + log.debug('No clientId on intent event, skipping response send'); + return; + } + + const wireResponse = this.toWireResponse(event.id, result, event); + + // For intents from an existing bridge session or JS Bridge, route via sendResponse + // (which handles JS bridge and session-encrypted responses). + if (event.origin === 'connectedBridge' || event.isJsBridge) { + try { + await this.bridgeManager.sendResponse(event as BridgeEvent, wireResponse); + } catch (error) { + if ( + !event.isJsBridge && + error instanceof WalletKitError && + error.code === ERROR_CODES.SESSION_NOT_FOUND + ) { + // Session was deleted (e.g. wallet disconnected before reject) — nothing to notify. + log.debug('Session gone before intent response could be sent, ignoring', { eventId: event.id }); + } else { + throw error; + } + } + } else { + await this.bridgeManager.sendIntentResponse(event.clientId!, wireResponse, event.traceId); + } + } + + private async sendBatchResponse( + batch: BatchedIntentEvent, + result: IntentResponseResult, + deliveryMode?: 'send' | 'signOnly', + ): Promise { + if (!batch.clientId && !batch.isJsBridge) { + log.debug('No clientId on batched intent, skipping response send'); + return; + } + + const wireResponse = this.toWireResponse(batch.id, result, undefined, deliveryMode); + + if (batch.isJsBridge) { + await this.bridgeManager.sendResponse(batch, wireResponse); + } else { + await this.bridgeManager.sendIntentResponse(batch.clientId!, wireResponse, batch.traceId); + } + } + + /** + * Convert SDK response model to TonConnect wire format. + * - Transaction (send): `{ result: "", id }` + * - Transaction (signOnly/signMsgDraft): `{ result: { internal_boc: "" }, id }` + * - SignData: `{ result: { signature, address, timestamp, domain, payload }, id }` + * - Error: `{ error: { code, message }, id }` + */ + private toWireResponse( + eventId: string, + result: IntentResponseResult, + event?: IntentRequestBase, + deliveryMode?: 'send' | 'signOnly', + ): Record { + if (result.type === 'error') { + return { + error: { code: result.error.code, message: result.error.message }, + id: eventId, + }; + } + + if (result.type === 'transaction') { + const txEvent = event as Extract | undefined; + const isSignOnly = deliveryMode === 'signOnly' || txEvent?.deliveryMode === 'signOnly'; + if (isSignOnly) { + return { result: { internal_boc: result.boc }, id: eventId }; + } + return { result: result.boc, id: eventId }; + } + + return { + result: { + signature: result.signature, + address: result.address, + timestamp: result.timestamp, + domain: result.domain, + payload: this.signDataPayloadToWire(result.payload), + }, + id: eventId, + }; + } + + /** + * Convert SignDataPayload model back to wire format. + */ + private signDataPayloadToWire(payload: SignDataPayload): Record { + const { data } = payload; + switch (data.type) { + case 'text': + return { type: 'text', text: data.value.content }; + case 'binary': + return { type: 'binary', bytes: data.value.content }; + case 'cell': + return { type: 'cell', cell: data.value.content, schema: data.value.schema }; + } + } + + // -- Private: Helpers ----------------------------------------------------- + + private getWallet(walletId: string): Wallet { + const wallet = this.walletManager.getWallet(walletId); + if (!wallet) { + throw new WalletKitError( + ERROR_CODES.WALLET_NOT_FOUND, + 'Wallet not found for intent processing', + undefined, + { walletId }, + ); + } + return wallet; + } + + private emit(event: IntentRequestEvent | BatchedIntentEvent): void { + for (const callback of this.callbacks) { + try { + callback(event); + } catch (error) { + log.error('Intent callback error', { error }); + } + } + } +} diff --git a/packages/walletkit/src/handlers/IntentParser.spec.ts b/packages/walletkit/src/handlers/IntentParser.spec.ts new file mode 100644 index 000000000..10cb3f576 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentParser.spec.ts @@ -0,0 +1,748 @@ +/** + * 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, afterEach, vi } from 'vitest'; +import nacl from 'tweetnacl'; + +import { IntentParser, isIntentUrl } from './IntentParser'; + +/** + * Helper: Build a tc://?m=intent URL from a spec-format request object. + * Encodes the request as base64url in the `mp` parameter. + * Pass `connectRequest` to add it as the `r` URL parameter. + */ +function buildInlineUrl( + clientId: string, + request: Record, + opts?: { traceId?: string; connectRequest?: Record }, +): string { + const json = JSON.stringify(request); + const b64 = Buffer.from(json, 'utf-8').toString('base64url'); + let url = `tc://?m=intent&id=${clientId}&mp=${b64}`; + if (opts?.traceId) url += `&trace_id=${opts.traceId}`; + if (opts?.connectRequest) url += `&r=${encodeURIComponent(JSON.stringify(opts.connectRequest))}`; + return url; +} + +/** + * Helper: Build a tc://?m=intent_remote URL for object-storage intent tests. + */ +function buildObjectStorageUrl(clientId: string, privateKeyHex: string, getUrl: string): string { + return `tc://?m=intent_remote&id=${clientId}&pk=${privateKeyHex}&get_url=${encodeURIComponent(getUrl)}`; +} + +/** Convert a Uint8Array to a lowercase hex string. */ +const toHex = (b: Uint8Array) => + Array.from(b) + .map((x) => x.toString(16).padStart(2, '0')) + .join(''); + +/** + * Encrypt a payload using the SDK self-encryption scheme: + * nacl.box(payload, nonce, ownPublicKey, ownSecretKey) + * The result is nonce (24 bytes) || ciphertext, matching what decryptPayload expects. + */ +function encryptForSelf( + payload: Record, + kp: { publicKey: Uint8Array; secretKey: Uint8Array }, +): Uint8Array { + const nonce = nacl.randomBytes(24); + const ciphertext = nacl.box(new TextEncoder().encode(JSON.stringify(payload)), nonce, kp.publicKey, kp.secretKey); + const encrypted = new Uint8Array(nonce.length + ciphertext.length); + encrypted.set(nonce); + encrypted.set(ciphertext, nonce.length); + return encrypted; +} + +describe('IntentParser', () => { + let parser: IntentParser; + + beforeEach(() => { + parser = new IntentParser(); + }); + + // ── isIntentUrl ────────────────────────────────────────────────────────── + + describe('isIntentUrl', () => { + it('returns true for m=intent URLs', () => { + expect(isIntentUrl('tc://?m=intent&id=abc&mp=data')).toBe(true); + }); + + it('returns true for m=intent_remote URLs', () => { + expect(isIntentUrl('tc://?m=intent_remote&id=abc&pk=key&get_url=http://example.com')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(isIntentUrl('TC://?M=INTENT&id=abc')).toBe(true); + expect(isIntentUrl(' TC://?M=INTENT_REMOTE&id=abc ')).toBe(true); + }); + + it('accepts https universal link scheme with m=intent', () => { + const url = 'https://wallet.example.com/ton-connect?v=2&id=abc&m=intent&mp=data'; + expect(isIntentUrl(url)).toBe(true); + }); + + it('returns false for non-intent URLs', () => { + expect(isIntentUrl('https://example.com')).toBe(false); + expect(isIntentUrl('tc://?m=connect&id=abc')).toBe(false); + expect(isIntentUrl('')).toBe(false); + }); + }); + + // ── parse – inline txDraft ────────────────────────────────────────────── + + describe('parse – txDraft (inline)', () => { + it('parses a transaction intent with TON items', async () => { + const url = buildInlineUrl('client-123', { + id: 'tx-1', + method: 'txDraft', + params: { + vu: 1700000000, + n: '-239', + i: [ + { t: 'ton', a: 'EQAddr1', am: '1000000000' }, + { t: 'ton', a: 'EQAddr2', am: '2000000000', p: 'payload-b64' }, + ], + }, + }); + + const { event, connectRequest } = await parser.parse(url); + + expect(connectRequest).toBeUndefined(); + expect(event.type).toBe('transaction'); + + if (event.type !== 'transaction') throw new Error('unexpected'); + const tx = event; + + expect(tx.id).toBe('tx-1'); + expect(tx.origin).toBe('deepLink'); + expect(tx.clientId).toBe('client-123'); + expect(tx.deliveryMode).toBe('send'); + expect(tx.network).toEqual({ chainId: '-239' }); + expect(tx.validUntil).toBe(1700000000); + expect(tx.items).toHaveLength(2); + + expect(tx.items[0].type).toBe('sendTon'); + if (tx.items[0].type === 'sendTon') { + expect(tx.items[0].address).toBe('EQAddr1'); + expect(tx.items[0].amount).toBe('1000000000'); + } + + expect(tx.items[1].type).toBe('sendTon'); + if (tx.items[1].type === 'sendTon') { + expect(tx.items[1].payload).toBe('payload-b64'); + } + }); + + it('parses signMsgDraft as signOnly delivery mode', async () => { + const url = buildInlineUrl('c1', { + id: 'sm-1', + method: 'signMsgDraft', + params: { + i: [{ t: 'ton', a: 'EQ1', am: '100' }], + }, + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + expect(event.deliveryMode).toBe('signOnly'); + } + }); + + it('parses jetton items', async () => { + const url = buildInlineUrl('c1', { + id: 'j-1', + method: 'txDraft', + params: { + i: [ + { + t: 'jetton', + ma: 'EQJettonMaster', + ja: '5000000', + d: 'EQDest', + rd: 'EQResp', + fta: '10000', + qi: 42, + }, + ], + }, + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + const item = event.items[0]; + expect(item.type).toBe('sendJetton'); + if (item.type === 'sendJetton') { + expect(item.jettonMasterAddress).toBe('EQJettonMaster'); + expect(item.jettonAmount).toBe('5000000'); + expect(item.destination).toBe('EQDest'); + expect(item.responseDestination).toBe('EQResp'); + expect(item.forwardTonAmount).toBe('10000'); + expect(item.queryId).toBe(42); + } + } + }); + + it('parses NFT items', async () => { + const url = buildInlineUrl('c1', { + id: 'n-1', + method: 'txDraft', + params: { + i: [ + { + t: 'nft', + na: 'EQNftAddr', + no: 'EQNewOwner', + rd: 'EQResp', + }, + ], + }, + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + const item = event.items[0]; + expect(item.type).toBe('sendNft'); + if (item.type === 'sendNft') { + expect(item.nftAddress).toBe('EQNftAddr'); + expect(item.newOwnerAddress).toBe('EQNewOwner'); + expect(item.responseDestination).toBe('EQResp'); + } + } + }); + }); + + // ── parse – inline signData ──────────────────────────────────────────── + + describe('parse – signData (inline)', () => { + it('parses a text sign data intent', async () => { + const url = buildInlineUrl( + 'c1', + { + id: 'si-1', + method: 'signData', + params: [JSON.stringify({ type: 'text', text: 'Hello world' })], + }, + { connectRequest: { manifestUrl: 'https://example.com/manifest.json', items: [] } }, + ); + + const { event } = await parser.parse(url); + + expect(event.type).toBe('signData'); + if (event.type === 'signData') { + expect(event.id).toBe('si-1'); + expect(event.manifestUrl).toBe('https://example.com/manifest.json'); + expect(event.payload.data.type).toBe('text'); + if (event.payload.data.type === 'text') { + expect(event.payload.data.value.content).toBe('Hello world'); + } + } + }); + + it('parses a binary sign data intent', async () => { + const url = buildInlineUrl( + 'c1', + { + id: 'si-2', + method: 'signData', + params: [JSON.stringify({ type: 'binary', bytes: 'AQID' })], + }, + { connectRequest: { manifestUrl: 'https://example.com/manifest.json', items: [] } }, + ); + + const { event } = await parser.parse(url); + if (event.type === 'signData') { + expect(event.payload.data.type).toBe('binary'); + if (event.payload.data.type === 'binary') { + expect(event.payload.data.value.content).toBe('AQID'); + } + } + }); + + it('parses a cell sign data intent', async () => { + const url = buildInlineUrl( + 'c1', + { + id: 'si-3', + method: 'signData', + params: [JSON.stringify({ type: 'cell', cell: 'te6cckEBAQEA', schema: 'MySchema' })], + }, + { connectRequest: { manifestUrl: 'https://example.com/manifest.json', items: [] } }, + ); + + const { event } = await parser.parse(url); + if (event.type === 'signData') { + expect(event.payload.data.type).toBe('cell'); + if (event.payload.data.type === 'cell') { + expect(event.payload.data.value.content).toBe('te6cckEBAQEA'); + expect(event.payload.data.value.schema).toBe('MySchema'); + } + } + }); + + it('uses manifestUrl from connect request when present', async () => { + const url = buildInlineUrl( + 'c1', + { + id: 'si-4', + method: 'signData', + params: [JSON.stringify({ type: 'text', text: 'Sign this' })], + }, + { connectRequest: { manifestUrl: 'https://dapp.com/manifest.json', items: [{ name: 'ton_addr' }] } }, + ); + + const { event, connectRequest } = await parser.parse(url); + expect(connectRequest).toBeDefined(); + if (event.type === 'signData') { + expect(event.manifestUrl).toBe('https://dapp.com/manifest.json'); + } + }); + }); + + // ── parse – inline actionDraft ────────────────────────────────────────── + + describe('parse – actionDraft (inline)', () => { + it('parses an action intent', async () => { + const url = buildInlineUrl('c1', { + id: 'a-1', + method: 'actionDraft', + params: { url: 'https://api.example.com/action' }, + }); + + const { event } = await parser.parse(url); + expect(event.type).toBe('action'); + if (event.type === 'action') { + expect(event.id).toBe('a-1'); + expect(event.actionUrl).toBe('https://api.example.com/action'); + } + }); + }); + + // ── parse – validation errors ──────────────────────────────────────────── + + describe('parse – validation', () => { + it('allows inline URL without client ID (fire-and-forget)', async () => { + const json = JSON.stringify({ + id: 'x', + method: 'txDraft', + params: { i: [{ t: 'ton', a: 'A', am: '1' }] }, + }); + const b64 = Buffer.from(json).toString('base64url'); + const url = `tc://?m=intent&mp=${b64}`; + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + expect(event.clientId).toBeUndefined(); + }); + + it('rejects object storage URL without client ID', async () => { + const url = 'tc://?m=intent_remote&pk=abc123&get_url=https%3A%2F%2Fexample.com%2Fpayload'; + await expect(parser.parse(url)).rejects.toThrow('Missing client ID'); + }); + + it('decrypts object storage payload using SDK self-encryption scheme', async () => { + // Reproduce the SDK's encryption scheme: + // const sessionCrypto = new SessionCrypto(); + // sessionCrypto.encrypt(payload, sessionCrypto.publicKey) + // which is: nacl.box(payload, randomNonce, ownPub, ownSec) || nonce prepended + const ephemeral = nacl.box.keyPair(); + const toHex = (b: Uint8Array) => + Array.from(b) + .map((x) => x.toString(16).padStart(2, '0')) + .join(''); + + const payload = { + id: 'os-1', + method: 'txDraft', + params: { i: [{ t: 'ton', a: 'EQAddr1', am: '500000000' }] }, + }; + const nonce = nacl.randomBytes(24); + const ciphertext = nacl.box( + new TextEncoder().encode(JSON.stringify(payload)), + nonce, + ephemeral.publicKey, // self-encrypt: receiverPub = own pub + ephemeral.secretKey, + ); + const encrypted = new Uint8Array(nonce.length + ciphertext.length); + encrypted.set(nonce); + encrypted.set(ciphertext, nonce.length); + + const encryptedB64 = Buffer.from(encrypted).toString('base64'); + const getUrl = 'https://storage.example.com/payload'; + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'text/plain' }, + arrayBuffer: async () => new TextEncoder().encode(encryptedB64).buffer, + }), + ); + + const clientId = toHex(nacl.box.keyPair().publicKey); // existing session id — not used for decrypt + const pk = toHex(ephemeral.secretKey); + const url = `tc://?m=intent_remote&id=${clientId}&pk=${pk}&get_url=${encodeURIComponent(getUrl)}`; + + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + + vi.unstubAllGlobals(); + }); + + it('rejects URL without payload', async () => { + const url = 'tc://?m=intent&id=c1'; + await expect(parser.parse(url)).rejects.toThrow('Missing payload'); + }); + + it('rejects unknown intent method', async () => { + const url = buildInlineUrl('c1', { id: 'x', method: 'badMethod' }); + await expect(parser.parse(url)).rejects.toThrow('Invalid intent method'); + }); + + it('rejects txDraft without items', async () => { + const url = buildInlineUrl('c1', { id: 'x', method: 'txDraft', params: {} }); + await expect(parser.parse(url)).rejects.toThrow('missing items'); + }); + + it('rejects txDraft with invalid item type', async () => { + const url = buildInlineUrl('c1', { id: 'x', method: 'txDraft', params: { i: [{ t: 'unknown' }] } }); + await expect(parser.parse(url)).rejects.toThrow('Invalid intent item type'); + }); + + it('rejects ton item missing address', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + method: 'txDraft', + params: { i: [{ t: 'ton', am: '100' }] }, + }); + await expect(parser.parse(url)).rejects.toThrow('missing address'); + }); + + it('rejects ton item missing amount', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + method: 'txDraft', + params: { i: [{ t: 'ton', a: 'A' }] }, + }); + await expect(parser.parse(url)).rejects.toThrow('missing amount'); + }); + + it('rejects jetton item missing master address', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + method: 'txDraft', + params: { i: [{ t: 'jetton', ja: '100', d: 'D' }] }, + }); + await expect(parser.parse(url)).rejects.toThrow('missing master address'); + }); + + it('rejects jetton item missing amount', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + method: 'txDraft', + params: { i: [{ t: 'jetton', ma: 'MA', d: 'D' }] }, + }); + await expect(parser.parse(url)).rejects.toThrow('missing amount'); + }); + + it('rejects jetton item missing destination', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + method: 'txDraft', + params: { i: [{ t: 'jetton', ma: 'MA', ja: '100' }] }, + }); + await expect(parser.parse(url)).rejects.toThrow('missing destination'); + }); + + it('rejects NFT item missing address', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + method: 'txDraft', + params: { i: [{ t: 'nft', no: 'NO' }] }, + }); + await expect(parser.parse(url)).rejects.toThrow('missing address'); + }); + + it('rejects NFT item missing new owner', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + method: 'txDraft', + params: { i: [{ t: 'nft', na: 'NA' }] }, + }); + await expect(parser.parse(url)).rejects.toThrow('missing new owner'); + }); + + it('rejects signData without payload', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + method: 'signData', + }); + await expect(parser.parse(url)).rejects.toThrow('missing payload'); + }); + + it('rejects actionDraft without action URL', async () => { + const url = buildInlineUrl('c1', { id: 'x', method: 'actionDraft', params: {} }); + await expect(parser.parse(url)).rejects.toThrow('missing url'); + }); + + it('rejects request without id', async () => { + const url = buildInlineUrl('c1', { + method: 'txDraft', + params: { i: [{ t: 'ton', a: 'A', am: '1' }] }, + }); + await expect(parser.parse(url)).rejects.toThrow('missing id'); + }); + + it('rejects unsupported sign data type', async () => { + const url = buildInlineUrl('c1', { + id: 'x', + method: 'signData', + params: [JSON.stringify({ type: 'unsupported' })], + }); + await expect(parser.parse(url)).rejects.toThrow('Unsupported sign data type'); + }); + }); + + // ── parse – object storage (intent_remote) ─────────────────────────────── + + describe('parse – object storage (intent_remote)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('parses raw-bytes (non-text) response from object storage', async () => { + const kp = nacl.box.keyPair(); + const payload = { id: 'os-raw', method: 'txDraft', params: { i: [{ t: 'ton', a: 'EQAddr', am: '100' }] } }; + const encrypted = encryptForSelf(payload, kp); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'application/octet-stream' }, + arrayBuffer: async () => encrypted.buffer, + }), + ); + + const url = buildObjectStorageUrl( + toHex(kp.publicKey), + toHex(kp.secretKey), + 'https://storage.example.com/payload', + ); + const { event } = await parser.parse(url); + expect(event.type).toBe('transaction'); + }); + + it('throws on HTTP error from object storage', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }), + ); + + const kp = nacl.box.keyPair(); + const url = buildObjectStorageUrl( + toHex(kp.publicKey), + toHex(kp.secretKey), + 'https://storage.example.com/missing', + ); + await expect(parser.parse(url)).rejects.toThrow('Object storage fetch failed'); + }); + + it('throws on network error fetching object storage', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + + const kp = nacl.box.keyPair(); + const url = buildObjectStorageUrl( + toHex(kp.publicKey), + toHex(kp.secretKey), + 'https://storage.example.com/payload', + ); + await expect(parser.parse(url)).rejects.toThrow('Failed to fetch intent payload'); + }); + + it('throws when payload is too short (≤24 bytes) to contain nonce', async () => { + const shortData = new Uint8Array(10); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'application/octet-stream' }, + arrayBuffer: async () => shortData.buffer, + }), + ); + + const kp = nacl.box.keyPair(); + const url = buildObjectStorageUrl( + toHex(kp.publicKey), + toHex(kp.secretKey), + 'https://storage.example.com/short', + ); + await expect(parser.parse(url)).rejects.toThrow('Encrypted payload too short'); + }); + + it('throws on invalid private key length in pk param', async () => { + const kp = nacl.box.keyPair(); + const payload = { id: 'x', method: 'txDraft', params: { i: [{ t: 'ton', a: 'A', am: '1' }] } }; + const encrypted = encryptForSelf(payload, kp); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'application/octet-stream' }, + arrayBuffer: async () => encrypted.buffer, + }), + ); + + // 'aabbccdd' decodes to 4 bytes — not a valid 32-byte NaCl secret key + const url = buildObjectStorageUrl(toHex(kp.publicKey), 'aabbccdd', 'https://storage.example.com/payload'); + await expect(parser.parse(url)).rejects.toThrow('Invalid wallet private key length'); + }); + + it('throws when decryption fails due to wrong key', async () => { + const encryptKp = nacl.box.keyPair(); + const wrongKp = nacl.box.keyPair(); + const payload = { id: 'x', method: 'txDraft', params: { i: [{ t: 'ton', a: 'A', am: '1' }] } }; + const encrypted = encryptForSelf(payload, encryptKp); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'application/octet-stream' }, + arrayBuffer: async () => encrypted.buffer, + }), + ); + + // Pass the wrong secret key — decryption will fail + const url = buildObjectStorageUrl( + toHex(encryptKp.publicKey), + toHex(wrongKp.secretKey), + 'https://storage.example.com/payload', + ); + await expect(parser.parse(url)).rejects.toThrow('Failed to decrypt intent payload'); + }); + + it('throws on missing pk param in intent_remote URL', async () => { + const kp = nacl.box.keyPair(); + const url = `tc://?m=intent_remote&id=${toHex(kp.publicKey)}&get_url=https%3A%2F%2Fexample.com%2Fpayload`; + await expect(parser.parse(url)).rejects.toThrow('Missing wallet private key'); + }); + + it('throws on missing get_url param in intent_remote URL', async () => { + const kp = nacl.box.keyPair(); + const url = `tc://?m=intent_remote&id=${toHex(kp.publicKey)}&pk=${toHex(kp.secretKey)}`; + await expect(parser.parse(url)).rejects.toThrow('Missing get_url'); + }); + }); + + // ── parseActionResponse ────────────────────────────────────────────────── + + describe('parseActionResponse', () => { + const baseActionEvent = { + type: 'action' as const, + id: 'a-1', + origin: 'deepLink' as const, + clientId: 'c1', + actionUrl: 'https://api.example.com/action', + }; + + it('parses sendTransaction action response', () => { + const payload = { + action_type: 'sendTransaction', + action: { + messages: [{ address: 'EQAddr', amount: '500', payload: 'abc123' }], + valid_until: 1700000000, + network: '-239', + }, + }; + + const event = parser.parseActionResponse(payload, baseActionEvent); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + expect(event.resolvedTransaction).toBeDefined(); + expect(event.resolvedTransaction!.messages).toHaveLength(1); + expect(event.resolvedTransaction!.messages[0].address).toBe('EQAddr'); + expect(event.resolvedTransaction!.messages[0].amount).toBe('500'); + expect(event.resolvedTransaction!.network).toEqual({ chainId: '-239' }); + } + }); + + it('parses signData action response', () => { + const payload = { + action_type: 'signData', + action: { + type: 'text', + text: 'Sign this message', + }, + }; + + const event = parser.parseActionResponse(payload, baseActionEvent); + expect(event.type).toBe('signData'); + if (event.type === 'signData') { + expect(event.manifestUrl).toBe('https://api.example.com/action'); + expect(event.payload.data.type).toBe('text'); + } + }); + + it('rejects missing action_type', () => { + expect(() => parser.parseActionResponse({ action: {} }, baseActionEvent)).toThrow('missing action_type'); + }); + + it('rejects missing action', () => { + expect(() => parser.parseActionResponse({ action_type: 'sendTransaction' }, baseActionEvent)).toThrow( + 'missing action_type or action', + ); + }); + + it('rejects unsupported action_type', () => { + expect(() => parser.parseActionResponse({ action_type: 'unknown', action: {} }, baseActionEvent)).toThrow( + 'unsupported action_type', + ); + }); + + it('rejects sendTransaction without messages', () => { + expect(() => + parser.parseActionResponse( + { action_type: 'sendTransaction', action: { messages: [] } }, + baseActionEvent, + ), + ).toThrow('missing messages'); + }); + + it('rejects signData without type', () => { + expect(() => + parser.parseActionResponse({ action_type: 'signData', action: { text: 'hello' } }, baseActionEvent), + ).toThrow('missing type'); + }); + + it('parses signMessage action response as signOnly transaction', () => { + const payload = { + action_type: 'signMessage', + action: { + messages: [{ address: 'EQAddr', amount: '0' }], + }, + }; + + const event = parser.parseActionResponse(payload, baseActionEvent); + expect(event.type).toBe('transaction'); + if (event.type === 'transaction') { + expect(event.deliveryMode).toBe('signOnly'); + expect(event.resolvedTransaction).toBeDefined(); + expect(event.resolvedTransaction!.messages).toHaveLength(1); + } + }); + }); +}); diff --git a/packages/walletkit/src/handlers/IntentParser.ts b/packages/walletkit/src/handlers/IntentParser.ts new file mode 100644 index 000000000..8dc966549 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentParser.ts @@ -0,0 +1,759 @@ +/** + * 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 { globalLogger } from '../core/Logger'; +import { WalletKitError, ERROR_CODES } from '../errors'; +import type { RawBridgeEvent } from '../types/internal'; +import type { + IntentActionItem, + IntentOrigin, + IntentRequestEvent, + IntentRequestBase, + ActionIntentRequestEvent, + IntentDeliveryMode, + TransactionRequest, + SignDataPayload, + SignData, + Base64String, + Network, +} from '../api/models'; + +const VALID_METHODS = ['txDraft', 'signMsgDraft', 'signData', 'actionDraft'] as const; +const log = globalLogger.createChild('IntentParser'); + +/** + * Wire-format intent item types. + */ +type WireItemType = 'ton' | 'jetton' | 'nft'; + +/** + * Wire-format intent item (short field names from spec). + */ +interface WireIntentItem { + t: WireItemType; + // ton + a?: string; + am?: string; + p?: string; + si?: string; + ec?: Record; + // jetton + ma?: string; + ja?: string; + d?: string; + rd?: string; + cp?: string; + fta?: string; + fp?: string; + qi?: number; + // nft + na?: string; + no?: string; +} + +interface TxDraftParams { + vu?: number; + f?: string; + n?: string; + i: WireIntentItem[]; +} + +type SignDataParams = [string]; + +interface ActionDraftParams { + url: string; +} + +/** + * Spec-compliant intent request payload (PR #103). + * method names match the spec: txDraft | signMsgDraft | signData | actionDraft. + * params is nested (not flat) and ConnectRequest lives in the URL r param, not here. + */ +interface SpecIntentRequest { + id: string; + method: 'txDraft' | 'signMsgDraft' | 'signData' | 'actionDraft'; + params: TxDraftParams | SignDataParams | ActionDraftParams; +} + +/** + * Parsed intent URL — intermediate result before event creation. + */ +export interface ParsedIntentUrl { + clientId?: string; + /** Raw sender ID for connectedBridge events (used for session crypto lookup) */ + from?: string; + request: SpecIntentRequest; + connectRequest?: ConnectRequest; + origin: IntentOrigin; + traceId?: string; +} + +/** + * Intent error codes from the TonConnect spec. + */ +export const INTENT_ERROR_CODES = { + UNKNOWN: 0, + BAD_REQUEST: 1, + UNKNOWN_APP: 100, + ACTION_URL_UNREACHABLE: 200, + USER_DECLINED: 300, + METHOD_NOT_SUPPORTED: 400, +} as const; + +export type IntentErrorCode = (typeof INTENT_ERROR_CODES)[keyof typeof INTENT_ERROR_CODES]; + +/** + * Parsing layer for intent deep links. + * + * Responsibility: URL parsing, payload decoding (inline + object storage), + * NaCl decryption, wire→model mapping, validation. + */ + +export function isIntentUrl(url: string): boolean { + try { + const parsedUrl = new URL(url.trim()); + const method = parsedUrl.searchParams.get('m') || parsedUrl.searchParams.get('M'); + return method?.toLowerCase() === 'intent' || method?.toLowerCase() === 'intent_remote'; + } catch { + return false; + } +} + +export class IntentParser { + /** + * Parse an intent URL into a typed IntentRequestEvent. + * Supports both `m=intent` (URL-embedded) and `m=intent_remote` (object storage). + */ + async parse(url: string): Promise<{ event: IntentRequestEvent; connectRequest?: ConnectRequest }> { + const parsed = await this.parseUrl(url); + return this.toIntentEvent(parsed); + } + + /** + * Parse a bridge-delivered draft RPC event into a typed IntentRequestEvent. + * Used when the wallet is already connected and receives txDraft/signMsgDraft/actionDraft + * via the existing bridge session (sendRequest path). + */ + parseBridgeDraftPayload(rawEvent: RawBridgeEvent): IntentRequestEvent { + const request: SpecIntentRequest = { + id: rawEvent.id, + method: rawEvent.method as 'txDraft' | 'signMsgDraft' | 'actionDraft', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: ((rawEvent.params as any)?.[0] ?? rawEvent.params ?? {}) as + | TxDraftParams + | SignDataParams + | ActionDraftParams, + }; + this.validateRequest(request); + const parsed: ParsedIntentUrl = { + clientId: rawEvent.from, + from: rawEvent.from, + request, + connectRequest: undefined, + origin: 'connectedBridge', + traceId: rawEvent.traceId, + }; + const { event } = this.toIntentEvent(parsed); + return event; + } + + // -- URL parsing ---------------------------------------------------------- + + private async parseUrl(url: string): Promise { + try { + const parsedUrl = new URL(url); + const clientId = parsedUrl.searchParams.get('id') || undefined; + + const methodKey = Array.from(parsedUrl.searchParams.keys()).find((k) => k.toLowerCase() === 'm'); + const method = methodKey ? parsedUrl.searchParams.get(methodKey)?.toLowerCase() : null; + + if (method === 'intent') { + return this.parseInlinePayload(parsedUrl, clientId); + } + + if (method === 'intent_remote') { + if (!clientId) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Missing client ID (id) in object storage intent URL (required for decryption)', + ); + } + return this.parseObjectStoragePayload(parsedUrl, clientId); + } + + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Unknown intent URL method'); + } catch (error) { + if (error instanceof WalletKitError) throw error; + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid intent URL format', error as Error); + } + } + + private parseInlinePayload(parsedUrl: URL, clientId: string | undefined): ParsedIntentUrl { + const encoded = parsedUrl.searchParams.get('mp'); + if (!encoded) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing payload (mp) in intent URL'); + } + const traceId = parsedUrl.searchParams.get('trace_id') || undefined; + + const rParam = parsedUrl.searchParams.get('r'); + const connectRequest = this.parseOptionalConnectRequest(rParam, 'inline'); + + const json = this.decodePayload(encoded); + let request: SpecIntentRequest; + try { + request = JSON.parse(json) as SpecIntentRequest; + } catch (error) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid JSON in intent payload', error as Error); + } + + this.validateRequest(request); + return { clientId, request, connectRequest, origin: 'deepLink', traceId }; + } + + /** + * Parse an object storage intent URL. + * Fetches encrypted payload from `get_url`, decrypts with NaCl using + * the provided wallet private key and client public key. + */ + private async parseObjectStoragePayload(parsedUrl: URL, clientId: string): Promise { + const walletPrivateKey = parsedUrl.searchParams.get('pk'); + const getUrl = parsedUrl.searchParams.get('get_url'); + const traceId = parsedUrl.searchParams.get('trace_id') || undefined; + + if (!walletPrivateKey) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing wallet private key (pk) in intent URL'); + } + if (!getUrl) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Missing get_url in intent URL'); + } + + const rParam = parsedUrl.searchParams.get('r'); + const connectRequest = this.parseOptionalConnectRequest(rParam, 'objectStorage'); + + const encryptedPayload = await this.fetchObjectStoragePayload(getUrl); + const json = this.decryptPayload(encryptedPayload, walletPrivateKey); + + let request: SpecIntentRequest; + try { + request = JSON.parse(json) as SpecIntentRequest; + } catch (error) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Invalid JSON in decrypted intent payload: ${json.substring(0, 100)}`, + error as Error, + ); + } + + this.validateRequest(request); + return { clientId, request, connectRequest, origin: 'objectStorage', traceId }; + } + + private parseOptionalConnectRequest( + rawValue: string | null, + source: 'inline' | 'objectStorage', + ): ConnectRequest | undefined { + if (!rawValue) { + return undefined; + } + + try { + return JSON.parse(rawValue) as ConnectRequest; + } catch (error) { + log.warn('Failed to parse optional connect request from intent URL', { + source, + error, + }); + return undefined; + } + } + + /** + * Fetch encrypted payload from object storage URL. + * + * The SDK stores the payload as raw bytes with Content-Type: text/plain. + * Some object storage providers base64-encode binary content when returning + * it as text, so we attempt base64 decode for text responses before falling + * back to raw bytes. + */ + private async fetchObjectStoragePayload(getUrl: string): Promise { + try { + const response = await fetch(getUrl); + if (!response.ok) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Object storage fetch failed: ${response.status} ${response.statusText}`, + ); + } + + const contentType = response.headers.get('content-type') || ''; + const buffer = await response.arrayBuffer(); + const raw = new Uint8Array(buffer); + + if (contentType.includes('text')) { + const text = new TextDecoder().decode(raw).trim(); + if (/^[A-Za-z0-9+/=_-]+$/.test(text) && text.length > 24) { + try { + return this.base64ToBytes(text); + } catch { + // Not valid base64, fall through to raw bytes + } + } + } + + return raw; + } catch (error) { + if (error instanceof WalletKitError) throw error; + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Failed to fetch intent payload from object storage: ${(error as Error).message}`, + error as Error, + ); + } + } + + private base64ToBytes(b64: string): Uint8Array { + // Handle base64url encoding + let base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); + const padding = base64.length % 4; + if (padding) base64 += '='.repeat(4 - padding); + + if (typeof atob === 'function') { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + return new Uint8Array(Buffer.from(base64, 'base64')); + } + + /** + * Decrypt an object storage payload using NaCl crypto_box. + * Format: nonce (24 bytes) || ciphertext + * + * The SDK self-encrypts using the ephemeral keypair it puts in `pk`: + * nacl.box(payload, nonce, ephemeralPub, ephemeralSec) + * So we derive the public key from `pk` and open with the same keypair. + */ + private decryptPayload(encrypted: Uint8Array, walletPrivateKeyHex: string): string { + if (encrypted.length <= 24) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Encrypted payload too short (${encrypted.length} bytes, need >24)`, + ); + } + + const walletPrivateKey = this.hexToBytes(walletPrivateKeyHex); + + if (walletPrivateKey.length !== 32) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Invalid wallet private key length: ${walletPrivateKey.length} (expected 32)`, + ); + } + + // Derive the public key from the private key — the SDK encrypted for this same keypair + const walletPublicKey = nacl.box.keyPair.fromSecretKey(walletPrivateKey).publicKey; + + const nonce = encrypted.slice(0, 24); + const ciphertext = encrypted.slice(24); + const decrypted = nacl.box.open(ciphertext, nonce, walletPublicKey, walletPrivateKey); + if (!decrypted) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Failed to decrypt intent payload'); + } + + return new TextDecoder().decode(decrypted); + } + + private hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; + } + + // -- Payload decoding ----------------------------------------------------- + + private decodePayload(encoded: string): string { + if (encoded.startsWith('%7B') || encoded.startsWith('%257B') || encoded.startsWith('{')) { + let decoded = decodeURIComponent(encoded); + if (decoded.startsWith('%7B') || decoded.startsWith('%')) { + decoded = decodeURIComponent(decoded); + } + return decoded; + } + return this.decodeBase64Url(encoded); + } + + private decodeBase64Url(encoded: string): string { + let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + const padding = base64.length % 4; + if (padding) base64 += '='.repeat(4 - padding); + + if (typeof atob === 'function') { + return atob(base64); + } + return Buffer.from(base64, 'base64').toString('utf-8'); + } + + // -- Validation ----------------------------------------------------------- + + private validateRequest(request: SpecIntentRequest): void { + if (!request.id) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Intent request missing id'); + } + if (!request.method || !VALID_METHODS.includes(request.method)) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Invalid intent method: ${request.method}`); + } + + switch (request.method) { + case 'txDraft': + case 'signMsgDraft': + this.validateTransactionItems(request); + break; + case 'signData': + this.validateSignData(request); + break; + case 'actionDraft': + this.validateAction(request); + break; + } + } + + private validateTransactionItems(request: SpecIntentRequest): void { + const params = request.params as TxDraftParams; + if (!params?.i || !Array.isArray(params.i) || params.i.length === 0) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Transaction intent missing items (i)'); + } + for (const item of params.i) { + this.validateItem(item); + } + } + + private validateItem(item: WireIntentItem): void { + const validTypes: WireItemType[] = ['ton', 'jetton', 'nft']; + if (!item.t || !validTypes.includes(item.t)) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Invalid intent item type: ${item.t}`); + } + + switch (item.t) { + case 'ton': + if (!item.a) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'TON item missing address (a)'); + if (!item.am) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'TON item missing amount (am)'); + break; + case 'jetton': + if (!item.ma) + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Jetton item missing master address (ma)'); + if (!item.ja) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Jetton item missing amount (ja)'); + if (!item.d) + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Jetton item missing destination (d)'); + break; + case 'nft': + if (!item.na) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'NFT item missing address (na)'); + if (!item.no) throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'NFT item missing new owner (no)'); + break; + } + } + + private validateSignData(request: SpecIntentRequest): void { + const params = request.params as SignDataParams; + if (!Array.isArray(params) || !params[0]) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Sign data intent missing payload'); + } + let raw: Record; + try { + raw = JSON.parse(params[0]) as Record; + } catch { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Invalid JSON in sign data payload'); + } + if (!raw.type) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Sign data intent missing type'); + } + } + + private validateAction(request: SpecIntentRequest): void { + const params = request.params as ActionDraftParams; + if (!params?.url) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent missing url'); + } + try { + const parsed = new URL(params.url); + if (parsed.protocol !== 'https:') { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action URL must use HTTPS'); + } + } catch (error) { + if (error instanceof WalletKitError) throw error; + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action intent url is not a valid URL'); + } + } + + /** + * Parse an action URL response payload into a typed intent event. + * + * Action URLs return standard TonConnect payloads: + * - `{ action_type: 'sendTransaction', action: { messages, valid_until?, network? } }` + * - `{ action_type: 'signData', action: { type, text?|bytes?|cell?, schema? } }` + */ + parseActionResponse( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, + sourceEvent: ActionIntentRequestEvent, + ): IntentRequestEvent { + const { action_type, action } = payload as { action_type?: string; action?: Record }; + + if (!action_type || !action) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action URL response missing action_type or action'); + } + + const base: IntentRequestBase = { + id: sourceEvent.id, + origin: sourceEvent.origin, + clientId: sourceEvent.clientId, + from: sourceEvent.from, + traceId: sourceEvent.traceId, + }; + + switch (action_type) { + case 'sendTransaction': + return this.parseActionTransaction(base, action); + case 'signMessage': + return this.parseActionTransaction(base, action, 'signOnly'); + 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, + deliveryMode: IntentDeliveryMode = 'send', + ): IntentRequestEvent { + const rawMessages = action.messages as Array> | undefined; + if (!rawMessages || !Array.isArray(rawMessages) || rawMessages.length === 0) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action sendTransaction missing messages'); + } + + const messages = rawMessages.map((msg) => ({ + address: msg.address as string, + amount: msg.amount as string, + payload: msg.payload as Base64String | undefined, + stateInit: (msg.stateInit ?? msg.state_init) as Base64String | undefined, + extraCurrency: (msg.extraCurrency ?? msg.extra_currency) as Record | undefined, + })); + + const network: Network | undefined = action.network ? { chainId: action.network as string } : undefined; + + const resolvedTransaction: TransactionRequest = { + messages, + network, + validUntil: (action.valid_until ?? action.validUntil) as number | undefined, + }; + + return { + type: 'transaction' as const, + ...base, + deliveryMode, + network, + validUntil: resolvedTransaction.validUntil, + items: [], + resolvedTransaction, + }; + } + + private parseActionSignData( + base: IntentRequestBase, + action: Record, + actionUrl: string, + ): IntentRequestEvent { + const wirePayload = { + type: action.type as string, + text: action.text as string | undefined, + bytes: action.bytes as string | undefined, + cell: action.cell as string | undefined, + schema: action.schema as string | undefined, + }; + + if (!wirePayload.type) { + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, 'Action signData missing type'); + } + + return { + type: 'signData' as const, + ...base, + network: action.network ? { chainId: action.network as string } : undefined, + manifestUrl: actionUrl, + payload: this.wirePayloadToSignDataPayload(wirePayload), + }; + } + + // -- Wire → Model mapping ------------------------------------------------- + + private toIntentEvent(parsed: ParsedIntentUrl): { event: IntentRequestEvent; connectRequest?: ConnectRequest } { + const { clientId, from, request, connectRequest, origin, traceId } = parsed; + + const base: IntentRequestBase = { + id: request.id, + origin, + clientId, + from, + traceId, + returnStrategy: undefined, + }; + + let event: IntentRequestEvent; + + switch (request.method) { + case 'txDraft': + case 'signMsgDraft': { + const params = request.params as TxDraftParams; + const deliveryMode: IntentDeliveryMode = request.method === 'txDraft' ? 'send' : 'signOnly'; + event = { + type: 'transaction' as const, + ...base, + deliveryMode, + network: params.n ? { chainId: params.n } : undefined, + validUntil: params.vu, + items: this.mapItems(params.i), + }; + break; + } + case 'signData': { + const params = request.params as SignDataParams; + let raw: Record; + try { + raw = JSON.parse(params[0]) as Record; + } catch (error) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + 'Invalid JSON in sign data payload', + error as Error, + ); + } + event = { + type: 'signData' as const, + ...base, + network: raw.network ? { chainId: raw.network as string } : undefined, + manifestUrl: connectRequest?.manifestUrl || '', + payload: this.wirePayloadToSignDataPayload({ + type: raw.type as string, + text: raw.text as string | undefined, + bytes: raw.bytes as string | undefined, + cell: raw.cell as string | undefined, + schema: raw.schema as string | undefined, + }), + }; + break; + } + case 'actionDraft': { + const params = request.params as ActionDraftParams; + event = { + type: 'action' as const, + ...base, + actionUrl: params.url, + }; + break; + } + default: + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Unhandled intent method: ${request.method}`); + } + + return { event, connectRequest }; + } + + private mapItems(wireItems: WireIntentItem[]): IntentActionItem[] { + return wireItems.map((item) => this.mapItem(item)); + } + + private mapItem(item: WireIntentItem): IntentActionItem { + switch (item.t) { + case 'ton': + 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)'); + return { + type: 'sendTon' as const, + address: item.a, + amount: item.am, + payload: item.p as Base64String | undefined, + stateInit: item.si as Base64String | undefined, + extraCurrency: item.ec, + }; + case 'jetton': + 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)'); + return { + type: 'sendJetton' as const, + jettonMasterAddress: item.ma, + jettonAmount: item.ja, + destination: item.d, + responseDestination: item.rd, + customPayload: item.cp as Base64String | undefined, + forwardTonAmount: item.fta, + forwardPayload: item.fp as Base64String | undefined, + queryId: item.qi, + }; + case 'nft': + 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)'); + return { + type: 'sendNft' as const, + nftAddress: item.na, + newOwnerAddress: item.no, + responseDestination: item.rd, + customPayload: item.cp as Base64String | undefined, + forwardTonAmount: item.fta, + forwardPayload: item.fp as Base64String | undefined, + queryId: item.qi, + }; + } + } + + /** + * Convert a wire-format sign data payload to SignDataPayload model. + */ + private wirePayloadToSignDataPayload(wire: { + type: string; + text?: string; + bytes?: string; + schema?: string; + cell?: string; + }): SignDataPayload { + let data: SignData; + + switch (wire.type) { + case 'text': + data = { type: 'text', value: { content: wire.text || '' } }; + break; + case 'binary': + data = { type: 'binary', value: { content: (wire.bytes || '') as Base64String } }; + break; + case 'cell': + data = { + type: 'cell', + value: { schema: wire.schema || '', content: (wire.cell || '') as Base64String }, + }; + break; + default: + throw new WalletKitError(ERROR_CODES.VALIDATION_ERROR, `Unsupported sign data type: ${wire.type}`); + } + + return { data }; + } +} diff --git a/packages/walletkit/src/handlers/IntentResolver.ts b/packages/walletkit/src/handlers/IntentResolver.ts new file mode 100644 index 000000000..e246dfd12 --- /dev/null +++ b/packages/walletkit/src/handlers/IntentResolver.ts @@ -0,0 +1,175 @@ +/** + * 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 = await Promise.all(items.map((item) => this.resolveItem(item, wallet))); + + return { + messages, + network, + validUntil, + fromAddress: wallet.getAddress(), + }; + } + + /** + * Fetch an action URL and return the raw response. + */ + async fetchActionUrl(actionUrl: string, walletAddress: string): Promise { + const separator = actionUrl.includes('?') ? '&' : '?'; + const url = `${actionUrl}${separator}address=${encodeURIComponent(walletAddress)}`; + + log.info('Fetching action URL', { url }); + + const response = await fetch(url); + if (!response.ok) { + throw new WalletKitError( + ERROR_CODES.NETWORK_ERROR, + `Action URL returned ${response.status}: ${response.statusText}`, + ); + } + + const rawBody = await response.text(); + try { + return JSON.parse(rawBody); + } catch (error) { + if (rawBody.trim().startsWith('<')) { + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Action URL returned HTML instead of JSON`, + error as Error, + ); + } + throw new WalletKitError( + ERROR_CODES.VALIDATION_ERROR, + `Action URL returned invalid JSON: ${(error as Error).message}`, + error as Error, + ); + } + } + + // -- Item resolution ------------------------------------------------------ + + private async resolveItem(item: IntentActionItem, wallet: Wallet): Promise { + switch (item.type) { + case 'sendTon': + return this.resolveTonItem(item); + case 'sendJetton': + return this.resolveJettonItem(item, wallet); + case 'sendNft': + return this.resolveNftItem(item, wallet); + } + } + + private resolveTonItem(item: SendTonAction): TransactionRequestMessage { + return { + address: item.address, + amount: item.amount, + payload: item.payload as Base64String | undefined, + stateInit: item.stateInit as Base64String | undefined, + extraCurrency: item.extraCurrency, + }; + } + + private async resolveJettonItem(item: SendJettonAction, wallet: Wallet): Promise { + const jettonWalletAddress = await wallet.getJettonWalletAddress(item.jettonMasterAddress); + + const forwardPayload = item.forwardPayload ? Cell.fromBase64(item.forwardPayload) : null; + const customPayload = item.customPayload ? Cell.fromBase64(item.customPayload) : null; + + const body = beginCell() + .store( + storeJettonTransferMessage({ + queryId: BigInt(item.queryId ?? 0), + amount: BigInt(item.jettonAmount), + destination: Address.parse(item.destination), + responseDestination: item.responseDestination + ? Address.parse(item.responseDestination) + : Address.parse(wallet.getAddress()), + customPayload, + forwardAmount: item.forwardTonAmount ? BigInt(item.forwardTonAmount) : DEFAULT_FORWARD_AMOUNT, + forwardPayload, + }), + ) + .endCell(); + + return { + address: jettonWalletAddress, + amount: DEFAULT_JETTON_GAS_FEE, + payload: body.toBoc().toString('base64') as Base64String, + }; + } + + private async resolveNftItem(item: SendNftAction, wallet: Wallet): Promise { + const forwardPayload = item.forwardPayload ? Cell.fromBase64(item.forwardPayload) : null; + const customPayload = item.customPayload ? Cell.fromBase64(item.customPayload) : null; + + const body = beginCell() + .store( + storeNftTransferMessage({ + queryId: BigInt(item.queryId ?? 0), + newOwner: Address.parse(item.newOwnerAddress), + responseDestination: item.responseDestination + ? Address.parse(item.responseDestination) + : Address.parse(wallet.getAddress()), + customPayload, + forwardAmount: item.forwardTonAmount ? BigInt(item.forwardTonAmount) : DEFAULT_FORWARD_AMOUNT, + forwardPayload, + }), + ) + .endCell(); + + return { + address: item.nftAddress, + amount: DEFAULT_NFT_GAS_FEE, + payload: body.toBoc().toString('base64') as Base64String, + }; + } +} diff --git a/packages/walletkit/src/handlers/SignMessageHandler.ts b/packages/walletkit/src/handlers/SignMessageHandler.ts new file mode 100644 index 000000000..409320b8d --- /dev/null +++ b/packages/walletkit/src/handlers/SignMessageHandler.ts @@ -0,0 +1,190 @@ +/** + * 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 { WalletResponseTemplateError } from '@tonconnect/protocol'; +import { SEND_TRANSACTION_ERROR_CODES } from '@tonconnect/protocol'; + +import type { TonWalletKitOptions, ValidationResult } from '../types'; +import { toTransactionRequest, parseConnectTransactionParamContent } from '../types/internal'; +import type { + RawBridgeEvent, + EventHandler, + RawBridgeEventSignMessage, + RawConnectTransactionParamContent, +} from '../types/internal'; +import { validateTransactionMessages as validateTonConnectTransactionMessages } from '../validation/transaction'; +import { globalLogger } from '../core/Logger'; +import { validateNetwork, validateFrom, validateValidUntil } from './transactionValidators'; +import { BasicHandler } from './BasicHandler'; +import type { EventEmitter } from '../core/EventEmitter'; +import type { WalletKitEvents } from '../types/emitter'; +import type { WalletManager } from '../core/WalletManager'; +import { WalletKitError, ERROR_CODES } from '../errors'; +import type { Wallet } from '../api/interfaces'; +import type { TransactionRequest, SignMessageRequestEvent } from '../api/models'; +import type { Analytics, AnalyticsManager } from '../analytics'; +import type { TONConnectSessionManager } from '../api/interfaces/TONConnectSessionManager'; + +const log = globalLogger.createChild('SignMessageHandler'); + +// Error response shape (mirrors SendTransactionRpcResponseError but for signMessage) +interface SignMessageRpcResponseError { + error: { code: number; message: string }; + id: string; +} + +export class SignMessageHandler + extends BasicHandler + implements EventHandler +{ + private eventEmitter: EventEmitter; + private analytics?: Analytics; + + constructor( + notify: (event: SignMessageRequestEvent) => void, + _config: TonWalletKitOptions, + eventEmitter: EventEmitter, + private readonly walletManager: WalletManager, + private readonly sessionManager: TONConnectSessionManager, + analyticsManager?: AnalyticsManager, + ) { + super(notify); + this.eventEmitter = eventEmitter; + this.sessionManager = sessionManager; + this.analytics = analyticsManager?.scoped(); + } + + canHandle(event: RawBridgeEvent): event is RawBridgeEventSignMessage { + return event.method === 'signMessage'; + } + + async handle(event: RawBridgeEventSignMessage): Promise { + const walletId = event.walletId; + const walletAddress = event.walletAddress; + + if (!walletId && !walletAddress) { + log.error('Wallet ID not found', { event }); + return { + error: { + code: SEND_TRANSACTION_ERROR_CODES.UNKNOWN_APP_ERROR, + message: 'Wallet ID not found', + }, + id: event.id, + } as SignMessageRpcResponseError; + } + + const wallet = walletId ? this.walletManager.getWallet(walletId) : undefined; + if (!wallet) { + log.error('Wallet not found', { event, walletId, walletAddress }); + return { + error: { + code: SEND_TRANSACTION_ERROR_CODES.UNKNOWN_APP_ERROR, + message: 'Wallet not found', + }, + id: event.id, + } as SignMessageRpcResponseError; + } + + const requestValidation = this.parseTonConnectTransactionRequest(event, wallet); + if (!requestValidation.result || !requestValidation?.validation?.isValid) { + log.error('Failed to parse sign message request', { event, requestValidation }); + this.eventEmitter.emit('eventError', event, 'sign-message-handler'); + return { + error: { + code: SEND_TRANSACTION_ERROR_CODES.BAD_REQUEST_ERROR, + message: 'Failed to parse sign message request', + }, + id: event.id, + } as SignMessageRpcResponseError; + } + const request = requestValidation.result; + + const signMessageEvent: SignMessageRequestEvent = { + ...event, + request, + dAppInfo: event.dAppInfo ?? {}, + walletId: walletId ?? this.walletManager.getWalletId(wallet), + walletAddress: walletAddress ?? wallet.getAddress(), + }; + + if (this.analytics) { + const sessionData = event.from ? await this.sessionManager.getSession(event.from) : undefined; + this.analytics?.emitWalletTransactionRequestReceived({ + trace_id: event.traceId, + client_id: event.from, + wallet_id: sessionData?.publicKey, + dapp_name: event.dAppInfo?.name, + network_id: wallet.getNetwork().chainId, + origin_url: event.dAppInfo?.url, + }); + } + + return signMessageEvent; + } + + private parseTonConnectTransactionRequest( + event: RawBridgeEventSignMessage, + wallet: Wallet, + ): { + result: TransactionRequest | undefined; + validation: ValidationResult; + } { + let errors: string[] = []; + try { + if (event.params.length !== 1) { + throw new WalletKitError( + ERROR_CODES.INVALID_REQUEST_EVENT, + 'Invalid sign message request - expected exactly 1 parameter', + undefined, + { paramCount: event.params.length, eventId: event.id }, + ); + } + const rawParams = JSON.parse(event.params[0]) as RawConnectTransactionParamContent; + const params = parseConnectTransactionParamContent(rawParams); + + const validUntilValidation = validateValidUntil(params.validUntil); + if (!validUntilValidation.isValid) { + errors = errors.concat(validUntilValidation.errors); + } else { + params.validUntil = validUntilValidation.result; + } + + const networkValidation = validateNetwork(params.network, wallet); + if (!networkValidation.isValid) { + errors = errors.concat(networkValidation.errors); + } else { + params.network = networkValidation.result; + } + + const fromValidation = validateFrom(params.from, wallet); + if (!fromValidation.isValid) { + errors = errors.concat(fromValidation.errors); + } else { + params.from = fromValidation.result; + } + + const isTonConnect = !event.isLocal; + const messagesValidation = validateTonConnectTransactionMessages(params.messages, isTonConnect, false); + if (!messagesValidation.isValid) { + errors = errors.concat(messagesValidation.errors); + } + + return { + result: toTransactionRequest(params), + validation: { isValid: errors.length === 0, errors: errors }, + }; + } catch (error) { + log.error('Failed to parse sign message request', { error }); + errors.push('Failed to parse sign message request'); + return { + result: undefined, + validation: { isValid: errors.length === 0, errors: errors }, + }; + } + } +} diff --git a/packages/walletkit/src/handlers/TransactionHandler.ts b/packages/walletkit/src/handlers/TransactionHandler.ts index fcba5bf05..893130db4 100644 --- a/packages/walletkit/src/handlers/TransactionHandler.ts +++ b/packages/walletkit/src/handlers/TransactionHandler.ts @@ -6,8 +6,7 @@ * */ -import { Address } from '@ton/core'; -import type { ChainId, SendTransactionRpcResponseError, WalletResponseTemplateError } from '@tonconnect/protocol'; +import type { SendTransactionRpcResponseError, WalletResponseTemplateError } from '@tonconnect/protocol'; import { SEND_TRANSACTION_ERROR_CODES } from '@tonconnect/protocol'; import type { TonWalletKitOptions, ValidationResult } from '../types'; @@ -20,13 +19,12 @@ import type { } from '../types/internal'; import { validateTransactionMessages as validateTonConnectTransactionMessages } from '../validation/transaction'; import { globalLogger } from '../core/Logger'; -import { isValidAddress } from '../utils/address'; import { createTransactionPreview as createTransactionPreviewHelper } from '../utils/toncenterEmulation'; +import { validateNetwork, validateFrom, validateValidUntil } from './transactionValidators'; import { BasicHandler } from './BasicHandler'; import { CallForSuccess } from '../utils/retry'; import type { WalletKitEventEmitter } from '../types/emitter'; import type { WalletManager } from '../core/WalletManager'; -import type { ReturnWithValidationResult } from '../validation/types'; import { WalletKitError, ERROR_CODES } from '../errors'; import type { Wallet } from '../api/interfaces'; import type { TransactionEmulatedPreview, TransactionRequest, SendTransactionRequestEvent } from '../api/models'; @@ -181,21 +179,21 @@ export class TransactionHandler const rawParams = JSON.parse(event.params[0]) as RawConnectTransactionParamContent; const params = parseConnectTransactionParamContent(rawParams); - const validUntilValidation = this.validateValidUntil(params.validUntil); + const validUntilValidation = validateValidUntil(params.validUntil); if (!validUntilValidation.isValid) { errors = errors.concat(validUntilValidation.errors); } else { params.validUntil = validUntilValidation.result; } - const networkValidation = this.validateNetwork(params.network, wallet); + const networkValidation = validateNetwork(params.network, wallet); if (!networkValidation.isValid) { errors = errors.concat(networkValidation.errors); } else { params.network = networkValidation.result; } - const fromValidation = this.validateFrom(params.from, wallet); + const fromValidation = validateFrom(params.from, wallet); if (!fromValidation.isValid) { errors = errors.concat(fromValidation.errors); } else { @@ -221,70 +219,4 @@ export class TransactionHandler }; } } - - /** - * Parse network from various possible formats - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private validateNetwork(network: any, wallet: Wallet): ReturnWithValidationResult { - let errors: string[] = []; - if (typeof network === 'string') { - const walletNetwork = wallet.getNetwork(); - if (network !== walletNetwork.chainId) { - errors.push('Invalid network not equal to wallet network'); - } else { - return { result: network, isValid: errors.length === 0, errors: errors }; - } - } else { - errors.push('Invalid network not a string'); - } - - return { result: undefined, isValid: errors.length === 0, errors: errors }; - } - - private validateFrom(from: unknown, wallet: Wallet): ReturnWithValidationResult { - let errors: string[] = []; - - if (typeof from !== 'string') { - errors.push('Invalid from address not a string'); - return { result: '', isValid: errors.length === 0, errors: errors }; - } - - if (!isValidAddress(from)) { - errors.push('Invalid from address'); - return { result: '', isValid: errors.length === 0, errors: errors }; - } - - const fromAddress = Address.parse(from); - const walletAddress = Address.parse(wallet.getAddress()); - if (!fromAddress.equals(walletAddress)) { - errors.push('Invalid from address not equal to wallet address'); - return { result: '', isValid: errors.length === 0, errors: errors }; - } - - return { result: from, isValid: errors.length === 0, errors: errors }; - } - - /** - * Parse validUntil timestamp - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private validateValidUntil(validUntil: any): ReturnWithValidationResult { - let errors: string[] = []; - if (typeof validUntil === 'undefined') { - return { result: 0, isValid: errors.length === 0, errors: errors }; - } - if (typeof validUntil !== 'number' || isNaN(validUntil)) { - errors.push('Invalid validUntil timestamp not a number'); - return { result: 0, isValid: errors.length === 0, errors: errors }; - } - - const now = Math.floor(Date.now() / 1000); - if (validUntil < now) { - errors.push('Invalid validUntil timestamp'); - return { result: 0, isValid: errors.length === 0, errors: errors }; - } - - return { result: validUntil, isValid: errors.length === 0, errors: errors }; - } } diff --git a/packages/walletkit/src/handlers/transactionValidators.ts b/packages/walletkit/src/handlers/transactionValidators.ts new file mode 100644 index 000000000..cf847ec30 --- /dev/null +++ b/packages/walletkit/src/handlers/transactionValidators.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { Address } from '@ton/core'; +import type { ChainId } from '@tonconnect/protocol'; + +import type { ReturnWithValidationResult } from '../validation/types'; +import { isValidAddress } from '../utils/address'; +import type { Wallet } from '../api/interfaces'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function validateNetwork(network: any, wallet: Wallet): ReturnWithValidationResult { + let errors: string[] = []; + if (typeof network === 'string') { + const walletNetwork = wallet.getNetwork(); + if (network !== walletNetwork.chainId) { + errors.push('Invalid network not equal to wallet network'); + } else { + return { result: network, isValid: errors.length === 0, errors: errors }; + } + } else { + errors.push('Invalid network not a string'); + } + return { result: undefined, isValid: errors.length === 0, errors: errors }; +} + +export function validateFrom(from: unknown, wallet: Wallet): ReturnWithValidationResult { + let errors: string[] = []; + if (typeof from !== 'string') { + errors.push('Invalid from address not a string'); + return { result: '', isValid: errors.length === 0, errors: errors }; + } + if (!isValidAddress(from)) { + errors.push('Invalid from address'); + return { result: '', isValid: errors.length === 0, errors: errors }; + } + const fromAddress = Address.parse(from); + const walletAddress = Address.parse(wallet.getAddress()); + if (!fromAddress.equals(walletAddress)) { + errors.push('Invalid from address not equal to wallet address'); + return { result: '', isValid: errors.length === 0, errors: errors }; + } + return { result: from, isValid: errors.length === 0, errors: errors }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function validateValidUntil(validUntil: any): ReturnWithValidationResult { + let errors: string[] = []; + if (typeof validUntil === 'undefined') { + return { result: 0, isValid: errors.length === 0, errors: errors }; + } + if (typeof validUntil !== 'number' || isNaN(validUntil)) { + errors.push('Invalid validUntil timestamp not a number'); + return { result: 0, isValid: errors.length === 0, errors: errors }; + } + const now = Math.floor(Date.now() / 1000); + if (validUntil < now) { + errors.push('Invalid validUntil timestamp'); + return { result: 0, isValid: errors.length === 0, errors: errors }; + } + return { result: validUntil, isValid: errors.length === 0, errors: errors }; +} diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index 323257051..161e1a6dc 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -34,6 +34,10 @@ export { ConnectHandler } from './handlers/ConnectHandler'; export { TransactionHandler } from './handlers/TransactionHandler'; export { SignDataHandler } from './handlers/SignDataHandler'; export { DisconnectHandler } from './handlers/DisconnectHandler'; +export { IntentParser, INTENT_ERROR_CODES } from './handlers/IntentParser'; +export type { ParsedIntentUrl, IntentErrorCode } from './handlers/IntentParser'; +export { IntentResolver } from './handlers/IntentResolver'; +export { IntentHandler } from './handlers/IntentHandler'; export { WalletV5, WalletV5R1Id, Opcodes } from './contracts/w5/WalletV5R1'; export type { WalletV5Config } from './contracts/w5/WalletV5R1'; export { WalletV5R1CodeCell, WalletV5R1CodeBoc } from './contracts/w5/WalletV5R1.source'; diff --git a/packages/walletkit/src/types/emitter.ts b/packages/walletkit/src/types/emitter.ts index 735b3d032..3540eba3d 100644 --- a/packages/walletkit/src/types/emitter.ts +++ b/packages/walletkit/src/types/emitter.ts @@ -6,8 +6,10 @@ * */ +import type { ConnectRequest } from '@tonconnect/protocol'; + import type { TransactionEmulatedTrace } from '../api/models'; -import type { RawBridgeEventRestoreConnection, RawBridgeEventTransaction } from './internal'; +import type { RawBridgeEvent, RawBridgeEventRestoreConnection } from './internal'; import type { EventEmitter } from '../core/EventEmitter'; import type { StreamingEvents } from '../api/models'; @@ -16,14 +18,28 @@ import type { StreamingEvents } from '../api/models'; */ export type SharedKitEvents = StreamingEvents; +/** + * Payload for the bridge-connect-with-intent event emitted when a JS Bridge + * connectWithIntent() call arrives from the injected provider. + */ +export interface BridgeConnectWithIntentPayload { + intentUrl: string; + connectRequest?: ConnectRequest; + tabId?: string; + messageId?: string; + walletId?: string; +} + /** * Definition of all events emitted by the TonWalletKit. */ export type WalletKitEvents = { restoreConnection: RawBridgeEventRestoreConnection; - eventError: RawBridgeEventTransaction; + eventError: RawBridgeEvent; emulationResult: TransactionEmulatedTrace; bridgeStorageUpdated: object; + 'bridge-draft-intent': RawBridgeEvent; + 'bridge-connect-with-intent': BridgeConnectWithIntentPayload; } & SharedKitEvents; export type WalletKitEventEmitter = EventEmitter; diff --git a/packages/walletkit/src/types/internal.ts b/packages/walletkit/src/types/internal.ts index 4ac6dcf99..c6dac703c 100644 --- a/packages/walletkit/src/types/internal.ts +++ b/packages/walletkit/src/types/internal.ts @@ -191,6 +191,15 @@ export function toConnectTransactionParamContent(request: TransactionRequest): R export type RawBridgeEventTransaction = BridgeEvent & SendTransactionRpcRequest; export type RawBridgeEventSignData = BridgeEvent & SignDataRpcRequest; +// TODO: Replace with BridgeEvent & SignMessageRpcRequest from @tonconnect/protocol once +// signMessage is standardized and added to the protocol package (currently absent in v2.4.0). +export interface RawBridgeEventSignMessage extends BridgeEvent { + id: string; + method: 'signMessage'; + params: [string]; // JSON-stringified, same format as sendTransaction params + timestamp?: number; +} + export interface RawBridgeEventDisconnect extends BridgeEvent { id: string; method: 'disconnect'; @@ -206,10 +215,11 @@ export type RawBridgeEvent = | RawBridgeEventRestoreConnection | RawBridgeEventTransaction | RawBridgeEventSignData + | RawBridgeEventSignMessage | RawBridgeEventDisconnect; // Internal event routing types -export type EventType = 'connect' | 'sendTransaction' | 'signData' | 'disconnect' | 'restoreConnection'; +export type EventType = 'connect' | 'sendTransaction' | 'signData' | 'signMessage' | 'disconnect' | 'restoreConnection' | 'txDraft' | 'signMsgDraft' | 'actionDraft' | 'connectWithIntent'; export interface EventHandler { canHandle(event: RawBridgeEvent): event is V; diff --git a/packages/walletkit/src/types/kit.ts b/packages/walletkit/src/types/kit.ts index 878fe718f..a5c8c3aa3 100644 --- a/packages/walletkit/src/types/kit.ts +++ b/packages/walletkit/src/types/kit.ts @@ -22,11 +22,23 @@ import type { RequestErrorEvent, DisconnectionEvent, SignDataRequestEvent, + SignMessageRequestEvent, ConnectionRequestEvent, SignDataApprovalResponse, + SignMessageApprovalResponse, TONConnectSession, SendTransactionApprovalResponse, ConnectionApprovalResponse, + IntentRequestEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentTransactionResponse, + IntentSignDataResponse, + IntentErrorResponse, + IntentActionItem, + BatchedIntentEvent, + ConnectionApprovalProof, } from '../api/models'; import type { SwapAPI, StakingAPI } from '../api/interfaces'; import type { NetworkManager } from '../core/NetworkManager'; @@ -131,6 +143,15 @@ export interface ITonWalletKit { /** Reject a sign data request */ rejectSignDataRequest(event: SignDataRequestEvent, reason?: string): Promise; + /** Approve a sign message (sign-only transaction) request */ + approveSignMessageRequest( + event: SignMessageRequestEvent, + response?: SignMessageApprovalResponse, + ): Promise; + + /** Reject a sign message request */ + rejectSignMessageRequest(event: SignMessageRequestEvent, reason?: string): Promise; + // === Event Handlers === /** Register connect request handler */ @@ -142,6 +163,9 @@ export interface ITonWalletKit { /** Register sign data request handler */ onSignDataRequest(cb: (event: SignDataRequestEvent) => void): void; + /** Register sign message request handler */ + onSignMessageRequest(cb: (event: SignMessageRequestEvent) => void): void; + /** Register disconnect handler */ onDisconnect(cb: (event: DisconnectionEvent) => void): void; @@ -152,9 +176,59 @@ export interface ITonWalletKit { removeConnectRequestCallback(cb: (event: ConnectionRequestEvent) => void): void; removeTransactionRequestCallback(cb: (event: SendTransactionRequestEvent) => void): void; removeSignDataRequestCallback(cb: (event: SignDataRequestEvent) => void): void; + removeSignMessageRequestCallback(cb: (event: SignMessageRequestEvent) => void): void; removeDisconnectCallback(cb: (event: DisconnectionEvent) => void): void; removeErrorCallback(cb: (event: RequestErrorEvent) => void): void; + // === Intent API === + + /** Check if a URL is a TonConnect intent deep link */ + isIntentUrl(url: string): boolean; + + /** Handle a TonConnect intent URL for the given wallet */ + handleIntentUrl( + url: string, + walletId: string, + jsBridgeContext?: { isJsBridge: boolean; tabId?: string; messageId?: string; connectRequest?: unknown }, + ): Promise; + + /** Register intent request handler */ + onIntentRequest(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void; + + /** Remove intent request handler */ + removeIntentRequestCallback(cb: (event: IntentRequestEvent | BatchedIntentEvent) => void): void; + + /** Approve a transaction draft intent */ + approveTransactionDraft(event: TransactionIntentRequestEvent, walletId: string): Promise; + + /** Approve a sign data intent */ + approveSignDataIntent(event: SignDataIntentRequestEvent, walletId: string): Promise; + + /** Approve an action draft intent */ + approveActionDraft( + event: ActionIntentRequestEvent, + walletId: string, + ): Promise; + + /** Approve a batched intent (connect + transaction/signData/action) */ + approveBatchedIntent( + batch: BatchedIntentEvent, + walletId: string, + proof?: ConnectionApprovalProof, + ): Promise; + + /** Reject any intent request */ + rejectIntent( + event: IntentRequestEvent | BatchedIntentEvent, + reason?: string, + errorCode?: number, + ): Promise; + + /** Convert intent action items to a TransactionRequest for preview */ + intentItemsToTransactionRequest(items: IntentActionItem[], walletId: string): Promise; + + // === Jettons API === + /** Jettons API access */ jettons: JettonsAPI; diff --git a/packages/walletkit/src/validation/transaction.ts b/packages/walletkit/src/validation/transaction.ts index 6509020e7..bbcf91373 100644 --- a/packages/walletkit/src/validation/transaction.ts +++ b/packages/walletkit/src/validation/transaction.ts @@ -10,7 +10,7 @@ import { Cell } from '@ton/core'; import type { ValidationResult } from './types'; import { validateTonAddress } from './address'; -import { isFriendlyTonAddress } from '../utils/address'; +import { isFriendlyTonAddress, isValidAddress } from '../utils/address'; /** * Human-readable transaction message @@ -36,8 +36,12 @@ export interface HumanReadableTx { /** * Validate transaction messages array */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function validateTransactionMessages(messages: any[], isTonConnect: boolean = true): ValidationResult { + +export function validateTransactionMessages( + messages: unknown[], + isTonConnect: boolean = true, + requireFriendlyAddress: boolean = true, +): ValidationResult { const errors: string[] = []; if (!Array.isArray(messages)) { @@ -52,7 +56,7 @@ export function validateTransactionMessages(messages: any[], isTonConnect: boole // Validate each message messages.forEach((msg, index) => { - const msgErrors = validateTransactionMessage(msg, isTonConnect).errors; + const msgErrors = validateTransactionMessage(msg, isTonConnect, requireFriendlyAddress).errors; msgErrors.forEach((error) => { errors.push(`message[${index}]: ${error}`); }); @@ -67,8 +71,12 @@ export function validateTransactionMessages(messages: any[], isTonConnect: boole /** * Validate individual transaction message */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function validateTransactionMessage(message: any, isTonConnect: boolean = true): ValidationResult { + +export function validateTransactionMessage( + message: unknown, + isTonConnect: boolean = true, + requireFriendlyAddress: boolean = true, +): ValidationResult { const errors: string[] = []; if (typeof message !== 'object') { @@ -79,12 +87,12 @@ export function validateTransactionMessage(message: any, isTonConnect: boolean = return { isValid: false, errors: ['Invalid message'] }; } - if (isTonConnect && typeof message.mode !== 'undefined') { + if (isTonConnect && 'mode' in message && typeof message.mode !== 'undefined') { errors.push('mode must be undefined for tonconnect!'); } // Object format - validate required fields - const objErrors = validateMessageObject(message).errors; + const objErrors = validateMessageObject(message, requireFriendlyAddress).errors; errors.push(...objErrors); return { @@ -97,15 +105,19 @@ export function validateTransactionMessage(message: any, isTonConnect: boolean = * Validate message object structure */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function validateMessageObject(message: any): ValidationResult { +export function validateMessageObject(message: any, requireFriendlyAddress: boolean = true): ValidationResult { const errors: string[] = []; // Required fields if (!message.address || typeof message.address !== 'string') { errors.push('to address is required and must be a string'); } else { - if (!isFriendlyTonAddress(message.address)) { - errors.push('to address must be a valid friendly TON address'); + if (requireFriendlyAddress ? !isFriendlyTonAddress(message.address) : !isValidAddress(message.address)) { + errors.push( + requireFriendlyAddress + ? 'to address must be a valid friendly TON address' + : 'to address must be a valid TON address', + ); } }