Build a TLSNotary Webapp
This tutorial walks you through building a complete browser-based TLSNotary attestation application from scratch. By the end, you’ll have a working webapp that can:- Connect to the Demos Network
- Request attestation tokens
- Perform TLS attestations on HTTPS endpoints
- Store cryptographic proofs on-chain
Prerequisites
- Node.js 18+ or Bun
- Basic knowledge of React and TypeScript
- A Demos wallet with some DEM tokens for fees
Project Setup
Step 1: Initialize Project
Copy
mkdir tlsnotary-app
cd tlsnotary-app
bun init -y
Step 2: Install Dependencies
The Demos SDK (
@kynesyslabs/demosdk) includes all TLSNotary WASM files and webpack helpers. You don’t need to install tlsn-js separately!Copy
bun add @kynesyslabs/demosdk react react-dom comlink
bun add -d webpack webpack-cli webpack-dev-server ts-loader \
html-webpack-plugin copy-webpack-plugin typescript \
@types/react @types/react-dom \
crypto-browserify stream-browserify vm-browserify buffer process
Step 3: Create Project Structure
Copy
tlsnotary-app/
├── src/
│ ├── app.tsx # Main React application
│ ├── worker.ts # Web Worker for WASM
│ ├── TLSNotaryClient.ts # WASM client wrapper
│ └── index.html # HTML template
├── webpack.config.cjs # Webpack configuration
├── tsconfig.json # TypeScript configuration
└── package.json
Configuration Files
tsconfig.json
Copy
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["DOM", "ES2020"]
},
"include": ["src/**/*"]
}
webpack.config.cjs
The cross-origin isolation headers are required for TLSNotary WASM to work. Without them,
SharedArrayBuffer won’t be available and the attestation will fail.Copy
const webpack = require('webpack');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
// Import TLSNotary webpack helpers from SDK - no need to install tlsn-js!
const {
getTlsnWebpackConfig,
getCrossOriginHeaders,
} = require('@kynesyslabs/demosdk/tlsnotary/webpack');
// Get the TLSNotary webpack configuration
// Output to dist root (.) because tlsn-js expects WASM files at the web root
const tlsnConfig = getTlsnWebpackConfig({
wasmOutputPath: '.', // Output to dist/ root
});
module.exports = {
mode: 'development',
entry: {
app: path.join(__dirname, 'src', 'app.tsx'),
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
clean: true,
publicPath: '/',
},
module: {
rules: [
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: ['ts-loader'],
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
fallback: {
// TLSNotary polyfills from SDK
...tlsnConfig.resolve.fallback,
// Additional polyfills for this app
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
vm: require.resolve('vm-browserify'),
// Stub out unused Node.js modules
constants: false,
readline: false,
},
},
plugins: [
// TLSNotary plugins (copies WASM files from SDK)
...tlsnConfig.plugins,
// App plugins
new HtmlWebpackPlugin({
template: path.join(__dirname, 'src', 'index.html'),
filename: 'index.html',
}),
// Provide Node.js globals for browser
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer'],
}),
],
experiments: {
// Enable async WASM loading
...tlsnConfig.experiments,
},
devServer: {
port: 3000,
host: 'localhost',
hot: true,
// Required for SharedArrayBuffer (WASM threads) - from SDK helper
headers: getCrossOriginHeaders(),
},
};
The
getTlsnWebpackConfig() helper automatically:- Configures CopyWebpackPlugin to copy WASM files from the SDK
- Sets up Node.js polyfills required by TLSNotary
- Enables async WebAssembly experiments
getCrossOriginHeaders() helper returns the required COOP/COEP headers for SharedArrayBuffer.package.json scripts
Add these scripts to yourpackage.json:
Copy
{
"scripts": {
"dev": "webpack serve --mode development",
"build": "webpack --mode production"
}
}
Source Files
src/index.html
Copy
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TLSNotary Attestation App</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
.card {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
input, button, textarea {
padding: 10px;
margin: 5px 0;
border-radius: 4px;
border: 1px solid #ddd;
}
button {
background: #3B82F6;
color: white;
border: none;
cursor: pointer;
}
button:hover { background: #2563EB; }
button:disabled { background: #ccc; cursor: not-allowed; }
.log {
background: #1a1a1a;
color: #00ff00;
padding: 15px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
max-height: 300px;
overflow-y: auto;
}
.error { color: #ff4444; }
.success { color: #44ff44; }
</style>
</head>
<body>
<div id="root"></div>
</body>
</html>
src/worker.ts
The Web Worker loads and exposes the TLSNotary WASM module. All imports come from the SDK:Copy
/**
* Web Worker for TLSNotary WASM
*
* Uses Comlink to expose the tlsn-js API to the main thread.
* The WASM runs in a worker to not block the UI.
*
* NOTE: We import from the SDK which re-exports tlsn-js classes.
* This means users don't need to install tlsn-js separately!
*/
import * as Comlink from 'comlink';
import {
init,
Prover,
Presentation,
NotaryServer,
Transcript,
} from '@kynesyslabs/demosdk/tlsnotary';
// Expose the API to the main thread
Comlink.expose({
init,
Prover,
Presentation,
NotaryServer,
Transcript,
});
src/TLSNotaryClient.ts
This wrapper handles the WASM initialization and provides a clean API. All types come from the SDK:Copy
import * as Comlink from 'comlink';
import {
Transcript,
type TLSNotaryConfig,
type AttestResult,
type AttestOptions,
type VerificationResult,
type ProxyRequestResponse,
type StatusCallback,
} from '@kynesyslabs/demosdk/tlsnotary';
import type { Prover as TProver, Presentation as TPresentation } from '@kynesyslabs/demosdk/tlsnotary';
// Create worker with Comlink
const worker: any = Comlink.wrap(
new Worker(new URL('./worker.ts', import.meta.url))
);
export class TLSNotaryClient {
private config: TLSNotaryConfig;
private initialized = false;
constructor(config: TLSNotaryConfig) {
this.config = {
loggingLevel: 'Info',
...config,
};
}
async initialize(): Promise<void> {
if (this.initialized) return;
await worker.init({ loggingLevel: this.config.loggingLevel });
this.initialized = true;
// Expose worker globally for direct Prover access
(window as any).__worker = worker;
}
async requestProxy(targetUrl: string): Promise<ProxyRequestResponse> {
if (!this.config.rpcUrl) {
throw new Error('No RPC URL configured for dynamic proxy requests.');
}
const response = await fetch(this.config.rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: 'nodeCall',
params: [{ message: 'requestTLSNproxy', data: { targetUrl } }],
}),
});
const result = await response.json();
if (result.result !== 200) {
throw new Error(`Failed to request proxy: ${result.response?.message || 'Unknown error'}`);
}
return result.response;
}
async verify(presentationJSON: any): Promise<VerificationResult> {
if (!this.initialized) {
throw new Error('TLSNotary not initialized. Call initialize() first.');
}
const proof = await new worker.Presentation(presentationJSON.data);
const verifierOutput = await proof.verify();
const transcript = new Transcript({
sent: verifierOutput.transcript?.sent || [],
recv: verifierOutput.transcript?.recv || [],
});
return {
time: verifierOutput.connection_info.time,
serverName: verifierOutput.server_name || 'Unknown',
sent: transcript.sent(),
recv: transcript.recv(),
notaryKey: 'N/A',
verifyingKey: 'N/A',
};
}
isInitialized(): boolean {
return this.initialized;
}
getConfig(): TLSNotaryConfig {
return { ...this.config };
}
}
src/app.tsx
The main React application with full TLSNotary integration:Copy
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { Demos } from '@kynesyslabs/demosdk/websdk';
import { TLSNotaryService } from '@kynesyslabs/demosdk/tlsnotary/service';
import { TLSNotaryClient } from './TLSNotaryClient';
// Global instances
let tlsnClient: TLSNotaryClient | null = null;
let demosInstance: Demos | null = null;
let tlsnService: TLSNotaryService | null = null;
function App() {
// State
const [logs, setLogs] = useState<Array<{ msg: string; type: string }>>([]);
const [wasmReady, setWasmReady] = useState(false);
const [walletConnected, setWalletConnected] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [tokenId, setTokenId] = useState('');
const [wsProxyUrl, setWsProxyUrl] = useState('');
const [result, setResult] = useState<any>(null);
// Configuration
const [notaryUrl, setNotaryUrl] = useState('http://localhost:7047');
const [rpcUrl, setRpcUrl] = useState('http://localhost:53550');
const [mnemonic, setMnemonic] = useState('');
const [targetUrl, setTargetUrl] = useState('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd');
const [method, setMethod] = useState('GET');
const [maxRecv, setMaxRecv] = useState(4096);
const logsEndRef = useRef<HTMLDivElement>(null);
// Logger
const log = useCallback((msg: string, type: string = 'info') => {
const timestamp = new Date().toLocaleTimeString();
setLogs(prev => [...prev, { msg: `[${timestamp}] ${msg}`, type }]);
}, []);
// Auto-scroll logs
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
// Initialize WASM on mount
useEffect(() => {
const init = async () => {
try {
log('Initializing TLSNotary WASM...', 'info');
tlsnClient = new TLSNotaryClient({ notaryUrl, rpcUrl });
await tlsnClient.initialize();
setWasmReady(true);
log('WASM initialized successfully!', 'success');
} catch (error: any) {
log(`WASM init failed: ${error.message}`, 'error');
}
};
init();
}, []);
// Connect wallet
const handleConnectWallet = useCallback(async () => {
if (!mnemonic.trim()) {
log('Please enter a mnemonic', 'error');
return;
}
setIsLoading(true);
try {
log('Connecting to Demos Network...', 'info');
demosInstance = new Demos();
await demosInstance.connect(rpcUrl);
log('Connected to node', 'success');
// For development: direct mnemonic
await demosInstance.connectWallet(mnemonic.trim());
// For production: use Wallet Extension instead
// await demosInstance.connectWalletExtension();
tlsnService = new TLSNotaryService(demosInstance);
setWalletConnected(true);
log('Wallet connected!', 'success');
} catch (error: any) {
log(`Wallet connection failed: ${error.message}`, 'error');
} finally {
setIsLoading(false);
}
}, [mnemonic, rpcUrl, log]);
// Request attestation token
const handleRequestToken = useCallback(async () => {
if (!tlsnService) {
log('Please connect wallet first', 'error');
return;
}
setIsLoading(true);
try {
log('Requesting attestation token (burns 1 DEM)...', 'info');
const response = await tlsnService.requestAttestation({
targetUrl: targetUrl,
});
setTokenId(response.tokenId);
setWsProxyUrl(response.proxyUrl);
log(`Token ID: ${response.tokenId}`, 'success');
log(`Proxy URL: ${response.proxyUrl}`, 'info');
log(`Expires: ${new Date(response.expiresAt).toLocaleTimeString()}`, 'info');
} catch (error: any) {
log(`Token request failed: ${error.message}`, 'error');
} finally {
setIsLoading(false);
}
}, [targetUrl, log]);
// Perform attestation
const handleAttest = useCallback(async () => {
if (!wsProxyUrl) {
log('Please request a token first', 'error');
return;
}
setIsLoading(true);
try {
log('Starting TLS attestation...', 'info');
const notarizeConfig = {
notaryUrl: notaryUrl,
websocketProxyUrl: wsProxyUrl,
maxSentData: 16384,
maxRecvData: maxRecv,
url: targetUrl,
method: method as 'GET' | 'POST',
headers: { Accept: 'application/json' },
commit: {
sent: [{ start: 0, end: 100 }],
recv: [{ start: 0, end: Math.min(200, maxRecv) }],
},
serverIdentity: true,
};
// Use the WASM worker directly
const presentationJSON = await (window as any).__worker.Prover.notarize(notarizeConfig);
log('Attestation complete!', 'success');
// Verify locally
log('Verifying proof...', 'info');
const verification = await tlsnClient?.verify(presentationJSON);
log('Verification passed!', 'success');
setResult({ presentation: presentationJSON, verification });
} catch (error: any) {
log(`Attestation failed: ${error.message}`, 'error');
} finally {
setIsLoading(false);
}
}, [wsProxyUrl, targetUrl, notaryUrl, method, maxRecv, log]);
// Full attestation (request token + attest in one click)
const handleFullAttestation = useCallback(async () => {
if (!tlsnService) {
log('Please connect wallet first', 'error');
return;
}
setIsLoading(true);
try {
// Step 1: Request token
log('Requesting attestation token...', 'info');
const tokenResponse = await tlsnService.requestAttestation({
targetUrl: targetUrl,
});
setTokenId(tokenResponse.tokenId);
setWsProxyUrl(tokenResponse.proxyUrl);
log(`Token received: ${tokenResponse.tokenId}`, 'success');
// Step 2: Perform attestation
log('Starting TLS attestation...', 'info');
const notarizeConfig = {
notaryUrl: notaryUrl,
websocketProxyUrl: tokenResponse.proxyUrl,
maxSentData: 16384,
maxRecvData: maxRecv,
url: targetUrl,
method: method as 'GET' | 'POST',
headers: { Accept: 'application/json' },
commit: {
sent: [{ start: 0, end: 100 }],
recv: [{ start: 0, end: Math.min(200, maxRecv) }],
},
serverIdentity: true,
};
const presentationJSON = await (window as any).__worker.Prover.notarize(notarizeConfig);
log('Attestation complete!', 'success');
// Step 3: Verify
const verification = await tlsnClient?.verify(presentationJSON);
log('Verification passed!', 'success');
setResult({ presentation: presentationJSON, verification });
} catch (error: any) {
log(`Full attestation failed: ${error.message}`, 'error');
} finally {
setIsLoading(false);
}
}, [targetUrl, notaryUrl, method, maxRecv, log]);
// Store proof on-chain
const handleStoreProof = useCallback(async (storageType: 'onchain' | 'ipfs') => {
if (!result || !tlsnService || !tokenId) {
log('Missing result, service, or token ID', 'error');
return;
}
setIsLoading(true);
try {
const proofString = typeof result.presentation === 'string'
? result.presentation
: JSON.stringify(result.presentation);
const proofSizeKB = Math.ceil(proofString.length / 1024);
const estimatedFee = tlsnService.calculateStorageFee(proofSizeKB);
log(`Storing proof (${proofSizeKB} KB, ~${estimatedFee} DEM fee)...`, 'info');
const storeResult = await tlsnService.storeProof(
tokenId,
proofString,
{ storage: storageType }
);
log(`Proof stored on ${storageType}!`, 'success');
log(`Transaction: ${storeResult.txHash}`, 'info');
log(`Fee: ${storeResult.storageFee} DEM`, 'info');
} catch (error: any) {
log(`Store failed: ${error.message}`, 'error');
} finally {
setIsLoading(false);
}
}, [result, tokenId, log]);
return (
<div>
<h1>TLSNotary Attestation App</h1>
{/* Configuration */}
<div className="card">
<h3>Configuration</h3>
<div>
<label>Notary URL:</label>
<input
type="text"
value={notaryUrl}
onChange={(e) => setNotaryUrl(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div>
<label>RPC URL:</label>
<input
type="text"
value={rpcUrl}
onChange={(e) => setRpcUrl(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<p>WASM Status: {wasmReady ? '✅ Ready' : '⏳ Loading...'}</p>
</div>
{/* Wallet Connection */}
<div className="card">
<h3>Wallet Connection</h3>
<p style={{ fontSize: '12px', color: '#666' }}>
For production apps, use the Demos Wallet Extension instead of entering a mnemonic directly.
</p>
<textarea
placeholder="Enter your mnemonic (for testing only)"
value={mnemonic}
onChange={(e) => setMnemonic(e.target.value)}
rows={2}
style={{ width: '100%' }}
/>
<button
onClick={handleConnectWallet}
disabled={!wasmReady || isLoading || walletConnected}
>
{walletConnected ? '✅ Wallet Connected' : 'Connect Wallet'}
</button>
</div>
{/* Attestation */}
<div className="card">
<h3>Attestation</h3>
<div>
<label>Target URL:</label>
<input
type="text"
value={targetUrl}
onChange={(e) => setTargetUrl(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', gap: '10px', marginTop: '10px' }}>
<select value={method} onChange={(e) => setMethod(e.target.value)}>
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
<input
type="number"
value={maxRecv}
onChange={(e) => setMaxRecv(Number(e.target.value))}
placeholder="Max receive bytes"
style={{ width: '150px' }}
/>
</div>
<div style={{ marginTop: '10px' }}>
<button
onClick={handleFullAttestation}
disabled={!walletConnected || isLoading}
style={{ marginRight: '10px' }}
>
{isLoading ? 'Processing...' : 'Full Attestation'}
</button>
<button
onClick={handleRequestToken}
disabled={!walletConnected || isLoading}
style={{ marginRight: '10px', background: '#6366F1' }}
>
1. Request Token
</button>
<button
onClick={handleAttest}
disabled={!wsProxyUrl || isLoading}
style={{ background: '#10B981' }}
>
2. Attest
</button>
</div>
{tokenId && <p>Token ID: <code>{tokenId}</code></p>}
</div>
{/* Result */}
{result && (
<div className="card">
<h3>Attestation Result</h3>
<p>✅ Proof generated and verified!</p>
<p>Sent data: {result.verification?.sent?.substring(0, 100)}...</p>
<p>Received data: {result.verification?.recv?.substring(0, 100)}...</p>
<div style={{ marginTop: '10px' }}>
<button
onClick={() => handleStoreProof('onchain')}
disabled={isLoading}
style={{ marginRight: '10px', background: '#F59E0B' }}
>
Store On-Chain
</button>
<button
onClick={() => handleStoreProof('ipfs')}
disabled={isLoading}
style={{ background: '#8B5CF6' }}
>
Store on IPFS
</button>
</div>
</div>
)}
{/* Logs */}
<div className="card">
<h3>Logs</h3>
<div className="log">
{logs.map((l, i) => (
<div key={i} className={l.type}>{l.msg}</div>
))}
<div ref={logsEndRef} />
</div>
<button onClick={() => setLogs([])} style={{ marginTop: '10px', background: '#666' }}>
Clear Logs
</button>
</div>
</div>
);
}
// Render
const root = createRoot(document.getElementById('root')!);
root.render(<App />);
Running the Application
Copy
bun run dev
Usage Flow
1
Initialize WASM
The app automatically initializes the TLSNotary WASM module on load.
2
Connect Wallet
Enter your mnemonic and click “Connect Wallet” to connect to the Demos Network.
3
Configure Target
Enter the HTTPS URL you want to attest. Only HTTPS URLs are supported.
4
Perform Attestation
Click “Full Attestation” to request a token and perform the attestation in one step.
This burns 1 DEM for the token request.
5
Store Proof (Optional)
After attestation, click “Store On-Chain” or “Store on IPFS” to persist the proof.
This burns 1 DEM base + 1 DEM per KB of proof data.
Production Considerations
Wallet Connection
For production applications, use the Demos Wallet Extension instead of direct mnemonic input:Copy
// Replace this:
await demosInstance.connectWallet(mnemonic);
// With this:
await demosInstance.connectWalletExtension();
Cross-Origin Isolation in Production
For production deployments, configure your web server to send the required headers:- nginx
- Express.js
- Apache
Copy
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
Copy
app.use((req, res, next) => {
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
next();
});
Copy
Header set Cross-Origin-Embedder-Policy "require-corp"
Header set Cross-Origin-Opener-Policy "same-origin"
Commit Ranges
Thecommit field in the notarization config specifies which parts of the request/response to include in the proof:
Copy
commit: {
sent: [{ start: 0, end: 100 }], // Reveal first 100 bytes of request
recv: [{ start: 0, end: 200 }], // Reveal first 200 bytes of response
}
Larger ranges result in larger proofs, which increase storage costs. Only include the data you need to prove.
Error Handling
Always implement proper error handling for production:Copy
try {
const response = await tlsnService.requestAttestation({ targetUrl });
} catch (error: any) {
if (error.message.includes('HTTPS')) {
// URL must be HTTPS
} else if (error.message.includes('Token not created')) {
// Token creation timed out - may need to retry
} else if (error.message.includes('balance')) {
// Insufficient DEM balance
}
}
Fee Summary
| Operation | Cost |
|---|---|
| Request attestation token | 1 DEM |
| Store proof (base fee) | 1 DEM |
| Store proof (per KB) | +1 DEM per KB |
- Token request: 1 DEM
- Storage: 1 (base) + 5 (size) = 6 DEM
- Total: 7 DEM + gas fees
Next Steps
- Learn about selective redaction to hide sensitive data in proofs
- Explore use cases for TLSNotary attestations
- Review the full API reference