@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.
@@ -33,6 +33,12 @@ export interface OperatorQuote {
33
33
  totalCost: bigint;
34
34
  signature: `0x${string}`;
35
35
  details: {
36
+ /**
37
+ * Address of the account that will submit `createServiceFromQuotes`.
38
+ * Required since tnt-core v0.13.0 — the contract enforces
39
+ * `requester == msg.sender` and rejects `address(0)` (no wildcard quotes).
40
+ */
41
+ requester: Address;
36
42
  blueprintId: bigint;
37
43
  ttlBlocks: bigint;
38
44
  totalCost: bigint;
@@ -170,13 +176,40 @@ function mapJsonResourceCommitment(resource: any): ResourceCommitment {
170
176
 
171
177
  // ── Hook ──
172
178
 
179
+ const ZERO_ADDRESS_LOWER = ZERO_ADDRESS.toLowerCase();
180
+
181
+ /**
182
+ * Fetches signed `OperatorQuote` payloads from a set of operators for
183
+ * `createServiceFromQuotes`.
184
+ *
185
+ * @param operators Operators discovered from the on-chain registry.
186
+ * @param blueprintId Target blueprint id.
187
+ * @param ttlBlocks Requested service TTL in blocks.
188
+ * @param enabled Gate to disable fetching (e.g. while inputs settle).
189
+ * @param requester Address that will submit `createServiceFromQuotes` —
190
+ * must equal `msg.sender` at submission time. Required
191
+ * since tnt-core v0.13.0; the contract rejects
192
+ * `address(0)` and any mismatch.
193
+ * @param requireTee If true, asks operators for TEE-attested quotes.
194
+ */
173
195
  export function useQuotes(
174
196
  operators: DiscoveredOperator[],
175
197
  blueprintId: bigint,
176
198
  ttlBlocks: bigint,
177
199
  enabled: boolean,
200
+ requester: Address,
178
201
  requireTee = false,
179
202
  ): UseQuotesResult {
203
+ // Defensive guard: only assert when the caller has actually opted in via
204
+ // `enabled`. This lets components compute `requester` from
205
+ // `useAccount().address` and pass `enabled=false` until the wallet connects.
206
+ if (enabled && (!requester || requester.toLowerCase() === ZERO_ADDRESS_LOWER)) {
207
+ throw new Error(
208
+ 'useQuotes: `requester` is required and must be a non-zero address when `enabled=true`. ' +
209
+ 'Pass `useAccount().address` from wagmi. tnt-core v0.13.0 contracts ' +
210
+ 'reject quotes whose requester is address(0) or != msg.sender.',
211
+ );
212
+ }
180
213
  const [quotes, setQuotes] = useState<OperatorQuote[]>([]);
181
214
  const [isLoading, setIsLoading] = useState(false);
182
215
  const [isSolvingPow, setIsSolvingPow] = useState(false);
@@ -224,6 +257,7 @@ export function useQuotes(
224
257
  proofOfWork: proof,
225
258
  challengeTimestamp: timestamp,
226
259
  requireTee,
260
+ requester,
227
261
  });
228
262
 
229
263
  if (!response) throw new Error('No quote returned from operator');
@@ -250,7 +284,7 @@ export function useQuotes(
250
284
  return () => {
251
285
  cancelled = true;
252
286
  };
253
- }, [operators, blueprintId, ttlBlocks, enabled, fetchKey, requireTee]);
287
+ }, [operators, blueprintId, ttlBlocks, enabled, fetchKey, requireTee, requester]);
254
288
 
255
289
  const totalCost = quotes.reduce((sum, q) => sum + q.totalCost, 0n);
256
290
 
@@ -270,6 +304,7 @@ async function fetchPriceFromOperator(
270
304
  proofOfWork: Uint8Array;
271
305
  challengeTimestamp: bigint;
272
306
  requireTee: boolean;
307
+ requester: Address;
273
308
  },
274
309
  ): Promise<OperatorQuote | null> {
275
310
  // Try JSON endpoint (simpler, no protobuf dependency required)
@@ -283,6 +318,10 @@ async function fetchPriceFromOperator(
283
318
  proof_of_work: toHex(params.proofOfWork),
284
319
  challenge_timestamp: String(params.challengeTimestamp),
285
320
  require_tee: params.requireTee,
321
+ // tnt-core v0.13.0: bind the quote to the future caller. Operators
322
+ // sign this address into QuoteDetails; the contract enforces
323
+ // `requester == msg.sender`.
324
+ requester: params.requester,
286
325
  resource_requirements: DEFAULT_RESOURCE_REQUIREMENTS,
287
326
  }),
288
327
  signal: AbortSignal.timeout(10_000),
@@ -299,6 +338,10 @@ async function fetchPriceFromOperator(
299
338
  teeAttested: Boolean(data.tee_attested),
300
339
  teeProvider: data.tee_provider || undefined,
301
340
  details: {
341
+ // Prefer the operator-signed value; fall back to the hook's input.
342
+ // If the operator returns a mismatched requester the contract will
343
+ // revert at submission, so callers should still verify equality.
344
+ requester: ((data.details?.requester as Address | undefined) ?? params.requester),
302
345
  blueprintId: BigInt(data.details?.blueprint_id ?? params.blueprintId),
303
346
  ttlBlocks: BigInt(data.details?.ttl_blocks ?? params.ttlBlocks),
304
347
  totalCost: BigInt(data.details?.total_cost ?? '0'),
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom/vitest';
@@ -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
+ }