@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.
- package/dist/{chunk-ZKICSKZH.js → chunk-TM5ROMDV.js} +1 -1
- package/dist/chunk-TM5ROMDV.js.map +1 -0
- package/dist/iframe/index.d.ts +5 -4
- package/dist/iframe/index.js +54 -4
- package/dist/iframe/index.js.map +1 -1
- package/dist/iframe/testing-index.d.ts +2 -2
- package/dist/iframe/testing-index.js +33 -8
- package/dist/iframe/testing-index.js.map +1 -1
- package/dist/{parentBridgeProtocol-BS2zbIvX.d.ts → parentBridgeProtocol-BSgLXg9g.d.ts} +12 -2
- package/dist/{tangleIframeClient-DES8FDF0.d.ts → tangleIframeClient-C7NFG_Dw.d.ts} +13 -1
- package/dist/wallet/index.d.ts +6 -1
- package/dist/wallet/index.js +25 -4
- package/dist/wallet/index.js.map +1 -1
- package/package.json +1 -1
- package/src/iframe/hooks.ts +6 -0
- package/src/iframe/integration.test.tsx +91 -0
- package/src/iframe/tangleIframeClient.test.ts +37 -0
- package/src/iframe/tangleIframeClient.ts +57 -3
- package/src/iframe/testing.tsx +49 -13
- package/src/wallet/parentBridgeProtocol.ts +15 -0
- package/src/wallet/parentBridgeProvider.ts +39 -4
- package/dist/chunk-ZKICSKZH.js.map +0 -1
|
@@ -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(() => {
|
package/src/iframe/testing.tsx
CHANGED
|
@@ -159,10 +159,15 @@ export const TangleParentHarness: FC<HarnessProps> = ({
|
|
|
159
159
|
callJobHandler.current = onCallJob;
|
|
160
160
|
const seenHandshake = useRef(false);
|
|
161
161
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
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
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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:
|
|
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":[]}
|