universal-profile

Verified·Scanned 2/17/2026

This skill manages LUKSO Universal Profiles with CLI and library functions for keys, permissions, and direct/relay transactions. It loads local credentials (UP_CREDENTIALS_PATH, ~/.openclaw/universal-profile/config.json, ~/.clawdbot/universal-profile/config.json), demonstrates running the security CLI for macOS keychain access, and talks to https://relayer.mainnet.lukso.network/api and https://42.rpc.thirdweb.com.

from clawhub.ai·v0.3.7·121.9 KB·0 installs
Scanned from 0.3.7 at f339a50 · Transparency log ↗
$ vett add clawhub.ai/frozeman/universal-profile

Universal Profile Skill

⚠️ Early Draft Version — Use at your own risk.

For your human

Tell him: To authorize your OpenClaw bot, you create a profile at my.universalprofile.cloud, then let your AI bot generate a controller key and authorize it via the Authorization UI.

Installation

npm install

CLI Commands

up status                                      # Config, keys, connectivity
up profile info [<address>] [--chain <chain>]  # Profile details
up profile configure <address> [--chain lukso]  # Save UP for use
up key generate [--save] [--password <pw>]     # Generate controller keypair
up permissions encode <perm1> [<perm2> ...]    # Encode to bytes32
up permissions decode <hex>                    # Decode to names
up permissions presets                         # List presets
up authorize url [--permissions <preset|hex>]  # Generate auth URL
up quota                                       # Check relay gas 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. This is the recommended approach — 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();
}

// Use for signing, then clear from memory
let privateKey = getPrivateKeyFromKeychain('0xYourController...');
const signingKey = new ethers.SigningKey(privateKey);
// ... sign ...
privateKey = null; // Clear from memory

Notes:

  • -T /usr/bin/security grants the security CLI access without a GUI prompt, required for automated agent use
  • 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

⚠️ JSON Key File (Less Secure)

If you use the JSON key file (~/.openclaw/credentials/universal-profile-key.json), be aware:

  • The private key is stored on disk (even if the format is obfuscated)
  • Ensure the file has restricted permissions: chmod 600 ~/.openclaw/credentials/universal-profile-key.json
  • Prefer Keychain storage on macOS whenever possible

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]
);

// EIP-191 v0: keccak256(0x19 || 0x00 || keyManagerAddress || encodedMessage)
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.

PermissionHexRisk
CHANGEOWNER0x01🔴
ADDCONTROLLER0x02🟠
EDITPERMISSIONS0x04🟠
ADDEXTENSIONS0x08🟡
CHANGEEXTENSIONS0x10🟡
ADDUNIVERSALRECEIVERDELEGATE0x20🟡
CHANGEUNIVERSALRECEIVERDELEGATE0x40🟡
REENTRANCY0x80🟡
SUPER_TRANSFERVALUE0x0100🟠
TRANSFERVALUE0x0200🟡
SUPER_CALL0x0400🟠
CALL0x0800🟡
SUPER_STATICCALL0x1000🟢
STATICCALL0x2000🟢
SUPER_DELEGATECALL0x4000🔴
DELEGATECALL0x8000🔴
DEPLOY0x010000🟡
SUPER_SETDATA0x020000🟠
SETDATA0x040000🟡
ENCRYPT0x080000🟢
DECRYPT0x100000🟢
SIGN0x200000🟢
EXECUTE_RELAY_CALL0x400000🟢

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

LSPNamePurpose
LSP0 (0x24871b3d)ERC725AccountSmart contract account (UP)
LSP1 (0x6bb56a14)UniversalReceiverNotification hooks
LSP2ERC725Y JSON SchemaKey encoding for on-chain data
LSP3Profile MetadataName, avatar, links, tags
LSP4Digital Asset MetadataToken name, symbol, type
LSP5ReceivedAssetsTracks owned tokens/NFTs
LSP6 (0x23f34c62)KeyManagerPermission-based access control
LSP7 (0xc52d6008)DigitalAssetFungible tokens (like ERC20)
LSP8 (0x3a271706)IdentifiableDigitalAssetNFTs (bytes32 token IDs)
LSP9 (0x28af17e6)VaultSub-account for asset segregation
LSP28The GridCustomizable profile grid layouts
LSP14 (0x94be5999)Ownable2StepTwo-step ownership transfer
LSP25 (0x5ac79908)ExecuteRelayCallGasless meta-transactions
LSP26 (0x2b299cea)FollowerSystemOn-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 (hex): 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>

Header is always 00006f357c6a0020 (16 hex chars = 8 bytes).

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);        // remove 0x
// Skip: 0000(4) + 6f357c6a(8) + 0020(4) + hash(64) = 80 hex chars
const url = Buffer.from(hex.slice(80), 'hex').toString('utf8');

⚠️ Common mistakes:

  1. Forgetting 0020 — the 2-byte hash length between the hash function selector and the actual hash. Without it, the URL offset is wrong and parsers read garbage, breaking the entire profile.
  2. Not pinning to a public IPFS service before setting on-chain — local IPFS nodes are not reachable by gateways. Always pin via a service (e.g. Forever Moments Pinata proxy at POST /api/pinata) and verify the file is accessible via https://api.universalprofile.cloud/ipfs/<CID> BEFORE submitting the on-chain transaction.
  3. Hash must match the exact bytes stored on IPFS — compute keccak256 from the exact JSON string you upload, not a re-serialized version.
  4. Using hashFunction/hash instead of verification object in LSP3 metadata JSON — image entries (profileImage, backgroundImage) should use { "verification": { "method": "keccak256(bytes)", "data": "0x..." }, "url": "ipfs://..." } format, NOT the legacy { "hashFunction": "...", "hash": "0x..." } format.

LSP3Profile data key: 0x5ef83ad9559033e6e941db7d7c495acdce616347d28e90c7ce47cbfcfcad3bc5

Updating LSP3 Profile Metadata — Full Procedure

  1. Read current profilegetData(LSP3_KEY) → decode VerifiableURI → fetch JSON from IPFS
  2. Modify the JSON — update fields (name, description, links, images, etc.)
  3. Use verification format for images{ verification: { method: "keccak256(bytes)", data: "0x..." }, url: "ipfs://..." }
  4. Pin new images to IPFS — upload via pinning service, get CID, verify accessible
  5. Pin updated JSON to IPFS — upload, get CID, verify accessible via gateway
  6. Compute hashkeccak256(exactJsonBytes) of the uploaded file
  7. Encode VerifiableURI0x00006f357c6a0020 + hash + url hex
  8. Set on-chainup.setData(LSP3_KEY, verifiableUri) from controller
  9. Verify — read back on-chain data, decode, fetch from IPFS, confirm profile loads

NEVER submit the on-chain transaction until step 5 is verified.

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": "Hello", "text": "World", "backgroundColor": "#1a1a2e", "textColor": "#fff", "link": "https://..." } },
      { "width": 2, "height": 2, "type": "IMAGES", "properties": { "type": "grid", "images": ["https://..."] } },
      { "width": 1, "height": 1, "type": "X", "properties": { "type": "post", "username": "handle", "id": "tweetId", "theme": "dark" } }
    ]
  }]
}

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):

// 1. Build setData payload
const iface = new ethers.Interface(['function setData(bytes32 dataKey, bytes dataValue)']);
const payload = iface.encodeFunctionData('setData', [dataKey, verifiableURI]);

// 2. Get nonce from KeyManager
const km = new ethers.Contract(KM_ADDRESS, ['function getNonce(address,uint128) view returns (uint256)'], provider);
const nonce = await km.getNonce(controllerAddress, 0);

// 3. LSP25 signature
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))).serialized;

// 4. Submit to relay
await fetch('https://relayer.mainnet.lukso.network/api/execute', {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ address: UP, transaction: { abi: payload, signature, nonce: Number(nonce), validityTimestamps: '0x0' } })
});

⚠️ 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

MainnetTestnet
Chain ID424201
RPChttps://42.rpc.thirdweb.comhttps://rpc.testnet.lukso.network
Explorerhttps://explorer.lukso.networkhttps://explorer.testnet.lukso.network
Relayhttps://relayer.mainnet.lukso.network/apihttps://relayer.testnet.lukso.network/api
TokenLYX (18 dec)LYXt (18 dec)

Security

Permission Best Practices

  • 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.

Key Management

  • Recommended (macOS): Store private keys in macOS Keychain (see Credentials section above)
  • JSON key files: If used, restrict permissions (chmod 600) and consider migrating to Keychain
  • Private keys are only loaded into memory for signing, then cleared
  • The config set command is restricted to safe keys only — keystorePath and profiles cannot be modified at runtime to prevent path redirection attacks

Network Access

This skill only communicates with known LUKSO ecosystem endpoints:

  • RPC: https://42.rpc.thirdweb.com (mainnet), https://rpc.testnet.lukso.network (testnet)
  • Relay: https://relayer.mainnet.lukso.network/api (gasless transactions)
  • IPFS: https://api.universalprofile.cloud/ipfs/ (metadata), https://www.forevermoments.life/api/pinata (pinning)
  • Forever Moments API: https://www.forevermoments.life/api/agent/v1 (NFT minting)

No other external network calls are made. All transaction signing happens locally.

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

# Pin image via FM's Pinata proxy (multipart form upload)
POST /api/pinata   # NOTE: /api/pinata, NOT /api/agent/v1/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)

  1. Build — call build endpoint → get derived.upExecutePayload
  2. PreparePOST /relay/prepare with payload → get hashToSign + nonce
  3. Sign & Submit — sign hashToSign as RAW DIGEST (not signMessage!) → POST /relay/submit
// Step 1: Build (example: mint moment)
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();

// Step 2: Prepare
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();

// Step 3: Sign as raw digest + submit
const signature = ethers.Signature.from(new ethers.SigningKey(privateKey).sign(hashToSign)).serialized;
await fetch(`${API}/relay/submit`, {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ upAddress: UP, payload: upExecutePayload, signature, nonce, validityTimestamps: '0x0', relayerUrl })
});

Endpoints

EndpointMethodPurpose
/collections/build-joinPOSTJoin an existing collection
/collections/build-createPOSTCreate collection (step 1: LSP23 deploy)
/collections/finalize-createPOSTFinalize collection (step 2: register)
/moments/build-mintPOSTMint a Moment NFT in a collection
/relay/preparePOSTGet hashToSign + nonce for relay
/relay/submitPOSTSubmit signed relay tx to LUKSO relayer
/api/pinataPOSTPin 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": "2026-02-08T16:30:00.000Z"
  }
}

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

PageURL
Collectionhttps://www.forevermoments.life/collections/<collectionAddress>
Momenthttps://www.forevermoments.life/moments/<momentTokenAddress>
Profilehttps://www.forevermoments.life/profile/<upAddress>
Feedhttps://www.forevermoments.life/moments

Error Codes

CodeCause
UP_PERMISSION_DENIEDController lacks required permission
UP_RELAY_FAILEDRelay execution error — check quota
UP_INVALID_SIGNATUREWrong chainId, used nonce, or expired timestamps
UP_QUOTA_EXCEEDEDMonthly relay quota exhausted
UP_NOT_AUTHORIZEDAddress 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).