import type { TonConnect } from '@tonconnect/sdk' import type { NetworkId, AvailableAddress, WalletMetadata, TonNativeTransferRequest, TransactionResult, SignatureType, TonSignDataPayload } from '@meshconnect/uwc-types' import { NamespacedStorage, TON_CONNECT_INJECTED_STORAGE_PREFIX, toFriendlyAddress, tryRestoreInjectedSession } from '@meshconnect/uwc-ton-connector' import { getTonWallets, type DetectedTonWallet } from '../../ton-discovery' import { extractTonSdkErrorMessage } from '../../utils/error-utils' import { TonTransactionService } from './ton-transaction-service' const CONNECT_TIMEOUT_MS = 60_000 interface TonConnectErrorEvent { payload?: { code?: number; message?: string } message?: string } export class TonWalletService { private detectedWallets: DetectedTonWallet[] = [] private connectedAddress: string | null = null private connectedJsBridgeKey: string | null = null private sdkInstance: TonConnect | null = null private manifestUrl: string | (() => string) | undefined private onSessionRestored: (() => void) | undefined private toUserFriendlyAddress: | ((hex: string, testOnly?: boolean) => string) | null = null private UserRejectsError: (new (...args: never[]) => Error) | null = null private transactionService: TonTransactionService // Restore preflight bookkeeping — mirrors TonConnectConnector. private abortPendingConnect: (() => void) | null = null private connectGeneration = 0 constructor( manifestUrl?: string | (() => string), onSessionRestored?: () => void ) { this.manifestUrl = manifestUrl this.onSessionRestored = onSessionRestored this.transactionService = new TonTransactionService() } async initializeDiscovery(expectedWallets?: WalletMetadata[]): Promise { this.detectedWallets = await getTonWallets(expectedWallets) } getDetectedWallets(): DetectedTonWallet[] { return this.detectedWallets } findWalletByUuid(uuid: string): DetectedTonWallet | undefined { return this.detectedWallets.find(w => w.uuid === uuid) } private async cleanupSdk(sdk: TonConnect): Promise { try { await sdk.disconnect() } catch { /* best effort */ } if (this.sdkInstance === sdk) { this.sdkInstance = null } } async connect( jsBridgeKey: string ): Promise<{ address: string; publicKey?: string }> { const resolvedManifestUrl = typeof this.manifestUrl === 'function' ? this.manifestUrl() : this.manifestUrl if (!resolvedManifestUrl) { throw new Error( 'TON Connect manifestUrl is required. Pass tonConnectConfig to the connector.' ) } // Tear down any prior SDK and abort an in-flight restore before reconnecting. await this.disconnect() const { TonConnect, toUserFriendlyAddress, UserRejectsError } = await import('@tonconnect/sdk') this.toUserFriendlyAddress = toUserFriendlyAddress this.UserRejectsError = UserRejectsError this.connectGeneration++ const generation = this.connectGeneration const sdkRef = new TonConnect({ storage: new NamespacedStorage(TON_CONNECT_INJECTED_STORAGE_PREFIX), manifestUrl: resolvedManifestUrl, analytics: { mode: 'off' } }) this.sdkInstance = sdkRef // Resume an existing injected session before a fresh handshake. const restored = await tryRestoreInjectedSession( { requestedJsBridgeKey: jsBridgeKey, sdk: sdkRef, generation }, { getCurrentGeneration: () => this.connectGeneration, setAbortHook: fn => { this.abortPendingConnect = fn }, toUserFriendlyAddressFn: this.toUserFriendlyAddress, ...(this.onSessionRestored ? { onSessionRestored: this.onSessionRestored } : {}) } ).catch(async error => { await this.cleanupSdk(sdkRef) throw error }) if (restored) { this.connectedAddress = restored.address this.connectedJsBridgeKey = jsBridgeKey return restored } // No prior session — fresh handshake (preflight cleaned any stale state). // Listen BEFORE connect — sdk.connect() is fire-and-forget const walletPromise = new Promise<{ address: string; publicKey?: string }>( (resolve, reject) => { let settled = false const timeout = setTimeout(async () => { if (settled) return settled = true unsubscribe() await this.cleanupSdk(sdkRef) reject(new Error('TON wallet connection timed out')) }, CONNECT_TIMEOUT_MS) const unsubscribe = sdkRef.onStatusChange( wallet => { if (settled) return settled = true clearTimeout(timeout) unsubscribe() if (wallet?.account?.address) { resolve({ address: wallet.account.address, ...(wallet.account.publicKey ? { publicKey: wallet.account.publicKey } : {}) }) } else { reject(new Error('TON wallet connection was rejected')) } }, error => { if (settled) return settled = true clearTimeout(timeout) unsubscribe() const errorObj = error as TonConnectErrorEvent const message = errorObj?.payload?.message || extractTonSdkErrorMessage(error) || 'TON wallet connection was rejected' reject(new Error(message)) } ) } ) this.sdkInstance.connect({ jsBridgeKey }) const { address, publicKey } = await walletPromise try { this.connectedAddress = this.toUserFriendlyAddress ? toFriendlyAddress(address, this.toUserFriendlyAddress) : address this.connectedJsBridgeKey = jsBridgeKey } catch (error) { await this.cleanupSdk(sdkRef) throw error } return { address: this.connectedAddress, ...(publicKey ? { publicKey } : {}) } } async signMessage( message: string, payloadOverride?: TonSignDataPayload ): Promise { if (!this.sdkInstance) { throw new Error('No active TON connection') } return this.transactionService.signMessage( message, this.sdkInstance, this.UserRejectsError, payloadOverride ) } async sendTransaction( request: TonNativeTransferRequest ): Promise { if (!this.sdkInstance) { throw new Error('No active TON connection') } return this.transactionService.sendTransaction( request, this.sdkInstance, this.UserRejectsError ) } async checkExistingConnection(): Promise { if (!this.sdkInstance) return null try { const account = this.sdkInstance.account if (account?.address) { return this.toUserFriendlyAddress ? toFriendlyAddress(account.address, this.toUserFriendlyAddress) : account.address } } catch { // Not connected } return null } buildAvailableAddresses( supportedNetworkIds: string[], address: string ): AvailableAddress[] { return supportedNetworkIds .filter(id => id.startsWith('tvm:')) .map(networkId => ({ address, networkId: networkId as NetworkId })) } getAccount(): string | null { return this.connectedAddress } getConnectedJsBridgeKey(): string | null { return this.connectedJsBridgeKey } isConnected(): boolean { return this.connectedAddress !== null } setConnectionState(address: string | null): void { this.connectedAddress = address } async disconnect(): Promise { // Advancing the generation cancels an in-flight restore still parked in its // pre-hook storage read, where the abort hook isn't registered yet. this.connectGeneration++ this.abortPendingConnect?.() this.abortPendingConnect = null if (this.sdkInstance) { try { await this.sdkInstance.disconnect() } catch { // Best effort — wallet may already be disconnected } } this.connectedAddress = null this.connectedJsBridgeKey = null this.sdkInstance = null } getTonBridgeKeysFromExpectedWallets( expectedWallets: WalletMetadata[] = [] ): string[] { return expectedWallets .map( w => w.extensionInjectedProvider?.namespaceMetaData?.tvm?.jsBridgeKey ) .filter((key): key is string => !!key) } }