From da4e54510aa50fd070353d3dd3d5113c8b7bad07 Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 16 Mar 2026 16:20:21 +0000 Subject: [PATCH 01/15] docs(key-manager): add guide on restricting controller allowed calls --- .../restrict-controller-actions.md | 602 ++++++++++++++++++ 1 file changed, 602 insertions(+) create mode 100644 docs/learn/universal-profile/key-manager/restrict-controller-actions.md diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md new file mode 100644 index 0000000000..be238f746b --- /dev/null +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -0,0 +1,602 @@ +--- +sidebar_label: 'Restrict What a Controller Can Do' +sidebar_position: 3 +description: Learn how to restrict a controller to only specific contracts, functions, or token standards using LSP6 Key Manager Allowed Calls on LUKSO. +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Restrict What a Controller Can Do + +Granting a controller the `CALL` permission lets it execute calls on behalf of your Universal Profile — but by itself that permission is too broad. An automated bot with `CALL` permission can theoretically interact with any contract your UP can reach. **Allowed Calls** add a second layer of access control by restricting _which_ contracts, _which_ interface standards, and _which_ function selectors a controller is allowed to call. + +Think of permissions as the doors a controller can open, and Allowed Calls as the specific keys they are allowed to use once inside. + +:::info + +Full code examples are available in the 👾 [lukso-playground](https://github.com/lukso-network/lukso-playground) repository. + +::: + +## What you will learn + +- How to restrict an automated staking bot to only call `deposit()` on a specific vault contract +- How to lock a controller so it can only send LYX to one specific address +- How to allow a DeFi bot to interact with any LSP7 token contract, but nothing else +- How to give a team member the ability to update NFT metadata without being able to transfer tokens or drain LYX + +## Prerequisites + +- A deployed Universal Profile (see [Deploy a Universal Profile](/learn/getting-started)) +- A controller address already granted `CALL` permission (see [Grant Permissions](./grant-permissions.md)) + +## How Allowed Calls work + +Each Allowed Calls entry is a **32-byte packed value** stored under the `AddressPermissions:AllowedCalls:` data key as a `CompactBytesArray`. The four fields are concatenated with no padding: + +| Field | Size | Description | +| --- | --- | --- | +| Call type | 4 bytes | Type of call: `CALL` (`0x00000002`), `STATICCALL` (`0x00000001`), `DELEGATECALL` (`0x00000003`) | +| Address | 20 bytes | Target contract address, or `0xffffffffffffffffffffffffffffffffffffffff` for any | +| Standard | 4 bytes | Interface ID the target contract must support, or `0xffffffff` for any | +| Function | 4 bytes | Function selector the controller may call, or `0xffffffff` for any | + +So a single 32-byte entry looks like: + +``` +0x 00000002 9F49a95b0c3c9e2A6c77a16C177928294c0F6F04 ffffffff d0e30db0 + ^------- ^--------------------------------------- ^------- ^------- + calltype address (20 bytes) standard selector +``` + +When multiple entries are present they are encoded as a `CompactBytesArray` — erc725.js handles this automatically. + +--- + +## Example 1: Staking-only controller + +**Context:** You want an automated bot to stake LYX on [Stakingverse](https://stakingverse.io) without being able to drain your UP or interact with any other contract. + +The bot is granted `CALL` permission (see [Grant Permissions](./grant-permissions.md)), and its Allowed Calls list is restricted to a single entry: `deposit()` on the Stakingverse vault. + + + + +```ts +import { ERC725 } from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; +import { createWalletClient, http } from 'viem'; +import { lukso } from 'viem/chains'; +import UniversalProfileArtifact from '@lukso/lsp-smart-contracts/artifacts/UniversalProfile.json'; + +const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; +const STAKING_BOT_ADDRESS = '0xYourBotAddress'; // replace with your bot address + +const erc725 = new ERC725( + LSP6Schema, + myUPAddress, + 'https://rpc.lukso.network', +); + +// CALL type (0x00000002) + vault address (20 bytes) + any standard (0xffffffff) + deposit() selector +const depositEntry = + `0x00000002` + + STAKING_VAULT.slice(2) + + `ffffffff` + + `d0e30db0`; // deposit() + +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [STAKING_BOT_ADDRESS], + value: [depositEntry], + }, +]); + +// Connect the UP Browser Extension and send the transaction +const [account] = await window.lukso.request({ method: 'eth_requestAccounts' }); + +const walletClient = createWalletClient({ account, chain: lukso, transport: http() }); + +await walletClient.writeContract({ + address: myUPAddress, + abi: UniversalProfileArtifact.abi, + functionName: 'setData', + args: [encodedAllowedCalls.keys[0], encodedAllowedCalls.values[0]], +}); +``` + + + + +```ts +import { ERC725 } from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; +import { ethers } from 'ethers'; +import UniversalProfileArtifact from '@lukso/lsp-smart-contracts/artifacts/UniversalProfile.json'; + +const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; +const STAKING_BOT_ADDRESS = '0xYourBotAddress'; // replace with your bot address + +const erc725 = new ERC725( + LSP6Schema, + myUPAddress, + 'https://rpc.lukso.network', +); + +// CALL type (0x00000002) + vault address (20 bytes) + any standard (0xffffffff) + deposit() selector +const depositEntry = + `0x00000002` + + STAKING_VAULT.slice(2) + + `ffffffff` + + `d0e30db0`; // deposit() + +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [STAKING_BOT_ADDRESS], + value: [depositEntry], + }, +]); + +// Connect the UP Browser Extension and send the transaction +const provider = new ethers.BrowserProvider(window.lukso); +const signer = await provider.getSigner(); + +const universalProfile = new ethers.Contract( + myUPAddress, + UniversalProfileArtifact.abi, + signer, +); + +await universalProfile.setData( + encodedAllowedCalls.keys[0], + encodedAllowedCalls.values[0], +); +``` + + + + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import {IERC725Y} from "@erc725/smart-contracts/contracts/interfaces/IERC725Y.sol"; + +contract SetStakingBotAllowedCalls { + // Stakingverse vault on LUKSO mainnet + address constant STAKING_VAULT = 0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04; + + function restrictStakingBot( + address universalProfile, + address stakingBot + ) external { + // Data key: AddressPermissions:AllowedCalls: + bytes32 dataKey = bytes32( + abi.encodePacked( + bytes6(0xef26ac33982a), // AddressPermissions:AllowedCalls: prefix + bytes2(0x0000), + stakingBot + ) + ); + + // 32-byte packed entry: CALL + vault + any standard + deposit() selector + bytes memory allowedCallEntry = abi.encodePacked( + bytes4(0x00000002), // CALL type + STAKING_VAULT, // target address (20 bytes) + bytes4(0xffffffff), // any interface standard + bytes4(0xd0e30db0) // deposit() selector + ); + + // CompactBytesArray encoding: 2-byte length prefix per element + bytes memory compactEncoded = abi.encodePacked( + uint16(allowedCallEntry.length), // 0x0020 (32 bytes) + allowedCallEntry + ); + + IERC725Y(universalProfile).setData(dataKey, compactEncoded); + } +} +``` + + + + +### Two-tier split: separate staking and withdrawal controllers + +For better security, split staking and withdrawal into two separate controllers. That way, a compromised staking bot cannot withdraw your funds. + + + + +```ts +const STAKING_BOT = '0xYourStakingBotAddress'; +const WITHDRAWAL_BOT = '0xYourWithdrawalBotAddress'; + +// Staking controller: deposit() only +const depositEntry = + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `d0e30db0`; + +// Withdrawal controller: requestWithdrawal(uint256) and claimWithdrawal() +const requestWithdrawalEntry = + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `9ee679e8`; + +const claimWithdrawalEntry = + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `6e66d84a`; + +const encodedData = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [STAKING_BOT], + value: [depositEntry], + }, + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [WITHDRAWAL_BOT], + value: [requestWithdrawalEntry, claimWithdrawalEntry], + }, +]); + +// setDataBatch to apply both in one transaction +await walletClient.writeContract({ + address: myUPAddress, + abi: UniversalProfileArtifact.abi, + functionName: 'setDataBatch', + args: [encodedData.keys, encodedData.values], +}); +``` + + + + +```ts +const STAKING_BOT = '0xYourStakingBotAddress'; +const WITHDRAWAL_BOT = '0xYourWithdrawalBotAddress'; + +// Staking controller: deposit() only +const depositEntry = + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `d0e30db0`; + +// Withdrawal controller: requestWithdrawal(uint256) and claimWithdrawal() +const requestWithdrawalEntry = + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `9ee679e8`; + +const claimWithdrawalEntry = + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `6e66d84a`; + +const encodedData = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [STAKING_BOT], + value: [depositEntry], + }, + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [WITHDRAWAL_BOT], + value: [requestWithdrawalEntry, claimWithdrawalEntry], + }, +]); + +// setDataBatch to apply both in one transaction +await universalProfile.setDataBatch(encodedData.keys, encodedData.values); +``` + + + + +```solidity +// For the withdrawal controller, allow both requestWithdrawal and claimWithdrawal +bytes memory requestEntry = abi.encodePacked( + bytes4(0x00000002), // CALL type + STAKING_VAULT, // target address + bytes4(0xffffffff), // any standard + bytes4(0x9ee679e8) // requestWithdrawal(uint256) +); + +bytes memory claimEntry = abi.encodePacked( + bytes4(0x00000002), // CALL type + STAKING_VAULT, // target address + bytes4(0xffffffff), // any standard + bytes4(0x6e66d84a) // claimWithdrawal() +); + +// CompactBytesArray with two 32-byte entries +bytes memory compactEncoded = abi.encodePacked( + uint16(32), requestEntry, + uint16(32), claimEntry +); +``` + + + + +--- + +## Example 2: Send LYX to one address only + +**Context:** A payroll or savings controller that can only send LYX to your cold wallet — nothing else. Even if the controller key is compromised, the attacker can only forward funds to your own cold address. + + + + +```ts +const CONTROLLER_ADDRESS = '0xYourPayrollController'; +const COLD_WALLET = '0xYourColdWalletAddress'; // the only permitted recipient + +// CALL type + cold wallet (20 bytes) + any standard + any selector +const coldWalletEntry = + `0x00000002` + COLD_WALLET.slice(2) + `ffffffff` + `ffffffff`; + +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [CONTROLLER_ADDRESS], + value: [coldWalletEntry], + }, +]); + +await walletClient.writeContract({ + address: myUPAddress, + abi: UniversalProfileArtifact.abi, + functionName: 'setData', + args: [encodedAllowedCalls.keys[0], encodedAllowedCalls.values[0]], +}); +``` + + + + +```ts +const CONTROLLER_ADDRESS = '0xYourPayrollController'; +const COLD_WALLET = '0xYourColdWalletAddress'; // the only permitted recipient + +// CALL type + cold wallet (20 bytes) + any standard + any selector +const coldWalletEntry = + `0x00000002` + COLD_WALLET.slice(2) + `ffffffff` + `ffffffff`; + +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [CONTROLLER_ADDRESS], + value: [coldWalletEntry], + }, +]); + +await universalProfile.setData( + encodedAllowedCalls.keys[0], + encodedAllowedCalls.values[0], +); +``` + + + + +```solidity +address constant COLD_WALLET = 0xYourColdWalletAddress; + +// CALL type + cold wallet address + any standard + any selector +bytes memory allowedCallEntry = abi.encodePacked( + bytes4(0x00000002), // CALL type + COLD_WALLET, // target address (20 bytes) + bytes4(0xffffffff), // any interface standard + bytes4(0xffffffff) // any function selector +); + +bytes memory compactEncoded = abi.encodePacked( + uint16(allowedCallEntry.length), + allowedCallEntry +); +``` + + + + +--- + +## Example 3: Interact with any LSP7 token + +**Context:** A DeFi bot that can call `transfer` on any LSP7 fungible token contract — but cannot touch your UP metadata, NFTs, or LYX balance. The Standard field is set to the LSP7 interface ID (`0xc52d6008`), so the Key Manager will verify the target contract supports LSP7 before allowing the call. + + + + +```ts +const DEFI_BOT = '0xYourDeFiBotAddress'; + +// CALL type + any address + LSP7 interface ID + transfer() selector +// transfer(address,address,uint256,bool,bytes) = 0x760d9bba +const lsp7TransferEntry = + `0x00000002` + + `ffffffffffffffffffffffffffffffffffffffff` + // any address + `c52d6008` + // INTERFACE_ID_LSP7 + `760d9bba`; // transfer(address,address,uint256,bool,bytes) + +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [DEFI_BOT], + value: [lsp7TransferEntry], + }, +]); + +await walletClient.writeContract({ + address: myUPAddress, + abi: UniversalProfileArtifact.abi, + functionName: 'setData', + args: [encodedAllowedCalls.keys[0], encodedAllowedCalls.values[0]], +}); +``` + + + + +```ts +const DEFI_BOT = '0xYourDeFiBotAddress'; + +// CALL type + any address + LSP7 interface ID + transfer() selector +// transfer(address,address,uint256,bool,bytes) = 0x760d9bba +const lsp7TransferEntry = + `0x00000002` + + `ffffffffffffffffffffffffffffffffffffffff` + // any address + `c52d6008` + // INTERFACE_ID_LSP7 + `760d9bba`; // transfer(address,address,uint256,bool,bytes) + +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [DEFI_BOT], + value: [lsp7TransferEntry], + }, +]); + +await universalProfile.setData( + encodedAllowedCalls.keys[0], + encodedAllowedCalls.values[0], +); +``` + + + + +```solidity +bytes4 constant INTERFACE_ID_LSP7 = 0xc52d6008; +bytes4 constant LSP7_TRANSFER_SELECTOR = 0x760d9bba; // transfer(address,address,uint256,bool,bytes) + +// CALL type + any address + LSP7 standard + transfer() selector +bytes memory allowedCallEntry = abi.encodePacked( + bytes4(0x00000002), // CALL type + address(0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF), // any address + INTERFACE_ID_LSP7, // only LSP7-compliant contracts + LSP7_TRANSFER_SELECTOR // transfer(address,address,uint256,bool,bytes) +); + +bytes memory compactEncoded = abi.encodePacked( + uint16(allowedCallEntry.length), + allowedCallEntry +); +``` + + + + +--- + +## Example 4: Update NFT metadata only (Marketing manager) + +**Context:** You give a team member the ability to call `setDataForTokenId` on your LSP8 NFT contract to update token metadata — without being able to transfer tokens or drain LYX. The Standard field is set to the LSP8 interface ID (`0x3a271706`) and the selector is locked to `setDataForTokenId`. + + + + +```ts +const MARKETING_MANAGER = '0xTeamMemberAddress'; +const NFT_CONTRACT = '0xYourLSP8ContractAddress'; // your specific NFT contract + +// CALL type + NFT contract + LSP8 interface ID + setDataForTokenId() selector +// setDataForTokenId(bytes32,bytes32,bytes) = 0xd6c1407c +const nftMetadataEntry = + `0x00000002` + + NFT_CONTRACT.slice(2) + + `3a271706` + // INTERFACE_ID_LSP8 + `d6c1407c`; // setDataForTokenId(bytes32,bytes32,bytes) + +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [MARKETING_MANAGER], + value: [nftMetadataEntry], + }, +]); + +await walletClient.writeContract({ + address: myUPAddress, + abi: UniversalProfileArtifact.abi, + functionName: 'setData', + args: [encodedAllowedCalls.keys[0], encodedAllowedCalls.values[0]], +}); +``` + + + + +```ts +const MARKETING_MANAGER = '0xTeamMemberAddress'; +const NFT_CONTRACT = '0xYourLSP8ContractAddress'; // your specific NFT contract + +// CALL type + NFT contract + LSP8 interface ID + setDataForTokenId() selector +// setDataForTokenId(bytes32,bytes32,bytes) = 0xd6c1407c +const nftMetadataEntry = + `0x00000002` + + NFT_CONTRACT.slice(2) + + `3a271706` + // INTERFACE_ID_LSP8 + `d6c1407c`; // setDataForTokenId(bytes32,bytes32,bytes) + +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [MARKETING_MANAGER], + value: [nftMetadataEntry], + }, +]); + +await universalProfile.setData( + encodedAllowedCalls.keys[0], + encodedAllowedCalls.values[0], +); +``` + + + + +```solidity +bytes4 constant INTERFACE_ID_LSP8 = 0x3a271706; +bytes4 constant SET_DATA_FOR_TOKEN_ID_SELECTOR = 0xd6c1407c; // setDataForTokenId(bytes32,bytes32,bytes) + +address constant NFT_CONTRACT = 0xYourLSP8ContractAddress; + +// CALL type + specific NFT contract + LSP8 standard + setDataForTokenId() selector +bytes memory allowedCallEntry = abi.encodePacked( + bytes4(0x00000002), // CALL type + NFT_CONTRACT, // specific NFT contract (20 bytes) + INTERFACE_ID_LSP8, // only LSP8-compliant contracts + SET_DATA_FOR_TOKEN_ID_SELECTOR // setDataForTokenId(bytes32,bytes32,bytes) +); + +bytes memory compactEncoded = abi.encodePacked( + uint16(allowedCallEntry.length), + allowedCallEntry +); +``` + + + + +--- + +## Common Mistakes + +:::warning SUPER_CALL bypasses Allowed Calls + +If you grant a controller the `SUPER_CALL` permission instead of `CALL`, **all Allowed Calls restrictions are completely ignored**. The controller can call any contract without restriction. Always use `CALL` (not `SUPER_CALL`) for controllers you want to restrict. + +::: + +:::caution Allowed Calls cannot cap LYX amounts + +Allowed Calls restrict _which_ contracts a controller can interact with, not _how much_ LYX it can send per transaction. If you need to cap deposit amounts, implement that guard in the target contract's logic. + +::: + +:::note Encoding gotcha + +The calltype + address + standard + selector fields must be packed **contiguously with no padding** between them. The resulting entry is exactly 32 bytes. One wrong byte offset causes the permission check to silently fail at runtime. Always test your encoding on testnet before going to mainnet. + +::: + +:::note LSP7 transfer selector + +The correct selector for LSP7 `transfer` is `0x760d9bba`. The **full** function signature is `transfer(address,address,uint256,bool,bytes)`. Omitting the `force` flag or the `data` parameter produces a different selector and every call will revert with a permission error that is hard to debug. + +::: From e376a9741e7114d1cfa661401dcdf2e26e1556cd Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 16 Mar 2026 16:36:19 +0000 Subject: [PATCH 02/15] docs(key-manager): add transferStake liquid staking controller example --- .../restrict-controller-actions.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md index be238f746b..87171467d4 100644 --- a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -312,6 +312,113 @@ bytes memory compactEncoded = abi.encodePacked( +### Liquid staking controller: convert stake to sLYX + +**Context:** You want a controller that can call [`transferStake`](https://stakingverse.io) on the Stakingverse vault to convert your staked LYX into liquid **sLYX** tokens ([`0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d`](https://explorer.execution.mainnet.lukso.network/address/0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d)) — without being able to call any other function on the vault or any other contract. + +`transferStake(address to, uint256 amount, bytes calldata data)` — selector: `0x1c892b5a` + + + + +```ts +import { createWalletClient, http } from 'viem'; +import { lukso } from 'viem/chains'; +import ERC725 from '@erc725/erc725.js'; + +const LIQUID_STAKING_CONTROLLER = '0xYourLiquidStakingControllerAddress'; +const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; +const SLYX_TOKEN = '0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d'; + +// Restrict to transferStake(address,uint256,bytes) only on the Stakingverse vault +// When called, the `to` param should be set to the sLYX token address to receive liquid sLYX +const transferStakeEntry = + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `1c892b5a`; + +const erc725 = new ERC725([]); +const encodedData = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [LIQUID_STAKING_CONTROLLER], + value: [transferStakeEntry], + }, +]); + +await walletClient.writeContract({ + address: myUPAddress, + abi: UniversalProfileArtifact.abi, + functionName: 'setDataBatch', + args: [encodedData.keys, encodedData.values], +}); +``` + + + + +```ts +import ERC725 from '@erc725/erc725.js'; +import { ethers } from 'ethers'; +import UniversalProfileArtifact from '@lukso/lsp-smart-contracts/artifacts/UniversalProfile.json'; + +const LIQUID_STAKING_CONTROLLER = '0xYourLiquidStakingControllerAddress'; +const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; + +// Restrict to transferStake(address,uint256,bytes) — selector 0x1c892b5a +const transferStakeEntry = + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `1c892b5a`; + +const erc725 = new ERC725([]); +const encodedData = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [LIQUID_STAKING_CONTROLLER], + value: [transferStakeEntry], + }, +]); + +const universalProfile = new ethers.Contract( + myUPAddress, + UniversalProfileArtifact.abi, + signer, +); +await universalProfile.setDataBatch(encodedData.keys, encodedData.values); +``` + + + + +```solidity +address constant STAKING_VAULT = 0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04; +address constant SLYX_TOKEN = 0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d; + +// transferStake(address,uint256,bytes) selector = 0x1c892b5a +bytes memory transferStakeEntry = abi.encodePacked( + bytes4(0x00000002), // CALL type + STAKING_VAULT, // target: Stakingverse vault only + bytes4(0xffffffff), // any ERC165 standard + bytes4(0x1c892b5a) // transferStake(address,uint256,bytes) +); + +bytes memory compactEncoded = abi.encodePacked(uint16(32), transferStakeEntry); + +bytes32 allowedCallsKey = bytes32( + abi.encodePacked( + bytes6(0x4b80742de2bf), // AddressPermissions:AllowedCalls: prefix + bytes2(0x0000), + LIQUID_STAKING_CONTROLLER + ) +); + +IERC725Y(myUPAddress).setData(allowedCallsKey, compactEncoded); +``` + + + + +:::tip Calling transferStake +When the restricted controller calls `transferStake`, it passes the **sLYX token address** (`0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d`) as the `to` param and the desired amount. If the recipient implements `IVaultStakeRecipient`, the vault will call `onVaultStakeReceived` — enabling automated liquid staking flows directly from your Universal Profile. +::: + --- ## Example 2: Send LYX to one address only From 7a3b5db9354e3f6190aff1cd9d66c886900dd54d Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 16 Mar 2026 16:41:45 +0000 Subject: [PATCH 03/15] fix(key-manager): correct withdrawal selectors and AllowedCalls data key prefix --- .../restrict-controller-actions.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md index 87171467d4..409751068f 100644 --- a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -176,8 +176,7 @@ contract SetStakingBotAllowedCalls { // Data key: AddressPermissions:AllowedCalls: bytes32 dataKey = bytes32( abi.encodePacked( - bytes6(0xef26ac33982a), // AddressPermissions:AllowedCalls: prefix - bytes2(0x0000), + bytes12(0x4b80742de2bf393a64c70000), // AddressPermissions:AllowedCalls:
full prefix stakingBot ) ); @@ -219,12 +218,12 @@ const WITHDRAWAL_BOT = '0xYourWithdrawalBotAddress'; const depositEntry = `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `d0e30db0`; -// Withdrawal controller: requestWithdrawal(uint256) and claimWithdrawal() +// Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) const requestWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `9ee679e8`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `fbbdb3ae`; const claimWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `6e66d84a`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `76657593`; const encodedData = erc725.encodeData([ { @@ -259,12 +258,12 @@ const WITHDRAWAL_BOT = '0xYourWithdrawalBotAddress'; const depositEntry = `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `d0e30db0`; -// Withdrawal controller: requestWithdrawal(uint256) and claimWithdrawal() +// Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) const requestWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `9ee679e8`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `fbbdb3ae`; const claimWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `6e66d84a`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `76657593`; const encodedData = erc725.encodeData([ { @@ -292,14 +291,14 @@ bytes memory requestEntry = abi.encodePacked( bytes4(0x00000002), // CALL type STAKING_VAULT, // target address bytes4(0xffffffff), // any standard - bytes4(0x9ee679e8) // requestWithdrawal(uint256) + bytes4(0xfbbdb3ae) // withdraw(uint256,address) ); bytes memory claimEntry = abi.encodePacked( bytes4(0x00000002), // CALL type STAKING_VAULT, // target address bytes4(0xffffffff), // any standard - bytes4(0x6e66d84a) // claimWithdrawal() + bytes4(0x76657593) // claim(uint256,address) ); // CompactBytesArray with two 32-byte entries @@ -401,10 +400,11 @@ bytes memory transferStakeEntry = abi.encodePacked( bytes memory compactEncoded = abi.encodePacked(uint16(32), transferStakeEntry); +// Full key: 0x4b80742de2bf393a64c70000 +// MappingWithGrouping: bytes6 hash + bytes4 hash + bytes2(0x0000) + address bytes32 allowedCallsKey = bytes32( abi.encodePacked( - bytes6(0x4b80742de2bf), // AddressPermissions:AllowedCalls: prefix - bytes2(0x0000), + bytes12(0x4b80742de2bf393a64c70000), // AddressPermissions:AllowedCalls:
full prefix LIQUID_STAKING_CONTROLLER ) ); From 13c0c6e15c180d52b559c1b8534a266c672931bb Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 16 Mar 2026 18:31:59 +0000 Subject: [PATCH 04/15] fix(key-manager): correct all Stakingverse selectors (keccak256 not sha3-256) + deposit(address) signature --- .../restrict-controller-actions.md | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md index 409751068f..2bfe497483 100644 --- a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -21,7 +21,7 @@ Full code examples are available in the 👾 [lukso-playground](https://github.c ## What you will learn -- How to restrict an automated staking bot to only call `deposit()` on a specific vault contract +- How to restrict an automated staking bot to only call `deposit(address)` on a specific vault contract - How to lock a controller so it can only send LYX to one specific address - How to allow a DeFi bot to interact with any LSP7 token contract, but nothing else - How to give a team member the ability to update NFT metadata without being able to transfer tokens or drain LYX @@ -45,7 +45,7 @@ Each Allowed Calls entry is a **32-byte packed value** stored under the `Address So a single 32-byte entry looks like: ``` -0x 00000002 9F49a95b0c3c9e2A6c77a16C177928294c0F6F04 ffffffff d0e30db0 +0x 00000002 9F49a95b0c3c9e2A6c77a16C177928294c0F6F04 ffffffff f340fa01 ^------- ^--------------------------------------- ^------- ^------- calltype address (20 bytes) standard selector ``` @@ -58,7 +58,7 @@ When multiple entries are present they are encoded as a `CompactBytesArray` — **Context:** You want an automated bot to stake LYX on [Stakingverse](https://stakingverse.io) without being able to drain your UP or interact with any other contract. -The bot is granted `CALL` permission (see [Grant Permissions](./grant-permissions.md)), and its Allowed Calls list is restricted to a single entry: `deposit()` on the Stakingverse vault. +The bot is granted `CALL` permission (see [Grant Permissions](./grant-permissions.md)), and its Allowed Calls list is restricted to a single entry: `deposit(address)` on the Stakingverse vault. @@ -79,12 +79,12 @@ const erc725 = new ERC725( 'https://rpc.lukso.network', ); -// CALL type (0x00000002) + vault address (20 bytes) + any standard (0xffffffff) + deposit() selector +// CALL type (0x00000002) + vault address (20 bytes) + any standard (0xffffffff) + deposit(address) selector const depositEntry = `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + - `d0e30db0`; // deposit() + `f340fa01`; // deposit(address) const encodedAllowedCalls = erc725.encodeData([ { @@ -125,12 +125,12 @@ const erc725 = new ERC725( 'https://rpc.lukso.network', ); -// CALL type (0x00000002) + vault address (20 bytes) + any standard (0xffffffff) + deposit() selector +// CALL type (0x00000002) + vault address (20 bytes) + any standard (0xffffffff) + deposit(address) selector const depositEntry = `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + - `d0e30db0`; // deposit() + `f340fa01`; // deposit(address) const encodedAllowedCalls = erc725.encodeData([ { @@ -181,12 +181,12 @@ contract SetStakingBotAllowedCalls { ) ); - // 32-byte packed entry: CALL + vault + any standard + deposit() selector + // 32-byte packed entry: CALL + vault + any standard + deposit(address) selector bytes memory allowedCallEntry = abi.encodePacked( bytes4(0x00000002), // CALL type STAKING_VAULT, // target address (20 bytes) bytes4(0xffffffff), // any interface standard - bytes4(0xd0e30db0) // deposit() selector + bytes4(0xf340fa01) // deposit(address) selector ); // CompactBytesArray encoding: 2-byte length prefix per element @@ -214,9 +214,9 @@ For better security, split staking and withdrawal into two separate controllers. const STAKING_BOT = '0xYourStakingBotAddress'; const WITHDRAWAL_BOT = '0xYourWithdrawalBotAddress'; -// Staking controller: deposit() only +// Staking controller: deposit(address) only const depositEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `d0e30db0`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; // Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) const requestWithdrawalEntry = @@ -254,9 +254,9 @@ await walletClient.writeContract({ const STAKING_BOT = '0xYourStakingBotAddress'; const WITHDRAWAL_BOT = '0xYourWithdrawalBotAddress'; -// Staking controller: deposit() only +// Staking controller: deposit(address) only const depositEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `d0e30db0`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; // Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) const requestWithdrawalEntry = @@ -291,14 +291,14 @@ bytes memory requestEntry = abi.encodePacked( bytes4(0x00000002), // CALL type STAKING_VAULT, // target address bytes4(0xffffffff), // any standard - bytes4(0xfbbdb3ae) // withdraw(uint256,address) + bytes4(0x00f714ce) // withdraw(uint256,address) ); bytes memory claimEntry = abi.encodePacked( bytes4(0x00000002), // CALL type STAKING_VAULT, // target address bytes4(0xffffffff), // any standard - bytes4(0x76657593) // claim(uint256,address) + bytes4(0xddd5e1b2) // claim(uint256,address) ); // CompactBytesArray with two 32-byte entries @@ -315,7 +315,7 @@ bytes memory compactEncoded = abi.encodePacked( **Context:** You want a controller that can call [`transferStake`](https://stakingverse.io) on the Stakingverse vault to convert your staked LYX into liquid **sLYX** tokens ([`0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d`](https://explorer.execution.mainnet.lukso.network/address/0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d)) — without being able to call any other function on the vault or any other contract. -`transferStake(address to, uint256 amount, bytes calldata data)` — selector: `0x1c892b5a` +`transferStake(address to, uint256 amount, bytes calldata data)` — selector: `0xf2f1042f` @@ -362,7 +362,7 @@ import UniversalProfileArtifact from '@lukso/lsp-smart-contracts/artifacts/Unive const LIQUID_STAKING_CONTROLLER = '0xYourLiquidStakingControllerAddress'; const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; -// Restrict to transferStake(address,uint256,bytes) — selector 0x1c892b5a +// Restrict to transferStake(address,uint256,bytes) — selector 0xf2f1042f const transferStakeEntry = `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `1c892b5a`; @@ -390,12 +390,12 @@ await universalProfile.setDataBatch(encodedData.keys, encodedData.values); address constant STAKING_VAULT = 0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04; address constant SLYX_TOKEN = 0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d; -// transferStake(address,uint256,bytes) selector = 0x1c892b5a +// transferStake(address,uint256,bytes) selector = 0xf2f1042f bytes memory transferStakeEntry = abi.encodePacked( bytes4(0x00000002), // CALL type STAKING_VAULT, // target: Stakingverse vault only bytes4(0xffffffff), // any ERC165 standard - bytes4(0x1c892b5a) // transferStake(address,uint256,bytes) + bytes4(0xf2f1042f) // transferStake(address,uint256,bytes) ); bytes memory compactEncoded = abi.encodePacked(uint16(32), transferStakeEntry); From 62e0d61ce06e1f6e115c8916f1a1f6d19bdfeb6f Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 16 Mar 2026 18:39:16 +0000 Subject: [PATCH 05/15] fix(key-manager): address all review comments - call types, imports, ERC725 init, Solidity placeholders --- .../restrict-controller-actions.md | 183 ++++++++++++------ 1 file changed, 120 insertions(+), 63 deletions(-) diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md index 2bfe497483..0707aaff2a 100644 --- a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -29,7 +29,7 @@ Full code examples are available in the 👾 [lukso-playground](https://github.c ## Prerequisites - A deployed Universal Profile (see [Deploy a Universal Profile](/learn/getting-started)) -- A controller address already granted `CALL` permission (see [Grant Permissions](./grant-permissions.md)) +- A controller address already granted `CALL` permission (and `TRANSFERVALUE` if the controller needs to send LYX) (see [Grant Permissions](./grant-permissions.md)) ## How Allowed Calls work @@ -37,7 +37,7 @@ Each Allowed Calls entry is a **32-byte packed value** stored under the `Address | Field | Size | Description | | --- | --- | --- | -| Call type | 4 bytes | Type of call: `CALL` (`0x00000002`), `STATICCALL` (`0x00000001`), `DELEGATECALL` (`0x00000003`) | +| Call type | 4 bytes | Bit flags: `TRANSFERVALUE` (`0x00000001`), `CALL` (`0x00000002`), `TRANSFERVALUE\|CALL` (`0x00000003`), `STATICCALL` (`0x00000004`), `DELEGATECALL` (`0x00000008`) | | Address | 20 bytes | Target contract address, or `0xffffffffffffffffffffffffffffffffffffffff` for any | | Standard | 4 bytes | Interface ID the target contract must support, or `0xffffffff` for any | | Function | 4 bytes | Function selector the controller may call, or `0xffffffff` for any | @@ -58,30 +58,26 @@ When multiple entries are present they are encoded as a `CompactBytesArray` — **Context:** You want an automated bot to stake LYX on [Stakingverse](https://stakingverse.io) without being able to drain your UP or interact with any other contract. -The bot is granted `CALL` permission (see [Grant Permissions](./grant-permissions.md)), and its Allowed Calls list is restricted to a single entry: `deposit(address)` on the Stakingverse vault. +The bot is granted `CALL` and `TRANSFERVALUE` permissions (see [Grant Permissions](./grant-permissions.md)), and its Allowed Calls list is restricted to a single entry: `deposit(address)` on the Stakingverse vault. ```ts -import { ERC725 } from '@erc725/erc725.js'; +import ERC725 from '@erc725/erc725.js'; import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; -import { createWalletClient, http } from 'viem'; +import { createWalletClient, custom } from 'viem'; import { lukso } from 'viem/chains'; import UniversalProfileArtifact from '@lukso/lsp-smart-contracts/artifacts/UniversalProfile.json'; const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; const STAKING_BOT_ADDRESS = '0xYourBotAddress'; // replace with your bot address -const erc725 = new ERC725( - LSP6Schema, - myUPAddress, - 'https://rpc.lukso.network', -); +const erc725 = new ERC725(LSP6Schema); -// CALL type (0x00000002) + vault address (20 bytes) + any standard (0xffffffff) + deposit(address) selector +// TRANSFERVALUE|CALL type (0x00000003) — deposit(address) sends LYX to the vault const depositEntry = - `0x00000002` + + `0x00000003` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; // deposit(address) @@ -96,8 +92,9 @@ const encodedAllowedCalls = erc725.encodeData([ // Connect the UP Browser Extension and send the transaction const [account] = await window.lukso.request({ method: 'eth_requestAccounts' }); +const myUPAddress = account; -const walletClient = createWalletClient({ account, chain: lukso, transport: http() }); +const walletClient = createWalletClient({ account, chain: lukso, transport: custom(window.lukso) }); await walletClient.writeContract({ address: myUPAddress, @@ -111,7 +108,7 @@ await walletClient.writeContract({ ```ts -import { ERC725 } from '@erc725/erc725.js'; +import ERC725 from '@erc725/erc725.js'; import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; import { ethers } from 'ethers'; import UniversalProfileArtifact from '@lukso/lsp-smart-contracts/artifacts/UniversalProfile.json'; @@ -119,15 +116,11 @@ import UniversalProfileArtifact from '@lukso/lsp-smart-contracts/artifacts/Unive const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; const STAKING_BOT_ADDRESS = '0xYourBotAddress'; // replace with your bot address -const erc725 = new ERC725( - LSP6Schema, - myUPAddress, - 'https://rpc.lukso.network', -); +const erc725 = new ERC725(LSP6Schema); -// CALL type (0x00000002) + vault address (20 bytes) + any standard (0xffffffff) + deposit(address) selector +// TRANSFERVALUE|CALL type (0x00000003) — deposit(address) sends LYX to the vault const depositEntry = - `0x00000002` + + `0x00000003` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; // deposit(address) @@ -143,6 +136,7 @@ const encodedAllowedCalls = erc725.encodeData([ // Connect the UP Browser Extension and send the transaction const provider = new ethers.BrowserProvider(window.lukso); const signer = await provider.getSigner(); +const myUPAddress = await signer.getAddress(); const universalProfile = new ethers.Contract( myUPAddress, @@ -181,9 +175,10 @@ contract SetStakingBotAllowedCalls { ) ); - // 32-byte packed entry: CALL + vault + any standard + deposit(address) selector + // 32-byte packed entry: TRANSFERVALUE|CALL + vault + any standard + deposit(address) selector + // deposit(address) sends LYX, so call type must be TRANSFERVALUE|CALL (0x00000003) bytes memory allowedCallEntry = abi.encodePacked( - bytes4(0x00000002), // CALL type + bytes4(0x00000003), // TRANSFERVALUE|CALL type — deposit sends LYX STAKING_VAULT, // target address (20 bytes) bytes4(0xffffffff), // any interface standard bytes4(0xf340fa01) // deposit(address) selector @@ -211,14 +206,22 @@ For better security, split staking and withdrawal into two separate controllers. ```ts +// See Example 1 for full viem setup (walletClient, myUPAddress, UniversalProfileArtifact) +import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; + +const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; const STAKING_BOT = '0xYourStakingBotAddress'; const WITHDRAWAL_BOT = '0xYourWithdrawalBotAddress'; -// Staking controller: deposit(address) only +const erc725 = new ERC725(LSP6Schema); + +// Staking controller: deposit(address) — sends LYX, so TRANSFERVALUE|CALL (0x00000003) const depositEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; + `0x00000003` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; // Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) +// These do NOT send LYX, so CALL (0x00000002) is correct const requestWithdrawalEntry = `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `fbbdb3ae`; @@ -251,14 +254,22 @@ await walletClient.writeContract({ ```ts +// See Example 1 for full ethers setup (universalProfile, myUPAddress, signer) +import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; + +const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; const STAKING_BOT = '0xYourStakingBotAddress'; const WITHDRAWAL_BOT = '0xYourWithdrawalBotAddress'; -// Staking controller: deposit(address) only +const erc725 = new ERC725(LSP6Schema); + +// Staking controller: deposit(address) — sends LYX, so TRANSFERVALUE|CALL (0x00000003) const depositEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; + `0x00000003` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; // Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) +// These do NOT send LYX, so CALL (0x00000002) is correct const requestWithdrawalEntry = `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `fbbdb3ae`; @@ -288,14 +299,14 @@ await universalProfile.setDataBatch(encodedData.keys, encodedData.values); ```solidity // For the withdrawal controller, allow both requestWithdrawal and claimWithdrawal bytes memory requestEntry = abi.encodePacked( - bytes4(0x00000002), // CALL type + bytes4(0x00000002), // CALL type — withdraw does not send LYX STAKING_VAULT, // target address bytes4(0xffffffff), // any standard bytes4(0x00f714ce) // withdraw(uint256,address) ); bytes memory claimEntry = abi.encodePacked( - bytes4(0x00000002), // CALL type + bytes4(0x00000002), // CALL type — claim does not send LYX STAKING_VAULT, // target address bytes4(0xffffffff), // any standard bytes4(0xddd5e1b2) // claim(uint256,address) @@ -321,20 +332,20 @@ bytes memory compactEncoded = abi.encodePacked( ```ts -import { createWalletClient, http } from 'viem'; -import { lukso } from 'viem/chains'; +// See Example 1 for full viem setup (walletClient, myUPAddress, UniversalProfileArtifact) import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; const LIQUID_STAKING_CONTROLLER = '0xYourLiquidStakingControllerAddress'; const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; const SLYX_TOKEN = '0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d'; // Restrict to transferStake(address,uint256,bytes) only on the Stakingverse vault -// When called, the `to` param should be set to the sLYX token address to receive liquid sLYX +// transferStake does NOT send LYX — CALL type (0x00000002) is correct const transferStakeEntry = `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `1c892b5a`; -const erc725 = new ERC725([]); +const erc725 = new ERC725(LSP6Schema); const encodedData = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', @@ -355,18 +366,20 @@ await walletClient.writeContract({ ```ts +// See Example 1 for full ethers setup (universalProfile, myUPAddress, signer) import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; import { ethers } from 'ethers'; import UniversalProfileArtifact from '@lukso/lsp-smart-contracts/artifacts/UniversalProfile.json'; const LIQUID_STAKING_CONTROLLER = '0xYourLiquidStakingControllerAddress'; const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; -// Restrict to transferStake(address,uint256,bytes) — selector 0xf2f1042f +// Restrict to transferStake(address,uint256,bytes) — transferStake does NOT send LYX const transferStakeEntry = `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `1c892b5a`; -const erc725 = new ERC725([]); +const erc725 = new ERC725(LSP6Schema); const encodedData = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', @@ -387,29 +400,42 @@ await universalProfile.setDataBatch(encodedData.keys, encodedData.values); ```solidity -address constant STAKING_VAULT = 0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04; -address constant SLYX_TOKEN = 0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d; - -// transferStake(address,uint256,bytes) selector = 0xf2f1042f -bytes memory transferStakeEntry = abi.encodePacked( - bytes4(0x00000002), // CALL type - STAKING_VAULT, // target: Stakingverse vault only - bytes4(0xffffffff), // any ERC165 standard - bytes4(0xf2f1042f) // transferStake(address,uint256,bytes) -); +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; -bytes memory compactEncoded = abi.encodePacked(uint16(32), transferStakeEntry); +import {IERC725Y} from "@erc725/smart-contracts/contracts/interfaces/IERC725Y.sol"; -// Full key: 0x4b80742de2bf393a64c70000 -// MappingWithGrouping: bytes6 hash + bytes4 hash + bytes2(0x0000) + address -bytes32 allowedCallsKey = bytes32( - abi.encodePacked( - bytes12(0x4b80742de2bf393a64c70000), // AddressPermissions:AllowedCalls:
full prefix - LIQUID_STAKING_CONTROLLER - ) -); +contract SetLiquidStakingAllowedCalls { + address constant STAKING_VAULT = 0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04; + address constant SLYX_TOKEN = 0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d; -IERC725Y(myUPAddress).setData(allowedCallsKey, compactEncoded); + function restrictLiquidStakingController( + address universalProfile, + address LIQUID_STAKING_CONTROLLER + ) external { + // transferStake(address,uint256,bytes) selector = 0xf2f1042f + // transferStake does NOT send LYX — CALL type (0x00000002) is correct + bytes memory transferStakeEntry = abi.encodePacked( + bytes4(0x00000002), // CALL type — transferStake does not send LYX + STAKING_VAULT, // target: Stakingverse vault only + bytes4(0xffffffff), // any ERC165 standard + bytes4(0xf2f1042f) // transferStake(address,uint256,bytes) + ); + + bytes memory compactEncoded = abi.encodePacked(uint16(32), transferStakeEntry); + + // Full key: 0x4b80742de2bf393a64c70000 + // MappingWithGrouping: bytes6 hash + bytes4 hash + bytes2(0x0000) + address + bytes32 allowedCallsKey = bytes32( + abi.encodePacked( + bytes12(0x4b80742de2bf393a64c70000), // AddressPermissions:AllowedCalls:
full prefix + LIQUID_STAKING_CONTROLLER + ) + ); + + IERC725Y(universalProfile).setData(allowedCallsKey, compactEncoded); + } +} ``` @@ -429,13 +455,18 @@ When the restricted controller calls `transferStake`, it passes the **sLYX token ```ts +// See Example 1 for full viem setup (walletClient, myUPAddress, UniversalProfileArtifact) +import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; + const CONTROLLER_ADDRESS = '0xYourPayrollController'; const COLD_WALLET = '0xYourColdWalletAddress'; // the only permitted recipient -// CALL type + cold wallet (20 bytes) + any standard + any selector +// TRANSFERVALUE type (0x00000001) — sending native LYX uses TRANSFERVALUE, not CALL const coldWalletEntry = - `0x00000002` + COLD_WALLET.slice(2) + `ffffffff` + `ffffffff`; + `0x00000001` + COLD_WALLET.slice(2) + `ffffffff` + `ffffffff`; +const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', @@ -456,13 +487,18 @@ await walletClient.writeContract({ ```ts +// See Example 1 for full ethers setup (universalProfile, myUPAddress, signer) +import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; + const CONTROLLER_ADDRESS = '0xYourPayrollController'; const COLD_WALLET = '0xYourColdWalletAddress'; // the only permitted recipient -// CALL type + cold wallet (20 bytes) + any standard + any selector +// TRANSFERVALUE type (0x00000001) — sending native LYX uses TRANSFERVALUE, not CALL const coldWalletEntry = - `0x00000002` + COLD_WALLET.slice(2) + `ffffffff` + `ffffffff`; + `0x00000001` + COLD_WALLET.slice(2) + `ffffffff` + `ffffffff`; +const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', @@ -481,11 +517,12 @@ await universalProfile.setData( ```solidity -address constant COLD_WALLET = 0xYourColdWalletAddress; +address constant COLD_WALLET = address(0); // TODO: replace with your cold wallet address -// CALL type + cold wallet address + any standard + any selector +// TRANSFERVALUE type (0x00000001) + cold wallet address + any standard + any selector +// Sending native LYX uses TRANSFERVALUE bit flag, not CALL bytes memory allowedCallEntry = abi.encodePacked( - bytes4(0x00000002), // CALL type + bytes4(0x00000001), // TRANSFERVALUE type — native LYX transfer COLD_WALLET, // target address (20 bytes) bytes4(0xffffffff), // any interface standard bytes4(0xffffffff) // any function selector @@ -510,6 +547,10 @@ bytes memory compactEncoded = abi.encodePacked( ```ts +// See Example 1 for full viem setup (walletClient, myUPAddress, UniversalProfileArtifact) +import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; + const DEFI_BOT = '0xYourDeFiBotAddress'; // CALL type + any address + LSP7 interface ID + transfer() selector @@ -520,6 +561,7 @@ const lsp7TransferEntry = `c52d6008` + // INTERFACE_ID_LSP7 `760d9bba`; // transfer(address,address,uint256,bool,bytes) +const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', @@ -540,6 +582,10 @@ await walletClient.writeContract({ ```ts +// See Example 1 for full ethers setup (universalProfile, myUPAddress, signer) +import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; + const DEFI_BOT = '0xYourDeFiBotAddress'; // CALL type + any address + LSP7 interface ID + transfer() selector @@ -550,6 +596,7 @@ const lsp7TransferEntry = `c52d6008` + // INTERFACE_ID_LSP7 `760d9bba`; // transfer(address,address,uint256,bool,bytes) +const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', @@ -598,6 +645,10 @@ bytes memory compactEncoded = abi.encodePacked( ```ts +// See Example 1 for full viem setup (walletClient, myUPAddress, UniversalProfileArtifact) +import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; + const MARKETING_MANAGER = '0xTeamMemberAddress'; const NFT_CONTRACT = '0xYourLSP8ContractAddress'; // your specific NFT contract @@ -609,6 +660,7 @@ const nftMetadataEntry = `3a271706` + // INTERFACE_ID_LSP8 `d6c1407c`; // setDataForTokenId(bytes32,bytes32,bytes) +const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', @@ -629,6 +681,10 @@ await walletClient.writeContract({ ```ts +// See Example 1 for full ethers setup (universalProfile, myUPAddress, signer) +import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; + const MARKETING_MANAGER = '0xTeamMemberAddress'; const NFT_CONTRACT = '0xYourLSP8ContractAddress'; // your specific NFT contract @@ -640,6 +696,7 @@ const nftMetadataEntry = `3a271706` + // INTERFACE_ID_LSP8 `d6c1407c`; // setDataForTokenId(bytes32,bytes32,bytes) +const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', @@ -661,7 +718,7 @@ await universalProfile.setData( bytes4 constant INTERFACE_ID_LSP8 = 0x3a271706; bytes4 constant SET_DATA_FOR_TOKEN_ID_SELECTOR = 0xd6c1407c; // setDataForTokenId(bytes32,bytes32,bytes) -address constant NFT_CONTRACT = 0xYourLSP8ContractAddress; +address constant NFT_CONTRACT = address(0); // TODO: replace with your LSP8 NFT contract address // CALL type + specific NFT contract + LSP8 standard + setDataForTokenId() selector bytes memory allowedCallEntry = abi.encodePacked( From 03a9e0f34317a974104ebd9e238d51b6f01fa54b Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 16 Mar 2026 19:41:39 +0000 Subject: [PATCH 06/15] fix(key-manager): sync withdraw/claim selectors in TypeScript examples to match Solidity --- .../key-manager/restrict-controller-actions.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md index 0707aaff2a..67d5e3efa5 100644 --- a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -223,10 +223,10 @@ const depositEntry = // Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) // These do NOT send LYX, so CALL (0x00000002) is correct const requestWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `fbbdb3ae`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `00f714ce`; const claimWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `76657593`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `ddd5e1b2`; const encodedData = erc725.encodeData([ { @@ -271,10 +271,10 @@ const depositEntry = // Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) // These do NOT send LYX, so CALL (0x00000002) is correct const requestWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `fbbdb3ae`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `00f714ce`; const claimWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `76657593`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `ddd5e1b2`; const encodedData = erc725.encodeData([ { From 3d08a572db6e6fc95d9a3754be41e9a92a34b15b Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 16 Mar 2026 20:36:27 +0000 Subject: [PATCH 07/15] feat(key-manager): add AllowedCallsReference component with selectors + gotchas --- .../restrict-controller-actions.md | 4 + .../AllowedCallsReference/index.tsx | 161 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/components/AllowedCallsReference/index.tsx diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md index 67d5e3efa5..77a8e5c358 100644 --- a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -4,6 +4,7 @@ sidebar_position: 3 description: Learn how to restrict a controller to only specific contracts, functions, or token standards using LSP6 Key Manager Allowed Calls on LUKSO. --- +import AllowedCallsReference from '@site/src/components/AllowedCallsReference'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -52,6 +53,9 @@ So a single 32-byte entry looks like: When multiple entries are present they are encoded as a `CompactBytesArray` — erc725.js handles this automatically. + + + --- ## Example 1: Staking-only controller diff --git a/src/components/AllowedCallsReference/index.tsx b/src/components/AllowedCallsReference/index.tsx new file mode 100644 index 0000000000..75b9679858 --- /dev/null +++ b/src/components/AllowedCallsReference/index.tsx @@ -0,0 +1,161 @@ +import React, { useState } from 'react'; +import Accordion from '@mui/material/Accordion'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import Typography from '@mui/material/Typography'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import CheckIcon from '@mui/icons-material/Check'; +import IconButton from '@mui/material/IconButton'; +import Chip from '@mui/material/Chip'; +import Alert from '@mui/material/Alert'; +import Tooltip from '@mui/material/Tooltip'; +import Box from '@mui/material/Box'; + +type CallData = { + useCase: string; + func: string; + callTypeLabel: string; + callType: string; + selector: string; +}; + +type Category = { + title: string; + items: CallData[]; + gotcha: string; +}; + +const categories: Category[] = [ + { + title: 'LSP7 (Fungible Tokens)', + items: [ + { useCase: 'Transfer tokens', func: 'transfer(address,address,uint256,bool,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x760d9bba' }, + { useCase: 'Authorize operator', func: 'authorizeOperator(address,uint256,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0xb49506fd' }, + { useCase: 'Revoke operator', func: 'revokeOperator(address,bool,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x4521748e' }, + { useCase: 'Mint tokens', func: 'mint(address,uint256,bool,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x7580d920' }, + { useCase: 'Burn tokens', func: 'burn(address,uint256,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x44d17187' }, + ], + gotcha: 'LSP7 `transfer` does NOT send LYX. Use `CALL` only (`0x00000002`), not `TRANSFERVALUE|CALL`. If you add `TRANSFERVALUE`, you\'re granting unnecessary LYX transfer rights.' + }, + { + title: 'LSP8 (NFTs / Identifiable Digital Assets)', + items: [ + { useCase: 'Transfer NFT', func: 'transfer(address,address,bytes32,bool,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x511b6952' }, + { useCase: 'Update token metadata', func: 'setDataForTokenId(bytes32,bytes32,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0xd6c1407c' }, + { useCase: 'Mint NFT', func: 'mint(address,bytes32,bool,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0xaf255b61' }, + { useCase: 'Burn NFT', func: 'burn(bytes32,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x6c79b70b' }, + ], + gotcha: 'The `bytes32` tokenId in LSP8 `transfer` is the token identifier — NOT an ERC721-style `uint256`. Make sure your AllowedCalls entry targets the right contract address alongside the selector.' + }, + { + title: 'ERC725X / ERC725Y (Universal Profile core)', + items: [ + { useCase: 'Execute external call', func: 'execute(uint256,address,uint256,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x44c028fe' }, + { useCase: 'Set single data key', func: 'setData(bytes32,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x7f23690c' }, + { useCase: 'Set multiple data keys', func: 'setDataBatch(bytes32[],bytes[])', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x97902421' }, + ], + gotcha: 'Allowing `execute` on a UP gives the controller the ability to call ANY external contract on behalf of the UP — combine with `AllowedCalls` to restrict which contracts and functions it can call.' + }, + { + title: 'Stakingverse (LYX Liquid Staking)', + items: [ + { useCase: 'Deposit LYX', func: 'deposit(address)', callTypeLabel: 'TRANSFERVALUE|CALL', callType: '0x00000003', selector: '0xf340fa01' }, + { useCase: 'Withdraw stake', func: 'withdraw(uint256,address)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x00f714ce' }, + { useCase: 'Claim rewards', func: 'claim(uint256,address)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0xddd5e1b2' }, + { useCase: 'Transfer staked position', func: 'transferStake(address,uint256,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0xf2f1042f' }, + ], + gotcha: '`deposit(address)` sends actual LYX to the vault — it MUST use `TRANSFERVALUE|CALL` (`0x00000003`), not just `CALL`. This is the #1 mistake when building staking controllers. All other Stakingverse functions are non-payable — use `CALL` only.' + } +]; + +const CopyButton = ({ text }: { text: string }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + {copied ? : } + + + ); +}; + +export default function AllowedCallsReference() { + const [expanded, setExpanded] = useState('panel0'); + + const handleChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => { + setExpanded(isExpanded ? panel : false); + }; + + return ( + + {categories.map((cat, index) => { + const panelId = `panel${index}`; + return ( + + }> + {cat.title} + + + + + + + + + + + + + + {cat.items.map((item, i) => ( + + + + + + + ))} + +
Use caseFunctionCall typeSelector
{item.useCase} + {item.func} + + + + {item.selector} + +
+
+ + $1') }} /> + +
+
+ ); + })} +
+ ); +} From 347733edc37cce8b0806e53f2637c6347d5a8066 Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 16 Mar 2026 20:37:30 +0000 Subject: [PATCH 08/15] fix(key-manager): correct transferStake selector in TypeScript examples (1c892b5a -> f2f1042f) --- .../key-manager/restrict-controller-actions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md index 77a8e5c358..f9eb7db380 100644 --- a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -347,7 +347,7 @@ const SLYX_TOKEN = '0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d'; // Restrict to transferStake(address,uint256,bytes) only on the Stakingverse vault // transferStake does NOT send LYX — CALL type (0x00000002) is correct const transferStakeEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `1c892b5a`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `f2f1042f`; const erc725 = new ERC725(LSP6Schema); const encodedData = erc725.encodeData([ @@ -381,7 +381,7 @@ const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; // Restrict to transferStake(address,uint256,bytes) — transferStake does NOT send LYX const transferStakeEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `1c892b5a`; + `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `f2f1042f`; const erc725 = new ERC725(LSP6Schema); const encodedData = erc725.encodeData([ From 2836667ff8b4b8a4bfe5133252bf17df44ec50f4 Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 16 Mar 2026 21:25:03 +0000 Subject: [PATCH 09/15] fix(key-manager): address Jean + Copilot review comments, add Token Metadata section, fix titles and selectors --- .../restrict-controller-actions.md | 204 +++++++++--- .../AllowedCallsReference/index.tsx | 302 +++++++++++++++--- 2 files changed, 425 insertions(+), 81 deletions(-) diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md index f9eb7db380..24b1661854 100644 --- a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -1,5 +1,5 @@ --- -sidebar_label: 'Restrict What a Controller Can Do' +sidebar_label: 'Restrict Controller Interactions' sidebar_position: 3 description: Learn how to restrict a controller to only specific contracts, functions, or token standards using LSP6 Key Manager Allowed Calls on LUKSO. --- @@ -8,11 +8,11 @@ import AllowedCallsReference from '@site/src/components/AllowedCallsReference'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# Restrict What a Controller Can Do +# Configure Controller Interactions -Granting a controller the `CALL` permission lets it execute calls on behalf of your Universal Profile — but by itself that permission is too broad. An automated bot with `CALL` permission can theoretically interact with any contract your UP can reach. **Allowed Calls** add a second layer of access control by restricting _which_ contracts, _which_ interface standards, and _which_ function selectors a controller is allowed to call. +Granting a controller the `SUPER_CALL` permission lets it execute calls on behalf of your Universal Profile — but by itself that permission is too broad. An address with the `SUPER_CALL` permission could theoretically interact with any contract your UP can reach. -Think of permissions as the doors a controller can open, and Allowed Calls as the specific keys they are allowed to use once inside. +Using the non-super permission `CALL` with some configured **Allowed Calls** add a better layer of access control by restricting _which_ contracts and which functions on a smart contract can be called. This can also be configured by _which_ interface standards for a more "opened restriction" level :::info @@ -36,12 +36,12 @@ Full code examples are available in the 👾 [lukso-playground](https://github.c Each Allowed Calls entry is a **32-byte packed value** stored under the `AddressPermissions:AllowedCalls:` data key as a `CompactBytesArray`. The four fields are concatenated with no padding: -| Field | Size | Description | -| --- | --- | --- | -| Call type | 4 bytes | Bit flags: `TRANSFERVALUE` (`0x00000001`), `CALL` (`0x00000002`), `TRANSFERVALUE\|CALL` (`0x00000003`), `STATICCALL` (`0x00000004`), `DELEGATECALL` (`0x00000008`) | -| Address | 20 bytes | Target contract address, or `0xffffffffffffffffffffffffffffffffffffffff` for any | -| Standard | 4 bytes | Interface ID the target contract must support, or `0xffffffff` for any | -| Function | 4 bytes | Function selector the controller may call, or `0xffffffff` for any | +| Field | Size | Description | +| --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Call type | 4 bytes | Bit flags: `TRANSFERVALUE` (`0x00000001`), `CALL` (`0x00000002`), `TRANSFERVALUE\|CALL` (`0x00000003`), `STATICCALL` (`0x00000004`), `DELEGATECALL` (`0x00000008`) | +| Address | 20 bytes | Target contract address, or `0xffffffffffffffffffffffffffffffffffffffff` for any | +| Standard | 4 bytes | Interface ID the target contract must support, or `0xffffffff` for any | +| Function | 4 bytes | Function selector the controller may call, or `0xffffffff` for any | So a single 32-byte entry looks like: @@ -55,7 +55,6 @@ When multiple entries are present they are encoded as a `CompactBytesArray` — - --- ## Example 1: Staking-only controller @@ -81,10 +80,7 @@ const erc725 = new ERC725(LSP6Schema); // TRANSFERVALUE|CALL type (0x00000003) — deposit(address) sends LYX to the vault const depositEntry = - `0x00000003` + - STAKING_VAULT.slice(2) + - `ffffffff` + - `f340fa01`; // deposit(address) + `0x00000003` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; // deposit(address) const encodedAllowedCalls = erc725.encodeData([ { @@ -98,7 +94,11 @@ const encodedAllowedCalls = erc725.encodeData([ const [account] = await window.lukso.request({ method: 'eth_requestAccounts' }); const myUPAddress = account; -const walletClient = createWalletClient({ account, chain: lukso, transport: custom(window.lukso) }); +const walletClient = createWalletClient({ + account, + chain: lukso, + transport: custom(window.lukso), +}); await walletClient.writeContract({ address: myUPAddress, @@ -124,10 +124,7 @@ const erc725 = new ERC725(LSP6Schema); // TRANSFERVALUE|CALL type (0x00000003) — deposit(address) sends LYX to the vault const depositEntry = - `0x00000003` + - STAKING_VAULT.slice(2) + - `ffffffff` + - `f340fa01`; // deposit(address) + `0x00000003` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; // deposit(address) const encodedAllowedCalls = erc725.encodeData([ { @@ -304,16 +301,16 @@ await universalProfile.setDataBatch(encodedData.keys, encodedData.values); // For the withdrawal controller, allow both requestWithdrawal and claimWithdrawal bytes memory requestEntry = abi.encodePacked( bytes4(0x00000002), // CALL type — withdraw does not send LYX - STAKING_VAULT, // target address - bytes4(0xffffffff), // any standard - bytes4(0x00f714ce) // withdraw(uint256,address) + STAKING_VAULT, // target address + bytes4(0xffffffff), // any standard + bytes4(0x00f714ce) // withdraw(uint256,address) ); bytes memory claimEntry = abi.encodePacked( bytes4(0x00000002), // CALL type — claim does not send LYX - STAKING_VAULT, // target address - bytes4(0xffffffff), // any standard - bytes4(0xddd5e1b2) // claim(uint256,address) + STAKING_VAULT, // target address + bytes4(0xffffffff), // any standard + bytes4(0xddd5e1b2) // claim(uint256,address) ); // CompactBytesArray with two 32-byte entries @@ -342,7 +339,7 @@ import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; const LIQUID_STAKING_CONTROLLER = '0xYourLiquidStakingControllerAddress'; const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; -const SLYX_TOKEN = '0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d'; +const SLYX_TOKEN = '0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d'; // Restrict to transferStake(address,uint256,bytes) only on the Stakingverse vault // transferStake does NOT send LYX — CALL type (0x00000002) is correct @@ -361,8 +358,8 @@ const encodedData = erc725.encodeData([ await walletClient.writeContract({ address: myUPAddress, abi: UniversalProfileArtifact.abi, - functionName: 'setDataBatch', - args: [encodedData.keys, encodedData.values], + functionName: 'setData', + args: [encodedData.keys[0], encodedData.values[0]], }); ``` @@ -378,6 +375,7 @@ import UniversalProfileArtifact from '@lukso/lsp-smart-contracts/artifacts/Unive const LIQUID_STAKING_CONTROLLER = '0xYourLiquidStakingControllerAddress'; const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; +const SLYX_TOKEN = '0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d'; // Restrict to transferStake(address,uint256,bytes) — transferStake does NOT send LYX const transferStakeEntry = @@ -397,7 +395,7 @@ const universalProfile = new ethers.Contract( UniversalProfileArtifact.abi, signer, ); -await universalProfile.setDataBatch(encodedData.keys, encodedData.values); +await universalProfile.setData(encodedData.keys[0], encodedData.values[0]); ``` @@ -411,7 +409,7 @@ import {IERC725Y} from "@erc725/smart-contracts/contracts/interfaces/IERC725Y.so contract SetLiquidStakingAllowedCalls { address constant STAKING_VAULT = 0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04; - address constant SLYX_TOKEN = 0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d; + address constant SLYX_TOKEN = 0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d; function restrictLiquidStakingController( address universalProfile, @@ -421,9 +419,9 @@ contract SetLiquidStakingAllowedCalls { // transferStake does NOT send LYX — CALL type (0x00000002) is correct bytes memory transferStakeEntry = abi.encodePacked( bytes4(0x00000002), // CALL type — transferStake does not send LYX - STAKING_VAULT, // target: Stakingverse vault only - bytes4(0xffffffff), // any ERC165 standard - bytes4(0xf2f1042f) // transferStake(address,uint256,bytes) + STAKING_VAULT, // target: Stakingverse vault only + bytes4(0xffffffff), // any ERC165 standard + bytes4(0xf2f1042f) // transferStake(address,uint256,bytes) ); bytes memory compactEncoded = abi.encodePacked(uint16(32), transferStakeEntry); @@ -527,9 +525,9 @@ address constant COLD_WALLET = address(0); // TODO: replace with your cold walle // Sending native LYX uses TRANSFERVALUE bit flag, not CALL bytes memory allowedCallEntry = abi.encodePacked( bytes4(0x00000001), // TRANSFERVALUE type — native LYX transfer - COLD_WALLET, // target address (20 bytes) - bytes4(0xffffffff), // any interface standard - bytes4(0xffffffff) // any function selector + COLD_WALLET, // target address (20 bytes) + bytes4(0xffffffff), // any interface standard + bytes4(0xffffffff) // any function selector ); bytes memory compactEncoded = abi.encodePacked( @@ -624,7 +622,7 @@ bytes4 constant LSP7_TRANSFER_SELECTOR = 0x760d9bba; // transfer(address,address // CALL type + any address + LSP7 standard + transfer() selector bytes memory allowedCallEntry = abi.encodePacked( - bytes4(0x00000002), // CALL type + bytes4(0x00000002), // CALL type address(0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF), // any address INTERFACE_ID_LSP7, // only LSP7-compliant contracts LSP7_TRANSFER_SELECTOR // transfer(address,address,uint256,bool,bytes) @@ -727,8 +725,8 @@ address constant NFT_CONTRACT = address(0); // TODO: replace with your LSP8 NFT // CALL type + specific NFT contract + LSP8 standard + setDataForTokenId() selector bytes memory allowedCallEntry = abi.encodePacked( bytes4(0x00000002), // CALL type - NFT_CONTRACT, // specific NFT contract (20 bytes) - INTERFACE_ID_LSP8, // only LSP8-compliant contracts + NFT_CONTRACT, // specific NFT contract (20 bytes) + INTERFACE_ID_LSP8, // only LSP8-compliant contracts SET_DATA_FOR_TOKEN_ID_SELECTOR // setDataForTokenId(bytes32,bytes32,bytes) ); @@ -743,6 +741,132 @@ bytes memory compactEncoded = abi.encodePacked( --- +## Example 5: Restrict a controller to UP data writes only + +**Context:** You want a controller that may update your Universal Profile metadata and permissions, but must not be able to execute arbitrary external calls. Restrict it to the ERC725Y write functions on the Universal Profile itself. + + + + +```ts +// See Example 1 for full viem setup (walletClient, myUPAddress, UniversalProfileArtifact) +import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; + +const METADATA_CONTROLLER = '0xYourMetadataControllerAddress'; +const MY_UP_ADDRESS = myUPAddress; + +const setDataEntry = + `0x00000002` + MY_UP_ADDRESS.slice(2) + `714df77c` + `7f23690c`; + +const setDataBatchEntry = + `0x00000002` + MY_UP_ADDRESS.slice(2) + `714df77c` + `97902421`; + +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [METADATA_CONTROLLER], + value: [setDataEntry, setDataBatchEntry], + }, +]); + +await walletClient.writeContract({ + address: myUPAddress, + abi: UniversalProfileArtifact.abi, + functionName: 'setData', + args: [encodedAllowedCalls.keys[0], encodedAllowedCalls.values[0]], +}); +``` + + + + +```ts +// See Example 1 for full ethers setup (universalProfile, myUPAddress, signer) +import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; + +const METADATA_CONTROLLER = '0xYourMetadataControllerAddress'; +const MY_UP_ADDRESS = myUPAddress; + +const setDataEntry = + `0x00000002` + MY_UP_ADDRESS.slice(2) + `714df77c` + `7f23690c`; + +const setDataBatchEntry = + `0x00000002` + MY_UP_ADDRESS.slice(2) + `714df77c` + `97902421`; + +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [METADATA_CONTROLLER], + value: [setDataEntry, setDataBatchEntry], + }, +]); + +await universalProfile.setData( + encodedAllowedCalls.keys[0], + encodedAllowedCalls.values[0], +); +``` + + + + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import {IERC725Y} from "@erc725/smart-contracts/contracts/interfaces/IERC725Y.sol"; + +contract RestrictMetadataController { + bytes4 constant INTERFACE_ID_ERC725Y = 0x714df77c; + bytes4 constant SET_DATA_SELECTOR = 0x7f23690c; + bytes4 constant SET_DATA_BATCH_SELECTOR = 0x97902421; + + function restrictControllerToMetadataWrites( + address universalProfile, + address metadataController + ) external { + bytes32 allowedCallsKey = bytes32( + abi.encodePacked( + bytes12(0x4b80742de2bf393a64c70000), + metadataController + ) + ); + + bytes memory setDataEntry = abi.encodePacked( + bytes4(0x00000002), // CALL + universalProfile, // only this UP + INTERFACE_ID_ERC725Y, // must support ERC725Y + SET_DATA_SELECTOR // setData(bytes32,bytes) + ); + + bytes memory setDataBatchEntry = abi.encodePacked( + bytes4(0x00000002), // CALL + universalProfile, // only this UP + INTERFACE_ID_ERC725Y, // must support ERC725Y + SET_DATA_BATCH_SELECTOR // setDataBatch(bytes32[],bytes[]) + ); + + bytes memory compactEncoded = abi.encodePacked( + uint16(setDataEntry.length), + setDataEntry, + uint16(setDataBatchEntry.length), + setDataBatchEntry + ); + + IERC725Y(universalProfile).setData(allowedCallsKey, compactEncoded); + } +} +``` + + + + +This pattern is safer than granting access to `execute(...)` or `executeBatch(...)` on the Universal Profile. If you allow `execute`, a controller can forward arbitrary calls to other contracts through the UP. + +--- + ## Common Mistakes :::warning SUPER_CALL bypasses Allowed Calls diff --git a/src/components/AllowedCallsReference/index.tsx b/src/components/AllowedCallsReference/index.tsx index 75b9679858..d8b12985cc 100644 --- a/src/components/AllowedCallsReference/index.tsx +++ b/src/components/AllowedCallsReference/index.tsx @@ -30,43 +30,171 @@ const categories: Category[] = [ { title: 'LSP7 (Fungible Tokens)', items: [ - { useCase: 'Transfer tokens', func: 'transfer(address,address,uint256,bool,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x760d9bba' }, - { useCase: 'Authorize operator', func: 'authorizeOperator(address,uint256,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0xb49506fd' }, - { useCase: 'Revoke operator', func: 'revokeOperator(address,bool,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x4521748e' }, - { useCase: 'Mint tokens', func: 'mint(address,uint256,bool,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x7580d920' }, - { useCase: 'Burn tokens', func: 'burn(address,uint256,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x44d17187' }, + { + useCase: 'Transfer tokens', + func: 'transfer(address,address,uint256,bool,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x760d9bba', + }, + { + useCase: 'Authorize operator', + func: 'authorizeOperator(address,uint256,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0xb49506fd', + }, + { + useCase: 'Revoke operator', + func: 'revokeOperator(address,bool,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x4521748e', + }, + { + useCase: 'Mint tokens', + func: 'mint(address,uint256,bool,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x7580d920', + }, + { + useCase: 'Burn tokens', + func: 'burn(address,uint256,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x44d17187', + }, ], - gotcha: 'LSP7 `transfer` does NOT send LYX. Use `CALL` only (`0x00000002`), not `TRANSFERVALUE|CALL`. If you add `TRANSFERVALUE`, you\'re granting unnecessary LYX transfer rights.' + gotcha: + "LSP7 `transfer` does NOT send LYX. Use `CALL` only (`0x00000002`), not `TRANSFERVALUE|CALL`. If you add `TRANSFERVALUE`, you're granting unnecessary LYX transfer rights.", }, { title: 'LSP8 (NFTs / Identifiable Digital Assets)', items: [ - { useCase: 'Transfer NFT', func: 'transfer(address,address,bytes32,bool,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x511b6952' }, - { useCase: 'Update token metadata', func: 'setDataForTokenId(bytes32,bytes32,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0xd6c1407c' }, - { useCase: 'Mint NFT', func: 'mint(address,bytes32,bool,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0xaf255b61' }, - { useCase: 'Burn NFT', func: 'burn(bytes32,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x6c79b70b' }, + { + useCase: 'Transfer NFT', + func: 'transfer(address,address,bytes32,bool,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x511b6952', + }, + { + useCase: 'Update NFT tokenId metadata', + func: 'setDataForTokenId(bytes32,bytes32,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0xd6c1407c', + }, + { + useCase: 'Mint NFT', + func: 'mint(address,bytes32,bool,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0xaf255b61', + }, + { + useCase: 'Burn NFT', + func: 'burn(bytes32,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x6c79b70b', + }, ], - gotcha: 'The `bytes32` tokenId in LSP8 `transfer` is the token identifier — NOT an ERC721-style `uint256`. Make sure your AllowedCalls entry targets the right contract address alongside the selector.' + gotcha: + 'The `bytes32` tokenId in LSP8 `transfer` is the token identifier — NOT an ERC721-style `uint256`. Make sure your AllowedCalls entry targets the right contract address alongside the selector.', }, { - title: 'ERC725X / ERC725Y (Universal Profile core)', + title: 'Universal Profile', items: [ - { useCase: 'Execute external call', func: 'execute(uint256,address,uint256,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x44c028fe' }, - { useCase: 'Set single data key', func: 'setData(bytes32,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x7f23690c' }, - { useCase: 'Set multiple data keys', func: 'setDataBatch(bytes32[],bytes[])', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x97902421' }, + { + useCase: 'Execute external call', + func: 'execute(uint256,address,uint256,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x44c028fe', + }, + { + useCase: 'Batch execute external calls', + func: 'executeBatch(uint256[],address[],uint256[],bytes[])', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x31858452', + }, + { + useCase: 'Set single data key', + func: 'setData(bytes32,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x7f23690c', + }, + { + useCase: 'Set multiple data keys', + func: 'setDataBatch(bytes32[],bytes[])', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x97902421', + }, ], - gotcha: 'Allowing `execute` on a UP gives the controller the ability to call ANY external contract on behalf of the UP — combine with `AllowedCalls` to restrict which contracts and functions it can call.' + gotcha: + 'Allowing `execute` on a UP gives the controller the ability to call external contract on behalf of the UP — the `AllowedCalls` list must be configured to specify which EOA / contract addresses + functions can be called.', }, { - title: 'Stakingverse (LYX Liquid Staking)', + title: 'Token Metadata', items: [ - { useCase: 'Deposit LYX', func: 'deposit(address)', callTypeLabel: 'TRANSFERVALUE|CALL', callType: '0x00000003', selector: '0xf340fa01' }, - { useCase: 'Withdraw stake', func: 'withdraw(uint256,address)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0x00f714ce' }, - { useCase: 'Claim rewards', func: 'claim(uint256,address)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0xddd5e1b2' }, - { useCase: 'Transfer staked position', func: 'transferStake(address,uint256,bytes)', callTypeLabel: 'CALL', callType: '0x00000002', selector: '0xf2f1042f' }, + { + useCase: 'Update LSP7 token metadata', + func: 'setData(bytes32,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x7f23690c', + }, + { + useCase: 'Update LSP8 collection metadata', + func: 'setDataBatch(bytes32[],bytes[])', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x97902421', + }, ], - gotcha: '`deposit(address)` sends actual LYX to the vault — it MUST use `TRANSFERVALUE|CALL` (`0x00000003`), not just `CALL`. This is the #1 mistake when building staking controllers. All other Stakingverse functions are non-payable — use `CALL` only.' - } + gotcha: + 'These functions update metadata on the token/collection contract itself — not on a Universal Profile. Ensure the controller has permission on the token contract, not just on a UP.', + }, + { + title: 'Stakingverse (sLYX Liquid Staking)', + items: [ + { + useCase: 'Deposit LYX', + func: 'deposit(address)', + callTypeLabel: 'TRANSFERVALUE + CALL', + callType: '0x00000003', + selector: '0xf340fa01', + }, + { + useCase: 'Request withdrawal', + func: 'withdraw(uint256,address)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0x00f714ce', + }, + { + useCase: 'Claim withdrawal', + func: 'claim(uint256,address)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0xddd5e1b2', + }, + { + useCase: 'Transfer stake to sLYX recipient', + func: 'transferStake(address,uint256,bytes)', + callTypeLabel: 'CALL', + callType: '0x00000002', + selector: '0xf2f1042f', + }, + ], + gotcha: + '`deposit(address)` sends actual LYX to the vault — it MUST use `TRANSFERVALUE + CALL` (`0x00000003`), not just `CALL`. This is the #1 mistake when building controllers for staking. All other Stakingverse functions are non-payable — use `CALL` only.', + }, ]; const CopyButton = ({ text }: { text: string }) => { @@ -81,7 +209,11 @@ const CopyButton = ({ text }: { text: string }) => { return ( - {copied ? : } + {copied ? ( + + ) : ( + + )} ); @@ -90,38 +222,101 @@ const CopyButton = ({ text }: { text: string }) => { export default function AllowedCallsReference() { const [expanded, setExpanded] = useState('panel0'); - const handleChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => { - setExpanded(isExpanded ? panel : false); - }; + const handleChange = + (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => { + setExpanded(isExpanded ? panel : false); + }; return ( {categories.map((cat, index) => { const panelId = `panel${index}`; return ( - + }> {cat.title} - +
- - - - + + + + {cat.items.map((item, i) => ( - - + - - @@ -149,8 +356,21 @@ export default function AllowedCallsReference() {
Use caseFunctionCall typeSelector + Use case + + Function + + Call type + + Selector +
{item.useCase} + + {item.useCase} + {item.func} + + {item.selector}
- - $1') }} /> + + $1'), + }} + />
From 5e03a880e2e18fd9bcc0cba97deeadfa9f732e83 Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 16 Mar 2026 21:28:44 +0000 Subject: [PATCH 10/15] fix(key-manager): correct ERC725Y interfaceId (0x714df77c -> 0x629aa694) --- .../key-manager/restrict-controller-actions.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md index 24b1661854..4c357ad41d 100644 --- a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -757,10 +757,10 @@ const METADATA_CONTROLLER = '0xYourMetadataControllerAddress'; const MY_UP_ADDRESS = myUPAddress; const setDataEntry = - `0x00000002` + MY_UP_ADDRESS.slice(2) + `714df77c` + `7f23690c`; + `0x00000002` + MY_UP_ADDRESS.slice(2) + `629aa694` + `7f23690c`; const setDataBatchEntry = - `0x00000002` + MY_UP_ADDRESS.slice(2) + `714df77c` + `97902421`; + `0x00000002` + MY_UP_ADDRESS.slice(2) + `629aa694` + `97902421`; const encodedAllowedCalls = erc725.encodeData([ { @@ -790,10 +790,10 @@ const METADATA_CONTROLLER = '0xYourMetadataControllerAddress'; const MY_UP_ADDRESS = myUPAddress; const setDataEntry = - `0x00000002` + MY_UP_ADDRESS.slice(2) + `714df77c` + `7f23690c`; + `0x00000002` + MY_UP_ADDRESS.slice(2) + `629aa694` + `7f23690c`; const setDataBatchEntry = - `0x00000002` + MY_UP_ADDRESS.slice(2) + `714df77c` + `97902421`; + `0x00000002` + MY_UP_ADDRESS.slice(2) + `629aa694` + `97902421`; const encodedAllowedCalls = erc725.encodeData([ { @@ -819,7 +819,7 @@ pragma solidity ^0.8.17; import {IERC725Y} from "@erc725/smart-contracts/contracts/interfaces/IERC725Y.sol"; contract RestrictMetadataController { - bytes4 constant INTERFACE_ID_ERC725Y = 0x714df77c; + bytes4 constant INTERFACE_ID_ERC725Y = 0x629aa694; bytes4 constant SET_DATA_SELECTOR = 0x7f23690c; bytes4 constant SET_DATA_BATCH_SELECTOR = 0x97902421; From 42cd247d8168e24838f7d7d2cad10d5d8ef2247e Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 16 Mar 2026 22:07:45 +0000 Subject: [PATCH 11/15] =?UTF-8?q?fix(key-manager):=20fix=20AllowedCalls=20?= =?UTF-8?q?encoding=20=E2=80=94=20use=20tuple=20format=20for=20erc725.js?= =?UTF-8?q?=20encodeData?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../restrict-controller-actions.md | 132 ++++++------------ 1 file changed, 43 insertions(+), 89 deletions(-) diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md index 4c357ad41d..316da43a6d 100644 --- a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -20,7 +20,7 @@ Full code examples are available in the 👾 [lukso-playground](https://github.c ::: -## What you will learn +## Examples you will learn - How to restrict an automated staking bot to only call `deposit(address)` on a specific vault contract - How to lock a controller so it can only send LYX to one specific address @@ -79,14 +79,11 @@ const STAKING_BOT_ADDRESS = '0xYourBotAddress'; // replace with your bot address const erc725 = new ERC725(LSP6Schema); // TRANSFERVALUE|CALL type (0x00000003) — deposit(address) sends LYX to the vault -const depositEntry = - `0x00000003` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; // deposit(address) - const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [STAKING_BOT_ADDRESS], - value: [depositEntry], + value: [['0x00000003', STAKING_VAULT, '0xffffffff', '0xf340fa01']], // deposit(address) }, ]); @@ -123,14 +120,11 @@ const STAKING_BOT_ADDRESS = '0xYourBotAddress'; // replace with your bot address const erc725 = new ERC725(LSP6Schema); // TRANSFERVALUE|CALL type (0x00000003) — deposit(address) sends LYX to the vault -const depositEntry = - `0x00000003` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; // deposit(address) - const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [STAKING_BOT_ADDRESS], - value: [depositEntry], + value: [['0x00000003', STAKING_VAULT, '0xffffffff', '0xf340fa01']], // deposit(address) }, ]); @@ -218,27 +212,21 @@ const WITHDRAWAL_BOT = '0xYourWithdrawalBotAddress'; const erc725 = new ERC725(LSP6Schema); // Staking controller: deposit(address) — sends LYX, so TRANSFERVALUE|CALL (0x00000003) -const depositEntry = - `0x00000003` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; - // Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) // These do NOT send LYX, so CALL (0x00000002) is correct -const requestWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `00f714ce`; - -const claimWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `ddd5e1b2`; - const encodedData = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [STAKING_BOT], - value: [depositEntry], + value: [['0x00000003', STAKING_VAULT, '0xffffffff', '0xf340fa01']], // deposit(address) }, { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [WITHDRAWAL_BOT], - value: [requestWithdrawalEntry, claimWithdrawalEntry], + value: [ + ['0x00000002', STAKING_VAULT, '0xffffffff', '0x00f714ce'], // withdraw(uint256,address) + ['0x00000002', STAKING_VAULT, '0xffffffff', '0xddd5e1b2'], // claim(uint256,address) + ], }, ]); @@ -266,27 +254,21 @@ const WITHDRAWAL_BOT = '0xYourWithdrawalBotAddress'; const erc725 = new ERC725(LSP6Schema); // Staking controller: deposit(address) — sends LYX, so TRANSFERVALUE|CALL (0x00000003) -const depositEntry = - `0x00000003` + STAKING_VAULT.slice(2) + `ffffffff` + `f340fa01`; - // Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) // These do NOT send LYX, so CALL (0x00000002) is correct -const requestWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `00f714ce`; - -const claimWithdrawalEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `ddd5e1b2`; - const encodedData = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [STAKING_BOT], - value: [depositEntry], + value: [['0x00000003', STAKING_VAULT, '0xffffffff', '0xf340fa01']], // deposit(address) }, { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [WITHDRAWAL_BOT], - value: [requestWithdrawalEntry, claimWithdrawalEntry], + value: [ + ['0x00000002', STAKING_VAULT, '0xffffffff', '0x00f714ce'], // withdraw(uint256,address) + ['0x00000002', STAKING_VAULT, '0xffffffff', '0xddd5e1b2'], // claim(uint256,address) + ], }, ]); @@ -343,15 +325,12 @@ const SLYX_TOKEN = '0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d'; // Restrict to transferStake(address,uint256,bytes) only on the Stakingverse vault // transferStake does NOT send LYX — CALL type (0x00000002) is correct -const transferStakeEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `f2f1042f`; - const erc725 = new ERC725(LSP6Schema); const encodedData = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [LIQUID_STAKING_CONTROLLER], - value: [transferStakeEntry], + value: [['0x00000002', STAKING_VAULT, '0xffffffff', '0xf2f1042f']], // transferStake(address,uint256,bytes) }, ]); @@ -378,15 +357,12 @@ const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; const SLYX_TOKEN = '0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d'; // Restrict to transferStake(address,uint256,bytes) — transferStake does NOT send LYX -const transferStakeEntry = - `0x00000002` + STAKING_VAULT.slice(2) + `ffffffff` + `f2f1042f`; - const erc725 = new ERC725(LSP6Schema); const encodedData = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [LIQUID_STAKING_CONTROLLER], - value: [transferStakeEntry], + value: [['0x00000002', STAKING_VAULT, '0xffffffff', '0xf2f1042f']], // transferStake(address,uint256,bytes) }, ]); @@ -465,15 +441,12 @@ const CONTROLLER_ADDRESS = '0xYourPayrollController'; const COLD_WALLET = '0xYourColdWalletAddress'; // the only permitted recipient // TRANSFERVALUE type (0x00000001) — sending native LYX uses TRANSFERVALUE, not CALL -const coldWalletEntry = - `0x00000001` + COLD_WALLET.slice(2) + `ffffffff` + `ffffffff`; - const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [CONTROLLER_ADDRESS], - value: [coldWalletEntry], + value: [['0x00000001', COLD_WALLET, '0xffffffff', '0xffffffff']], // send LYX to cold wallet }, ]); @@ -497,15 +470,12 @@ const CONTROLLER_ADDRESS = '0xYourPayrollController'; const COLD_WALLET = '0xYourColdWalletAddress'; // the only permitted recipient // TRANSFERVALUE type (0x00000001) — sending native LYX uses TRANSFERVALUE, not CALL -const coldWalletEntry = - `0x00000001` + COLD_WALLET.slice(2) + `ffffffff` + `ffffffff`; - const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [CONTROLLER_ADDRESS], - value: [coldWalletEntry], + value: [['0x00000001', COLD_WALLET, '0xffffffff', '0xffffffff']], // send LYX to cold wallet }, ]); @@ -557,18 +527,19 @@ const DEFI_BOT = '0xYourDeFiBotAddress'; // CALL type + any address + LSP7 interface ID + transfer() selector // transfer(address,address,uint256,bool,bytes) = 0x760d9bba -const lsp7TransferEntry = - `0x00000002` + - `ffffffffffffffffffffffffffffffffffffffff` + // any address - `c52d6008` + // INTERFACE_ID_LSP7 - `760d9bba`; // transfer(address,address,uint256,bool,bytes) - const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [DEFI_BOT], - value: [lsp7TransferEntry], + value: [ + [ + '0x00000002', + '0xffffffffffffffffffffffffffffffffffffffff', + '0xc52d6008', + '0x760d9bba', + ], + ], // transfer(address,address,uint256,bool,bytes) }, ]); @@ -592,18 +563,19 @@ const DEFI_BOT = '0xYourDeFiBotAddress'; // CALL type + any address + LSP7 interface ID + transfer() selector // transfer(address,address,uint256,bool,bytes) = 0x760d9bba -const lsp7TransferEntry = - `0x00000002` + - `ffffffffffffffffffffffffffffffffffffffff` + // any address - `c52d6008` + // INTERFACE_ID_LSP7 - `760d9bba`; // transfer(address,address,uint256,bool,bytes) - const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [DEFI_BOT], - value: [lsp7TransferEntry], + value: [ + [ + '0x00000002', + '0xffffffffffffffffffffffffffffffffffffffff', + '0xc52d6008', + '0x760d9bba', + ], + ], // transfer(address,address,uint256,bool,bytes) }, ]); @@ -656,18 +628,12 @@ const NFT_CONTRACT = '0xYourLSP8ContractAddress'; // your specific NFT contract // CALL type + NFT contract + LSP8 interface ID + setDataForTokenId() selector // setDataForTokenId(bytes32,bytes32,bytes) = 0xd6c1407c -const nftMetadataEntry = - `0x00000002` + - NFT_CONTRACT.slice(2) + - `3a271706` + // INTERFACE_ID_LSP8 - `d6c1407c`; // setDataForTokenId(bytes32,bytes32,bytes) - const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [MARKETING_MANAGER], - value: [nftMetadataEntry], + value: [['0x00000002', NFT_CONTRACT, '0x3a271706', '0xd6c1407c']], // setDataForTokenId(bytes32,bytes32,bytes) }, ]); @@ -692,18 +658,12 @@ const NFT_CONTRACT = '0xYourLSP8ContractAddress'; // your specific NFT contract // CALL type + NFT contract + LSP8 interface ID + setDataForTokenId() selector // setDataForTokenId(bytes32,bytes32,bytes) = 0xd6c1407c -const nftMetadataEntry = - `0x00000002` + - NFT_CONTRACT.slice(2) + - `3a271706` + // INTERFACE_ID_LSP8 - `d6c1407c`; // setDataForTokenId(bytes32,bytes32,bytes) - const erc725 = new ERC725(LSP6Schema); const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [MARKETING_MANAGER], - value: [nftMetadataEntry], + value: [['0x00000002', NFT_CONTRACT, '0x3a271706', '0xd6c1407c']], // setDataForTokenId(bytes32,bytes32,bytes) }, ]); @@ -756,17 +716,14 @@ import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; const METADATA_CONTROLLER = '0xYourMetadataControllerAddress'; const MY_UP_ADDRESS = myUPAddress; -const setDataEntry = - `0x00000002` + MY_UP_ADDRESS.slice(2) + `629aa694` + `7f23690c`; - -const setDataBatchEntry = - `0x00000002` + MY_UP_ADDRESS.slice(2) + `629aa694` + `97902421`; - const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [METADATA_CONTROLLER], - value: [setDataEntry, setDataBatchEntry], + value: [ + ['0x00000002', MY_UP_ADDRESS, '0x629aa694', '0x7f23690c'], + ['0x00000002', MY_UP_ADDRESS, '0x629aa694', '0x97902421'], + ], }, ]); @@ -789,17 +746,14 @@ import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; const METADATA_CONTROLLER = '0xYourMetadataControllerAddress'; const MY_UP_ADDRESS = myUPAddress; -const setDataEntry = - `0x00000002` + MY_UP_ADDRESS.slice(2) + `629aa694` + `7f23690c`; - -const setDataBatchEntry = - `0x00000002` + MY_UP_ADDRESS.slice(2) + `629aa694` + `97902421`; - const encodedAllowedCalls = erc725.encodeData([ { keyName: 'AddressPermissions:AllowedCalls:
', dynamicKeyParts: [METADATA_CONTROLLER], - value: [setDataEntry, setDataBatchEntry], + value: [ + ['0x00000002', MY_UP_ADDRESS, '0x629aa694', '0x7f23690c'], + ['0x00000002', MY_UP_ADDRESS, '0x629aa694', '0x97902421'], + ], }, ]); From 55ad40ea11a83008dcc2cea87fe9216b86e0f72b Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Tue, 17 Mar 2026 06:28:59 +0000 Subject: [PATCH 12/15] feat(key-manager): add interactive AllowedCallsBuilder encoder component --- .../restrict-controller-actions.md | 9 + src/components/AllowedCallsBuilder/index.tsx | 571 ++++++++++++++++++ 2 files changed, 580 insertions(+) create mode 100644 src/components/AllowedCallsBuilder/index.tsx diff --git a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md index 316da43a6d..10823bf1c2 100644 --- a/docs/learn/universal-profile/key-manager/restrict-controller-actions.md +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -4,6 +4,7 @@ sidebar_position: 3 description: Learn how to restrict a controller to only specific contracts, functions, or token standards using LSP6 Key Manager Allowed Calls on LUKSO. --- +import AllowedCallsBuilder from '@site/src/components/AllowedCallsBuilder'; import AllowedCallsReference from '@site/src/components/AllowedCallsReference'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -53,6 +54,14 @@ So a single 32-byte entry looks like: When multiple entries are present they are encoded as a `CompactBytesArray` — erc725.js handles this automatically. +## Interactive builder + +Use this builder to assemble one or more Allowed Calls entries and preview the final `CompactBytesArray` value before storing it on your Universal Profile. + + + +## Common selectors and packed values reference + --- diff --git a/src/components/AllowedCallsBuilder/index.tsx b/src/components/AllowedCallsBuilder/index.tsx new file mode 100644 index 0000000000..126f314448 --- /dev/null +++ b/src/components/AllowedCallsBuilder/index.tsx @@ -0,0 +1,571 @@ +import React, { useMemo, useState } from 'react'; +import CodeBlock from '@theme/CodeBlock'; +import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import IconButton from '@mui/material/IconButton'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Paper from '@mui/material/Paper'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import AddIcon from '@mui/icons-material/Add'; +import CheckIcon from '@mui/icons-material/Check'; + +type CallTypeOption = { + label: string; + value: string; + description: string; +}; + +type TemplateItem = { + useCase: string; + func: string; + callType: string; + address: string; + standard: string; + selector: string; +}; + +type TemplateCategory = { + title: string; + items: TemplateItem[]; +}; + +type AllowedCallEntry = { + id: number; + name: string; + callType: string; + address: string; + standard: string; + selector: string; +}; + +const callTypeOptions: CallTypeOption[] = [ + { + label: 'TRANSFERVALUE', + value: '0x00000001', + description: 'Send LYX only', + }, + { + label: 'CALL', + value: '0x00000002', + description: 'Regular contract call', + }, + { + label: 'TRANSFERVALUE + CALL', + value: '0x00000003', + description: 'Send LYX and call a payable function', + }, + { + label: 'STATICCALL', + value: '0x00000004', + description: 'Read-only external call', + }, + { + label: 'DELEGATECALL', + value: '0x00000008', + description: 'Delegate execution context', + }, +]; + +const ANY_ADDRESS = '0xffffffffffffffffffffffffffffffffffffffff'; +const ANY_STANDARD = '0xffffffff'; +const ANY_SELECTOR = '0xffffffff'; + +const templateCategories: TemplateCategory[] = [ + { + title: 'Common LSP token actions', + items: [ + { + useCase: 'LSP7 transfer', + func: 'transfer(address,address,uint256,bool,bytes)', + callType: '0x00000002', + address: ANY_ADDRESS, + standard: '0xe33f65c3', + selector: '0x760d9bba', + }, + { + useCase: 'LSP8 transfer', + func: 'transfer(address,address,bytes32,bool,bytes)', + callType: '0x00000002', + address: ANY_ADDRESS, + standard: '0x49399145', + selector: '0x511b6952', + }, + { + useCase: 'UP setData', + func: 'setData(bytes32,bytes)', + callType: '0x00000002', + address: ANY_ADDRESS, + standard: '0x629aa694', + selector: '0x7f23690c', + }, + ], + }, + { + title: 'Stakingverse examples', + items: [ + { + useCase: 'Deposit LYX', + func: 'deposit(address)', + callType: '0x00000003', + address: '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04', + standard: ANY_STANDARD, + selector: '0xf340fa01', + }, + { + useCase: 'Request withdrawal', + func: 'withdraw(uint256,address)', + callType: '0x00000002', + address: '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04', + standard: ANY_STANDARD, + selector: '0x00f714ce', + }, + { + useCase: 'Claim withdrawal', + func: 'claim(uint256,address)', + callType: '0x00000002', + address: '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04', + standard: ANY_STANDARD, + selector: '0xddd5e1b2', + }, + ], + }, +]; + +const createDefaultEntry = (): AllowedCallEntry => ({ + id: Date.now(), + name: 'Entry 1', + callType: '0x00000002', + address: ANY_ADDRESS, + standard: ANY_STANDARD, + selector: ANY_SELECTOR, +}); + +const isHexOfBytes = (value: string, bytes: number) => + /^0x[0-9a-fA-F]+$/.test(value) && value.length === 2 + bytes * 2; + +const getEntryError = (entry: AllowedCallEntry) => { + if (!isHexOfBytes(entry.callType, 4)) return 'Call type must be 4 bytes'; + if (!isHexOfBytes(entry.address, 20)) return 'Address must be 20 bytes'; + if (!isHexOfBytes(entry.standard, 4)) return 'Standard must be 4 bytes'; + if (!isHexOfBytes(entry.selector, 4)) + return 'Function selector must be 4 bytes'; + return null; +}; + +const encodeEntry = (entry: AllowedCallEntry) => { + const error = getEntryError(entry); + if (error) return null; + + return `0x${entry.callType.slice(2)}${entry.address.slice(2)}${entry.standard.slice(2)}${entry.selector.slice(2)}`.toLowerCase(); +}; + +const encodeCompactBytesArray = (entries: AllowedCallEntry[]) => { + const encodedEntries = entries.map(encodeEntry); + if (encodedEntries.some((value) => value === null)) return null; + + return `0x${encodedEntries + .map((value) => `0020${value!.slice(2)}`) + .join('')}`.toLowerCase(); +}; + +const CopyButton = ({ text }: { text: string }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( + + + {copied ? ( + + ) : ( + + )} + + + ); +}; + +export default function AllowedCallsBuilder() { + const [entries, setEntries] = useState([ + createDefaultEntry(), + ]); + const [selectedTab, setSelectedTab] = useState(0); + + const compactBytesArray = useMemo( + () => encodeCompactBytesArray(entries), + [entries], + ); + + const updateEntry = ( + id: number, + field: keyof AllowedCallEntry, + value: string, + ) => { + setEntries((current) => + current.map((entry) => + entry.id === id ? { ...entry, [field]: value } : entry, + ), + ); + }; + + const addEntry = () => { + setEntries((current) => [ + ...current, + { + ...createDefaultEntry(), + id: Date.now() + current.length, + name: `Entry ${current.length + 1}`, + }, + ]); + }; + + const removeEntry = (id: number) => { + setEntries((current) => current.filter((entry) => entry.id !== id)); + }; + + const applyTemplate = (template: TemplateItem) => { + setEntries((current) => { + const first = current[0] ?? createDefaultEntry(); + return [ + { + ...first, + name: template.useCase, + callType: template.callType, + address: template.address, + standard: template.standard, + selector: template.selector, + }, + ...current.slice(1), + ]; + }); + }; + + return ( + + + + + + Allowed Calls Builder + + + Build one or more 32-byte Allowed Calls entries, preview the + packed values, and copy the final CompactBytesArray{' '} + you can store under + AddressPermissions:AllowedCalls:<address>. + + + + + Each entry is packed as{' '} + + callType (4 bytes) + address (20 bytes) + standard (4 bytes) + + selector (4 bytes) + + . The final value below is the CompactBytesArray{' '} + representation, where every 32-byte entry is prefixed by{' '} + 0x0020. + + + + setSelectedTab(value)} + variant="scrollable" + allowScrollButtonsMobile + > + + + + + + {selectedTab === 1 && ( + + {templateCategories.map((category) => ( + + + {category.title} + + + {category.items.map((item) => ( + + ))} + + + ))} + + )} + + + {entries.map((entry, index) => { + const error = getEntryError(entry); + const encodedEntry = encodeEntry(entry); + + return ( + + + + + updateEntry(entry.id, 'name', event.target.value) + } + sx={{ minWidth: { xs: '100%', sm: 220 } }} + /> + + + removeEntry(entry.id)} + disabled={entries.length === 1} + > + + + + + + + + + Call type + + + + + + updateEntry( + entry.id, + 'address', + event.target.value.trim(), + ) + } + helperText="20-byte address or 0xffff...ffff for any address" + error={!isHexOfBytes(entry.address, 20)} + /> + + + + + updateEntry( + entry.id, + 'standard', + event.target.value.trim(), + ) + } + helperText="4-byte interface ID or 0xffffffff for any standard" + error={!isHexOfBytes(entry.standard, 4)} + /> + + + updateEntry( + entry.id, + 'selector', + event.target.value.trim(), + ) + } + helperText="4-byte selector or 0xffffffff for any function" + error={!isHexOfBytes(entry.selector, 4)} + /> + + + + + + + + + {error ? ( + {error} + ) : ( + + + + Packed 32-byte entry + + + + {encodedEntry!} + + )} + + + ); + })} + + + + + + + + + + + CompactBytesArray output + {compactBytesArray && } + + {compactBytesArray ? ( + {compactBytesArray} + ) : ( + + Fix the invalid fields above to generate the final + CompactBytesArray. + + )} + + + {compactBytesArray && ( + + + Example with erc725.js + + {`import ERC725 from '@erc725/erc725.js'; +import LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; + +const erc725 = new ERC725(LSP6Schema); + +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: ['0xControllerAddress'], + value: [ +${entries + .map( + (entry) => + ` ['${entry.callType}', '${entry.address}', '${entry.standard}', '${entry.selector}'], // ${entry.name}`, + ) + .join('')} + ], + }, +]); + +console.log(encodedAllowedCalls.values[0]); +// ${compactBytesArray}`} + + )} + + + + ); +} From 4992c8e12c79ec4367779ad716911925dc941549 Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Tue, 17 Mar 2026 06:31:21 +0000 Subject: [PATCH 13/15] fix(key-manager): correct LSP8 interface ID in AllowedCallsBuilder (0x49399145 -> 0x3a271706) --- src/components/AllowedCallsBuilder/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AllowedCallsBuilder/index.tsx b/src/components/AllowedCallsBuilder/index.tsx index 126f314448..47f1917580 100644 --- a/src/components/AllowedCallsBuilder/index.tsx +++ b/src/components/AllowedCallsBuilder/index.tsx @@ -100,7 +100,7 @@ const templateCategories: TemplateCategory[] = [ func: 'transfer(address,address,bytes32,bool,bytes)', callType: '0x00000002', address: ANY_ADDRESS, - standard: '0x49399145', + standard: '0x3a271706', selector: '0x511b6952', }, { From 593305f0a8dc4159f48332cc93e2faecb190ca37 Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Tue, 17 Mar 2026 06:34:46 +0000 Subject: [PATCH 14/15] fix(key-manager): correct LSP7 standard ID (0xe33f65c3 -> 0xc52d6008), add controller address input + live ERC725Y data key computation --- src/components/AllowedCallsBuilder/index.tsx | 41 +++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/components/AllowedCallsBuilder/index.tsx b/src/components/AllowedCallsBuilder/index.tsx index 47f1917580..8087d11293 100644 --- a/src/components/AllowedCallsBuilder/index.tsx +++ b/src/components/AllowedCallsBuilder/index.tsx @@ -92,7 +92,7 @@ const templateCategories: TemplateCategory[] = [ func: 'transfer(address,address,uint256,bool,bytes)', callType: '0x00000002', address: ANY_ADDRESS, - standard: '0xe33f65c3', + standard: '0xc52d6008', selector: '0x760d9bba', }, { @@ -203,11 +203,28 @@ const CopyButton = ({ text }: { text: string }) => { ); }; +const ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; + +function computeDataKey(controllerAddress: string): string { + if (!ADDRESS_REGEX.test(controllerAddress)) return ''; + const prefix = '4b80742de2bf393a64c70000'; + const addr = controllerAddress.replace('0x', '').toLowerCase(); + return '0x' + prefix + addr; +} + export default function AllowedCallsBuilder() { const [entries, setEntries] = useState([ createDefaultEntry(), ]); const [selectedTab, setSelectedTab] = useState(0); + const [controllerAddress, setControllerAddress] = useState(''); + + const isValidController = + controllerAddress === '' || ADDRESS_REGEX.test(controllerAddress); + const dataKey = useMemo( + () => computeDataKey(controllerAddress), + [controllerAddress], + ); const compactBytesArray = useMemo( () => encodeCompactBytesArray(entries), @@ -292,6 +309,26 @@ export default function AllowedCallsBuilder() { 0x0020. + + setControllerAddress(e.target.value)} + fullWidth + size="small" + error={!isValidController} + helperText={ + !isValidController + ? 'Invalid EVM address — must be 0x followed by 40 hex characters' + : dataKey + ? `ERC725Y key: ${dataKey}` + : 'Enter the address of the controller to compute the ERC725Y data key' + } + slotProps={{ htmlInput: { spellCheck: false } }} + /> + + ', - dynamicKeyParts: ['0xControllerAddress'], + dynamicKeyParts: ['${controllerAddress || '0xYourControllerAddress'}'], value: [ ${entries .map( From cbf8f1582ee892826dd08de47477f94b3c295489 Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Tue, 17 Mar 2026 06:36:11 +0000 Subject: [PATCH 15/15] =?UTF-8?q?fix(key-manager):=20AllowedCallsBuilder?= =?UTF-8?q?=20=E2=80=94=20preset=20appends=20entry,=20TRANSFERVALUE=20warn?= =?UTF-8?q?ing,=20clipboard=20fallback,=20code=20snippet=20newlines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AllowedCallsBuilder/index.tsx | 105 ++++++++++++------- 1 file changed, 66 insertions(+), 39 deletions(-) diff --git a/src/components/AllowedCallsBuilder/index.tsx b/src/components/AllowedCallsBuilder/index.tsx index 8087d11293..41e46f4cee 100644 --- a/src/components/AllowedCallsBuilder/index.tsx +++ b/src/components/AllowedCallsBuilder/index.tsx @@ -185,7 +185,19 @@ const CopyButton = ({ text }: { text: string }) => { const [copied, setCopied] = useState(false); const handleCopy = async () => { - await navigator.clipboard.writeText(text); + try { + await navigator.clipboard.writeText(text); + } catch { + // Fallback for non-secure contexts (HTTP Docusaurus previews) + const el = document.createElement('textarea'); + el.value = text; + el.style.position = 'fixed'; + el.style.opacity = '0'; + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); + } setCopied(true); setTimeout(() => setCopied(false), 1500); }; @@ -259,20 +271,17 @@ export default function AllowedCallsBuilder() { }; const applyTemplate = (template: TemplateItem) => { - setEntries((current) => { - const first = current[0] ?? createDefaultEntry(); - return [ - { - ...first, - name: template.useCase, - callType: template.callType, - address: template.address, - standard: template.standard, - selector: template.selector, - }, - ...current.slice(1), - ]; - }); + setEntries((current) => [ + ...current, + { + ...createDefaultEntry(), + name: template.useCase, + callType: template.callType, + address: template.address, + standard: template.standard, + selector: template.selector, + }, + ]); }; return ( @@ -409,29 +418,47 @@ export default function AllowedCallsBuilder() { - - - Call type - - - + + + + Call type + + + + {(entry.callType === '0x00000001' || + entry.callType === '0x00000003') && ( + + ⚠️ Grants permission to transfer LYX — use only for + payable functions like deposit(address) + + )} + ` ['${entry.callType}', '${entry.address}', '${entry.standard}', '${entry.selector}'], // ${entry.name}`, ) - .join('')} + .join('\n')} ], }, ]);