@tangle-network/blueprint-ui 0.5.0 → 0.5.2

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.
@@ -77,6 +77,43 @@ describe('TangleIframeClient', () => {
77
77
  ]);
78
78
  });
79
79
 
80
+ it('connect() asks the parent to connect and resolves with the address', async () => {
81
+ client.install();
82
+ fake.sendFromParent({
83
+ kind: 'tangle.app.handshakeAck',
84
+ appId: 'test-app',
85
+ protocolVersion: '1',
86
+ });
87
+ const promise = client.connect();
88
+ await vi.waitFor(() =>
89
+ expect(
90
+ fake.captured.some(
91
+ (m) => (m as { kind?: string }).kind === 'tangle.app.requestConnect',
92
+ ),
93
+ ).toBe(true),
94
+ );
95
+ const req = fake.captured.find(
96
+ (m) => (m as { kind?: string }).kind === 'tangle.app.requestConnect',
97
+ ) as { correlationId: string };
98
+ fake.sendFromParent({
99
+ kind: 'tangle.app.connectResult',
100
+ correlationId: req.correlationId,
101
+ ok: true,
102
+ data: {
103
+ account: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
104
+ chainId: 84532,
105
+ },
106
+ });
107
+ await expect(promise).resolves.toBe(
108
+ '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
109
+ );
110
+ expect(client.getWallet()).toMatchObject({
111
+ address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
112
+ chainId: 84532,
113
+ isConnected: true,
114
+ });
115
+ });
116
+
80
117
  it('emits a service snapshot when the parent broadcasts serviceContext', () => {
81
118
  client.install();
82
119
  const seen: unknown[] = [];
@@ -85,6 +85,11 @@ export type TangleIframeClientOptions = {
85
85
  };
86
86
 
87
87
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
88
+ // Connecting is gated on the user picking + approving a wallet in the parent's
89
+ // modal — give it a generous window rather than the standard request timeout.
90
+ const CONNECT_REQUEST_TIMEOUT_MS = 300_000;
91
+ const HANDSHAKE_RETRY_MS = 250;
92
+ const HANDSHAKE_RETRY_BUDGET_MS = 10_000;
88
93
  const NULL_WALLET: WalletSnapshot = {
89
94
  address: null,
90
95
  chainId: null,
@@ -112,6 +117,7 @@ export class TangleIframeClient {
112
117
  private handshakeAcked = false;
113
118
  private handshakeWaiters: Array<() => void> = [];
114
119
  private installed = false;
120
+ private handshakeRetry: ReturnType<typeof setInterval> | null = null;
115
121
  private listeners: {
116
122
  [K in keyof ClientEventMap]: Set<Listener<K>>;
117
123
  } = {
@@ -129,11 +135,27 @@ export class TangleIframeClient {
129
135
  this.installed = true;
130
136
  window.addEventListener('message', this.handleParentMessage);
131
137
  this.postHandshake();
138
+ // Stand up a bounded retry. The parent may attach its listener slightly
139
+ // after the iframe loads (React mounts child effects before parent
140
+ // effects; a real parent may create the frame before its handler is
141
+ // ready), so a single handshake can be dropped. Retry until acked.
142
+ if (this.handshakeRetry === null) {
143
+ let elapsed = 0;
144
+ this.handshakeRetry = setInterval(() => {
145
+ elapsed += HANDSHAKE_RETRY_MS;
146
+ if (this.handshakeAcked || elapsed >= HANDSHAKE_RETRY_BUDGET_MS) {
147
+ this.clearHandshakeRetry();
148
+ return;
149
+ }
150
+ this.postHandshake();
151
+ }, HANDSHAKE_RETRY_MS);
152
+ }
132
153
  }
133
154
 
134
155
  uninstall(): void {
135
156
  if (!this.installed || typeof window === 'undefined') return;
136
157
  this.installed = false;
158
+ this.clearHandshakeRetry();
137
159
  window.removeEventListener('message', this.handleParentMessage);
138
160
  for (const [, pending] of this.pendingJobs) {
139
161
  clearTimeout(pending.timer);
@@ -165,6 +187,28 @@ export class TangleIframeClient {
165
187
 
166
188
  // ── Wallet operations ───────────────────────────────────────────────────
167
189
 
190
+ /**
191
+ * Ask the parent dapp to connect a wallet — opening its connect modal if
192
+ * none is connected. The iframe is sandboxed and cannot reach a wallet
193
+ * itself, so connection is always delegated to the parent. Resolves with the
194
+ * connected address (or `null` if the user dismissed without connecting).
195
+ *
196
+ * Uses a long timeout (the user is interacting with a modal). Already-
197
+ * connected parents resolve immediately.
198
+ */
199
+ async connect(): Promise<Address | null> {
200
+ await this.ensureBootstrapped();
201
+ const data = await this.dispatchWallet(
202
+ 'tangle.app.requestConnect',
203
+ {},
204
+ CONNECT_REQUEST_TIMEOUT_MS,
205
+ );
206
+ const { account, chainId } = data as { account: Address; chainId: number };
207
+ const address = account === NO_WALLET_ADDRESS ? null : account;
208
+ this.updateWallet({ address, chainId, isConnected: address !== null });
209
+ return address;
210
+ }
211
+
168
212
  async signMessage(message: string): Promise<Hex> {
169
213
  await this.ensureBootstrapped();
170
214
  return this.dispatchWallet('tangle.app.signMessage', {
@@ -272,6 +316,13 @@ export class TangleIframeClient {
272
316
 
273
317
  // ── Internals ───────────────────────────────────────────────────────────
274
318
 
319
+ private clearHandshakeRetry(): void {
320
+ if (this.handshakeRetry !== null) {
321
+ clearInterval(this.handshakeRetry);
322
+ this.handshakeRetry = null;
323
+ }
324
+ }
325
+
275
326
  private postHandshake(): void {
276
327
  this.postToParent({
277
328
  kind: 'tangle.app.handshake',
@@ -297,6 +348,7 @@ export class TangleIframeClient {
297
348
  switch (message.kind) {
298
349
  case 'tangle.app.handshakeAck':
299
350
  this.handshakeAcked = true;
351
+ this.clearHandshakeRetry();
300
352
  for (const resolve of this.handshakeWaiters) resolve();
301
353
  this.handshakeWaiters = [];
302
354
  return;
@@ -344,19 +396,21 @@ export class TangleIframeClient {
344
396
  | 'tangle.app.signMessage'
345
397
  | 'tangle.app.signTransaction'
346
398
  | 'tangle.app.signTypedData'
347
- | 'tangle.app.switchChain',
399
+ | 'tangle.app.switchChain'
400
+ | 'tangle.app.requestConnect',
348
401
  payload: Record<string, unknown>,
402
+ timeoutMs?: number,
349
403
  ): Promise<unknown> {
350
404
  return new Promise((resolve, reject) => {
351
405
  const correlationId = makeCorrelationId(kind);
352
- const timeout =
353
- this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
406
+ const timeout = timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
354
407
  const expectedKind = (
355
408
  {
356
409
  'tangle.app.signMessage': 'tangle.app.signMessageResult',
357
410
  'tangle.app.signTransaction': 'tangle.app.signTransactionResult',
358
411
  'tangle.app.signTypedData': 'tangle.app.signTypedDataResult',
359
412
  'tangle.app.switchChain': 'tangle.app.switchChainResult',
413
+ 'tangle.app.requestConnect': 'tangle.app.connectResult',
360
414
  } as const
361
415
  )[kind];
362
416
  const timer = setTimeout(() => {
@@ -159,10 +159,15 @@ export const TangleParentHarness: FC<HarnessProps> = ({
159
159
  callJobHandler.current = onCallJob;
160
160
  const seenHandshake = useRef(false);
161
161
 
162
- // Listen for iframe → "parent" messages. Since the harness shares the
163
- // window, `window.postMessage` with the synthetic origin is the easiest
164
- // wire the iframe SDK posts to `window.parent`, which in same-window
165
- // mode IS this listener.
162
+ // Bridge the iframe → "parent" channel. The SDK posts via
163
+ // `window.parent.postMessage(msg, parentOrigin)`. In a real iframe that
164
+ // crosses a window boundary; in the harness both live in one window, where
165
+ // a same-window `postMessage` with a synthetic targetOrigin is *not*
166
+ // delivered (the origin won't match the document's real origin in jsdom /
167
+ // happy-dom / a real browser). So we intercept `window.parent.postMessage`
168
+ // directly — identical to how production parents receive frames, minus the
169
+ // window hop. Replies still travel back as dispatched `message` events
170
+ // tagged with HARNESS_ORIGIN, which the SDK's listener filters for.
166
171
  useEffect(() => {
167
172
  const reply = (message: ParentMessage) => {
168
173
  window.dispatchEvent(
@@ -199,13 +204,7 @@ export const TangleParentHarness: FC<HarnessProps> = ({
199
204
  }
200
205
  };
201
206
 
202
- const handler = async (event: MessageEvent) => {
203
- // The iframe posts via `window.parent.postMessage(msg, parentOrigin)`.
204
- // In same-window mode, that fires a message event on this same window
205
- // with origin = parentOrigin. Filter out events the harness itself
206
- // dispatched (origin === HARNESS_ORIGIN) — those are replies.
207
- if (event.origin === HARNESS_ORIGIN) return;
208
- const data = event.data;
207
+ const handleInbound = async (data: unknown) => {
209
208
  if (typeof data !== 'object' || data === null) return;
210
209
  const message = data as { kind?: string; correlationId?: string };
211
210
 
@@ -237,6 +236,25 @@ export const TangleParentHarness: FC<HarnessProps> = ({
237
236
  });
238
237
  return;
239
238
  }
239
+ case 'tangle.app.requestConnect': {
240
+ // The harness "connects" immediately to the mocked wallet — a real
241
+ // parent would open its connect modal and resolve once the user
242
+ // picks a wallet. Tests that need the disconnected path pass a
243
+ // wallet with a null address.
244
+ if (typeof message.correlationId !== 'string') return;
245
+ reply({
246
+ kind: 'tangle.app.connectResult',
247
+ correlationId: message.correlationId,
248
+ ok: true,
249
+ data: {
250
+ account:
251
+ currentWallet.address ??
252
+ ('0x0000000000000000000000000000000000000000' as Address),
253
+ chainId: currentWallet.chainId ?? 0,
254
+ },
255
+ });
256
+ return;
257
+ }
240
258
  case 'tangle.app.callJob': {
241
259
  if (typeof message.correlationId !== 'string') return;
242
260
  const request = message as unknown as CallJobRequest;
@@ -338,8 +356,26 @@ export const TangleParentHarness: FC<HarnessProps> = ({
338
356
  }
339
357
  }
340
358
  };
341
- window.addEventListener('message', handler);
342
- return () => window.removeEventListener('message', handler);
359
+
360
+ // Route the iframe's outbound posts straight into the handler. We expose
361
+ // only `postMessage` — the SDK never touches other `window.parent`
362
+ // members — and restore the original on teardown.
363
+ const originalParent = window.parent;
364
+ const proxyParent = {
365
+ postMessage: (message: unknown) => {
366
+ void handleInbound(message);
367
+ },
368
+ } as unknown as Window;
369
+ Object.defineProperty(window, 'parent', {
370
+ configurable: true,
371
+ get: () => proxyParent,
372
+ });
373
+ return () => {
374
+ Object.defineProperty(window, 'parent', {
375
+ configurable: true,
376
+ value: originalParent,
377
+ });
378
+ };
343
379
  }, [appId, currentWallet, currentService]);
344
380
 
345
381
  // Re-broadcast when state changes.
@@ -20,6 +20,15 @@ export type ReadAccountRequest = {
20
20
  correlationId: string;
21
21
  };
22
22
 
23
+ // Ask the parent to ensure a wallet is connected — opening its connect modal
24
+ // if none is. A sandboxed iframe can't reach a wallet extension itself, so
25
+ // this is the *only* way an iframe can initiate a connection: it delegates to
26
+ // the parent, which owns the wallet. Resolves once the parent has an account.
27
+ export type RequestConnectRequest = {
28
+ kind: 'tangle.app.requestConnect';
29
+ correlationId: string;
30
+ };
31
+
23
32
  export type SwitchChainRequest = {
24
33
  kind: 'tangle.app.switchChain';
25
34
  correlationId: string;
@@ -90,6 +99,10 @@ export type ReadAccountResult = {
90
99
  kind: 'tangle.app.readAccountResult';
91
100
  } & ResultEnvelope<{ account: Address; chainId: number }>;
92
101
 
102
+ export type ConnectResult = {
103
+ kind: 'tangle.app.connectResult';
104
+ } & ResultEnvelope<{ account: Address; chainId: number }>;
105
+
93
106
  export type SwitchChainResult = {
94
107
  kind: 'tangle.app.switchChainResult';
95
108
  } & ResultEnvelope<{ chainId: number }>;
@@ -221,6 +234,7 @@ export type JobResultEvent = {
221
234
  export type ParentMessage =
222
235
  | HandshakeAck
223
236
  | ReadAccountResult
237
+ | ConnectResult
224
238
  | SwitchChainResult
225
239
  | SignMessageResult
226
240
  | SignTransactionResult
@@ -233,6 +247,7 @@ export type ParentMessage =
233
247
  export type IframeRequest =
234
248
  | HandshakeRequest
235
249
  | ReadAccountRequest
250
+ | RequestConnectRequest
236
251
  | SwitchChainRequest
237
252
  | SignMessageRequest
238
253
  | SignTransactionRequest
@@ -14,6 +14,7 @@ import {
14
14
  makeCorrelationId,
15
15
  NO_WALLET_ADDRESS,
16
16
  TANGLE_IFRAME_PROTOCOL_VERSION,
17
+ type ConnectResult,
17
18
  type ParentMessage,
18
19
  type ReadAccountResult,
19
20
  type SignMessageResult,
@@ -51,6 +52,9 @@ export type ParentBridgeOptions = {
51
52
  };
52
53
 
53
54
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
55
+ // Connecting is gated on the user interacting with the parent's modal — allow
56
+ // a generous window rather than the standard per-request timeout.
57
+ const CONNECT_REQUEST_TIMEOUT_MS = 300_000;
54
58
 
55
59
  /**
56
60
  * Detect iframe execution context. When this returns `false` the bridge
@@ -121,11 +125,19 @@ export class ParentBridgeProvider {
121
125
  await this.ensureBootstrapped();
122
126
  return this.cachedChainId !== null ? `0x${this.cachedChainId.toString(16)}` : '0x0';
123
127
  }
124
- case 'eth_accounts':
125
- case 'eth_requestAccounts': {
128
+ case 'eth_accounts': {
129
+ // Passive read — never opens a modal.
126
130
  await this.ensureBootstrapped();
127
131
  return this.cachedAccount !== null ? [this.cachedAccount] : [];
128
132
  }
133
+ case 'eth_requestAccounts': {
134
+ // Active connect (the wallet "Connect" button). If the parent has no
135
+ // wallet yet, delegate to it to open its connect modal and wait.
136
+ await this.ensureBootstrapped();
137
+ if (this.cachedAccount !== null) return [this.cachedAccount];
138
+ const account = await this.requestConnect();
139
+ return account !== null ? [account] : [];
140
+ }
129
141
  case 'personal_sign': {
130
142
  const [message, _signer] = params as [string, Address];
131
143
  return this.requestSignMessage(message);
@@ -213,6 +225,7 @@ export class ParentBridgeProvider {
213
225
  });
214
226
  return;
215
227
  case 'tangle.app.readAccountResult':
228
+ case 'tangle.app.connectResult':
216
229
  this.resolvePending(message);
217
230
  if (message.ok) {
218
231
  this.updateAccount(
@@ -247,6 +260,21 @@ export class ParentBridgeProvider {
247
260
  }) as Promise<{ account: Address; chainId: number }>;
248
261
  }
249
262
 
263
+ /**
264
+ * Ask the parent to connect a wallet (opening its modal if needed) and wait
265
+ * for the result. Long timeout — the user is interacting with the modal.
266
+ */
267
+ private requestConnect(): Promise<Address | null> {
268
+ return this.dispatch({
269
+ kind: 'tangle.app.requestConnect',
270
+ expectedKind: 'tangle.app.connectResult',
271
+ timeoutMs: CONNECT_REQUEST_TIMEOUT_MS,
272
+ }).then((data) => {
273
+ const { account } = data as { account: Address; chainId: number };
274
+ return account === NO_WALLET_ADDRESS ? null : account;
275
+ });
276
+ }
277
+
250
278
  private requestSignMessage(message: string): Promise<Hex> {
251
279
  const chainId = this.cachedChainId ?? 1;
252
280
  return this.dispatch({
@@ -289,13 +317,19 @@ export class ParentBridgeProvider {
289
317
  }
290
318
 
291
319
  private async dispatch(req: {
292
- kind: 'tangle.app.readAccount' | 'tangle.app.switchChain' | 'tangle.app.signMessage' | 'tangle.app.signTransaction';
320
+ kind:
321
+ | 'tangle.app.readAccount'
322
+ | 'tangle.app.switchChain'
323
+ | 'tangle.app.signMessage'
324
+ | 'tangle.app.signTransaction'
325
+ | 'tangle.app.requestConnect';
293
326
  expectedKind: ParentMessage['kind'];
294
327
  payload?: Record<string, unknown>;
328
+ timeoutMs?: number;
295
329
  }): Promise<unknown> {
296
330
  await this.ensureBootstrapped();
297
331
  const correlationId = makeCorrelationId(req.kind);
298
- const timeout = this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
332
+ const timeout = req.timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
299
333
  return new Promise<unknown>((resolve, reject) => {
300
334
  const timer = window.setTimeout(() => {
301
335
  this.pending.delete(correlationId);
@@ -329,6 +363,7 @@ export class ParentBridgeProvider {
329
363
  private resolvePending(
330
364
  message:
331
365
  | ReadAccountResult
366
+ | ConnectResult
332
367
  | SwitchChainResult
333
368
  | SignMessageResult
334
369
  | SignTransactionResult,
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/wallet/detectParentOrigin.ts","../src/wallet/parentBridgeProtocol.ts"],"sourcesContent":["// Determine which origin to trust as the parent dapp.\n//\n// `document.referrer` is the *initial* embedder — it's set when the iframe is\n// first loaded and survives reloads (though it can be cleared by `referrerpolicy`\n// or by the embedder). The Tangle Cloud iframe wrapper deliberately omits\n// `referrerpolicy=\"no-referrer\"` so we get the embedder's origin here.\n//\n// We compare it against an allowlist of known Tangle Cloud origins. If it\n// matches, that's the parent. Otherwise the iframe is being loaded directly\n// (standalone domain visit, dev server, untrusted embedder) and the bridge\n// stays disabled — the app falls back to its normal injected/WC wallet path.\n\n/**\n * Default Tangle Cloud origins. Consumers (agent-sandbox UI,\n * trading-arena, future iframe blueprints) pass app-specific additions\n * via `extraOrigins` rather than mutating this list.\n */\nexport const TANGLE_CLOUD_ORIGINS_DEFAULT = Object.freeze([\n 'https://cloud.tangle.tools',\n 'https://develop.cloud.tangle.tools',\n // Local dev (Vite default port for tangle-cloud + Netlify dev preview).\n 'http://localhost:4300',\n 'http://localhost:8888',\n] as const);\n\nfunction originFromReferrer(): string | null {\n if (typeof document === 'undefined') return null;\n const ref = document.referrer;\n if (!ref) return null;\n try {\n return new URL(ref).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * Returns the parent origin to bridge to, or null when no trusted parent is\n * detected. Caller should skip installing the bridge connector when this\n * returns null.\n *\n * `extraOrigins` is the application's escape hatch for staging or dev\n * deploys not covered by the default list. The library deliberately does\n * not read environment variables itself (consumers may bundle for non-Vite\n * runtimes); the consuming app threads `import.meta.env.VITE_*` or\n * `process.env.*` in itself.\n *\n * Falls back to a `?parent=<origin>` query parameter when no referrer is\n * present (some browsers strip referrer from cross-origin loads). Useful\n * for dev embedding flows.\n */\nexport function detectTangleCloudParentOrigin(\n options: { extraOrigins?: readonly string[] } = {},\n): string | null {\n if (typeof window === 'undefined' || window.parent === window) {\n return null;\n }\n const allowlist = new Set<string>([\n ...TANGLE_CLOUD_ORIGINS_DEFAULT,\n ...(options.extraOrigins ?? []),\n ]);\n const referrerOrigin = originFromReferrer();\n if (referrerOrigin && allowlist.has(referrerOrigin)) {\n return referrerOrigin;\n }\n try {\n const url = new URL(window.location.href);\n const explicit = url.searchParams.get('parent');\n if (explicit && allowlist.has(explicit)) return explicit;\n } catch {\n // ignore\n }\n return null;\n}\n","// Tangle Cloud iframe ↔ parent dapp protocol — must mirror the parent's\n// spec at `apps/tangle-cloud/src/blueprintApps/iframe/protocol.ts`. Bump the\n// version constant in lockstep when either side adds a request kind.\n\nimport type { Address, Hex } from 'viem';\n\nexport const TANGLE_IFRAME_PROTOCOL_VERSION = '1' as const;\nexport const TANGLE_IFRAME_PROTOCOL_PREFIX = 'tangle.app.';\n\n// ─── Iframe → Parent requests ────────────────────────────────────────────────\n\nexport type HandshakeRequest = {\n kind: 'tangle.app.handshake';\n appId: string;\n version: typeof TANGLE_IFRAME_PROTOCOL_VERSION;\n};\n\nexport type ReadAccountRequest = {\n kind: 'tangle.app.readAccount';\n correlationId: string;\n};\n\nexport type SwitchChainRequest = {\n kind: 'tangle.app.switchChain';\n correlationId: string;\n chainId: number;\n};\n\nexport type SignMessageRequest = {\n kind: 'tangle.app.signMessage';\n correlationId: string;\n chainId: number;\n message: string;\n};\n\nexport type SignTransactionRequest = {\n kind: 'tangle.app.signTransaction';\n correlationId: string;\n chainId: number;\n to: Address;\n data: Hex;\n value?: string;\n};\n\n// EIP-712 typed-data signing for publishers that need to sign custom message\n// shapes — operator envelopes, off-chain attestations, claim proofs, etc.\n// The parent renders the typed-data fields in its approval modal so the user\n// can audit what they're signing. Iframes never see the wallet's signing key\n// or private state.\n//\n// Shape mirrors viem's `signTypedData` argument: `domain` + `types` (without\n// the EIP712Domain entry — viem injects it) + `primaryType` + `message`.\n// Validation on the parent side rejects payloads that are obviously\n// malformed (missing primaryType, types map empty, etc.) but does NOT\n// re-shape the message — the user is the one who decides whether to sign.\nexport type SignTypedDataRequest = {\n kind: 'tangle.app.signTypedData';\n correlationId: string;\n chainId: number;\n domain: Readonly<{\n name?: string;\n version?: string;\n chainId?: number;\n verifyingContract?: Address;\n salt?: Hex;\n }>;\n /** EIP-712 types map; do NOT include the EIP712Domain entry (the parent\n * injects it derived from `domain`). */\n types: Readonly<Record<string, ReadonlyArray<{ name: string; type: string }>>>;\n /** Top-level type name in `types` whose values appear in `message`. */\n primaryType: string;\n /** The actual typed-data values. Shape matches `types[primaryType]`. */\n message: Readonly<Record<string, unknown>>;\n};\n\n// ─── Parent → Iframe messages ────────────────────────────────────────────────\n\nexport type HandshakeAck = {\n kind: 'tangle.app.handshakeAck';\n appId: string;\n protocolVersion: typeof TANGLE_IFRAME_PROTOCOL_VERSION;\n};\n\nexport type ResultEnvelope<T> = { correlationId: string } & (\n | { ok: true; data: T }\n | { ok: false; error: string }\n);\n\nexport type ReadAccountResult = {\n kind: 'tangle.app.readAccountResult';\n} & ResultEnvelope<{ account: Address; chainId: number }>;\n\nexport type SwitchChainResult = {\n kind: 'tangle.app.switchChainResult';\n} & ResultEnvelope<{ chainId: number }>;\n\nexport type SignMessageResult = {\n kind: 'tangle.app.signMessageResult';\n} & ResultEnvelope<{ signature: Hex }>;\n\nexport type SignTransactionResult = {\n kind: 'tangle.app.signTransactionResult';\n} & ResultEnvelope<{ txHash: Hex }>;\n\nexport type SignTypedDataResult = {\n kind: 'tangle.app.signTypedDataResult';\n} & ResultEnvelope<{ signature: Hex }>;\n\nexport type AccountChanged = {\n kind: 'tangle.app.accountChanged';\n account: Address | null;\n};\n\nexport type ChainChanged = {\n kind: 'tangle.app.chainChanged';\n chainId: number;\n};\n\n// ─── Service context (parent → iframe) ──────────────────────────────────────\n//\n// Iframe blueprints embedded by Tangle Cloud need to know which service +\n// blueprint they're rendering for, plus which operators are quoted. The\n// parent broadcasts this on mount and on every change (mode picker swap,\n// new service activation, operator delta). The iframe just reads — it\n// doesn't query the chain itself.\n//\n// The thin-iframe SDK exposes this as `useTangleService()`. Iframes that\n// use the full wagmi connector path can still listen to `serviceContext`\n// for routing convenience.\n\nexport type ServiceContextOperator = {\n readonly address: Address;\n readonly rpcAddress: string | undefined;\n readonly status: 'active' | 'inactive' | 'unknown';\n};\n\nexport type ServiceContextJob = {\n readonly index: number;\n readonly name: string;\n readonly inputSchema?: unknown;\n};\n\n/**\n * Chain configuration the parent broadcasts to the iframe along with\n * service context. Iframes use this to build a `viem` public client for\n * READ-ONLY queries (`useTanglePublicClient` is the convenience hook).\n *\n * Iframes can ignore this and roll their own RPC config — particularly\n * when they need to read from chains OTHER than the active one (e.g. a\n * trading dapp pulling oracle data from mainnet while the active service\n * lives on Base Sepolia). The injected client is a hint, not a constraint.\n *\n * `rpcUrl` is the public RPC the parent uses, NOT a wallet RPC. Iframes\n * cannot sign or submit with this URL; signing always routes upstream via\n * the bridge.\n */\nexport type ChainContext = {\n readonly id: number;\n readonly name: string;\n readonly rpcUrl: string;\n /** Block-explorer base URL — useful for rendering tx links. */\n readonly blockExplorerUrl?: string;\n /** Native currency metadata for cost displays. */\n readonly nativeCurrency?: { readonly name: string; readonly symbol: string; readonly decimals: number };\n};\n\nexport type ServiceContextBroadcast = {\n kind: 'tangle.app.serviceContext';\n readonly blueprintId: string;\n readonly serviceId: string | null;\n readonly operators: readonly ServiceContextOperator[];\n readonly jobs: readonly ServiceContextJob[];\n readonly mode: string | null;\n /** Active chain the parent is connected to; iframes can build a viem\n * publicClient against this for convenience. Optional for backwards\n * compatibility with parents that haven't been upgraded yet. */\n readonly chain?: ChainContext;\n};\n\n// ─── Job invocation (iframe ↔ parent) ────────────────────────────────────────\n//\n// Instead of the iframe wiring up its own EIP-712 quote / sign / submit\n// flow, it sends a single CallJob request upstream. The parent does the\n// whole dance (fetch RFQ quote, build typed data, request user signature,\n// submit on-chain) and streams results back. The iframe never touches\n// chain logic.\n\nexport type JobInputs = Readonly<Record<string, unknown>>;\n\nexport type CallJobRequest = {\n kind: 'tangle.app.callJob';\n correlationId: string;\n /** Job index within the blueprint, e.g. 0 for the primary entry-point. */\n jobIndex: number;\n /** Free-form inputs validated by the parent against the on-chain ABI. */\n inputs: JobInputs;\n /**\n * Whether the publisher wants intermediate progress (streaming chunks)\n * or just the terminal result. Streaming jobs (LLM generation, video\n * encode) opt in; one-shots (embeddings, classifications) don't.\n */\n stream?: boolean;\n};\n\nexport type JobResultStatus = 'pending' | 'streaming' | 'success' | 'error';\n\nexport type JobResultEvent = {\n kind: 'tangle.app.jobResult';\n correlationId: string;\n status: JobResultStatus;\n /** Present on `streaming` and `success`. Shape is publisher-defined. */\n data?: unknown;\n /** Present on `streaming` only — incremental chunk for live UI. */\n chunk?: unknown;\n /** Present on `error`. Human-readable. */\n error?: string;\n /** Optional progress metadata (e.g. `{ percent: 0.42, eta_ms: 8000 }`). */\n progress?: { readonly percent?: number; readonly eta_ms?: number };\n};\n\nexport type ParentMessage =\n | HandshakeAck\n | ReadAccountResult\n | SwitchChainResult\n | SignMessageResult\n | SignTransactionResult\n | SignTypedDataResult\n | AccountChanged\n | ChainChanged\n | ServiceContextBroadcast\n | JobResultEvent;\n\nexport type IframeRequest =\n | HandshakeRequest\n | ReadAccountRequest\n | SwitchChainRequest\n | SignMessageRequest\n | SignTransactionRequest\n | SignTypedDataRequest\n | CallJobRequest;\n\n// The zero address used by the parent when no wallet is connected. The parent\n// always responds to readAccount with an address; this sentinel means \"no\n// wallet\" without making the response type a union of result shapes.\nexport const NO_WALLET_ADDRESS = '0x0000000000000000000000000000000000000000';\n\n/**\n * Cryptographically-random ASCII correlation id matching the parent's\n * validator regex (`/^[\\w.\\-:]+$/`, max length 128). The connector keeps a\n * Map<correlationId, Resolver> so each request resolves independently.\n */\nexport function makeCorrelationId(prefix: string): string {\n const random =\n typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'\n ? crypto.randomUUID()\n : Math.random().toString(36).slice(2) + Date.now().toString(36);\n return `${prefix}.${random}`;\n}\n"],"mappings":";AAiBO,IAAM,+BAA+B,OAAO,OAAO;AAAA,EACxD;AAAA,EACA;AAAA;AAAA,EAEA;AAAA,EACA;AACF,CAAU;AAEV,SAAS,qBAAoC;AAC3C,MAAI,OAAO,aAAa,YAAa,QAAO;AAC5C,QAAM,MAAM,SAAS;AACrB,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI;AACF,WAAO,IAAI,IAAI,GAAG,EAAE;AAAA,EACtB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAiBO,SAAS,8BACd,UAAgD,CAAC,GAClC;AACf,MAAI,OAAO,WAAW,eAAe,OAAO,WAAW,QAAQ;AAC7D,WAAO;AAAA,EACT;AACA,QAAM,YAAY,oBAAI,IAAY;AAAA,IAChC,GAAG;AAAA,IACH,GAAI,QAAQ,gBAAgB,CAAC;AAAA,EAC/B,CAAC;AACD,QAAM,iBAAiB,mBAAmB;AAC1C,MAAI,kBAAkB,UAAU,IAAI,cAAc,GAAG;AACnD,WAAO;AAAA,EACT;AACA,MAAI;AACF,UAAM,MAAM,IAAI,IAAI,OAAO,SAAS,IAAI;AACxC,UAAM,WAAW,IAAI,aAAa,IAAI,QAAQ;AAC9C,QAAI,YAAY,UAAU,IAAI,QAAQ,EAAG,QAAO;AAAA,EAClD,QAAQ;AAAA,EAER;AACA,SAAO;AACT;;;ACnEO,IAAM,iCAAiC;AACvC,IAAM,gCAAgC;AA6OtC,IAAM,oBAAoB;AAO1B,SAAS,kBAAkB,QAAwB;AACxD,QAAM,SACJ,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe,aAC1D,OAAO,WAAW,IAClB,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,CAAC,IAAI,KAAK,IAAI,EAAE,SAAS,EAAE;AAClE,SAAO,GAAG,MAAM,IAAI,MAAM;AAC5B;","names":[]}