Skip to main content

Integrate TLSNotary (Any Framework)

The standard Build a TLSNotary Webapp guide uses webpack directly, which requires complex WASM polyfills, worker setup, and browser polyfill configuration. If you use Vite, Next.js, SvelteKit, Nuxt, Remix, Astro, or any other bundler, you can skip all of that. The TlsnClient approach runs all the heavy TLSNotary WASM machinery inside a hosted iframe, and your app communicates with it through a simple async API over postMessage.

Why Use the Iframe Approach?

Webpack (Direct)Iframe (TlsnClient)
BundlerWebpack onlyAny (Vite, Next.js, SvelteKit, etc.)
WASM configComplex polyfills requiredNone
Dependencies@kynesyslabs/demosdk, comlink, polyfillsZero (single file)
Bundle size impactLarge (~5MB+ WASM)Negligible (~4KB)
Setup complexityHighMinimal
COOP/COEP headersYou must configureHandled by the hosted embed

How It Works

Your app loads a hidden <iframe> pointing to a hosted TLSNotary embed. The embed contains the full webpack build with WASM, workers, and polyfills. Your app calls methods on TlsnClient, which sends postMessage commands to the iframe and returns promises with the results.
┌─────────────────────────────┐       ┌──────────────────────────────┐
│  Your App (any bundler)     │       │  TLSN Embed (iframe)         │
│                             │       │                              │
│  TlsnClient                 │       │  TlsnManager + WASM          │
│    .connect()  ─────────────┼──────►│    WebSocket proxy           │
│    .attest()   ─────────────┼──────►│    TLSNotary notarization    │
│    .verify()   ◄────────────┼───────┤    Proof generation          │
│                             │       │                              │
│  postMessage ↔ postMessage  │       │  COOP/COEP headers           │
└─────────────────────────────┘       └──────────────────────────────┘

Quick Start

Step 1: Copy the TlsnClient

The TlsnClient is a single TypeScript file with zero dependencies. Copy it into your project:
TlsnClient.ts
// TlsnClient - Lightweight client SDK for TLSN component
// Zero dependencies. Works in any bundler or even as a plain <script> tag.

export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export type LogLevel = 'info' | 'success' | 'warning' | 'error';

export interface AttestRequest {
  url: string;
  method: HttpMethod;
  headers?: Record<string, string>;
  body?: string | Record<string, unknown>;
}

export interface CommitRanges {
  sent: Array<{ start: number; end: number }>;
  recv: Array<{ start: number; end: number }>;
}

export interface AttestOptions {
  commitRanges?: CommitRanges;
  storage?: 'local' | 'onchain';
}

export interface AttestResult {
  presentation: unknown;
  serverName: string;
  time: number;
  sent: string;
  recv: string;
  verifyingKey: string;
  tokenId: string;
  requestTxHash: string;
}

export interface VerifyResult {
  serverName: string;
  time: number;
  sent: string;
  recv: string;
  verifyingKey: string;
}

export interface TranscriptPreview {
  sent: number[];
  recv: number[];
  sentText: string;
  recvText: string;
}

export interface StoreResult {
  txHash: string;
}

export interface TransactionDetails {
  description: string;
  amount: string;
  targetUrl?: string;
  tokenId?: string;
  txHash: string;
}

export interface TlsnClientOptions {
  /** URL where the TLSN iframe embed is hosted */
  iframeUrl: string;
  /** Container element to append the iframe to. Defaults to document.body. */
  container?: HTMLElement;
  /** Timeout in milliseconds for RPC calls. Defaults to 300000 (5 min). */
  timeout?: number;
  /** Timeout in milliseconds for iframe ready. Defaults to 30000 (30s). */
  readyTimeout?: number;
  /** Enable debug logging to console. Defaults to false. */
  debug?: boolean;
}

type EventCallback = (...args: any[]) => void;
type TxConfirmHandler = (details: TransactionDetails) => Promise<boolean>;

export class TlsnClient {
  private iframe: HTMLIFrameElement;
  private ready: Promise<void>;
  private pendingCalls = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
  private listeners = new Map<string, Set<EventCallback>>();
  private txConfirmHandler: TxConfirmHandler | null = null;
  private timeout: number;
  private readyTimeout: number;
  private debugEnabled: boolean;
  private destroyed = false;

  constructor(options: TlsnClientOptions) {
    this.timeout = options.timeout || 300_000;
    this.readyTimeout = options.readyTimeout || 30_000;
    this.debugEnabled = options.debug || false;

    this.iframe = document.createElement('iframe');
    this.iframe.src = options.iframeUrl;
    this.iframe.style.cssText = 'display:none;width:0;height:0;border:none;position:absolute;';

    const container = options.container || document.body;
    container.appendChild(this.iframe);

    this.ready = new Promise<void>((resolve, reject) => {
      const timer = setTimeout(() => {
        window.removeEventListener('message', onMessage);
        reject(new Error(
          `TlsnClient: iframe did not become ready within ${this.readyTimeout}ms. ` +
          `Check that ${options.iframeUrl} is reachable and serving the TLSN embed with COOP/COEP headers.`
        ));
      }, this.readyTimeout);

      const onMessage = (event: MessageEvent) => {
        if (event.source !== this.iframe.contentWindow) return;
        if (event.data?.type === 'ready') {
          clearTimeout(timer);
          window.removeEventListener('message', onMessage);
          resolve();
        }
      };
      window.addEventListener('message', onMessage);
    });

    window.addEventListener('message', this.handleMessage);
  }

  async waitReady(): Promise<void> { await this.ready; }

  destroy(): void {
    this.destroyed = true;
    window.removeEventListener('message', this.handleMessage);
    this.iframe.remove();
    for (const [, pending] of this.pendingCalls) {
      pending.reject(new Error('TlsnClient destroyed'));
    }
    this.pendingCalls.clear();
    this.listeners.clear();
  }

  on(event: string, callback: EventCallback): void {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
    this.listeners.get(event)!.add(callback);
  }

  off(event: string, callback: EventCallback): void {
    this.listeners.get(event)?.delete(callback);
  }

  onTransactionConfirm(handler: TxConfirmHandler): void {
    this.txConfirmHandler = handler;
  }

  async connect(rpcUrl: string): Promise<void> {
    await this.ready;
    await this.call('connect', { rpcUrl });
  }

  async connectWallet(mnemonic: string): Promise<string> {
    const result = await this.call<{ address: string }>('connectWallet', { mnemonic });
    return result.address;
  }

  async disconnect(): Promise<void> { await this.call('disconnect', {}); }

  async getAddress(): Promise<string> {
    const result = await this.call<{ address: string }>('getAddress', {});
    return result.address;
  }

  async getBalance(): Promise<string> {
    const result = await this.call<{ balance: string }>('getBalance', {});
    return result.balance;
  }

  async checkTlsNotary(): Promise<boolean> {
    const result = await this.call<{ enabled: boolean }>('checkTlsNotary', {});
    return result.enabled;
  }

  async generateMnemonic(): Promise<string> {
    const result = await this.call<{ mnemonic: string }>('generateMnemonic', {});
    return result.mnemonic;
  }

  async attest(request: AttestRequest, options?: AttestOptions): Promise<AttestResult> {
    return this.call<AttestResult>('attest', { request, options });
  }

  async verify(presentation: unknown): Promise<VerifyResult> {
    return this.call<VerifyResult>('verify', { presentation });
  }

  async previewTranscript(request: AttestRequest): Promise<TranscriptPreview> {
    return this.call<TranscriptPreview>('previewTranscript', { request });
  }

  async storeProof(tokenId: string, presentation: unknown): Promise<StoreResult> {
    return this.call<StoreResult>('storeProof', { tokenId, presentation });
  }

  private handleMessage = (event: MessageEvent): void => {
    if (event.source !== this.iframe.contentWindow) return;
    const msg = event.data;
    if (!msg || !msg.type) return;

    if (msg.type === 'result' && msg.id) {
      const pending = this.pendingCalls.get(msg.id);
      if (pending) { this.pendingCalls.delete(msg.id); pending.resolve(msg.data); }
      return;
    }

    if (msg.type === 'error' && msg.id) {
      const pending = this.pendingCalls.get(msg.id);
      if (pending) { this.pendingCalls.delete(msg.id); pending.reject(new Error(msg.message)); }
      return;
    }

    if (msg.type === 'event') {
      if (msg.event === 'tx:confirm') {
        this.handleTxConfirm(msg.id, msg.data);
        return;
      }
      const callbacks = this.listeners.get(msg.event);
      if (callbacks) {
        for (const cb of callbacks) {
          try { cb(msg.data); } catch (err) { console.error(`TlsnClient event error [${msg.event}]:`, err); }
        }
      }
    }
  };

  private async handleTxConfirm(confirmId: string, details: TransactionDetails): Promise<void> {
    let confirmed = true;
    if (this.txConfirmHandler) {
      try { confirmed = await this.txConfirmHandler(details); } catch { confirmed = false; }
    }
    this.iframe.contentWindow?.postMessage({ id: confirmId, type: 'txConfirmResponse', confirmed }, '*');
  }

  private call<T = unknown>(type: string, params: Record<string, unknown>): Promise<T> {
    if (this.destroyed) return Promise.reject(new Error('TlsnClient has been destroyed'));
    const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

    return new Promise<T>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pendingCalls.delete(id);
        reject(new Error(`TlsnClient call '${type}' timed out after ${this.timeout}ms`));
      }, this.timeout);

      this.pendingCalls.set(id, {
        resolve: (value: any) => { clearTimeout(timer); resolve(value); },
        reject: (error: Error) => { clearTimeout(timer); reject(error); },
      });

      this.iframe.contentWindow?.postMessage({ id, type, ...params }, '*');
    });
  }
}

Step 2: Use in Your App

import { TlsnClient } from './TlsnClient';

const TLSN_EMBED_URL = 'https://tlsn.demos.sh';
const RPC_URL = 'https://node2.demos.sh';

async function main() {
  // 1. Create client pointing to the hosted TLSN embed
  const tlsn = new TlsnClient({
    iframeUrl: TLSN_EMBED_URL,
    debug: true, // optional: logs postMessage traffic to console
  });

  // 2. Wait for the iframe to load and initialize
  await tlsn.waitReady();

  // 3. Connect to the Demos Network
  await tlsn.connect(RPC_URL);

  // 4. Connect a wallet (generate or provide a mnemonic)
  const mnemonic = await tlsn.generateMnemonic();
  const address = await tlsn.connectWallet(mnemonic);
  console.log('Wallet address:', address);

  // 5. Handle transaction confirmations
  tlsn.onTransactionConfirm(async (details) => {
    return window.confirm(`Confirm transaction: ${details.description}\nAmount: ${details.amount} DEM`);
  });

  // 6. Perform an attestation
  const result = await tlsn.attest({
    url: 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd',
    method: 'GET',
  });

  console.log('Server:', result.serverName);
  console.log('Response:', result.recv);
  console.log('Proof token:', result.tokenId);

  // 7. Verify the proof locally
  const verification = await tlsn.verify(result.presentation);
  console.log('Verified at:', new Date(verification.time * 1000));

  // 8. Clean up when done
  tlsn.destroy();
}

main().catch(console.error);
That’s it. No webpack configuration, no WASM polyfills, no worker setup.

Framework Examples

Vite + TypeScript

bun create vite my-tlsn-app --template vanilla-ts
cd my-tlsn-app
Copy TlsnClient.ts into src/, then use it in src/main.ts:
src/main.ts
import { TlsnClient } from './TlsnClient';

const tlsn = new TlsnClient({ iframeUrl: 'https://tlsn.demos.sh' });

document.querySelector<HTMLButtonElement>('#attest-btn')?.addEventListener('click', async () => {
  await tlsn.waitReady();
  await tlsn.connect('https://node2.demos.sh');

  const mnemonic = await tlsn.generateMnemonic();
  await tlsn.connectWallet(mnemonic);

  const result = await tlsn.attest({
    url: 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd',
    method: 'GET',
  });

  document.querySelector('#result')!.textContent = result.recv;
});

React (Vite or CRA)

src/hooks/useTlsn.ts
import { useEffect, useRef, useState } from 'react';
import { TlsnClient, type AttestResult } from '../TlsnClient';

export function useTlsn() {
  const clientRef = useRef<TlsnClient | null>(null);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const client = new TlsnClient({ iframeUrl: 'https://tlsn.demos.sh' });
    clientRef.current = client;

    client.waitReady().then(() => setReady(true));

    return () => client.destroy();
  }, []);

  const attest = async (url: string): Promise<AttestResult | null> => {
    const client = clientRef.current;
    if (!client) return null;

    await client.connect('https://node2.demos.sh');
    const mnemonic = await client.generateMnemonic();
    await client.connectWallet(mnemonic);

    client.onTransactionConfirm(async (details) => {
      return window.confirm(`${details.description}: ${details.amount} DEM`);
    });

    return client.attest({ url, method: 'GET' });
  };

  return { ready, attest };
}
src/App.tsx
import { useTlsn } from './hooks/useTlsn';

export function App() {
  const { ready, attest } = useTlsn();

  const handleAttest = async () => {
    const result = await attest('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd');
    if (result) {
      console.log('Attestation:', result.recv);
    }
  };

  return (
    <div>
      <button onClick={handleAttest} disabled={!ready}>
        {ready ? 'Attest Bitcoin Price' : 'Loading TLSN...'}
      </button>
    </div>
  );
}

Next.js

Since TlsnClient uses document and window, it must run client-side only:
app/components/TlsnAttest.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { TlsnClient } from '@/lib/TlsnClient';

export function TlsnAttest() {
  const clientRef = useRef<TlsnClient | null>(null);
  const [ready, setReady] = useState(false);
  const [result, setResult] = useState<string | null>(null);

  useEffect(() => {
    const client = new TlsnClient({ iframeUrl: 'https://tlsn.demos.sh' });
    clientRef.current = client;
    client.waitReady().then(() => setReady(true));
    return () => client.destroy();
  }, []);

  const handleAttest = async () => {
    const client = clientRef.current;
    if (!client) return;

    await client.connect('https://node2.demos.sh');
    const mnemonic = await client.generateMnemonic();
    await client.connectWallet(mnemonic);

    const attestResult = await client.attest({
      url: 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd',
      method: 'GET',
    });

    setResult(attestResult.recv);
  };

  return (
    <div>
      <button onClick={handleAttest} disabled={!ready}>Attest</button>
      {result && <pre>{result}</pre>}
    </div>
  );
}
Do not import TlsnClient in server components or getServerSideProps. It requires browser APIs (document, window, postMessage).

SvelteKit

src/lib/components/TlsnAttest.svelte
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import { TlsnClient } from '$lib/TlsnClient';

  let client: TlsnClient | null = null;
  let ready = false;
  let result = '';

  onMount(() => {
    client = new TlsnClient({ iframeUrl: 'https://tlsn.demos.sh' });
    client.waitReady().then(() => (ready = true));
  });

  onDestroy(() => client?.destroy());

  async function handleAttest() {
    if (!client) return;
    await client.connect('https://node2.demos.sh');
    const mnemonic = await client.generateMnemonic();
    await client.connectWallet(mnemonic);

    const attestResult = await client.attest({
      url: 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd',
      method: 'GET',
    });

    result = attestResult.recv;
  }
</script>

<button on:click={handleAttest} disabled={!ready}>
  {ready ? 'Attest Bitcoin Price' : 'Loading TLSN...'}
</button>

{#if result}
  <pre>{result}</pre>
{/if}

Vue / Nuxt

components/TlsnAttest.vue
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { TlsnClient } from '@/lib/TlsnClient';

const client = ref<TlsnClient | null>(null);
const ready = ref(false);
const result = ref('');

onMounted(() => {
  client.value = new TlsnClient({ iframeUrl: 'https://tlsn.demos.sh' });
  client.value.waitReady().then(() => (ready.value = true));
});

onUnmounted(() => client.value?.destroy());

async function handleAttest() {
  if (!client.value) return;
  await client.value.connect('https://node2.demos.sh');
  const mnemonic = await client.value.generateMnemonic();
  await client.value.connectWallet(mnemonic);

  const attestResult = await client.value.attest({
    url: 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd',
    method: 'GET',
  });

  result.value = attestResult.recv;
}
</script>

<template>
  <div>
    <button @click="handleAttest" :disabled="!ready">
      {{ ready ? 'Attest Bitcoin Price' : 'Loading TLSN...' }}
    </button>
    <pre v-if="result">{{ result }}</pre>
  </div>
</template>

TlsnClient API Reference

Constructor Options

OptionTypeDefaultDescription
iframeUrlstringrequiredURL of the hosted TLSN embed
containerHTMLElementdocument.bodyElement to append the hidden iframe to
timeoutnumber300000 (5 min)Timeout for RPC calls in ms
readyTimeoutnumber30000 (30s)Timeout for iframe initialization in ms
debugbooleanfalseLog all postMessage traffic to console

Methods

MethodReturnsDescription
waitReady()Promise<void>Wait for iframe to initialize
connect(rpcUrl)Promise<void>Connect to Demos Network node
connectWallet(mnemonic)Promise<string>Connect wallet, returns address
disconnect()Promise<void>Disconnect from network
generateMnemonic()Promise<string>Generate a new BIP39 mnemonic
getAddress()Promise<string>Get connected wallet address
getBalance()Promise<string>Get wallet balance in DEM
checkTlsNotary()Promise<boolean>Check if TLSNotary is enabled on the node
previewTranscript(request)Promise<TranscriptPreview>Preview request/response before attesting
attest(request, options?)Promise<AttestResult>Perform TLS attestation
verify(presentation)Promise<VerifyResult>Verify a proof locally
storeProof(tokenId, presentation)Promise<StoreResult>Store proof on-chain
destroy()voidRemove iframe, reject pending calls, clean up

Events

// Listen for status changes
tlsn.on('status', (status) => {
  console.log('Status:', status);
  // 'disconnected' | 'connecting' | 'connected' | 'ready' | 'attesting' | 'verifying' | 'error'
});

// Listen for log messages
tlsn.on('log', ({ message, level }) => {
  console.log(`[${level}] ${message}`);
});

// Handle transaction confirmations
tlsn.onTransactionConfirm(async (details) => {
  // details: { description, amount, targetUrl?, tokenId?, txHash }
  return window.confirm(`${details.description}\nCost: ${details.amount} DEM`);
});

Self-Hosting the TLSN Embed (Optional)

The public instance at https://tlsn.demos.sh is the easiest way to get started. If you need to host the embed yourself (for example, on a private network or for development), you can use Docker Compose.

Prerequisites

Clone the tlsn-component repository and build:
git clone https://github.com/kynesyslabs/tlsn-component.git
cd tlsn-component
bun install
bun run build:embed

Run with Docker Compose

docker-compose.yml
services:
  tlsn-embed:
    build: .
    ports:
      - "8443:8443"
    volumes:
      - ./build/embed:/srv:ro
    environment:
      - TLSN_ADDRESS=your_tlsn_self_hosted_address_such_as_http://localhost:8443
docker compose up -d
The embed will be available at http://localhost:8443. Caddy handles the required Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers automatically. Then point your client to the local instance:
const tlsn = new TlsnClient({
  iframeUrl: 'http://localhost:8443',
});
The COOP/COEP headers (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp) are required because TLSNotary uses SharedArrayBuffer for WASM threads. These headers are configured on the embed server, not on your app’s server. If you use the public https://tlsn.demos.sh instance, you don’t need to worry about this.

Troubleshooting

“iframe did not become ready within 30000ms”
  • Verify the iframe URL is reachable: open https://tlsn.demos.sh directly in a browser tab
  • Check your browser’s console for CORS or CSP errors
  • If self-hosting, ensure COOP/COEP headers are being served
“TlsnClient call timed out”
  • Attestation can take 2-5 seconds. The default timeout is 5 minutes.
  • Check network connectivity to the Demos RPC node
  • Enable debug: true in constructor options to see postMessage traffic
Blank page or hydration errors (Next.js / Nuxt)
  • TlsnClient uses browser APIs (document, window). Only import and instantiate it in client-side code.
  • In Next.js: use 'use client' directive or dynamic imports with { ssr: false }
  • In Nuxt: use <ClientOnly> wrapper or onMounted() lifecycle hook