@meshconnect/uwc-ton-connector 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,347 @@
1
+ import type {
2
+ Network,
3
+ Connector,
4
+ ConnectorResult,
5
+ SwitchNetworkResult,
6
+ TonConnectConfig,
7
+ TonConnectWalletProvider,
8
+ TonNativeTransferRequest,
9
+ TransactionRequest,
10
+ TransactionResult,
11
+ SignatureType,
12
+ NetworkId,
13
+ AvailableAddress
14
+ } from '@meshconnect/uwc-types'
15
+ import type {
16
+ ITonConnect,
17
+ WalletInfoRemote,
18
+ TonConnectOptions
19
+ } from '@tonconnect/sdk'
20
+ import { NamespacedStorage } from './namespaced-storage'
21
+ import {
22
+ buildTonTransactionRequest,
23
+ buildSignDataPayload,
24
+ executeSignData,
25
+ unwrapBoc,
26
+ bocToHash,
27
+ toFriendlyAddress
28
+ } from './ton-transaction-utils'
29
+
30
+ // 5 minutes matches WalletConnect's proposal expiry. The TonConnect protocol
31
+ // has no built-in timeout — without this, a pending connect hangs indefinitely.
32
+ const CONNECT_TIMEOUT_MS = 5 * 60 * 1000
33
+
34
+ // Shape of the error object passed to onStatusChange's errorCallback.
35
+ // Not exported by @tonconnect/sdk — the SDK wraps the raw bridge RPC error internally.
36
+ interface TonConnectErrorEvent {
37
+ payload?: { code?: number; message?: string }
38
+ message?: string
39
+ }
40
+
41
+ export class TonConnectConnector implements Connector {
42
+ private config: TonConnectConfig
43
+ private sdk: ITonConnect | null = null
44
+ private connectionURI: string | undefined
45
+ private connectedAddress: string | null = null
46
+ private connectedNetworkId: NetworkId | null = null
47
+ private visibilityHandler: (() => void) | null = null
48
+ private connectTimeout: ReturnType<typeof setTimeout> | null = null
49
+ private toUserFriendlyAddressFn:
50
+ | ((hex: string, testOnly?: boolean) => string)
51
+ | null = null
52
+ private UserRejectsErrorClass: (new (...args: never[]) => Error) | null = null
53
+ private connectInFlight = false
54
+
55
+ constructor(config: TonConnectConfig) {
56
+ this.config = config
57
+ }
58
+
59
+ /** Connect to a TON wallet via HTTP Bridge (QR code / deeplink). */
60
+ async connect(
61
+ network: Network,
62
+ provider?: TonConnectWalletProvider
63
+ ): Promise<ConnectorResult> {
64
+ if (!provider) {
65
+ throw new Error(
66
+ 'TonConnectWalletProvider is required for TON Connect connection'
67
+ )
68
+ }
69
+ if (!provider.walletListAppName) {
70
+ throw new Error(
71
+ 'walletListAppName is required — set it on TonConnectWalletProvider, or ensure the wallet has a jsBridgeKey in extensionInjectedProvider.tvm'
72
+ )
73
+ }
74
+
75
+ if (this.connectInFlight) {
76
+ throw new Error('TON Connect connection already in progress')
77
+ }
78
+ this.connectInFlight = true
79
+
80
+ if (this.sdk) {
81
+ await this.cleanupSdk(this.sdk)
82
+ }
83
+
84
+ try {
85
+ const { TonConnect, toUserFriendlyAddress, UserRejectsError } =
86
+ await import('@tonconnect/sdk')
87
+ this.toUserFriendlyAddressFn = toUserFriendlyAddress
88
+ this.UserRejectsErrorClass = UserRejectsError
89
+
90
+ const sdkOptions: TonConnectOptions = {
91
+ storage: new NamespacedStorage('uwc-ton-remote'),
92
+ manifestUrl: this.config.manifestUrl
93
+ }
94
+ if (this.config.walletsListSource) {
95
+ sdkOptions.walletsListSource = this.config.walletsListSource
96
+ }
97
+
98
+ this.sdk = new TonConnect(sdkOptions) as ITonConnect
99
+
100
+ // ONC-2717: The SDK supports restoreConnection() to resume a previous
101
+ // session from NamespacedStorage without a new QR scan. Currently we
102
+ // always start fresh (disconnect stale sessions below). Adding session
103
+ // resumption would let users stay connected across page reloads —
104
+ // call sdk.restoreConnection() here and skip the QR flow if it succeeds.
105
+
106
+ if (this.sdk.connected) {
107
+ // Can't reuse: the SDK binds one provider (JS or HTTP bridge) per instance.
108
+ // A stale HTTP session from a previous wallet would corrupt the new one.
109
+ try {
110
+ await this.sdk.disconnect()
111
+ } catch {
112
+ /* best effort — clear stale session from NamespacedStorage */
113
+ }
114
+ }
115
+
116
+ const sdkRef = this.sdk
117
+
118
+ // Resolve bridge URLs from the SDK's wallet list (built-in or custom walletsListSource)
119
+ const wallets = await sdkRef.getWallets()
120
+ const remoteWallets = wallets.filter(
121
+ (w): w is WalletInfoRemote => 'bridgeUrl' in w && 'universalLink' in w
122
+ )
123
+ const walletInfo = remoteWallets.find(
124
+ w => w.appName === provider.walletListAppName
125
+ )
126
+ if (!walletInfo) {
127
+ throw new Error(
128
+ `Wallet '${provider.walletListAppName}' not found in TonConnect wallet list, or missing bridge/universalLink`
129
+ )
130
+ }
131
+
132
+ const address = await this.waitForWalletApproval(
133
+ sdkRef,
134
+ walletInfo.universalLink,
135
+ walletInfo.bridgeUrl
136
+ )
137
+ this.connectionURI = undefined // URI consumed — clear before notify fires
138
+
139
+ const friendlyAddress = this.toUserFriendlyAddressFn
140
+ ? toFriendlyAddress(address, this.toUserFriendlyAddressFn)
141
+ : address
142
+
143
+ this.connectedAddress = friendlyAddress
144
+ this.connectedNetworkId = network.id
145
+ this.setupVisibilityHandler(sdkRef)
146
+
147
+ const availableAddresses = this.buildAvailableAddresses(
148
+ provider.supportedNetworkIds,
149
+ friendlyAddress
150
+ )
151
+
152
+ return {
153
+ networkId: network.id,
154
+ address: friendlyAddress,
155
+ availableAddresses
156
+ }
157
+ } finally {
158
+ this.connectInFlight = false
159
+ }
160
+ }
161
+
162
+ /** Return the connection URI generated by the last connect() call. */
163
+ getConnectionURI(): string {
164
+ if (!this.connectionURI) {
165
+ throw new Error('No connection URI available. Call connect() first.')
166
+ }
167
+ return this.connectionURI
168
+ }
169
+
170
+ /** Sign a message using TON Connect signData. */
171
+ async signMessage(message: string): Promise<SignatureType> {
172
+ if (!this.sdk) {
173
+ throw new Error('No active TON connection')
174
+ }
175
+
176
+ const payload = buildSignDataPayload(message)
177
+ return executeSignData(this.sdk, payload)
178
+ }
179
+
180
+ /** Send a TON transaction via the connected wallet. */
181
+ async sendTransaction(
182
+ request: TransactionRequest
183
+ ): Promise<TransactionResult> {
184
+ if (!this.sdk) {
185
+ throw new Error('No active TON connection')
186
+ }
187
+
188
+ const tonRequest = request as TonNativeTransferRequest
189
+ const senderRawAddress = this.sdk.account?.address
190
+ const txRequest = buildTonTransactionRequest(tonRequest, senderRawAddress)
191
+
192
+ try {
193
+ const result = await this.sdk.sendTransaction(txRequest)
194
+ const boc = unwrapBoc(result as string | { boc: string })
195
+ return bocToHash(boc)
196
+ } catch (error) {
197
+ if (
198
+ this.UserRejectsErrorClass &&
199
+ error instanceof this.UserRejectsErrorClass
200
+ ) {
201
+ throw new Error('Transaction was rejected by the user')
202
+ }
203
+ throw error
204
+ }
205
+ }
206
+
207
+ /** TON is single-chain — switchNetwork returns the current state. */
208
+ async switchNetwork(network: Network): Promise<SwitchNetworkResult> {
209
+ return {
210
+ networkId: this.connectedNetworkId ?? network.id,
211
+ address: this.connectedAddress ?? ''
212
+ }
213
+ }
214
+
215
+ /** Disconnect from the wallet and clean up resources. */
216
+ async disconnect(): Promise<void> {
217
+ if (this.sdk) {
218
+ try {
219
+ await this.sdk.disconnect()
220
+ } catch {
221
+ /* best effort */
222
+ }
223
+ }
224
+ this.sdk = null
225
+ this.connectedAddress = null
226
+ this.connectedNetworkId = null
227
+ this.connectionURI = undefined
228
+ this.connectInFlight = false
229
+ this.clearConnectTimeout()
230
+ this.removeVisibilityHandler()
231
+ }
232
+
233
+ /** Tear down SDK, event listeners, and pending timeouts. */
234
+ async destroy(): Promise<void> {
235
+ await this.disconnect()
236
+ }
237
+
238
+ private async waitForWalletApproval(
239
+ sdk: ITonConnect,
240
+ universalLink: string,
241
+ bridgeUrl: string
242
+ ): Promise<string> {
243
+ return new Promise<string>((resolve, reject) => {
244
+ let settled = false
245
+ let unsubscribe: () => void
246
+
247
+ const cleanup = (): void => {
248
+ this.clearConnectTimeout()
249
+ unsubscribe()
250
+ this.connectionURI = undefined
251
+ this.cleanupSdk(sdk)
252
+ }
253
+
254
+ this.connectTimeout = setTimeout(() => {
255
+ if (settled) return
256
+ settled = true
257
+ cleanup()
258
+ reject(new Error('TON wallet connection timed out'))
259
+ }, CONNECT_TIMEOUT_MS)
260
+
261
+ unsubscribe = sdk.onStatusChange(
262
+ wallet => {
263
+ if (settled) return
264
+ settled = true
265
+ if (wallet?.account?.address) {
266
+ this.clearConnectTimeout()
267
+ unsubscribe()
268
+ resolve(wallet.account.address)
269
+ } else {
270
+ cleanup()
271
+ reject(new Error('TON wallet connection was rejected'))
272
+ }
273
+ },
274
+ error => {
275
+ if (settled) return
276
+ settled = true
277
+ const errorObj = error as TonConnectErrorEvent
278
+ const message =
279
+ errorObj?.payload?.message ||
280
+ this.extractSdkErrorMessage(error) ||
281
+ 'TON wallet connection was rejected'
282
+ cleanup()
283
+ reject(new Error(message))
284
+ }
285
+ )
286
+
287
+ this.connectionURI = sdk.connect({ universalLink, bridgeUrl })
288
+ })
289
+ }
290
+
291
+ private extractSdkErrorMessage(error: unknown): string | undefined {
292
+ if (error && typeof error === 'object' && 'message' in error) {
293
+ const msg = (error as { message: string }).message
294
+ const lines = msg.split('\n')
295
+ if (lines.length > 1) {
296
+ return lines[lines.length - 1]
297
+ }
298
+ }
299
+ return undefined
300
+ }
301
+
302
+ private async cleanupSdk(sdk: ITonConnect): Promise<void> {
303
+ try {
304
+ await sdk.disconnect()
305
+ } catch {
306
+ /* best effort */
307
+ }
308
+ if (this.sdk === sdk) {
309
+ this.sdk = null
310
+ }
311
+ }
312
+
313
+ private setupVisibilityHandler(sdk: ITonConnect): void {
314
+ this.removeVisibilityHandler()
315
+ this.visibilityHandler = () => {
316
+ if (document.visibilityState === 'visible') {
317
+ sdk.unPauseConnection()
318
+ } else {
319
+ sdk.pauseConnection()
320
+ }
321
+ }
322
+ document.addEventListener('visibilitychange', this.visibilityHandler)
323
+ }
324
+
325
+ private removeVisibilityHandler(): void {
326
+ if (this.visibilityHandler) {
327
+ document.removeEventListener('visibilitychange', this.visibilityHandler)
328
+ this.visibilityHandler = null
329
+ }
330
+ }
331
+
332
+ private clearConnectTimeout(): void {
333
+ if (this.connectTimeout) {
334
+ clearTimeout(this.connectTimeout)
335
+ this.connectTimeout = null
336
+ }
337
+ }
338
+
339
+ private buildAvailableAddresses(
340
+ supportedNetworkIds: string[],
341
+ address: string
342
+ ): AvailableAddress[] {
343
+ return supportedNetworkIds
344
+ .filter(id => id.startsWith('tvm:'))
345
+ .map(networkId => ({ address, networkId: networkId as NetworkId }))
346
+ }
347
+ }
@@ -3,31 +3,13 @@ import type {
3
3
  TonSignDataPayload,
4
4
  SignatureType
5
5
  } from '@meshconnect/uwc-types'
6
+ import type { SendTransactionRequest } from '@tonconnect/sdk'
6
7
  import { TON_MAINNET_CHAIN_ID } from '@meshconnect/uwc-constants'
7
8
  import { isValidBase64 } from './validation'
8
9
 
9
10
  /** Default transaction expiry window in seconds. TON transactions must include an expiry timestamp. */
10
11
  const TX_EXPIRY_SECONDS = 300
11
12
 
12
- /*
13
- * Local mirrors of @tonconnect/sdk types.
14
- * Defined here so ton-connector has zero runtime dependency on @tonconnect/sdk,
15
- * avoiding type leaks in the published .d.ts files.
16
- */
17
-
18
- /** Mirrors @tonconnect/sdk SendTransactionRequest. */
19
- interface TonConnectTransactionRequest {
20
- validUntil: number
21
- network?: string
22
- from?: string
23
- messages: {
24
- address: string
25
- amount: string
26
- stateInit?: string
27
- payload?: string
28
- }[]
29
- }
30
-
31
13
  /**
32
14
  * Convert raw TON address to user-friendly format.
33
15
  * TON has two formats:
@@ -85,10 +67,10 @@ export function buildTonTransactionMessage(request: TonNativeTransferRequest) {
85
67
  export function buildTonTransactionRequest(
86
68
  request: TonNativeTransferRequest,
87
69
  senderRawAddress?: string
88
- ): TonConnectTransactionRequest & { sendMode?: number } {
70
+ ): SendTransactionRequest & { sendMode?: number } {
89
71
  const message = buildTonTransactionMessage(request)
90
72
 
91
- const txRequest: TonConnectTransactionRequest & { sendMode?: number } = {
73
+ const txRequest: SendTransactionRequest & { sendMode?: number } = {
92
74
  validUntil:
93
75
  request.validUntil ?? Math.floor(Date.now() / 1000) + TX_EXPIRY_SECONDS,
94
76
  network: TON_MAINNET_CHAIN_ID,