Cascade

Editing a Universal Profile

End-to-end tutorial for updating LSP-3 profile metadata with images, links, tags, and 3D avatars stored permanently on Cascade.

What you will build

A small web flow that, given a connected Universal Profile, uploads a profile image at multiple resolutions, an optional background image, an optional 3D avatar, plus the LSP-3 JSON itself to Cascade, encodes a hash-bound LSP-2 VerifiableURI, and writes it to the user's UP. Once the transaction confirms, the profile renders correctly on universalprofile.cloud, universaleverything.io, and the Universal Profile Browser Extension, with no reader-side awareness that Cascade is involved.

The reference implementation in this guide mirrors permanent-up-app one-to-one. If you want to read the working code instead of recreating it from snippets, clone the repo and follow its README.

Prerequisites

  • A Universal Profile on Lukso mainnet or testnet. If you don't have one, install the Universal Profile Browser Extension and create one.
  • A cascade-api bearer token. The public deployment lives at https://api.lumera.help; request a key from the operator. To self-host, follow the cascade-api repo.
  • Node.js 22 or newer.

Install

npm install @erc725/erc725.js @lumera-protocol/data-provider-cascade viem

You do not need @lumera-protocol/sdk-js on this side: @lumera-protocol/data-provider-cascade posts to a cascade-api backend that holds the Lumera key for you.

Architecture

browser

  ├─ viem + window.lukso ────────── setData on UP ──→ Lukso L1

  └─ POST /api/upload (Next.js Route Handler)

            └─ @lumera-protocol/data-provider-cascade
                    │   Authorization: Bearer $CASCADE_API_TOKEN

                    └─ POST {CASCADE_API_URL}/upload (cascade-api)

                              ├─ MsgRequestAction → Lumera L1 (pays ulume)
                              └─ stream bytes → Cascade Supernodes (RaptorQ)

Two halves: the Lukso half (browser ↔ Lukso L1, signed by the UP browser extension) and the Cascade half (browser → your server → cascade-api). The two halves only meet in setData, where you write the cascade-api URL into LSP-3.

The cascade-api bearer token is sensitive. Keep it server-side: a Next.js Route Handler is the simplest place. Never expose it as a NEXT_PUBLIC_* variable in production, unless you've intentionally allocated a quota-limited key for browser-direct upload.

Step 1: Build the LSP-3 JSON

LSP-3 is a fixed shape. The schema lives in @erc725/erc725.js/schemas/LSP3ProfileMetadata.json.

lib/lsp3.ts
type ImageEntry = {
  width: number;
  height: number;
  url: string;
  verification?: { method: "keccak256(bytes)"; data: `0x${string}` };
};
 
type AvatarEntry = {
  hashFunction: "keccak256(bytes)";
  hash: `0x${string}`;
  url: string;
  fileType: string;
};
 
export type LSP3ProfileJSON = {
  LSP3Profile: {
    name: string;
    description: string;
    links: { title: string; url: string }[];
    tags: string[];
    profileImage: ImageEntry[];
    backgroundImage: ImageEntry[];
    avatar: AvatarEntry[];
  };
};

Notice the two different verification shapes:

  • profileImage[] and backgroundImage[] use the modern verification: { method, data } form.
  • avatar[] uses the legacy { hashFunction, hash, fileType } form.

Both are spec-conformant. erc725.js accepts either.

Step 2: Resize images client-side

A typical reader (e.g. universalprofile.cloud) wants to render the profile image at multiple sizes: thumbnail in chat lists, medium in profile cards, large on the profile page. LSP-3 supports this natively as an array.

lib/resize.ts
export async function resizeImage(
  file: File,
  sizes: number[],
  mime: "image/jpeg" | "image/png" = "image/jpeg",
): Promise<{ width: number; height: number; blob: Blob }[]> {
  const bitmap = await createImageBitmap(file);
  const minSrc = Math.min(bitmap.width, bitmap.height);
  const sx = (bitmap.width - minSrc) / 2;
  const sy = (bitmap.height - minSrc) / 2;
 
  const out: { width: number; height: number; blob: Blob }[] = [];
  for (const size of sizes) {
    if (size > minSrc) continue; // do not upscale
    const canvas = document.createElement("canvas");
    canvas.width = canvas.height = size;
    const ctx = canvas.getContext("2d")!;
    ctx.imageSmoothingQuality = "high";
    ctx.drawImage(bitmap, sx, sy, minSrc, minSrc, 0, 0, size, size);
    const blob = await new Promise<Blob>((resolve) =>
      canvas.toBlob((b) => resolve(b!), mime, 0.92),
    );
    out.push({ width: size, height: size, blob });
  }
  // Always include source resolution if it's not redundant with a target.
  if (!sizes.some((s) => Math.abs(s - minSrc) <= 32)) {
    out.push({ width: minSrc, height: minSrc, blob: await sourceCenterCrop(bitmap, sx, sy, minSrc, mime) });
  }
  return out;
}

The detail to internalise: always include the source resolution as a final variant (unless it would be redundant with a target). Otherwise users uploading a 700×700 photo with targets [256, 512, 1024] get only one variant, which is a bad surprise.

Step 3: Upload to Cascade through your Route Handler

Server-side: a thin proxy that owns the bearer token and calls @lumera-protocol/data-provider-cascade.

app/api/upload/route.ts
import { NextResponse } from "next/server";
import { CascadeUploader } from "@lumera-protocol/data-provider-cascade";
 
export const runtime = "nodejs";
export const maxDuration = 120; // cascade upload is 30-60s typical
 
export async function POST(req: Request): Promise<Response> {
  const uploader = new CascadeUploader({
    backendUrl: process.env.CASCADE_API_URL,    // default: https://api.lumera.help
    bearerToken: process.env.CASCADE_API_TOKEN, // required
  });
 
  const form = await req.formData();
  const file = form.get("file");
  if (!(file instanceof File)) {
    return NextResponse.json({ error: "Missing 'file' field" }, { status: 400 });
  }
 
  const { url, hash, meta } = await uploader.uploadWithMetadata(file);
  // url:  https://api.lumera.help/download/<action_id>
  // hash: 0x<keccak256 of file bytes>
  // meta: { action_id, tx_hash, block_height, task_id, filename, size_bytes }
  return NextResponse.json({ url, hash, meta });
}

uploadWithMetadata() is a Lumera-specific extension that returns the cascade-api raw response (tx_hash, block_height, action_id, etc.) on top of the standard { url, hash } shape. If you only need the standard shape (for instance, you're swapping out an existing @lukso/data-provider-pinata call), use uploader.upload(file) instead and get back { url, hash }.

On Vercel's Hobby plan the request body is capped at 4.5 MB. Most images fit, but VRM avatars are typically 5-30 MB. Production deployments either use a Vercel paid tier (100 MB on Pro, 1 GB on Enterprise), self-host, or skip the proxy and let the browser POST to cascade-api directly with a quota-limited bearer token.

Step 4: Encode the LSP-2 VerifiableURI

erc725.js knows the byte layout. Pass the JSON object and its URL; it computes the hash and stitches the bytes together.

lib/encode.ts
import { ERC725 } from "@erc725/erc725.js";
import LSP3ProfileSchema from "@erc725/erc725.js/schemas/LSP3ProfileMetadata.json" with { type: "json" };
 
export function encodeLSP3Profile(
  profileAddress: `0x${string}`,
  json: object,
  url: string,
): { key: `0x${string}`; value: `0x${string}` } {
  const erc725 = new ERC725(
    LSP3ProfileSchema,
    profileAddress,
    process.env.NEXT_PUBLIC_LUKSO_RPC!,
  );
  const encoded = erc725.encodeData([
    { keyName: "LSP3Profile", value: { json, url } },
  ]);
  return {
    key: encoded.keys[0] as `0x${string}`,
    value: encoded.values[0] as `0x${string}`,
  };
}

Behind the scenes, erc725.js produces:

  • keys[0] = 0x5ef83ad9559033e6e941db7d7c495acdce616347d28e90c7ce47cbfcfcad3bc5 (the LSP3Profile data key, a constant)
  • values[0] = 0x00006f357c6a0020<keccak256(JSON.stringify(json))><url-bytes>

The 0x6f357c6a is the keccak256(utf8) method ID. The 0x0020 is the hash length (32 bytes).

Step 5: setData on the Universal Profile

lib/wallet.ts
import { createWalletClient, custom, parseAbi } from "viem";
import { lukso } from "viem/chains";
 
const UP_ABI = parseAbi([
  "function setData(bytes32 dataKey, bytes dataValue) external payable",
]);
 
export async function setUPData(
  upAddress: `0x${string}`,
  key: `0x${string}`,
  value: `0x${string}`,
): Promise<`0x${string}`> {
  const client = createWalletClient({
    chain: lukso,
    transport: custom(window.lukso!), // UP browser extension
  });
  return client.writeContract({
    account: upAddress, // the UP itself; the extension routes through the controller
    address: upAddress,
    abi: UP_ABI,
    functionName: "setData",
    args: [key, value],
  });
}

The Universal Profile Browser Extension exposes the UP address as the connected account. When you writeContract with account: upAddress, the extension automatically routes the transaction through the controller key (an EOA the user holds) into the UP. You do not interact with LSP6KeyManager directly.

Putting it together

app/page.tsx (excerpt)
async function onSubmit() {
  // 1) Profile image: resize variants + always include source resolution.
  const profileImage = [];
  if (profileFile) {
    const variants = await resizeImage(profileFile, [256, 512, 1024]);
    for (const v of variants) {
      const r = await uploadToCascade(v.blob, `profile-${v.width}.jpg`);
      profileImage.push({
        width: v.width,
        height: v.height,
        url: r.url,
        verification: { method: "keccak256(bytes)", data: r.hash },
      });
    }
  }
 
  // 2) Background image (uploaded as-is).
  const backgroundImage = [];
  if (backgroundFile) {
    const r = await uploadToCascade(backgroundFile, backgroundFile.name);
    backgroundImage.push({
      width: backgroundDims.w,
      height: backgroundDims.h,
      url: r.url,
      verification: { method: "keccak256(bytes)", data: r.hash },
    });
  }
 
  // 3) Avatar (3D model or image fallback).
  const avatar = [];
  if (avatarFile) {
    const r = await uploadToCascade(avatarFile, avatarFile.name);
    avatar.push({
      hashFunction: "keccak256(bytes)",
      hash: r.hash,
      url: r.url,
      fileType: detectFileType(avatarFile), // "vrm", "glb", "png", ...
    });
  }
 
  // 4) Build LSP-3 JSON, upload it to Cascade.
  const profile = buildProfile({ name, description, links, tags, profileImage, backgroundImage, avatar });
  const jsonResult = await uploadJSONToCascade(profile, `${account}-lsp3.json`);
 
  // 5) Encode VerifiableURI and setData on the UP.
  const { key, value } = encodeLSP3Profile(account, profile, jsonResult.url);
  const tx = await setUPData(account, key, value);
  console.log("setData tx:", tx);
}

For a 1024×1024 source image with a background and a VRM avatar, that's 5 to 6 Cascade uploads per save (3 profile-image variants + 1 background + 1 avatar + 1 JSON), each one its own on-chain action on Lumera, each one billed once and stored forever.

How retrieval works

Reading is the inverse, and crucially, standard Lukso clients don't need a Cascade-aware reader. Any HTTPS-fetching reader works against the cascade-api gateway URL.

lib/read.ts
import { ERC725 } from "@erc725/erc725.js";
import LSP3ProfileSchema from "@erc725/erc725.js/schemas/LSP3ProfileMetadata.json" with { type: "json" };
 
const erc725 = new ERC725(
  LSP3ProfileSchema,
  profileAddress,
  "https://rpc.mainnet.lukso.network",
);
 
const data = await erc725.fetchData("LSP3Profile");
console.log(data.value);
// {
//   LSP3Profile: {
//     name: "Alice",
//     profileImage: [
//       { width: 256, height: 256, url: "https://api.lumera.help/download/15823", verification: { ... } },
//       { width: 512, height: 512, url: "https://api.lumera.help/download/15824", verification: { ... } },
//       ...
//     ],
//     ...
//   }
// }

fetchData does four things in one call: pulls the on-chain bytes, decodes the VerifiableURI, fetches the JSON via HTTPS, and verifies the keccak256 hash matches the on-chain commitment. If the hash doesn't match, it throws. Per-image verification is the caller's responsibility.

Lessons from the reference implementation

  1. Multi-resolution profile images are a meaningful Cascade load. A single profile-image upload can be 3-4 variants if the source is large enough. It exercises the cascade-api fully and matches what readers actually want.
  2. Avatars are the killer storage demo. A VRM file is typically 5-30 MB; that's exactly the kind of asset where pin-loss on IPFS would actually hurt. Cascade's pay-once-store-forever model directly addresses it.
  3. Always include source resolution as a fallback variant. Targets that exceed the source are skipped by the resize step; without a source-resolution fallback, a 400px upload would only produce one variant.
  4. Keep the bearer token server-side. Move the upload through a Next.js Route Handler. Server-side concurrency will be the eventual bottleneck before client-side will.
  5. The on-chain footprint per save is one setData call. The total work the user signs is exactly one transaction on Lukso, regardless of how many Cascade uploads happened.

Source code

The complete implementation: github.com/kaleababayneh/permanent-up.

Next steps

Edit this page