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.

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,
  };
}

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 which 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

Edit this page

On this page