@tangle-network/blueprint-ui 0.4.0 → 0.5.1
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-BLXSBQU4.js → chunk-ZKICSKZH.js} +1 -1
- package/dist/chunk-ZKICSKZH.js.map +1 -0
- package/dist/iframe/index.d.ts +38 -6
- package/dist/iframe/index.js +95 -5
- package/dist/iframe/index.js.map +1 -1
- package/dist/iframe/testing-index.d.ts +3 -2
- package/dist/iframe/testing-index.js +44 -11
- package/dist/iframe/testing-index.js.map +1 -1
- package/dist/{parentBridgeProtocol-CqK9e6Fk.d.ts → parentBridgeProtocol-BS2zbIvX.d.ts} +61 -3
- package/dist/{tangleIframeClient-D-PP-KhN.d.ts → tangleIframeClient-CAyUr99p.d.ts} +21 -1
- package/dist/wallet/index.d.ts +1 -1
- package/dist/wallet/index.js +1 -1
- package/package.json +1 -1
- package/src/iframe/TangleIframeProvider.tsx +1 -0
- package/src/iframe/hooks.ts +95 -3
- package/src/iframe/index.ts +5 -0
- package/src/iframe/integration.test.tsx +91 -0
- package/src/iframe/tangleIframeClient.test.ts +88 -0
- package/src/iframe/tangleIframeClient.ts +61 -0
- package/src/iframe/testing.tsx +63 -13
- package/src/wallet/index.ts +3 -0
- package/src/wallet/parentBridgeProtocol.ts +65 -0
- package/dist/chunk-BLXSBQU4.js.map +0 -1
|
@@ -226,4 +226,92 @@ describe('TangleIframeClient', () => {
|
|
|
226
226
|
);
|
|
227
227
|
expect(seen).toEqual([]);
|
|
228
228
|
});
|
|
229
|
+
|
|
230
|
+
it('forwards signTypedData through the bridge with the full EIP-712 payload', async () => {
|
|
231
|
+
client.install();
|
|
232
|
+
fake.sendFromParent({
|
|
233
|
+
kind: 'tangle.app.handshakeAck',
|
|
234
|
+
appId: 'test-app',
|
|
235
|
+
protocolVersion: '1',
|
|
236
|
+
});
|
|
237
|
+
fake.sendFromParent({ kind: 'tangle.app.chainChanged', chainId: 84532 });
|
|
238
|
+
const payload = {
|
|
239
|
+
domain: {
|
|
240
|
+
name: 'TradingArena',
|
|
241
|
+
version: '1',
|
|
242
|
+
chainId: 84532,
|
|
243
|
+
verifyingContract:
|
|
244
|
+
'0x0000000000000000000000000000000000000abc' as `0x${string}`,
|
|
245
|
+
},
|
|
246
|
+
types: {
|
|
247
|
+
Envelope: [
|
|
248
|
+
{ name: 'trader', type: 'address' },
|
|
249
|
+
{ name: 'qty', type: 'uint256' },
|
|
250
|
+
{ name: 'price', type: 'uint256' },
|
|
251
|
+
{ name: 'nonce', type: 'uint256' },
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
primaryType: 'Envelope',
|
|
255
|
+
message: {
|
|
256
|
+
trader: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
|
|
257
|
+
qty: '100',
|
|
258
|
+
price: '4200',
|
|
259
|
+
nonce: '7',
|
|
260
|
+
} as Readonly<Record<string, unknown>>,
|
|
261
|
+
};
|
|
262
|
+
const signed = client.signTypedData(payload);
|
|
263
|
+
const outbound = await vi.waitFor(() => {
|
|
264
|
+
const msg = fake.captured.find(
|
|
265
|
+
(c) => (c as { kind?: string }).kind === 'tangle.app.signTypedData',
|
|
266
|
+
);
|
|
267
|
+
if (!msg) throw new Error('signTypedData not posted yet');
|
|
268
|
+
return msg as {
|
|
269
|
+
correlationId: string;
|
|
270
|
+
primaryType: string;
|
|
271
|
+
domain: { chainId?: number };
|
|
272
|
+
types: Record<string, unknown>;
|
|
273
|
+
message: Record<string, unknown>;
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
expect(outbound.primaryType).toBe('Envelope');
|
|
277
|
+
expect(outbound.domain.chainId).toBe(84532);
|
|
278
|
+
expect(outbound.types).toHaveProperty('Envelope');
|
|
279
|
+
expect((outbound.message as { qty: string }).qty).toBe('100');
|
|
280
|
+
fake.sendFromParent({
|
|
281
|
+
kind: 'tangle.app.signTypedDataResult',
|
|
282
|
+
correlationId: outbound.correlationId,
|
|
283
|
+
ok: true,
|
|
284
|
+
data: { signature: '0xabcdef' as `0x${string}` },
|
|
285
|
+
});
|
|
286
|
+
await expect(signed).resolves.toBe('0xabcdef');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('emits chain context as part of the service snapshot', () => {
|
|
290
|
+
client.install();
|
|
291
|
+
const seen: unknown[] = [];
|
|
292
|
+
client.subscribe('service', (snap) => seen.push(snap));
|
|
293
|
+
fake.sendFromParent({
|
|
294
|
+
kind: 'tangle.app.serviceContext',
|
|
295
|
+
blueprintId: '12',
|
|
296
|
+
serviceId: '42',
|
|
297
|
+
operators: [],
|
|
298
|
+
jobs: [],
|
|
299
|
+
mode: null,
|
|
300
|
+
chain: {
|
|
301
|
+
id: 84532,
|
|
302
|
+
name: 'Base Sepolia',
|
|
303
|
+
rpcUrl: 'https://sepolia.base.org',
|
|
304
|
+
blockExplorerUrl: 'https://sepolia.basescan.org',
|
|
305
|
+
nativeCurrency: {
|
|
306
|
+
name: 'Sepolia Ether',
|
|
307
|
+
symbol: 'ETH',
|
|
308
|
+
decimals: 18,
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
expect(seen).toHaveLength(1);
|
|
313
|
+
const snap = seen[0] as { chain: { id: number; rpcUrl: string } | null };
|
|
314
|
+
expect(snap.chain?.id).toBe(84532);
|
|
315
|
+
expect(snap.chain?.rpcUrl).toBe('https://sepolia.base.org');
|
|
316
|
+
});
|
|
229
317
|
});
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
NO_WALLET_ADDRESS,
|
|
15
15
|
TANGLE_IFRAME_PROTOCOL_VERSION,
|
|
16
16
|
type CallJobRequest,
|
|
17
|
+
type ChainContext,
|
|
17
18
|
type JobInputs,
|
|
18
19
|
type JobResultEvent,
|
|
19
20
|
type JobResultStatus,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
type ServiceContextBroadcast,
|
|
22
23
|
type ServiceContextJob,
|
|
23
24
|
type ServiceContextOperator,
|
|
25
|
+
type SignTypedDataRequest,
|
|
24
26
|
} from '../wallet/parentBridgeProtocol';
|
|
25
27
|
|
|
26
28
|
export type WalletSnapshot = {
|
|
@@ -35,6 +37,9 @@ export type ServiceSnapshot = {
|
|
|
35
37
|
readonly operators: readonly ServiceContextOperator[];
|
|
36
38
|
readonly jobs: readonly ServiceContextJob[];
|
|
37
39
|
readonly mode: string | null;
|
|
40
|
+
/** Chain context broadcast by the parent — drives `useTanglePublicClient`.
|
|
41
|
+
* `null` when the parent hasn't sent one (older parent or dev mode). */
|
|
42
|
+
readonly chain: ChainContext | null;
|
|
38
43
|
};
|
|
39
44
|
|
|
40
45
|
export type JobInvocation = {
|
|
@@ -80,6 +85,8 @@ export type TangleIframeClientOptions = {
|
|
|
80
85
|
};
|
|
81
86
|
|
|
82
87
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
88
|
+
const HANDSHAKE_RETRY_MS = 250;
|
|
89
|
+
const HANDSHAKE_RETRY_BUDGET_MS = 10_000;
|
|
83
90
|
const NULL_WALLET: WalletSnapshot = {
|
|
84
91
|
address: null,
|
|
85
92
|
chainId: null,
|
|
@@ -91,6 +98,7 @@ const NULL_SERVICE: ServiceSnapshot = {
|
|
|
91
98
|
operators: [],
|
|
92
99
|
jobs: [],
|
|
93
100
|
mode: null,
|
|
101
|
+
chain: null,
|
|
94
102
|
};
|
|
95
103
|
|
|
96
104
|
type PendingJob = {
|
|
@@ -106,6 +114,7 @@ export class TangleIframeClient {
|
|
|
106
114
|
private handshakeAcked = false;
|
|
107
115
|
private handshakeWaiters: Array<() => void> = [];
|
|
108
116
|
private installed = false;
|
|
117
|
+
private handshakeRetry: ReturnType<typeof setInterval> | null = null;
|
|
109
118
|
private listeners: {
|
|
110
119
|
[K in keyof ClientEventMap]: Set<Listener<K>>;
|
|
111
120
|
} = {
|
|
@@ -123,11 +132,27 @@ export class TangleIframeClient {
|
|
|
123
132
|
this.installed = true;
|
|
124
133
|
window.addEventListener('message', this.handleParentMessage);
|
|
125
134
|
this.postHandshake();
|
|
135
|
+
// Stand up a bounded retry. The parent may attach its listener slightly
|
|
136
|
+
// after the iframe loads (React mounts child effects before parent
|
|
137
|
+
// effects; a real parent may create the frame before its handler is
|
|
138
|
+
// ready), so a single handshake can be dropped. Retry until acked.
|
|
139
|
+
if (this.handshakeRetry === null) {
|
|
140
|
+
let elapsed = 0;
|
|
141
|
+
this.handshakeRetry = setInterval(() => {
|
|
142
|
+
elapsed += HANDSHAKE_RETRY_MS;
|
|
143
|
+
if (this.handshakeAcked || elapsed >= HANDSHAKE_RETRY_BUDGET_MS) {
|
|
144
|
+
this.clearHandshakeRetry();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.postHandshake();
|
|
148
|
+
}, HANDSHAKE_RETRY_MS);
|
|
149
|
+
}
|
|
126
150
|
}
|
|
127
151
|
|
|
128
152
|
uninstall(): void {
|
|
129
153
|
if (!this.installed || typeof window === 'undefined') return;
|
|
130
154
|
this.installed = false;
|
|
155
|
+
this.clearHandshakeRetry();
|
|
131
156
|
window.removeEventListener('message', this.handleParentMessage);
|
|
132
157
|
for (const [, pending] of this.pendingJobs) {
|
|
133
158
|
clearTimeout(pending.timer);
|
|
@@ -188,6 +213,31 @@ export class TangleIframeClient {
|
|
|
188
213
|
);
|
|
189
214
|
}
|
|
190
215
|
|
|
216
|
+
/**
|
|
217
|
+
* EIP-712 typed-data signing. The parent renders the typed-data fields in
|
|
218
|
+
* its approval modal; the user audits what they're signing. Use for
|
|
219
|
+
* operator envelopes, off-chain attestations, anything that needs a
|
|
220
|
+
* signature outside the standard blueprint-job RFQ flow.
|
|
221
|
+
*
|
|
222
|
+
* Shape mirrors viem's `signTypedData` argument. Do not include the
|
|
223
|
+
* EIP712Domain entry in `types` — the parent injects it from `domain`.
|
|
224
|
+
*/
|
|
225
|
+
async signTypedData(args: {
|
|
226
|
+
domain: SignTypedDataRequest['domain'];
|
|
227
|
+
types: SignTypedDataRequest['types'];
|
|
228
|
+
primaryType: string;
|
|
229
|
+
message: Readonly<Record<string, unknown>>;
|
|
230
|
+
}): Promise<Hex> {
|
|
231
|
+
await this.ensureBootstrapped();
|
|
232
|
+
return this.dispatchWallet('tangle.app.signTypedData', {
|
|
233
|
+
chainId: this.wallet.chainId ?? 0,
|
|
234
|
+
domain: args.domain,
|
|
235
|
+
types: args.types,
|
|
236
|
+
primaryType: args.primaryType,
|
|
237
|
+
message: args.message,
|
|
238
|
+
}).then((data) => (data as { signature: Hex }).signature);
|
|
239
|
+
}
|
|
240
|
+
|
|
191
241
|
// ── Job invocation ──────────────────────────────────────────────────────
|
|
192
242
|
|
|
193
243
|
/**
|
|
@@ -241,6 +291,13 @@ export class TangleIframeClient {
|
|
|
241
291
|
|
|
242
292
|
// ── Internals ───────────────────────────────────────────────────────────
|
|
243
293
|
|
|
294
|
+
private clearHandshakeRetry(): void {
|
|
295
|
+
if (this.handshakeRetry !== null) {
|
|
296
|
+
clearInterval(this.handshakeRetry);
|
|
297
|
+
this.handshakeRetry = null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
244
301
|
private postHandshake(): void {
|
|
245
302
|
this.postToParent({
|
|
246
303
|
kind: 'tangle.app.handshake',
|
|
@@ -266,6 +323,7 @@ export class TangleIframeClient {
|
|
|
266
323
|
switch (message.kind) {
|
|
267
324
|
case 'tangle.app.handshakeAck':
|
|
268
325
|
this.handshakeAcked = true;
|
|
326
|
+
this.clearHandshakeRetry();
|
|
269
327
|
for (const resolve of this.handshakeWaiters) resolve();
|
|
270
328
|
this.handshakeWaiters = [];
|
|
271
329
|
return;
|
|
@@ -312,6 +370,7 @@ export class TangleIframeClient {
|
|
|
312
370
|
kind:
|
|
313
371
|
| 'tangle.app.signMessage'
|
|
314
372
|
| 'tangle.app.signTransaction'
|
|
373
|
+
| 'tangle.app.signTypedData'
|
|
315
374
|
| 'tangle.app.switchChain',
|
|
316
375
|
payload: Record<string, unknown>,
|
|
317
376
|
): Promise<unknown> {
|
|
@@ -323,6 +382,7 @@ export class TangleIframeClient {
|
|
|
323
382
|
{
|
|
324
383
|
'tangle.app.signMessage': 'tangle.app.signMessageResult',
|
|
325
384
|
'tangle.app.signTransaction': 'tangle.app.signTransactionResult',
|
|
385
|
+
'tangle.app.signTypedData': 'tangle.app.signTypedDataResult',
|
|
326
386
|
'tangle.app.switchChain': 'tangle.app.switchChainResult',
|
|
327
387
|
} as const
|
|
328
388
|
)[kind];
|
|
@@ -407,6 +467,7 @@ export class TangleIframeClient {
|
|
|
407
467
|
operators: broadcast.operators,
|
|
408
468
|
jobs: broadcast.jobs,
|
|
409
469
|
mode: broadcast.mode,
|
|
470
|
+
chain: broadcast.chain ?? null,
|
|
410
471
|
};
|
|
411
472
|
this.service = next;
|
|
412
473
|
this.emit('service', next);
|
package/src/iframe/testing.tsx
CHANGED
|
@@ -39,6 +39,7 @@ export type MockServiceInput = Partial<{
|
|
|
39
39
|
operators: readonly ServiceContextOperator[];
|
|
40
40
|
jobs: readonly ServiceContextJob[];
|
|
41
41
|
mode: string | null;
|
|
42
|
+
chain: import('../wallet/parentBridgeProtocol').ChainContext | null;
|
|
42
43
|
}>;
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -80,6 +81,16 @@ export function mockServiceContext(
|
|
|
80
81
|
{ index: 0, name: 'invoke' },
|
|
81
82
|
],
|
|
82
83
|
mode: input.mode ?? null,
|
|
84
|
+
chain:
|
|
85
|
+
input.chain === undefined
|
|
86
|
+
? {
|
|
87
|
+
id: 84532,
|
|
88
|
+
name: 'Base Sepolia',
|
|
89
|
+
rpcUrl: 'https://sepolia.base.org',
|
|
90
|
+
blockExplorerUrl: 'https://sepolia.basescan.org',
|
|
91
|
+
nativeCurrency: { name: 'Sepolia Ether', symbol: 'ETH', decimals: 18 },
|
|
92
|
+
}
|
|
93
|
+
: input.chain,
|
|
83
94
|
};
|
|
84
95
|
}
|
|
85
96
|
|
|
@@ -148,10 +159,15 @@ export const TangleParentHarness: FC<HarnessProps> = ({
|
|
|
148
159
|
callJobHandler.current = onCallJob;
|
|
149
160
|
const seenHandshake = useRef(false);
|
|
150
161
|
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
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.
|
|
155
171
|
useEffect(() => {
|
|
156
172
|
const reply = (message: ParentMessage) => {
|
|
157
173
|
window.dispatchEvent(
|
|
@@ -170,6 +186,9 @@ export const TangleParentHarness: FC<HarnessProps> = ({
|
|
|
170
186
|
operators: currentService.operators,
|
|
171
187
|
jobs: currentService.jobs,
|
|
172
188
|
mode: currentService.mode,
|
|
189
|
+
...(currentService.chain !== null
|
|
190
|
+
? { chain: currentService.chain }
|
|
191
|
+
: {}),
|
|
173
192
|
};
|
|
174
193
|
reply(broadcastMsg);
|
|
175
194
|
// Also broadcast wallet — combined into accountChanged + chainChanged.
|
|
@@ -185,13 +204,7 @@ export const TangleParentHarness: FC<HarnessProps> = ({
|
|
|
185
204
|
}
|
|
186
205
|
};
|
|
187
206
|
|
|
188
|
-
const
|
|
189
|
-
// The iframe posts via `window.parent.postMessage(msg, parentOrigin)`.
|
|
190
|
-
// In same-window mode, that fires a message event on this same window
|
|
191
|
-
// with origin = parentOrigin. Filter out events the harness itself
|
|
192
|
-
// dispatched (origin === HARNESS_ORIGIN) — those are replies.
|
|
193
|
-
if (event.origin === HARNESS_ORIGIN) return;
|
|
194
|
-
const data = event.data;
|
|
207
|
+
const handleInbound = async (data: unknown) => {
|
|
195
208
|
if (typeof data !== 'object' || data === null) return;
|
|
196
209
|
const message = data as { kind?: string; correlationId?: string };
|
|
197
210
|
|
|
@@ -279,6 +292,22 @@ export const TangleParentHarness: FC<HarnessProps> = ({
|
|
|
279
292
|
});
|
|
280
293
|
return;
|
|
281
294
|
}
|
|
295
|
+
case 'tangle.app.signTypedData': {
|
|
296
|
+
if (typeof message.correlationId !== 'string') return;
|
|
297
|
+
// The harness signs deterministically — production parents show
|
|
298
|
+
// an approval modal first. Tests that need to assert the
|
|
299
|
+
// typed-data payload should inspect callLog (extend later if
|
|
300
|
+
// needed) or pass a custom onSignTypedData handler.
|
|
301
|
+
reply({
|
|
302
|
+
kind: 'tangle.app.signTypedDataResult',
|
|
303
|
+
correlationId: message.correlationId,
|
|
304
|
+
ok: true,
|
|
305
|
+
data: {
|
|
306
|
+
signature: ('0x' + '11'.repeat(65)) as `0x${string}`,
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
282
311
|
case 'tangle.app.signTransaction': {
|
|
283
312
|
if (typeof message.correlationId !== 'string') return;
|
|
284
313
|
reply({
|
|
@@ -308,8 +337,26 @@ export const TangleParentHarness: FC<HarnessProps> = ({
|
|
|
308
337
|
}
|
|
309
338
|
}
|
|
310
339
|
};
|
|
311
|
-
|
|
312
|
-
|
|
340
|
+
|
|
341
|
+
// Route the iframe's outbound posts straight into the handler. We expose
|
|
342
|
+
// only `postMessage` — the SDK never touches other `window.parent`
|
|
343
|
+
// members — and restore the original on teardown.
|
|
344
|
+
const originalParent = window.parent;
|
|
345
|
+
const proxyParent = {
|
|
346
|
+
postMessage: (message: unknown) => {
|
|
347
|
+
void handleInbound(message);
|
|
348
|
+
},
|
|
349
|
+
} as unknown as Window;
|
|
350
|
+
Object.defineProperty(window, 'parent', {
|
|
351
|
+
configurable: true,
|
|
352
|
+
get: () => proxyParent,
|
|
353
|
+
});
|
|
354
|
+
return () => {
|
|
355
|
+
Object.defineProperty(window, 'parent', {
|
|
356
|
+
configurable: true,
|
|
357
|
+
value: originalParent,
|
|
358
|
+
});
|
|
359
|
+
};
|
|
313
360
|
}, [appId, currentWallet, currentService]);
|
|
314
361
|
|
|
315
362
|
// Re-broadcast when state changes.
|
|
@@ -350,6 +397,9 @@ export const TangleParentHarness: FC<HarnessProps> = ({
|
|
|
350
397
|
operators: currentService.operators,
|
|
351
398
|
jobs: currentService.jobs,
|
|
352
399
|
mode: currentService.mode,
|
|
400
|
+
...(currentService.chain !== null
|
|
401
|
+
? { chain: currentService.chain }
|
|
402
|
+
: {}),
|
|
353
403
|
},
|
|
354
404
|
origin: HARNESS_ORIGIN,
|
|
355
405
|
}),
|
package/src/wallet/index.ts
CHANGED
|
@@ -54,6 +54,7 @@ export {
|
|
|
54
54
|
type AccountChanged,
|
|
55
55
|
type CallJobRequest,
|
|
56
56
|
type ChainChanged,
|
|
57
|
+
type ChainContext,
|
|
57
58
|
type HandshakeAck,
|
|
58
59
|
type HandshakeRequest,
|
|
59
60
|
type IframeRequest,
|
|
@@ -70,6 +71,8 @@ export {
|
|
|
70
71
|
type SignMessageResult,
|
|
71
72
|
type SignTransactionRequest,
|
|
72
73
|
type SignTransactionResult,
|
|
74
|
+
type SignTypedDataRequest,
|
|
75
|
+
type SignTypedDataResult,
|
|
73
76
|
type SwitchChainRequest,
|
|
74
77
|
type SwitchChainResult,
|
|
75
78
|
} from './parentBridgeProtocol';
|
|
@@ -42,6 +42,37 @@ export type SignTransactionRequest = {
|
|
|
42
42
|
value?: string;
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
// EIP-712 typed-data signing for publishers that need to sign custom message
|
|
46
|
+
// shapes — operator envelopes, off-chain attestations, claim proofs, etc.
|
|
47
|
+
// The parent renders the typed-data fields in its approval modal so the user
|
|
48
|
+
// can audit what they're signing. Iframes never see the wallet's signing key
|
|
49
|
+
// or private state.
|
|
50
|
+
//
|
|
51
|
+
// Shape mirrors viem's `signTypedData` argument: `domain` + `types` (without
|
|
52
|
+
// the EIP712Domain entry — viem injects it) + `primaryType` + `message`.
|
|
53
|
+
// Validation on the parent side rejects payloads that are obviously
|
|
54
|
+
// malformed (missing primaryType, types map empty, etc.) but does NOT
|
|
55
|
+
// re-shape the message — the user is the one who decides whether to sign.
|
|
56
|
+
export type SignTypedDataRequest = {
|
|
57
|
+
kind: 'tangle.app.signTypedData';
|
|
58
|
+
correlationId: string;
|
|
59
|
+
chainId: number;
|
|
60
|
+
domain: Readonly<{
|
|
61
|
+
name?: string;
|
|
62
|
+
version?: string;
|
|
63
|
+
chainId?: number;
|
|
64
|
+
verifyingContract?: Address;
|
|
65
|
+
salt?: Hex;
|
|
66
|
+
}>;
|
|
67
|
+
/** EIP-712 types map; do NOT include the EIP712Domain entry (the parent
|
|
68
|
+
* injects it derived from `domain`). */
|
|
69
|
+
types: Readonly<Record<string, ReadonlyArray<{ name: string; type: string }>>>;
|
|
70
|
+
/** Top-level type name in `types` whose values appear in `message`. */
|
|
71
|
+
primaryType: string;
|
|
72
|
+
/** The actual typed-data values. Shape matches `types[primaryType]`. */
|
|
73
|
+
message: Readonly<Record<string, unknown>>;
|
|
74
|
+
};
|
|
75
|
+
|
|
45
76
|
// ─── Parent → Iframe messages ────────────────────────────────────────────────
|
|
46
77
|
|
|
47
78
|
export type HandshakeAck = {
|
|
@@ -71,6 +102,10 @@ export type SignTransactionResult = {
|
|
|
71
102
|
kind: 'tangle.app.signTransactionResult';
|
|
72
103
|
} & ResultEnvelope<{ txHash: Hex }>;
|
|
73
104
|
|
|
105
|
+
export type SignTypedDataResult = {
|
|
106
|
+
kind: 'tangle.app.signTypedDataResult';
|
|
107
|
+
} & ResultEnvelope<{ signature: Hex }>;
|
|
108
|
+
|
|
74
109
|
export type AccountChanged = {
|
|
75
110
|
kind: 'tangle.app.accountChanged';
|
|
76
111
|
account: Address | null;
|
|
@@ -105,6 +140,30 @@ export type ServiceContextJob = {
|
|
|
105
140
|
readonly inputSchema?: unknown;
|
|
106
141
|
};
|
|
107
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Chain configuration the parent broadcasts to the iframe along with
|
|
145
|
+
* service context. Iframes use this to build a `viem` public client for
|
|
146
|
+
* READ-ONLY queries (`useTanglePublicClient` is the convenience hook).
|
|
147
|
+
*
|
|
148
|
+
* Iframes can ignore this and roll their own RPC config — particularly
|
|
149
|
+
* when they need to read from chains OTHER than the active one (e.g. a
|
|
150
|
+
* trading dapp pulling oracle data from mainnet while the active service
|
|
151
|
+
* lives on Base Sepolia). The injected client is a hint, not a constraint.
|
|
152
|
+
*
|
|
153
|
+
* `rpcUrl` is the public RPC the parent uses, NOT a wallet RPC. Iframes
|
|
154
|
+
* cannot sign or submit with this URL; signing always routes upstream via
|
|
155
|
+
* the bridge.
|
|
156
|
+
*/
|
|
157
|
+
export type ChainContext = {
|
|
158
|
+
readonly id: number;
|
|
159
|
+
readonly name: string;
|
|
160
|
+
readonly rpcUrl: string;
|
|
161
|
+
/** Block-explorer base URL — useful for rendering tx links. */
|
|
162
|
+
readonly blockExplorerUrl?: string;
|
|
163
|
+
/** Native currency metadata for cost displays. */
|
|
164
|
+
readonly nativeCurrency?: { readonly name: string; readonly symbol: string; readonly decimals: number };
|
|
165
|
+
};
|
|
166
|
+
|
|
108
167
|
export type ServiceContextBroadcast = {
|
|
109
168
|
kind: 'tangle.app.serviceContext';
|
|
110
169
|
readonly blueprintId: string;
|
|
@@ -112,6 +171,10 @@ export type ServiceContextBroadcast = {
|
|
|
112
171
|
readonly operators: readonly ServiceContextOperator[];
|
|
113
172
|
readonly jobs: readonly ServiceContextJob[];
|
|
114
173
|
readonly mode: string | null;
|
|
174
|
+
/** Active chain the parent is connected to; iframes can build a viem
|
|
175
|
+
* publicClient against this for convenience. Optional for backwards
|
|
176
|
+
* compatibility with parents that haven't been upgraded yet. */
|
|
177
|
+
readonly chain?: ChainContext;
|
|
115
178
|
};
|
|
116
179
|
|
|
117
180
|
// ─── Job invocation (iframe ↔ parent) ────────────────────────────────────────
|
|
@@ -161,6 +224,7 @@ export type ParentMessage =
|
|
|
161
224
|
| SwitchChainResult
|
|
162
225
|
| SignMessageResult
|
|
163
226
|
| SignTransactionResult
|
|
227
|
+
| SignTypedDataResult
|
|
164
228
|
| AccountChanged
|
|
165
229
|
| ChainChanged
|
|
166
230
|
| ServiceContextBroadcast
|
|
@@ -172,6 +236,7 @@ export type IframeRequest =
|
|
|
172
236
|
| SwitchChainRequest
|
|
173
237
|
| SignMessageRequest
|
|
174
238
|
| SignTransactionRequest
|
|
239
|
+
| SignTypedDataRequest
|
|
175
240
|
| CallJobRequest;
|
|
176
241
|
|
|
177
242
|
// The zero address used by the parent when no wallet is connected. The parent
|
|
@@ -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// ─── 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 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\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};\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 | AccountChanged\n | ChainChanged\n | ServiceContextBroadcast\n | JobResultEvent;\n\nexport type IframeRequest =\n | HandshakeRequest\n | ReadAccountRequest\n | SwitchChainRequest\n | SignMessageRequest\n | SignTransactionRequest\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;AA4KtC,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":[]}
|