@tangle-network/blueprint-ui 0.3.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/blueprint-ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared blueprint UI components, hooks, and contract utilities for Tangle Network apps",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -43,6 +43,21 @@
43
43
  "types": "./dist/preset.d.ts",
44
44
  "default": "./dist/preset.js"
45
45
  },
46
+ "./wallet": {
47
+ "import": "./dist/wallet/index.js",
48
+ "types": "./dist/wallet/index.d.ts",
49
+ "default": "./dist/wallet/index.js"
50
+ },
51
+ "./iframe": {
52
+ "import": "./dist/iframe/index.js",
53
+ "types": "./dist/iframe/index.d.ts",
54
+ "default": "./dist/iframe/index.js"
55
+ },
56
+ "./iframe/testing": {
57
+ "import": "./dist/iframe/testing-index.js",
58
+ "types": "./dist/iframe/testing-index.d.ts",
59
+ "default": "./dist/iframe/testing-index.js"
60
+ },
46
61
  "./styles.css": "./dist/styles.css"
47
62
  },
48
63
  "scripts": {
@@ -0,0 +1,171 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ } from 'react';
10
+
11
+ import {
12
+ TangleIframeClient,
13
+ type ServiceSnapshot,
14
+ type TangleIframeClientOptions,
15
+ type WalletSnapshot,
16
+ } from './tangleIframeClient';
17
+ import {
18
+ detectTangleCloudParentOrigin,
19
+ TANGLE_CLOUD_ORIGINS_DEFAULT,
20
+ } from '../wallet/detectParentOrigin';
21
+
22
+ type Props = {
23
+ appId: string;
24
+ /** Override the detected parent origin (e.g. dev/staging deploys). */
25
+ parentOrigin?: string;
26
+ /** Extra trusted origins for `detectTangleCloudParentOrigin`. */
27
+ extraOrigins?: readonly string[];
28
+ /**
29
+ * Override the bootstrap behavior. When `'auto'` (default), the SDK
30
+ * sniffs the embed context: real parent → install the bridge, top-frame
31
+ * → drop into dev mode. `'bridge'` forces real-parent mode and throws
32
+ * if no parent is detected. `'dev'` forces dev mode even when embedded
33
+ * — useful for component-level tests.
34
+ */
35
+ mode?: 'auto' | 'bridge' | 'dev';
36
+ children: ReactNode;
37
+ };
38
+
39
+ type ContextValue = {
40
+ readonly client: TangleIframeClient | null;
41
+ readonly wallet: WalletSnapshot;
42
+ readonly service: ServiceSnapshot;
43
+ readonly mode: 'bridge' | 'dev';
44
+ readonly isReady: boolean;
45
+ };
46
+
47
+ const TangleIframeContext = createContext<ContextValue | null>(null);
48
+
49
+ const NULL_WALLET: WalletSnapshot = {
50
+ address: null,
51
+ chainId: null,
52
+ isConnected: false,
53
+ };
54
+ const NULL_SERVICE: ServiceSnapshot = {
55
+ blueprintId: null,
56
+ serviceId: null,
57
+ operators: [],
58
+ jobs: [],
59
+ mode: null,
60
+ };
61
+
62
+ /**
63
+ * Iframe-blueprint root provider. Wrap your app once at the entry point.
64
+ *
65
+ * In `auto` mode (default) the SDK detects whether the app is embedded by a
66
+ * trusted Tangle Cloud parent. If yes → installs the postMessage bridge.
67
+ * If no (running standalone at `localhost:5173` etc.) → enters **dev mode**
68
+ * with an in-memory state machine that the developer can drive via the
69
+ * exported debug controls. Dev mode keeps the hook surface identical to
70
+ * production so component code never branches on embed-vs-not.
71
+ *
72
+ * Three lifecycle stages:
73
+ *
74
+ * 1. Mount — `client` is created, mode is decided.
75
+ * 2. Bootstrap — handshake (bridge) or first-paint setup (dev). The
76
+ * `isReady` flag flips to true.
77
+ * 3. Active — wallet + service snapshots flow in via subscriptions.
78
+ */
79
+ export function TangleIframeProvider({
80
+ appId,
81
+ parentOrigin: explicitOrigin,
82
+ extraOrigins,
83
+ mode: requestedMode = 'auto',
84
+ children,
85
+ }: Props) {
86
+ // Resolve the effective mode once at mount. Switching modes mid-session
87
+ // would tear down the bridge / dev state inconsistently; restart instead.
88
+ const resolution = useMemo(() => {
89
+ if (requestedMode === 'dev') {
90
+ return { mode: 'dev' as const, parentOrigin: null };
91
+ }
92
+ const detected =
93
+ explicitOrigin ?? detectTangleCloudParentOrigin({ extraOrigins });
94
+ if (requestedMode === 'bridge') {
95
+ if (!detected) {
96
+ // eslint-disable-next-line no-console
97
+ console.error(
98
+ '[TangleIframeProvider] mode="bridge" but no trusted parent was detected. Falling back to dev mode.',
99
+ );
100
+ return { mode: 'dev' as const, parentOrigin: null };
101
+ }
102
+ return { mode: 'bridge' as const, parentOrigin: detected };
103
+ }
104
+ // auto: bridge when detected, dev otherwise.
105
+ return detected
106
+ ? { mode: 'bridge' as const, parentOrigin: detected }
107
+ : { mode: 'dev' as const, parentOrigin: null };
108
+ }, [requestedMode, explicitOrigin, extraOrigins]);
109
+
110
+ const clientRef = useRef<TangleIframeClient | null>(null);
111
+ const [wallet, setWallet] = useState<WalletSnapshot>(NULL_WALLET);
112
+ const [service, setService] = useState<ServiceSnapshot>(NULL_SERVICE);
113
+ const [isReady, setIsReady] = useState(false);
114
+
115
+ useEffect(() => {
116
+ if (resolution.mode === 'dev') {
117
+ // Dev mode: no bridge. The DevHarness component (or a test) seeds
118
+ // wallet + service via `setDevWallet` / `setDevService` on the
119
+ // returned context. Mark ready immediately so app code unblocks.
120
+ setIsReady(true);
121
+ return undefined;
122
+ }
123
+ // Bridge mode
124
+ const options: TangleIframeClientOptions = {
125
+ parentOrigin: resolution.parentOrigin,
126
+ appId,
127
+ };
128
+ const client = new TangleIframeClient(options);
129
+ clientRef.current = client;
130
+ const unsubWallet = client.subscribe('wallet', setWallet);
131
+ const unsubService = client.subscribe('service', setService);
132
+ client.install();
133
+ setIsReady(true);
134
+ return () => {
135
+ unsubWallet();
136
+ unsubService();
137
+ client.uninstall();
138
+ clientRef.current = null;
139
+ setIsReady(false);
140
+ };
141
+ }, [resolution, appId]);
142
+
143
+ const value = useMemo<ContextValue>(
144
+ () => ({
145
+ client: clientRef.current,
146
+ wallet,
147
+ service,
148
+ mode: resolution.mode,
149
+ isReady,
150
+ }),
151
+ [wallet, service, resolution.mode, isReady],
152
+ );
153
+
154
+ return (
155
+ <TangleIframeContext.Provider value={value}>
156
+ {children}
157
+ </TangleIframeContext.Provider>
158
+ );
159
+ }
160
+
161
+ export function useTangleIframeContext(): ContextValue {
162
+ const ctx = useContext(TangleIframeContext);
163
+ if (!ctx) {
164
+ throw new Error(
165
+ 'useTangleIframeContext must be used inside <TangleIframeProvider>.',
166
+ );
167
+ }
168
+ return ctx;
169
+ }
170
+
171
+ export { TANGLE_CLOUD_ORIGINS_DEFAULT };
@@ -0,0 +1,142 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import type { Address, Hex } from 'viem';
3
+
4
+ import { useTangleIframeContext } from './TangleIframeProvider';
5
+ import type {
6
+ JobInvocation,
7
+ ServiceSnapshot,
8
+ WalletSnapshot,
9
+ } from './tangleIframeClient';
10
+ import type { JobInputs } from '../wallet/parentBridgeProtocol';
11
+
12
+ /**
13
+ * Read-only view of the connected wallet, plus the operations the iframe
14
+ * can request the parent to perform.
15
+ *
16
+ * The iframe never holds a private key, never sees `window.ethereum`, never
17
+ * imports wagmi. All wallet work happens upstream in the Tangle Cloud
18
+ * dapp's wagmi config + ConnectKit modal.
19
+ */
20
+ export function useTangleWallet(): WalletSnapshot & {
21
+ signMessage: (message: string) => Promise<Hex>;
22
+ sendTransaction: (tx: {
23
+ to: Address;
24
+ data: Hex;
25
+ value?: bigint;
26
+ }) => Promise<Hex>;
27
+ switchChain: (chainId: number) => Promise<number>;
28
+ } {
29
+ const { client, wallet } = useTangleIframeContext();
30
+ const signMessage = useCallback(
31
+ (message: string) => {
32
+ if (!client) throw new Error('Wallet not available in dev mode.');
33
+ return client.signMessage(message);
34
+ },
35
+ [client],
36
+ );
37
+ const sendTransaction = useCallback(
38
+ (tx: { to: Address; data: Hex; value?: bigint }) => {
39
+ if (!client) throw new Error('Wallet not available in dev mode.');
40
+ return client.sendTransaction(tx);
41
+ },
42
+ [client],
43
+ );
44
+ const switchChain = useCallback(
45
+ (chainId: number) => {
46
+ if (!client) throw new Error('Wallet not available in dev mode.');
47
+ return client.switchChain(chainId);
48
+ },
49
+ [client],
50
+ );
51
+ return { ...wallet, signMessage, sendTransaction, switchChain };
52
+ }
53
+
54
+ /**
55
+ * The service the iframe is currently rendering for. Broadcast by the
56
+ * parent dapp on mount + every time the service/mode changes — the iframe
57
+ * never queries the chain or the indexer itself.
58
+ *
59
+ * `serviceId === null` means the operator hasn't deployed an instance yet;
60
+ * the iframe should render its deploy-ready / configuration surface.
61
+ */
62
+ export function useTangleService(): ServiceSnapshot {
63
+ return useTangleIframeContext().service;
64
+ }
65
+
66
+ /**
67
+ * Invoke a blueprint job. Returns a callable + a snapshot of the most
68
+ * recent invocation (or null if none yet).
69
+ *
70
+ * Streaming jobs (LLM, video, audio) opt in via `stream: true`. The hook's
71
+ * `invocation.chunks` accumulates each streaming chunk so the UI can render
72
+ * progressive output. For one-shot jobs (embeddings, classification), use
73
+ * the `invocation.data` once `status === 'success'`.
74
+ *
75
+ * Multiple in-flight invocations are supported — each `call()` returns its
76
+ * own correlationId. The hook tracks only the *latest* invocation in its
77
+ * state; consumers that need all history can subscribe to the client's
78
+ * `job` event directly.
79
+ */
80
+ export function useCallJob() {
81
+ const { client } = useTangleIframeContext();
82
+ const [invocation, setInvocation] = useState<JobInvocation | null>(null);
83
+ const [latestId, setLatestId] = useState<string | null>(null);
84
+
85
+ useEffect(() => {
86
+ if (!client) return undefined;
87
+ return client.subscribe('job', (next) => {
88
+ // Only update if this is the latest invocation, or no latest tracked.
89
+ setLatestId((prevLatest) => {
90
+ if (prevLatest === null || prevLatest === next.correlationId) {
91
+ setInvocation(next);
92
+ return next.correlationId;
93
+ }
94
+ return prevLatest;
95
+ });
96
+ });
97
+ }, [client]);
98
+
99
+ const call = useCallback(
100
+ async (args: { jobIndex: number; inputs: JobInputs; stream?: boolean }) => {
101
+ if (!client) {
102
+ throw new Error(
103
+ 'Job invocation not available in dev mode without a configured stub. See `setDevJobHandler` in the testing harness.',
104
+ );
105
+ }
106
+ // Clear prior invocation state when starting a new call.
107
+ setInvocation(null);
108
+ const result = await client.callJob(args);
109
+ setLatestId(result.correlationId);
110
+ return result;
111
+ },
112
+ [client],
113
+ );
114
+
115
+ const reset = useCallback(() => {
116
+ setInvocation(null);
117
+ setLatestId(null);
118
+ }, []);
119
+
120
+ return useMemo(
121
+ () => ({ call, invocation, reset, isPending: invocation?.status === 'pending' || invocation?.status === 'streaming' }),
122
+ [call, invocation, reset],
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Convenience: returns just the address when connected, or `null`. Most
128
+ * iframe components only care about the address.
129
+ */
130
+ export function useTangleAddress(): Address | null {
131
+ return useTangleIframeContext().wallet.address;
132
+ }
133
+
134
+ /** Whether the iframe has completed its parent-handshake (or is in dev mode). */
135
+ export function useTangleReady(): boolean {
136
+ return useTangleIframeContext().isReady;
137
+ }
138
+
139
+ /** Resolved mode — `'bridge'` (real parent) or `'dev'` (standalone). */
140
+ export function useTangleMode(): 'bridge' | 'dev' {
141
+ return useTangleIframeContext().mode;
142
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Tangle Cloud iframe SDK — thin renderer for marketplace blueprints.
3
+ *
4
+ * Use this when building a blueprint UI that will be embedded by the
5
+ * Tangle Cloud dapp. The SDK ships wallet + service-context state
6
+ * subscriptions and a `callJob` helper, all driven by the parent dapp
7
+ * over postMessage. The iframe never imports wagmi, never holds a wallet,
8
+ * never touches the chain.
9
+ *
10
+ * Quick start:
11
+ *
12
+ * import { TangleIframeProvider, useTangleWallet, useCallJob }
13
+ * from '@tangle-network/blueprint-iframe-sdk';
14
+ *
15
+ * <TangleIframeProvider appId="llm-inference">
16
+ * <App />
17
+ * </TangleIframeProvider>
18
+ *
19
+ * function PromptBar() {
20
+ * const { address } = useTangleWallet();
21
+ * const { call, invocation } = useCallJob();
22
+ * return <button onClick={() => call({ jobIndex: 0, inputs: { prompt: '...' }, stream: true })} />;
23
+ * }
24
+ *
25
+ * Two execution modes auto-detected:
26
+ *
27
+ * - **bridge** (production): real Tangle Cloud parent. Wallet + service
28
+ * state flows in via postMessage. `callJob` is forwarded upstream and
29
+ * the parent handles RFQ + signing + submission.
30
+ * - **dev** (standalone): no parent detected. Hook surface is identical;
31
+ * drive state via the testing harness or a `<TangleParentHarness>`
32
+ * wrapped around the provider with `mode="bridge"` + the harness
33
+ * origin.
34
+ *
35
+ * The mode is decided once at mount and doesn't switch mid-session.
36
+ */
37
+
38
+ export {
39
+ TangleIframeProvider,
40
+ useTangleIframeContext,
41
+ TANGLE_CLOUD_ORIGINS_DEFAULT,
42
+ } from './TangleIframeProvider';
43
+
44
+ export {
45
+ useTangleWallet,
46
+ useTangleService,
47
+ useCallJob,
48
+ useTangleAddress,
49
+ useTangleReady,
50
+ useTangleMode,
51
+ } from './hooks';
52
+
53
+ export {
54
+ TangleIframeClient,
55
+ type ClientEventMap,
56
+ type JobInvocation,
57
+ type ServiceSnapshot,
58
+ type TangleIframeClientOptions,
59
+ type WalletSnapshot,
60
+ } from './tangleIframeClient';
61
+
62
+ // Re-export the protocol types so consumers can build their own clients
63
+ // against the same wire format if they want to skip the React layer.
64
+ export type {
65
+ CallJobRequest,
66
+ JobInputs,
67
+ JobResultEvent,
68
+ JobResultStatus,
69
+ ServiceContextBroadcast,
70
+ ServiceContextJob,
71
+ ServiceContextOperator,
72
+ } from '../wallet/parentBridgeProtocol';
@@ -0,0 +1,229 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { TangleIframeClient } from './tangleIframeClient';
4
+ import { HARNESS_ORIGIN } from './testing';
5
+
6
+ const PARENT_ORIGIN = HARNESS_ORIGIN;
7
+
8
+ /**
9
+ * Drive the client against a fake parent that lives in the same window.
10
+ * Production flow goes iframe → window.parent (a different window); these
11
+ * tests collapse the two windows for assertability while keeping the
12
+ * exact protocol surface.
13
+ */
14
+ function setupFakeParent() {
15
+ const captured: object[] = [];
16
+ const originalParent = window.parent;
17
+ Object.defineProperty(window, 'parent', {
18
+ configurable: true,
19
+ get: () => ({
20
+ postMessage: (message: object) => {
21
+ captured.push(message);
22
+ },
23
+ }),
24
+ });
25
+ const restore = () => {
26
+ Object.defineProperty(window, 'parent', {
27
+ configurable: true,
28
+ value: originalParent,
29
+ });
30
+ };
31
+ const sendFromParent = (data: object) =>
32
+ window.dispatchEvent(new MessageEvent('message', { data, origin: PARENT_ORIGIN }));
33
+ return { captured, sendFromParent, restore };
34
+ }
35
+
36
+ describe('TangleIframeClient', () => {
37
+ let fake: ReturnType<typeof setupFakeParent>;
38
+ let client: TangleIframeClient;
39
+
40
+ beforeEach(() => {
41
+ fake = setupFakeParent();
42
+ client = new TangleIframeClient({
43
+ parentOrigin: PARENT_ORIGIN,
44
+ appId: 'test-app',
45
+ requestTimeoutMs: 1_000,
46
+ });
47
+ });
48
+
49
+ afterEach(() => {
50
+ client.uninstall();
51
+ fake.restore();
52
+ });
53
+
54
+ it('posts a versioned handshake on install', () => {
55
+ client.install();
56
+ expect(fake.captured[0]).toEqual({
57
+ kind: 'tangle.app.handshake',
58
+ appId: 'test-app',
59
+ version: '1',
60
+ });
61
+ });
62
+
63
+ it('emits a wallet snapshot when the parent broadcasts accountChanged', () => {
64
+ client.install();
65
+ const seen: unknown[] = [];
66
+ client.subscribe('wallet', (snap) => seen.push(snap));
67
+ fake.sendFromParent({
68
+ kind: 'tangle.app.accountChanged',
69
+ account: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
70
+ });
71
+ expect(seen).toEqual([
72
+ {
73
+ address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
74
+ chainId: null,
75
+ isConnected: true,
76
+ },
77
+ ]);
78
+ });
79
+
80
+ it('emits a service snapshot when the parent broadcasts serviceContext', () => {
81
+ client.install();
82
+ const seen: unknown[] = [];
83
+ client.subscribe('service', (snap) => seen.push(snap));
84
+ fake.sendFromParent({
85
+ kind: 'tangle.app.serviceContext',
86
+ blueprintId: '12',
87
+ serviceId: '42',
88
+ operators: [
89
+ {
90
+ address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
91
+ rpcAddress: 'http://op1:8000',
92
+ status: 'active',
93
+ },
94
+ ],
95
+ jobs: [{ index: 0, name: 'invoke' }],
96
+ mode: 'cloud',
97
+ });
98
+ expect(seen).toHaveLength(1);
99
+ const snap = seen[0] as {
100
+ blueprintId: string;
101
+ serviceId: string;
102
+ operators: unknown[];
103
+ jobs: unknown[];
104
+ };
105
+ expect(snap.blueprintId).toBe('12');
106
+ expect(snap.serviceId).toBe('42');
107
+ expect(snap.operators).toHaveLength(1);
108
+ expect(snap.jobs).toHaveLength(1);
109
+ });
110
+
111
+ it('routes callJob requests + resolves on success terminal status', async () => {
112
+ client.install();
113
+ fake.sendFromParent({
114
+ kind: 'tangle.app.handshakeAck',
115
+ appId: 'test-app',
116
+ protocolVersion: '1',
117
+ });
118
+ const call = client.callJob({
119
+ jobIndex: 0,
120
+ inputs: { prompt: 'hi' },
121
+ });
122
+ // First captured message is the handshake; the callJob is somewhere after.
123
+ const outbound = await vi.waitFor(() => {
124
+ const msg = fake.captured.find(
125
+ (c) => (c as { kind?: string }).kind === 'tangle.app.callJob',
126
+ );
127
+ if (!msg) throw new Error('callJob not posted yet');
128
+ return msg as { correlationId: string; inputs: Record<string, unknown> };
129
+ });
130
+ expect(outbound.inputs).toEqual({ prompt: 'hi' });
131
+ // Reply with terminal success
132
+ fake.sendFromParent({
133
+ kind: 'tangle.app.jobResult',
134
+ correlationId: outbound.correlationId,
135
+ status: 'success',
136
+ data: { text: 'hello world' },
137
+ });
138
+ const result = await call;
139
+ expect(result.status).toBe('success');
140
+ expect(result.data).toEqual({ text: 'hello world' });
141
+ });
142
+
143
+ it('accumulates streaming chunks in invocation state', async () => {
144
+ client.install();
145
+ fake.sendFromParent({
146
+ kind: 'tangle.app.handshakeAck',
147
+ appId: 'test-app',
148
+ protocolVersion: '1',
149
+ });
150
+ const jobEvents: unknown[] = [];
151
+ client.subscribe('job', (inv) => jobEvents.push(inv));
152
+ const call = client.callJob({
153
+ jobIndex: 0,
154
+ inputs: { prompt: 'stream' },
155
+ stream: true,
156
+ });
157
+ const outbound = await vi.waitFor(() => {
158
+ const msg = fake.captured.find(
159
+ (c) => (c as { kind?: string }).kind === 'tangle.app.callJob',
160
+ );
161
+ if (!msg) throw new Error('callJob not posted yet');
162
+ return msg as { correlationId: string };
163
+ });
164
+ // Stream chunks then terminal
165
+ fake.sendFromParent({
166
+ kind: 'tangle.app.jobResult',
167
+ correlationId: outbound.correlationId,
168
+ status: 'streaming',
169
+ chunk: 'hel',
170
+ });
171
+ fake.sendFromParent({
172
+ kind: 'tangle.app.jobResult',
173
+ correlationId: outbound.correlationId,
174
+ status: 'streaming',
175
+ chunk: 'lo',
176
+ });
177
+ fake.sendFromParent({
178
+ kind: 'tangle.app.jobResult',
179
+ correlationId: outbound.correlationId,
180
+ status: 'success',
181
+ data: { text: 'hello' },
182
+ });
183
+ const result = await call;
184
+ expect(result.chunks).toEqual(['hel', 'lo']);
185
+ expect(result.data).toEqual({ text: 'hello' });
186
+ // Job events: initial pending + 2 streaming + final success = 4
187
+ expect(jobEvents).toHaveLength(4);
188
+ });
189
+
190
+ it('rejects callJob on parent error', async () => {
191
+ client.install();
192
+ fake.sendFromParent({
193
+ kind: 'tangle.app.handshakeAck',
194
+ appId: 'test-app',
195
+ protocolVersion: '1',
196
+ });
197
+ const call = client.callJob({ jobIndex: 0, inputs: {} });
198
+ const outbound = await vi.waitFor(() => {
199
+ const msg = fake.captured.find(
200
+ (c) => (c as { kind?: string }).kind === 'tangle.app.callJob',
201
+ );
202
+ if (!msg) throw new Error('callJob not posted yet');
203
+ return msg as { correlationId: string };
204
+ });
205
+ fake.sendFromParent({
206
+ kind: 'tangle.app.jobResult',
207
+ correlationId: outbound.correlationId,
208
+ status: 'error',
209
+ error: 'operator-unavailable',
210
+ });
211
+ await expect(call).rejects.toThrow('operator-unavailable');
212
+ });
213
+
214
+ it('ignores parent messages from untrusted origins', () => {
215
+ client.install();
216
+ const seen: unknown[] = [];
217
+ client.subscribe('wallet', (snap) => seen.push(snap));
218
+ window.dispatchEvent(
219
+ new MessageEvent('message', {
220
+ data: {
221
+ kind: 'tangle.app.accountChanged',
222
+ account: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
223
+ },
224
+ origin: 'https://evil.example.com',
225
+ }),
226
+ );
227
+ expect(seen).toEqual([]);
228
+ });
229
+ });