@tangle-network/blueprint-ui 0.1.2 → 0.3.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/{registry-JhwB9BPD.d.ts → BlueprintHostPanel-L1KKLNbr.d.ts} +38 -1
- package/dist/{chunk-A6PJT5YQ.js → chunk-5PCH2RJF.js} +370 -10
- package/dist/chunk-5PCH2RJF.js.map +1 -0
- package/dist/components.d.ts +2 -2
- package/dist/components.js +18 -15
- package/dist/components.js.map +1 -1
- package/dist/index.d.ts +142 -8
- package/dist/index.js +19 -21
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -0
- package/dist/wallet/index.d.ts +188 -0
- package/dist/wallet/index.js +466 -0
- package/dist/wallet/index.js.map +1 -0
- package/package.json +21 -15
- package/src/components/forms/JobExecutionDialog.tsx +10 -2
- package/src/contracts/abi.ts +3 -0
- package/src/hooks/useJobPrice.test.ts +214 -0
- package/src/hooks/useJobPrice.ts +56 -2
- package/src/hooks/useQuotes.ts +44 -1
- package/src/test-setup.ts +1 -0
- package/src/wallet/detectParentOrigin.ts +74 -0
- package/src/wallet/index.ts +67 -0
- package/src/wallet/parentBridgeConnector.ts +156 -0
- package/src/wallet/parentBridgeProtocol.ts +109 -0
- package/src/wallet/parentBridgeProvider.test.ts +209 -0
- package/src/wallet/parentBridgeProvider.ts +411 -0
- package/tsconfig.json +1 -1
- package/dist/BlueprintHostPanel-6iVEh-f1.d.ts +0 -39
- package/dist/chunk-A6PJT5YQ.js.map +0 -1
- package/dist/chunk-GD3AZEJL.js +0 -327
- package/dist/chunk-GD3AZEJL.js.map +0 -1
- package/dist/host.d.ts +0 -96
- package/dist/host.js +0 -39
- package/dist/host.js.map +0 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ParentBridgeProvider } from './parentBridgeProvider';
|
|
3
|
+
import {
|
|
4
|
+
NO_WALLET_ADDRESS,
|
|
5
|
+
TANGLE_IFRAME_PROTOCOL_VERSION,
|
|
6
|
+
} from './parentBridgeProtocol';
|
|
7
|
+
|
|
8
|
+
const PARENT_ORIGIN = 'https://cloud.tangle.tools';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Drive the provider against a fake parent: capture every message the
|
|
12
|
+
* provider posts, route a scripted response (or broadcast) back, and assert
|
|
13
|
+
* the provider's observable behavior.
|
|
14
|
+
*
|
|
15
|
+
* The fake parent runs in the same JS context as the provider (jsdom's
|
|
16
|
+
* `window.parent === window`), so we monkey-patch `window.parent.postMessage`
|
|
17
|
+
* to intercept; for inbound messages we synthesize `MessageEvent`s with the
|
|
18
|
+
* trusted origin and dispatch them onto `window`.
|
|
19
|
+
*/
|
|
20
|
+
type Captured = { message: unknown; origin: string };
|
|
21
|
+
|
|
22
|
+
function setupFakeParent() {
|
|
23
|
+
const captured: Captured[] = [];
|
|
24
|
+
const originalParent = window.parent;
|
|
25
|
+
Object.defineProperty(window, 'parent', {
|
|
26
|
+
configurable: true,
|
|
27
|
+
get() {
|
|
28
|
+
return {
|
|
29
|
+
postMessage: (message: unknown, targetOrigin: string) => {
|
|
30
|
+
captured.push({ message, origin: targetOrigin });
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const restore = () => {
|
|
36
|
+
Object.defineProperty(window, 'parent', {
|
|
37
|
+
configurable: true,
|
|
38
|
+
value: originalParent,
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
const inbound = (data: object) =>
|
|
42
|
+
window.dispatchEvent(new MessageEvent('message', { data, origin: PARENT_ORIGIN }));
|
|
43
|
+
return { captured, inbound, restore };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('ParentBridgeProvider', () => {
|
|
47
|
+
let fake: ReturnType<typeof setupFakeParent>;
|
|
48
|
+
let provider: ParentBridgeProvider;
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
fake = setupFakeParent();
|
|
52
|
+
provider = new ParentBridgeProvider({
|
|
53
|
+
parentOrigin: PARENT_ORIGIN,
|
|
54
|
+
appId: 'agent-sandbox',
|
|
55
|
+
requestTimeoutMs: 1_000,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
provider.uninstall();
|
|
61
|
+
fake.restore();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('sends a handshake on install + pins targetOrigin to the parent', () => {
|
|
65
|
+
provider.install();
|
|
66
|
+
expect(fake.captured).toHaveLength(1);
|
|
67
|
+
expect(fake.captured[0].origin).toBe(PARENT_ORIGIN);
|
|
68
|
+
expect(fake.captured[0].message).toEqual({
|
|
69
|
+
kind: 'tangle.app.handshake',
|
|
70
|
+
appId: 'agent-sandbox',
|
|
71
|
+
version: TANGLE_IFRAME_PROTOCOL_VERSION,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects messages from origins that are not the configured parent', () => {
|
|
76
|
+
provider.install();
|
|
77
|
+
// Manually dispatch a message from a different origin claiming to be a
|
|
78
|
+
// handshake ack. The provider must ignore it — verified by inspecting
|
|
79
|
+
// `getCachedAccount` (which gets set on a successful readAccountResult).
|
|
80
|
+
window.dispatchEvent(
|
|
81
|
+
new MessageEvent('message', {
|
|
82
|
+
data: {
|
|
83
|
+
kind: 'tangle.app.accountChanged',
|
|
84
|
+
account: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
|
|
85
|
+
},
|
|
86
|
+
origin: 'https://evil.example.com',
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
expect(provider.getCachedAccount()).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('resolves eth_accounts to [] when parent reports zero-address', async () => {
|
|
93
|
+
provider.install();
|
|
94
|
+
fake.inbound({
|
|
95
|
+
kind: 'tangle.app.handshakeAck',
|
|
96
|
+
appId: 'agent-sandbox',
|
|
97
|
+
protocolVersion: TANGLE_IFRAME_PROTOCOL_VERSION,
|
|
98
|
+
});
|
|
99
|
+
// Provider auto-fires a readAccount after handshakeAck. Wait for it,
|
|
100
|
+
// then reply with the zero-address sentinel.
|
|
101
|
+
const readAccountMsg = await vi.waitFor(() => {
|
|
102
|
+
const m = fake.captured.find(
|
|
103
|
+
(c) =>
|
|
104
|
+
typeof c.message === 'object' &&
|
|
105
|
+
c.message !== null &&
|
|
106
|
+
(c.message as { kind?: string }).kind === 'tangle.app.readAccount',
|
|
107
|
+
);
|
|
108
|
+
if (!m) throw new Error('readAccount not posted yet');
|
|
109
|
+
return m;
|
|
110
|
+
});
|
|
111
|
+
const correlationId = (readAccountMsg.message as { correlationId: string })
|
|
112
|
+
.correlationId;
|
|
113
|
+
fake.inbound({
|
|
114
|
+
kind: 'tangle.app.readAccountResult',
|
|
115
|
+
correlationId,
|
|
116
|
+
ok: true,
|
|
117
|
+
data: { account: NO_WALLET_ADDRESS, chainId: 84532 },
|
|
118
|
+
});
|
|
119
|
+
const accounts = await provider.request({ method: 'eth_accounts' });
|
|
120
|
+
expect(accounts).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('emits accountsChanged when the parent broadcasts a new account', () => {
|
|
124
|
+
provider.install();
|
|
125
|
+
fake.inbound({
|
|
126
|
+
kind: 'tangle.app.handshakeAck',
|
|
127
|
+
appId: 'agent-sandbox',
|
|
128
|
+
protocolVersion: TANGLE_IFRAME_PROTOCOL_VERSION,
|
|
129
|
+
});
|
|
130
|
+
const seen: unknown[] = [];
|
|
131
|
+
provider.on('accountsChanged', (accounts) => seen.push(accounts));
|
|
132
|
+
fake.inbound({
|
|
133
|
+
kind: 'tangle.app.accountChanged',
|
|
134
|
+
account: '0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc',
|
|
135
|
+
});
|
|
136
|
+
expect(seen).toEqual([['0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc']]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('forwards personal_sign through the bridge and resolves on signMessageResult', async () => {
|
|
140
|
+
provider.install();
|
|
141
|
+
fake.inbound({
|
|
142
|
+
kind: 'tangle.app.handshakeAck',
|
|
143
|
+
appId: 'agent-sandbox',
|
|
144
|
+
protocolVersion: TANGLE_IFRAME_PROTOCOL_VERSION,
|
|
145
|
+
});
|
|
146
|
+
// Establish chain context — the bridge needs `cachedChainId` to sign.
|
|
147
|
+
fake.inbound({ kind: 'tangle.app.chainChanged', chainId: 84532 });
|
|
148
|
+
|
|
149
|
+
const signed = provider.request({
|
|
150
|
+
method: 'personal_sign',
|
|
151
|
+
params: ['hello', '0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc'],
|
|
152
|
+
});
|
|
153
|
+
// Find the outbound signMessage request.
|
|
154
|
+
const outbound = await vi.waitFor(() => {
|
|
155
|
+
const msg = fake.captured.find(
|
|
156
|
+
(c) =>
|
|
157
|
+
typeof c.message === 'object' &&
|
|
158
|
+
c.message !== null &&
|
|
159
|
+
(c.message as { kind?: string }).kind === 'tangle.app.signMessage',
|
|
160
|
+
);
|
|
161
|
+
if (!msg) throw new Error('signMessage not posted yet');
|
|
162
|
+
return msg;
|
|
163
|
+
});
|
|
164
|
+
const correlationId = (outbound.message as { correlationId: string })
|
|
165
|
+
.correlationId;
|
|
166
|
+
expect((outbound.message as { message: string }).message).toBe('hello');
|
|
167
|
+
fake.inbound({
|
|
168
|
+
kind: 'tangle.app.signMessageResult',
|
|
169
|
+
correlationId,
|
|
170
|
+
ok: true,
|
|
171
|
+
data: { signature: '0xdeadbeef' },
|
|
172
|
+
});
|
|
173
|
+
await expect(signed).resolves.toBe('0xdeadbeef');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('rejects on parent error response with the parent-provided error message', async () => {
|
|
177
|
+
provider.install();
|
|
178
|
+
fake.inbound({
|
|
179
|
+
kind: 'tangle.app.handshakeAck',
|
|
180
|
+
appId: 'agent-sandbox',
|
|
181
|
+
protocolVersion: TANGLE_IFRAME_PROTOCOL_VERSION,
|
|
182
|
+
});
|
|
183
|
+
fake.inbound({ kind: 'tangle.app.chainChanged', chainId: 84532 });
|
|
184
|
+
|
|
185
|
+
const signed = provider.request({
|
|
186
|
+
method: 'personal_sign',
|
|
187
|
+
params: ['hello', '0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc'],
|
|
188
|
+
});
|
|
189
|
+
const outbound = await vi.waitFor(() => {
|
|
190
|
+
const msg = fake.captured.find(
|
|
191
|
+
(c) =>
|
|
192
|
+
typeof c.message === 'object' &&
|
|
193
|
+
c.message !== null &&
|
|
194
|
+
(c.message as { kind?: string }).kind === 'tangle.app.signMessage',
|
|
195
|
+
);
|
|
196
|
+
if (!msg) throw new Error('not posted yet');
|
|
197
|
+
return msg;
|
|
198
|
+
});
|
|
199
|
+
const correlationId = (outbound.message as { correlationId: string })
|
|
200
|
+
.correlationId;
|
|
201
|
+
fake.inbound({
|
|
202
|
+
kind: 'tangle.app.signMessageResult',
|
|
203
|
+
correlationId,
|
|
204
|
+
ok: false,
|
|
205
|
+
error: 'user-rejected',
|
|
206
|
+
});
|
|
207
|
+
await expect(signed).rejects.toThrow('user-rejected');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// EIP-1193 provider implementation that proxies wallet calls to the parent
|
|
2
|
+
// dapp via window.postMessage. The iframe doesn't talk to a wallet directly
|
|
3
|
+
// — it inherits the parent's connected account + chain, and forwards signing
|
|
4
|
+
// requests through the existing tangle.app.* protocol.
|
|
5
|
+
//
|
|
6
|
+
// This is the lowest layer of the parent-bridge stack. Wagmi sees this as a
|
|
7
|
+
// regular Ethereum provider and routes `eth_accounts`, `eth_chainId`,
|
|
8
|
+
// `personal_sign`, `eth_sendTransaction`, `wallet_switchEthereumChain`, etc.
|
|
9
|
+
// through it.
|
|
10
|
+
|
|
11
|
+
import type { Address, Hex } from 'viem';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
makeCorrelationId,
|
|
15
|
+
NO_WALLET_ADDRESS,
|
|
16
|
+
TANGLE_IFRAME_PROTOCOL_VERSION,
|
|
17
|
+
type ParentMessage,
|
|
18
|
+
} from './parentBridgeProtocol';
|
|
19
|
+
|
|
20
|
+
type EventName = 'accountsChanged' | 'chainChanged' | 'connect' | 'disconnect' | 'message';
|
|
21
|
+
type Listener = (...args: unknown[]) => void;
|
|
22
|
+
|
|
23
|
+
type PendingRequest<T> = {
|
|
24
|
+
resolve: (value: T) => void;
|
|
25
|
+
reject: (reason: Error) => void;
|
|
26
|
+
expectedKind: ParentMessage['kind'];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ParentBridgeOptions = {
|
|
30
|
+
/**
|
|
31
|
+
* Origin of the parent dapp that hosts this iframe. The provider posts to
|
|
32
|
+
* `window.parent` with this exact origin and rejects inbound messages from
|
|
33
|
+
* any other origin. Pass `'*'` only in development; production must pin to
|
|
34
|
+
* the real parent (`https://cloud.tangle.tools` or its develop equivalent).
|
|
35
|
+
*/
|
|
36
|
+
parentOrigin: string;
|
|
37
|
+
/**
|
|
38
|
+
* Stable identifier for this iframe app. The parent includes this in the
|
|
39
|
+
* handshake ack so dev tooling can correlate logs across the two windows.
|
|
40
|
+
*/
|
|
41
|
+
appId: string;
|
|
42
|
+
/**
|
|
43
|
+
* Optional ms timeout for each bridged request. Defaults to 60 seconds —
|
|
44
|
+
* long enough for a user to read + approve a signing prompt in the parent.
|
|
45
|
+
*/
|
|
46
|
+
requestTimeoutMs?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Detect iframe execution context. When this returns `false` the bridge
|
|
53
|
+
* connector should not be installed and the host app should fall back to its
|
|
54
|
+
* normal wallet config (ConnectKit + injected/walletConnect).
|
|
55
|
+
*
|
|
56
|
+
* `window.parent !== window` is the most reliable signal that works across
|
|
57
|
+
* sandbox-iframe contexts where direct property access to parent throws.
|
|
58
|
+
*/
|
|
59
|
+
export function isRunningInIframe(): boolean {
|
|
60
|
+
if (typeof window === 'undefined') return false;
|
|
61
|
+
try {
|
|
62
|
+
return window.parent !== undefined && window.parent !== window;
|
|
63
|
+
} catch {
|
|
64
|
+
// Cross-origin read of `window.parent` shouldn't throw, but be defensive.
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* EIP-1193 provider backed by the Tangle Cloud iframe protocol. One instance
|
|
71
|
+
* lives per iframe app; the wagmi connector owns the singleton.
|
|
72
|
+
*/
|
|
73
|
+
export class ParentBridgeProvider {
|
|
74
|
+
private listeners = new Map<EventName, Set<Listener>>();
|
|
75
|
+
private pending = new Map<string, PendingRequest<unknown>>();
|
|
76
|
+
private cachedAccount: Address | null = null;
|
|
77
|
+
private cachedChainId: number | null = null;
|
|
78
|
+
private handshakeAcked = false;
|
|
79
|
+
private handshakeWaiters: Array<() => void> = [];
|
|
80
|
+
private installed = false;
|
|
81
|
+
|
|
82
|
+
constructor(private readonly options: ParentBridgeOptions) {}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Wire up the global message listener and send the initial handshake.
|
|
86
|
+
* Idempotent — safe to call repeatedly during reconnect attempts.
|
|
87
|
+
*/
|
|
88
|
+
install(): void {
|
|
89
|
+
if (this.installed || typeof window === 'undefined') return;
|
|
90
|
+
this.installed = true;
|
|
91
|
+
window.addEventListener('message', this.handleParentMessage);
|
|
92
|
+
this.postToParent({
|
|
93
|
+
kind: 'tangle.app.handshake',
|
|
94
|
+
appId: this.options.appId,
|
|
95
|
+
version: TANGLE_IFRAME_PROTOCOL_VERSION,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
uninstall(): void {
|
|
100
|
+
if (!this.installed || typeof window === 'undefined') return;
|
|
101
|
+
this.installed = false;
|
|
102
|
+
window.removeEventListener('message', this.handleParentMessage);
|
|
103
|
+
// Reject every pending request so callers don't hang forever.
|
|
104
|
+
for (const [, pending] of this.pending) {
|
|
105
|
+
pending.reject(new Error('Parent bridge uninstalled'));
|
|
106
|
+
}
|
|
107
|
+
this.pending.clear();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── EIP-1193 surface ────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
async request(req: { method: string; params?: unknown[] }): Promise<unknown> {
|
|
113
|
+
const method = req.method;
|
|
114
|
+
const params = (req.params ?? []) as unknown[];
|
|
115
|
+
switch (method) {
|
|
116
|
+
case 'eth_chainId': {
|
|
117
|
+
await this.ensureBootstrapped();
|
|
118
|
+
return this.cachedChainId !== null ? `0x${this.cachedChainId.toString(16)}` : '0x0';
|
|
119
|
+
}
|
|
120
|
+
case 'eth_accounts':
|
|
121
|
+
case 'eth_requestAccounts': {
|
|
122
|
+
await this.ensureBootstrapped();
|
|
123
|
+
return this.cachedAccount !== null ? [this.cachedAccount] : [];
|
|
124
|
+
}
|
|
125
|
+
case 'personal_sign': {
|
|
126
|
+
const [message, _signer] = params as [string, Address];
|
|
127
|
+
return this.requestSignMessage(message);
|
|
128
|
+
}
|
|
129
|
+
case 'eth_signTypedData_v4': {
|
|
130
|
+
// The current protocol doesn't carry typed-data — surface a clear
|
|
131
|
+
// error rather than silently producing a personal_sign. Publishers
|
|
132
|
+
// that need typed-data signing should upgrade the protocol.
|
|
133
|
+
throw bridgeError(
|
|
134
|
+
4200,
|
|
135
|
+
'eth_signTypedData_v4 is not supported by the parent-bridge protocol yet.',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
case 'eth_sendTransaction': {
|
|
139
|
+
const [tx] = params as [
|
|
140
|
+
{ to?: Address; data?: Hex; value?: Hex | string; chainId?: Hex | number },
|
|
141
|
+
];
|
|
142
|
+
if (!tx?.to || !tx.data) {
|
|
143
|
+
throw bridgeError(-32602, 'eth_sendTransaction requires `to` and `data`.');
|
|
144
|
+
}
|
|
145
|
+
return this.requestSignTransaction(tx);
|
|
146
|
+
}
|
|
147
|
+
case 'wallet_switchEthereumChain': {
|
|
148
|
+
const [{ chainId }] = params as [{ chainId: Hex }];
|
|
149
|
+
const numeric = Number.parseInt(chainId, 16);
|
|
150
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
151
|
+
throw bridgeError(-32602, `Invalid chainId: ${chainId}`);
|
|
152
|
+
}
|
|
153
|
+
await this.requestSwitchChain(numeric);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
case 'wallet_addEthereumChain': {
|
|
157
|
+
// The parent owns the chain registry; iframes can't add chains the
|
|
158
|
+
// dapp doesn't already know about.
|
|
159
|
+
throw bridgeError(
|
|
160
|
+
4200,
|
|
161
|
+
'wallet_addEthereumChain is not supported through the parent bridge.',
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
default:
|
|
165
|
+
throw bridgeError(4200, `Method ${method} not supported by parent bridge.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
on(event: EventName, listener: Listener): void {
|
|
170
|
+
const set = this.listeners.get(event) ?? new Set();
|
|
171
|
+
set.add(listener);
|
|
172
|
+
this.listeners.set(event, set);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
removeListener(event: EventName, listener: Listener): void {
|
|
176
|
+
this.listeners.get(event)?.delete(listener);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Internal: dispatch + book-keeping ───────────────────────────────────
|
|
180
|
+
|
|
181
|
+
private postToParent(message: object): void {
|
|
182
|
+
if (typeof window === 'undefined') return;
|
|
183
|
+
try {
|
|
184
|
+
window.parent.postMessage(message, this.options.parentOrigin);
|
|
185
|
+
} catch {
|
|
186
|
+
// Cross-origin / sandboxed; parent.postMessage shouldn't actually throw
|
|
187
|
+
// but be defensive against future browser changes.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private handleParentMessage = (event: MessageEvent): void => {
|
|
192
|
+
// Origin gate first; never parse untrusted payloads.
|
|
193
|
+
if (event.origin !== this.options.parentOrigin) return;
|
|
194
|
+
const data = event.data;
|
|
195
|
+
if (typeof data !== 'object' || data === null) return;
|
|
196
|
+
const message = data as ParentMessage;
|
|
197
|
+
switch (message.kind) {
|
|
198
|
+
case 'tangle.app.handshakeAck':
|
|
199
|
+
this.handshakeAcked = true;
|
|
200
|
+
for (const resolve of this.handshakeWaiters) resolve();
|
|
201
|
+
this.handshakeWaiters = [];
|
|
202
|
+
// After ack, ask for the current account so cached state reflects
|
|
203
|
+
// reality before any consumer queries. Fire-and-forget — explicit
|
|
204
|
+
// calls (`eth_accounts`, etc.) await their own request.
|
|
205
|
+
this.sendReadAccount().catch(() => {
|
|
206
|
+
// The first read commonly races with bridge teardown in tests
|
|
207
|
+
// and isn't user-facing; swallow rather than producing unhandled
|
|
208
|
+
// rejections. Subsequent `eth_accounts` calls retry on demand.
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
case 'tangle.app.readAccountResult':
|
|
212
|
+
this.resolvePending(message);
|
|
213
|
+
if (message.ok) {
|
|
214
|
+
this.updateAccount(
|
|
215
|
+
message.data.account === NO_WALLET_ADDRESS
|
|
216
|
+
? null
|
|
217
|
+
: message.data.account,
|
|
218
|
+
);
|
|
219
|
+
this.updateChainId(message.data.chainId);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
case 'tangle.app.switchChainResult':
|
|
223
|
+
this.resolvePending(message);
|
|
224
|
+
if (message.ok) this.updateChainId(message.data.chainId);
|
|
225
|
+
return;
|
|
226
|
+
case 'tangle.app.signMessageResult':
|
|
227
|
+
case 'tangle.app.signTransactionResult':
|
|
228
|
+
this.resolvePending(message);
|
|
229
|
+
return;
|
|
230
|
+
case 'tangle.app.accountChanged':
|
|
231
|
+
this.updateAccount(message.account);
|
|
232
|
+
return;
|
|
233
|
+
case 'tangle.app.chainChanged':
|
|
234
|
+
this.updateChainId(message.chainId);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
private sendReadAccount(): Promise<{ account: Address; chainId: number }> {
|
|
240
|
+
return this.dispatch({
|
|
241
|
+
kind: 'tangle.app.readAccount',
|
|
242
|
+
expectedKind: 'tangle.app.readAccountResult',
|
|
243
|
+
}) as Promise<{ account: Address; chainId: number }>;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private requestSignMessage(message: string): Promise<Hex> {
|
|
247
|
+
const chainId = this.cachedChainId ?? 1;
|
|
248
|
+
return this.dispatch({
|
|
249
|
+
kind: 'tangle.app.signMessage',
|
|
250
|
+
expectedKind: 'tangle.app.signMessageResult',
|
|
251
|
+
payload: { chainId, message },
|
|
252
|
+
}).then((data) => (data as { signature: Hex }).signature);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private requestSignTransaction(tx: {
|
|
256
|
+
to?: Address;
|
|
257
|
+
data?: Hex;
|
|
258
|
+
value?: Hex | string;
|
|
259
|
+
}): Promise<Hex> {
|
|
260
|
+
const chainId = this.cachedChainId ?? 1;
|
|
261
|
+
const value =
|
|
262
|
+
typeof tx.value === 'string' && tx.value.startsWith('0x')
|
|
263
|
+
? BigInt(tx.value).toString(10)
|
|
264
|
+
: typeof tx.value === 'string'
|
|
265
|
+
? tx.value
|
|
266
|
+
: undefined;
|
|
267
|
+
return this.dispatch({
|
|
268
|
+
kind: 'tangle.app.signTransaction',
|
|
269
|
+
expectedKind: 'tangle.app.signTransactionResult',
|
|
270
|
+
payload: {
|
|
271
|
+
chainId,
|
|
272
|
+
to: tx.to as Address,
|
|
273
|
+
data: tx.data as Hex,
|
|
274
|
+
...(value !== undefined ? { value } : {}),
|
|
275
|
+
},
|
|
276
|
+
}).then((data) => (data as { txHash: Hex }).txHash);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private requestSwitchChain(chainId: number): Promise<number> {
|
|
280
|
+
return this.dispatch({
|
|
281
|
+
kind: 'tangle.app.switchChain',
|
|
282
|
+
expectedKind: 'tangle.app.switchChainResult',
|
|
283
|
+
payload: { chainId },
|
|
284
|
+
}).then((data) => (data as { chainId: number }).chainId);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async dispatch(req: {
|
|
288
|
+
kind: 'tangle.app.readAccount' | 'tangle.app.switchChain' | 'tangle.app.signMessage' | 'tangle.app.signTransaction';
|
|
289
|
+
expectedKind: ParentMessage['kind'];
|
|
290
|
+
payload?: Record<string, unknown>;
|
|
291
|
+
}): Promise<unknown> {
|
|
292
|
+
await this.ensureBootstrapped();
|
|
293
|
+
const correlationId = makeCorrelationId(req.kind);
|
|
294
|
+
const timeout = this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
295
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
296
|
+
const timer = window.setTimeout(() => {
|
|
297
|
+
this.pending.delete(correlationId);
|
|
298
|
+
reject(bridgeError(4900, `Parent did not respond to ${req.kind} within ${timeout}ms`));
|
|
299
|
+
}, timeout);
|
|
300
|
+
this.pending.set(correlationId, {
|
|
301
|
+
resolve: (v) => {
|
|
302
|
+
window.clearTimeout(timer);
|
|
303
|
+
resolve(v);
|
|
304
|
+
},
|
|
305
|
+
reject: (e) => {
|
|
306
|
+
window.clearTimeout(timer);
|
|
307
|
+
reject(e);
|
|
308
|
+
},
|
|
309
|
+
expectedKind: req.expectedKind,
|
|
310
|
+
});
|
|
311
|
+
this.postToParent({
|
|
312
|
+
kind: req.kind,
|
|
313
|
+
correlationId,
|
|
314
|
+
...(req.payload ?? {}),
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private resolvePending(message: Extract<ParentMessage, { correlationId: string }>): void {
|
|
320
|
+
const entry = this.pending.get(message.correlationId);
|
|
321
|
+
if (!entry) return;
|
|
322
|
+
this.pending.delete(message.correlationId);
|
|
323
|
+
if (entry.expectedKind !== message.kind) {
|
|
324
|
+
entry.reject(
|
|
325
|
+
bridgeError(
|
|
326
|
+
-32000,
|
|
327
|
+
`Parent replied with ${message.kind} but ${entry.expectedKind} was expected`,
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (message.ok) {
|
|
333
|
+
entry.resolve(message.data);
|
|
334
|
+
} else {
|
|
335
|
+
entry.reject(bridgeError(4001, message.error));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private async ensureBootstrapped(): Promise<void> {
|
|
340
|
+
if (this.handshakeAcked) return;
|
|
341
|
+
this.install();
|
|
342
|
+
await new Promise<void>((resolve) => {
|
|
343
|
+
this.handshakeWaiters.push(resolve);
|
|
344
|
+
// Re-send handshake every 500ms while we wait — covers a parent that
|
|
345
|
+
// mounted after the iframe and missed the initial post.
|
|
346
|
+
const retry = window.setInterval(() => {
|
|
347
|
+
if (this.handshakeAcked) {
|
|
348
|
+
window.clearInterval(retry);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.postToParent({
|
|
352
|
+
kind: 'tangle.app.handshake',
|
|
353
|
+
appId: this.options.appId,
|
|
354
|
+
version: TANGLE_IFRAME_PROTOCOL_VERSION,
|
|
355
|
+
});
|
|
356
|
+
}, 500);
|
|
357
|
+
// Safety stop — handshake won't be re-attempted indefinitely.
|
|
358
|
+
window.setTimeout(() => window.clearInterval(retry), 10_000);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private updateAccount(next: Address | null): void {
|
|
363
|
+
if (this.cachedAccount === next) return;
|
|
364
|
+
const prev = this.cachedAccount;
|
|
365
|
+
this.cachedAccount = next;
|
|
366
|
+
if (next === null && prev !== null) {
|
|
367
|
+
this.emit('disconnect');
|
|
368
|
+
this.emit('accountsChanged', []);
|
|
369
|
+
} else if (next !== null) {
|
|
370
|
+
this.emit('accountsChanged', [next]);
|
|
371
|
+
if (prev === null) {
|
|
372
|
+
this.emit('connect', { chainId: this.cachedChainId ?? 0 });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private updateChainId(next: number): void {
|
|
378
|
+
if (this.cachedChainId === next) return;
|
|
379
|
+
this.cachedChainId = next;
|
|
380
|
+
this.emit('chainChanged', `0x${next.toString(16)}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private emit(event: EventName, ...args: unknown[]): void {
|
|
384
|
+
const set = this.listeners.get(event);
|
|
385
|
+
if (!set) return;
|
|
386
|
+
for (const listener of [...set]) {
|
|
387
|
+
try {
|
|
388
|
+
listener(...args);
|
|
389
|
+
} catch {
|
|
390
|
+
// Listener bugs shouldn't break the bridge.
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Test seams ──────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
/** Visible for tests + the connector's `getAccounts()` shortcut. */
|
|
398
|
+
getCachedAccount(): Address | null {
|
|
399
|
+
return this.cachedAccount;
|
|
400
|
+
}
|
|
401
|
+
/** Visible for tests + the connector's `getChainId()` shortcut. */
|
|
402
|
+
getCachedChainId(): number | null {
|
|
403
|
+
return this.cachedChainId;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function bridgeError(code: number, message: string): Error {
|
|
408
|
+
const err = new Error(message) as Error & { code?: number };
|
|
409
|
+
err.code = code;
|
|
410
|
+
return err;
|
|
411
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
-
import * as React from 'react';
|
|
3
|
-
import * as class_variance_authority_types from 'class-variance-authority/types';
|
|
4
|
-
import { VariantProps } from 'class-variance-authority';
|
|
5
|
-
|
|
6
|
-
declare const buttonVariants: (props?: ({
|
|
7
|
-
variant?: "default" | "link" | "success" | "secondary" | "destructive" | "outline" | "ghost" | null | undefined;
|
|
8
|
-
size?: "default" | "sm" | "lg" | "icon" | "icon-sm" | null | undefined;
|
|
9
|
-
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
10
|
-
declare function Button({ className, variant, size, asChild, ...props }: React.ComponentProps<'button'> & VariantProps<typeof buttonVariants> & {
|
|
11
|
-
asChild?: boolean;
|
|
12
|
-
}): react_jsx_runtime.JSX.Element;
|
|
13
|
-
|
|
14
|
-
type Action = {
|
|
15
|
-
label: string;
|
|
16
|
-
href?: string;
|
|
17
|
-
onClick?: () => void;
|
|
18
|
-
variant?: React.ComponentProps<typeof Button>['variant'];
|
|
19
|
-
disabled?: boolean;
|
|
20
|
-
};
|
|
21
|
-
type BlueprintHostHeroProps = {
|
|
22
|
-
title: string;
|
|
23
|
-
tagline?: string;
|
|
24
|
-
description?: string;
|
|
25
|
-
badges?: string[];
|
|
26
|
-
actions?: Action[];
|
|
27
|
-
children?: React.ReactNode;
|
|
28
|
-
className?: string;
|
|
29
|
-
};
|
|
30
|
-
declare function BlueprintHostHero({ title, tagline, description, badges, actions, children, className, }: BlueprintHostHeroProps): react_jsx_runtime.JSX.Element;
|
|
31
|
-
|
|
32
|
-
type BlueprintHostPanelProps = {
|
|
33
|
-
title: string;
|
|
34
|
-
children: React.ReactNode;
|
|
35
|
-
className?: string;
|
|
36
|
-
};
|
|
37
|
-
declare function BlueprintHostPanel({ title, children, className, }: BlueprintHostPanelProps): react_jsx_runtime.JSX.Element;
|
|
38
|
-
|
|
39
|
-
export { BlueprintHostHero as B, type BlueprintHostHeroProps as a, BlueprintHostPanel as b, type BlueprintHostPanelProps as c, Button as d, buttonVariants as e };
|