diff --git a/packages/ui/src/library.ts b/packages/ui/src/library.ts index b7479b56..86348602 100644 --- a/packages/ui/src/library.ts +++ b/packages/ui/src/library.ts @@ -1,4 +1,19 @@ export { TonConnectUI as default } from './ton-connect-ui'; export { TonConnectUI } from './ton-connect-ui'; +export type { + UserActionEvent, + ConnectionEvent, + ConnectionStartedEvent, + ConnectionCompletedEvent, + ConnectionErrorEvent, + ConnectionRestoringStartedEvent, + ConnectionRestoringCompletedEvent, + ConnectionRestoringErrorEvent, + DisconnectionEvent, + TransactionSigningEvent, + TransactionSentForSignatureEvent, + TransactionSignedEvent, + TransactionSigningFailedEvent +} from './tracker/types'; export * from './models'; export * from './errors'; diff --git a/packages/ui/src/ton-connect-ui.ts b/packages/ui/src/ton-connect-ui.ts index 3a81900a..7574c6d7 100644 --- a/packages/ui/src/ton-connect-ui.ts +++ b/packages/ui/src/ton-connect-ui.ts @@ -37,6 +37,7 @@ import { isInTMA, sendExpand } from 'src/app/utils/tma-api'; import { redirectToTelegram, redirectToWallet } from 'src/app/utils/url-strategy-helpers'; import { SingleWalletModalManager } from 'src/managers/single-wallet-modal-manager'; import { SingleWalletModal, SingleWalletModalState } from 'src/models/single-wallet-modal'; +import { TonConnectTracker } from 'src/tracker/ton-connect-tracker'; export class TonConnectUI { public static getWallets(): Promise { @@ -47,6 +48,8 @@ export class TonConnectUI { private readonly preferredWalletStorage = new PreferredWalletStorage(); + private readonly tracker = new TonConnectTracker(); + private walletInfo: WalletInfoWithOpenMethod | null = null; private systemThemeChangeUnsubscribe: (() => void) | null = null; @@ -218,11 +221,15 @@ export class TonConnectUI { this.subscribeToWalletChange(); if (options?.restoreConnection !== false) { + this.tracker.trackConnectionRestoringStarted(); this.connectionRestored = new Promise(async resolve => { await this.connector.restoreConnection(); if (!this.connector.connected) { + this.tracker.trackConnectionRestoringError('Connection was not restored'); this.walletInfoStorage.removeWalletInfo(); + } else { + this.tracker.trackConnectionRestoringCompleted(this.wallet); } resolve(this.connector.connected); @@ -287,6 +294,7 @@ export class TonConnectUI { * Opens the modal window, returns a promise that resolves after the modal window is opened. */ public async openModal(): Promise { + this.tracker.trackConnectionStarted(); return this.modal.open(); } @@ -294,6 +302,7 @@ export class TonConnectUI { * Closes the modal window. */ public closeModal(): void { + this.tracker.trackConnectionError('Connection was cancelled'); this.modal.close(); } @@ -316,6 +325,7 @@ export class TonConnectUI { * @experimental */ public async openSingleWalletModal(wallet: string): Promise { + this.tracker.trackConnectionStarted(); return this.singleWalletModal.open(wallet); } @@ -324,6 +334,7 @@ export class TonConnectUI { * @experimental */ public closeSingleWalletModal(): void { + this.tracker.trackConnectionError('Connection was cancelled'); this.singleWalletModal.close(); } @@ -366,6 +377,8 @@ export class TonConnectUI { * Disconnect wallet and clean localstorage. */ public disconnect(): Promise { + this.tracker.trackDisconnection(this.wallet, 'dapp'); + widgetController.clearAction(); widgetController.removeSelectedWalletInfo(); this.walletInfoStorage.removeWalletInfo(); @@ -381,7 +394,10 @@ export class TonConnectUI { tx: SendTransactionRequest, options?: ActionConfiguration ): Promise { + this.tracker.trackTransactionSentForSignature(this.wallet, tx); + if (!this.connected) { + this.tracker.trackTransactionSigningFailed(this.wallet, tx, 'Wallet was not connected'); throw new TonConnectUIError('Connect wallet to send a transaction.'); } @@ -459,6 +475,8 @@ export class TonConnectUI { onRequestSent ); + this.tracker.trackTransactionSigned(this.wallet, tx, result); + widgetController.setAction({ name: 'transaction-sent', showNotification: notifications.includes('success'), @@ -467,6 +485,8 @@ export class TonConnectUI { return result; } catch (e) { + this.tracker.trackTransactionSigningFailed(this.wallet, tx, e.message); + widgetController.setAction({ name: 'transaction-canceled', showNotification: notifications.includes('error'), @@ -554,14 +574,18 @@ export class TonConnectUI { options: WaitWalletConnectionOptions ): Promise { return new Promise((resolve, reject) => { + this.tracker.trackConnectionStarted(); const { ignoreErrors = false, signal = null } = options; if (signal && signal.aborted) { + this.tracker.trackConnectionError('Connection was cancelled'); return reject(new TonConnectUIError('Wallet was not connected')); } const onStatusChangeHandler = async (wallet: ConnectedWallet | null): Promise => { if (!wallet) { + this.tracker.trackConnectionError('Connection was cancelled'); + if (ignoreErrors) { // skip empty wallet status changes to avoid aborting the process return; @@ -570,12 +594,16 @@ export class TonConnectUI { unsubscribe(); reject(new TonConnectUIError('Wallet was not connected')); } else { + this.tracker.trackConnectionCompleted(wallet); + unsubscribe(); resolve(wallet); } }; const onErrorsHandler = (reason: TonConnectError): void => { + this.tracker.trackConnectionError(reason.message); + if (ignoreErrors) { // skip errors to avoid aborting the process return; diff --git a/packages/ui/src/tracker/ton-connect-tracker.ts b/packages/ui/src/tracker/ton-connect-tracker.ts new file mode 100644 index 00000000..305c1af9 --- /dev/null +++ b/packages/ui/src/tracker/ton-connect-tracker.ts @@ -0,0 +1,184 @@ +import { + createConnectionCompletedEvent, + createConnectionErrorEvent, + createConnectionRestoringCompletedEvent, + createConnectionRestoringErrorEvent, + createConnectionRestoringStartedEvent, + createConnectionStartedEvent, + createDisconnectionEvent, + createTransactionSentForSignatureEvent, + createTransactionSignedEvent, + createTransactionSigningFailedEvent, + UserActionEvent +} from './types'; +import { getWindow } from 'src/app/utils/web-api'; + +/** + * Tracker for TonConnectUI user actions, such as transaction signing, connection, etc. + * + * List of events: + * — `connection-started`: when a user starts connecting a wallet. + * — `connection-completed`: when a user successfully connected a wallet. + * — `connection-error`: when a user cancels a connection or there is an error during the connection process. + * — `disconnection`: when a user starts disconnecting a wallet. + * — `transaction-sent-for-signature`: when a user sends a transaction for signature. + * — `transaction-signed`: when a user successfully signs a transaction. + * — `transaction-signing-failed`: when a user cancels transaction signing or there is an error during the signing process. + * + * If you want to track user actions, you can subscribe to the window events with prefix `ton-connect-ui-`: + * ```typescript + * window.addEventListener('ton-connect-ui-transaction-sent-for-signature', (event) => { + * console.log('Transaction init', event.detail); + * }); + * ``` + * + * @internal + */ +export class TonConnectTracker { + /** + * Event prefix for user actions. + * @private + */ + private readonly eventPrefix = 'ton-connect-ui-'; + + /** + * Window object, possibly undefined in the server environment. + * @private + */ + private readonly window: Window | undefined = getWindow(); + + /** + * Emit user action event to the window. + * @param eventDetails + * @private + */ + private dispatchUserActionEvent(eventDetails: UserActionEvent): void { + try { + const eventName = `${this.eventPrefix}${eventDetails.type}`; + const event = new CustomEvent(eventName, { detail: eventDetails }); + this.window?.dispatchEvent(event); + } catch (e) {} + } + + /** + * Track connection init event. + * @param args + */ + public trackConnectionStarted(...args: Parameters): void { + try { + const event = createConnectionStartedEvent(...args); + this.dispatchUserActionEvent(event); + } catch (e) {} + } + + /** + * Track connection success event. + * @param args + */ + public trackConnectionCompleted( + ...args: Parameters + ): void { + try { + const event = createConnectionCompletedEvent(...args); + this.dispatchUserActionEvent(event); + } catch (e) {} + } + + /** + * Track connection error event. + * @param args + */ + public trackConnectionError(...args: Parameters): void { + try { + const event = createConnectionErrorEvent(...args); + this.dispatchUserActionEvent(event); + } catch (e) {} + } + + /** + * Track connection restoring init event. + * @param args + */ + public trackConnectionRestoringStarted( + ...args: Parameters + ): void { + try { + const event = createConnectionRestoringStartedEvent(...args); + this.dispatchUserActionEvent(event); + } catch (e) {} + } + + /** + * Track connection restoring success event. + * @param args + */ + public trackConnectionRestoringCompleted( + ...args: Parameters + ): void { + try { + const event = createConnectionRestoringCompletedEvent(...args); + this.dispatchUserActionEvent(event); + } catch (e) {} + } + + /** + * Track connection restoring error event. + * @param args + */ + public trackConnectionRestoringError( + ...args: Parameters + ): void { + try { + const event = createConnectionRestoringErrorEvent(...args); + this.dispatchUserActionEvent(event); + } catch (e) {} + } + + /** + * Track disconnect event. + * @param args + */ + public trackDisconnection(...args: Parameters): void { + try { + const event = createDisconnectionEvent(...args); + this.dispatchUserActionEvent(event); + } catch (e) {} + } + + /** + * Track transaction init event. + * @param args + */ + public trackTransactionSentForSignature( + ...args: Parameters + ): void { + try { + const event = createTransactionSentForSignatureEvent(...args); + this.dispatchUserActionEvent(event); + } catch (e) {} + } + + /** + * Track transaction signed event. + * @param args + */ + public trackTransactionSigned(...args: Parameters): void { + try { + const event = createTransactionSignedEvent(...args); + this.dispatchUserActionEvent(event); + } catch (e) {} + } + + /** + * Track transaction error event. + * @param args + */ + public trackTransactionSigningFailed( + ...args: Parameters + ): void { + try { + const event = createTransactionSigningFailedEvent(...args); + this.dispatchUserActionEvent(event); + } catch (e) {} + } +} diff --git a/packages/ui/src/tracker/types.ts b/packages/ui/src/tracker/types.ts new file mode 100644 index 00000000..f903717b --- /dev/null +++ b/packages/ui/src/tracker/types.ts @@ -0,0 +1,422 @@ +import { ConnectItem } from '@tonconnect/protocol'; +import { SendTransactionRequest, SendTransactionResponse, Wallet } from '@tonconnect/sdk'; + +/** + * Requested authentication type: 'ton_addr' or 'ton_proof'. + */ +export type AuthType = ConnectItem['name']; + +/** + * Information about a connected wallet. + */ +export type ConnectionInfo = { + /** + * Connected wallet address. + */ + address: string | null; + /** + * Connected chain ID. + */ + chainId: string | null; + /** + * Wallet provider. + */ + provider: 'http' | 'injected' | null; + /** + * Wallet type: 'tonkeeper', 'tonhub', etc. + */ + walletType: string | null; + /** + * Wallet version. + */ + walletVersion: string | null; + /** + * Requested authentication types. + */ + authType: AuthType | null; +}; + +function createConnnectionInfo(wallet: Wallet | null): ConnectionInfo { + let authType: AuthType | null = null; + if (wallet?.connectItems?.tonProof) { + authType = 'proof' in wallet.connectItems.tonProof ? 'ton_proof' : null; + } else if (wallet?.connectItems) { + authType = 'ton_addr'; + } + + return { + address: wallet?.account?.address ?? null, + chainId: wallet?.account?.chain ?? null, + provider: wallet?.provider ?? null, + walletType: wallet?.device.appName ?? null, + walletVersion: wallet?.device.appVersion ?? null, + authType: authType + }; +} + +/** + * Initial connection event when a user initiates a connection. + */ +export type ConnectionStartedEvent = { + /** + * Event type. + */ + type: 'connection-started'; +}; + +/** + * Create a connection init event. + */ +export function createConnectionStartedEvent(): ConnectionStartedEvent { + return { + type: 'connection-started' + }; +} + +/** + * Successful connection event when a user successfully connected a wallet. + */ +export type ConnectionCompletedEvent = { + /** + * Event type. + */ + type: 'connection-completed'; + /** + * Wallet information. + */ + connectionInfo: ConnectionInfo; +}; + +/** + * Create a connection completed event. + * @param wallet + */ +export function createConnectionCompletedEvent(wallet: Wallet | null): ConnectionCompletedEvent { + return { + type: 'connection-completed', + connectionInfo: createConnnectionInfo(wallet) + }; +} + +/** + * Connection error event when a user cancels a connection or there is an error during the connection process. + */ +export type ConnectionErrorEvent = { + /** + * Event type. + */ + type: 'connection-error'; + /** + * Reason for the error. + */ + reason: string; +}; + +/** + * Create a connection error event. + * @param reason + */ +export function createConnectionErrorEvent(reason: string): ConnectionErrorEvent { + return { + type: 'connection-error', + reason + }; +} + +/** + * Connection events. + */ +export type ConnectionEvent = + | ConnectionStartedEvent + | ConnectionCompletedEvent + | ConnectionErrorEvent; + +/** + * Connection restoring started event when initiates a connection restoring process. + */ +export type ConnectionRestoringStartedEvent = { + /** + * Event type. + */ + type: 'connection-restoring-started'; +}; + +/** + * Create a connection restoring started event. + */ +export function createConnectionRestoringStartedEvent(): ConnectionRestoringStartedEvent { + return { + type: 'connection-restoring-started' + }; +} + +/** + * Connection restoring completed event when successfully restored a connection. + */ +export type ConnectionRestoringCompletedEvent = { + /** + * Event type. + */ + type: 'connection-restoring-completed'; + /** + * Wallet information. + */ + connectionInfo: ConnectionInfo; +}; + +/** + * Create a connection restoring completed event. + * @param wallet + */ +export function createConnectionRestoringCompletedEvent( + wallet: Wallet | null +): ConnectionRestoringCompletedEvent { + return { + type: 'connection-restoring-completed', + connectionInfo: createConnnectionInfo(wallet) + }; +} + +/** + * Connection restoring error event when there is an error during the connection restoring process. + */ +export type ConnectionRestoringErrorEvent = { + /** + * Event type. + */ + type: 'connection-restoring-error'; + /** + * Reason for the error. + */ + reason: string; +}; + +/** + * Create a connection restoring error event. + * @param reason + */ +export function createConnectionRestoringErrorEvent(reason: string): ConnectionRestoringErrorEvent { + return { + type: 'connection-restoring-error', + reason + }; +} + +/** + * Connection restoring events. + */ +export type ConnectionRestoringEvent = + | ConnectionRestoringStartedEvent + | ConnectionRestoringCompletedEvent + | ConnectionRestoringErrorEvent; + +/** + * Transaction message. + */ +export type TransactionMessage = { + /** + * Recipient address. + */ + address: string | null; + /** + * Transfer amount. + */ + amount: string | null; +}; + +/** + * Transaction information. + */ +export type TransactionInfo = { + /** + * Transaction validity time in unix timestamp. + */ + validUntil: number | null; + /** + * Sender address. + */ + from: string | null; + /** + * Transaction messages. + */ + messages: TransactionMessage[]; +}; + +function createTransactionInfo(transaction: SendTransactionRequest): TransactionInfo { + return { + validUntil: transaction.validUntil ?? null, + from: transaction.from ?? null, + messages: transaction.messages.map(message => ({ + address: message.address ?? null, + amount: message.amount ?? null + })) + }; +} + +/** + * Initial transaction event when a user initiates a transaction. + */ +export type TransactionSentForSignatureEvent = { + /** + * Event type. + */ + type: 'transaction-sent-for-signature'; + /** + * Wallet information. + */ + connectionInfo: ConnectionInfo; + /** + * Transaction information. + */ + transactionInfo: TransactionInfo; +}; + +/** + * Create a transaction init event. + * @param wallet + * @param transaction + */ +export function createTransactionSentForSignatureEvent( + wallet: Wallet | null, + transaction: SendTransactionRequest +): TransactionSentForSignatureEvent { + return { + type: 'transaction-sent-for-signature', + connectionInfo: createConnnectionInfo(wallet), + transactionInfo: createTransactionInfo(transaction) + }; +} + +/** + * Transaction signed event when a user successfully signed a transaction. + */ +export type TransactionSignedEvent = { + /** + * Event type. + */ + type: 'transaction-signed'; + /** + * Wallet information. + */ + connectionInfo: ConnectionInfo; + /** + * Transaction information. + */ + transactionInfo: TransactionInfo; + /** + * Signed transaction. + */ + signedTransaction: string; +}; + +/** + * Create a transaction signed event. + * @param wallet + * @param transaction + * @param signedTransaction + */ +export function createTransactionSignedEvent( + wallet: Wallet | null, + transaction: SendTransactionRequest, + signedTransaction: SendTransactionResponse +): TransactionSignedEvent { + return { + type: 'transaction-signed', + connectionInfo: createConnnectionInfo(wallet), + transactionInfo: createTransactionInfo(transaction), + signedTransaction: signedTransaction.boc + }; +} + +/** + * Transaction error event when a user cancels a transaction or there is an error during the transaction process. + */ +export type TransactionSigningFailedEvent = { + /** + * Event type. + */ + type: 'transaction-signing-failed'; + /** + * Wallet information. + */ + connectionInfo: ConnectionInfo; + /** + * Transaction information. + */ + transactionInfo: TransactionInfo; + /** + * Reason for the error. + */ + reason: string; +}; + +/** + * Create a transaction error event. + * @param wallet + * @param transaction + * @param reason + */ +export function createTransactionSigningFailedEvent( + wallet: Wallet | null, + transaction: SendTransactionRequest, + reason: string +): TransactionSigningFailedEvent { + return { + type: 'transaction-signing-failed', + connectionInfo: createConnnectionInfo(wallet), + transactionInfo: createTransactionInfo(transaction), + reason + }; +} + +/** + * Transaction events. + */ +export type TransactionSigningEvent = + | TransactionSentForSignatureEvent + | TransactionSignedEvent + | TransactionSigningFailedEvent; + +/** + * Disconnect event when a user initiates a disconnection. + */ +export type DisconnectionEvent = { + /** + * Event type. + */ + type: 'disconnection'; + /** + * Wallet information. + */ + connectionInfo: ConnectionInfo; + /** + * Disconnect scope: 'dapp' or 'wallet'. + */ + scope: 'dapp' | 'wallet'; +}; + +/** + * Create a disconnect event. + * @param wallet + * @param scope + * @returns + */ +export function createDisconnectionEvent( + wallet: Wallet | null, + scope: 'dapp' | 'wallet' +): DisconnectionEvent { + return { + type: 'disconnection', + connectionInfo: createConnnectionInfo(wallet), + scope: scope + }; +} + +/** + * User action events. + */ +export type UserActionEvent = + | ConnectionEvent + | ConnectionRestoringEvent + | DisconnectionEvent + | TransactionSigningEvent;