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
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
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.
Notice the two different verification shapes:
profileImage[]andbackgroundImage[]use the modernverification: { 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.
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.
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.
Behind the scenes, erc725.js produces:
keys[0]=0x5ef83ad9559033e6e941db7d7c495acdce616347d28e90c7ce47cbfcfcad3bc5(theLSP3Profiledata 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
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
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.
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
- 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.
- 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.
- 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.
- 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.
- The on-chain footprint per save is one
setDatacall. 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.