@thru/wallet 0.2.22

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.
Files changed (69) hide show
  1. package/README.md +67 -0
  2. package/android/build.gradle +37 -0
  3. package/android/src/main/AndroidManifest.xml +1 -0
  4. package/android/src/main/java/org/thru/walletnative/ThruWebViewBridgeModule.kt +77 -0
  5. package/app.plugin.cjs +101 -0
  6. package/dist/BrowserSDK-CpRFiJsW.d.ts +409 -0
  7. package/dist/index.d.ts +23 -0
  8. package/dist/index.js +941 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/native/react.d.ts +109 -0
  11. package/dist/native/react.js +2381 -0
  12. package/dist/native/react.js.map +1 -0
  13. package/dist/native.d.ts +329 -0
  14. package/dist/native.js +1126 -0
  15. package/dist/native.js.map +1 -0
  16. package/dist/react-ui.d.ts +5 -0
  17. package/dist/react-ui.js +266 -0
  18. package/dist/react-ui.js.map +1 -0
  19. package/dist/react.d.ts +66 -0
  20. package/dist/react.js +1151 -0
  21. package/dist/react.js.map +1 -0
  22. package/expo-module.config.json +6 -0
  23. package/package.json +114 -0
  24. package/src/BrowserSDK.ts +315 -0
  25. package/src/index.ts +27 -0
  26. package/src/interfaces/IThruChain.ts +37 -0
  27. package/src/interfaces/accounts.ts +61 -0
  28. package/src/interfaces/index.ts +9 -0
  29. package/src/interfaces/types.ts +95 -0
  30. package/src/native/NativeSDK.test.ts +819 -0
  31. package/src/native/NativeSDK.ts +773 -0
  32. package/src/native/index.ts +39 -0
  33. package/src/native/provider/NativeProvider.ts +363 -0
  34. package/src/native/provider/WebViewBridge.test.ts +339 -0
  35. package/src/native/provider/WebViewBridge.ts +339 -0
  36. package/src/native/provider/chains/ThruChain.ts +85 -0
  37. package/src/native/provider/shell.html +88 -0
  38. package/src/native/provider/shell.test.ts +56 -0
  39. package/src/native/provider/shell.ts +111 -0
  40. package/src/native/provider/shims-html.d.ts +4 -0
  41. package/src/native/react/ThruContext.ts +37 -0
  42. package/src/native/react/ThruProvider.tsx +168 -0
  43. package/src/native/react/ThruWalletSheet.tsx +1162 -0
  44. package/src/native/react/android-webauthn.ts +37 -0
  45. package/src/native/react/hooks/useAccounts.ts +35 -0
  46. package/src/native/react/hooks/useThru.ts +11 -0
  47. package/src/native/react/hooks/useWallet.ts +71 -0
  48. package/src/native/react/hooks/useWalletAvailability.ts +31 -0
  49. package/src/native/react/hooks/waitForWallet.ts +21 -0
  50. package/src/native/react/index.ts +29 -0
  51. package/src/protocol/index.ts +2 -0
  52. package/src/protocol/postMessage.ts +283 -0
  53. package/src/protocol/walletState.ts +12 -0
  54. package/src/provider/EmbeddedProvider.ts +330 -0
  55. package/src/provider/IframeManager.ts +438 -0
  56. package/src/provider/chains/ThruChain.ts +86 -0
  57. package/src/provider/index.ts +17 -0
  58. package/src/provider/types/messages.ts +37 -0
  59. package/src/react/ThruContext.ts +31 -0
  60. package/src/react/ThruProvider.tsx +169 -0
  61. package/src/react/hooks/useAccounts.ts +38 -0
  62. package/src/react/hooks/useThru.ts +11 -0
  63. package/src/react/hooks/useWallet.ts +81 -0
  64. package/src/react/index.ts +30 -0
  65. package/src/react-ui/ThruAccountSwitcher.tsx +187 -0
  66. package/src/react-ui/custom.d.ts +8 -0
  67. package/src/react-ui/index.ts +1 -0
  68. package/src/static/logo.png +0 -0
  69. package/src/static/logomark_red.svg +11 -0
@@ -0,0 +1,339 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import {
3
+ IFRAME_READY_EVENT,
4
+ POST_MESSAGE_EVENT_TYPE,
5
+ POST_MESSAGE_REQUEST_TYPES,
6
+ EMBEDDED_PROVIDER_EVENTS,
7
+ ErrorCode,
8
+ createRequestId,
9
+ } from "../../protocol";
10
+ import { WebViewBridge, type WebViewMessageEventLike } from './WebViewBridge';
11
+
12
+ const WALLET_URL = 'http://localhost:3000/embedded';
13
+ const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
14
+
15
+ function restoreNodeEnv(): void {
16
+ if (ORIGINAL_NODE_ENV === undefined) {
17
+ delete process.env.NODE_ENV;
18
+ return;
19
+ }
20
+ process.env.NODE_ENV = ORIGINAL_NODE_ENV;
21
+ }
22
+
23
+ class MockWebView {
24
+ injected: string[] = [];
25
+ injectJavaScript = (script: string): void => {
26
+ this.injected.push(script);
27
+ };
28
+ }
29
+
30
+ function readyMessage(frameId: string): WebViewMessageEventLike {
31
+ return {
32
+ nativeEvent: {
33
+ data: JSON.stringify({
34
+ type: IFRAME_READY_EVENT,
35
+ frameId,
36
+ data: { ready: true },
37
+ }),
38
+ },
39
+ };
40
+ }
41
+
42
+ function responseMessage(
43
+ frameId: string,
44
+ id: string,
45
+ result: unknown
46
+ ): WebViewMessageEventLike {
47
+ return {
48
+ nativeEvent: {
49
+ data: JSON.stringify({ id, frameId, success: true, result }),
50
+ },
51
+ };
52
+ }
53
+
54
+ function errorMessage(
55
+ frameId: string,
56
+ id: string,
57
+ code: string,
58
+ message: string
59
+ ): WebViewMessageEventLike {
60
+ return {
61
+ nativeEvent: {
62
+ data: JSON.stringify({
63
+ id,
64
+ frameId,
65
+ success: false,
66
+ error: { code, message },
67
+ }),
68
+ },
69
+ };
70
+ }
71
+
72
+ function eventMessage(
73
+ frameId: string,
74
+ event: string,
75
+ data?: unknown
76
+ ): WebViewMessageEventLike {
77
+ return {
78
+ nativeEvent: {
79
+ data: JSON.stringify({
80
+ type: POST_MESSAGE_EVENT_TYPE,
81
+ frameId,
82
+ event,
83
+ data,
84
+ }),
85
+ },
86
+ };
87
+ }
88
+
89
+ /* Flush enough microtask ticks for sendMessage's `await awaitReady()` to
90
+ land, the injectJavaScript call to fire, and the handler to register. */
91
+ async function flush(): Promise<void> {
92
+ for (let i = 0; i < 4; i++) await Promise.resolve();
93
+ }
94
+
95
+ describe('WebViewBridge', () => {
96
+ let bridge: WebViewBridge;
97
+ let webView: MockWebView;
98
+
99
+ beforeEach(() => {
100
+ bridge = new WebViewBridge({ walletUrl: WALLET_URL });
101
+ webView = new MockWebView();
102
+ bridge.attachWebView(webView);
103
+ });
104
+
105
+ afterEach(() => {
106
+ restoreNodeEnv();
107
+ delete (globalThis as typeof globalThis & { __DEV__?: boolean }).__DEV__;
108
+ bridge.destroy();
109
+ });
110
+
111
+ it('rejects untrusted wallet origins at construction', () => {
112
+ expect(
113
+ () => new WebViewBridge({ walletUrl: 'https://evil.example.com/embed' })
114
+ ).toThrow(/Untrusted wallet origin/);
115
+ });
116
+
117
+ it('allows generic dev wallet origins outside production builds', () => {
118
+ const localHostnameBridge = new WebViewBridge({
119
+ walletUrl: 'http://dev-wallet:3000/embedded',
120
+ });
121
+ expect(localHostnameBridge.walletOrigin).toBe('http://dev-wallet:3000');
122
+ localHostnameBridge.destroy();
123
+
124
+ const tailscaleBridge = new WebViewBridge({
125
+ walletUrl: 'https://wallet-dev.tailabc.ts.net/embedded',
126
+ });
127
+ expect(tailscaleBridge.walletOrigin).toBe('https://wallet-dev.tailabc.ts.net');
128
+ tailscaleBridge.destroy();
129
+
130
+ const tailscaleIpBridge = new WebViewBridge({
131
+ walletUrl: 'http://100.64.0.1:3000/embedded',
132
+ });
133
+ expect(tailscaleIpBridge.walletOrigin).toBe('http://100.64.0.1:3000');
134
+ tailscaleIpBridge.destroy();
135
+ });
136
+
137
+ it('allows a Bonjour wallet dev origin', () => {
138
+ const bridge = new WebViewBridge({
139
+ walletUrl: 'http://dev-wallet.local:3000/embedded',
140
+ });
141
+ expect(bridge.walletOrigin).toBe('http://dev-wallet.local:3000');
142
+ bridge.destroy();
143
+ });
144
+
145
+ it('allows the Tailscale HTTPS wallet dev origin', () => {
146
+ const bridge = new WebViewBridge({
147
+ walletUrl: 'https://wallet-dev.tailabc.ts.net/embedded',
148
+ });
149
+ expect(bridge.walletOrigin).toBe('https://wallet-dev.tailabc.ts.net');
150
+ bridge.destroy();
151
+ });
152
+
153
+ it('rejects dev wallet origins in production builds', () => {
154
+ process.env.NODE_ENV = 'production';
155
+
156
+ const productionBridge = new WebViewBridge({
157
+ walletUrl: 'https://wallet.thru.org/embedded',
158
+ });
159
+ expect(productionBridge.walletOrigin).toBe('https://wallet.thru.org');
160
+ productionBridge.destroy();
161
+
162
+ expect(
163
+ () => new WebViewBridge({ walletUrl: 'http://localhost:3000/embedded' })
164
+ ).toThrow(/Untrusted wallet origin/);
165
+ expect(
166
+ () => new WebViewBridge({ walletUrl: 'https://wallet-dev.tailabc.ts.net/embedded' })
167
+ ).toThrow(/Untrusted wallet origin/);
168
+ expect(
169
+ () => new WebViewBridge({ walletUrl: 'http://100.64.0.1:3000/embedded' })
170
+ ).toThrow(/Untrusted wallet origin/);
171
+ });
172
+
173
+ it('uses the React Native __DEV__ flag when present', () => {
174
+ process.env.NODE_ENV = 'test';
175
+ (globalThis as typeof globalThis & { __DEV__?: boolean }).__DEV__ = false;
176
+
177
+ expect(
178
+ () => new WebViewBridge({ walletUrl: 'http://localhost:3000/embedded' })
179
+ ).toThrow(/Untrusted wallet origin/);
180
+
181
+ (globalThis as typeof globalThis & { __DEV__?: boolean }).__DEV__ = true;
182
+ const bridge = new WebViewBridge({
183
+ walletUrl: 'http://localhost:3000/embedded',
184
+ });
185
+ expect(bridge.walletOrigin).toBe('http://localhost:3000');
186
+ bridge.destroy();
187
+ });
188
+
189
+ it('appends tn_frame_id to the iframe src', () => {
190
+ const src = bridge.getIframeSrc();
191
+ expect(src).toContain('tn_frame_id=');
192
+ expect(src).toContain('/embedded/native');
193
+ expect(src.startsWith('http://localhost:3000/embedded')).toBe(true);
194
+ });
195
+
196
+ it('resolves awaitReady on IFRAME_READY_EVENT with matching frameId', async () => {
197
+ const ready = bridge.awaitReady();
198
+ bridge.onMessage(readyMessage(bridge.frameId));
199
+ await expect(ready).resolves.toBeUndefined();
200
+ });
201
+
202
+ it('remembers an early IFRAME_READY_EVENT before awaitReady is called', async () => {
203
+ bridge.onMessage(readyMessage(bridge.frameId));
204
+ await expect(bridge.awaitReady()).resolves.toBeUndefined();
205
+ });
206
+
207
+ it('ignores ready events tagged with a different frameId', async () => {
208
+ const ready = bridge.awaitReady();
209
+ bridge.onMessage(readyMessage('frame_other'));
210
+ await expect(
211
+ Promise.race([
212
+ ready,
213
+ new Promise((res) => setTimeout(() => res('not-ready'), 30)),
214
+ ])
215
+ ).resolves.toBe('not-ready');
216
+ });
217
+
218
+ it('returns the same ready promise on repeated calls', () => {
219
+ expect(bridge.awaitReady()).toBe(bridge.awaitReady());
220
+ });
221
+
222
+ it('routes a successful response back to the request promise', async () => {
223
+ bridge.awaitReady();
224
+ bridge.onMessage(readyMessage(bridge.frameId));
225
+
226
+ const id = createRequestId();
227
+ const promise = bridge.sendMessage({
228
+ id,
229
+ type: POST_MESSAGE_REQUEST_TYPES.GET_ACCOUNTS,
230
+ origin: 'app://test',
231
+ });
232
+
233
+ await flush();
234
+ expect(webView.injected.length).toBe(1);
235
+ expect(webView.injected[0]).toContain('window.__pushIn');
236
+ expect(webView.injected[0]).toContain('MessageEvent');
237
+ expect(webView.injected[0]).toContain(id);
238
+
239
+ bridge.onMessage(responseMessage(bridge.frameId, id, { accounts: [] }));
240
+ const res = await promise;
241
+ expect(res.success).toBe(true);
242
+ expect(res.result).toEqual({ accounts: [] });
243
+ });
244
+
245
+ it('rejects with the carried error code on a failure response', async () => {
246
+ bridge.awaitReady();
247
+ bridge.onMessage(readyMessage(bridge.frameId));
248
+
249
+ const id = createRequestId();
250
+ const promise = bridge.sendMessage({
251
+ id,
252
+ type: POST_MESSAGE_REQUEST_TYPES.DISCONNECT,
253
+ origin: 'app://test',
254
+ });
255
+ await flush();
256
+ bridge.onMessage(
257
+ errorMessage(bridge.frameId, id, ErrorCode.USER_REJECTED, 'nope')
258
+ );
259
+ await expect(promise).rejects.toMatchObject({
260
+ message: 'nope',
261
+ code: ErrorCode.USER_REJECTED,
262
+ });
263
+ });
264
+
265
+ it('rejects in-flight requests when the native sheet is dismissed', async () => {
266
+ bridge.awaitReady();
267
+ bridge.onMessage(readyMessage(bridge.frameId));
268
+
269
+ const promise = bridge.sendMessage({
270
+ id: createRequestId(),
271
+ type: POST_MESSAGE_REQUEST_TYPES.SIGN_TRANSACTION,
272
+ origin: 'app://test',
273
+ payload: {
274
+ instructionData: 'AAAA',
275
+ programAddress: 'thru_program',
276
+ },
277
+ });
278
+
279
+ await flush();
280
+ bridge.rejectPendingRequests();
281
+
282
+ await expect(promise).rejects.toMatchObject({
283
+ message: 'User rejected the request',
284
+ code: ErrorCode.USER_REJECTED,
285
+ });
286
+ });
287
+
288
+ it('drops responses tagged with a different frameId', async () => {
289
+ bridge.awaitReady();
290
+ bridge.onMessage(readyMessage(bridge.frameId));
291
+
292
+ const id = createRequestId();
293
+ const promise = bridge.sendMessage({
294
+ id,
295
+ type: POST_MESSAGE_REQUEST_TYPES.GET_ACCOUNTS,
296
+ origin: 'app://test',
297
+ });
298
+ await flush();
299
+ bridge.onMessage(responseMessage('frame_other', id, { accounts: [] }));
300
+ bridge.onMessage(responseMessage(bridge.frameId, id, { accounts: [] }));
301
+ await expect(promise).resolves.toBeDefined();
302
+ });
303
+
304
+ it('forwards event broadcasts via onEvent', () => {
305
+ const seen: Array<{ event: string; data: unknown }> = [];
306
+ bridge.onEvent = (event, data) => seen.push({ event, data });
307
+ bridge.onMessage(
308
+ eventMessage(bridge.frameId, EMBEDDED_PROVIDER_EVENTS.UI_SHOW)
309
+ );
310
+ expect(seen).toEqual([
311
+ { event: EMBEDDED_PROVIDER_EVENTS.UI_SHOW, data: undefined },
312
+ ]);
313
+ });
314
+
315
+ it('rejects in-flight sendMessage if the WebView ref is dropped before injection', async () => {
316
+ bridge.awaitReady();
317
+ bridge.onMessage(readyMessage(bridge.frameId));
318
+
319
+ const promise = bridge.sendMessage({
320
+ id: createRequestId(),
321
+ type: POST_MESSAGE_REQUEST_TYPES.GET_ACCOUNTS,
322
+ origin: 'app://test',
323
+ });
324
+ /* Destroy before the post-await body of sendMessage gets to run. */
325
+ bridge.destroy();
326
+ await expect(promise).rejects.toThrow(/WebView not attached/);
327
+ });
328
+
329
+ it('rejects awaitReady on destroy when ready hasn\'t arrived', async () => {
330
+ const ready = bridge.awaitReady();
331
+ bridge.destroy();
332
+ await expect(ready).rejects.toThrow(/Bridge destroyed/);
333
+ });
334
+
335
+ /* Note: the 30s / 5min timeout values are exercised by integration
336
+ tests; unit-testing them with fake timers leaks pending rejections
337
+ past test boundaries. See SLOW_REQUEST_TIMEOUT_MS / FAST_REQUEST_TIMEOUT_MS
338
+ in WebViewBridge.ts for the contract. */
339
+ });
@@ -0,0 +1,339 @@
1
+ import {
2
+ ErrorCode,
3
+ IFRAME_READY_EVENT,
4
+ POST_MESSAGE_EVENT_TYPE,
5
+ POST_MESSAGE_REQUEST_TYPES,
6
+ createRequestId,
7
+ type InferSuccessfulPostMessageResponse,
8
+ type PostMessageEvent,
9
+ type PostMessageRequest,
10
+ type PostMessageResponse,
11
+ } from "../../protocol";
12
+
13
+ /* RN-side analog of `web/packages/embedded-provider/src/IframeManager.ts`.
14
+ The wallet ships unchanged. The shell HTML (src/shell.html) hosts an
15
+ <iframe src="wallet.thru.org/embedded/native"> and forwards
16
+ iframe<->ReactNativeWebView postMessage traffic. This bridge only
17
+ speaks the RN side: webView.injectJavaScript out, onMessage in. */
18
+
19
+ const PRODUCTION_WALLET_ORIGINS = ['https://wallet.thru.org'];
20
+
21
+ function isDevelopmentBuild(): boolean {
22
+ const runtime = globalThis as typeof globalThis & {
23
+ __DEV__?: boolean;
24
+ process?: { env?: { NODE_ENV?: string } };
25
+ };
26
+
27
+ const devFlag = runtime.__DEV__;
28
+ if (typeof devFlag === 'boolean') return devFlag;
29
+
30
+ return (
31
+ runtime.process?.env?.NODE_ENV !== undefined &&
32
+ runtime.process.env.NODE_ENV !== 'production'
33
+ );
34
+ }
35
+
36
+ function isPrivateIpv4Host(hostname: string): boolean {
37
+ const parts = hostname.split('.').map((part) => Number(part));
38
+ if (
39
+ parts.length !== 4 ||
40
+ parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
41
+ ) {
42
+ return false;
43
+ }
44
+
45
+ const [a, b] = parts;
46
+ return (
47
+ a === 10 ||
48
+ a === 127 ||
49
+ (a === 172 && b >= 16 && b <= 31) ||
50
+ (a === 192 && b === 168) ||
51
+ (a === 100 && b >= 64 && b <= 127)
52
+ );
53
+ }
54
+
55
+ function isAllowedDevelopmentOrigin(url: URL): boolean {
56
+ if (!isDevelopmentBuild()) return false;
57
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
58
+
59
+ const hostname = url.hostname.toLowerCase();
60
+ return (
61
+ hostname === 'localhost' ||
62
+ hostname === '::1' ||
63
+ !hostname.includes('.') ||
64
+ hostname.endsWith('.local') ||
65
+ hostname.endsWith('.ts.net') ||
66
+ isPrivateIpv4Host(hostname)
67
+ );
68
+ }
69
+
70
+ function validateWalletOrigin(walletUrl: string): void {
71
+ let url: URL;
72
+ try {
73
+ url = new URL(walletUrl);
74
+ } catch {
75
+ throw new Error(
76
+ `Invalid wallet URL: ${walletUrl}. URL must be a valid absolute URL.`
77
+ );
78
+ }
79
+ const origin = url.origin;
80
+ const isAllowed =
81
+ PRODUCTION_WALLET_ORIGINS.includes(origin) ||
82
+ isAllowedDevelopmentOrigin(url);
83
+ if (!isAllowed) {
84
+ throw new Error(
85
+ `Untrusted wallet origin: ${origin}. Only trusted origins are allowed: ${PRODUCTION_WALLET_ORIGINS.join(', ')}. ` +
86
+ 'Development builds also allow localhost, LAN, and Tailscale wallet origins.'
87
+ );
88
+ }
89
+ }
90
+
91
+ /* Minimal contract for a react-native-webview ref. We accept both refs
92
+ ({ current: WebView }) and direct WebView instances. */
93
+ export interface WebViewRefLike {
94
+ injectJavaScript: (script: string) => void;
95
+ }
96
+
97
+ export interface WebViewMessageEventLike {
98
+ nativeEvent: { data: string };
99
+ }
100
+
101
+ const READY_TIMEOUT_MS = 10_000;
102
+ const SLOW_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
103
+ const FAST_REQUEST_TIMEOUT_MS = 30 * 1000;
104
+
105
+ const SLOW_REQUEST_TYPES: ReadonlySet<string> = new Set([
106
+ POST_MESSAGE_REQUEST_TYPES.CONNECT,
107
+ POST_MESSAGE_REQUEST_TYPES.SIGN_MESSAGE,
108
+ POST_MESSAGE_REQUEST_TYPES.SIGN_TRANSACTION,
109
+ POST_MESSAGE_REQUEST_TYPES.MANAGE_ACCOUNTS,
110
+ ]);
111
+
112
+ export interface WebViewBridgeOptions {
113
+ walletUrl: string;
114
+ }
115
+
116
+ /**
117
+ * Bidirectional bridge between a host React Native app and the wallet
118
+ * iframe running inside a `react-native-webview`. Mirrors the public
119
+ * surface of `IframeManager` minus DOM-only concerns (visibility
120
+ * styling lives with the host bottom sheet). All other invariants -
121
+ * frameId correlation, IFRAME_READY handshake, request/response
122
+ * routing, timeouts - match the iframe implementation exactly.
123
+ */
124
+ export class WebViewBridge {
125
+ readonly walletUrl: string;
126
+ readonly walletOrigin: string;
127
+ readonly frameId: string;
128
+
129
+ private webView: WebViewRefLike | null = null;
130
+ private ready = false;
131
+ private readyPromise: Promise<void> | null = null;
132
+ private resolveReady: (() => void) | null = null;
133
+ private rejectReady: ((err: Error) => void) | null = null;
134
+ private readyTimer: ReturnType<typeof setTimeout> | null = null;
135
+
136
+ private messageHandlers = new Map<
137
+ string,
138
+ (response: PostMessageResponse) => void
139
+ >();
140
+
141
+ /* Event broadcasts from the iframe (type === 'event'). */
142
+ public onEvent?: (eventType: string, payload: unknown) => void;
143
+
144
+ constructor(options: WebViewBridgeOptions) {
145
+ validateWalletOrigin(options.walletUrl);
146
+ this.walletUrl = options.walletUrl;
147
+ this.walletOrigin = new URL(options.walletUrl).origin;
148
+ this.frameId = createRequestId('frame');
149
+ }
150
+
151
+ /**
152
+ * Compose the URL to load inside the shell <iframe>. The host
153
+ * (ThruWalletSheet) calls this when building the shell HTML.
154
+ */
155
+ getIframeSrc(): string {
156
+ const url = new URL(this.walletUrl);
157
+ if (!url.pathname.endsWith('/native')) {
158
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/native`;
159
+ }
160
+ url.searchParams.set('tn_frame_id', this.frameId);
161
+ return url.toString();
162
+ }
163
+
164
+ /**
165
+ * Hand the bridge a WebView ref. Required before `awaitReady()` /
166
+ * `sendMessage()` will resolve.
167
+ */
168
+ attachWebView(ref: WebViewRefLike): void {
169
+ this.webView = ref;
170
+ }
171
+
172
+ /**
173
+ * Mark the bridge ready when the native host loads the wallet as the
174
+ * top-level WebView document instead of through the shell iframe.
175
+ */
176
+ markReady(): void {
177
+ if (this.ready) return;
178
+ this.ready = true;
179
+ if (this.readyTimer) clearTimeout(this.readyTimer);
180
+ this.readyTimer = null;
181
+ const r = this.resolveReady;
182
+ this.resolveReady = null;
183
+ this.rejectReady = null;
184
+ r?.();
185
+ }
186
+
187
+ /**
188
+ * Returns a promise that resolves when the iframe sends
189
+ * IFRAME_READY_EVENT. Idempotent: returns the same promise on
190
+ * subsequent calls. Rejects after READY_TIMEOUT_MS.
191
+ */
192
+ awaitReady(): Promise<void> {
193
+ if (this.ready) return Promise.resolve();
194
+ if (this.readyPromise) return this.readyPromise;
195
+ this.readyPromise = new Promise<void>((resolve, reject) => {
196
+ this.resolveReady = resolve;
197
+ this.rejectReady = reject;
198
+ this.readyTimer = setTimeout(() => {
199
+ this.readyTimer = null;
200
+ if (this.rejectReady) {
201
+ const r = this.rejectReady;
202
+ this.rejectReady = null;
203
+ this.resolveReady = null;
204
+ r(new Error('WebView ready timeout - wallet failed to load'));
205
+ }
206
+ }, READY_TIMEOUT_MS);
207
+ });
208
+ return this.readyPromise;
209
+ }
210
+
211
+ /**
212
+ * Send a request to the iframe (via injectJavaScript -> shell ->
213
+ * iframe.postMessage) and resolve with the matching response.
214
+ */
215
+ async sendMessage<TRequest extends PostMessageRequest>(
216
+ request: TRequest
217
+ ): Promise<InferSuccessfulPostMessageResponse<TRequest>> {
218
+ await this.awaitReady();
219
+ if (!this.webView) {
220
+ throw new Error('WebView not attached - call attachWebView() first');
221
+ }
222
+
223
+ const timeoutMs = SLOW_REQUEST_TYPES.has(request.type)
224
+ ? SLOW_REQUEST_TIMEOUT_MS
225
+ : FAST_REQUEST_TIMEOUT_MS;
226
+
227
+ return new Promise((resolve, reject) => {
228
+ const timer = setTimeout(() => {
229
+ this.messageHandlers.delete(request.id);
230
+ reject(new Error('Request timeout - wallet did not respond'));
231
+ }, timeoutMs);
232
+
233
+ this.messageHandlers.set(request.id, (response) => {
234
+ clearTimeout(timer);
235
+ this.messageHandlers.delete(request.id);
236
+ if (response.success) {
237
+ resolve(
238
+ response as InferSuccessfulPostMessageResponse<TRequest>
239
+ );
240
+ } else {
241
+ const err = new Error(response.error?.message || 'Unknown error');
242
+ (err as { code?: string; data?: unknown }).code = response.error?.code;
243
+ (err as { code?: string; data?: unknown }).data = response.error?.data;
244
+ reject(err);
245
+ }
246
+ });
247
+
248
+ const script = `try {
249
+ var msg = ${JSON.stringify(request)};
250
+ if (window.__pushIn) {
251
+ window.__pushIn(msg);
252
+ } else {
253
+ window.dispatchEvent(new MessageEvent('message', {
254
+ data: msg,
255
+ origin: msg.origin || ''
256
+ }));
257
+ }
258
+ } catch (e) {} ; true;`;
259
+ this.webView!.injectJavaScript(script);
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Reject all in-flight wallet requests when the native host dismisses the
265
+ * WebView without waiting for a wallet-side response.
266
+ */
267
+ rejectPendingRequests(message = 'User rejected the request'): void {
268
+ for (const [id, handler] of Array.from(this.messageHandlers.entries())) {
269
+ handler({
270
+ id,
271
+ success: false,
272
+ error: {
273
+ code: ErrorCode.USER_REJECTED,
274
+ message,
275
+ },
276
+ });
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Hook this into <WebView onMessage>. The shell forwards iframe
282
+ * postMessage payloads to ReactNativeWebView; we route them here.
283
+ */
284
+ onMessage(event: WebViewMessageEventLike): void {
285
+ let data: unknown;
286
+ try {
287
+ data = JSON.parse(event.nativeEvent.data);
288
+ } catch {
289
+ return;
290
+ }
291
+ if (!data || typeof data !== 'object') return;
292
+ const msg = data as Record<string, unknown>;
293
+
294
+ /* Frame-id check matches the iframe model: ignore traffic that
295
+ isn't tagged for this bridge instance. */
296
+ if (msg.frameId !== this.frameId) return;
297
+
298
+ if (msg.type === IFRAME_READY_EVENT) {
299
+ this.markReady();
300
+ return;
301
+ }
302
+
303
+ /* Response to a specific request (has `id`). */
304
+ if (typeof msg.id === 'string' && this.messageHandlers.has(msg.id)) {
305
+ const handler = this.messageHandlers.get(msg.id)!;
306
+ handler(msg as unknown as PostMessageResponse);
307
+ return;
308
+ }
309
+
310
+ /* Event broadcast (no id). */
311
+ if (msg.type === POST_MESSAGE_EVENT_TYPE) {
312
+ const evt = msg as unknown as PostMessageEvent;
313
+ this.onEvent?.(evt.event, evt.data);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Drop pending handlers and clear ready promise. Call when the host
319
+ * unmounts the WebView.
320
+ */
321
+ destroy(): void {
322
+ if (this.readyTimer) {
323
+ clearTimeout(this.readyTimer);
324
+ this.readyTimer = null;
325
+ }
326
+ if (this.rejectReady && this.readyPromise) {
327
+ /* Attach a swallow handler so Node doesn't flag the rejection as
328
+ unhandled if the host wasn't awaiting it at destroy time. */
329
+ this.readyPromise.catch(() => {});
330
+ this.rejectReady(new Error('Bridge destroyed'));
331
+ }
332
+ this.resolveReady = null;
333
+ this.rejectReady = null;
334
+ this.readyPromise = null;
335
+ this.ready = false;
336
+ this.messageHandlers.clear();
337
+ this.webView = null;
338
+ }
339
+ }