Skill: frozeman/universal-profile (version 0.9.0)

--- name: universal-profile description: Manage LUKSO Universal Profiles — identity, permissions, tokens, blockchain operations. Cross-chain support for Base and Ethereum. version: 0.9.0 author: frozeman --- # Universal Profile Skill Authorize your bot: create a profile at [my.universalprofile.cloud](https://my.universalprofile.cloud), generate a controller key, authorize via [Authorization UI](https://openclaw.universalprofile.cloud). ## Core Concepts - **UP (Universal Profile)** = smart contract account (LSP0/ERC725Account). This is the on-chain identity. - **KeyManager (LSP6)** = access control. Controllers have permission bitmasks. - **Controller** = EOA with permissions to act on behalf of the UP. - All calls to external contracts MUST route through UP via `execute()` so `msg.sender` = UP address. - Exception: `setData()`/`setDataBatch()` can be called directly on UP (checks permissions internally). ## Execution Models ### Direct (all chains — controller pays gas) ``` Controller → UP.execute(operation, target, value, data) → Target ``` The controller calls `execute()` directly on the UP contract. The UP internally verifies permissions via its KeyManager (LSP20 lsp20VerifyCall). **Do NOT call the KeyManager's execute() function directly.** Always call the UP. ### Gasless Relay (LUKSO ONLY — chains 42/4201) ``` Controller signs LSP25 → Relay API submits → KeyManager.executeRelayCall() → UP ``` The controller signs a message, then the LUKSO relay service submits the transaction. **Do NOT call executeRelayCall() yourself — the relay API does this.** **⚠️ CRITICAL: The relay/gasless option exists ONLY on LUKSO mainnet (42) and testnet (4201). On Base, Ethereum, and all other chains, the controller must hold native ETH and pay gas directly. There is no gasless alternative.** Typical gas costs: LUKSO ~free via relay, Base ~$0.001-0.01/tx, Ethereum ~$0.10-1.00/tx. ## Networks | Chain | ID | RPC | Explorer | Relay | Token | |---|---|---|---|---|---| | LUKSO | 42 | `https://42.rpc.thirdweb.com` | `https://explorer.lukso.network` | `https://relayer.mainnet.lukso.network/api` | LYX | | LUKSO Testnet | 4201 | `https://rpc.testnet.lukso.network` | `https://explorer.testnet.lukso.network` | `https://relayer.testnet.lukso.network/api` | LYXt | | Base | 8453 | `https://mainnet.base.org` | `https://basescan.org` | ❌ | ETH | | Ethereum | 1 | `https://eth.llamarpc.com` | `https://etherscan.io` | ❌ | ETH | ## CLI ``` 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 (LUKSO only) ``` Presets: `read-only` 🟢 | `token-operator` 🟡 | `nft-trader` 🟡 | `defi-trader` 🟠 | `profile-manager` 🟡 | `full-access` 🔴 ## Credentials Config lookup order: `UP_CREDENTIALS_PATH` env → `~/.openclaw/universal-profile/config.json` → `~/.clawdbot/universal-profile/config.json` Key lookup order: `UP_KEY_PATH` env → `~/.openclaw/credentials/universal-profile-key.json` → `~/.clawdbot/credentials/universal-profile-key.json` Canonical path for new credentials: `~/.openclaw/credentials/universal-profile-key.json` Skill config path: `~/.openclaw/skills/universal-profile/config.json` Expected JSON format: ```json { "universalProfile": { "address": "0xYourUniversalProfileAddress" }, "controller": { "address": "0xYourControllerAddress", "privateKey": "0xYourPrivateKey" } } ``` Key file permissions: `chmod 600`. Keys loaded only for signing, then cleared. The skill warns if credential files are readable by group/others. ## Permissions (bytes32 BitArray) | Permission | Hex | Risk | Notes | |---|---|---|---| | CHANGEOWNER | 0x01 | 🔴 | | | ADDCONTROLLER | 0x02 | 🟠 | | | EDITPERMISSIONS | 0x04 | 🟠 | | | ADDEXTENSIONS | 0x08 | 🟡 | | | CHANGEEXTENSIONS | 0x10 | 🟡 | | | ADDUNIVERSALRECEIVERDELEGATE | 0x20 | 🟡 | | | CHANGEUNIVERSALRECEIVERDELEGATE | 0x40 | 🟡 | | | REENTRANCY | 0x80 | 🟡 | | | SUPER_TRANSFERVALUE | 0x0100 | 🟠 | Any recipient | | TRANSFERVALUE | 0x0200 | 🟡 | AllowedCalls only | | SUPER_CALL | 0x0400 | 🟠 | Any contract | | CALL | 0x0800 | 🟡 | AllowedCalls only | | SUPER_STATICCALL | 0x1000 | 🟢 | | | STATICCALL | 0x2000 | 🟢 | | | SUPER_DELEGATECALL | 0x4000 | 🔴 | | | DELEGATECALL | 0x8000 | 🔴 | | | DEPLOY | 0x010000 | 🟡 | | | SUPER_SETDATA | 0x020000 | 🟠 | Any key | | SETDATA | 0x040000 | 🟡 | AllowedERC725YDataKeys only | | ENCRYPT | 0x080000 | 🟢 | | | DECRYPT | 0x100000 | 🟢 | | | SIGN | 0x200000 | 🟢 | | | EXECUTE_RELAY_CALL | 0x400000 | 🟢 | | SUPER variants = unrestricted. Regular = restricted to AllowedCalls/AllowedERC725YDataKeys. Prefer restricted. ## Transactions ### Direct Execution (all chains) ```javascript // Controller calls UP.execute() directly — works on LUKSO, Base, Ethereum const provider = new ethers.JsonRpcProvider(rpcUrl); // use correct RPC for chain const wallet = new ethers.Wallet(controllerPrivateKey, provider); const up = new ethers.Contract(upAddress, ['function execute(uint256,address,uint256,bytes) payable returns (bytes)'], wallet); await (await up.execute(0, recipient, ethers.parseEther('0.01'), '0x')).wait(); ``` ### Gasless Relay (LUKSO only) **LSP25 Relay Signature — EIP-191 v0, do NOT use `signMessage()`:** ```javascript const encoded = 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(kmAddress), ...ethers.getBytes(encoded)]); const signature = ethers.Signature.from(new ethers.SigningKey(privateKey).sign(ethers.keccak256(msg))).serialized; ``` **Relay API:** ``` POST https://relayer.mainnet.lukso.network/api/execute { "address": "0xUP", "transaction": { "abi": "0xpayload", "signature": "0x...", "nonce": 0, "validityTimestamps": "0x0" } } ``` The `payload` for relay calls is the full `UP.execute(...)` calldata. The relay service calls `KeyManager.executeRelayCall()` — you never call the KM directly. For `setData` via relay, the payload is the `setData(...)` calldata (NOT wrapped in `execute()`). Nonce channels: `getNonce(controller, channelId)` — same channel = sequential, different = parallel. Validity timestamps: `(startTimestamp << 128) | endTimestamp`. Use `0` for no restriction. ## Cross-Chain Deployment (LSP23) UPs can be redeployed at the same address on other chains by replaying the original LSP23 factory calldata. ### Factory & Implementations (identical addresses on LUKSO, Base, Ethereum) | Contract | Address | |---|---| | LSP23 Factory | [REDACTED_SECRET] | | UniversalProfileInit v0.14.0 | [REDACTED_SECRET] | | LSP6KeyManagerInit v0.14.0 | [REDACTED_SECRET] | | PostDeploymentModule | [REDACTED_SECRET] | ### Workflow 1. Retrieve original deployment calldata: `node commands/cross-chain-deploy-data.js <upAddress> [--verify]` 2. Fund controller with ETH on target chain 3. Submit same calldata to factory: `wallet.sendTransaction({ to: factoryAddress, data: calldata, value: 0n })` 4. Authorize controller on new chain via [Authorization UI](https://openclaw.universalprofile.cloud) (permissions are per-chain) ### Limitations - Legacy UPs (pre-LSP23, old lsp-factory) have no deployment events - Determinism requires identical salt + implementations + init data ## LSP Ecosystem | LSP | Interface ID | Name | Purpose | |---|---|---|---| | LSP0 | 0x24871b3d | ERC725Account | Smart contract account (UP) | | LSP1 | 0x6bb56a14 | UniversalReceiver | Notification hooks | | LSP2 | — | ERC725Y JSON Schema | Key encoding | | 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 | | LSP14 | 0x94be5999 | Ownable2Step | Two-step ownership transfer | | LSP25 | 0x5ac79908 | ExecuteRelayCall | Gasless meta-transactions (LUKSO only) | | LSP26 | 0x2b299cea | FollowerSystem | On-chain follow/unfollow | | LSP28 | — | TheGrid | Customizable profile grid layouts | Full ABIs, interface IDs, and ERC725Y data keys in `lib/constants.js`. ## LSP26 Follow/Unfollow Contract: [REDACTED_SECRET] (LUKSO mainnet). MUST route through UP via `execute()` — never call directly from controller. ```javascript const followData = lsp26Iface.encodeFunctionData('follow', [targetAddress]); // Direct: km.execute(up.encodeFunctionData('execute', [0, LSP26_ADDR, 0, followData])) // Relay: sign + submit via relay API ``` ## VerifiableURI (LSP2) Format: `0x` + `00006f357c6a0020` (8-byte header) + `keccak256hash` (32 bytes) + `url as UTF-8 hex` Header = verificationMethod(2) + hashFunction(4=keccak256(utf8)) + hashLength(2=0x0020). Decoding: skip 80 hex chars (2 + 8 + 4 + 64 + 2 prefix), rest = UTF-8 URL. **Common mistakes:** forgetting `0020` hash length bytes, not pinning IPFS before on-chain tx, hash mismatch from re-serialization. ### LSP3 Profile Update Procedure 1. Read current: `getData(0x5ef83ad9559033e6e941db7d7c495acdce616347d28e90c7ce47cbfcfcad3bc5)` → decode VerifiableURI → fetch JSON 2. Modify JSON 3. Use `{ verification: { method: "keccak256(bytes)", data: "0x..." }, url: "ipfs://..." }` for images 4. Pin images + JSON to IPFS, verify accessible via gateway 5. Compute `keccak256(exactJsonBytes)`, encode VerifiableURI 6. `setData(LSP3_KEY, verifiableUri)` from controller 7. Verify: read back, decode, fetch, confirm LSP3 key: [REDACTED_SECRET] LSP28 key: [REDACTED_SECRET] ## LSP28 TheGrid Grid layout JSON at LSP28 key as VerifiableURI. ```json { "LSP28TheGrid": [{ "title": "My Grid", "gridColumns": 2, "visibility": "public", "grid": [ { "width": 1, "height": 1, "type": "TEXT", "properties": { "title": "Hi", "text": "...", "backgroundColor": "#1a1a2e", "textColor": "#fff" } }, { "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" } } ] }] } ``` Types: `IFRAME`, `TEXT`, `IMAGES`, `X`, `INSTAGRAM`, `QR_CODE`, `ELFSIGHT`. gridColumns 2–4, width/height 1–3. ## Forever Moments (LUKSO only) Social NFT platform. Agent API at `https://www.forevermoments.life/api/agent/v1`. ### 3-Step Relay Flow 1. **Build** — `POST /moments/build-mint` (or `/collections/build-join`, etc.) → get `derived.upExecutePayload` 2. **Prepare** — `POST /relay/prepare` with `{ upAddress, controllerAddress, payload }` → get `hashToSign`, `nonce` 3. **Sign & Submit** — sign `hashToSign` as RAW DIGEST (`SigningKey.sign()`, NOT `signMessage()`) → `POST /relay/submit` ### Endpoints - `/collections/build-join` — join collection - `/collections/build-create` + `/collections/finalize-create` — create collection (2-step) - `/moments/build-mint` — mint Moment NFT - `/relay/prepare` + `/relay/submit` — relay flow - `/api/pinata` (NOT `/api/agent/v1/pinata`) — pin file to IPFS (multipart) ### Metadata (LSP4) ```json { "LSP4Metadata": { "name": "Title", "description": "...", "images": [[{ "width": 1024, "height": 1024, "url": "ipfs://Qm..." }]], "icon": [{ "width": 1024, "height": 1024, "url": "ipfs://Qm..." }], "tags": ["tag1"], "createdAt": "2026-02-08T16:30:00.000Z" } } ``` Known collection "Art by the Machine": [REDACTED_SECRET] ### URLs - Collection: `https://www.forevermoments.life/collections/<addr>` - Moment: `https://www.forevermoments.life/moments/<addr>` - Profile: `https://www.forevermoments.life/profile/<addr>` ## Error Codes | Code | Cause | |---|---| | UP_PERMISSION_DENIED | Controller lacks required permission | | UP_RELAY_FAILED | Relay error — check quota (LUKSO only) | | UP_INVALID_SIGNATURE | Wrong chainId, used nonce, or expired timestamps | | UP_QUOTA_EXCEEDED | Monthly relay quota exhausted (LUKSO only) | | UP_NOT_AUTHORIZED | Not a controller — use [Authorization UI](https://openclaw.universalprofile.cloud) | ## Security - Grant minimum permissions. Prefer CALL over SUPER_CALL. - Use AllowedCalls/AllowedERC725YDataKeys to restrict. - Avoid DELEGATECALL and CHANGEOWNER unless necessary. - Never log/print/transmit private keys. - Test on testnet (4201) first. - `config set` restricted to safe keys only. ## ERC725.js — Reading & Writing UP Data **Package:** `@erc725/erc725.js` — the standard library for reading, encoding, and decoding ERC725Y data on Universal Profiles. **Use this for all profile data operations** (reading profiles, permissions, assets, encoding setData payloads). Full reference: [`references/ERC725-JS.md`](references/ERC725-JS.md) ### Quick Start ```js import ERC725 from '@erc725/erc725.js'; import { LSP3Schema, LSP5Schema, LSP6Schema } from '@erc725/erc725.js/schemas'; // Connect to a UP const erc725 = new ERC725(LSP3Schema, upAddress, 'https://42.rpc.thirdweb.com', { ipfsGateway: 'https://api.universalprofile.cloud/ipfs/', }); // Read profile (fetches + verifies IPFS content) const profile = await erc725.fetchData('LSP3Profile'); // Encode data for setData/setDataBatch const { keys, values } = erc725.encodeData([{ keyName: 'LSP3Profile', value: { json: profileJson, url: 'ipfs://Qm...' }, }]); ``` ### Key Operations - **`getData(key)`** — read raw decoded value from contract - **`fetchData(key)`** — read + download linked content (IPFS) + verify hash - **`encodeData(data)`** — encode for `setData()`/`setDataBatch()` → `{ keys, values }` - **`decodeData(data)`** — decode raw contract bytes back to structured data - **`encodeKeyName(name)`** — hash a key name to bytes32 - **Built-in schemas:** LSP1–LSP12, LSP17 (import from `@erc725/erc725.js/schemas`) ### Common Patterns ```js // Read permissions const erc725 = new ERC725(LSP6Schema, upAddress, RPC_URL); const perms = await erc725.getData({ keyName: 'AddressPermissions:Permissions:<address>', dynamicKeyParts: [controllerAddress], }); // Read received assets const erc725 = new ERC725(LSP5Schema, upAddress, RPC_URL); const assets = await erc725.getData('LSP5ReceivedAssets[]'); // Encode VerifiableURI (auto-hashes JSON) erc725.encodeData([{ keyName: 'LSP3Profile', value: { json: myJson, url: 'ipfs://QmNew...' }, }]); ``` ### Important Notes - **Array encoding:** Always set `totalArrayLength` + `startingIndex` when modifying arrays to avoid corrupting contract state - **Dynamic keys:** Use `dynamicKeyParts` for Mapping key types (e.g., permissions per address) - **Backend:** Also install `isomorphic-fetch` - **VerifiableURI:** Pass `{ json, url }` for auto-hashing, or `{ verification, url }` for pre-computed ## Dependencies Node.js 18+, ethers.js v6, @erc725/erc725.js, viem. ## Links [LUKSO Docs](https://docs.lukso.tech/) · [Universal Everything](https://universaleverything.io/) · [LSP6 Spec](https://docs.lukso.tech/standards/access-control/lsp6-key-manager) · [Authorization UI](https://openclaw.universalprofile.cloud) Profile URLs: always use `https://universaleverything.io/<address>`

path: index.js
content: #!/usr/bin/env node
/**
 * Universal Profile Skill for Clawdbot
 * Main entry point and CLI command handlers
 */

import {
  PERMISSIONS,
  PERMISSION_PRESETS,
  CHAINS,
  DATA_KEYS,
  ABIS,
} from './lib/constants.js';

import {
  encodePermissions,
  decodePermissions,
  combinePermissions,
  validatePermissions,
  getPreset,
  listPresets,
  formatPermissions,
  hasPermission,
} from './lib/permissions.js';

import {
  getProvider,
  generateKeyPair,
  encryptAndStoreKey,
  loadKey,
  listKeys,
  isUniversalProfile,
  getKeyManager,
  getProfileInfo,
  getControllerPermissions,
  listControllers,
  formatProfileInfo,
} from './lib/profile.js';

import {
  loadConfig,
  saveConfig,
  setConfigValue,
  getConfigValue,
  getChainConfig,
  getProfileConfig,
  saveProfileConfig,
  formatConfig,
} from './lib/config.js';

/**
 * Parse command line arguments
 * @param {string[]} args - Command line arguments
 * @returns {Object} Parsed command and options
 */
function parseArgs(args) {
  const result = {
    command: [],
    options: {},
    positional: [],
  };
  
  let i = 0;
  while (i < args.length) {
    const arg = args[i];
    
    if (arg.startsWith('--')) {
      const key = arg.slice(2);
      const nextArg = args[i + 1];
      
      if (nextArg && !nextArg.startsWith('-')) {
        result.options[key] = nextArg;
        i += 2;
      } else {
        result.options[key] = true;
        i += 1;
      }
    } else if (arg.startsWith('-')) {
      const key = arg.slice(1);
      const nextArg = args[i + 1];
      
      if (nextArg && !nextArg.startsWith('-')) {
        result.options[key] = nextArg;
        i += 2;
      } else {
        result.options[key] = true;
        i += 1;
      }
    } else {
      if (result.command.length < 2) {
        result.command.push(arg);
      } else {
        result.positional.push(arg);
      }
      i += 1;
    }
  }
  
  return result;
}

/**
 * Command handlers
 */
const commands = {
  /**
   * up key generate - Generate a new controller key
   */
  'key:generate': async (args) => {
    const { options } = args;
    
    console.log('🔐 Generating new controller key...\n');
    
    const keyPair = generateKeyPair();
    
    console.log(`✓ Address: ${keyPair.address}`);
    console.log(`✓ Public Key: ${keyPair.publicKey}`);
    
    if (options.save) {
      const [REDACTED_SECRET] || process.env.UP_KEYSTORE_PASSWORD;
      
      if (!password) {
        console.log('\n⚠️ No password provided. Use --password <password> or set UP_KEYSTORE_PASSWORD');
        console.log('\nPrivate Key (save this securely!):');
        console.log(keyPair.privateKey);
        return;
      }
      
      await encryptAndStoreKey(keyPair.privateKey, password);
      console.log(`\n✓ Key saved to encrypted keystore`);
      console.log('\n⚠️ IMPORTANT: Remember your password! It cannot be recovered.');
    } else {
      console.log('\nPrivate Key (save this securely!):');
      console.log(keyPair.privateKey);
      console.log('\nTo save to keystore, use: up key generate --save --password <password>');
    }
    
    return keyPair;
  },
  
  /**
   * up key list - List stored keys
   */
  'key:list': async () => {
    const keys = await listKeys();
    
    if (keys.length === 0) {
      console.log('No keys stored. Generate one with: up key generate --save');
      return [];
    }
    
    console.log('Stored Keys:\n');
    for (const key of keys) {
      console.log(`  ${key.address}`);
      if (key.createdAt) {
        console.log(`    Created: ${new Date(key.createdAt).toLocaleDateString()}`);
      }
    }
    
    return keys;
  },
  
  /**
   * up status - Check configuration status
   */
  'status': async (args) => {
    const { options } = args;
    const config = await loadConfig();
    
    console.log(formatConfig(config));
    
    // Check keystore
    const keys = await listKeys();
    if (keys.length > 0) {
      console.log(`\n📋 Stored Keys: ${keys.length}`);
      for (const key of keys) {
        console.log(`   - ${key.address}`);
      }
    } else {
      console.log('\n⚠️ No keys stored. Run: up key generate --save');
    }
    
    // Check chain connectivity
    const chainName = options.chain || config.defaultChain;
    const chainConfig = await getChainConfig(chainName);
    
    if (chainConfig) {
      try {
        const provider = getProvider(chainConfig);
        const blockNumber = await provider.getBlockNumber();
        console.log(`\n✓ Connected to ${chainName} (block ${blockNumber})`);
      } catch (err) {
        console.log(`\n✗ Failed to connect to ${chainName}: ${err.message}`);
      }
    }
    
    return { config, keys };
  },
  
  /**
   * up profile info - Get profile information
   */
  'profile:info': async (args) => {
    const { positional, options } = args;
    const config = await loadConfig();
    
    // Get address from args or config
    let upAddress = positional[0];
    
    if (!upAddress) {
      const chainName = options.chain || config.defaultChain;
      const chainConfig = await getChainConfig(chainName);
      const profileConfig = await getProfileConfig(chainConfig?.chainId || chainName);
      
      if (profileConfig?.upAddress) {
        upAddress = profileConfig.upAddress;
      } else {
        console.log('Usage: up profile info <address>');
        console.log('Or configure a default profile first.');
        return null;
      }
    }
    
    const chainName = options.chain || config.defaultChain;
    const chainConfig = await getChainConfig(chainName);
    const provider = getProvider(chainConfig);
    
    // Check if valid UP
    const isUP = await isUniversalProfile(upAddress, provider);
    if (!isUP) {
      console.log(`⚠️ ${upAddress} does not appear to be a Universal Profile`);
      return null;
    }
    
    const info = await getProfileInfo(upAddress, provider);
    console.log(formatProfileInfo(info));
    
    // List controllers if any
    if (info.keyManager && info.controllersCount > 0) {
      console.log('\nControllers:');
      const controllers = await listControllers(upAddress, provider);
      for (const ctrl of controllers) {
        const permsShort = ctrl.permissionNames.slice(0, 3).join(', ');
        const more = ctrl.permissionNames.length > 3 ? ` +${ctrl.permissionNames.length - 3} more` : '';
        console.log(`  ${ctrl.address.slice(0, 10)}...${ctrl.address.slice(-4)}: ${permsShort}${more}`);
      }
    }
    
    return info;
  },
  
  /**
   * up profile configure - Configure a profile for use
   */
  'profile:configure': async (args) => {
    const { positional, options } = args;
    
    if (positional.length === 0) {
      console.log('Usage: up profile configure <up-address> [--key-manager <km-address>] [--chain <chain>]');
      return null;
    }
    
    const upAddress = positional[0];
    const config = await loadConfig();
    const chainName = options.chain || config.defaultChain;
    const chainConfig = await getChainConfig(chainName);
    
    const provider = getProvider(chainConfig);
    
    // Verify it's a UP
    const isUP = await isUniversalProfile(upAddress, provider);
    if (!isUP) {
      console.log(`⚠️ ${upAddress} does not appear to be a Universal Profile`);
      return null;
    }
    
    // Get Key Manager
    let kmAddress = options['key-manager'];
    if (!kmAddress) {
      kmAddress = await getKeyManager(upAddress, provider);
    }
    
    if (!kmAddress) {
      console.log(`⚠️ Could not find Key Manager for ${upAddress}`);
      console.log('Specify manually with --key-manager <address>');
      return null;
    }
    
    // Save profile config
    const profileConfig = {
      upAddress,
      keyManagerAddress: kmAddress,
    };
    
    await saveProfileConfig(chainConfig.chainId, profileConfig);
    
    console.log(`✓ Profile configured for ${chainName}`);
    console.log(`  UP: ${upAddress}`);
    console.log(`  Key Manager: ${kmAddress}`);
    
    return profileConfig;
  },
  
  /**
   * up permissions encode - Encode permissions
   */
  'permissions:encode': async (args) => {
    const { positional } = args;
    
    if (positional.length === 0) {
      console.log('Usage: up permissions encode <permission1> [<permission2> ...]');
      console.log('\nAvailable permissions:');
      Object.keys(PERMISSIONS).forEach(p => console.log(`  - ${p}`));
      return null;
    }
    
    try {
      const encoded = encodePermissions(positional);
      console.log(encoded);
      return encoded;
    } catch (err) {
      console.log(`Error: ${err.message}`);
      return null;
    }
  },
  
  /**
   * up permissions decode - Decode permissions
   */
  'permissions:decode': async (args) => {
    const { positional } = args;
    
    if (positional.length === 0) {
      console.log('Usage: up permissions decode <permissions-hex>');
      return null;
    }
    
    const permHex = positional[0];
    console.log(formatPermissions(permHex));
    
    return decodePermissions(permHex);
  },
  
  /**
   * up permissions presets - List permission presets
   */
  'permissions:presets': async () => {
    const presets = listPresets();
    
    console.log('Permission Presets:\n');
    
    for (const preset of presets) {
      const riskIcon = {
        LOW: '🟢',
        MEDIUM: '🟡',
        HIGH: '🟠',
        CRITICAL: '🔴',
      }[preset.riskLevel];
      
      console.log(`${riskIcon} ${preset.key}`);
      console.log(`   ${preset.description}`);
      console.log(`   Permissions: ${preset.permissions.join(', ')}`);
      console.log(`   Hex: ${preset.permissionsHex.slice(0, 18)}...`);
      if (preset.warning) {
        console.log(`   ⚠️ ${preset.warning}`);
      }
      console.log('');
    }
    
    return presets;
  },
  
  /**
   * up permissions validate - Validate permissions for security
   */
  'permissions:validate': async (args) => {
    const { positional } = args;
    
    if (positional.length === 0) {
      console.log('Usage: up permissions validate <permissions-hex>');
      return null;
    }
    
    const permHex = positional[0];
    const result = validatePermissions(permHex);
    
    const levelIcon = {
      LOW: '🟢',
      MEDIUM: '🟡',
      HIGH: '🟠',
      CRITICAL: '🔴',
    }[result.riskLevel];
    
    console.log(`${levelIcon} Risk Level: ${result.riskLevel}\n`);
    
    console.log('Active Permissions:');
    result.permissions.forEach(p => console.log(`  - ${p}`));
    
    if (result.risks.length > 0) {
      console.log('\n⚠️ SECURITY RISKS:');
      result.risks.forEach(r => console.log(`  🔴 ${r}`));
    }
    
    if (result.warnings.length > 0) {
      console.log('\n⚡ Warnings:');
      result.warnings.forEach(w => console.log(`  🟡 ${w}`));
    }
    
    if (result.valid) {
      console.log('\n✓ No critical security risks detected');
    } else {
      console.log('\n✗ Critical security risks detected - review carefully!');
    }
    
    return result;
  },
  
  /**
   * up authorize url - Generate authorization URL
   */
  'authorize:url': async (args) => {
    const { options } = args;
    const config = await loadConfig();
    
    // Get or generate controller key
    const keys = await listKeys();
    let controllerAddress;
    
    if (keys.length > 0) {
      controllerAddress = keys[0].address;
    } else {
      console.log('No keys found. Generate one first:');
      console.log('  up key generate --save --password <password>\n');
      return null;
    }
    
    // Get permissions
    let permissions;
    const presetName = options.permissions || 'token-operator';
    
    if (presetName.startsWith('0x')) {
      permissions = presetName;
    } else {
      try {
        const preset = getPreset(presetName);
        permissions = preset.permissions;
        console.log(`Using preset: ${preset.name}`);
        console.log(`Description: ${preset.description}\n`);
      } catch (err) {
        console.log(`Unknown preset: ${presetName}`);
        console.log('Available presets: read-only, token-operator, nft-trader, defi-trader, profile-manager, full-access');
        return null;
      }
    }
    
    const chainName = options.chain || config.defaultChain;
    const chainConfig = await getChainConfig(chainName);
    
    // Generate URL (placeholder - actual UI would be hosted)
    const authUrl = `https://up-auth.example.com/?controller=${controllerAddress}&permissions=${permissions}&chain=${chainConfig.chainId}`;
    
    console.log(`✓ Controller Address: ${controllerAddress}`);
    console.log(`✓ Permissions: ${permissions.slice(0, 18)}...`);
    console.log(`✓ Chain: ${chainName} (${chainConfig.chainId})`);
    console.log(`\nAuthorization URL:`);
    console.log(authUrl);
    console.log(`\nInstructions:`);
    console.log('1. Open the URL in a browser with UP extension');
    console.log('2. Connect your Universal Profile');
    console.log('3. Review and approve the permissions');
    console.log('4. Clawdbot will be added as a controller');
    
    return { controllerAddress, permissions, url: authUrl };
  },
  
  /**
   * up config show - Show current configuration
   */
  'config:show': async () => {
    const config = await loadConfig();
    console.log(JSON.stringify(config, null, 2));
    return config;
  },
  
  /**
   * up config set - Set a configuration value
   */
  'config:set': async (args) => {
    const { positional } = args;
    
    if (positional.length < 2) {
      console.log('Usage: up config set <key> <value>');
      console.log('\nAllowed keys:');
      console.log('  defaultChain - Default chain (lukso, lukso-testnet)');
      console.log('\nNote: keystorePath and profiles cannot be changed at runtime for security.');
      return null;
    }
    
    const [key, value] = positional;
    await setConfigValue(key, value);
    console.log(`✓ Set ${key} = ${value}`);
    
    return { key, value };
  },
  
  /**
   * up quota - Check relay quota
   */
  'quota': async (args) => {
    const { options } = args;
    const network = options.chain === 'lukso-testnet' ? 'testnet' : 'mainnet';
    
    console.log('🔍 Checking relay quota...\n');
    
    const { getRelayQuota } = await import('./lib/execute/relay.js');
    const data = await getRelayQuota({ network });
    
    const used = data.totalQuota - data.quota;
    const pct = ((data.quota / data.totalQuota) * 100).toFixed(1);
    const resetDate = new Date(data.resetDate * 1000);
    
    console.log(`⛽ Relay Quota`);
    console.log(`  Remaining: ${data.quota.toLocaleString()} ${data.unit} (${pct}%)`);
    console.log(`  Used:      ${used.toLocaleString()} ${data.unit}`);
    console.log(`  Total:     ${data.totalQuota.toLocaleString()} ${data.unit}`);
    console.log(`  Resets:    ${resetDate.toLocaleDateString()} ${resetDate.toLocaleTimeString()}`);
    
    return data;
  },

  /**
   * up help - Show help
   */
  'help': async () => {
    console.log(`
Universal Profile Skill for Clawdbot

Usage: up <command> [subcommand] [options]

Commands:
  key generate [--save] [--password <pw>]  Generate a new controller key
  key list                                 List stored keys
  
  status [--chain <chain>]                 Check configuration status
  
  profile info [<address>] [--chain]       Get profile information
  profile configure <address> [--chain]    Configure a profile for use
  
  permissions encode <perm1> [perm2...]    Encode permissions to hex
  permissions decode <hex>                 Decode permissions from hex
  permissions presets                      List permission presets
  permissions validate <hex>               Validate permissions for security
  
  authorize url [--permissions <preset>]   Generate authorization URL
  
  quota [--chain <chain>]                  Check relay gas quota
  
  config show                              Show current configuration
  config set <key> <value>                 Set configuration value
  
  help                                     Show this help

Options:
  --chain <name>       Chain to use (lukso, lukso-testnet)
  --password <pw>      Password for keystore operations
  --permissions <p>    Permission preset or hex value

Examples:
  up key generate --save --password mysecret
  up profile info 0x1234...
  up permissions encode CALL TRANSFERVALUE
  up permissions validate 0x0000...0801
  up authorize url --permissions token-operator
`);
  },
};

/**
 * Main entry point
 */
export async function main(args) {
  const parsed = parseArgs(args);
  
  // Build command key
  const cmdParts = parsed.command;
  let handler;
  
  if (cmdParts.length === 0 || cmdParts[0] === 'help') {
    handler = commands['help'];
  } else if (cmdParts.length === 1) {
    // Single word command like "status"
    handler = commands[cmdParts[0]];
  } else {
    // Two word command like "key generate"
    const cmdKey = `${cmdParts[0]}:${cmdParts[1]}`;
    handler = commands[cmdKey];
    
    // Adjust positional args
    if (parsed.command.length > 2) {
      parsed.positional = [...parsed.command.slice(2), ...parsed.positional];
    }
  }
  
  if (!handler) {
    console.log(`Unknown command: ${cmdParts.join(' ')}`);
    console.log('Run "up help" for usage information.');
    return null;
  }
  
  try {
    return await handler(parsed);
  } catch (err) {
    console.error(`Error: ${err.message}`);
    if (process.env.DEBUG) {
      console.error(err.stack);
    }
    return null;
  }
}

// CLI entry point
if (process.argv[1] === new URL(import.meta.url).pathname || 
    process.argv[1]?.endsWith('/index.js')) {
  main(process.argv.slice(2)).catch(console.error);
}

// Export everything for programmatic use
export {
  // Constants
  PERMISSIONS,
  PERMISSION_PRESETS,
  CHAINS,
  DATA_KEYS,
  ABIS,
  
  // Permissions
  encodePermissions,
  decodePermissions,
  combinePermissions,
  validatePermissions,
  getPreset,
  listPresets,
  formatPermissions,
  hasPermission,
  
  // Profile
  getProvider,
  generateKeyPair,
  encryptAndStoreKey,
  loadKey,
  listKeys,
  isUniversalProfile,
  getKeyManager,
  getProfileInfo,
  getControllerPermissions,
  listControllers,
  formatProfileInfo,
  
  // Config
  loadConfig,
  saveConfig,
  setConfigValue,
  getConfigValue,
  getChainConfig,
  getProfileConfig,
  saveProfileConfig,
  formatConfig,
};

export default {
  main,
  commands,
};


path: package-lock.json
content: {
  "name": "skill-universal-profile",
  "version": "0.9.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "skill-universal-profile",
      "version": "0.9.0",
      "license": "MIT",
      "dependencies": {
        "ethers": "^6.9.0",
        "viem": "^2.45.1"
      },
      "bin": {
        "up": "index.js"
      },
      "devDependencies": {
        "eslint": "^8.56.0",
        "prettier": "^3.2.0"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@adraffy/ens-normalize": {
      "version": "1.10.1",
      "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/@eslint-community/eslint-utils": {
      "version": "4.9.1",
      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "eslint-visitor-keys": "^3.4.3"
      },
      "engines": {
        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      },
      "peerDependencies": {
        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
      }
    },
    "node_modules/@eslint-community/regexpp": {
      "version": "4.12.2",
      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
      }
    },
    "node_modules/@eslint/eslintrc": {
      "version": "2.1.4",
      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ajv": "^6.12.4",
        "debug": "^4.3.2",
        "espree": "^9.6.0",
        "globals": "^13.19.0",
        "ignore": "^5.2.0",
        "import-fresh": "^3.2.1",
        "js-yaml": "^4.1.0",
        "minimatch": "^3.1.2",
        "strip-json-comments": "^3.1.1"
      },
      "engines": {
        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/@eslint/js": {
      "version": "8.57.1",
      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
      }
    },
    "node_modules/@humanwhocodes/config-array": {
      "version": "0.13.0",
      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
      "integrity": [REDACTED_SECRET],
      "deprecated": "Use @eslint/config-array instead",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@humanwhocodes/object-schema": "^2.0.3",
        "debug": "^4.3.1",
        "minimatch": "^3.0.5"
      },
      "engines": {
        "node": ">=10.10.0"
      }
    },
    "node_modules/@humanwhocodes/module-importer": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=12.22"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/nzakas"
      }
    },
    "node_modules/@humanwhocodes/object-schema": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "deprecated": "Use @eslint/object-schema instead",
      "dev": true,
      "license": "BSD-3-Clause"
    },
    "node_modules/@noble/ciphers": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": "^14.21.3 || >=16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@noble/curves": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@noble/hashes": "1.3.2"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@noble/hashes": {
      "version": "1.3.2",
      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@nodelib/fs.scandir": {
      "version": "2.1.5",
      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@nodelib/fs.stat": "2.0.5",
        "run-parallel": "^1.1.9"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/@nodelib/fs.stat": {
      "version": "2.0.5",
      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/@nodelib/fs.walk": {
      "version": "1.2.8",
      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@nodelib/fs.scandir": "2.1.5",
        "fastq": "^1.6.0"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/@scure/base": {
      "version": "1.2.6",
      "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@scure/bip32": {
      "version": "1.7.0",
      "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@noble/curves": "~1.9.0",
        "@noble/hashes": "~1.8.0",
        "@scure/base": "~1.2.5"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@scure/bip32/node_modules/@noble/curves": {
      "version": "1.9.7",
      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@noble/hashes": "1.8.0"
      },
      "engines": {
        "node": "^14.21.3 || >=16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@scure/bip32/node_modules/@noble/hashes": {
      "version": "1.8.0",
      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": "^14.21.3 || >=16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@scure/bip39": {
      "version": "1.6.0",
      "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@noble/hashes": "~1.8.0",
        "@scure/base": "~1.2.5"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@scure/bip39/node_modules/@noble/hashes": {
      "version": "1.8.0",
      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": "^14.21.3 || >=16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@types/node": {
      "version": "22.7.5",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "undici-types": "~6.19.2"
      }
    },
    "node_modules/@ungap/structured-clone": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "ISC"
    },
    "node_modules/abitype": {
      "version": "1.2.3",
      "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/wevm"
      },
      "peerDependencies": {
        "typescript": ">=5.0.4",
        "zod": "^3.22.0 || ^4.0.0"
      },
      "peerDependenciesMeta": {
        "typescript": {
          "optional": true
        },
        "zod": {
          "optional": true
        }
      }
    },
    "node_modules/acorn": {
      "version": "8.16.0",
      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "bin": {
        "acorn": "bin/acorn"
      },
      "engines": {
        "node": ">=0.4.0"
      }
    },
    "node_modules/acorn-jsx": {
      "version": "5.3.2",
      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "peerDependencies": {
        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
      }
    },
    "node_modules/aes-js": {
      "version": "4.0.0-beta.5",
      "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/ajv": {
      "version": "6.14.0",
      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "fast-deep-equal": "^3.1.1",
        "fast-json-stable-stringify": "^2.0.0",
        "json-schema-traverse": "^0.4.1",
        "uri-js": "^4.2.2"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/epoberezkin"
      }
    },
    "node_modules/ansi-regex": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/ansi-styles": {
      "version": "4.3.0",
      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "color-convert": "^2.0.1"
      },
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
      }
    },
    "node_modules/argparse": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "Python-2.0"
    },
    "node_modules/balanced-match": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/brace-expansion": {
      "version": "1.1.12",
      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "balanced-match": "^1.0.0",
        "concat-map": "0.0.1"
      }
    },
    "node_modules/callsites": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/chalk": {
      "version": "4.1.2",
      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ansi-styles": "^4.1.0",
        "supports-color": "^7.1.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/chalk/chalk?sponsor=1"
      }
    },
    "node_modules/color-convert": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "color-name": "~1.1.4"
      },
      "engines": {
        "node": ">=7.0.0"
      }
    },
    "node_modules/color-name": {
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/concat-map": {
      "version": "0.0.1",
      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/cross-spawn": {
      "version": "7.0.6",
      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "path-key": "^3.1.0",
        "shebang-command": "^2.0.0",
        "which": "^2.0.1"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/debug": {
      "version": "4.4.3",
      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ms": "^2.1.3"
      },
      "engines": {
        "node": ">=6.0"
      },
      "peerDependenciesMeta": {
        "supports-color": {
          "optional": true
        }
      }
    },
    "node_modules/deep-is": {
      "version": "0.1.4",
      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/doctrine": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "esutils": "^2.0.2"
      },
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/escape-string-regexp": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/eslint": {
      "version": "8.57.1",
      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
      "integrity": [REDACTED_SECRET],
      "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@eslint-community/eslint-utils": "^4.2.0",
        "@eslint-community/regexpp": "^4.6.1",
        "@eslint/eslintrc": "^2.1.4",
        "@eslint/js": "8.57.1",
        "@humanwhocodes/config-array": "^0.13.0",
        "@humanwhocodes/module-importer": "^1.0.1",
        "@nodelib/fs.walk": "^1.2.8",
        "@ungap/structured-clone": "^1.2.0",
        "ajv": "^6.12.4",
        "chalk": "^4.0.0",
        "cross-spawn": "^7.0.2",
        "debug": "^4.3.2",
        "doctrine": "^3.0.0",
        "escape-string-regexp": "^4.0.0",
        "eslint-scope": "^7.2.2",
        "eslint-visitor-keys": "^3.4.3",
        "espree": "^9.6.1",
        "esquery": "^1.4.2",
        "esutils": "^2.0.2",
        "fast-deep-equal": "^3.1.3",
        "file-entry-cache": "^6.0.1",
        "find-up": "^5.0.0",
        "glob-parent": "^6.0.2",
        "globals": "^13.19.0",
        "graphemer": "^1.4.0",
        "ignore": "^5.2.0",
        "imurmurhash": "^0.1.4",
        "is-glob": "^4.0.0",
        "is-path-inside": "^3.0.3",
        "js-yaml": "^4.1.0",
        "json-stable-stringify-without-jsonify": "^1.0.1",
        "levn": "^0.4.1",
        "lodash.merge": "^4.6.2",
        "minimatch": "^3.1.2",
        "natural-compare": "^1.4.0",
        "optionator": "^0.9.3",
        "strip-ansi": "^6.0.1",
        "text-table": "^0.2.0"
      },
      "bin": {
        "eslint": "bin/eslint.js"
      },
      "engines": {
        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/eslint-scope": {
      "version": "7.2.2",
      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "esrecurse": "^4.3.0",
        "estraverse": "^5.2.0"
      },
      "engines": {
        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/eslint-visitor-keys": {
      "version": "3.4.3",
      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/espree": {
      "version": "9.6.1",
      "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "acorn": "^8.9.0",
        "acorn-jsx": "^5.3.2",
        "eslint-visitor-keys": "^3.4.1"
      },
      "engines": {
        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/esquery": {
      "version": "1.7.0",
      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "BSD-3-Clause",
      "dependencies": {
        "estraverse": "^5.1.0"
      },
      "engines": {
        "node": ">=0.10"
      }
    },
    "node_modules/esrecurse": {
      "version": "4.3.0",
      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "estraverse": "^5.2.0"
      },
      "engines": {
        "node": ">=4.0"
      }
    },
    "node_modules/estraverse": {
      "version": "5.3.0",
      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=4.0"
      }
    },
    "node_modules/esutils": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/ethers": {
      "version": "6.16.0",
      "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "individual",
          "url": "https://github.com/sponsors/ethers-io/"
        },
        {
          "type": "individual",
          "url": "https://www.buymeacoffee.com/ricmoo"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "@adraffy/ens-normalize": "1.10.1",
        "@noble/curves": "1.2.0",
        "@noble/hashes": "1.3.2",
        "@types/node": "22.7.5",
        "aes-js": "4.0.0-beta.5",
        "tslib": "2.7.0",
        "ws": "8.17.1"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/eventemitter3": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/fast-deep-equal": {
      "version": "3.1.3",
      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fast-json-stable-stringify": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fast-levenshtein": {
      "version": "2.0.6",
      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fastq": {
      "version": "1.20.1",
      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "reusify": "^1.0.4"
      }
    },
    "node_modules/file-entry-cache": {
      "version": "6.0.1",
      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "flat-cache": "^3.0.4"
      },
      "engines": {
        "node": "^10.12.0 || >=12.0.0"
      }
    },
    "node_modules/find-up": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "locate-path": "^6.0.0",
        "path-exists": "^4.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/flat-cache": {
      "version": "3.2.0",
      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "flatted": "^3.2.9",
        "keyv": "^4.5.3",
        "rimraf": "^3.0.2"
      },
      "engines": {
        "node": "^10.12.0 || >=12.0.0"
      }
    },
    "node_modules/flatted": {
      "version": "3.3.4",
      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "ISC"
    },
    "node_modules/fs.realpath": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "ISC"
    },
    "node_modules/glob": {
      "version": "7.2.3",
      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
      "integrity": [REDACTED_SECRET],
      "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting [REDACTED_SECRET]",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "fs.realpath": "^1.0.0",
        "inflight": "^1.0.4",
        "inherits": "2",
        "minimatch": "^3.1.1",
        "once": "^1.3.0",
        "path-is-absolute": "^1.0.0"
      },
      "engines": {
        "node": "*"
      },
      "funding": {
        "url": "https://github.com/sponsors/isaacs"
      }
    },
    "node_modules/glob-parent": {
      "version": "6.0.2",
      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "is-glob": "^4.0.3"
      },
      "engines": {
        "node": ">=10.13.0"
      }
    },
    "node_modules/globals": {
      "version": "13.24.0",
      "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "type-fest": "^0.20.2"
      },
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/graphemer": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/has-flag": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/ignore": {
      "version": "5.3.2",
      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 4"
      }
    },
    "node_modules/import-fresh": {
      "version": "3.3.1",
      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "parent-module": "^1.0.0",
        "resolve-from": "^4.0.0"
      },
      "engines": {
        "node": ">=6"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/imurmurhash": {
      "version": "0.1.4",
      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.8.19"
      }
    },
    "node_modules/inflight": {
      "version": "1.0.6",
      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
      "integrity": [REDACTED_SECRET],
      "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "once": "^1.3.0",
        "wrappy": "1"
      }
    },
    "node_modules/inherits": {
      "version": "2.0.4",
      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "ISC"
    },
    "node_modules/is-extglob": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/is-glob": {
      "version": "4.0.3",
      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "is-extglob": "^2.1.1"
      },
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/is-path-inside": {
      "version": "3.0.3",
      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/isexe": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "ISC"
    },
    "node_modules/isows": {
      "version": "1.0.7",
      "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/wevm"
        }
      ],
      "license": "MIT",
      "peerDependencies": {
        "ws": "*"
      }
    },
    "node_modules/js-yaml": {
      "version": "4.1.1",
      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "argparse": "^2.0.1"
      },
      "bin": {
        "js-yaml": "bin/js-yaml.js"
      }
    },
    "node_modules/json-buffer": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/json-schema-traverse": {
      "version": "0.4.1",
      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/json-stable-stringify-without-jsonify": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/keyv": {
      "version": "4.5.4",
      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "json-buffer": "3.0.1"
      }
    },
    "node_modules/levn": {
      "version": "0.4.1",
      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "prelude-ls": "^1.2.1",
        "type-check": "~0.4.0"
      },
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/locate-path": {
      "version": "6.0.0",
      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "p-locate": "^5.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/lodash.merge": {
      "version": "4.6.2",
      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/minimatch": {
      "version": "3.1.5",
      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "brace-expansion": "^1.1.7"
      },
      "engines": {
        "node": "*"
      }
    },
    "node_modules/ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/natural-compare": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/once": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "wrappy": "1"
      }
    },
    "node_modules/optionator": {
      "version": "0.9.4",
      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "deep-is": "^0.1.3",
        "fast-levenshtein": "^2.0.6",
        "levn": "^0.4.1",
        "prelude-ls": "^1.2.1",
        "type-check": "^0.4.0",
        "word-wrap": "^1.2.5"
      },
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/ox": {
      "version": "0.12.4",
      "resolved": "https://registry.npmjs.org/ox/-/ox-0.12.4.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/wevm"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "@adraffy/ens-normalize": "^1.11.0",
        "@noble/ciphers": "^1.3.0",
        "@noble/curves": "1.9.1",
        "@noble/hashes": "^1.8.0",
        "@scure/bip32": "^1.7.0",
        "@scure/bip39": "^1.6.0",
        "abitype": "^1.2.3",
        "eventemitter3": "5.0.1"
      },
      "peerDependencies": {
        "typescript": ">=5.4.0"
      },
      "peerDependenciesMeta": {
        "typescript": {
          "optional": true
        }
      }
    },
    "node_modules/ox/node_modules/@adraffy/ens-normalize": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/ox/node_modules/@noble/curves": {
      "version": "1.9.1",
      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@noble/hashes": "1.8.0"
      },
      "engines": {
        "node": "^14.21.3 || >=16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/ox/node_modules/@noble/hashes": {
      "version": "1.8.0",
      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": "^14.21.3 || >=16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/p-limit": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "yocto-queue": "^0.1.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/p-locate": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "p-limit": "^3.0.2"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/parent-module": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "callsites": "^3.0.0"
      },
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/path-exists": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/path-is-absolute": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/path-key": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/prelude-ls": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/prettier": {
      "version": "3.8.1",
      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "bin": {
        "prettier": "bin/prettier.cjs"
      },
      "engines": {
        "node": ">=14"
      },
      "funding": {
        "url": "https://github.com/prettier/prettier?sponsor=1"
      }
    },
    "node_modules/punycode": {
      "version": "2.3.1",
      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/queue-microtask": {
      "version": "1.2.3",
      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT"
    },
    "node_modules/resolve-from": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=4"
      }
    },
    "node_modules/reusify": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "iojs": ">=1.0.0",
        "node": ">=0.10.0"
      }
    },
    "node_modules/rimraf": {
      "version": "3.0.2",
      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "deprecated": "Rimraf versions prior to v4 are no longer supported",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "glob": "^7.1.3"
      },
      "bin": {
        "rimraf": "bin.js"
      },
      "funding": {
        "url": "https://github.com/sponsors/isaacs"
      }
    },
    "node_modules/run-parallel": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "queue-microtask": "^1.2.2"
      }
    },
    "node_modules/shebang-command": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "shebang-regex": "^3.0.0"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/shebang-regex": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/strip-ansi": {
      "version": "6.0.1",
      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ansi-regex": "^5.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/strip-json-comments": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/supports-color": {
      "version": "7.2.0",
      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "has-flag": "^4.0.0"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/text-table": {
      "version": "0.2.0",
      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    },
    "node_modules/tslib": {
      "version": "2.7.0",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "0BSD"
    },
    "node_modules/type-check": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "prelude-ls": "^1.2.1"
      },
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/type-fest": {
      "version": "0.20.2",
      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "(MIT OR CC0-1.0)",
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/undici-types": {
      "version": "6.19.8",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/uri-js": {
      "version": "4.4.1",
      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "punycode": "^2.1.0"
      }
    },
    "node_modules/viem": {
      "version": "2.46.3",
      "resolved": "https://registry.npmjs.org/viem/-/viem-2.46.3.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/wevm"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "@noble/curves": "1.9.1",
        "@noble/hashes": "1.8.0",
        "@scure/bip32": "1.7.0",
        "@scure/bip39": "1.6.0",
        "abitype": "1.2.3",
        "isows": "1.0.7",
        "ox": "0.12.4",
        "ws": "8.18.3"
      },
      "peerDependencies": {
        "typescript": ">=5.0.4"
      },
      "peerDependenciesMeta": {
        "typescript": {
          "optional": true
        }
      }
    },
    "node_modules/viem/node_modules/@noble/curves": {
      "version": "1.9.1",
      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@noble/hashes": "1.8.0"
      },
      "engines": {
        "node": "^14.21.3 || >=16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/viem/node_modules/@noble/hashes": {
      "version": "1.8.0",
      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": "^14.21.3 || >=16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/viem/node_modules/ws": {
      "version": "8.18.3",
      "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=10.0.0"
      },
      "peerDependencies": {
        "bufferutil": "^4.0.1",
        "utf-8-validate": ">=5.0.2"
      },
      "peerDependenciesMeta": {
        "bufferutil": {
          "optional": true
        },
        "utf-8-validate": {
          "optional": true
        }
      }
    },
    "node_modules/which": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "isexe": "^2.0.0"
      },
      "bin": {
        "node-which": "bin/node-which"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/word-wrap": {
      "version": "1.2.5",
      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/wrappy": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "ISC"
    },
    "node_modules/ws": {
      "version": "8.17.1",
      "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=10.0.0"
      },
      "peerDependencies": {
        "bufferutil": "^4.0.1",
        "utf-8-validate": ">=5.0.2"
      },
      "peerDependenciesMeta": {
        "bufferutil": {
          "optional": true
        },
        "utf-8-validate": {
          "optional": true
        }
      }
    },
    "node_modules/yocto-queue": {
      "version": "0.1.0",
      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    }
  }
}


path: package.json
content: {
  "name": "skill-universal-profile",
  "version": "0.9.0",
  "description": "Universal Profile skill for OpenClaw — LUKSO blockchain identity, permissions, tokens, and gasless relay transactions",
  "main": "index.js",
  "bin": {
    "up": "index.js"
  },
  "type": "module",
  "scripts": {
    "test": "node --test tests/*.test.js",
    "lint": "eslint .",
    "format": "prettier --write .",
    "audit": "npm audit"
  },
  "keywords": [
    "openclaw",
    "skill",
    "lukso",
    "universal-profile",
    "lsp6",
    "blockchain",
    "web3"
  ],
  "author": "frozeman",
  "license": "MIT",
  "dependencies": {
    "ethers": "^6.9.0",
    "viem": "^2.45.1"
  },
  "devDependencies": {
    "eslint": "^8.56.0",
    "prettier": "^3.2.0"
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "files": [
    "index.js",
    "lib/**/*.js",
    "commands/**/*.js",
    "SKILL.md"
  ]
}


path: commands/transfer-lsp7.js
content: #!/usr/bin/env node
/**
 * Transfer LSP7 Tokens CLI
 * 
 * Unified command that can use either direct or relay execution.
 * Uses the modular lib/ structure.
 * 
 * Usage:
 *   node transfer-lsp7.js <token> <to> <amount> [--relay]
 * 
 * Examples:
 *   # Direct execution (controller pays gas)
 *   node transfer-lsp7.js 0x403b... 0x378B... 100
 * 
 *   # Relay execution (gasless, requires SIGN permission)
 *   node transfer-lsp7.js 0x403b... 0x378B... 100 --relay
 */

import { 
  getLSP7Info, 
  getLSP7Balance, 
  buildLSP7TransferPayload, 
  validateLSP7Transfer 
} from '../lib/tokens/lsp7.js';
import { 
  buildExecutePayload, 
  executeDirect,
  executeRelay 
} from '../lib/execute/index.js';
import { loadCredentials } from '../lib/credentials.js';

async function transfer(tokenAddress, toAddress, amount, useRelay = false, options = {}) {
  const creds = loadCredentials();
  const upAddress = creds.universalProfile.address;

  console.log('🐙 LSP7 Token Transfer');
  console.log('======================');
  console.log('From UP:', upAddress);
  console.log('To:', toAddress);
  console.log('Amount:', amount);
  console.log('Mode:', useRelay ? 'Relay (gasless)' : 'Direct (pay gas)');
  console.log('');

  // Get token info
  const info = await getLSP7Info(tokenAddress);
  console.log(`[REDACTED_SECRET] (${info.symbol})`);
  console.log(`Decimals: ${info.decimals}`);

  // Check balance
  const { formatted: balance } = await getLSP7Balance(tokenAddress, upAddress);
  console.log(`Balance: ${balance} ${info.symbol}`);
  console.log('');

  // Validate
  const validation = await validateLSP7Transfer(tokenAddress, upAddress, amount);
  if (!validation.valid) {
    console.error('❌', validation.error);
    process.exit(1);
  }

  // Build transfer payload
  console.log('📝 Building transaction...');
  const transferData = await buildLSP7TransferPayload(
    tokenAddress,
    upAddress,  // from
    toAddress,  // to
    amount
  );

  // Wrap in UP.execute()
  const payload = buildExecutePayload(
    0,             // CALL operation
    tokenAddress,  // target
    0,             // value (0 LYX)
    transferData   // calldata
  );

  // Execute
  console.log('📤 Sending transaction...');
  let result;
  
  if (useRelay) {
    result = await executeRelay(payload);
  } else {
    // Direct execution: controller calls UP.execute() and pays gas
    result = await executeDirect(
      0,             // CALL operation
      tokenAddress,  // target
      0,             // value (0)
      transferData,  // calldata
      { network: options.network || 'mainnet' }
    );
  }

  console.log('');
  console.log('✅ Transfer successful!');
  console.log('TX:', result.txHash);
  console.log('Explorer:', result.explorerUrl);

  return result;
}

// CLI
if (import.meta.url === `file://${process.argv[1]}`) {
  const args = process.argv.slice(2);
  const useRelay = args.includes('--relay');
  const filteredArgs = args.filter(a => a !== '--relay');
  
  const [tokenAddress, toAddress, amount] = filteredArgs;

  if (!tokenAddress || !toAddress || !amount) {
    console.error('Usage: node transfer-lsp7.js <token-address> <to-address> <amount> [--relay]');
    console.error('');
    console.error('Options:');
    console.error('  --relay    Use relay API (gasless, requires SIGN permission)');
    console.error('');
    console.error('Examples:');
    console.error('  node transfer-lsp7.js 0x403b... 0x378B... 100');
    console.error('  node transfer-lsp7.js 0x403b... 0x378B... 100 --relay');
    process.exit(1);
  }

  transfer(tokenAddress, toAddress, amount, useRelay).catch(error => {
    console.error('');
    console.error('❌ Error:', error.message);
    process.exit(1);
  });
}

export { transfer };


path: commands/query-assets.js
content: #!/usr/bin/env node
/**
 * Query Universal Profile assets via LUKSO Indexer (Envio GraphQL)
 * 
 * Usage:
 *   node query-assets.js <up-address>
 * 
 * Returns: JSON array of assets held by the UP
 */

const INDEXER_ENDPOINT = 'https://envio.lukso-mainnet.universal.tech/v1/graphql';

export async function queryAssets(upAddress) {
  // Validate address format to prevent GraphQL injection
  if (!upAddress || !/^0x[a-fA-F0-9]{40}$/.test(upAddress)) {
    throw new Error('Invalid address format. Expected 0x followed by 40 hex characters.');
  }

  const query = `{
    Hold(where: { profile_id: { _ilike: "${upAddress.toLowerCase()}" } }) {
      asset_id
      balance
      asset {
        id
        name
        description
      }
    }
  }`;

  try {
    const response = await fetch(INDEXER_ENDPOINT, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query })
    });

    const result = await response.json();
    
    if (result.errors) {
      throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
    }

    return result.data.Hold;
  } catch (error) {
    throw new Error(`Failed to query assets: ${error.message}`);
  }
}

// CLI execution
if (import.meta.url === `file://${process.argv[1]}`) {
  const upAddress = process.argv[2];
  
  if (!upAddress) {
    console.error('Usage: node query-assets.js <up-address>');
    process.exit(1);
  }

  queryAssets(upAddress)
    .then(assets => {
      console.log(JSON.stringify(assets, null, 2));
    })
    .catch(error => {
      console.error('Error:', error.message);
      process.exit(1);
    });
}


path: commands/cross-chain-deploy-data.js
content: #!/usr/bin/env node

/**
 * cross-chain-deploy-data.js
 *
 * Retrieves the LSP23 deployment calldata for a Universal Profile,
 * enabling cross-chain redeployment on any network where the same
 * LSP23 factory and implementation contracts are deployed.
 *
 * Usage:
 *   node cross-chain-deploy-data.js <upAddress> [--chain lukso|lukso-testnet] [--verify]
 *
 * The --verify flag checks that the required base contracts exist on
 * all supported target chains (Base, Ethereum).
 */

import { ethers } from 'ethers';

// ── Constants ──────────────────────────────────────────────────────

const LSP23_FACTORY = [REDACTED_SECRET];

const EVENTS = {
  DeployedERC1167Proxies: '0xe20570ed9bda3b93eea277b4e5d975c8933fd5f85f2c824d0845ae96c55a54fe',
  DeployedContracts: '0x1ea27dabd8fd1508e844ab51c2fd3d9081f2684346857f9187da6d4a1aa7d3e6',
};

const DEPLOY_SELECTOR = '0x6a66a753'; // deployERC1167Proxies

// Base contracts that must exist on target chains for redeployment
const BASE_CONTRACTS = {
  LSP23LinkedContractsFactory: [REDACTED_SECRET],
  UniversalProfileInit_v014: [REDACTED_SECRET],
  LSP6KeyManagerInit_v014: [REDACTED_SECRET],
  PostDeploymentModule: [REDACTED_SECRET],
};

const RPCS = {
  lukso: 'https://rpc.mainnet.lukso.network',
  'lukso-testnet': 'https://rpc.testnet.lukso.network',
  base: 'https://mainnet.base.org',
  ethereum: 'https://eth.llamarpc.com',
};

// ── Core Functions ─────────────────────────────────────────────────

/**
 * Find the LSP23 deployment event for a Universal Profile.
 * Returns the transaction hash of the deployment.
 */
async function findDeploymentTx(upAddress, rpcUrl) {
  const provider = new ethers.JsonRpcProvider(rpcUrl);
  const upTopic = ethers.zeroPadValue(upAddress, 32);

  // Try proxy deployment first (most common)
  let logs = await provider.getLogs({
    address: LSP23_FACTORY,
    topics: [EVENTS.DeployedERC1167Proxies, upTopic],
    fromBlock: 0,
    toBlock: 'latest',
  });

  let deploymentType = 'proxy';

  // Fall back to full contract deployment
  if (logs.length === 0) {
    logs = await provider.getLogs({
      address: LSP23_FACTORY,
      topics: [EVENTS.DeployedContracts, upTopic],
      fromBlock: 0,
      toBlock: 'latest',
    });
    deploymentType = 'full';
  }

  if (logs.length === 0) {
    return null;
  }

  return {
    txHash: logs[0].transactionHash,
    blockNumber: parseInt(logs[0].blockNumber, 16) || logs[0].blockNumber,
    deploymentType,
    keyManagerAddress: logs[0].topics[2]
      ? ethers.getAddress('0x' + logs[0].topics[2].slice(26))
      : null,
  };
}

/**
 * Get the full deployment calldata from a transaction.
 */
async function getDeploymentCalldata(txHash, rpcUrl) {
  const provider = new ethers.JsonRpcProvider(rpcUrl);
  const tx = await provider.getTransaction(txHash);

  if (!tx) throw new Error(`Transaction ${txHash} not found`);
  if (tx.to?.toLowerCase() !== LSP23_FACTORY.toLowerCase()) {
    throw new Error(`Transaction target is not LSP23 factory: ${tx.to}`);
  }

  const selector = tx.data.slice(0, 10);
  if (selector !== DEPLOY_SELECTOR) {
    console.warn(`⚠️  Unexpected function selector: ${selector} (expected ${DEPLOY_SELECTOR})`);
  }

  return {
    factoryAddress: tx.to,
    calldata: tx.data,
    calldataLength: (tx.data.length - 2) / 2,
    functionSelector: selector,
    value: tx.value.toString(),
    from: tx.from,
  };
}

/**
 * Verify that all required base contracts exist on a target chain.
 */
async function verifyBaseContracts(rpcUrl, chainName) {
  const provider = new ethers.JsonRpcProvider(rpcUrl);
  const results = {};
  let allPresent = true;

  for (const [name, address] of Object.entries(BASE_CONTRACTS)) {
    const code = await provider.getCode(address);
    const present = code !== '0x' && code.length > 2;
    const byteSize = present ? (code.length - 2) / 2 : 0;
    results[name] = { address, present, byteSize };
    if (!present) allPresent = false;
  }

  return { chain: chainName, allPresent, contracts: results };
}

/**
 * Full retrieval: find deployment + get calldata + optional verification.
 */
async function getCrossChainDeployData(upAddress, options = {}) {
  const chain = options.chain || 'lukso';
  const rpcUrl = RPCS[chain];
  if (!rpcUrl) throw new Error(`Unknown chain: ${chain}`);

  // 1. Find deployment
  const deployment = await findDeploymentTx(upAddress, rpcUrl);
  if (!deployment) {
    throw new Error(
      `No LSP23 deployment found for ${upAddress} on ${chain}. ` +
        'This UP may have been deployed via legacy lsp-factory (pre-LSP23).'
    );
  }

  // 2. Get calldata
  const calldataInfo = await getDeploymentCalldata(deployment.txHash, rpcUrl);

  const result = {
    upAddress: ethers.getAddress(upAddress),
    keyManagerAddress: deployment.keyManagerAddress,
    sourceChain: chain,
    deploymentType: deployment.deploymentType,
    blockNumber: deployment.blockNumber,
    txHash: deployment.txHash,
    factoryAddress: calldataInfo.factoryAddress,
    functionSelector: calldataInfo.functionSelector,
    calldataLength: calldataInfo.calldataLength,
    calldata: calldataInfo.calldata,
    value: calldataInfo.value,
  };

  // 3. Optional: verify target chains
  if (options.verify) {
    const targetChains = ['lukso', 'base', 'ethereum'].filter((c) => c !== chain);
    result.targetChainVerification = {};
    for (const target of targetChains) {
      result.targetChainVerification[target] = await verifyBaseContracts(RPCS[target], target);
    }
  }

  return result;
}

// ── CLI ────────────────────────────────────────────────────────────

async function main() {
  const args = process.argv.slice(2);
  const upAddress = args.find((a) => a.startsWith('0x'));
  const chain = args.includes('--chain') ? args[args.indexOf('--chain') + 1] : 'lukso';
  const verify = args.includes('--verify');
  const jsonOutput = args.includes('--json');

  if (!upAddress) {
    console.error('Usage: cross-chain-deploy-data.js <upAddress> [--chain lukso] [--verify] [--json]');
    process.exit(1);
  }

  try {
    const result = await getCrossChainDeployData(upAddress, { chain, verify });

    if (jsonOutput) {
      console.log(JSON.stringify(result, null, 2));
      return;
    }

    console.log('\n🔗 LSP23 Cross-Chain Deployment Data');
    console.log('════════════════════════════════════════');
    console.log(`UP Address:       ${result.upAddress}`);
    console.log(`KeyManager:       ${result.keyManagerAddress}`);
    console.log(`Source Chain:     ${result.sourceChain}`);
    console.log(`Deployment Type:  ${result.deploymentType}`);
    console.log(`Block:            ${result.blockNumber}`);
    console.log(`TX Hash:          ${result.txHash}`);
    console.log(`Factory:          ${result.factoryAddress}`);
    console.log(`Selector:         ${result.functionSelector}`);
    console.log(`Calldata Size:    ${result.calldataLength} bytes`);
    console.log(`Value:            ${result.value} wei`);
    console.log('\n📦 Calldata:');
    console.log(result.calldata);

    if (result.targetChainVerification) {
      console.log('\n🌐 Target Chain Verification:');
      for (const [chainName, info] of Object.entries(result.targetChainVerification)) {
        const status = info.allPresent ? '✅' : '❌';
        console.log(`\n  ${status} ${chainName}`);
        for (const [name, contract] of Object.entries(info.contracts)) {
          const icon = contract.present ? '✅' : '❌';
          const size = contract.present ? `${contract.byteSize.toLocaleString()} bytes` : 'missing';
          console.log(`    ${icon} ${name}: ${size}`);
        }
      }
    }

    console.log('\n💡 To redeploy on another chain, send this calldata to');
    console.log(`   ${result.factoryAddress} on the target chain.`);
    console.log('   The same salt produces the same UP address.\n');
  } catch (err) {
    console.error(`❌ ${err.message}`);
    process.exit(1);
  }
}

export { getCrossChainDeployData, findDeploymentTx, getDeploymentCalldata, verifyBaseContracts, BASE_CONTRACTS, RPCS, LSP23_FACTORY };

// Run CLI only when executed directly
const isDirectRun = process.argv[1] && (
  process.argv[1].endsWith('cross-chain-deploy-data.js') ||
  process.argv[1].endsWith('cross-chain-deploy-data')
);
if (isDirectRun) main();


path: lib/constants.js
content: /**
 * Universal Profile Constants
 * Contract addresses, data keys, and permission definitions
 */

/**
 * LSP6 Key Manager Permissions
 * Each permission is a single bit in a bytes32 value
 */
export const PERMISSIONS = {
  CHANGEOWNER: '0x0000000000000000000000000000000000000000000000000000000000000001',
  ADDCONTROLLER: '0x0000000000000000000000000000000000000000000000000000000000000002',
  EDITPERMISSIONS: '0x0000000000000000000000000000000000000000000000000000000000000004',
  ADDEXTENSIONS: '0x0000000000000000000000000000000000000000000000000000000000000008',
  CHANGEEXTENSIONS: '0x0000000000000000000000000000000000000000000000000000000000000010',
  ADDUNIVERSALRECEIVERDELEGATE: '0x0000000000000000000000000000000000000000000000000000000000000020',
  CHANGEUNIVERSALRECEIVERDELEGATE: '0x0000000000000000000000000000000000000000000000000000000000000040',
  REENTRANCY: '0x0000000000000000000000000000000000000000000000000000000000000080',
  SUPER_TRANSFERVALUE: '0x0000000000000000000000000000000000000000000000000000000000000100',
  TRANSFERVALUE: '0x0000000000000000000000000000000000000000000000000000000000000200',
  SUPER_CALL: '0x0000000000000000000000000000000000000000000000000000000000000400',
  CALL: '0x0000000000000000000000000000000000000000000000000000000000000800',
  SUPER_STATICCALL: '0x0000000000000000000000000000000000000000000000000000000000001000',
  STATICCALL: '0x0000000000000000000000000000000000000000000000000000000000002000',
  SUPER_DELEGATECALL: '0x0000000000000000000000000000000000000000000000000000000000004000',
  DELEGATECALL: '0x0000000000000000000000000000000000000000000000000000000000008000',
  DEPLOY: '0x0000000000000000000000000000000000000000000000000000000000010000',
  SUPER_SETDATA: '0x0000000000000000000000000000000000000000000000000000000000020000',
  SETDATA: '0x0000000000000000000000000000000000000000000000000000000000040000',
  ENCRYPT: '0x0000000000000000000000000000000000000000000000000000000000080000',
  DECRYPT: '0x0000000000000000000000000000000000000000000000000000000000100000',
  SIGN: '0x0000000000000000000000000000000000000000000000000000000000200000',
  EXECUTE_RELAY_CALL: '0x0000000000000000000000000000000000000000000000000000000000400000',
  
  // Combined permission for all
  ALL_PERMISSIONS: '0x00000000000000000000000000000000000000000000000000000000007fffff',
};

/**
 * Permission bit positions (for decoding)
 */
export const PERMISSION_NAMES = {
  0: 'CHANGEOWNER',
  1: 'ADDCONTROLLER',
  2: 'EDITPERMISSIONS',
  3: 'ADDEXTENSIONS',
  4: 'CHANGEEXTENSIONS',
  5: 'ADDUNIVERSALRECEIVERDELEGATE',
  6: 'CHANGEUNIVERSALRECEIVERDELEGATE',
  7: 'REENTRANCY',
  8: 'SUPER_TRANSFERVALUE',
  9: 'TRANSFERVALUE',
  10: 'SUPER_CALL',
  11: 'CALL',
  12: 'SUPER_STATICCALL',
  13: 'STATICCALL',
  14: 'SUPER_DELEGATECALL',
  15: 'DELEGATECALL',
  16: 'DEPLOY',
  17: 'SUPER_SETDATA',
  18: 'SETDATA',
  19: 'ENCRYPT',
  20: 'DECRYPT',
  21: 'SIGN',
  22: 'EXECUTE_RELAY_CALL',
};

/**
 * Permission presets for common use cases
 */
export const PERMISSION_PRESETS = {
  'read-only': {
    name: 'Read Only',
    description: 'Can only read data, cannot make changes',
    permissions: ['STATICCALL'],
    riskLevel: 'LOW',
  },
  'token-operator': {
    name: 'Token Operator',
    description: 'Can transfer tokens and NFTs',
    permissions: ['CALL', 'TRANSFERVALUE'],
    riskLevel: 'MEDIUM',
  },
  'nft-trader': {
    name: 'NFT Trader',
    description: 'Marketplace operations - list, buy, sell NFTs',
    permissions: ['CALL', 'TRANSFERVALUE', 'STATICCALL'],
    riskLevel: 'MEDIUM',
  },
  'defi-trader': {
    name: 'DeFi Trader',
    description: 'DEX swaps, liquidity provision, marketplace operations',
    permissions: ['CALL', 'TRANSFERVALUE', 'STATICCALL'],
    riskLevel: 'HIGH',
  },
  'profile-manager': {
    name: 'Profile Manager',
    description: 'Can update profile metadata and data',
    permissions: ['SETDATA', 'CALL'],
    riskLevel: 'MEDIUM',
  },
  'full-access': {
    name: 'Full Access',
    description: 'Complete control over the Universal Profile (dangerous!)',
    permissions: ['ALL_PERMISSIONS'],
    riskLevel: 'CRITICAL',
    warning: 'This grants full control over your Universal Profile',
  },
};

/**
 * ERC725Y Data Keys
 */
export const DATA_KEYS = {
  // LSP3 Profile
  LSP3Profile: '0x5ef83ad9559033e6e941db7d7c495acdce616347d28e90c7ce47cbfcfcad3bc5',
  
  // LSP5 Received Assets
  'LSP5ReceivedAssets[]': '0x6460ee3c0aac563ccbf76d6e1d07bada78e3a9514e6382b736ed3f478ab7b90b',
  'LSP5ReceivedAssetsMap': '0x812c4334633eb816c80d0000', // + asset address
  
  // LSP6 Key Manager Permissions
  'AddressPermissions[]': '0xdf30dba06db6a30e65354d9a64c609861f089545ca58c6b4dbe31a5f338cb0e3',
  'AddressPermissions:Permissions': '0x4b80742de2bf82acb3630000', // + controller address
  'AddressPermissions:AllowedCalls': '0x4b80742de2bf393a64c70000', // + controller address
  'AddressPermissions:AllowedERC725YDataKeys': '0x4b80742de2bf866c29110000', // + controller address
  
  // LSP10 Received Vaults
  'LSP10Vaults[]': '0x55482936e01da86729a45d2b87a6b1d3bc582bea0ec00e38bdb340e3af6f9f06',
  'LSP10VaultsMap': '0x192448c3c0f88c7f238c0000', // + vault address
  
  // LSP12 Issued Assets
  'LSP12IssuedAssets[]': '0x7c8c3416d6cda87cd42c71ea1843df28ac4850354f988d55ee2eaa47b6dc05cd',
  'LSP12IssuedAssetsMap': '0x74ac2555c10b9349e78f0000', // + asset address
};

/**
 * Chain configurations
 */
export const CHAINS = {
  lukso: {
    chainId: 42,
    name: 'LUKSO',
    rpcUrl: 'https://42.rpc.thirdweb.com',
    explorer: 'https://explorer.lukso.network',
    currency: { name: 'LYX', symbol: 'LYX', decimals: 18 },
  },
  'lukso-testnet': {
    chainId: 4201,
    name: 'LUKSO Testnet',
    rpcUrl: 'https://rpc.testnet.lukso.network',
    explorer: 'https://explorer.testnet.lukso.network',
    currency: { name: 'LYXt', symbol: 'LYXt', decimals: 18 },
  },
  base: {
    chainId: 8453,
    name: 'Base',
    rpcUrl: 'https://mainnet.base.org',
    explorer: 'https://basescan.org',
    currency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
  },
  ethereum: {
    chainId: 1,
    name: 'Ethereum',
    rpcUrl: 'https://eth.llamarpc.com',
    explorer: 'https://etherscan.io',
    currency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
  },
};

/**
 * Factory contract addresses (deterministic across chains)
 */
export const FACTORY_ADDRESSES = {
  LSP16_UNIVERSAL_FACTORY: [REDACTED_SECRET],
  LSP23_LINKED_CONTRACTS_FACTORY: [REDACTED_SECRET],
};

/**
 * Singleton contract addresses (same on mainnet & testnet)
 */
export const SINGLETON_ADDRESSES = {
  LSP26_FOLLOWER_SYSTEM: [REDACTED_SECRET],
};

/**
 * Contract ABIs (minimal interfaces)
 */
export const ABIS = {
  // ERC725Y (Key-Value Store)
  ERC725Y: [
    'function getData(bytes32 dataKey) view returns (bytes)',
    'function getDataBatch(bytes32[] dataKeys) view returns (bytes[])',
    'function setData(bytes32 dataKey, bytes dataValue)',
    'function setDataBatch(bytes32[] dataKeys, bytes[] dataValues)',
  ],
  
  // LSP0 (Universal Profile / ERC725Account)
  LSP0: [
    'function execute(uint256 operationType, address target, uint256 value, bytes data) payable returns (bytes)',
    'function executeBatch(uint256[] operationTypes, address[] targets, uint256[] values, bytes[] datas) payable returns (bytes[])',
    'function getData(bytes32 dataKey) view returns (bytes)',
    'function getDataBatch(bytes32[] dataKeys) view returns (bytes[])',
    'function setData(bytes32 dataKey, bytes dataValue)',
    'function setDataBatch(bytes32[] dataKeys, bytes[] dataValues)',
    'function owner() view returns (address)',
    'function supportsInterface(bytes4 interfaceId) view returns (bool)',
  ],
  
  // LSP6 (Key Manager)
  LSP6: [
    'function execute(bytes calldata payload) payable returns (bytes)',
    'function executeBatch(uint256[] values, bytes[] payloads) payable returns (bytes[])',
    'function executeRelayCall(bytes signature, uint256 nonce, uint256 validityTimestamps, bytes payload) payable returns (bytes)',
    'function executeRelayCallBatch(bytes[] signatures, uint256[] nonces, uint256[] validityTimestamps, uint256[] values, bytes[] payloads) payable returns (bytes[])',
    'function getNonce(address from, uint128 channelId) view returns (uint256)',
    'function target() view returns (address)',
    'function isValidSignature(bytes32 dataHash, bytes signature) view returns (bytes4)',
  ],
  
  // LSP7 (Digital Asset - Fungible Token)
  LSP7: [
    'function name() view returns (string)',
    'function symbol() view returns (string)',
    'function decimals() view returns (uint8)',
    'function totalSupply() view returns (uint256)',
    'function balanceOf(address tokenOwner) view returns (uint256)',
    'function transfer(address from, address to, uint256 amount, bool force, bytes data)',
    'function authorizeOperator(address operator, uint256 amount, bytes operatorNotificationData)',
    'function revokeOperator(address operator, address tokenOwner, bool notify, bytes operatorNotificationData)',
    'function authorizedAmountFor(address operator, address tokenOwner) view returns (uint256)',
  ],
  
  // LSP8 (Identifiable Digital Asset - NFT)
  LSP8: [
    'function name() view returns (string)',
    'function symbol() view returns (string)',
    'function totalSupply() view returns (uint256)',
    'function balanceOf(address tokenOwner) view returns (uint256)',
    'function tokenOwnerOf(bytes32 tokenId) view returns (address)',
    'function tokenIdsOf(address tokenOwner) view returns (bytes32[])',
    'function transfer(address from, address to, bytes32 tokenId, bool force, bytes data)',
    'function authorizeOperator(address operator, bytes32 tokenId, bytes operatorNotificationData)',
    'function revokeOperator(address operator, bytes32 tokenId, bool notify, bytes operatorNotificationData)',
    'function isOperatorFor(address operator, bytes32 tokenId) view returns (bool)',
  ],

  // LSP26 (Follower System)
  LSP26: [
    'function follow(address addr)',
    'function unfollow(address addr)',
    'function isFollowing(address follower, address addr) view returns (bool)',
    'function followerCount(address addr) view returns (uint256)',
    'function followingCount(address addr) view returns (uint256)',
  ],
};

/**
 * Interface IDs for contract detection
 */
export const INTERFACE_IDS = {
  ERC165: '0x01ffc9a7',
  ERC725X: '0x7545acac',
  ERC725Y: '0x629aa694',
  LSP0: '0x24871b3d', // ERC725Account
  LSP6: '0x23f34c62', // Key Manager
  LSP7: '0xc52d6008', // Digital Asset
  LSP8: '0x3a271706', // Identifiable Digital Asset
  LSP9: '0x28af17e6', // Vault
  LSP26: '0x2b299cea', // Follower System
};

/**
 * Operation types for execute()
 */
export const OPERATION_TYPES = {
  CALL: 0,
  CREATE: 1,
  CREATE2: 2,
  STATICCALL: 3,
  DELEGATECALL: 4,
};

/**
 * Permission risk levels and warnings
 */
export const PERMISSION_RISKS = {
  CHANGEOWNER: {
    level: 'CRITICAL',
    warning: 'Can transfer profile ownership - use only for recovery',
  },
  SUPER_DELEGATECALL: {
    level: 'CRITICAL',
    warning: 'Can execute arbitrary code in UP context - extremely dangerous',
  },
  DELEGATECALL: {
    level: 'CRITICAL',
    warning: 'Can execute code in UP context with restrictions - dangerous',
  },
  EDITPERMISSIONS: {
    level: 'HIGH',
    warning: 'Can modify permissions of other controllers',
  },
  ADDCONTROLLER: {
    level: 'HIGH',
    warning: 'Can add new controllers to the profile',
  },
  SUPER_SETDATA: {
    level: 'HIGH',
    warning: 'Can modify any data on profile without restrictions',
  },
  SUPER_CALL: {
    level: 'HIGH',
    warning: 'Can call any contract without restrictions',
  },
  SUPER_TRANSFERVALUE: {
    level: 'HIGH',
    warning: 'Can transfer native tokens without restrictions',
  },
  SETDATA: {
    level: 'MEDIUM',
    warning: 'Can modify profile data (restricted by AllowedERC725YDataKeys)',
  },
  CALL: {
    level: 'MEDIUM',
    warning: 'Can call contracts (restricted by AllowedCalls)',
  },
  TRANSFERVALUE: {
    level: 'MEDIUM',
    warning: 'Can transfer native tokens',
  },
  DEPLOY: {
    level: 'MEDIUM',
    warning: 'Can deploy new contracts from the profile',
  },
  STATICCALL: {
    level: 'LOW',
    warning: 'Can read data from any contract - safe for queries',
  },
  SUPER_STATICCALL: {
    level: 'LOW',
    warning: 'Can read data from any contract without restrictions',
  },
  SIGN: {
    level: 'LOW',
    warning: 'Can sign messages on behalf of the profile',
  },
  ENCRYPT: {
    level: 'LOW',
    warning: 'Can encrypt data',
  },
  DECRYPT: {
    level: 'LOW',
    warning: 'Can decrypt data',
  },
  EXECUTE_RELAY_CALL: {
    level: 'LOW',
    warning: 'Can execute relay calls (meta-transactions)',
  },
  REENTRANCY: {
    level: 'MEDIUM',
    warning: 'Allows reentrant calls - may be needed for some protocols',
  },
  ADDEXTENSIONS: {
    level: 'MEDIUM',
    warning: 'Can add new extensions to the profile',
  },
  CHANGEEXTENSIONS: {
    level: 'MEDIUM',
    warning: 'Can modify existing extensions',
  },
  ADDUNIVERSALRECEIVERDELEGATE: {
    level: 'MEDIUM',
    warning: 'Can add universal receiver delegates',
  },
  CHANGEUNIVERSALRECEIVERDELEGATE: {
    level: 'MEDIUM',
    warning: 'Can modify universal receiver delegates',
  },
};

export default {
  PERMISSIONS,
  PERMISSION_NAMES,
  PERMISSION_PRESETS,
  DATA_KEYS,
  CHAINS,
  FACTORY_ADDRESSES,
  ABIS,
  INTERFACE_IDS,
  OPERATION_TYPES,
  PERMISSION_RISKS,
};

/**
 * Resolve explorer base URL from chain ID
 */
function getExplorerBase(chainId = 42) {
  const map = { 42: 'https://explorer.lukso.network', 4201: 'https://explorer.testnet.lukso.network', 8453: 'https://basescan.org', 1: 'https://etherscan.io' };
  return map[chainId] || map[42];
}

/**
 * Get explorer URL for a transaction
 */
export function getExplorerUrl(txHash, chainId = 42) {
  return `${getExplorerBase(chainId)}/tx/${txHash}`;
}

/**
 * Get explorer URL for an address
 */
export function getAddressExplorerUrl(address, chainId = 42) {
  return `${getExplorerBase(chainId)}/address/${address}`;
}


path: lib/profile.js
content: /**
 * Universal Profile Operations
 * Functions for interacting with Universal Profiles on LUKSO
 */

import { ethers } from 'ethers';
import crypto from 'crypto';
import { promises as fs } from 'fs';
import {
  ABIS,
  INTERFACE_IDS,
  DATA_KEYS,
  CHAINS,
} from './constants.js';
import {
  buildPermissionsDataKey,
  decodePermissions,
} from './permissions.js';
import { getConfigDir, getKeystorePath } from './config.js';

/**
 * Get a provider for a specific chain
 * @param {string|Object} chain - Chain name or config object
 * @returns {ethers.JsonRpcProvider}
 */
export function getProvider(chain) {
  let rpcUrl;
  
  if (typeof chain === 'string') {
    const chainConfig = CHAINS[chain];
    if (!chainConfig) {
      throw new Error(`Unknown chain: ${chain}`);
    }
    rpcUrl = chainConfig.rpcUrl;
  } else if (chain.rpcUrl) {
    rpcUrl = chain.rpcUrl;
  } else {
    throw new Error('Invalid chain configuration');
  }
  
  return new ethers.JsonRpcProvider(rpcUrl);
}

/**
 * Generate a new keypair for use as a controller
 * @returns {{ privateKey: string, publicKey: string, address: string }}
 */
export function generateKeyPair() {
  const privateKeyBytes = crypto.randomBytes(32);
  const privateKey = '0x' + privateKeyBytes.toString('hex');
  const wallet = new ethers.Wallet(privateKey);
  
  return {
    privateKey,
    publicKey: wallet.signingKey.publicKey,
    address: wallet.address,
  };
}

/**
 * Encrypt and store a private key
 * @param {string} privateKey - Private key to encrypt
 * @param {string} password - Encryption password
 * @param {string} [keystorePath] - Optional path to keystore file
 * @returns {Promise<Object>} Keystore entry
 */
export async function encryptAndStoreKey(privateKey, password, keystorePath) {
  const path = keystorePath || getKeystorePath();
  const wallet = new ethers.Wallet(privateKey);
  
  // Generate encryption parameters
  const salt = crypto.randomBytes(32);
  const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
  const iv = crypto.randomBytes(16);
  
  // Encrypt
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
  let encrypted = cipher.update(privateKey, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  const authTag = cipher.getAuthTag();
  
  // Create keystore entry
  const entry = {
    address: wallet.address,
    encryptedKey: encrypted + authTag.toString('hex'),
    iv: iv.toString('hex'),
    salt: salt.toString('hex'),
    algorithm: 'aes-256-gcm',
    createdAt: new Date().toISOString(),
  };
  
  // Load existing keystore or create new
  let keystore = { keys: [] };
  try {
    const data = await fs.readFile(path, 'utf8');
    keystore = JSON.parse(data);
  } catch (err) {
    if (err.code !== 'ENOENT') throw err;
    // Create directory if needed
    const dir = getConfigDir();
    await fs.mkdir(dir, { recursive: true });
  }
  
  // Check if key already exists
  const existingIndex = keystore.keys.findIndex(
    k => k.address.toLowerCase() === wallet.address.toLowerCase()
  );
  
  if (existingIndex >= 0) {
    keystore.keys[existingIndex] = entry;
  } else {
    keystore.keys.push(entry);
  }
  
  // Save keystore
  await fs.writeFile(path, JSON.stringify(keystore, null, 2), 'utf8');
  
  return entry;
}

/**
 * Load and decrypt a private key from the keystore
 * @param {string} address - Address of the key to load
 * @param {string} password - Decryption password
 * @param {string} [keystorePath] - Optional path to keystore file
 * @returns {Promise<string>} Decrypted private key
 */
export async function loadKey(address, password, keystorePath) {
  const path = keystorePath || getKeystorePath();
  
  // Load keystore
  let keystore;
  try {
    const data = await fs.readFile(path, 'utf8');
    keystore = JSON.parse(data);
  } catch (err) {
    if (err.code === 'ENOENT') {
      throw new Error('Keystore not found. Generate a key first with "up key generate"');
    }
    throw err;
  }
  
  // Find key entry
  const entry = keystore.keys.find(
    k => k.address.toLowerCase() === address.toLowerCase()
  );
  
  if (!entry) {
    throw new Error(`Key not found for address: ${address}`);
  }
  
  // Decrypt
  const salt = Buffer.from(entry.salt, 'hex');
  const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
  const iv = Buffer.from(entry.iv, 'hex');
  
  const encrypted = entry.encryptedKey.slice(0, -32);
  const authTag = Buffer.from(entry.encryptedKey.slice(-32), 'hex');
  
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(authTag);
  
  let decrypted;
  try {
    decrypted = decipher.update(encrypted, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
  } catch (err) {
    throw new Error('Invalid password');
  }
  
  return decrypted;
}

/**
 * List all keys in the keystore
 * @param {string} [keystorePath] - Optional path to keystore file
 * @returns {Promise<Object[]>} Array of key metadata (without private keys)
 */
export async function listKeys(keystorePath) {
  const path = keystorePath || getKeystorePath();
  
  try {
    const data = await fs.readFile(path, 'utf8');
    const keystore = JSON.parse(data);
    
    return keystore.keys.map(k => ({
      address: k.address,
      createdAt: k.createdAt,
    }));
  } catch (err) {
    if (err.code === 'ENOENT') {
      return [];
    }
    throw err;
  }
}

/**
 * Check if an address is a Universal Profile
 * @param {string} address - Address to check
 * @param {ethers.Provider} provider - Provider to use
 * @returns {Promise<boolean>}
 */
export async function isUniversalProfile(address, provider) {
  try {
    const contract = new ethers.Contract(address, ABIS.LSP0, provider);
    const supportsLSP0 = await contract.supportsInterface(INTERFACE_IDS.LSP0);
    return supportsLSP0;
  } catch (err) {
    return false;
  }
}

/**
 * Get the Key Manager address for a Universal Profile
 * @param {string} upAddress - Universal Profile address
 * @param {ethers.Provider} provider - Provider to use
 * @returns {Promise<string|null>}
 */
export async function getKeyManager(upAddress, provider) {
  try {
    const contract = new ethers.Contract(upAddress, ABIS.LSP0, provider);
    const owner = await contract.owner();
    
    // Check if owner is a Key Manager
    const kmContract = new ethers.Contract(owner, ABIS.LSP6, provider);
    const target = await kmContract.target();
    
    if (target.toLowerCase() === upAddress.toLowerCase()) {
      return owner;
    }
    
    return null;
  } catch (err) {
    return null;
  }
}

/**
 * Get Universal Profile information
 * @param {string} upAddress - Universal Profile address
 * @param {ethers.Provider} provider - Provider to use
 * @returns {Promise<Object>} Profile information
 */
export async function getProfileInfo(upAddress, provider) {
  const up = new ethers.Contract(upAddress, ABIS.LSP0, provider);
  
  // Get basic info
  const [owner, balance, lsp3Data] = await Promise.all([
    up.owner(),
    provider.getBalance(upAddress),
    up.getData(DATA_KEYS.LSP3Profile).catch(() => '0x'),
  ]);
  
  // Parse LSP3 metadata if available
  let profileMetadata = null;
  if (lsp3Data && lsp3Data !== '0x') {
    try {
      // LSP3 data is typically a VerifiableURI
      // Format: 0x + 4 bytes hashFunction + 32 bytes hash + url
      // For simplicity, we just note it exists
      profileMetadata = { hasMetadata: true, raw: lsp3Data };
    } catch (err) {
      // Ignore parsing errors
    }
  }
  
  // Get Key Manager if owner is one
  const keyManager = await getKeyManager(upAddress, provider);
  
  // Get controllers count
  let controllersCount = 0;
  if (keyManager) {
    try {
      const lengthData = await up.getData(DATA_KEYS['AddressPermissions[]']);
      if (lengthData && lengthData !== '0x') {
        controllersCount = parseInt(lengthData, 16);
      }
    } catch (err) {
      // Ignore
    }
  }
  
  // Get received assets count
  let assetsCount = 0;
  try {
    const assetsLengthData = await up.getData(DATA_KEYS['LSP5ReceivedAssets[]']);
    if (assetsLengthData && assetsLengthData !== '0x') {
      assetsCount = parseInt(assetsLengthData, 16);
    }
  } catch (err) {
    // Ignore
  }
  
  return {
    address: upAddress,
    owner,
    keyManager,
    balance: ethers.formatEther(balance),
    balanceWei: balance.toString(),
    controllersCount,
    assetsCount,
    profileMetadata,
    isUniversalProfile: true,
  };
}

/**
 * Get permissions for a controller on a Universal Profile
 * @param {string} upAddress - Universal Profile address
 * @param {string} controllerAddress - Controller address
 * @param {ethers.Provider} provider - Provider to use
 * @returns {Promise<Object>} Permission information
 */
export async function getControllerPermissions(upAddress, controllerAddress, provider) {
  const up = new ethers.Contract(upAddress, ABIS.LSP0, provider);
  
  const permissionsKey = buildPermissionsDataKey(controllerAddress);
  const permissionsData = await up.getData(permissionsKey);
  
  if (!permissionsData || permissionsData === '0x') {
    return {
      address: controllerAddress,
      permissions: '0x0000000000000000000000000000000000000000000000000000000000000000',
      permissionNames: [],
      hasAccess: false,
    };
  }
  
  const permissionNames = decodePermissions(permissionsData);
  
  return {
    address: controllerAddress,
    permissions: permissionsData,
    permissionNames,
    hasAccess: permissionNames.length > 0,
  };
}

/**
 * List all controllers of a Universal Profile
 * @param {string} upAddress - Universal Profile address
 * @param {ethers.Provider} provider - Provider to use
 * @returns {Promise<Object[]>} Array of controller info
 */
export async function listControllers(upAddress, provider) {
  const up = new ethers.Contract(upAddress, ABIS.LSP0, provider);
  
  // Get controllers count
  const lengthData = await up.getData(DATA_KEYS['AddressPermissions[]']);
  if (!lengthData || lengthData === '0x') {
    return [];
  }
  
  const count = parseInt(lengthData, 16);
  const controllers = [];
  
  // Build keys for all controller addresses
  const indexKeys = [];
  for (let i = 0; i < count; i++) {
    const indexKey = DATA_KEYS['AddressPermissions[]'] + 
      i.toString(16).padStart(32, '0');
    indexKeys.push(indexKey);
  }
  
  // Get all addresses
  const addresses = await up.getDataBatch(indexKeys);
  
  // Get permissions for each
  for (let i = 0; i < addresses.length; i++) {
    if (addresses[i] && addresses[i] !== '0x') {
      // Extract address from bytes (might be padded)
      const addr = '0x' + addresses[i].slice(-40);
      const info = await getControllerPermissions(upAddress, addr, provider);
      controllers.push(info);
    }
  }
  
  return controllers;
}

/**
 * Create a wallet signer from a private key
 * @param {string} privateKey - Private key
 * @param {ethers.Provider} provider - Provider to use
 * @returns {ethers.Wallet}
 */
export function createSigner(privateKey, provider) {
  return new ethers.Wallet(privateKey, provider);
}

/**
 * Format profile info for display
 * @param {Object} info - Profile info object
 * @returns {string} Formatted string
 */
export function formatProfileInfo(info) {
  const lines = [];
  
  lines.push('┌─────────────────────────────────────┐');
  lines.push('│ Universal Profile Info              │');
  lines.push('├─────────────────────────────────────┤');
  lines.push(`│ Address: ${info.address.slice(0, 10)}...${info.address.slice(-4)}   │`);
  
  if (info.keyManager) {
    lines.push(`│ Key Manager: ${info.keyManager.slice(0, 10)}...│`);
  } else {
    lines.push(`│ Key Manager: Not set                │`);
  }
  
  lines.push(`│ Balance: ${info.balance.padEnd(22)} LYX │`);
  lines.push(`│ Controllers: ${String(info.controllersCount).padEnd(22)} │`);
  lines.push(`│ Received Assets: ${String(info.assetsCount).padEnd(18)} │`);
  
  if (info.profileMetadata?.hasMetadata) {
    lines.push(`│ Has LSP3 Metadata: Yes              │`);
  }
  
  lines.push('└─────────────────────────────────────┘');
  
  return lines.join('\n');
}

export default {
  getProvider,
  generateKeyPair,
  encryptAndStoreKey,
  loadKey,
  listKeys,
  isUniversalProfile,
  getKeyManager,
  getProfileInfo,
  getControllerPermissions,
  listControllers,
  createSigner,
  formatProfileInfo,
};


path: lib/credentials.js
content: /**
 * Universal Profile Credentials Helper
 * Handles credential loading from multiple possible locations
 */

import fs from 'fs';
import { promises as fsp } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

/**
 * Get the user's home directory with fallback for Windows
 * @returns {string}
 */
function getHomeDir() {
  const home = process.env.HOME || process.env.USERPROFILE;
  if (!home) {
    throw new Error(
      'Cannot determine home directory. Set the HOME (or USERPROFILE on Windows) environment variable.'
    );
  }
  return home;
}

/**
 * Possible credential locations (in priority order)
 */
function getCredentialPaths() {
  const home = getHomeDir();
  return [
    // 1. Environment variable (highest priority)
    process.env.UP_CREDENTIALS_PATH,

    // 2. OpenClaw standard location
    path.join(home, '.openclaw', 'universal-profile', 'config.json'),

    // 3. Legacy clawdbot location
    path.join(home, '.clawdbot', 'universal-profile', 'config.json'),
  ];
}

function getKeyPaths() {
  const home = getHomeDir();
  return [
    // Environment variable
    process.env.UP_KEY_PATH,

    // OpenClaw standard
    path.join(home, '.openclaw', 'credentials', 'universal-profile-key.json'),

    // Legacy clawdbot
    path.join(home, '.clawdbot', 'credentials', 'universal-profile-key.json'),
  ];
}

/**
 * Warn if credential file has insecure permissions (group/others can read)
 * @param {string} filePath
 */
function warnIfInsecurePermissions(filePath) {
  try {
    const stat = fs.statSync(filePath);
    const mode = stat.mode;
    // Check if group (0o070) or others (0o007) have any permissions
    if (mode & 0o077) {
      console.warn(
        `⚠️  Credential file ${filePath} is readable by group/others (mode ${(mode & 0o777).toString(8)}). ` +
        `Run: chmod 600 ${filePath}`
      );
    }
  } catch {
    // Ignore — file might not exist yet or stat might fail on some platforms
  }
}

/**
 * Find and load credentials
 * @returns {Object} Credentials object with universalProfile and controller
 * @throws {Error} If no credentials found
 */
export function loadCredentials() {
  const credentialPaths = getCredentialPaths();
  const keyPaths = getKeyPaths();

  // Try config.json locations
  for (const credPath of credentialPaths.filter(Boolean)) {
    if (fs.existsSync(credPath)) {
      try {
        warnIfInsecurePermissions(credPath);
        const config = JSON.parse(fs.readFileSync(credPath, 'utf8'));

        // If controller.privateKey is in config, we're done
        if (config.controller?.privateKey) {
          return config;
        }

        // Otherwise, try to load key from separate file
        for (const keyPath of keyPaths.filter(Boolean)) {
          if (fs.existsSync(keyPath)) {
            warnIfInsecurePermissions(keyPath);
            const keyData = JSON.parse(fs.readFileSync(keyPath, 'utf8'));
            return {
              ...config,
              controller: {
                ...config.controller,
                ...keyData.controller,
              }
            };
          }
        }

        // Config found but no key
        throw new Error(`Found config at ${credPath} but no private key found`);
      } catch (error) {
        if (error.code === 'ENOENT') continue;
        throw error;
      }
    }
  }

  // Try key-only files (some setups might only have the key file)
  for (const keyPath of keyPaths.filter(Boolean)) {
    if (fs.existsSync(keyPath)) {
      try {
        warnIfInsecurePermissions(keyPath);
        return JSON.parse(fs.readFileSync(keyPath, 'utf8'));
      } catch (error) {
        if (error.code === 'ENOENT') continue;
        throw error;
      }
    }
  }

  // No credentials found anywhere
  throw new Error(
    'Universal Profile credentials not found!\n\n' +
    'Searched locations:\n' +
    credentialPaths.filter(Boolean).map(p => `  - ${p}`).join('\n') +
    '\n\nTo fix:\n' +
    '  1. Set UP_CREDENTIALS_PATH environment variable, or\n' +
    '  2. Place credentials in ~/.openclaw/universal-profile/config.json, or\n' +
    '  3. Place credentials in ~/.openclaw/credentials/universal-profile-key.json\n\n' +
    'Expected JSON format:\n' +
    '  {\n' +
    '    "universalProfile": { "address": "0x..." },\n' +
    '    "controller": { "address": "0x...", "privateKey": "0x..." }\n' +
    '  }\n\n' +
    'See SKILL.md for setup instructions.'
  );
}

/**
 * Get the canonical credential file path (where new credentials should be saved)
 * @returns {string} Canonical path for credentials
 */
export function getCredentialPath() {
  const home = getHomeDir();
  return path.join(home, '.openclaw', 'credentials', 'universal-profile-key.json');
}

/**
 * Get credential file path (for reference — returns first found path, or null)
 * @returns {string|null} Path to credentials file, or null if not found
 */
export function getCredentialsPath() {
  const credentialPaths = getCredentialPaths();
  const keyPaths = getKeyPaths();

  for (const credPath of credentialPaths.filter(Boolean)) {
    if (fs.existsSync(credPath)) return credPath;
  }
  for (const keyPath of keyPaths.filter(Boolean)) {
    if (fs.existsSync(keyPath)) return keyPath;
  }
  return null;
}

/**
 * Save credentials to the canonical path with secure permissions
 * @param {Object} creds - Credentials object to save
 * @returns {Promise<string>} Path where credentials were saved
 */
export async function saveCredentials(creds) {
  validateCredentials(creds);

  const credPath = getCredentialPath();
  const credDir = path.dirname(credPath);

  // Ensure directory exists
  await fsp.mkdir(credDir, { recursive: true });

  // Write with restrictive permissions (owner read/write only)
  const data = JSON.stringify(creds, null, 2);
  await fsp.writeFile(credPath, data, { mode: 0o600, encoding: 'utf8' });

  return credPath;
}

/**
 * Validate credentials structure
 * @param {Object} creds - Credentials object
 * @throws {Error} If credentials are invalid
 */
export function validateCredentials(creds) {
  if (!creds.universalProfile?.address) {
    throw new Error('Missing universalProfile.address in credentials');
  }
  if (!creds.controller?.address) {
    throw new Error('Missing controller.address in credentials');
  }
  if (!creds.controller?.privateKey) {
    throw new Error('Missing controller.privateKey in credentials');
  }

  // Validate address format
  const addressRegex = /^0x[a-fA-F0-9]{40}$/;
  if (!addressRegex.test(creds.universalProfile.address)) {
    throw new Error('Invalid universalProfile.address format');
  }
  if (!addressRegex.test(creds.controller.address)) {
    throw new Error('Invalid controller.address format');
  }

  // Validate private key format (with or without 0x prefix)
  const keyRegex = /^(0x)?[a-fA-F0-9]{64}$/;
  if (!keyRegex.test(creds.controller.privateKey)) {
    throw new Error('Invalid controller.privateKey format');
  }
}

/**
 * Load and validate credentials (convenience function)
 * @returns {Object} Validated credentials
 */
export function loadAndValidateCredentials() {
  const creds = loadCredentials();
  validateCredentials(creds);
  return creds;
}


path: lib/provider.js
content: /**
 * Shared RPC Provider Setup
 * 
 * Multi-chain support: LUKSO, Base, and Ethereum.
 * The UP address is the same across all chains (deployed via LSP23 CREATE2).
 */

import { createPublicClient, createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { lukso, luksoTestnet, base, mainnet } from 'viem/chains';
import { ethers } from 'ethers';
import { loadCredentials } from './credentials.js';

// Network configurations: name → { chainId, rpcUrl, viemChain }
const NETWORKS = {
  mainnet:  { chainId: 42,    rpcUrl: 'https://42.rpc.thirdweb.com',         viemChain: lukso },
  testnet:  { chainId: 4201,  rpcUrl: 'https://rpc.testnet.lukso.network',   viemChain: luksoTestnet },
  base:     { chainId: 8453,  rpcUrl: 'https://mainnet.base.org',            viemChain: base },
  ethereum: { chainId: 1,     rpcUrl: 'https://eth.llamarpc.com',            viemChain: mainnet },
};

/**
 * Resolve a network identifier to its config.
 * Accepts: 'mainnet', 'testnet', 'base', 'ethereum', or a chain ID number.
 */
export function resolveNetwork(network = 'mainnet') {
  if (typeof network === 'number') {
    const entry = Object.entries(NETWORKS).find(([, v]) => v.chainId === network);
    return entry ? NETWORKS[entry[0]] : NETWORKS.mainnet;
  }
  return NETWORKS[network] || NETWORKS.mainnet;
}

/**
 * Get RPC URL for a network
 */
export function getRpcUrl(network = 'mainnet') {
  return resolveNetwork(network).rpcUrl;
}

/**
 * Get viem chain config
 */
export function getChain(network = 'mainnet') {
  return resolveNetwork(network).viemChain;
}

/**
 * Create a viem public client (read-only)
 */
export function createProvider(network = 'mainnet') {
  return createPublicClient({
    chain: getChain(network),
    transport: http(getRpcUrl(network))
  });
}

/**
 * Create a viem wallet client (for signing/sending)
 */
export function createSigner(privateKey, network = 'mainnet') {
  const account = privateKeyToAccount(privateKey);
  return {
    account,
    client: createWalletClient({
      account,
      chain: getChain(network),
      transport: http(getRpcUrl(network))
    })
  };
}

/**
 * Create an ethers provider
 */
export function createEthersProvider(network = 'mainnet') {
  return new ethers.JsonRpcProvider(getRpcUrl(network));
}

/**
 * Create an ethers wallet (provider + signer)
 */
export function createEthersWallet(privateKey, network = 'mainnet') {
  const provider = createEthersProvider(network);
  return new ethers.Wallet(privateKey, provider);
}

/**
 * Get configured provider and credentials for any supported network.
 * @param {string|number} network - 'mainnet'|'testnet'|'base'|'ethereum' or chain ID
 */
export function getProviderWithCredentials(network = 'mainnet') {
  const creds = loadCredentials();
  const resolved = resolveNetwork(network);
  const provider = createProvider(network);
  const ethersProvider = createEthersProvider(network);
  const { account, client: walletClient } = createSigner(creds.controller.privateKey, network);
  const ethersWallet = createEthersWallet(creds.controller.privateKey, network);
  
  return {
    // Credentials
    upAddress: creds.universalProfile.address,
    controllerAddress: creds.controller.address,
    privateKey: creds.controller.privateKey,
    
    // Viem clients
    publicClient: provider,
    walletClient,
    account,
    
    // Ethers clients
    ethersProvider,
    ethersWallet,
    
    // Network info
    network: typeof network === 'string' ? network : Object.entries(NETWORKS).find(([, v]) => v.chainId === network)?.[0] || 'mainnet',
    chainId: resolved.chainId,
  };
}

export { NETWORKS };


path: lib/config.js
content: /**
 * Configuration Management
 * Handles loading, saving, and validating skill configuration
 */

import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { CHAINS } from './constants.js';

/**
 * Default configuration
 */
const DEFAULT_CONFIG = {
  defaultChain: 'lukso',
  keystorePath: null, // Set dynamically
  chains: { ...CHAINS },
  profiles: {},
};

/**
 * Get the config directory path
 * @returns {string} Config directory path
 */
export function getConfigDir() {
  const home = os.homedir();
  return path.join(home, '.openclaw', 'skills', 'universal-profile');
}

/**
 * Get the config file path
 * @returns {string} Config file path
 */
export function getConfigPath() {
  return path.join(getConfigDir(), 'config.json');
}

/**
 * Get the keystore file path
 * @returns {string} Keystore file path
 */
export function getKeystorePath() {
  return path.join(getConfigDir(), 'keystore.json');
}

/**
 * Ensure the config directory exists
 */
async function ensureConfigDir() {
  const configDir = getConfigDir();
  try {
    await fs.mkdir(configDir, { recursive: true });
  } catch (err) {
    if (err.code !== 'EEXIST') {
      throw err;
    }
  }
}

/**
 * Load configuration from disk
 * @returns {Promise<Object>} Configuration object
 */
export async function loadConfig() {
  await ensureConfigDir();
  
  const configPath = getConfigPath();
  
  try {
    const data = await fs.readFile(configPath, 'utf8');
    const config = JSON.parse(data);
    
    // Merge with defaults for any missing keys
    return {
      ...DEFAULT_CONFIG,
      ...config,
      chains: {
        ...DEFAULT_CONFIG.chains,
        ...(config.chains || {}),
      },
      keystorePath: config.keystorePath || getKeystorePath(),
    };
  } catch (err) {
    if (err.code === 'ENOENT') {
      // Config doesn't exist, return defaults
      return {
        ...DEFAULT_CONFIG,
        keystorePath: getKeystorePath(),
      };
    }
    throw err;
  }
}

/**
 * Save configuration to disk
 * @param {Object} config - Configuration object
 */
export async function saveConfig(config) {
  await ensureConfigDir();
  
  const configPath = getConfigPath();
  const data = JSON.stringify(config, null, 2);
  
  await fs.writeFile(configPath, data, 'utf8');
}

/**
 * Allowed top-level config keys that can be modified via `config set`.
 * Restricting this prevents abuse (e.g. redirecting keystorePath).
 */
const ALLOWED_CONFIG_KEYS = new Set([
  'defaultChain',
]);

/**
 * Update a specific configuration value
 * @param {string} key - Configuration key (supports dot notation)
 * @param {*} value - Value to set
 * @returns {Promise<Object>} Updated configuration
 */
export async function setConfigValue(key, value) {
  // Security: only allow known safe keys to be modified
  const topKey = key.split('.')[0];
  if (!ALLOWED_CONFIG_KEYS.has(topKey)) {
    throw new Error(
      `Cannot modify "${key}" via config set. ` +
      `Allowed keys: ${[...ALLOWED_CONFIG_KEYS].join(', ')}. ` +
      `Sensitive paths (keystorePath, profiles) must be set during initial setup.`
    );
  }

  const config = await loadConfig();
  
  // Support dot notation (e.g., "chains.lukso.rpcUrl")
  const keys = key.split('.');
  let target = config;
  
  for (let i = 0; i < keys.length - 1; i++) {
    if (!(keys[i] in target)) {
      target[keys[i]] = {};
    }
    target = target[keys[i]];
  }
  
  target[keys[keys.length - 1]] = value;
  
  await saveConfig(config);
  return config;
}

/**
 * Get a specific configuration value
 * @param {string} key - Configuration key (supports dot notation)
 * @param {*} defaultValue - Default value if key not found
 * @returns {Promise<*>} Configuration value
 */
export async function getConfigValue(key, defaultValue = undefined) {
  const config = await loadConfig();
  
  const keys = key.split('.');
  let value = config;
  
  for (const k of keys) {
    if (value && typeof value === 'object' && k in value) {
      value = value[k];
    } else {
      return defaultValue;
    }
  }
  
  return value;
}

/**
 * Get chain configuration
 * @param {string} chainName - Chain name (lukso, lukso-testnet)
 * @returns {Promise<Object|null>} Chain configuration or null
 */
export async function getChainConfig(chainName) {
  const config = await loadConfig();
  return config.chains?.[chainName] || CHAINS[chainName] || null;
}

/**
 * Get profile configuration for a chain
 * @param {number|string} chainId - Chain ID or name
 * @returns {Promise<Object|null>} Profile configuration or null
 */
export async function getProfileConfig(chainId) {
  const config = await loadConfig();
  
  // Convert chain name to ID if necessary
  let id = chainId;
  if (typeof chainId === 'string' && isNaN(parseInt(chainId))) {
    const chainConfig = config.chains?.[chainId];
    if (chainConfig) {
      id = chainConfig.chainId;
    }
  }
  
  return config.profiles?.[id.toString()] || null;
}

/**
 * Save profile configuration for a chain
 * @param {number|string} chainId - Chain ID
 * @param {Object} profile - Profile configuration
 */
export async function saveProfileConfig(chainId, profile) {
  const config = await loadConfig();
  
  if (!config.profiles) {
    config.profiles = {};
  }
  
  config.profiles[chainId.toString()] = {
    ...config.profiles[chainId.toString()],
    ...profile,
    updatedAt: new Date().toISOString(),
  };
  
  await saveConfig(config);
}

/**
 * Validate configuration
 * @param {Object} config - Configuration to validate
 * @returns {{ valid: boolean, errors: string[] }}
 */
export function validateConfig(config) {
  const errors = [];
  
  if (config.defaultChain && !CHAINS[config.defaultChain] && !config.chains?.[config.defaultChain]) {
    errors.push(`Unknown default chain: ${config.defaultChain}`);
  }
  
  if (config.chains) {
    for (const [name, chain] of Object.entries(config.chains)) {
      if (!chain.chainId) {
        errors.push(`Chain "${name}" is missing chainId`);
      }
      if (!chain.rpcUrl) {
        errors.push(`Chain "${name}" is missing rpcUrl`);
      }
    }
  }
  
  if (config.profiles) {
    for (const [chainId, profile] of Object.entries(config.profiles)) {
      if (profile.upAddress && !isValidAddress(profile.upAddress)) {
        errors.push(`Invalid UP address for chain ${chainId}`);
      }
      if (profile.keyManagerAddress && !isValidAddress(profile.keyManagerAddress)) {
        errors.push(`Invalid Key Manager address for chain ${chainId}`);
      }
      if (profile.controllerAddress && !isValidAddress(profile.controllerAddress)) {
        errors.push(`Invalid controller address for chain ${chainId}`);
      }
    }
  }
  
  return {
    valid: errors.length === 0,
    errors,
  };
}

/**
 * Check if a string is a valid Ethereum address
 * @param {string} address - Address to check
 * @returns {boolean}
 */
function isValidAddress(address) {
  return /^0x[a-fA-F0-9]{40}$/.test(address);
}

/**
 * Format configuration for display
 * @param {Object} config - Configuration object
 * @returns {string} Formatted configuration string
 */
export function formatConfig(config) {
  const lines = [];
  
  lines.push('┌─────────────────────────────────────┐');
  lines.push('│ Universal Profile Configuration     │');
  lines.push('├─────────────────────────────────────┤');
  lines.push(`│ Default Chain: ${config.defaultChain.padEnd(20)} │`);
  lines.push(`│ Keystore: ${(config.keystorePath ? '✓ Set' : '✗ Not set').padEnd(25)} │`);
  
  if (Object.keys(config.profiles || {}).length > 0) {
    lines.push('├─────────────────────────────────────┤');
    lines.push('│ Configured Profiles:                │');
    for (const [chainId, profile] of Object.entries(config.profiles)) {
      const chainName = Object.entries(config.chains).find(([, c]) => c.chainId === parseInt(chainId))?.[0] || chainId;
      const upShort = profile.upAddress ? `${profile.upAddress.slice(0, 6)}...${profile.upAddress.slice(-4)}` : 'N/A';
      lines.push(`│   ${chainName}: ${upShort.padEnd(23)} │`);
    }
  }
  
  lines.push('└─────────────────────────────────────┘');
  
  return lines.join('\n');
}

export default {
  getConfigDir,
  getConfigPath,
  getKeystorePath,
  loadConfig,
  saveConfig,
  setConfigValue,
  getConfigValue,
  getChainConfig,
  getProfileConfig,
  saveProfileConfig,
  validateConfig,
  formatConfig,
};


path: lib/permissions.js
content: /**
 * LSP6 Permission Encoding/Decoding
 * Utilities for working with Universal Profile permissions
 */

import {
  PERMISSIONS,
  PERMISSION_NAMES,
  PERMISSION_PRESETS,
  PERMISSION_RISKS,
  DATA_KEYS,
} from './constants.js';

/**
 * Encode permission names into bytes32 format
 * @param {string[]} permissionNames - Array of permission names
 * @returns {string} Encoded permissions as hex string
 */
export function encodePermissions(permissionNames) {
  if (!Array.isArray(permissionNames)) {
    permissionNames = [permissionNames];
  }
  
  let combined = 0n;
  
  for (const name of permissionNames) {
    const upperName = name.toUpperCase();
    
    // Handle ALL_PERMISSIONS specially
    if (upperName === 'ALL_PERMISSIONS') {
      return PERMISSIONS.ALL_PERMISSIONS;
    }
    
    const permValue = PERMISSIONS[upperName];
    if (!permValue) {
      throw new Error(`Unknown permission: ${name}`);
    }
    
    combined |= BigInt(permValue);
  }
  
  // Pad to 32 bytes (64 hex chars + 0x prefix)
  return '0x' + combined.toString(16).padStart(64, '0');
}

/**
 * Combine multiple permission values
 * @param {string[]} permissions - Array of permission hex values or names
 * @returns {string} Combined permissions as hex string
 */
export function combinePermissions(permissions) {
  let combined = 0n;
  
  for (const perm of permissions) {
    if (typeof perm === 'string') {
      if (perm.startsWith('0x')) {
        combined |= BigInt(perm);
      } else {
        const permValue = PERMISSIONS[perm.toUpperCase()];
        if (permValue) {
          combined |= BigInt(permValue);
        }
      }
    } else if (typeof perm === 'bigint') {
      combined |= perm;
    }
  }
  
  return '0x' + combined.toString(16).padStart(64, '0');
}

/**
 * Decode permissions from bytes32 format
 * @param {string} permissionHex - Permissions as hex string
 * @returns {string[]} Array of permission names
 */
export function decodePermissions(permissionHex) {
  const permissions = [];
  const value = BigInt(permissionHex);
  
  // Check ALL_PERMISSIONS first
  if (value === BigInt(PERMISSIONS.ALL_PERMISSIONS)) {
    return ['ALL_PERMISSIONS'];
  }
  
  // Check each bit position
  for (let i = 0; i < 32; i++) {
    const bit = 1n << BigInt(i);
    if ((value & bit) !== 0n) {
      const name = PERMISSION_NAMES[i];
      if (name) {
        permissions.push(name);
      } else {
        permissions.push(`UNKNOWN_BIT_${i}`);
      }
    }
  }
  
  return permissions;
}

/**
 * Check if a permission set includes a specific permission
 * @param {string} permissionHex - Permissions as hex string
 * @param {string} permissionName - Permission name to check
 * @returns {boolean}
 */
export function hasPermission(permissionHex, permissionName) {
  const value = BigInt(permissionHex);
  const permValue = PERMISSIONS[permissionName.toUpperCase()];
  
  if (!permValue) {
    throw new Error(`Unknown permission: ${permissionName}`);
  }
  
  return (value & BigInt(permValue)) !== 0n;
}

/**
 * Add a permission to an existing permission set
 * @param {string} existingPermissions - Current permissions hex
 * @param {string} permissionToAdd - Permission name or hex to add
 * @returns {string} Updated permissions hex
 */
export function addPermission(existingPermissions, permissionToAdd) {
  const existing = BigInt(existingPermissions || '0x0');
  const toAdd = typeof permissionToAdd === 'string' && !permissionToAdd.startsWith('0x')
    ? BigInt(PERMISSIONS[permissionToAdd.toUpperCase()] || '0x0')
    : BigInt(permissionToAdd);
  
  return '0x' + (existing | toAdd).toString(16).padStart(64, '0');
}

/**
 * Remove a permission from an existing permission set
 * @param {string} existingPermissions - Current permissions hex
 * @param {string} permissionToRemove - Permission name or hex to remove
 * @returns {string} Updated permissions hex
 */
export function removePermission(existingPermissions, permissionToRemove) {
  const existing = BigInt(existingPermissions || '0x0');
  const toRemove = typeof permissionToRemove === 'string' && !permissionToRemove.startsWith('0x')
    ? BigInt(PERMISSIONS[permissionToRemove.toUpperCase()] || '0x0')
    : BigInt(permissionToRemove);
  
  return '0x' + (existing & ~toRemove).toString(16).padStart(64, '0');
}

/**
 * Get a preset permission configuration
 * @param {string} presetName - Name of the preset
 * @returns {{ permissions: string, name: string, description: string, riskLevel: string }}
 */
export function getPreset(presetName) {
  const preset = PERMISSION_PRESETS[presetName.toLowerCase()];
  if (!preset) {
    throw new Error(`Unknown preset: ${presetName}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}`);
  }
  
  return {
    ...preset,
    permissions: encodePermissions(preset.permissions),
  };
}

/**
 * List all available presets
 * @returns {Object[]} Array of preset info objects
 */
export function listPresets() {
  return Object.entries(PERMISSION_PRESETS).map(([key, preset]) => ({
    key,
    ...preset,
    permissionsHex: encodePermissions(preset.permissions),
  }));
}

/**
 * Validate permissions for security risks
 * @param {string} permissionHex - Permissions as hex string
 * @returns {{ valid: boolean, warnings: string[], risks: string[], riskLevel: string }}
 */
export function validatePermissions(permissionHex) {
  const warnings = [];
  const risks = [];
  const decodedPerms = decodePermissions(permissionHex);
  
  let highestRiskLevel = 'LOW';
  const riskLevels = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
  
  for (const perm of decodedPerms) {
    const riskInfo = PERMISSION_RISKS[perm];
    if (riskInfo) {
      const message = `${perm}: ${riskInfo.warning}`;
      
      if (riskInfo.level === 'CRITICAL') {
        risks.push(message);
        highestRiskLevel = 'CRITICAL';
      } else if (riskInfo.level === 'HIGH') {
        risks.push(message);
        if (riskLevels.indexOf(highestRiskLevel) < riskLevels.indexOf('HIGH')) {
          highestRiskLevel = 'HIGH';
        }
      } else if (riskInfo.level === 'MEDIUM') {
        warnings.push(message);
        if (riskLevels.indexOf(highestRiskLevel) < riskLevels.indexOf('MEDIUM')) {
          highestRiskLevel = 'MEDIUM';
        }
      } else {
        // LOW - don't add to warnings
      }
    }
  }
  
  return {
    valid: risks.length === 0,
    warnings,
    risks,
    riskLevel: highestRiskLevel,
    permissions: decodedPerms,
  };
}

/**
 * Build the data key for a controller's permissions
 * @param {string} controllerAddress - Controller address
 * @returns {string} Data key for permissions
 */
export function buildPermissionsDataKey(controllerAddress) {
  const prefix = DATA_KEYS['AddressPermissions:Permissions'];
  const address = controllerAddress.toLowerCase().replace('0x', '');
  return prefix + address;
}

/**
 * Build the data key for a controller's allowed calls
 * @param {string} controllerAddress - Controller address
 * @returns {string} Data key for allowed calls
 */
export function buildAllowedCallsDataKey(controllerAddress) {
  const prefix = DATA_KEYS['AddressPermissions:AllowedCalls'];
  const address = controllerAddress.toLowerCase().replace('0x', '');
  return prefix + address;
}

/**
 * Build the data key for a controller's allowed ERC725Y data keys
 * @param {string} controllerAddress - Controller address
 * @returns {string} Data key for allowed data keys
 */
export function buildAllowedDataKeysDataKey(controllerAddress) {
  const prefix = DATA_KEYS['AddressPermissions:AllowedERC725YDataKeys'];
  const address = controllerAddress.toLowerCase().replace('0x', '');
  return prefix + address;
}

/**
 * Encode AllowedCalls restriction
 * @param {Object[]} allowedCalls - Array of allowed call configurations
 * @returns {string} Encoded allowed calls
 */
export function encodeAllowedCalls(allowedCalls) {
  if (!allowedCalls || allowedCalls.length === 0) {
    return '0x';
  }
  
  // Each allowed call entry is:
  // - 4 bytes: call types bitmask
  // - 20 bytes: address (or 0xFFFF...FFFF for any)
  // - 4 bytes: interface ID (or 0xFFFFFFFF for any)
  // - 4 bytes: function selector (or 0xFFFFFFFF for any)
  // Total: 32 bytes per entry
  
  let encoded = '0x';
  
  for (const call of allowedCalls) {
    // Call types: TRANSFERVALUE (0x1) | CALL (0x2) | STATICCALL (0x4) | DELEGATECALL (0x8)
    const callType = (call.callType || 0x0002).toString(16).padStart(8, '0');
    const address = (call.address || [REDACTED_SECRET]).toLowerCase().replace('0x', '');
    const interfaceId = (call.interfaceId || '0xffffffff').replace('0x', '').padStart(8, '0');
    const functionSelector = (call.functionSelector || '0xffffffff').replace('0x', '').padStart(8, '0');
    
    encoded += callType + address + interfaceId + functionSelector;
  }
  
  return encoded;
}

/**
 * Decode AllowedCalls restriction
 * @param {string} encodedCalls - Encoded allowed calls hex string
 * @returns {Object[]} Array of allowed call configurations
 */
export function decodeAllowedCalls(encodedCalls) {
  if (!encodedCalls || encodedCalls === '0x' || encodedCalls.length < 66) {
    return [];
  }
  
  const data = encodedCalls.replace('0x', '');
  const calls = [];
  
  // Each entry is 64 hex chars (32 bytes)
  for (let i = 0; i < data.length; i += 64) {
    if (i + 64 > data.length) break;
    
    const entry = data.slice(i, i + 64);
    calls.push({
      callType: parseInt(entry.slice(0, 8), 16),
      address: '0x' + entry.slice(8, 48),
      interfaceId: '0x' + entry.slice(48, 56),
      functionSelector: '0x' + entry.slice(56, 64),
    });
  }
  
  return calls;
}

/**
 * Format permissions for display
 * @param {string} permissionHex - Permissions as hex string
 * @returns {string} Formatted permissions string
 */
export function formatPermissions(permissionHex) {
  const decoded = decodePermissions(permissionHex);
  const validation = validatePermissions(permissionHex);
  
  const lines = [];
  lines.push(`Permissions: ${permissionHex}`);
  lines.push(`Risk Level: ${validation.riskLevel}`);
  lines.push('');
  lines.push('Active Permissions:');
  
  for (const perm of decoded) {
    const risk = PERMISSION_RISKS[perm];
    const riskIcon = {
      CRITICAL: '🔴',
      HIGH: '🟠',
      MEDIUM: '🟡',
      LOW: '🟢',
    }[risk?.level || 'LOW'];
    
    lines.push(`  ${riskIcon} ${perm}`);
  }
  
  if (validation.risks.length > 0) {
    lines.push('');
    lines.push('⚠️ Security Risks:');
    for (const risk of validation.risks) {
      lines.push(`  - ${risk}`);
    }
  }
  
  if (validation.warnings.length > 0) {
    lines.push('');
    lines.push('⚡ Warnings:');
    for (const warning of validation.warnings) {
      lines.push(`  - ${warning}`);
    }
  }
  
  return lines.join('\n');
}

export default {
  encodePermissions,
  combinePermissions,
  decodePermissions,
  hasPermission,
  addPermission,
  removePermission,
  getPreset,
  listPresets,
  validatePermissions,
  buildPermissionsDataKey,
  buildAllowedCallsDataKey,
  buildAllowedDataKeysDataKey,
  encodeAllowedCalls,
  decodeAllowedCalls,
  formatPermissions,
};


path: lib/tokens/lsp7.js
content: /**
 * LSP7 Token Module
 * 
 * Provides functions to build LSP7 (fungible token) payloads.
 * This module does NOT execute transactions - it only builds calldata.
 * Use the execute module (lib/execute/) to actually send transactions.
 * 
 * Example:
 *   import { buildLSP7TransferPayload, getLSP7Info } from './lib/tokens/lsp7.js';
 *   import { buildExecutePayload, executeRelay } from './lib/execute/index.js';
 *   
 *   // 1. Get token info
 *   const info = await getLSP7Info(tokenAddress);
 *   
 *   // 2. Build transfer calldata
 *   const data = await buildLSP7TransferPayload(tokenAddress, from, to, '100');
 *   
 *   // 3. Wrap in UP.execute() and send
 *   const payload = buildExecutePayload(0, tokenAddress, 0, data);
 *   const { txHash } = await executeRelay(payload);
 */

import { ethers } from 'ethers';
import { getProviderWithCredentials } from '../provider.js';

// LSP7 ABI (subset we need)
const LSP7_ABI = [
  'function decimals() view returns (uint8)',
  'function totalSupply() view returns (uint256)',
  'function balanceOf(address) view returns (uint256)',
  'function transfer(address from, address to, uint256 amount, bool force, bytes data) external',
  'function authorizedAmountFor(address operator, address tokenOwner) view returns (uint256)',
  // ERC725Y for metadata
  'function getData(bytes32) view returns (bytes)'
];

// LSP4 Data Keys for token metadata
const LSP4_TOKEN_NAME_KEY = '0xdeba1e292f8ba88238e10ab3c7f88bd4be4fac56cad5194b6ecceaf653468af1';
const LSP4_TOKEN_SYMBOL_KEY = '0x2f0a68ab07768e01943a599e73362a0e17a63a72e94dd2e384d2c1d4db932756';

/**
 * Decode LSP4 metadata value (string)
 */
function decodeLSP4String(data) {
  if (!data || data === '0x') return '';
  try {
    // LSP4 strings are encoded as: offset (32 bytes) + length (32 bytes) + data
    // Or just the raw string bytes
    const bytes = ethers.getBytes(data);
    if (bytes.length <= 64) {
      // Try direct UTF8 decode
      return ethers.toUtf8String(data);
    }
    // ABI decode
    return ethers.AbiCoder.defaultAbiCoder().decode(['string'], data)[0];
  } catch {
    // Fallback: just decode as UTF8
    try {
      return ethers.toUtf8String(data);
    } catch {
      return '';
    }
  }
}

/**
 * Get LSP7 token info
 * 
 * @param {string} tokenAddress - Token contract address
 * @param {Object} options - Optional overrides
 * @returns {Promise<{name, symbol, decimals, totalSupply}>}
 */
export async function getLSP7Info(tokenAddress, options = {}) {
  const network = options.network || 'mainnet';
  const { ethersProvider } = getProviderWithCredentials(network);
  
  const token = new ethers.Contract(tokenAddress, LSP7_ABI, ethersProvider);
  
  const [nameData, symbolData, decimals, totalSupply] = await Promise.all([
    token.getData(LSP4_TOKEN_NAME_KEY),
    token.getData(LSP4_TOKEN_SYMBOL_KEY),
    token.decimals(),
    token.totalSupply()
  ]);

  return {
    address: tokenAddress,
    name: decodeLSP4String(nameData) || 'Unknown Token',
    symbol: decodeLSP4String(symbolData) || '???',
    decimals: Number(decimals),
    totalSupply: ethers.formatUnits(totalSupply, decimals)
  };
}

/**
 * Get LSP7 balance for an address
 * 
 * @param {string} tokenAddress - Token contract address
 * @param {string} holderAddress - Address to check balance for
 * @param {Object} options - Optional overrides
 * @returns {Promise<{balance, formatted, decimals}>}
 */
export async function getLSP7Balance(tokenAddress, holderAddress, options = {}) {
  const network = options.network || 'mainnet';
  const { ethersProvider } = getProviderWithCredentials(network);
  
  const token = new ethers.Contract(tokenAddress, LSP7_ABI, ethersProvider);
  const decimals = await token.decimals();
  const balance = await token.balanceOf(holderAddress);

  return {
    balance: balance.toString(),
    formatted: ethers.formatUnits(balance, decimals),
    decimals: Number(decimals)
  };
}

/**
 * Build LSP7 transfer calldata
 * 
 * This returns the encoded function call data for LSP7.transfer().
 * Wrap this in UP.execute() to actually transfer tokens.
 * 
 * @param {string} tokenAddress - Token contract address
 * @param {string} from - Sender address (usually the UP)
 * @param {string} to - Recipient address
 * @param {string} amount - Human-readable amount (e.g., "100", not raw decimals)
 * @param {Object} options - Optional overrides
 * @param {boolean} options.force - Force transfer even if recipient is not a UP (default: true)
 * @param {string} options.data - Additional data to pass (default: '0x')
 * @returns {Promise<string>} Encoded transfer calldata
 */
export async function buildLSP7TransferPayload(tokenAddress, from, to, amount, options = {}) {
  const network = options.network || 'mainnet';
  const force = options.force !== undefined ? options.force : true;
  const data = options.data || '0x';
  
  const { ethersProvider } = getProviderWithCredentials(network);
  
  const token = new ethers.Contract(tokenAddress, LSP7_ABI, ethersProvider);
  const decimals = await token.decimals();
  const amountInWei = ethers.parseUnits(amount.toString(), decimals);

  return token.interface.encodeFunctionData('transfer', [
    from,
    to,
    amountInWei,
    force,
    data
  ]);
}

/**
 * Validate a transfer can be made
 * 
 * @param {string} tokenAddress - Token contract address  
 * @param {string} from - Sender address
 * @param {string} amount - Human-readable amount
 * @param {Object} options - Optional overrides
 * @returns {Promise<{valid, balance, required, error?}>}
 */
export async function validateLSP7Transfer(tokenAddress, from, amount, options = {}) {
  const network = options.network || 'mainnet';
  const { ethersProvider } = getProviderWithCredentials(network);
  
  const token = new ethers.Contract(tokenAddress, LSP7_ABI, ethersProvider);
  const decimals = await token.decimals();
  const balance = await token.balanceOf(from);
  const required = ethers.parseUnits(amount.toString(), decimals);

  if (balance < required) {
    return {
      valid: false,
      balance: ethers.formatUnits(balance, decimals),
      required: amount,
      error: `Insufficient balance. Have: ${ethers.formatUnits(balance, decimals)}, Need: ${amount}`
    };
  }

  return {
    valid: true,
    balance: ethers.formatUnits(balance, decimals),
    required: amount
  };
}

export default {
  getLSP7Info,
  getLSP7Balance,
  buildLSP7TransferPayload,
  validateLSP7Transfer
};


path: lib/tokens/index.js
content: /**
 * Token Modules Index
 * 
 * Re-exports all token-related functions.
 * Each token module builds calldata only - use execute/ to send transactions.
 */

// LSP7 - Fungible tokens
export {
  getLSP7Info,
  getLSP7Balance,
  buildLSP7TransferPayload,
  validateLSP7Transfer
} from './lsp7.js';

// LSP8 - NFTs / Identifiable Digital Assets
export {
  getLSP8Info,
  getLSP8TokensOf,
  buildLSP8TransferPayload
} from './lsp8.js';

// Default export
import lsp7 from './lsp7.js';
import lsp8 from './lsp8.js';

export default {
  lsp7,
  lsp8
};


path: lib/tokens/lsp8.js
content: /**
 * LSP8 Token Module
 * 
 * Provides functions to build LSP8 (identifiable digital asset / NFT) payloads.
 * This module does NOT execute transactions - it only builds calldata.
 * Use the execute module (lib/execute/) to actually send transactions.
 * 
 * TODO: Implement LSP8 functions
 */

import { ethers } from 'ethers';
import { getProviderWithCredentials } from '../provider.js';

// LSP8 ABI (subset we need)
const LSP8_ABI = [
  'function name() view returns (string)',
  'function symbol() view returns (string)',
  'function totalSupply() view returns (uint256)',
  'function balanceOf(address) view returns (uint256)',
  'function tokenOwnerOf(bytes32 tokenId) view returns (address)',
  'function tokenIdsOf(address owner) view returns (bytes32[])',
  'function transfer(address from, address to, bytes32 tokenId, bool force, bytes data) external'
];

/**
 * Get LSP8 token info
 * 
 * @param {string} tokenAddress - Token contract address
 * @param {Object} options - Optional overrides
 * @returns {Promise<{name, symbol, totalSupply}>}
 */
export async function getLSP8Info(tokenAddress, options = {}) {
  const network = options.network || 'mainnet';
  const { ethersProvider } = getProviderWithCredentials(network);
  
  const token = new ethers.Contract(tokenAddress, LSP8_ABI, ethersProvider);
  
  const [name, symbol, totalSupply] = await Promise.all([
    token.name(),
    token.symbol(),
    token.totalSupply()
  ]);

  return {
    address: tokenAddress,
    name,
    symbol,
    totalSupply: totalSupply.toString()
  };
}

/**
 * Get LSP8 tokens owned by an address
 * 
 * @param {string} tokenAddress - Token contract address
 * @param {string} ownerAddress - Address to check
 * @param {Object} options - Optional overrides
 * @returns {Promise<{balance, tokenIds}>}
 */
export async function getLSP8TokensOf(tokenAddress, ownerAddress, options = {}) {
  const network = options.network || 'mainnet';
  const { ethersProvider } = getProviderWithCredentials(network);
  
  const token = new ethers.Contract(tokenAddress, LSP8_ABI, ethersProvider);
  const [balance, tokenIds] = await Promise.all([
    token.balanceOf(ownerAddress),
    token.tokenIdsOf(ownerAddress)
  ]);

  return {
    balance: Number(balance),
    tokenIds: tokenIds.map(id => id.toString())
  };
}

/**
 * Build LSP8 transfer calldata
 * 
 * @param {string} tokenAddress - Token contract address
 * @param {string} from - Sender address
 * @param {string} to - Recipient address
 * @param {string} tokenId - Token ID (bytes32)
 * @param {Object} options - Optional overrides
 * @returns {string} Encoded transfer calldata
 */
export function buildLSP8TransferPayload(tokenAddress, from, to, tokenId, options = {}) {
  const force = options.force !== undefined ? options.force : true;
  const data = options.data || '0x';
  
  const iface = new ethers.Interface(LSP8_ABI);
  return iface.encodeFunctionData('transfer', [
    from,
    to,
    tokenId,
    force,
    data
  ]);
}

export default {
  getLSP8Info,
  getLSP8TokensOf,
  buildLSP8TransferPayload
};


path: lib/execute/index.js
content: /**
 * Execute Module - Multi-Chain Transaction Execution
 * 
 * Two execution methods:
 * 
 * 1. DIRECT (all chains) — Controller calls UP.execute() and pays gas
 *    - executeDirect(operation, target, value, data, { network })
 *    - executeBatch(calls, { network })
 * 
 * 2. RELAY (LUKSO only) — Gasless via LUKSO relay service API
 *    - executeRelay(payload, { network })
 *    - Requires SIGN + EXECUTE_RELAY_CALL permissions and relay quota
 * 
 * Supported networks: 'mainnet' (LUKSO), 'testnet', 'base', 'ethereum'
 * 
 * Example:
 *   import { executeDirect, buildExecutePayload } from './lib/execute/index.js';
 *   import { buildLSP7TransferPayload } from './lib/tokens/lsp7.js';
 *   
 *   const tokenData = await buildLSP7TransferPayload(tokenAddr, from, to, amount);
 *   const { txHash } = await executeDirect(0, tokenAddr, 0, tokenData, { network: 'base' });
 */

export { 
  executeDirect, 
  executeBatch,
  buildExecutePayload 
} from './direct.js';

export { 
  executeRelay, 
  getRelayQuota 
} from './relay.js';

import directModule from './direct.js';
import relayModule from './relay.js';

export default {
  ...directModule,
  ...relayModule
};


path: lib/execute/direct.js
content: /**
 * Direct Transaction Execution (Multi-Chain)
 * 
 * Calls UP.execute() directly — controller sends the transaction and pays gas.
 * Works on ALL chains: LUKSO, Base, Ethereum.
 * 
 * The controller calls execute() on the UP contract. The UP verifies permissions
 * via its owner (KeyManager) using LSP20 lsp20VerifyCall internally.
 * The caller does NOT need to call the KeyManager directly.
 * 
 * For gasless transactions on LUKSO, use relay.js instead (LUKSO relay API only).
 * 
 * REQUIRES:
 * - Controller must have sufficient LSP6 permissions for the action
 * - Controller must have native tokens to pay gas (LYX, ETH)
 */

import { ethers } from 'ethers';
import { getProviderWithCredentials } from '../provider.js';
import { getExplorerUrl } from '../constants.js';

// UP ABI — only the execute functions needed
const UP_ABI = [
  'function execute(uint256 operation, address target, uint256 value, bytes data) payable returns (bytes)',
  'function executeBatch(uint256[] operationTypes, address[] targets, uint256[] values, bytes[] datas) payable returns (bytes[])',
];

/**
 * Execute a payload via UP.execute() on any supported chain.
 * 
 * @param {number} operation - 0=CALL, 1=CREATE, 2=CREATE2, 3=STATICCALL, 4=DELEGATECALL
 * @param {string} target - Target contract address
 * @param {bigint|number} value - Native token value to send (LYX or ETH)
 * @param {string} data - Encoded calldata for target
 * @param {Object} options - Optional overrides
 * @param {string} options.network - 'mainnet'|'testnet'|'base'|'ethereum' (default: 'mainnet')
 * @returns {Promise<{txHash: string, explorerUrl: string}>}
 */
export async function executeDirect(operation, target, value, data, options = {}) {
  const network = options.network || 'mainnet';
  
  const { upAddress, ethersWallet, chainId } = getProviderWithCredentials(network);

  const up = new ethers.Contract(upAddress, UP_ABI, ethersWallet);
  
  const tx = await up.execute(operation, target, value, data);
  const receipt = await tx.wait();

  return {
    txHash: receipt.hash,
    explorerUrl: getExplorerUrl(receipt.hash, chainId),
  };
}

/**
 * Execute a batch of operations in a single transaction.
 * 
 * @param {Array<{operation: number, target: string, value: bigint|number, data: string}>} calls
 * @param {Object} options
 * @returns {Promise<{txHash: string, explorerUrl: string}>}
 */
export async function executeBatch(calls, options = {}) {
  const network = options.network || 'mainnet';
  
  const { upAddress, ethersWallet, chainId } = getProviderWithCredentials(network);

  const up = new ethers.Contract(upAddress, UP_ABI, ethersWallet);
  
  const tx = await up.executeBatch(
    calls.map(c => c.operation),
    calls.map(c => c.target),
    calls.map(c => c.value),
    calls.map(c => c.data),
  );
  const receipt = await tx.wait();

  return {
    txHash: receipt.hash,
    explorerUrl: getExplorerUrl(receipt.hash, chainId),
  };
}

/**
 * Build a UP.execute() payload (helper for relay.js or batch building)
 */
export function buildExecutePayload(operation, target, value, data) {
  const iface = new ethers.Interface(UP_ABI);
  return iface.encodeFunctionData('execute', [operation, target, value, data]);
}

export default { executeDirect, executeBatch, buildExecutePayload };


path: lib/execute/relay.js
content: /**
 * Relay Execution (LSP25) — LUKSO ONLY
 * 
 * Gasless execution via LUKSO's transaction relay service API.
 * NOT available on Base, Ethereum, or other chains.
 * 
 * The controller signs the payload, and the relay service submits the
 * transaction on-chain. Gas is paid from the UP's relay quota.
 * 
 * For non-LUKSO chains, use direct.js (controller pays gas).
 * 
 * REQUIRES:
 * - EXECUTE_RELAY_CALL permission (0x400000)
 * - SIGN permission (0x200000) - for ERC-1271 verification by relayer
 * - Relay quota (check via LUKSO relay API)
 */

import { ethers } from 'ethers';
import { getProviderWithCredentials } from '../provider.js';
import { getExplorerUrl } from '../constants.js';

// LSP25 version constant
const LSP25_VERSION = 25n;

// Relayer endpoints
const RELAYER_URLS = {
  mainnet: 'https://relayer.mainnet.lukso.network/api',
  testnet: 'https://relayer.testnet.lukso.network/api'
};

// ABIs needed for relay execution
const UP_ABI = ['function owner() view returns (address)'];
const KM_ABI = ['function getNonce(address, uint128) view returns (uint256)'];

/**
 * Execute a payload via relay (gasless)
 * 
 * @param {string} payload - The encoded UP.execute() calldata
 * @param {Object} options - Optional overrides
 * @param {string} options.network - 'mainnet' or 'testnet'
 * @param {number} options.value - LYX value to send (default: 0)
 * @returns {Promise<{txHash: string, explorerUrl: string}>}
 */
export async function executeRelay(payload, options = {}) {
  const network = options.network || 'mainnet';
  const value = options.value || 0;
  
  const {
    upAddress,
    controllerAddress,
    privateKey,
    ethersProvider,
    chainId
  } = getProviderWithCredentials(network);

  // Get Key Manager address
  const up = new ethers.Contract(upAddress, UP_ABI, ethersProvider);
  const kmAddress = await up.owner();
  const km = new ethers.Contract(kmAddress, KM_ABI, ethersProvider);

  // Get nonce for our controller
  const nonce = await km.getNonce(controllerAddress, 0);

  // Build LSP25 message
  // Format: version || chainId || nonce || validityTimestamps || value || payload
  const encodedMessage = ethers.solidityPacked(
    ['uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'bytes'],
    [LSP25_VERSION, chainId, nonce, 0n, BigInt(value), payload]
  );

  // EIP-191 v0 hash with Key Manager as intended validator
  const hash = ethers.keccak256(
    ethers.concat(['0x19', '0x00', kmAddress, encodedMessage])
  );

  // Sign
  const sig = new ethers.SigningKey(privateKey).sign(hash);
  const signature = ethers.Signature.from(sig).serialized;

  // Verify signature locally
  const recovered = ethers.recoverAddress(hash, signature);
  if (recovered.toLowerCase() !== controllerAddress.toLowerCase()) {
    throw new Error(`Signature verification failed! Recovered: ${recovered}, Expected: ${controllerAddress}`);
  }

  // Build relay request
  const relayRequest = {
    address: upAddress,
    transaction: {
      abi: payload,
      signature,
      nonce: nonce.toString(),
      validityTimestamps: '0x0'
    }
  };

  // Send to relayer
  const relayerUrl = RELAYER_URLS[network] || RELAYER_URLS.mainnet;
  const response = await fetch(`${relayerUrl}/execute`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(relayRequest)
  });

  const result = await response.json();

  if (!response.ok || !result.transactionHash) {
    if (response.status === 401) {
      throw new Error(
        'Relay API returned 401 Unauthorized. ' +
        'The SIGN permission (0x200000) is required for gasless relay transactions. ' +
        'Use direct execution instead, or add SIGN permission to your controller.'
      );
    }
    throw new Error(`Relay failed: ${response.status} - ${JSON.stringify(result)}`);
  }

  const explorerChainId = network === 'testnet' ? 4201 : 42;
  return {
    txHash: result.transactionHash,
    explorerUrl: getExplorerUrl(result.transactionHash, explorerChainId)
  };
}

/**
 * Check relay quota for a UP (requires signed request)
 * 
 * @param {Object} options - Optional overrides
 * @param {string} options.network - 'mainnet' or 'testnet'
 * @returns {Promise<{quota: number, unit: string, totalQuota: number, resetDate: number}>}
 */
export async function getRelayQuota(options = {}) {
  const network = options.network || 'mainnet';
  const relayerUrl = RELAYER_URLS[network] || RELAYER_URLS.mainnet;

  // Load credentials
  const { loadAndValidateCredentials } = await import('../credentials.js');
  const creds = loadAndValidateCredentials();
  
  const upAddress = creds.universalProfile.address;
  const privateKey = creds.controller.privateKey;
  
  const timestamp = Math.floor(Date.now() / 1000);
  const wallet = new ethers.Wallet(privateKey);
  
  const messageHash = ethers.solidityPackedKeccak256(
    ['address', 'uint256'],
    [upAddress, timestamp]
  );
  const signature = await wallet.signMessage(ethers.getBytes(messageHash));

  const response = await fetch(`${relayerUrl}/quota`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ address: upAddress, timestamp, signature })
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Failed to get quota: ${response.status} - ${body}`);
  }

  return response.json();
}

export default { executeRelay, getRelayQuota };


path: examples/token-deployment.md
content: # Token Deployment Examples

## Deploying LSP7 Fungible Token

```typescript
import { deployLSP7Token, mintLSP7 } from 'openclaw-universalprofile-skill';

// Deploy token
const tokenAddress = await deployLSP7Token({
  name: 'EmmetCoin',
  symbol: 'EMT',
  decimals: 18,
  isNFT: false,
  profileAddress: process.env.UP_ADDRESS
});

console.log(`Token deployed at: ${tokenAddress}`);

// Mint initial supply
await mintLSP7({
  tokenAddress,
  to: process.env.UP_ADDRESS,
  amount: '1000000000000000000000000', // 1 million tokens
  force: true // bypass LSP1 Universal Receiver check
});
```

## Deploying LSP8 NFT Collection

```typescript
import { deployLSP8NFT } from 'openclaw-universalprofile-skill';

const nftAddress = await deployLSP8NFT({
  name: 'Emmet Art Collection',
  symbol: 'EART',
  profileAddress: process.env.UP_ADDRESS
});

console.log(`NFT collection deployed at: ${nftAddress}`);
```

## Minting NFTs with Metadata

```typescript
import { mintLSP8WithMetadata } from 'openclaw-universalprofile-skill';

// Mint NFT #1
await mintLSP8WithMetadata({
  nftAddress: '0x...',
  tokenId: '1',
  to: '0xRECIPIENT...',
  metadata: {
    name: 'Octopus #1',
    description: 'A purple octopus representing AI intelligence',
    image: 'ipfs://QmXXX...',
    attributes: [
      { trait_type: 'Color', value: 'Purple' },
      { trait_type: 'Arms', value: '8' },
      { trait_type: 'Rarity', value: 'Legendary' }
    ]
  }
});
```

## Batch Minting NFTs

```typescript
import { batchMintLSP8 } from 'openclaw-universalprofile-skill';

const tokenIds = ['1', '2', '3', '4', '5'];
const recipients = Array(5).fill('0xRECIPIENT...');

await batchMintLSP8({
  nftAddress: '0x...',
  tokenIds,
  recipients
});
```

## Transferring Tokens

```typescript
import { transferLSP7, transferLSP8 } from 'openclaw-universalprofile-skill';

// Transfer LSP7 tokens
await transferLSP7({
  tokenAddress: '0x...',
  from: process.env.UP_ADDRESS,
  to: '0xRECIPIENT...',
  amount: '1000000000000000000' // 1 token
});

// Transfer LSP8 NFT
await transferLSP8({
  nftAddress: '0x...',
  from: process.env.UP_ADDRESS,
  to: '0xRECIPIENT...',
  tokenId: '1',
  force: false // respect LSP1 Universal Receiver
});
```

## Querying Token Balances

```typescript
import { getTokenBalance, getNFTBalance } from 'openclaw-universalprofile-skill';

// LSP7 balance
const balance = await getTokenBalance({
  tokenAddress: '0x...',
  owner: process.env.UP_ADDRESS
});
console.log(`Balance: ${balance}`);

// LSP8 balance (token count)
const nftBalance = await getNFTBalance({
  nftAddress: '0x...',
  owner: process.env.UP_ADDRESS
});
console.log(`NFT count: ${nftBalance}`);

// LSP8 token IDs owned
const tokenIds = await getOwnedTokenIds({
  nftAddress: '0x...',
  owner: process.env.UP_ADDRESS
});
console.log(`Owned token IDs:`, tokenIds);
```

## Tips

- Use `force: true` for LSP7/LSP8 transfers to EOAs (they don't have Universal Receiver)
- Use `force: false` for transfers to Universal Profiles (respects LSP1)
- Store metadata on IPFS and use IPFS hashes in LSP4 metadata
- LSP7 amounts are in wei (use ethers.parseEther() for decimals)


path: examples/basic-profile.md
content: # Basic Universal Profile Operations

This guide shows basic operations with Universal Profiles.

## Creating a Universal Profile

```typescript
import { createUniversalProfile } from 'openclaw-universalprofile-skill';

const profile = await createUniversalProfile({
  name: 'Emmet AI',
  description: 'AI assistant with its own Universal Profile',
  profileImage: 'ipfs://QmXXX...',
  controllerPrivateKey: process.env.UP_PRIVATE_KEY
});

console.log(`Profile created at: ${profile.address}`);
```

## Querying Profile Information

```typescript
import { getProfileInfo } from 'openclaw-universalprofile-skill';

const info = await getProfileInfo('0x...');

console.log('Profile Name:', info.name);
console.log('Profile Image:', info.profileImage);
console.log('LSP3 Metadata:', info.metadata);
```

## Updating Profile Metadata

```typescript
import { updateProfile } from 'openclaw-universalprofile-skill';

await updateProfile({
  profileAddress: '0x...',
  updates: {
    name: 'Emmet the Octopus',
    description: 'Updated description',
    profileImage: 'ipfs://QmYYY...'
  }
});
```

## Managing Permissions (LSP6)

```typescript
import { grantPermissions, revokePermissions } from 'openclaw-universalprofile-skill';

// Grant CALL permission to an address
await grantPermissions({
  profileAddress: '0x...',
  beneficiary: '0xBENEFICIARY...',
  permissions: ['CALL', 'TRANSFERVALUE']
});

// Revoke permissions
await revokePermissions({
  profileAddress: '0x...',
  beneficiary: '0xBENEFICIARY...'
});
```

## Executing Transactions Through UP

```typescript
import { executeViaProfile } from 'openclaw-universalprofile-skill';

// Send LYX to an address
await executeViaProfile({
  profileAddress: '0x...',
  to: '0xRECIPIENT...',
  value: '1000000000000000000', // 1 LYX in wei
  data: '0x'
});
```

## Tips

- Universal Profiles are smart contract accounts (ERC725Account)
- All transactions are executed through the UP, not the controller EOA
- Controller pays gas, but transactions execute in UP context
- Use LSP6 Key Manager for fine-grained permission control


path: tests/profile.test.ts
content: import { describe, it, expect, vi, beforeEach } from 'vitest';

import { DATA_KEYS, INTERFACE_IDS, PERMISSIONS } from '../../src/utils/constants.js';
import { UniversalProfileError, ERROR_CODES } from '../../src/types/index.js';

// ==================== Helpers ====================

const VALID_UP_ADDRESS = [REDACTED_SECRET];
const VALID_CONTROLLER = [REDACTED_SECRET];
const VALID_KM_ADDRESS = [REDACTED_SECRET];
const ZERO_PERMISSIONS = '0x0000000000000000000000000000000000000000000000000000000000000000';

/**
 * Build a mock contract whose methods are vi.fn() stubs.
 */
function createMockContract() {
  return {
    getData: vi.fn(),
    getDataBatch: vi.fn(),
    owner: vi.fn(),
    supportsInterface: vi.fn(),
    getFunction: vi.fn(),
  };
}

/**
 * Build a mock provider with getCode and getBalance stubs.
 */
function createMockProvider() {
  return {
    getCode: vi.fn(),
    getBalance: vi.fn(),
  } as any;
}

// Shared mock for the Contract constructor. The vi.mock factory below captures
// this reference. In beforeEach we swap out `contractFactory` to point at a fresh
// mock contract, and the factory closure picks it up.
let contractFactory: (...args: any[]) => any;

vi.mock('ethers', async () => {
  const actual = await vi.importActual<typeof import('ethers')>('ethers');
  return {
    ...actual,
    Contract: vi.fn((...args: any[]) => contractFactory(...args)),
  };
});

// We import profile AFTER the mock is set up (vi.mock is hoisted anyway).
import {
  getProfileInfo,
  getControllers,
  isController,
  getControllerPermissions,
  getReceivedAssets,
  getData,
  getDataBatch,
  getKeyManager,
  verifyKeyManager,
} from '../../src/lib/profile.js';

// We also need actual ethers utilities (isAddress, getAddress, etc.)
import { ethers } from 'ethers';

// ==================== Setup ====================

let mockContract: ReturnType<typeof createMockContract>;
let mockProvider: ReturnType<typeof createMockProvider>;

beforeEach(() => {
  vi.clearAllMocks();
  mockContract = createMockContract();
  contractFactory = () => mockContract;
  mockProvider = createMockProvider();
});

// ==================== getProfileInfo ====================

describe('getProfileInfo', () => {
  it('should throw on an invalid address', async () => {
    await expect(getProfileInfo('not-an-address', mockProvider)).rejects.toThrow(
      UniversalProfileError
    );
    await expect(getProfileInfo('not-an-address', mockProvider)).rejects.toThrow(
      /Invalid address/
    );
  });

  it('should throw with INVALID_ADDRESS code for invalid address', async () => {
    try {
      await getProfileInfo('0xZZZ', mockProvider);
      expect.unreachable('should have thrown');
    } catch (err) {
      expect(err).toBeInstanceOf(UniversalProfileError);
      expect((err as UniversalProfileError).code).toBe(ERROR_CODES.INVALID_ADDRESS);
    }
  });

  it('should throw when no contract code exists at address', async () => {
    mockProvider.getCode.mockResolvedValue('0x');

    await expect(getProfileInfo(VALID_UP_ADDRESS, mockProvider)).rejects.toThrow(
      /No contract found/
    );
  });

  it('should throw with NOT_AUTHORIZED code when no contract at address', async () => {
    mockProvider.getCode.mockResolvedValue('0x');

    try {
      await getProfileInfo(VALID_UP_ADDRESS, mockProvider);
      expect.unreachable('should have thrown');
    } catch (err) {
      expect(err).toBeInstanceOf(UniversalProfileError);
      expect((err as UniversalProfileError).code).toBe(ERROR_CODES.NOT_AUTHORIZED);
    }
  });

  it('should throw when address is not a Universal Profile (supportsInterface returns false)', async () => {
    mockProvider.getCode.mockResolvedValue('0x6080604052');
    mockContract.supportsInterface.mockResolvedValue(false);

    await expect(getProfileInfo(VALID_UP_ADDRESS, mockProvider)).rejects.toThrow(
      /not a Universal Profile/
    );
  });

  it('should throw with INVALID_ADDRESS code when not a UP', async () => {
    mockProvider.getCode.mockResolvedValue('0x6080604052');
    mockContract.supportsInterface.mockResolvedValue(false);

    try {
      await getProfileInfo(VALID_UP_ADDRESS, mockProvider);
      expect.unreachable('should have thrown');
    } catch (err) {
      expect(err).toBeInstanceOf(UniversalProfileError);
      expect((err as UniversalProfileError).code).toBe(ERROR_CODES.INVALID_ADDRESS);
    }
  });

  it('should treat supportsInterface rejection as not a UP', async () => {
    mockProvider.getCode.mockResolvedValue('0x6080604052');
    mockContract.supportsInterface.mockRejectedValue(new Error('revert'));

    await expect(getProfileInfo(VALID_UP_ADDRESS, mockProvider)).rejects.toThrow(
      /not a Universal Profile/
    );
  });

  it('should return full profile info on success', async () => {
    const ownerAddress = '0x0000000000000000000000000000000000000042';
    const balance = 1000000000000000000n;

    mockProvider.getCode.mockResolvedValue('0x6080604052');
    mockProvider.getBalance.mockResolvedValue(balance);

    mockContract.supportsInterface.mockResolvedValue(true);
    mockContract.owner.mockResolvedValue(ownerAddress);

    // Profile metadata -- return 0x so no name/description parsed
    // Controllers array length -- return 0x (no controllers)
    // Received assets array length -- return 0x (no assets)
    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS.LSP3Profile) return '0x';
      if (key === DATA_KEYS['AddressPermissions[]'].length) return '0x';
      if (key === DATA_KEYS['LSP5ReceivedAssets[]'].length) return '0x';
      return '0x';
    });

    const info = await getProfileInfo(VALID_UP_ADDRESS, mockProvider);

    expect(info.address).toBe(VALID_UP_ADDRESS);
    expect(info.owner).toBe(ownerAddress);
    expect(info.keyManager).toBe(ownerAddress);
    expect(info.balance).toBe(balance);
    expect(info.controllers).toEqual([]);
    expect(info.receivedAssets).toEqual({ lsp7: [], lsp8: [] });
  });

  it('should return name and description when metadata is decodable', async () => {
    const ownerAddress = '0x0000000000000000000000000000000000000042';

    mockProvider.getCode.mockResolvedValue('0x6080604052');
    mockProvider.getBalance.mockResolvedValue(0n);

    mockContract.supportsInterface.mockResolvedValue(true);
    mockContract.owner.mockResolvedValue(ownerAddress);

    // Encode a JSON profile as hex bytes
    const profileJson = JSON.stringify({
      LSP3Profile: { name: 'TestProfile', description: 'A test profile' },
    });
    const profileHex = ethers.hexlify(ethers.toUtf8Bytes(profileJson));

    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS.LSP3Profile) return profileHex;
      if (key === DATA_KEYS['AddressPermissions[]'].length) return '0x';
      if (key === DATA_KEYS['LSP5ReceivedAssets[]'].length) return '0x';
      return '0x';
    });

    const info = await getProfileInfo(VALID_UP_ADDRESS, mockProvider);

    expect(info.name).toBe('TestProfile');
    expect(info.description).toBe('A test profile');
  });

  it('should handle profile metadata with VerifiableURI prefix gracefully', async () => {
    const ownerAddress = '0x0000000000000000000000000000000000000042';

    mockProvider.getCode.mockResolvedValue('0x6080604052');
    mockProvider.getBalance.mockResolvedValue(0n);

    mockContract.supportsInterface.mockResolvedValue(true);
    mockContract.owner.mockResolvedValue(ownerAddress);

    // VerifiableURI prefix: 0x6f357c6a...
    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS.LSP3Profile)
        return '0x6f357c6a0000000000000000000000000000000000000000000000000000000000';
      if (key === DATA_KEYS['AddressPermissions[]'].length) return '0x';
      if (key === DATA_KEYS['LSP5ReceivedAssets[]'].length) return '0x';
      return '0x';
    });

    const info = await getProfileInfo(VALID_UP_ADDRESS, mockProvider);

    // VerifiableURI is not fetched in current implementation, so name/description are undefined
    expect(info.name).toBeUndefined();
    expect(info.description).toBeUndefined();
  });

  it('should gracefully handle metadata read failure', async () => {
    const ownerAddress = '0x0000000000000000000000000000000000000042';

    mockProvider.getCode.mockResolvedValue('0x6080604052');
    mockProvider.getBalance.mockResolvedValue(0n);

    mockContract.supportsInterface.mockResolvedValue(true);
    mockContract.owner.mockResolvedValue(ownerAddress);

    let lsp3CallCount = 0;
    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS.LSP3Profile) {
        lsp3CallCount++;
        if (lsp3CallCount <= 1) throw new Error('revert');
        return '0x';
      }
      if (key === DATA_KEYS['AddressPermissions[]'].length) return '0x';
      if (key === DATA_KEYS['LSP5ReceivedAssets[]'].length) return '0x';
      return '0x';
    });

    const info = await getProfileInfo(VALID_UP_ADDRESS, mockProvider);

    // Should not throw -- metadata error is swallowed
    expect(info.name).toBeUndefined();
    expect(info.description).toBeUndefined();
  });
});

// ==================== getControllers ====================

describe('getControllers', () => {
  it('should return an empty array when no controllers exist', async () => {
    mockContract.getData.mockResolvedValue('0x');

    const controllers = await getControllers(VALID_UP_ADDRESS, mockProvider);
    expect(controllers).toEqual([]);
  });

  it('should return an empty array when length data is null', async () => {
    mockContract.getData.mockResolvedValue(null);

    const controllers = await getControllers(VALID_UP_ADDRESS, mockProvider);
    expect(controllers).toEqual([]);
  });

  it('should parse a single controller with permissions', async () => {
    const controllerAddressRaw =
      '0x000000000000000000000000' + VALID_CONTROLLER.slice(2).toLowerCase();
    const permissionHex = PERMISSIONS.CALL;

    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS['AddressPermissions[]'].length) {
        return '0x01';
      }
      // Index key for element 0
      if (
        key.startsWith(DATA_KEYS['AddressPermissions[]'].index) &&
        key !== DATA_KEYS['AddressPermissions[]'].length
      ) {
        return controllerAddressRaw;
      }
      // Permissions key
      if (key.startsWith(DATA_KEYS['AddressPermissions:Permissions'])) {
        return permissionHex;
      }
      // Allowed calls / data keys
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedCalls'])) {
        return '0x';
      }
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedERC725YDataKeys'])) {
        return '0x';
      }
      return '0x';
    });

    const controllers = await getControllers(VALID_UP_ADDRESS, mockProvider);

    expect(controllers).toHaveLength(1);
    expect(controllers[0].address).toBe(ethers.getAddress(VALID_CONTROLLER));
    expect(controllers[0].permissions).toBe(permissionHex);
    expect(controllers[0].decodedPermissions).toContain('CALL');
    expect(controllers[0].allowedCalls).toBeUndefined();
    expect(controllers[0].allowedDataKeys).toBeUndefined();
  });

  it('should include allowedCalls when present', async () => {
    const controllerAddressRaw =
      '0x000000000000000000000000' + VALID_CONTROLLER.slice(2).toLowerCase();
    const allowedCallsData = '0x0020aabbccdd11111111111111111111111111111111111111c52d6008760d9bba';

    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS['AddressPermissions[]'].length) return '0x01';
      if (
        key.startsWith(DATA_KEYS['AddressPermissions[]'].index) &&
        key !== DATA_KEYS['AddressPermissions[]'].length
      ) {
        return controllerAddressRaw;
      }
      if (key.startsWith(DATA_KEYS['AddressPermissions:Permissions'])) return PERMISSIONS.CALL;
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedCalls'])) return allowedCallsData;
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedERC725YDataKeys'])) return '0x';
      return '0x';
    });

    const controllers = await getControllers(VALID_UP_ADDRESS, mockProvider);

    expect(controllers[0].allowedCalls).toBe(allowedCallsData);
  });

  it('should include allowedDataKeys when present', async () => {
    const controllerAddressRaw =
      '0x000000000000000000000000' + VALID_CONTROLLER.slice(2).toLowerCase();
    const allowedDataKeysData = '0x00045ef83ad9';

    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS['AddressPermissions[]'].length) return '0x01';
      if (
        key.startsWith(DATA_KEYS['AddressPermissions[]'].index) &&
        key !== DATA_KEYS['AddressPermissions[]'].length
      ) {
        return controllerAddressRaw;
      }
      if (key.startsWith(DATA_KEYS['AddressPermissions:Permissions'])) return PERMISSIONS.SETDATA;
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedCalls'])) return '0x';
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedERC725YDataKeys']))
        return allowedDataKeysData;
      return '0x';
    });

    const controllers = await getControllers(VALID_UP_ADDRESS, mockProvider);

    expect(controllers[0].allowedDataKeys).toBe(allowedDataKeysData);
  });

  it('should skip entries where controller address data is 0x', async () => {
    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS['AddressPermissions[]'].length) return '0x02';
      // Index key -- differentiate by last portion
      if (
        key.startsWith(DATA_KEYS['AddressPermissions[]'].index) &&
        key !== DATA_KEYS['AddressPermissions[]'].length
      ) {
        const indexHex = key.slice(DATA_KEYS['AddressPermissions[]'].index.length);
        const index = parseInt(indexHex, 16);
        if (index === 0) return '0x';
        return '0x000000000000000000000000' + VALID_CONTROLLER.slice(2).toLowerCase();
      }
      if (key.startsWith(DATA_KEYS['AddressPermissions:Permissions'])) return PERMISSIONS.CALL;
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedCalls'])) return '0x';
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedERC725YDataKeys'])) return '0x';
      return '0x';
    });

    const controllers = await getControllers(VALID_UP_ADDRESS, mockProvider);

    // Only the second valid entry should be included
    expect(controllers).toHaveLength(1);
  });

  it('should handle allowedCalls read failure gracefully', async () => {
    const controllerAddressRaw =
      '0x000000000000000000000000' + VALID_CONTROLLER.slice(2).toLowerCase();

    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS['AddressPermissions[]'].length) return '0x01';
      if (
        key.startsWith(DATA_KEYS['AddressPermissions[]'].index) &&
        key !== DATA_KEYS['AddressPermissions[]'].length
      ) {
        return controllerAddressRaw;
      }
      if (key.startsWith(DATA_KEYS['AddressPermissions:Permissions'])) return PERMISSIONS.CALL;
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedCalls']))
        throw new Error('revert');
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedERC725YDataKeys'])) return '0x';
      return '0x';
    });

    const controllers = await getControllers(VALID_UP_ADDRESS, mockProvider);

    expect(controllers).toHaveLength(1);
    expect(controllers[0].allowedCalls).toBeUndefined();
  });

  it('should handle allowedDataKeys read failure gracefully', async () => {
    const controllerAddressRaw =
      '0x000000000000000000000000' + VALID_CONTROLLER.slice(2).toLowerCase();

    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS['AddressPermissions[]'].length) return '0x01';
      if (
        key.startsWith(DATA_KEYS['AddressPermissions[]'].index) &&
        key !== DATA_KEYS['AddressPermissions[]'].length
      ) {
        return controllerAddressRaw;
      }
      if (key.startsWith(DATA_KEYS['AddressPermissions:Permissions'])) return PERMISSIONS.CALL;
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedCalls'])) return '0x';
      if (key.startsWith(DATA_KEYS['AddressPermissions:AllowedERC725YDataKeys']))
        throw new Error('revert');
      return '0x';
    });

    const controllers = await getControllers(VALID_UP_ADDRESS, mockProvider);

    expect(controllers).toHaveLength(1);
    expect(controllers[0].allowedDataKeys).toBeUndefined();
  });
});

// ==================== isController ====================

describe('isController', () => {
  it('should return true when controller has non-zero permissions', async () => {
    mockContract.getData.mockResolvedValue(PERMISSIONS.CALL);

    const result = await isController(VALID_UP_ADDRESS, VALID_CONTROLLER, mockProvider);
    expect(result).toBe(true);
  });

  it('should return true for ALL_PERMISSIONS', async () => {
    mockContract.getData.mockResolvedValue(PERMISSIONS.ALL_PERMISSIONS);

    const result = await isController(VALID_UP_ADDRESS, VALID_CONTROLLER, mockProvider);
    expect(result).toBe(true);
  });

  it('should return false when permissions are zero', async () => {
    mockContract.getData.mockResolvedValue(ZERO_PERMISSIONS);

    const result = await isController(VALID_UP_ADDRESS, VALID_CONTROLLER, mockProvider);
    expect(result).toBe(false);
  });

  it('should return false when permissions are 0x', async () => {
    mockContract.getData.mockResolvedValue('0x');

    const result = await isController(VALID_UP_ADDRESS, VALID_CONTROLLER, mockProvider);
    expect(result).toBe(false);
  });

  it('should return falsy when permissions are null', async () => {
    mockContract.getData.mockResolvedValue(null);

    const result = await isController(VALID_UP_ADDRESS, VALID_CONTROLLER, mockProvider);
    expect(result).toBeFalsy();
  });

  it('should query the correct permission data key for the controller', async () => {
    mockContract.getData.mockResolvedValue(PERMISSIONS.CALL);

    await isController(VALID_UP_ADDRESS, VALID_CONTROLLER, mockProvider);

    const expectedKey =
      DATA_KEYS['AddressPermissions:Permissions'] +
      VALID_CONTROLLER.slice(2).toLowerCase();
    expect(mockContract.getData).toHaveBeenCalledWith(expectedKey);
  });
});

// ==================== getControllerPermissions ====================

describe('getControllerPermissions', () => {
  it('should return the permission hex when set', async () => {
    mockContract.getData.mockResolvedValue(PERMISSIONS.CALL);

    const result = await getControllerPermissions(VALID_UP_ADDRESS, VALID_CONTROLLER, mockProvider);
    expect(result).toBe(PERMISSIONS.CALL);
  });

  it('should return null when permissions are 0x', async () => {
    mockContract.getData.mockResolvedValue('0x');

    const result = await getControllerPermissions(VALID_UP_ADDRESS, VALID_CONTROLLER, mockProvider);
    expect(result).toBeNull();
  });

  it('should return null when permissions are null/undefined', async () => {
    mockContract.getData.mockResolvedValue(null);

    const result = await getControllerPermissions(VALID_UP_ADDRESS, VALID_CONTROLLER, mockProvider);
    expect(result).toBeNull();
  });

  it('should return the full permissions value for ALL_PERMISSIONS', async () => {
    mockContract.getData.mockResolvedValue(PERMISSIONS.ALL_PERMISSIONS);

    const result = await getControllerPermissions(VALID_UP_ADDRESS, VALID_CONTROLLER, mockProvider);
    expect(result).toBe(PERMISSIONS.ALL_PERMISSIONS);
  });

  it('should query the correct permission data key', async () => {
    mockContract.getData.mockResolvedValue(PERMISSIONS.SETDATA);

    await getControllerPermissions(VALID_UP_ADDRESS, VALID_CONTROLLER, mockProvider);

    const expectedKey =
      DATA_KEYS['AddressPermissions:Permissions'] +
      VALID_CONTROLLER.slice(2).toLowerCase();
    expect(mockContract.getData).toHaveBeenCalledWith(expectedKey);
  });
});

// ==================== getReceivedAssets ====================

describe('getReceivedAssets', () => {
  it('should return empty arrays when no received assets exist', async () => {
    mockContract.getData.mockResolvedValue('0x');

    const result = await getReceivedAssets(VALID_UP_ADDRESS, mockProvider);
    expect(result).toEqual({ lsp7: [], lsp8: [] });
  });

  it('should return empty arrays when length data is null', async () => {
    mockContract.getData.mockResolvedValue(null);

    const result = await getReceivedAssets(VALID_UP_ADDRESS, mockProvider);
    expect(result).toEqual({ lsp7: [], lsp8: [] });
  });

  it('should classify an LSP7 asset correctly', async () => {
    const assetAddress = '0x1111111111111111111111111111111111111111';
    const assetAddressRaw = '0x000000000000000000000000' + assetAddress.slice(2);

    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS['LSP5ReceivedAssets[]'].length) return '0x01';
      return assetAddressRaw;
    });

    const assetMock = {
      ...createMockContract(),
      supportsInterface: vi.fn().mockImplementation(async (interfaceId: string) => {
        if (interfaceId === INTERFACE_IDS.LSP7DigitalAsset) return true;
        return false;
      }),
    };

    let contractCallIndex = 0;
    contractFactory = (...args: any[]) => {
      contractCallIndex++;
      // First call: UP contract (in getReceivedAssets)
      if (contractCallIndex === 1) return mockContract;
      // Subsequent calls: asset contracts
      return assetMock;
    };

    const result = await getReceivedAssets(VALID_UP_ADDRESS, mockProvider);

    expect(result.lsp7).toContain(ethers.getAddress(assetAddress));
    expect(result.lsp8).toHaveLength(0);
  });

  it('should classify an LSP8 asset correctly', async () => {
    const assetAddress = '0x2222222222222222222222222222222222222222';
    const assetAddressRaw = '0x000000000000000000000000' + assetAddress.slice(2);

    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS['LSP5ReceivedAssets[]'].length) return '0x01';
      return assetAddressRaw;
    });

    const assetMock = {
      ...createMockContract(),
      supportsInterface: vi.fn().mockImplementation(async (interfaceId: string) => {
        if (interfaceId === INTERFACE_IDS.LSP7DigitalAsset) return false;
        if (interfaceId === INTERFACE_IDS.LSP8IdentifiableDigitalAsset) return true;
        return false;
      }),
    };

    let contractCallIndex = 0;
    contractFactory = (...args: any[]) => {
      contractCallIndex++;
      if (contractCallIndex === 1) return mockContract;
      return assetMock;
    };

    const result = await getReceivedAssets(VALID_UP_ADDRESS, mockProvider);

    expect(result.lsp8).toContain(ethers.getAddress(assetAddress));
    expect(result.lsp7).toHaveLength(0);
  });

  it('should handle asset that is neither LSP7 nor LSP8', async () => {
    const assetAddress = '0x3333333333333333333333333333333333333333';
    const assetAddressRaw = '0x000000000000000000000000' + assetAddress.slice(2);

    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS['LSP5ReceivedAssets[]'].length) return '0x01';
      return assetAddressRaw;
    });

    const assetMock = {
      ...createMockContract(),
      supportsInterface: vi.fn().mockResolvedValue(false),
    };

    let contractCallIndex = 0;
    contractFactory = (...args: any[]) => {
      contractCallIndex++;
      if (contractCallIndex === 1) return mockContract;
      return assetMock;
    };

    const result = await getReceivedAssets(VALID_UP_ADDRESS, mockProvider);

    expect(result.lsp7).toHaveLength(0);
    expect(result.lsp8).toHaveLength(0);
  });

  it('should handle supportsInterface failure gracefully for assets', async () => {
    const assetAddress = '0x4444444444444444444444444444444444444444';
    const assetAddressRaw = '0x000000000000000000000000' + assetAddress.slice(2);

    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS['LSP5ReceivedAssets[]'].length) return '0x01';
      return assetAddressRaw;
    });

    const assetMock = {
      ...createMockContract(),
      supportsInterface: vi.fn().mockRejectedValue(new Error('revert')),
    };

    let contractCallIndex = 0;
    contractFactory = (...args: any[]) => {
      contractCallIndex++;
      if (contractCallIndex === 1) return mockContract;
      return assetMock;
    };

    const result = await getReceivedAssets(VALID_UP_ADDRESS, mockProvider);

    // Both interface checks throw, so asset is not classified
    expect(result.lsp7).toHaveLength(0);
    expect(result.lsp8).toHaveLength(0);
  });

  it('should skip asset entries where data is 0x', async () => {
    mockContract.getData.mockImplementation(async (key: string) => {
      if (key === DATA_KEYS['LSP5ReceivedAssets[]'].length) return '0x01';
      // Index data is empty
      return '0x';
    });

    const result = await getReceivedAssets(VALID_UP_ADDRESS, mockProvider);

    expect(result.lsp7).toHaveLength(0);
    expect(result.lsp8).toHaveLength(0);
  });

  it('should return empty arrays when the entire read throws', async () => {
    mockContract.getData.mockRejectedValue(new Error('network error'));

    const result = await getReceivedAssets(VALID_UP_ADDRESS, mockProvider);
    expect(result).toEqual({ lsp7: [], lsp8: [] });
  });
});

// ==================== getData ====================

describe('getData', () => {
  it('should pass through the data key and return the result', async () => {
    const dataKey = DATA_KEYS.LSP3Profile;
    const dataValue = '0xdeadbeef';
    mockContract.getData.mockResolvedValue(dataValue);

    const result = await getData(VALID_UP_ADDRESS, dataKey, mockProvider);

    expect(result).toBe(dataValue);
    expect(mockContract.getData).toHaveBeenCalledWith(dataKey);
  });

  it('should return 0x when no data is set', async () => {
    mockContract.getData.mockResolvedValue('0x');

    const result = await getData(VALID_UP_ADDRESS, '0xaabbccdd', mockProvider);
    expect(result).toBe('0x');
  });

  it('should propagate errors from the contract', async () => {
    mockContract.getData.mockRejectedValue(new Error('contract error'));

    await expect(getData(VALID_UP_ADDRESS, '0xaabbccdd', mockProvider)).rejects.toThrow(
      'contract error'
    );
  });
});

// ==================== getDataBatch ====================

describe('getDataBatch', () => {
  it('should pass through multiple data keys and return results', async () => {
    const keys = [DATA_KEYS.LSP3Profile, DATA_KEYS.LSP4TokenName];
    const values = ['0xaa', '0xbb'];
    mockContract.getDataBatch.mockResolvedValue(values);

    const result = await getDataBatch(VALID_UP_ADDRESS, keys, mockProvider);

    expect(result).toEqual(values);
    expect(mockContract.getDataBatch).toHaveBeenCalledWith(keys);
  });

  it('should return empty results for empty keys', async () => {
    mockContract.getDataBatch.mockResolvedValue([]);

    const result = await getDataBatch(VALID_UP_ADDRESS, [], mockProvider);
    expect(result).toEqual([]);
  });

  it('should propagate errors from the contract', async () => {
    mockContract.getDataBatch.mockRejectedValue(new Error('batch error'));

    await expect(
      getDataBatch(VALID_UP_ADDRESS, ['0xaa'], mockProvider)
    ).rejects.toThrow('batch error');
  });
});

// ==================== getKeyManager ====================

describe('getKeyManager', () => {
  it('should return the owner address of the UP', async () => {
    mockContract.owner.mockResolvedValue(VALID_KM_ADDRESS);

    const result = await getKeyManager(VALID_UP_ADDRESS, mockProvider);
    expect(result).toBe(VALID_KM_ADDRESS);
  });

  it('should propagate errors from the contract', async () => {
    mockContract.owner.mockRejectedValue(new Error('owner error'));

    await expect(getKeyManager(VALID_UP_ADDRESS, mockProvider)).rejects.toThrow('owner error');
  });
});

// ==================== verifyKeyManager ====================

describe('verifyKeyManager', () => {
  it('should return true when owner matches and target matches', async () => {
    let contractCallIndex = 0;

    const upMock = {
      ...createMockContract(),
      owner: vi.fn().mockResolvedValue(VALID_KM_ADDRESS),
    };

    const kmTargetFn = vi.fn().mockResolvedValue(VALID_UP_ADDRESS);
    const kmMock = {
      ...createMockContract(),
      getFunction: vi.fn().mockReturnValue(kmTargetFn),
    };

    contractFactory = (...args: any[]) => {
      contractCallIndex++;
      if (contractCallIndex === 1) return upMock;
      return kmMock;
    };

    const result = await verifyKeyManager(VALID_UP_ADDRESS, VALID_KM_ADDRESS, mockProvider);
    expect(result).toBe(true);
  });

  it('should return false when owner does not match key manager address', async () => {
    const differentOwner = '0x9999999999999999999999999999999999999999';

    const upMock = {
      ...createMockContract(),
      owner: vi.fn().mockResolvedValue(differentOwner),
    };

    contractFactory = () => upMock;

    const result = await verifyKeyManager(VALID_UP_ADDRESS, VALID_KM_ADDRESS, mockProvider);
    expect(result).toBe(false);
  });

  it('should return false when target does not match UP address', async () => {
    const differentTarget = '0x8888888888888888888888888888888888888888';

    let contractCallIndex = 0;

    const upMock = {
      ...createMockContract(),
      owner: vi.fn().mockResolvedValue(VALID_KM_ADDRESS),
    };

    const kmTargetFn = vi.fn().mockResolvedValue(differentTarget);
    const kmMock = {
      ...createMockContract(),
      getFunction: vi.fn().mockReturnValue(kmTargetFn),
    };

    contractFactory = (...args: any[]) => {
      contractCallIndex++;
      if (contractCallIndex === 1) return upMock;
      return kmMock;
    };

    const result = await verifyKeyManager(VALID_UP_ADDRESS, VALID_KM_ADDRESS, mockProvider);
    expect(result).toBe(false);
  });

  it('should be case-insensitive for address comparison', async () => {
    const lowerUp = VALID_UP_ADDRESS.toLowerCase();
    const upperKm = VALID_KM_ADDRESS.toUpperCase();

    let contractCallIndex = 0;

    const upMock = {
      ...createMockContract(),
      owner: vi.fn().mockResolvedValue(upperKm),
    };

    const kmTargetFn = vi.fn().mockResolvedValue(lowerUp);
    const kmMock = {
      ...createMockContract(),
      getFunction: vi.fn().mockReturnValue(kmTargetFn),
    };

    contractFactory = (...args: any[]) => {
      contractCallIndex++;
      if (contractCallIndex === 1) return upMock;
      return kmMock;
    };

    // Even though casing differs, the comparison should pass
    const result = await verifyKeyManager(lowerUp, upperKm, mockProvider);
    expect(result).toBe(true);
  });

  it('should call getFunction with "target"', async () => {
    let contractCallIndex = 0;

    const upMock = {
      ...createMockContract(),
      owner: vi.fn().mockResolvedValue(VALID_KM_ADDRESS),
    };

    const kmTargetFn = vi.fn().mockResolvedValue(VALID_UP_ADDRESS);
    const kmMock = {
      ...createMockContract(),
      getFunction: vi.fn().mockReturnValue(kmTargetFn),
    };

    contractFactory = (...args: any[]) => {
      contractCallIndex++;
      if (contractCallIndex === 1) return upMock;
      return kmMock;
    };

    await verifyKeyManager(VALID_UP_ADDRESS, VALID_KM_ADDRESS, mockProvider);

    expect(kmMock.getFunction).toHaveBeenCalledWith('target');
  });
});


path: tests/marketplace.test.ts
content: import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { ListingInfo, ListingParams, OfferParams } from '../../src/types/index.js';

// ==================== TEST CONSTANTS ====================

const MARKETPLACE_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678';
const NFT_CONTRACT = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd';
const SELLER_ADDRESS = '0x1111111111111111111111111111111111111111';
const BUYER_ADDRESS = '0x2222222222222222222222222222222222222222';
const TOKEN_ID = '0x' + '0'.repeat(63) + '1';
const TOKEN_ID_2 = '0x' + '0'.repeat(63) + '2';

// ==================== MOCK HELPERS ====================

function makeMockListing(overrides: Partial<ListingInfo> = {}): ListingInfo {
  return {
    listingId: 1n,
    seller: SELLER_ADDRESS,
    nftContract: NFT_CONTRACT,
    tokenId: TOKEN_ID,
    price: 1000000000000000000n, // 1 LYX
    startTime: Math.floor(Date.now() / 1000) - 3600,
    endTime: Math.floor(Date.now() / 1000) + 86400,
    isActive: true,
    ...overrides,
  };
}

function createMockContractResult(listing: Partial<ListingInfo> = {}) {
  const l = makeMockListing(listing);
  return {
    seller: l.seller,
    nftContract: l.nftContract,
    tokenId: l.tokenId,
    price: l.price,
    startTime: BigInt(l.startTime),
    endTime: BigInt(l.endTime),
    isActive: l.isActive,
  };
}

function createMockOfferResult(overrides: Record<string, unknown> = {}) {
  return {
    buyer: BUYER_ADDRESS,
    nftContract: NFT_CONTRACT,
    tokenId: TOKEN_ID,
    price: 500000000000000000n,
    expiration: BigInt(Math.floor(Date.now() / 1000) + 86400),
    ...overrides,
  };
}

// Use vi.hoisted so these fns are available when vi.mock (which is hoisted) runs
const {
  mockGetListing,
  mockGetListingsByCollection,
  mockGetListingsBySeller,
  mockGetOffer,
  mockGetOffersForToken,
  mockMarketplaceFee,
} = vi.hoisted(() => ({
  mockGetListing: vi.fn(),
  mockGetListingsByCollection: vi.fn(),
  mockGetListingsBySeller: vi.fn(),
  mockGetOffer: vi.fn(),
  mockGetOffersForToken: vi.fn(),
  mockMarketplaceFee: vi.fn(),
}));

// Mock ethers Contract - the mock instance is shared via the hoisted fns
vi.mock('ethers', async () => {
  const actual = await vi.importActual<typeof import('ethers')>('ethers');

  const mockContract = vi.fn().mockImplementation(() => ({
    getListing: mockGetListing,
    getListingsByCollection: mockGetListingsByCollection,
    getListingsBySeller: mockGetListingsBySeller,
    getOffer: mockGetOffer,
    getOffersForToken: mockGetOffersForToken,
    marketplaceFee: mockMarketplaceFee,
  }));

  return {
    ...actual,
    Contract: mockContract,
    ethers: {
      ...actual.ethers,
      Contract: mockContract,
    },
  };
});

// Import after mock setup
import {
  getListing,
  getCollectionListings,
  getSellerListings,
  getCollectionFloorPrice,
  isNFTListed,
  getOffer,
  getOffersForNFT,
  getMarketplaceFee,
  calculateTotalCost,
  encodeCreateListing,
  encodeCancelListing,
  encodeUpdateListingPrice,
  encodeBuyListing,
  encodeMakeOffer,
  encodeAcceptOffer,
  encodeCancelOffer,
  getListingOperations,
  sortListingsByPrice,
  filterActiveListings,
} from '../../src/lib/marketplace.js';

// ==================== ENCODING FUNCTIONS ====================

describe('encodeCreateListing', () => {
  const params: ListingParams = {
    nftContract: NFT_CONTRACT,
    tokenId: TOKEN_ID,
    price: 1000000000000000000n,
    duration: 86400,
  };

  it('returns an ExecuteParams with operationType CALL (0)', () => {
    const result = encodeCreateListing(MARKETPLACE_ADDRESS, params);
    expect(result.operationType).toBe(0);
  });

  it('targets the marketplace address', () => {
    const result = encodeCreateListing(MARKETPLACE_ADDRESS, params);
    expect(result.target).toBe(MARKETPLACE_ADDRESS);
  });

  it('has value of 0n', () => {
    const result = encodeCreateListing(MARKETPLACE_ADDRESS, params);
    expect(result.value).toBe(0n);
  });

  it('encodes non-empty calldata', () => {
    const result = encodeCreateListing(MARKETPLACE_ADDRESS, params);
    expect(result.data).toBeTruthy();
    expect(result.data.startsWith('0x')).toBe(true);
    expect(result.data.length).toBeGreaterThan(10);
  });

  it('includes the createListing function selector in the data', () => {
    const result = encodeCreateListing(MARKETPLACE_ADDRESS, params);
    // createListing(address,bytes32,uint256,uint256) selector
    // Verify the data starts with a 4-byte (10 hex char including 0x) function selector
    expect(result.data).toMatch(/^0x[0-9a-f]{8}/i);
  });

  it('returns all expected ExecuteParams properties', () => {
    const result = encodeCreateListing(MARKETPLACE_ADDRESS, params);
    expect(result).toHaveProperty('operationType');
    expect(result).toHaveProperty('target');
    expect(result).toHaveProperty('value');
    expect(result).toHaveProperty('data');
  });

  it('produces different data for different params', () => {
    const params2: ListingParams = {
      nftContract: NFT_CONTRACT,
      tokenId: TOKEN_ID_2,
      price: 2000000000000000000n,
      duration: 43200,
    };
    const result1 = encodeCreateListing(MARKETPLACE_ADDRESS, params);
    const result2 = encodeCreateListing(MARKETPLACE_ADDRESS, params2);
    expect(result1.data).not.toBe(result2.data);
  });
});

describe('encodeCancelListing', () => {
  const listingId = 42n;

  it('returns an ExecuteParams with operationType CALL (0)', () => {
    const result = encodeCancelListing(MARKETPLACE_ADDRESS, listingId);
    expect(result.operationType).toBe(0);
  });

  it('targets the marketplace address', () => {
    const result = encodeCancelListing(MARKETPLACE_ADDRESS, listingId);
    expect(result.target).toBe(MARKETPLACE_ADDRESS);
  });

  it('has value of 0n', () => {
    const result = encodeCancelListing(MARKETPLACE_ADDRESS, listingId);
    expect(result.value).toBe(0n);
  });

  it('encodes non-empty calldata with cancelListing selector', () => {
    const result = encodeCancelListing(MARKETPLACE_ADDRESS, listingId);
    expect(result.data).toBeTruthy();
    expect(result.data.startsWith('0x')).toBe(true);
  });

  it('produces different data for different listing IDs', () => {
    const result1 = encodeCancelListing(MARKETPLACE_ADDRESS, 1n);
    const result2 = encodeCancelListing(MARKETPLACE_ADDRESS, 2n);
    expect(result1.data).not.toBe(result2.data);
  });
});

describe('encodeUpdateListingPrice', () => {
  const listingId = 10n;
  const newPrice = 2000000000000000000n;

  it('returns an ExecuteParams with operationType CALL (0)', () => {
    const result = encodeUpdateListingPrice(MARKETPLACE_ADDRESS, listingId, newPrice);
    expect(result.operationType).toBe(0);
  });

  it('targets the marketplace address', () => {
    const result = encodeUpdateListingPrice(MARKETPLACE_ADDRESS, listingId, newPrice);
    expect(result.target).toBe(MARKETPLACE_ADDRESS);
  });

  it('has value of 0n', () => {
    const result = encodeUpdateListingPrice(MARKETPLACE_ADDRESS, listingId, newPrice);
    expect(result.value).toBe(0n);
  });

  it('encodes non-empty calldata', () => {
    const result = encodeUpdateListingPrice(MARKETPLACE_ADDRESS, listingId, newPrice);
    expect(result.data).toBeTruthy();
    expect(result.data.startsWith('0x')).toBe(true);
    expect(result.data.length).toBeGreaterThan(10);
  });

  it('produces different data for different prices', () => {
    const result1 = encodeUpdateListingPrice(MARKETPLACE_ADDRESS, listingId, 1000n);
    const result2 = encodeUpdateListingPrice(MARKETPLACE_ADDRESS, listingId, 2000n);
    expect(result1.data).not.toBe(result2.data);
  });
});

describe('encodeBuyListing', () => {
  const listingId = 5n;
  const price = 3000000000000000000n;

  it('returns an ExecuteParams with operationType CALL (0)', () => {
    const result = encodeBuyListing(MARKETPLACE_ADDRESS, listingId, price);
    expect(result.operationType).toBe(0);
  });

  it('targets the marketplace address', () => {
    const result = encodeBuyListing(MARKETPLACE_ADDRESS, listingId, price);
    expect(result.target).toBe(MARKETPLACE_ADDRESS);
  });

  it('has value equal to the price', () => {
    const result = encodeBuyListing(MARKETPLACE_ADDRESS, listingId, price);
    expect(result.value).toBe(price);
  });

  it('value matches the exact price argument', () => {
    const customPrice = 999n;
    const result = encodeBuyListing(MARKETPLACE_ADDRESS, listingId, customPrice);
    expect(result.value).toBe(999n);
  });

  it('encodes non-empty calldata with buyListing selector', () => {
    const result = encodeBuyListing(MARKETPLACE_ADDRESS, listingId, price);
    expect(result.data).toBeTruthy();
    expect(result.data.startsWith('0x')).toBe(true);
  });

  it('value is not 0n when price is non-zero', () => {
    const result = encodeBuyListing(MARKETPLACE_ADDRESS, listingId, 1n);
    expect(result.value).not.toBe(0n);
  });
});

describe('encodeMakeOffer', () => {
  const params: OfferParams = {
    nftContract: NFT_CONTRACT,
    tokenId: TOKEN_ID,
    price: 500000000000000000n,
    duration: 43200,
  };

  it('returns an ExecuteParams with operationType CALL (0)', () => {
    const result = encodeMakeOffer(MARKETPLACE_ADDRESS, params);
    expect(result.operationType).toBe(0);
  });

  it('targets the marketplace address', () => {
    const result = encodeMakeOffer(MARKETPLACE_ADDRESS, params);
    expect(result.target).toBe(MARKETPLACE_ADDRESS);
  });

  it('has value equal to params.price (offer value is locked)', () => {
    const result = encodeMakeOffer(MARKETPLACE_ADDRESS, params);
    expect(result.value).toBe(params.price);
  });

  it('value changes with different offer prices', () => {
    const lowOffer = { ...params, price: 100n };
    const highOffer = { ...params, price: 999999n };
    const resultLow = encodeMakeOffer(MARKETPLACE_ADDRESS, lowOffer);
    const resultHigh = encodeMakeOffer(MARKETPLACE_ADDRESS, highOffer);
    expect(resultLow.value).toBe(100n);
    expect(resultHigh.value).toBe(999999n);
  });

  it('encodes non-empty calldata', () => {
    const result = encodeMakeOffer(MARKETPLACE_ADDRESS, params);
    expect(result.data).toBeTruthy();
    expect(result.data.startsWith('0x')).toBe(true);
    expect(result.data.length).toBeGreaterThan(10);
  });
});

describe('encodeAcceptOffer', () => {
  const offerId = 7n;

  it('returns an ExecuteParams with operationType CALL (0)', () => {
    const result = encodeAcceptOffer(MARKETPLACE_ADDRESS, offerId);
    expect(result.operationType).toBe(0);
  });

  it('targets the marketplace address', () => {
    const result = encodeAcceptOffer(MARKETPLACE_ADDRESS, offerId);
    expect(result.target).toBe(MARKETPLACE_ADDRESS);
  });

  it('has value of 0n', () => {
    const result = encodeAcceptOffer(MARKETPLACE_ADDRESS, offerId);
    expect(result.value).toBe(0n);
  });

  it('encodes non-empty calldata', () => {
    const result = encodeAcceptOffer(MARKETPLACE_ADDRESS, offerId);
    expect(result.data).toBeTruthy();
    expect(result.data.startsWith('0x')).toBe(true);
  });

  it('produces different data for different offer IDs', () => {
    const result1 = encodeAcceptOffer(MARKETPLACE_ADDRESS, 1n);
    const result2 = encodeAcceptOffer(MARKETPLACE_ADDRESS, 99n);
    expect(result1.data).not.toBe(result2.data);
  });
});

describe('encodeCancelOffer', () => {
  const offerId = 15n;

  it('returns an ExecuteParams with operationType CALL (0)', () => {
    const result = encodeCancelOffer(MARKETPLACE_ADDRESS, offerId);
    expect(result.operationType).toBe(0);
  });

  it('targets the marketplace address', () => {
    const result = encodeCancelOffer(MARKETPLACE_ADDRESS, offerId);
    expect(result.target).toBe(MARKETPLACE_ADDRESS);
  });

  it('has value of 0n', () => {
    const result = encodeCancelOffer(MARKETPLACE_ADDRESS, offerId);
    expect(result.value).toBe(0n);
  });

  it('encodes non-empty calldata', () => {
    const result = encodeCancelOffer(MARKETPLACE_ADDRESS, offerId);
    expect(result.data).toBeTruthy();
    expect(result.data.startsWith('0x')).toBe(true);
  });

  it('produces different data for different offer IDs', () => {
    const result1 = encodeCancelOffer(MARKETPLACE_ADDRESS, 1n);
    const result2 = encodeCancelOffer(MARKETPLACE_ADDRESS, 200n);
    expect(result1.data).not.toBe(result2.data);
  });
});

// ==================== PURE HELPER FUNCTIONS ====================

describe('sortListingsByPrice', () => {
  const listings: ListingInfo[] = [
    makeMockListing({ listingId: 1n, price: 3000n }),
    makeMockListing({ listingId: 2n, price: 1000n }),
    makeMockListing({ listingId: 3n, price: 5000n }),
    makeMockListing({ listingId: 4n, price: 2000n }),
  ];

  it('sorts listings in ascending order by default', () => {
    const sorted = sortListingsByPrice(listings);
    expect(sorted[0].price).toBe(1000n);
    expect(sorted[1].price).toBe(2000n);
    expect(sorted[2].price).toBe(3000n);
    expect(sorted[3].price).toBe(5000n);
  });

  it('sorts listings in ascending order when explicitly specified', () => {
    const sorted = sortListingsByPrice(listings, true);
    expect(sorted[0].price).toBe(1000n);
    expect(sorted[3].price).toBe(5000n);
  });

  it('sorts listings in descending order', () => {
    const sorted = sortListingsByPrice(listings, false);
    expect(sorted[0].price).toBe(5000n);
    expect(sorted[1].price).toBe(3000n);
    expect(sorted[2].price).toBe(2000n);
    expect(sorted[3].price).toBe(1000n);
  });

  it('does not mutate the original array', () => {
    const original = [...listings];
    sortListingsByPrice(listings);
    expect(listings).toEqual(original);
  });

  it('returns a new array', () => {
    const sorted = sortListingsByPrice(listings);
    expect(sorted).not.toBe(listings);
  });

  it('handles an empty array', () => {
    const sorted = sortListingsByPrice([]);
    expect(sorted).toEqual([]);
  });

  it('handles a single-element array', () => {
    const single = [makeMockListing({ price: 42n })];
    const sorted = sortListingsByPrice(single);
    expect(sorted).toHaveLength(1);
    expect(sorted[0].price).toBe(42n);
  });

  it('handles listings with identical prices', () => {
    const same = [
      makeMockListing({ listingId: 1n, price: 100n }),
      makeMockListing({ listingId: 2n, price: 100n }),
    ];
    const sorted = sortListingsByPrice(same);
    expect(sorted).toHaveLength(2);
    expect(sorted[0].price).toBe(100n);
    expect(sorted[1].price).toBe(100n);
  });
});

describe('filterActiveListings', () => {
  const NOW_SECONDS = 1000000;
  const NOW_MS = NOW_SECONDS * 1000;
  let dateNowSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(() => {
    dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(NOW_MS);
  });

  afterEach(() => {
    dateNowSpy.mockRestore();
  });

  it('returns listings that are active, started, and not expired', () => {
    const listings: ListingInfo[] = [
      makeMockListing({
        isActive: true,
        startTime: NOW_SECONDS - 100,
        endTime: NOW_SECONDS + 100,
      }),
    ];
    const result = filterActiveListings(listings);
    expect(result).toHaveLength(1);
  });

  it('filters out inactive listings', () => {
    const listings: ListingInfo[] = [
      makeMockListing({
        isActive: false,
        startTime: NOW_SECONDS - 100,
        endTime: NOW_SECONDS + 100,
      }),
    ];
    const result = filterActiveListings(listings);
    expect(result).toHaveLength(0);
  });

  it('filters out listings that have not started yet', () => {
    const listings: ListingInfo[] = [
      makeMockListing({
        isActive: true,
        startTime: NOW_SECONDS + 100, // future start
        endTime: NOW_SECONDS + 200,
      }),
    ];
    const result = filterActiveListings(listings);
    expect(result).toHaveLength(0);
  });

  it('filters out expired listings', () => {
    const listings: ListingInfo[] = [
      makeMockListing({
        isActive: true,
        startTime: NOW_SECONDS - 200,
        endTime: NOW_SECONDS - 100, // already ended
      }),
    ];
    const result = filterActiveListings(listings);
    expect(result).toHaveLength(0);
  });

  it('includes listing where startTime equals now', () => {
    const listings: ListingInfo[] = [
      makeMockListing({
        isActive: true,
        startTime: NOW_SECONDS, // exactly now
        endTime: NOW_SECONDS + 100,
      }),
    ];
    const result = filterActiveListings(listings);
    expect(result).toHaveLength(1);
  });

  it('excludes listing where endTime equals now (endTime must be > now)', () => {
    const listings: ListingInfo[] = [
      makeMockListing({
        isActive: true,
        startTime: NOW_SECONDS - 100,
        endTime: NOW_SECONDS, // exactly now
      }),
    ];
    const result = filterActiveListings(listings);
    expect(result).toHaveLength(0);
  });

  it('filters a mixed list correctly', () => {
    const listings: ListingInfo[] = [
      makeMockListing({
        listingId: 1n,
        isActive: true,
        startTime: NOW_SECONDS - 100,
        endTime: NOW_SECONDS + 100,
      }),
      makeMockListing({
        listingId: 2n,
        isActive: false,
        startTime: NOW_SECONDS - 100,
        endTime: NOW_SECONDS + 100,
      }),
      makeMockListing({
        listingId: 3n,
        isActive: true,
        startTime: NOW_SECONDS + 50,
        endTime: NOW_SECONDS + 200,
      }),
      makeMockListing({
        listingId: 4n,
        isActive: true,
        startTime: NOW_SECONDS - 500,
        endTime: NOW_SECONDS - 1,
      }),
      makeMockListing({
        listingId: 5n,
        isActive: true,
        startTime: NOW_SECONDS - 200,
        endTime: NOW_SECONDS + 999,
      }),
    ];
    const result = filterActiveListings(listings);
    expect(result).toHaveLength(2);
    expect(result.map((l) => l.listingId)).toEqual([1n, 5n]);
  });

  it('returns an empty array when given an empty array', () => {
    const result = filterActiveListings([]);
    expect(result).toEqual([]);
  });
});

// ==================== WORKFLOW HELPERS ====================

describe('getListingOperations', () => {
  const params: ListingParams = {
    nftContract: NFT_CONTRACT,
    tokenId: TOKEN_ID,
    price: 1000000000000000000n,
    duration: 86400,
  };

  it('returns an array of exactly 2 ExecuteParams', () => {
    const ops = getListingOperations(MARKETPLACE_ADDRESS, params);
    expect(ops).toHaveLength(2);
  });

  it('first operation is the authorize operator call', () => {
    const ops = getListingOperations(MARKETPLACE_ADDRESS, params);
    const authorizeOp = ops[0];
    expect(authorizeOp.operationType).toBe(0);
    expect(authorizeOp.target).toBe(NFT_CONTRACT);
    expect(authorizeOp.value).toBe(0n);
    expect(authorizeOp.data).toBeTruthy();
    expect(authorizeOp.data.startsWith('0x')).toBe(true);
  });

  it('second operation is the create listing call', () => {
    const ops = getListingOperations(MARKETPLACE_ADDRESS, params);
    const listingOp = ops[1];
    expect(listingOp.operationType).toBe(0);
    expect(listingOp.target).toBe(MARKETPLACE_ADDRESS);
    expect(listingOp.value).toBe(0n);
    expect(listingOp.data).toBeTruthy();
    expect(listingOp.data.startsWith('0x')).toBe(true);
  });

  it('authorize targets the NFT contract, not the marketplace', () => {
    const ops = getListingOperations(MARKETPLACE_ADDRESS, params);
    expect(ops[0].target).toBe(params.nftContract);
    expect(ops[0].target).not.toBe(MARKETPLACE_ADDRESS);
  });

  it('listing operation targets the marketplace, not the NFT contract', () => {
    const ops = getListingOperations(MARKETPLACE_ADDRESS, params);
    expect(ops[1].target).toBe(MARKETPLACE_ADDRESS);
    expect(ops[1].target).not.toBe(params.nftContract);
  });

  it('both operations have all ExecuteParams properties', () => {
    const ops = getListingOperations(MARKETPLACE_ADDRESS, params);
    for (const op of ops) {
      expect(op).toHaveProperty('operationType');
      expect(op).toHaveProperty('target');
      expect(op).toHaveProperty('value');
      expect(op).toHaveProperty('data');
    }
  });
});

// ==================== QUERY FUNCTIONS (mocked Contract) ====================

describe('getListing', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns a ListingInfo with the correct shape', async () => {
    mockGetListing.mockResolvedValue(createMockContractResult());

    const result = await getListing(MARKETPLACE_ADDRESS, 1n, {} as any);
    expect(result).toHaveProperty('listingId');
    expect(result).toHaveProperty('seller');
    expect(result).toHaveProperty('nftContract');
    expect(result).toHaveProperty('tokenId');
    expect(result).toHaveProperty('price');
    expect(result).toHaveProperty('startTime');
    expect(result).toHaveProperty('endTime');
    expect(result).toHaveProperty('isActive');
  });

  it('passes the listing ID to the contract method', async () => {
    mockGetListing.mockResolvedValue(createMockContractResult());

    await getListing(MARKETPLACE_ADDRESS, 42n, {} as any);
    expect(mockGetListing).toHaveBeenCalledWith(42n);
  });

  it('preserves the listingId in the returned object', async () => {
    mockGetListing.mockResolvedValue(createMockContractResult());

    const result = await getListing(MARKETPLACE_ADDRESS, 99n, {} as any);
    expect(result.listingId).toBe(99n);
  });

  it('converts startTime and endTime to numbers', async () => {
    mockGetListing.mockResolvedValue(
      createMockContractResult({ startTime: 1000, endTime: 2000 })
    );

    const result = await getListing(MARKETPLACE_ADDRESS, 1n, {} as any);
    expect(typeof result.startTime).toBe('number');
    expect(typeof result.endTime).toBe('number');
  });

  it('maps seller, nftContract, tokenId, price, isActive from the contract result', async () => {
    const contractResult = createMockContractResult({
      seller: SELLER_ADDRESS,
      nftContract: NFT_CONTRACT,
      tokenId: TOKEN_ID,
      price: 5000n,
      isActive: true,
    });
    mockGetListing.mockResolvedValue(contractResult);

    const result = await getListing(MARKETPLACE_ADDRESS, 1n, {} as any);
    expect(result.seller).toBe(SELLER_ADDRESS);
    expect(result.nftContract).toBe(NFT_CONTRACT);
    expect(result.tokenId).toBe(TOKEN_ID);
    expect(result.price).toBe(5000n);
    expect(result.isActive).toBe(true);
  });
});

describe('getCollectionListings', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns an array of ListingInfo', async () => {
    mockGetListingsByCollection.mockResolvedValue([1n, 2n]);
    mockGetListing
      .mockResolvedValueOnce(createMockContractResult({ isActive: true }))
      .mockResolvedValueOnce(createMockContractResult({ isActive: true }));

    const result = await getCollectionListings(MARKETPLACE_ADDRESS, NFT_CONTRACT, {} as any);
    expect(Array.isArray(result)).toBe(true);
    expect(result).toHaveLength(2);
  });

  it('only returns active listings', async () => {
    mockGetListingsByCollection.mockResolvedValue([1n, 2n, 3n]);
    mockGetListing
      .mockResolvedValueOnce(createMockContractResult({ isActive: true }))
      .mockResolvedValueOnce(createMockContractResult({ isActive: false }))
      .mockResolvedValueOnce(createMockContractResult({ isActive: true }));

    const result = await getCollectionListings(MARKETPLACE_ADDRESS, NFT_CONTRACT, {} as any);
    expect(result).toHaveLength(2);
  });

  it('returns empty array when no listings exist', async () => {
    mockGetListingsByCollection.mockResolvedValue([]);

    const result = await getCollectionListings(MARKETPLACE_ADDRESS, NFT_CONTRACT, {} as any);
    expect(result).toEqual([]);
  });

  it('skips invalid listings without throwing', async () => {
    mockGetListingsByCollection.mockResolvedValue([1n, 2n]);
    mockGetListing
      .mockRejectedValueOnce(new Error('invalid listing'))
      .mockResolvedValueOnce(createMockContractResult({ isActive: true }));

    const result = await getCollectionListings(MARKETPLACE_ADDRESS, NFT_CONTRACT, {} as any);
    expect(result).toHaveLength(1);
  });

  it('calls getListingsByCollection with the correct nft contract address', async () => {
    mockGetListingsByCollection.mockResolvedValue([]);

    await getCollectionListings(MARKETPLACE_ADDRESS, NFT_CONTRACT, {} as any);
    expect(mockGetListingsByCollection).toHaveBeenCalledWith(NFT_CONTRACT);
  });
});

describe('getSellerListings', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns an array of ListingInfo (including inactive)', async () => {
    mockGetListingsBySeller.mockResolvedValue([1n, 2n]);
    mockGetListing
      .mockResolvedValueOnce(createMockContractResult({ isActive: true }))
      .mockResolvedValueOnce(createMockContractResult({ isActive: false }));

    const result = await getSellerListings(MARKETPLACE_ADDRESS, SELLER_ADDRESS, {} as any);
    // getSellerListings does not filter by isActive
    expect(result).toHaveLength(2);
  });

  it('returns empty array when seller has no listings', async () => {
    mockGetListingsBySeller.mockResolvedValue([]);

    const result = await getSellerListings(MARKETPLACE_ADDRESS, SELLER_ADDRESS, {} as any);
    expect(result).toEqual([]);
  });

  it('calls getListingsBySeller with the correct seller address', async () => {
    mockGetListingsBySeller.mockResolvedValue([]);

    await getSellerListings(MARKETPLACE_ADDRESS, SELLER_ADDRESS, {} as any);
    expect(mockGetListingsBySeller).toHaveBeenCalledWith(SELLER_ADDRESS);
  });

  it('skips invalid listings without throwing', async () => {
    mockGetListingsBySeller.mockResolvedValue([1n, 2n]);
    mockGetListing
      .mockRejectedValueOnce(new Error('bad listing'))
      .mockResolvedValueOnce(createMockContractResult());

    const result = await getSellerListings(MARKETPLACE_ADDRESS, SELLER_ADDRESS, {} as any);
    expect(result).toHaveLength(1);
  });
});

describe('getCollectionFloorPrice', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns the lowest price among active listings', async () => {
    mockGetListingsByCollection.mockResolvedValue([1n, 2n, 3n]);
    mockGetListing
      .mockResolvedValueOnce(createMockContractResult({ isActive: true, price: 3000n }))
      .mockResolvedValueOnce(createMockContractResult({ isActive: true, price: 1000n }))
      .mockResolvedValueOnce(createMockContractResult({ isActive: true, price: 5000n }));

    const result = await getCollectionFloorPrice(MARKETPLACE_ADDRESS, NFT_CONTRACT, {} as any);
    expect(result).toBe(1000n);
  });

  it('returns null when there are no active listings', async () => {
    mockGetListingsByCollection.mockResolvedValue([]);

    const result = await getCollectionFloorPrice(MARKETPLACE_ADDRESS, NFT_CONTRACT, {} as any);
    expect(result).toBeNull();
  });

  it('returns null when all listings are inactive', async () => {
    mockGetListingsByCollection.mockResolvedValue([1n, 2n]);
    mockGetListing
      .mockResolvedValueOnce(createMockContractResult({ isActive: false }))
      .mockResolvedValueOnce(createMockContractResult({ isActive: false }));

    const result = await getCollectionFloorPrice(MARKETPLACE_ADDRESS, NFT_CONTRACT, {} as any);
    expect(result).toBeNull();
  });

  it('returns the single price when only one active listing exists', async () => {
    mockGetListingsByCollection.mockResolvedValue([1n]);
    mockGetListing.mockResolvedValueOnce(
      createMockContractResult({ isActive: true, price: 7777n })
    );

    const result = await getCollectionFloorPrice(MARKETPLACE_ADDRESS, NFT_CONTRACT, {} as any);
    expect(result).toBe(7777n);
  });
});

describe('isNFTListed', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns { listed: true, listingId, price } when NFT is listed', async () => {
    mockGetListingsByCollection.mockResolvedValue([1n]);
    mockGetListing.mockResolvedValueOnce(
      createMockContractResult({
        isActive: true,
        nftContract: NFT_CONTRACT,
        tokenId: TOKEN_ID,
        price: 9999n,
      })
    );

    const result = await isNFTListed(MARKETPLACE_ADDRESS, NFT_CONTRACT, TOKEN_ID, {} as any);
    expect(result.listed).toBe(true);
    expect(result.listingId).toBeDefined();
    expect(result.price).toBe(9999n);
  });

  it('returns { listed: false } when NFT is not listed', async () => {
    mockGetListingsByCollection.mockResolvedValue([1n]);
    mockGetListing.mockResolvedValueOnce(
      createMockContractResult({
        isActive: true,
        nftContract: NFT_CONTRACT,
        tokenId: TOKEN_ID_2, // different token
      })
    );

    const result = await isNFTListed(MARKETPLACE_ADDRESS, NFT_CONTRACT, TOKEN_ID, {} as any);
    expect(result.listed).toBe(false);
    expect(result.listingId).toBeUndefined();
    expect(result.price).toBeUndefined();
  });

  it('returns { listed: false } when no listings exist', async () => {
    mockGetListingsByCollection.mockResolvedValue([]);

    const result = await isNFTListed(MARKETPLACE_ADDRESS, NFT_CONTRACT, TOKEN_ID, {} as any);
    expect(result.listed).toBe(false);
  });

  it('performs case-insensitive comparison on nftContract address', async () => {
    const lowerCaseContract = NFT_CONTRACT.toLowerCase();
    mockGetListingsByCollection.mockResolvedValue([1n]);
    mockGetListing.mockResolvedValueOnce(
      createMockContractResult({
        isActive: true,
        nftContract: lowerCaseContract,
        tokenId: TOKEN_ID,
        price: 42n,
      })
    );

    const result = await isNFTListed(
      MARKETPLACE_ADDRESS,
      NFT_CONTRACT.toUpperCase().replace('0X', '0x'),
      TOKEN_ID,
      {} as any
    );
    expect(result.listed).toBe(true);
  });
});

describe('getOffer', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns an OfferInfo with the correct shape', async () => {
    mockGetOffer.mockResolvedValue(createMockOfferResult());

    const result = await getOffer(MARKETPLACE_ADDRESS, 1n, {} as any);
    expect(result).toHaveProperty('offerId');
    expect(result).toHaveProperty('buyer');
    expect(result).toHaveProperty('nftContract');
    expect(result).toHaveProperty('tokenId');
    expect(result).toHaveProperty('price');
    expect(result).toHaveProperty('expiration');
  });

  it('passes the offer ID to the contract method', async () => {
    mockGetOffer.mockResolvedValue(createMockOfferResult());

    await getOffer(MARKETPLACE_ADDRESS, 55n, {} as any);
    expect(mockGetOffer).toHaveBeenCalledWith(55n);
  });

  it('preserves the offerId in the returned object', async () => {
    mockGetOffer.mockResolvedValue(createMockOfferResult());

    const result = await getOffer(MARKETPLACE_ADDRESS, 77n, {} as any);
    expect(result.offerId).toBe(77n);
  });

  it('converts expiration to a number', async () => {
    mockGetOffer.mockResolvedValue(createMockOfferResult({ expiration: 99999n }));

    const result = await getOffer(MARKETPLACE_ADDRESS, 1n, {} as any);
    expect(typeof result.expiration).toBe('number');
    expect(result.expiration).toBe(99999);
  });
});

describe('getOffersForNFT', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns an array of non-expired OfferInfo', async () => {
    const futureExpiration = BigInt(Math.floor(Date.now() / 1000) + 86400);
    mockGetOffersForToken.mockResolvedValue([1n, 2n]);
    mockGetOffer
      .mockResolvedValueOnce(createMockOfferResult({ expiration: futureExpiration }))
      .mockResolvedValueOnce(createMockOfferResult({ expiration: futureExpiration }));

    const result = await getOffersForNFT(
      MARKETPLACE_ADDRESS,
      NFT_CONTRACT,
      TOKEN_ID,
      {} as any
    );
    expect(Array.isArray(result)).toBe(true);
    expect(result).toHaveLength(2);
  });

  it('filters out expired offers', async () => {
    const futureExpiration = BigInt(Math.floor(Date.now() / 1000) + 86400);
    const pastExpiration = BigInt(Math.floor(Date.now() / 1000) - 100);
    mockGetOffersForToken.mockResolvedValue([1n, 2n]);
    mockGetOffer
      .mockResolvedValueOnce(createMockOfferResult({ expiration: futureExpiration }))
      .mockResolvedValueOnce(createMockOfferResult({ expiration: pastExpiration }));

    const result = await getOffersForNFT(
      MARKETPLACE_ADDRESS,
      NFT_CONTRACT,
      TOKEN_ID,
      {} as any
    );
    expect(result).toHaveLength(1);
  });

  it('returns empty array when getOffersForToken throws', async () => {
    mockGetOffersForToken.mockRejectedValue(new Error('not supported'));

    const result = await getOffersForNFT(
      MARKETPLACE_ADDRESS,
      NFT_CONTRACT,
      TOKEN_ID,
      {} as any
    );
    expect(result).toEqual([]);
  });

  it('skips individual invalid offers without throwing', async () => {
    const futureExpiration = BigInt(Math.floor(Date.now() / 1000) + 86400);
    mockGetOffersForToken.mockResolvedValue([1n, 2n]);
    mockGetOffer
      .mockRejectedValueOnce(new Error('invalid'))
      .mockResolvedValueOnce(createMockOfferResult({ expiration: futureExpiration }));

    const result = await getOffersForNFT(
      MARKETPLACE_ADDRESS,
      NFT_CONTRACT,
      TOKEN_ID,
      {} as any
    );
    expect(result).toHaveLength(1);
  });

  it('calls getOffersForToken with the correct nftContract and tokenId', async () => {
    mockGetOffersForToken.mockResolvedValue([]);

    await getOffersForNFT(MARKETPLACE_ADDRESS, NFT_CONTRACT, TOKEN_ID, {} as any);
    expect(mockGetOffersForToken).toHaveBeenCalledWith(NFT_CONTRACT, TOKEN_ID);
  });
});

describe('getMarketplaceFee', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns the fee as a bigint', async () => {
    mockMarketplaceFee.mockResolvedValue(250n);

    const result = await getMarketplaceFee(MARKETPLACE_ADDRESS, {} as any);
    expect(result).toBe(250n);
  });

  it('calls the marketplaceFee method on the contract', async () => {
    mockMarketplaceFee.mockResolvedValue(500n);

    await getMarketplaceFee(MARKETPLACE_ADDRESS, {} as any);
    expect(mockMarketplaceFee).toHaveBeenCalled();
  });
});

describe('calculateTotalCost', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns an object with price, fee, and total', async () => {
    mockMarketplaceFee.mockResolvedValue(250n); // 2.5%

    const price = 10000n;
    const result = await calculateTotalCost(MARKETPLACE_ADDRESS, price, {} as any);
    expect(result).toHaveProperty('price');
    expect(result).toHaveProperty('fee');
    expect(result).toHaveProperty('total');
  });

  it('calculates fee correctly at 2.5% (250 bps)', async () => {
    mockMarketplaceFee.mockResolvedValue(250n);

    const price = 10000n;
    const result = await calculateTotalCost(MARKETPLACE_ADDRESS, price, {} as any);
    // fee = 10000 * 250 / 10000 = 250
    expect(result.fee).toBe(250n);
  });

  it('calculates total as price + fee', async () => {
    mockMarketplaceFee.mockResolvedValue(250n);

    const price = 10000n;
    const result = await calculateTotalCost(MARKETPLACE_ADDRESS, price, {} as any);
    expect(result.total).toBe(result.price + result.fee);
  });

  it('preserves the original price in the result', async () => {
    mockMarketplaceFee.mockResolvedValue(100n);

    const price = 5000n;
    const result = await calculateTotalCost(MARKETPLACE_ADDRESS, price, {} as any);
    expect(result.price).toBe(5000n);
  });

  it('handles zero fee', async () => {
    mockMarketplaceFee.mockResolvedValue(0n);

    const price = 10000n;
    const result = await calculateTotalCost(MARKETPLACE_ADDRESS, price, {} as any);
    expect(result.fee).toBe(0n);
    expect(result.total).toBe(price);
  });

  it('handles zero price', async () => {
    mockMarketplaceFee.mockResolvedValue(250n);

    const result = await calculateTotalCost(MARKETPLACE_ADDRESS, 0n, {} as any);
    expect(result.price).toBe(0n);
    expect(result.fee).toBe(0n);
    expect(result.total).toBe(0n);
  });

  it('calculates correctly with large values (1 LYX at 5% fee)', async () => {
    mockMarketplaceFee.mockResolvedValue(500n); // 5%

    const price = 1000000000000000000n; // 1 LYX in wei
    const result = await calculateTotalCost(MARKETPLACE_ADDRESS, price, {} as any);
    // fee = 1e18 * 500 / 10000 = 5e16
    expect(result.fee).toBe(50000000000000000n);
    expect(result.total).toBe(1050000000000000000n);
  });
});


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)