@tangle-network/blueprint-ui 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +16 -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,74 @@
|
|
|
1
|
+
// Determine which origin to trust as the parent dapp.
|
|
2
|
+
//
|
|
3
|
+
// `document.referrer` is the *initial* embedder — it's set when the iframe is
|
|
4
|
+
// first loaded and survives reloads (though it can be cleared by `referrerpolicy`
|
|
5
|
+
// or by the embedder). The Tangle Cloud iframe wrapper deliberately omits
|
|
6
|
+
// `referrerpolicy="no-referrer"` so we get the embedder's origin here.
|
|
7
|
+
//
|
|
8
|
+
// We compare it against an allowlist of known Tangle Cloud origins. If it
|
|
9
|
+
// matches, that's the parent. Otherwise the iframe is being loaded directly
|
|
10
|
+
// (standalone domain visit, dev server, untrusted embedder) and the bridge
|
|
11
|
+
// stays disabled — the app falls back to its normal injected/WC wallet path.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default Tangle Cloud origins. Consumers (agent-sandbox UI,
|
|
15
|
+
* trading-arena, future iframe blueprints) pass app-specific additions
|
|
16
|
+
* via `extraOrigins` rather than mutating this list.
|
|
17
|
+
*/
|
|
18
|
+
export const TANGLE_CLOUD_ORIGINS_DEFAULT = Object.freeze([
|
|
19
|
+
'https://cloud.tangle.tools',
|
|
20
|
+
'https://develop.cloud.tangle.tools',
|
|
21
|
+
// Local dev (Vite default port for tangle-cloud + Netlify dev preview).
|
|
22
|
+
'http://localhost:4300',
|
|
23
|
+
'http://localhost:8888',
|
|
24
|
+
] as const);
|
|
25
|
+
|
|
26
|
+
function originFromReferrer(): string | null {
|
|
27
|
+
if (typeof document === 'undefined') return null;
|
|
28
|
+
const ref = document.referrer;
|
|
29
|
+
if (!ref) return null;
|
|
30
|
+
try {
|
|
31
|
+
return new URL(ref).origin;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the parent origin to bridge to, or null when no trusted parent is
|
|
39
|
+
* detected. Caller should skip installing the bridge connector when this
|
|
40
|
+
* returns null.
|
|
41
|
+
*
|
|
42
|
+
* `extraOrigins` is the application's escape hatch for staging or dev
|
|
43
|
+
* deploys not covered by the default list. The library deliberately does
|
|
44
|
+
* not read environment variables itself (consumers may bundle for non-Vite
|
|
45
|
+
* runtimes); the consuming app threads `import.meta.env.VITE_*` or
|
|
46
|
+
* `process.env.*` in itself.
|
|
47
|
+
*
|
|
48
|
+
* Falls back to a `?parent=<origin>` query parameter when no referrer is
|
|
49
|
+
* present (some browsers strip referrer from cross-origin loads). Useful
|
|
50
|
+
* for dev embedding flows.
|
|
51
|
+
*/
|
|
52
|
+
export function detectTangleCloudParentOrigin(
|
|
53
|
+
options: { extraOrigins?: readonly string[] } = {},
|
|
54
|
+
): string | null {
|
|
55
|
+
if (typeof window === 'undefined' || window.parent === window) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const allowlist = new Set<string>([
|
|
59
|
+
...TANGLE_CLOUD_ORIGINS_DEFAULT,
|
|
60
|
+
...(options.extraOrigins ?? []),
|
|
61
|
+
]);
|
|
62
|
+
const referrerOrigin = originFromReferrer();
|
|
63
|
+
if (referrerOrigin && allowlist.has(referrerOrigin)) {
|
|
64
|
+
return referrerOrigin;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const url = new URL(window.location.href);
|
|
68
|
+
const explicit = url.searchParams.get('parent');
|
|
69
|
+
if (explicit && allowlist.has(explicit)) return explicit;
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tangle Cloud parent-bridge wallet adapter.
|
|
3
|
+
*
|
|
4
|
+
* iframe blueprints embedded by the Tangle Cloud dapp can't use the usual
|
|
5
|
+
* `window.ethereum` connector — browser wallet extensions don't inject into
|
|
6
|
+
* sandboxed iframes. This module ships a wagmi connector that proxies wallet
|
|
7
|
+
* operations to the parent dapp through the existing `tangle.app.*`
|
|
8
|
+
* postMessage protocol, so the iframe inherits the parent's wallet without
|
|
9
|
+
* its own picker.
|
|
10
|
+
*
|
|
11
|
+
* Usage in an iframe app's wagmi config:
|
|
12
|
+
*
|
|
13
|
+
* import {
|
|
14
|
+
* detectTangleCloudParentOrigin,
|
|
15
|
+
* parentBridgeConnector,
|
|
16
|
+
* } from '@tangle-network/blueprint-ui/wallet';
|
|
17
|
+
*
|
|
18
|
+
* const parent = detectTangleCloudParentOrigin();
|
|
19
|
+
* const config = createConfig(
|
|
20
|
+
* parent !== null
|
|
21
|
+
* ? { ...getDefaultConfig({...}), connectors: [
|
|
22
|
+
* parentBridgeConnector({ parentOrigin: parent, appId: 'my-app' }),
|
|
23
|
+
* ] }
|
|
24
|
+
* : getDefaultConfig({...}),
|
|
25
|
+
* );
|
|
26
|
+
*
|
|
27
|
+
* The bridge is intentionally the ONLY connector when running inside the
|
|
28
|
+
* dapp — surfacing injected / WalletConnect / Coinbase inside a sandboxed
|
|
29
|
+
* iframe doesn't work (no popup, no extension injection) and would just
|
|
30
|
+
* confuse operators.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
detectTangleCloudParentOrigin,
|
|
35
|
+
TANGLE_CLOUD_ORIGINS_DEFAULT,
|
|
36
|
+
} from './detectParentOrigin';
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
parentBridgeConnector,
|
|
40
|
+
type ParentBridgeConnectorOptions,
|
|
41
|
+
} from './parentBridgeConnector';
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
ParentBridgeProvider,
|
|
45
|
+
isRunningInIframe,
|
|
46
|
+
type ParentBridgeOptions,
|
|
47
|
+
} from './parentBridgeProvider';
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
TANGLE_IFRAME_PROTOCOL_PREFIX,
|
|
51
|
+
TANGLE_IFRAME_PROTOCOL_VERSION,
|
|
52
|
+
NO_WALLET_ADDRESS,
|
|
53
|
+
makeCorrelationId,
|
|
54
|
+
type AccountChanged,
|
|
55
|
+
type ChainChanged,
|
|
56
|
+
type HandshakeAck,
|
|
57
|
+
type HandshakeRequest,
|
|
58
|
+
type ParentMessage,
|
|
59
|
+
type ReadAccountRequest,
|
|
60
|
+
type ReadAccountResult,
|
|
61
|
+
type SignMessageRequest,
|
|
62
|
+
type SignMessageResult,
|
|
63
|
+
type SignTransactionRequest,
|
|
64
|
+
type SignTransactionResult,
|
|
65
|
+
type SwitchChainRequest,
|
|
66
|
+
type SwitchChainResult,
|
|
67
|
+
} from './parentBridgeProtocol';
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Wagmi connector that proxies wallet operations to the Tangle Cloud parent
|
|
2
|
+
// dapp via the iframe postMessage bridge. Becomes the autoConnect target
|
|
3
|
+
// when this app is loaded inside an iframe sandbox without a window.ethereum
|
|
4
|
+
// — i.e. always, when embedded by cloud.tangle.tools.
|
|
5
|
+
//
|
|
6
|
+
// Architecture: the connector owns one `ParentBridgeProvider` (singleton),
|
|
7
|
+
// forwards every wagmi method to it, and reflects the provider's EIP-1193
|
|
8
|
+
// events back to wagmi's emitter so the rest of the dapp (ConnectKit's
|
|
9
|
+
// account chip, hooks like useAccount/useChainId) reacts to parent-state
|
|
10
|
+
// changes without polling.
|
|
11
|
+
|
|
12
|
+
import type { Address, Chain } from 'viem';
|
|
13
|
+
import { createConnector } from 'wagmi';
|
|
14
|
+
|
|
15
|
+
import { ParentBridgeProvider, type ParentBridgeOptions } from './parentBridgeProvider';
|
|
16
|
+
|
|
17
|
+
export type ParentBridgeConnectorOptions = ParentBridgeOptions;
|
|
18
|
+
|
|
19
|
+
export function parentBridgeConnector(options: ParentBridgeConnectorOptions) {
|
|
20
|
+
let provider: ParentBridgeProvider | undefined;
|
|
21
|
+
let installed = false;
|
|
22
|
+
|
|
23
|
+
return createConnector<ParentBridgeProvider>((config) => {
|
|
24
|
+
const ensureProvider = (): ParentBridgeProvider => {
|
|
25
|
+
if (!provider) provider = new ParentBridgeProvider(options);
|
|
26
|
+
if (!installed) {
|
|
27
|
+
provider.install();
|
|
28
|
+
installed = true;
|
|
29
|
+
// Wire the provider's EIP-1193 events to wagmi's emitter so
|
|
30
|
+
// ConnectKit and useAccount/useChainId reflect parent-state changes
|
|
31
|
+
// without polling.
|
|
32
|
+
provider.on('accountsChanged', (accounts) => {
|
|
33
|
+
config.emitter.emit('change', {
|
|
34
|
+
accounts: Array.isArray(accounts)
|
|
35
|
+
? (accounts as readonly Address[])
|
|
36
|
+
: ([] as readonly Address[]),
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
provider.on('chainChanged', (chainIdHex) => {
|
|
40
|
+
const chainId =
|
|
41
|
+
typeof chainIdHex === 'string'
|
|
42
|
+
? Number.parseInt(chainIdHex, 16)
|
|
43
|
+
: Number(chainIdHex);
|
|
44
|
+
if (Number.isFinite(chainId)) {
|
|
45
|
+
config.emitter.emit('change', { chainId });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
provider.on('disconnect', () => {
|
|
49
|
+
config.emitter.emit('disconnect');
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return provider;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
id: 'tangleParentBridge',
|
|
57
|
+
name: 'Tangle Cloud',
|
|
58
|
+
type: 'parentBridge',
|
|
59
|
+
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
+
async connect(): Promise<any> {
|
|
62
|
+
// wagmi v3's connect() return type is a conditional based on
|
|
63
|
+
// `withCapabilities`. We always return plain addresses; cast through
|
|
64
|
+
// `any` rather than re-implementing the type predicate.
|
|
65
|
+
const p = ensureProvider();
|
|
66
|
+
const accountsResult = (await p.request({
|
|
67
|
+
method: 'eth_requestAccounts',
|
|
68
|
+
})) as readonly Address[];
|
|
69
|
+
const chainIdHex = (await p.request({ method: 'eth_chainId' })) as string;
|
|
70
|
+
const chainId = Number.parseInt(chainIdHex, 16);
|
|
71
|
+
return {
|
|
72
|
+
accounts: accountsResult,
|
|
73
|
+
chainId: Number.isFinite(chainId) ? chainId : 0,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async disconnect() {
|
|
78
|
+
// Disconnect from the iframe's perspective is a local-only state
|
|
79
|
+
// reset — we can't ask the parent dapp to disconnect its wallet on
|
|
80
|
+
// our behalf, and a real disconnect should be initiated from the
|
|
81
|
+
// parent's UI. Tear down listeners + the message bridge so a future
|
|
82
|
+
// reconnect re-handshakes cleanly.
|
|
83
|
+
if (provider) provider.uninstall();
|
|
84
|
+
installed = false;
|
|
85
|
+
provider = undefined;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async getAccounts() {
|
|
89
|
+
const p = ensureProvider();
|
|
90
|
+
const cached = p.getCachedAccount();
|
|
91
|
+
if (cached) return [cached];
|
|
92
|
+
const accounts = (await p.request({
|
|
93
|
+
method: 'eth_accounts',
|
|
94
|
+
})) as readonly Address[];
|
|
95
|
+
return accounts;
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async getChainId() {
|
|
99
|
+
const p = ensureProvider();
|
|
100
|
+
const cached = p.getCachedChainId();
|
|
101
|
+
if (cached !== null) return cached;
|
|
102
|
+
const chainIdHex = (await p.request({ method: 'eth_chainId' })) as string;
|
|
103
|
+
const chainId = Number.parseInt(chainIdHex, 16);
|
|
104
|
+
return Number.isFinite(chainId) ? chainId : 0;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async getProvider() {
|
|
108
|
+
return ensureProvider();
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async isAuthorized() {
|
|
112
|
+
// Always authorized when in iframe mode — the parent dapp has
|
|
113
|
+
// already gated access by being the embedder. Returning `true`
|
|
114
|
+
// makes wagmi auto-reconnect on every page load, which is the
|
|
115
|
+
// right UX (iframe → parent wallet is always-on).
|
|
116
|
+
try {
|
|
117
|
+
const p = ensureProvider();
|
|
118
|
+
const accounts = (await p.request({
|
|
119
|
+
method: 'eth_accounts',
|
|
120
|
+
})) as readonly Address[];
|
|
121
|
+
return accounts.length > 0;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async switchChain({ chainId }): Promise<Chain> {
|
|
128
|
+
const p = ensureProvider();
|
|
129
|
+
await p.request({
|
|
130
|
+
method: 'wallet_switchEthereumChain',
|
|
131
|
+
params: [{ chainId: `0x${chainId.toString(16)}` }],
|
|
132
|
+
});
|
|
133
|
+
const chain = config.chains.find((c) => c.id === chainId);
|
|
134
|
+
if (!chain) {
|
|
135
|
+
throw new Error(`Chain ${chainId} not configured for this app`);
|
|
136
|
+
}
|
|
137
|
+
return chain;
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
onAccountsChanged(accounts) {
|
|
141
|
+
config.emitter.emit('change', {
|
|
142
|
+
accounts: accounts as readonly Address[],
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
onChainChanged(chainIdHex) {
|
|
146
|
+
const chainId = Number.parseInt(chainIdHex, 16);
|
|
147
|
+
if (Number.isFinite(chainId)) {
|
|
148
|
+
config.emitter.emit('change', { chainId });
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
onDisconnect() {
|
|
152
|
+
config.emitter.emit('disconnect');
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Tangle Cloud iframe ↔ parent dapp protocol — must mirror the parent's
|
|
2
|
+
// spec at `apps/tangle-cloud/src/blueprintApps/iframe/protocol.ts`. Bump the
|
|
3
|
+
// version constant in lockstep when either side adds a request kind.
|
|
4
|
+
|
|
5
|
+
import type { Address, Hex } from 'viem';
|
|
6
|
+
|
|
7
|
+
export const TANGLE_IFRAME_PROTOCOL_VERSION = '1' as const;
|
|
8
|
+
export const TANGLE_IFRAME_PROTOCOL_PREFIX = 'tangle.app.';
|
|
9
|
+
|
|
10
|
+
// ─── Iframe → Parent requests ────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type HandshakeRequest = {
|
|
13
|
+
kind: 'tangle.app.handshake';
|
|
14
|
+
appId: string;
|
|
15
|
+
version: typeof TANGLE_IFRAME_PROTOCOL_VERSION;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ReadAccountRequest = {
|
|
19
|
+
kind: 'tangle.app.readAccount';
|
|
20
|
+
correlationId: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SwitchChainRequest = {
|
|
24
|
+
kind: 'tangle.app.switchChain';
|
|
25
|
+
correlationId: string;
|
|
26
|
+
chainId: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SignMessageRequest = {
|
|
30
|
+
kind: 'tangle.app.signMessage';
|
|
31
|
+
correlationId: string;
|
|
32
|
+
chainId: number;
|
|
33
|
+
message: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type SignTransactionRequest = {
|
|
37
|
+
kind: 'tangle.app.signTransaction';
|
|
38
|
+
correlationId: string;
|
|
39
|
+
chainId: number;
|
|
40
|
+
to: Address;
|
|
41
|
+
data: Hex;
|
|
42
|
+
value?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ─── Parent → Iframe messages ────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export type HandshakeAck = {
|
|
48
|
+
kind: 'tangle.app.handshakeAck';
|
|
49
|
+
appId: string;
|
|
50
|
+
protocolVersion: typeof TANGLE_IFRAME_PROTOCOL_VERSION;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ResultEnvelope<T> = { correlationId: string } & (
|
|
54
|
+
| { ok: true; data: T }
|
|
55
|
+
| { ok: false; error: string }
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export type ReadAccountResult = {
|
|
59
|
+
kind: 'tangle.app.readAccountResult';
|
|
60
|
+
} & ResultEnvelope<{ account: Address; chainId: number }>;
|
|
61
|
+
|
|
62
|
+
export type SwitchChainResult = {
|
|
63
|
+
kind: 'tangle.app.switchChainResult';
|
|
64
|
+
} & ResultEnvelope<{ chainId: number }>;
|
|
65
|
+
|
|
66
|
+
export type SignMessageResult = {
|
|
67
|
+
kind: 'tangle.app.signMessageResult';
|
|
68
|
+
} & ResultEnvelope<{ signature: Hex }>;
|
|
69
|
+
|
|
70
|
+
export type SignTransactionResult = {
|
|
71
|
+
kind: 'tangle.app.signTransactionResult';
|
|
72
|
+
} & ResultEnvelope<{ txHash: Hex }>;
|
|
73
|
+
|
|
74
|
+
export type AccountChanged = {
|
|
75
|
+
kind: 'tangle.app.accountChanged';
|
|
76
|
+
account: Address | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type ChainChanged = {
|
|
80
|
+
kind: 'tangle.app.chainChanged';
|
|
81
|
+
chainId: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type ParentMessage =
|
|
85
|
+
| HandshakeAck
|
|
86
|
+
| ReadAccountResult
|
|
87
|
+
| SwitchChainResult
|
|
88
|
+
| SignMessageResult
|
|
89
|
+
| SignTransactionResult
|
|
90
|
+
| AccountChanged
|
|
91
|
+
| ChainChanged;
|
|
92
|
+
|
|
93
|
+
// The zero address used by the parent when no wallet is connected. The parent
|
|
94
|
+
// always responds to readAccount with an address; this sentinel means "no
|
|
95
|
+
// wallet" without making the response type a union of result shapes.
|
|
96
|
+
export const NO_WALLET_ADDRESS = '0x0000000000000000000000000000000000000000';
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Cryptographically-random ASCII correlation id matching the parent's
|
|
100
|
+
* validator regex (`/^[\w.\-:]+$/`, max length 128). The connector keeps a
|
|
101
|
+
* Map<correlationId, Resolver> so each request resolves independently.
|
|
102
|
+
*/
|
|
103
|
+
export function makeCorrelationId(prefix: string): string {
|
|
104
|
+
const random =
|
|
105
|
+
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
106
|
+
? crypto.randomUUID()
|
|
107
|
+
: Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
108
|
+
return `${prefix}.${random}`;
|
|
109
|
+
}
|
|
@@ -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
|
+
});
|