| name | universal-profile |
| description | Manage LUKSO Universal Profiles — identity, permissions, tokens, and blockchain operations via direct or gasless relay transactions |
| version | 0.3.3 |
| author | frozeman |
Universal Profile Skill
⚠️ Early Draft Version — Use at your own risk.
To authorize your OpenClaw bot, create a profile at my.universalprofile.cloud, generate a controller key, then authorize it via the Authorization UI.
Installation
npm install
CLI Commands
up status
up profile info [<address>] [--chain <chain>]
up profile configure <address> [--chain lukso]
up key generate [--save] [--password <pw>]
up permissions encode <perm1> [<perm2> ...]
up permissions decode <hex>
up permissions presets
up authorize url [--permissions <preset|hex>]
up quota
Permission presets: read-only 🟢 | token-operator 🟡 | nft-trader 🟡 | defi-trader 🟠 | profile-manager 🟡 | full-access 🔴
Credentials
Loaded from (in order): UP_CREDENTIALS_PATH env → ~/.openclaw/universal-profile/config.json → ~/.clawdbot/universal-profile/config.json → ./credentials/config.json
Key files: UP_KEY_PATH env → ~/.openclaw/credentials/universal-profile-key.json → ~/.clawdbot/credentials/universal-profile-key.json
macOS Keychain Storage (Recommended on macOS)
On macOS, store the controller private key in the system Keychain instead of a plaintext JSON file. The key is retrieved in memory only for signing and never written to disk.
Store the key:
security add-generic-password \
-a "<controller-address>" \
-s "universalprofile-controller" \
-l "UP Controller Key" \
-D "Ethereum Private Key" \
-w "<private-key>" \
-T /usr/bin/security \
-U
Retrieve in code (Node.js):
import { execSync } from 'child_process';
function getPrivateKeyFromKeychain(controllerAddress) {
return execSync(
`security find-generic-password -a "${controllerAddress}" -s "universalprofile-controller" -w`,
{ encoding: 'utf8', timeout: 10000 }
).trim();
}
let privateKey = getPrivateKeyFromKeychain('0xYourController...');
const signingKey = new ethers.SigningKey(privateKey);
privateKey = null;
Notes:
-T /usr/bin/security grants the security CLI access without a GUI prompt
- Apple's Secure Enclave does not support secp256k1 (Ethereum's curve), so the key must be extracted for signing — but it stays in memory only, never on disk
- After storing in Keychain, delete the JSON credentials file
- This approach is macOS-only. On Linux, consider using a secrets manager, encrypted keyring, or environment variables instead
Transactions
Direct (controller pays gas)
Controller EOA → KeyManager.execute(payload) → UP.execute(...) → Target
const payload = up.interface.encodeFunctionData('execute', [0, recipient, ethers.parseEther('1.5'), '0x']);
await (await km.execute(payload)).wait();
Relay / Gasless (LSP25)
Controller signs off-chain, relayer submits on-chain. UPs created via universalprofile.cloud have monthly gas quota from LUKSO.
LSP25 Signature (EIP-191 v0 — CRITICAL: do NOT use signMessage()):
const encodedMessage = ethers.solidityPacked(
['uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'bytes'],
[25, chainId, nonce, validityTimestamps, msgValue, payload]
);
const prefix = new Uint8Array([0x19, 0x00]);
const msg = new Uint8Array([...prefix, ...ethers.getBytes(keyManagerAddress), ...ethers.getBytes(encodedMessage)]);
const hash = ethers.keccak256(msg);
const signature = ethers.Signature.from(new ethers.SigningKey(privateKey).sign(hash)).serialized;
Or use @lukso/eip191-signer.js:
const { signature } = await new EIP191Signer().signDataWithIntendedValidator(kmAddress, encodedMessage, privateKey);
Relay API (LSP-15):
POST https://relayer.mainnet.lukso.network/api/execute
{ "address": "0xUP", "transaction": { "abi": "0xpayload", "signature": "0x...", "nonce": 0, "validityTimestamps": "0x0" } }
Quota check requires signed request — use up quota CLI or checkRelayQuota() from lib/execute/relay.js.
Nonce channels: getNonce(controller, channelId) — same channel = sequential, different = parallel.
Validity timestamps: (startTimestamp << 128) | endTimestamp. Use 0 for no restriction.
Permission System
Permissions are a bytes32 BitArray at AddressPermissions:Permissions:<address>. Combine with bitwise OR.
| Permission | Hex | Risk |
|---|
| CHANGEOWNER | 0x01 | 🔴 |
| ADDCONTROLLER | 0x02 | 🟠 |
| EDITPERMISSIONS | 0x04 | 🟠 |
| ADDEXTENSIONS | 0x08 | 🟡 |
| CHANGEEXTENSIONS | 0x10 | 🟡 |
| ADDUNIVERSALRECEIVERDELEGATE | 0x20 | 🟡 |
| CHANGEUNIVERSALRECEIVERDELEGATE | 0x40 | 🟡 |
| REENTRANCY | 0x80 | 🟡 |
| SUPER_TRANSFERVALUE | 0x0100 | 🟠 |
| TRANSFERVALUE | 0x0200 | 🟡 |
| SUPER_CALL | 0x0400 | 🟠 |
| CALL | 0x0800 | 🟡 |
| SUPER_STATICCALL | 0x1000 | 🟢 |
| STATICCALL | 0x2000 | 🟢 |
| SUPER_DELEGATECALL | 0x4000 | 🔴 |
| DELEGATECALL | 0x8000 | 🔴 |
| DEPLOY | 0x010000 | 🟡 |
| SUPER_SETDATA | 0x020000 | 🟠 |
| SETDATA | 0x040000 | 🟡 |
| ENCRYPT | 0x080000 | 🟢 |
| DECRYPT | 0x100000 | 🟢 |
| SIGN | 0x200000 | 🟢 |
| EXECUTE_RELAY_CALL | 0x400000 | 🟢 |
SUPER vs Regular: SUPER_CALL = any contract; CALL = only AllowedCalls. SUPER_SETDATA = any key; SETDATA = only AllowedERC725YDataKeys. Prefer restricted.
AllowedCalls: CompactBytesArray at AddressPermissions:AllowedCalls:<addr>. Each entry: <callTypes(4)><address(20)><interfaceId(4)><selector(4)>.
LSP Ecosystem
| LSP | Name | Purpose |
|---|
LSP0 (0x24871b3d) | ERC725Account | Smart contract account (UP) |
LSP1 (0x6bb56a14) | UniversalReceiver | Notification hooks |
| LSP2 | ERC725Y JSON Schema | Key encoding for on-chain data |
| LSP3 | Profile Metadata | Name, avatar, links, tags |
| LSP4 | Digital Asset Metadata | Token name, symbol, type |
| LSP5 | ReceivedAssets | Tracks owned tokens/NFTs |
LSP6 (0x23f34c62) | KeyManager | Permission-based access control |
LSP7 (0xc52d6008) | DigitalAsset | Fungible tokens (like ERC20) |
LSP8 (0x3a271706) | IdentifiableDigitalAsset | NFTs (bytes32 token IDs) |
LSP9 (0x28af17e6) | Vault | Sub-account for asset segregation |
| LSP28 | The Grid | Customizable profile grid layouts |
LSP14 (0x94be5999) | Ownable2Step | Two-step ownership transfer |
LSP25 (0x5ac79908) | ExecuteRelayCall | Gasless meta-transactions |
LSP26 (0x2b299cea) | FollowerSystem | On-chain follow/unfollow |
Full ABIs, interface IDs, and ERC725Y data keys are in lib/constants.js.
VerifiableURI Encoding (LSP2)
Used for LSP3 profile metadata, LSP4 asset metadata, and any on-chain JSON reference.
Format: 0x + 0000 (2 bytes verification method) + 6f357c6a (4 bytes = keccak256(utf8) hash function) + 0020 (2 bytes = hash length 32) + <keccak256 hash> (32 bytes) + <url as UTF-8 hex>
const jsonBytes = fs.readFileSync('metadata.json');
const jsonHash = ethers.keccak256(jsonBytes);
const url = `ipfs://${cid}`;
const urlHex = Buffer.from(url, 'utf8').toString('hex');
const verifiableURI = '0x' + '00006f357c6a0020' + jsonHash.slice(2) + urlHex;
Decoding:
const hex = data.slice(2);
const url = Buffer.from(hex.slice(80), 'hex').toString('utf8');
⚠️ Common mistake: Forgetting the 0020 hash length bytes between the hash function selector and the actual hash. Without it, the URL offset is wrong and parsers will read garbage.
LSP3Profile data key: 0x5ef83ad9559033e6e941db7d7c495acdce616347d28e90c7ce47cbfcfcad3bc5
LSP28TheGrid data key: 0x724141d9918ce69e6b8afcf53a91748466086ba2c74b94cab43c649ae2ac23ff
LSP28 — The Grid
Customizable grid layouts for profiles/tokens. Stored as VerifiableURI at the LSP28 data key.
{
"LSP28TheGrid": [{
"title": "My Grid",
"gridColumns": 2,
"visibility": "public",
"grid": [
{ "width": 1, "height": 1, "type": "IFRAME", "properties": { "src": "https://..." } },
{ "width": 1, "height": 1, "type": "TEXT", "properties": { "title":
Grid types: IFRAME, TEXT, IMAGES, X (Twitter embed), INSTAGRAM, QR_CODE, ELFSIGHT (custom widget).
Recommended: gridColumns 2–4, width/height 1–3.
setData via Gasless Relay (Direct Pattern)
For setting ERC725Y data (LSP3 profile, LSP28 grid, custom keys) via relay — use setData payload directly (do NOT wrap in execute):
const iface = new ethers.Interface(['function setData(bytes32 dataKey, bytes dataValue)']);
const payload = iface.encodeFunctionData('setData', [dataKey, verifiableURI]);
const km = new ethers.Contract(KM_ADDRESS, ['function getNonce(address,uint128) view returns (uint256)'], provider);
const nonce = await km.getNonce(controllerAddress, 0);
const encoded = ethers.solidityPacked(
['uint256','uint256','uint256','uint256','uint256','bytes'],
[25, chainId, nonce, '0x' + '00'.repeat(32), 0, payload]
);
const msg = new Uint8Array([0x19, 0x00, ...ethers.getBytes(KM_ADDRESS), ...ethers.getBytes(encoded)]);
const signature = ethers.Signature.from(new ethers.SigningKey(privateKey).sign(ethers.keccak256(msg))).;
(, {
: , : { : },
: .({ : , : { : payload, signature, : (nonce), : } })
});
⚠️ Key distinction: setData payload goes directly to the KeyManager — do NOT wrap it in execute(CALL, self, setData(...)). The KeyManager forwards calls to the UP automatically. Only use execute() wrapper for operations targeting other contracts.
Network Config
| Mainnet | Testnet |
|---|
| Chain ID | 42 | 4201 |
| RPC | https://42.rpc.thirdweb.com | https://rpc.testnet.lukso.network |
| Explorer | https://explorer.lukso.network | https://explorer.testnet.lukso.network |
| Relay | https://relayer.mainnet.lukso.network/api | https://relayer.testnet.lukso.network/api |
| Token | LYX (18 dec) | LYXt (18 dec) |
Security
- Grant minimum permissions. Prefer CALL over SUPER_CALL.
- Use AllowedCalls/AllowedERC725YDataKeys to restrict access.
- Avoid DELEGATECALL and CHANGEOWNER unless absolutely necessary.
- Use validity timestamps for relay calls.
- Test on testnet (chain 4201) first.
- Never log private keys.
Forever Moments (NFT Moments & Collections)
Forever Moments is a social NFT platform on LUKSO. The Agent API lets you mint Moment NFTs, join/create collections, and pin images to IPFS — all via gasless relay.
Base URL: https://www.forevermoments.life/api/agent/v1
IPFS Pinning
POST /api/pinata
Content-Type: multipart/form-data
Body: file=@image.png
Response: { "IpfsHash": "Qm...", "PinSize": 123456 }
Relay Flow (3-step pattern for all on-chain actions)
- Build — call build endpoint → get
derived.upExecutePayload
- Prepare —
POST /relay/prepare with payload → get hashToSign + nonce
- Sign & Submit — sign
hashToSign as RAW DIGEST (not signMessage!) → POST /relay/submit
const build = await fetch(`${API}/moments/build-mint`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userUPAddress: UP, collectionUP: COLLECTION, metadataJson: { LSP4Metadata: { name, description, images, icon, tags } } })
});
const { data: { derived: { upExecutePayload } } } = await build.json();
const prep = await fetch(`${API}/relay/prepare`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upAddress: UP, controllerAddress: CONTROLLER, payload: upExecutePayload })
});
const { data: { hashToSign, nonce, relayerUrl } } = await prep.json();
const signature = ethers..( ethers.(privateKey).(hashToSign)).;
(, {
: , : { : },
: .({ : , : upExecutePayload, signature, nonce, : , relayerUrl })
});
Endpoints
| Endpoint | Method | Purpose |
|---|
/collections/build-join | POST | Join an existing collection |
/collections/build-create | POST | Create collection (step 1: LSP23 deploy) |
/collections/finalize-create | POST | Finalize collection (step 2: register) |
/moments/build-mint | POST | Mint a Moment NFT in a collection |
/relay/prepare | POST | Get hashToSign + nonce for relay |
/relay/submit | POST | Submit signed relay tx to LUKSO relayer |
/api/pinata | POST | Pin file to IPFS (multipart) |
Metadata Format (LSP4)
{
"LSP4Metadata": {
"name": "Moment Title",
"description": "Description text",
"images": [[{ "width": 1024, "height": 1024, "url": "ipfs://Qm..." }]],
"icon": [{ "width": 1024, "height": 1024, "url": "ipfs://Qm..." }],
"tags": ["tag1", "tag2"],
"createdAt":
Pass metadataJson to build-mint and the API auto-pins it to IPFS.
Key Notes
- Signing: The
hashToSign from /relay/prepare is already a full hash — sign it as a raw digest with SigningKey.sign(), NOT wallet.signMessage()
- Join before mint: You may need to join a collection before minting. If join fails with gas estimation error, you might already be a member
- Collection creation is 2-step:
build-create (deploys contracts via LSP23) → finalize-create (registers)
- Known collection: "Art by the Machine" =
0x439f6793b10b0a9d88ad05293a074a8141f19d77
Forever Moments URL Patterns
| Page | URL |
|---|
| Collection | https://www.forevermoments.life/collections/<collectionAddress> |
| Moment | https://www.forevermoments.life/moments/<momentTokenAddress> |
| Profile | https://www.forevermoments.life/profile/<upAddress> |
| Feed | https://www.forevermoments.life/moments |
Error Codes
| Code | Cause |
|---|
UP_PERMISSION_DENIED | Controller lacks required permission |
UP_RELAY_FAILED | Relay execution error — check quota |
UP_INVALID_SIGNATURE | Wrong chainId, used nonce, or expired timestamps |
UP_QUOTA_EXCEEDED | Monthly relay quota exhausted |
UP_NOT_AUTHORIZED | Address not a controller — use Authorization UI |
Dependencies
- Node.js 18+ / ethers.js v6
@lukso/lsp-smart-contracts / @erc725/erc725.js (optional)
Links
Profile URLs: Always use https://universaleverything.io/<address> to link to Universal Profiles (NOT universalprofile.cloud).