Lumera Cascade
Guides

Build a Browser App

Step-by-step guide to building a browser-based application with Cascade permanent storage and Keplr wallet.

What You Will Build

A Vite + TypeScript single-page application that connects to Keplr wallet, uploads files to Cascade, and retrieves them by action ID. This is the recommended approach — the browser environment has full SDK compatibility.

Why browser-first? The Lumera JS SDK works reliably in browser environments. Node.js support has known limitations with WASM module loading. For production applications, use a browser-based frontend that communicates with Cascade directly.

Prerequisites

Project Setup

npm create vite@latest my-cascade-app -- --template vanilla-ts
cd my-cascade-app
npm install @lumera-protocol/sdk-js @cosmjs/proto-signing @cosmjs/stargate
npm install -D vite-plugin-node-polyfills vite-plugin-wasm vite-plugin-top-level-await

Configure Vite

The SDK depends on WASM modules and Node.js globals that need polyfilling in the browser:

vite.config.ts
import { defineConfig } from "vite";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
 
export default defineConfig({
  plugins: [
    wasm(),
    topLevelAwait(),
    nodePolyfills({
      include: ["buffer", "process"],
      globals: { Buffer: true, global: true, process: true },
    }),
  ],
  optimizeDeps: {
    exclude: ["undici", "@bokuweb/zstd-wasm"],
  },
  define: {
    "process.env": "{}",
  },
});

Chain Configuration

src/config.ts
export const CHAIN_ID = "lumera-testnet-2";
export const GAS_PRICE = "0.025ulume";
export const CHAIN_INFO = {
  chainId: CHAIN_ID,
  chainName: "Lumera Testnet",
  rpc: "https://rpc.testnet.lumera.io",
  rest: "https://lcd.testnet.lumera.io",
  bip44: { coinType: 118 },
  bech32Config: {
    bech32PrefixAccAddr: "lumera",
    bech32PrefixAccPub: "lumerapub",
    bech32PrefixValAddr: "lumeravaloper",
    bech32PrefixValPub: "lumeravaloperpub",
    bech32PrefixConsAddr: "lumeravalcons",
    bech32PrefixConsPub: "lumeravalconspub",
  },
  currencies: [
    { coinDenom: "LUME", coinMinimalDenom: "ulume", coinDecimals: 6 },
  ],
  feeCurrencies: [
    {
      coinDenom: "LUME",
      coinMinimalDenom: "ulume",
      coinDecimals: 6,
      gasPriceStep: { low: 0.025, average: 0.03, high: 0.04 },
    },
  ],
  stakeCurrency: {
    coinDenom: "LUME",
    coinMinimalDenom: "ulume",
    coinDecimals: 6,
  },
};

Wallet Connection

src/wallet.ts
import { CHAIN_ID, CHAIN_INFO } from "./config";
 
export async function connectKeplr(): Promise<{
  address: string;
  signer: any;
}> {
  if (!window.keplr) {
    throw new Error("Keplr extension not found. Please install Keplr.");
  }
 
  // Suggest the Lumera chain to Keplr
  await window.keplr.experimentalSuggestChain(CHAIN_INFO);
  await window.keplr.enable(CHAIN_ID);
 
  const offlineSigner = await window.keplr.getOfflineSignerAuto(CHAIN_ID);
  const accounts = await offlineSigner.getAccounts();
 
  return {
    address: accounts[0].address,
    signer: offlineSigner,
  };
}

The SDK requires a signer that supports signArbitrary (ADR-036) for authenticating Cascade operations. Keplr's getOfflineSignerAuto returns a signer that supports this. If you use getKeplrSigner from the SDK, it wraps this with the correct interface.

Cascade Client

src/cascade.ts
import { createLumeraClient, getKeplrSigner } from "@lumera-protocol/sdk-js";
import { CHAIN_ID, GAS_PRICE } from "./config";
 
let client: Awaited<ReturnType<typeof createLumeraClient>> | null = null;
 
export async function initCascade(address: string) {
  const signer = await getKeplrSigner(CHAIN_ID);
 
  client = await createLumeraClient({
    preset: "testnet",
    signer,
    address,
    gasPrice: GAS_PRICE,
  });
 
  return client;
}
 
export async function uploadFile(
  file: File,
  onProgress?: (status: string) => void
): Promise<string> {
  if (!client) throw new Error("Client not initialized");
 
  onProgress?.("Reading file...");
  const fileBytes = new Uint8Array(await file.arrayBuffer());
 
  onProgress?.("Registering action on-chain...");
  const result = await client.Cascade.uploader.uploadFile(fileBytes, {
    fileName: file.name,
    isPublic: true,
    taskOptions: {
      pollInterval: 2000,
      timeout: 300000,
    },
  });
 
  const actionId =
    (result as any).action_id || result.taskId || `file-${Date.now()}`;
 
  onProgress?.(`Upload complete! Action ID: ${actionId}`);
  return actionId;
}
 
export async function downloadFile(actionId: string): Promise<Uint8Array> {
  if (!client) throw new Error("Client not initialized");
 
  const stream = await client.Cascade.downloader.download(actionId);
  const reader = stream.getReader();
  const chunks: Uint8Array[] = [];
 
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    if (value) chunks.push(value);
  }
 
  // Reassemble chunks
  const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
  const result = new Uint8Array(totalLength);
  let offset = 0;
  for (const chunk of chunks) {
    result.set(chunk, offset);
    offset += chunk.length;
  }
 
  return result;
}

Putting It Together

src/main.ts
import { connectKeplr } from "./wallet";
import { initCascade, uploadFile, downloadFile } from "./cascade";
 
const connectBtn = document.getElementById("connect") as HTMLButtonElement;
const uploadBtn = document.getElementById("upload") as HTMLButtonElement;
const downloadBtn = document.getElementById("download") as HTMLButtonElement;
const fileInput = document.getElementById("file") as HTMLInputElement;
const actionIdInput = document.getElementById("actionId") as HTMLInputElement;
const status = document.getElementById("status") as HTMLDivElement;
 
let address: string | null = null;
 
connectBtn.addEventListener("click", async () => {
  try {
    const wallet = await connectKeplr();
    address = wallet.address;
    await initCascade(address);
    status.textContent = `Connected: ${address}`;
    uploadBtn.disabled = false;
    downloadBtn.disabled = false;
  } catch (err: any) {
    status.textContent = `Error: ${err.message}`;
  }
});
 
uploadBtn.addEventListener("click", async () => {
  const file = fileInput.files?.[0];
  if (!file) {
    status.textContent = "Please select a file first.";
    return;
  }
 
  try {
    const actionId = await uploadFile(file, (s) => {
      status.textContent = s;
    });
    actionIdInput.value = actionId;
    status.textContent = `File stored permanently! Action ID: ${actionId}`;
  } catch (err: any) {
    status.textContent = `Upload error: ${err.message}`;
  }
});
 
downloadBtn.addEventListener("click", async () => {
  const actionId = actionIdInput.value.trim();
  if (!actionId) {
    status.textContent = "Enter an action ID to download.";
    return;
  }
 
  try {
    status.textContent = "Downloading...";
    const data = await downloadFile(actionId);
    status.textContent = `Downloaded ${data.length} bytes.`;
 
    // Trigger browser download
    const blob = new Blob([data]);
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "cascade-file";
    a.click();
    URL.revokeObjectURL(url);
  } catch (err: any) {
    status.textContent = `Download error: ${err.message}`;
  }
});
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Cascade File Storage</title>
    <style>
      body { font-family: system-ui; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
      button { padding: 0.5rem 1rem; margin: 0.25rem; cursor: pointer; }
      input { padding: 0.5rem; margin: 0.25rem; width: 100%; box-sizing: border-box; }
      #status { margin-top: 1rem; padding: 1rem; background: #f5f5f5; border-radius: 0.5rem; }
    </style>
  </head>
  <body>
    <h1>Cascade File Storage</h1>
    <button id="connect">Connect Keplr</button>
    <hr />
    <h2>Upload</h2>
    <input type="file" id="file" />
    <button id="upload" disabled>Upload to Cascade</button>
    <hr />
    <h2>Download</h2>
    <input type="text" id="actionId" placeholder="Enter action ID" />
    <button id="download" disabled>Download from Cascade</button>
    <div id="status">Not connected</div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Run the App

npm run dev

Open http://localhost:5173, connect Keplr, and try uploading a file. The returned action ID is your permanent reference — use it to retrieve the file from anywhere, at any time.

Key Takeaways

  1. Always use getKeplrSigner (or getLeapSigner) from the SDK — these wrap the wallet extension's signer with ADR-036 signArbitrary support that Cascade requires.
  2. The action ID is your permanent reference. Store it in your database, on-chain, or wherever your application tracks state.
  3. Uploads are two-phase: first an on-chain transaction (fees, metadata), then a file transfer to SN-API. The SDK handles both.
  4. Downloads are streaming: use ReadableStream to handle large files without loading everything into memory at once.

Next Steps

On this page