@tangle-network/blueprint-ui 0.5.1 → 0.5.3

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.
@@ -14,6 +14,7 @@ import {
14
14
  makeCorrelationId,
15
15
  NO_WALLET_ADDRESS,
16
16
  TANGLE_IFRAME_PROTOCOL_VERSION,
17
+ type ConnectResult,
17
18
  type ParentMessage,
18
19
  type ReadAccountResult,
19
20
  type SignMessageResult,
@@ -51,6 +52,9 @@ export type ParentBridgeOptions = {
51
52
  };
52
53
 
53
54
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
55
+ // Connecting is gated on the user interacting with the parent's modal — allow
56
+ // a generous window rather than the standard per-request timeout.
57
+ const CONNECT_REQUEST_TIMEOUT_MS = 300_000;
54
58
 
55
59
  /**
56
60
  * Detect iframe execution context. When this returns `false` the bridge
@@ -121,11 +125,19 @@ export class ParentBridgeProvider {
121
125
  await this.ensureBootstrapped();
122
126
  return this.cachedChainId !== null ? `0x${this.cachedChainId.toString(16)}` : '0x0';
123
127
  }
124
- case 'eth_accounts':
125
- case 'eth_requestAccounts': {
128
+ case 'eth_accounts': {
129
+ // Passive read — never opens a modal.
126
130
  await this.ensureBootstrapped();
127
131
  return this.cachedAccount !== null ? [this.cachedAccount] : [];
128
132
  }
133
+ case 'eth_requestAccounts': {
134
+ // Active connect (the wallet "Connect" button). If the parent has no
135
+ // wallet yet, delegate to it to open its connect modal and wait.
136
+ await this.ensureBootstrapped();
137
+ if (this.cachedAccount !== null) return [this.cachedAccount];
138
+ const account = await this.requestConnect();
139
+ return account !== null ? [account] : [];
140
+ }
129
141
  case 'personal_sign': {
130
142
  const [message, _signer] = params as [string, Address];
131
143
  return this.requestSignMessage(message);
@@ -213,6 +225,7 @@ export class ParentBridgeProvider {
213
225
  });
214
226
  return;
215
227
  case 'tangle.app.readAccountResult':
228
+ case 'tangle.app.connectResult':
216
229
  this.resolvePending(message);
217
230
  if (message.ok) {
218
231
  this.updateAccount(
@@ -247,6 +260,21 @@ export class ParentBridgeProvider {
247
260
  }) as Promise<{ account: Address; chainId: number }>;
248
261
  }
249
262
 
263
+ /**
264
+ * Ask the parent to connect a wallet (opening its modal if needed) and wait
265
+ * for the result. Long timeout — the user is interacting with the modal.
266
+ */
267
+ private requestConnect(): Promise<Address | null> {
268
+ return this.dispatch({
269
+ kind: 'tangle.app.requestConnect',
270
+ expectedKind: 'tangle.app.connectResult',
271
+ timeoutMs: CONNECT_REQUEST_TIMEOUT_MS,
272
+ }).then((data) => {
273
+ const { account } = data as { account: Address; chainId: number };
274
+ return account === NO_WALLET_ADDRESS ? null : account;
275
+ });
276
+ }
277
+
250
278
  private requestSignMessage(message: string): Promise<Hex> {
251
279
  const chainId = this.cachedChainId ?? 1;
252
280
  return this.dispatch({
@@ -289,13 +317,19 @@ export class ParentBridgeProvider {
289
317
  }
290
318
 
291
319
  private async dispatch(req: {
292
- kind: 'tangle.app.readAccount' | 'tangle.app.switchChain' | 'tangle.app.signMessage' | 'tangle.app.signTransaction';
320
+ kind:
321
+ | 'tangle.app.readAccount'
322
+ | 'tangle.app.switchChain'
323
+ | 'tangle.app.signMessage'
324
+ | 'tangle.app.signTransaction'
325
+ | 'tangle.app.requestConnect';
293
326
  expectedKind: ParentMessage['kind'];
294
327
  payload?: Record<string, unknown>;
328
+ timeoutMs?: number;
295
329
  }): Promise<unknown> {
296
330
  await this.ensureBootstrapped();
297
331
  const correlationId = makeCorrelationId(req.kind);
298
- const timeout = this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
332
+ const timeout = req.timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
299
333
  return new Promise<unknown>((resolve, reject) => {
300
334
  const timer = window.setTimeout(() => {
301
335
  this.pending.delete(correlationId);
@@ -329,6 +363,7 @@ export class ParentBridgeProvider {
329
363
  private resolvePending(
330
364
  message:
331
365
  | ReadAccountResult
366
+ | ConnectResult
332
367
  | SwitchChainResult
333
368
  | SignMessageResult
334
369
  | SignTransactionResult,
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CreateConnectorFn } from 'wagmi';
3
+
4
+ import { tangleBlueprintConnectors } from './tangleConnectors';
5
+
6
+ // jsdom: window.parent === window (not iframed) → no trusted parent → the
7
+ // helper must hand back the app's own standalone connectors unchanged. The
8
+ // embedded branch needs a real cross-origin referrer and is covered by the
9
+ // connector's own integration tests.
10
+ describe('tangleBlueprintConnectors', () => {
11
+ it('returns the standalone connectors when not embedded', () => {
12
+ const a = (() => ({})) as unknown as CreateConnectorFn;
13
+ const b = (() => ({})) as unknown as CreateConnectorFn;
14
+ expect(
15
+ tangleBlueprintConnectors({ appId: 'my-app', standalone: [a, b] }),
16
+ ).toEqual([a, b]);
17
+ });
18
+ });
@@ -0,0 +1,62 @@
1
+ import type { CreateConnectorFn } from 'wagmi';
2
+
3
+ import { detectTangleCloudParentOrigin } from './detectParentOrigin';
4
+ import { parentBridgeConnector } from './parentBridgeConnector';
5
+
6
+ export interface TangleBlueprintConnectorsOptions {
7
+ /** App id reported to the parent dapp on handshake (when embedded). */
8
+ appId: string;
9
+ /**
10
+ * Connectors used when running STANDALONE (the app's own domain) — e.g.
11
+ * `[injected(), walletConnect({ projectId })]`. Ignored when embedded.
12
+ */
13
+ standalone: CreateConnectorFn[];
14
+ /** Extra trusted parent origins (staging / preview deploys). */
15
+ extraOrigins?: readonly string[];
16
+ /** Override the bridged-request timeout (ms). */
17
+ requestTimeoutMs?: number;
18
+ }
19
+
20
+ /**
21
+ * One connector list, two deployment modes — so a blueprint app can ship a
22
+ * single build that runs both standalone and embedded in Tangle Cloud:
23
+ *
24
+ * - **Embedded** (a trusted Tangle Cloud parent is detected): the
25
+ * parent-bridge connector is the *only* connector. The sandboxed iframe
26
+ * can't inject a wallet extension, so it inherits / drives the parent's
27
+ * wallet over the postMessage bridge — surfacing injected/WalletConnect
28
+ * here would just dead-end.
29
+ * - **Standalone** (no trusted parent): the app's own `standalone`
30
+ * connectors, exactly as a normal dapp.
31
+ *
32
+ * The choice is made at runtime from the embedding context, so the same
33
+ * artifact works in both places with no build flags.
34
+ *
35
+ * createConfig({
36
+ * chains,
37
+ * transports,
38
+ * connectors: tangleBlueprintConnectors({
39
+ * appId: 'trading-arena',
40
+ * standalone: [injected(), walletConnect({ projectId })],
41
+ * }),
42
+ * })
43
+ */
44
+ export function tangleBlueprintConnectors(
45
+ options: TangleBlueprintConnectorsOptions,
46
+ ): CreateConnectorFn[] {
47
+ const parentOrigin = detectTangleCloudParentOrigin({
48
+ extraOrigins: options.extraOrigins,
49
+ });
50
+ if (parentOrigin === null) {
51
+ return options.standalone;
52
+ }
53
+ return [
54
+ parentBridgeConnector({
55
+ parentOrigin,
56
+ appId: options.appId,
57
+ ...(options.requestTimeoutMs !== undefined
58
+ ? { requestTimeoutMs: options.requestTimeoutMs }
59
+ : {}),
60
+ }),
61
+ ];
62
+ }
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/wallet/detectParentOrigin.ts","../src/wallet/parentBridgeProtocol.ts"],"sourcesContent":["// Determine which origin to trust as the parent dapp.\n//\n// `document.referrer` is the *initial* embedder — it's set when the iframe is\n// first loaded and survives reloads (though it can be cleared by `referrerpolicy`\n// or by the embedder). The Tangle Cloud iframe wrapper deliberately omits\n// `referrerpolicy=\"no-referrer\"` so we get the embedder's origin here.\n//\n// We compare it against an allowlist of known Tangle Cloud origins. If it\n// matches, that's the parent. Otherwise the iframe is being loaded directly\n// (standalone domain visit, dev server, untrusted embedder) and the bridge\n// stays disabled — the app falls back to its normal injected/WC wallet path.\n\n/**\n * Default Tangle Cloud origins. Consumers (agent-sandbox UI,\n * trading-arena, future iframe blueprints) pass app-specific additions\n * via `extraOrigins` rather than mutating this list.\n */\nexport const TANGLE_CLOUD_ORIGINS_DEFAULT = Object.freeze([\n 'https://cloud.tangle.tools',\n 'https://develop.cloud.tangle.tools',\n // Local dev (Vite default port for tangle-cloud + Netlify dev preview).\n 'http://localhost:4300',\n 'http://localhost:8888',\n] as const);\n\nfunction originFromReferrer(): string | null {\n if (typeof document === 'undefined') return null;\n const ref = document.referrer;\n if (!ref) return null;\n try {\n return new URL(ref).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * Returns the parent origin to bridge to, or null when no trusted parent is\n * detected. Caller should skip installing the bridge connector when this\n * returns null.\n *\n * `extraOrigins` is the application's escape hatch for staging or dev\n * deploys not covered by the default list. The library deliberately does\n * not read environment variables itself (consumers may bundle for non-Vite\n * runtimes); the consuming app threads `import.meta.env.VITE_*` or\n * `process.env.*` in itself.\n *\n * Falls back to a `?parent=<origin>` query parameter when no referrer is\n * present (some browsers strip referrer from cross-origin loads). Useful\n * for dev embedding flows.\n */\nexport function detectTangleCloudParentOrigin(\n options: { extraOrigins?: readonly string[] } = {},\n): string | null {\n if (typeof window === 'undefined' || window.parent === window) {\n return null;\n }\n const allowlist = new Set<string>([\n ...TANGLE_CLOUD_ORIGINS_DEFAULT,\n ...(options.extraOrigins ?? []),\n ]);\n const referrerOrigin = originFromReferrer();\n if (referrerOrigin && allowlist.has(referrerOrigin)) {\n return referrerOrigin;\n }\n try {\n const url = new URL(window.location.href);\n const explicit = url.searchParams.get('parent');\n if (explicit && allowlist.has(explicit)) return explicit;\n } catch {\n // ignore\n }\n return null;\n}\n","// Tangle Cloud iframe ↔ parent dapp protocol — must mirror the parent's\n// spec at `apps/tangle-cloud/src/blueprintApps/iframe/protocol.ts`. Bump the\n// version constant in lockstep when either side adds a request kind.\n\nimport type { Address, Hex } from 'viem';\n\nexport const TANGLE_IFRAME_PROTOCOL_VERSION = '1' as const;\nexport const TANGLE_IFRAME_PROTOCOL_PREFIX = 'tangle.app.';\n\n// ─── Iframe → Parent requests ────────────────────────────────────────────────\n\nexport type HandshakeRequest = {\n kind: 'tangle.app.handshake';\n appId: string;\n version: typeof TANGLE_IFRAME_PROTOCOL_VERSION;\n};\n\nexport type ReadAccountRequest = {\n kind: 'tangle.app.readAccount';\n correlationId: string;\n};\n\nexport type SwitchChainRequest = {\n kind: 'tangle.app.switchChain';\n correlationId: string;\n chainId: number;\n};\n\nexport type SignMessageRequest = {\n kind: 'tangle.app.signMessage';\n correlationId: string;\n chainId: number;\n message: string;\n};\n\nexport type SignTransactionRequest = {\n kind: 'tangle.app.signTransaction';\n correlationId: string;\n chainId: number;\n to: Address;\n data: Hex;\n value?: string;\n};\n\n// EIP-712 typed-data signing for publishers that need to sign custom message\n// shapes — operator envelopes, off-chain attestations, claim proofs, etc.\n// The parent renders the typed-data fields in its approval modal so the user\n// can audit what they're signing. Iframes never see the wallet's signing key\n// or private state.\n//\n// Shape mirrors viem's `signTypedData` argument: `domain` + `types` (without\n// the EIP712Domain entry — viem injects it) + `primaryType` + `message`.\n// Validation on the parent side rejects payloads that are obviously\n// malformed (missing primaryType, types map empty, etc.) but does NOT\n// re-shape the message — the user is the one who decides whether to sign.\nexport type SignTypedDataRequest = {\n kind: 'tangle.app.signTypedData';\n correlationId: string;\n chainId: number;\n domain: Readonly<{\n name?: string;\n version?: string;\n chainId?: number;\n verifyingContract?: Address;\n salt?: Hex;\n }>;\n /** EIP-712 types map; do NOT include the EIP712Domain entry (the parent\n * injects it derived from `domain`). */\n types: Readonly<Record<string, ReadonlyArray<{ name: string; type: string }>>>;\n /** Top-level type name in `types` whose values appear in `message`. */\n primaryType: string;\n /** The actual typed-data values. Shape matches `types[primaryType]`. */\n message: Readonly<Record<string, unknown>>;\n};\n\n// ─── Parent → Iframe messages ────────────────────────────────────────────────\n\nexport type HandshakeAck = {\n kind: 'tangle.app.handshakeAck';\n appId: string;\n protocolVersion: typeof TANGLE_IFRAME_PROTOCOL_VERSION;\n};\n\nexport type ResultEnvelope<T> = { correlationId: string } & (\n | { ok: true; data: T }\n | { ok: false; error: string }\n);\n\nexport type ReadAccountResult = {\n kind: 'tangle.app.readAccountResult';\n} & ResultEnvelope<{ account: Address; chainId: number }>;\n\nexport type SwitchChainResult = {\n kind: 'tangle.app.switchChainResult';\n} & ResultEnvelope<{ chainId: number }>;\n\nexport type SignMessageResult = {\n kind: 'tangle.app.signMessageResult';\n} & ResultEnvelope<{ signature: Hex }>;\n\nexport type SignTransactionResult = {\n kind: 'tangle.app.signTransactionResult';\n} & ResultEnvelope<{ txHash: Hex }>;\n\nexport type SignTypedDataResult = {\n kind: 'tangle.app.signTypedDataResult';\n} & ResultEnvelope<{ signature: Hex }>;\n\nexport type AccountChanged = {\n kind: 'tangle.app.accountChanged';\n account: Address | null;\n};\n\nexport type ChainChanged = {\n kind: 'tangle.app.chainChanged';\n chainId: number;\n};\n\n// ─── Service context (parent → iframe) ──────────────────────────────────────\n//\n// Iframe blueprints embedded by Tangle Cloud need to know which service +\n// blueprint they're rendering for, plus which operators are quoted. The\n// parent broadcasts this on mount and on every change (mode picker swap,\n// new service activation, operator delta). The iframe just reads — it\n// doesn't query the chain itself.\n//\n// The thin-iframe SDK exposes this as `useTangleService()`. Iframes that\n// use the full wagmi connector path can still listen to `serviceContext`\n// for routing convenience.\n\nexport type ServiceContextOperator = {\n readonly address: Address;\n readonly rpcAddress: string | undefined;\n readonly status: 'active' | 'inactive' | 'unknown';\n};\n\nexport type ServiceContextJob = {\n readonly index: number;\n readonly name: string;\n readonly inputSchema?: unknown;\n};\n\n/**\n * Chain configuration the parent broadcasts to the iframe along with\n * service context. Iframes use this to build a `viem` public client for\n * READ-ONLY queries (`useTanglePublicClient` is the convenience hook).\n *\n * Iframes can ignore this and roll their own RPC config — particularly\n * when they need to read from chains OTHER than the active one (e.g. a\n * trading dapp pulling oracle data from mainnet while the active service\n * lives on Base Sepolia). The injected client is a hint, not a constraint.\n *\n * `rpcUrl` is the public RPC the parent uses, NOT a wallet RPC. Iframes\n * cannot sign or submit with this URL; signing always routes upstream via\n * the bridge.\n */\nexport type ChainContext = {\n readonly id: number;\n readonly name: string;\n readonly rpcUrl: string;\n /** Block-explorer base URL — useful for rendering tx links. */\n readonly blockExplorerUrl?: string;\n /** Native currency metadata for cost displays. */\n readonly nativeCurrency?: { readonly name: string; readonly symbol: string; readonly decimals: number };\n};\n\nexport type ServiceContextBroadcast = {\n kind: 'tangle.app.serviceContext';\n readonly blueprintId: string;\n readonly serviceId: string | null;\n readonly operators: readonly ServiceContextOperator[];\n readonly jobs: readonly ServiceContextJob[];\n readonly mode: string | null;\n /** Active chain the parent is connected to; iframes can build a viem\n * publicClient against this for convenience. Optional for backwards\n * compatibility with parents that haven't been upgraded yet. */\n readonly chain?: ChainContext;\n};\n\n// ─── Job invocation (iframe ↔ parent) ────────────────────────────────────────\n//\n// Instead of the iframe wiring up its own EIP-712 quote / sign / submit\n// flow, it sends a single CallJob request upstream. The parent does the\n// whole dance (fetch RFQ quote, build typed data, request user signature,\n// submit on-chain) and streams results back. The iframe never touches\n// chain logic.\n\nexport type JobInputs = Readonly<Record<string, unknown>>;\n\nexport type CallJobRequest = {\n kind: 'tangle.app.callJob';\n correlationId: string;\n /** Job index within the blueprint, e.g. 0 for the primary entry-point. */\n jobIndex: number;\n /** Free-form inputs validated by the parent against the on-chain ABI. */\n inputs: JobInputs;\n /**\n * Whether the publisher wants intermediate progress (streaming chunks)\n * or just the terminal result. Streaming jobs (LLM generation, video\n * encode) opt in; one-shots (embeddings, classifications) don't.\n */\n stream?: boolean;\n};\n\nexport type JobResultStatus = 'pending' | 'streaming' | 'success' | 'error';\n\nexport type JobResultEvent = {\n kind: 'tangle.app.jobResult';\n correlationId: string;\n status: JobResultStatus;\n /** Present on `streaming` and `success`. Shape is publisher-defined. */\n data?: unknown;\n /** Present on `streaming` only — incremental chunk for live UI. */\n chunk?: unknown;\n /** Present on `error`. Human-readable. */\n error?: string;\n /** Optional progress metadata (e.g. `{ percent: 0.42, eta_ms: 8000 }`). */\n progress?: { readonly percent?: number; readonly eta_ms?: number };\n};\n\nexport type ParentMessage =\n | HandshakeAck\n | ReadAccountResult\n | SwitchChainResult\n | SignMessageResult\n | SignTransactionResult\n | SignTypedDataResult\n | AccountChanged\n | ChainChanged\n | ServiceContextBroadcast\n | JobResultEvent;\n\nexport type IframeRequest =\n | HandshakeRequest\n | ReadAccountRequest\n | SwitchChainRequest\n | SignMessageRequest\n | SignTransactionRequest\n | SignTypedDataRequest\n | CallJobRequest;\n\n// The zero address used by the parent when no wallet is connected. The parent\n// always responds to readAccount with an address; this sentinel means \"no\n// wallet\" without making the response type a union of result shapes.\nexport const NO_WALLET_ADDRESS = '0x0000000000000000000000000000000000000000';\n\n/**\n * Cryptographically-random ASCII correlation id matching the parent's\n * validator regex (`/^[\\w.\\-:]+$/`, max length 128). The connector keeps a\n * Map<correlationId, Resolver> so each request resolves independently.\n */\nexport function makeCorrelationId(prefix: string): string {\n const random =\n typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'\n ? crypto.randomUUID()\n : Math.random().toString(36).slice(2) + Date.now().toString(36);\n return `${prefix}.${random}`;\n}\n"],"mappings":";AAiBO,IAAM,+BAA+B,OAAO,OAAO;AAAA,EACxD;AAAA,EACA;AAAA;AAAA,EAEA;AAAA,EACA;AACF,CAAU;AAEV,SAAS,qBAAoC;AAC3C,MAAI,OAAO,aAAa,YAAa,QAAO;AAC5C,QAAM,MAAM,SAAS;AACrB,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI;AACF,WAAO,IAAI,IAAI,GAAG,EAAE;AAAA,EACtB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAiBO,SAAS,8BACd,UAAgD,CAAC,GAClC;AACf,MAAI,OAAO,WAAW,eAAe,OAAO,WAAW,QAAQ;AAC7D,WAAO;AAAA,EACT;AACA,QAAM,YAAY,oBAAI,IAAY;AAAA,IAChC,GAAG;AAAA,IACH,GAAI,QAAQ,gBAAgB,CAAC;AAAA,EAC/B,CAAC;AACD,QAAM,iBAAiB,mBAAmB;AAC1C,MAAI,kBAAkB,UAAU,IAAI,cAAc,GAAG;AACnD,WAAO;AAAA,EACT;AACA,MAAI;AACF,UAAM,MAAM,IAAI,IAAI,OAAO,SAAS,IAAI;AACxC,UAAM,WAAW,IAAI,aAAa,IAAI,QAAQ;AAC9C,QAAI,YAAY,UAAU,IAAI,QAAQ,EAAG,QAAO;AAAA,EAClD,QAAQ;AAAA,EAER;AACA,SAAO;AACT;;;ACnEO,IAAM,iCAAiC;AACvC,IAAM,gCAAgC;AA6OtC,IAAM,oBAAoB;AAO1B,SAAS,kBAAkB,QAAwB;AACxD,QAAM,SACJ,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe,aAC1D,OAAO,WAAW,IAClB,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,CAAC,IAAI,KAAK,IAAI,EAAE,SAAS,EAAE;AAClE,SAAO,GAAG,MAAM,IAAI,MAAM;AAC5B;","names":[]}