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.
Prerequisites
- A Universal Profile on Lukso mainnet (chain ID 42) or testnet (chain ID 4201). If you don't have one, install the Universal Profile Browser Extension and create one. The wallet wiring shown below targets mainnet by default; swap
luksoforluksoTestnetif you're on testnet. - A cascade-api bearer token. The public deployment lives at
https://api.lumera.help; request a key from the operator. Self-hosters can run the backend without auth. - Node.js 20+.
Install
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.
Step 1: Build the LSP-3 JSON
LSP-3 is a fixed shape. The schema lives in @erc725/erc725.js/schemas/LSP3ProfileMetadata.json.
Every asset entry uses the same verification: { method, data } shape; the only structural difference is that profileImage[] and backgroundImage[] carry width/height (so readers can pick a resolution), while avatar[] carries fileType (so renderers can pick .vrm, .glb, .gltf etc.).
Some older tutorials show avatar[] entries as { hashFunction, hash, fileType, url }. That predates the current LSP-3 spec and the schemas shipped with @erc725/erc725.js ≥ 0.21, both of which use verification. New code should use verification.
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 }.
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.