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..10823bf1c2 --- /dev/null +++ b/docs/learn/universal-profile/key-manager/restrict-controller-actions.md @@ -0,0 +1,857 @@ +--- +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. +--- + +import AllowedCallsBuilder from '@site/src/components/AllowedCallsBuilder'; +import AllowedCallsReference from '@site/src/components/AllowedCallsReference'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Configure Controller Interactions + +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. + +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 + +Full code examples are available in the 👾 [lukso-playground](https://github.com/lukso-network/lukso-playground) repository. + +::: + +## 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 +- 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 (and `TRANSFERVALUE` if the controller needs to send LYX) (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 | 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: + +``` +0x 00000002 9F49a95b0c3c9e2A6c77a16C177928294c0F6F04 ffffffff f340fa01 + ^------- ^--------------------------------------- ^------- ^------- + calltype address (20 bytes) standard selector +``` + +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 + + + +--- + +## 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` 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 LSP6Schema from '@erc725/erc725.js/schemas/LSP6KeyManager.json'; +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); + +// TRANSFERVALUE|CALL type (0x00000003) — deposit(address) sends LYX to the vault +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [STAKING_BOT_ADDRESS], + value: [['0x00000003', STAKING_VAULT, '0xffffffff', '0xf340fa01']], // deposit(address) + }, +]); + +// 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: custom(window.lukso), +}); + +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); + +// TRANSFERVALUE|CALL type (0x00000003) — deposit(address) sends LYX to the vault +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [STAKING_BOT_ADDRESS], + value: [['0x00000003', STAKING_VAULT, '0xffffffff', '0xf340fa01']], // deposit(address) + }, +]); + +// 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, + 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( + bytes12(0x4b80742de2bf393a64c70000), // AddressPermissions:AllowedCalls:
full prefix + stakingBot + ) + ); + + // 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(0x00000003), // TRANSFERVALUE|CALL type — deposit sends LYX + STAKING_VAULT, // target address (20 bytes) + bytes4(0xffffffff), // any interface standard + bytes4(0xf340fa01) // deposit(address) 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 +// 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'; + +const erc725 = new ERC725(LSP6Schema); + +// Staking controller: deposit(address) — sends LYX, so TRANSFERVALUE|CALL (0x00000003) +// Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) +// These do NOT send LYX, so CALL (0x00000002) is correct +const encodedData = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [STAKING_BOT], + value: [['0x00000003', STAKING_VAULT, '0xffffffff', '0xf340fa01']], // deposit(address) + }, + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [WITHDRAWAL_BOT], + value: [ + ['0x00000002', STAKING_VAULT, '0xffffffff', '0x00f714ce'], // withdraw(uint256,address) + ['0x00000002', STAKING_VAULT, '0xffffffff', '0xddd5e1b2'], // claim(uint256,address) + ], + }, +]); + +// setDataBatch to apply both in one transaction +await walletClient.writeContract({ + address: myUPAddress, + abi: UniversalProfileArtifact.abi, + functionName: 'setDataBatch', + args: [encodedData.keys, encodedData.values], +}); +``` + + + + +```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'; + +const erc725 = new ERC725(LSP6Schema); + +// Staking controller: deposit(address) — sends LYX, so TRANSFERVALUE|CALL (0x00000003) +// Withdrawal controller: withdraw(uint256,address) and claim(uint256,address) +// These do NOT send LYX, so CALL (0x00000002) is correct +const encodedData = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [STAKING_BOT], + value: [['0x00000003', STAKING_VAULT, '0xffffffff', '0xf340fa01']], // deposit(address) + }, + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [WITHDRAWAL_BOT], + value: [ + ['0x00000002', STAKING_VAULT, '0xffffffff', '0x00f714ce'], // withdraw(uint256,address) + ['0x00000002', STAKING_VAULT, '0xffffffff', '0xddd5e1b2'], // claim(uint256,address) + ], + }, +]); + +// 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 — 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 — claim does not send LYX + STAKING_VAULT, // target address + bytes4(0xffffffff), // any standard + bytes4(0xddd5e1b2) // claim(uint256,address) +); + +// CompactBytesArray with two 32-byte entries +bytes memory compactEncoded = abi.encodePacked( + uint16(32), requestEntry, + uint16(32), claimEntry +); +``` + + + + +### 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: `0xf2f1042f` + + + + +```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 LIQUID_STAKING_CONTROLLER = '0xYourLiquidStakingControllerAddress'; +const STAKING_VAULT = '0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04'; +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 erc725 = new ERC725(LSP6Schema); +const encodedData = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [LIQUID_STAKING_CONTROLLER], + value: [['0x00000002', STAKING_VAULT, '0xffffffff', '0xf2f1042f']], // transferStake(address,uint256,bytes) + }, +]); + +await walletClient.writeContract({ + address: myUPAddress, + abi: UniversalProfileArtifact.abi, + functionName: 'setData', + args: [encodedData.keys[0], encodedData.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'; +import { ethers } from 'ethers'; +import UniversalProfileArtifact from '@lukso/lsp-smart-contracts/artifacts/UniversalProfile.json'; + +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 erc725 = new ERC725(LSP6Schema); +const encodedData = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [LIQUID_STAKING_CONTROLLER], + value: [['0x00000002', STAKING_VAULT, '0xffffffff', '0xf2f1042f']], // transferStake(address,uint256,bytes) + }, +]); + +const universalProfile = new ethers.Contract( + myUPAddress, + UniversalProfileArtifact.abi, + signer, +); +await universalProfile.setData(encodedData.keys[0], encodedData.values[0]); +``` + + + + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import {IERC725Y} from "@erc725/smart-contracts/contracts/interfaces/IERC725Y.sol"; + +contract SetLiquidStakingAllowedCalls { + address constant STAKING_VAULT = 0x9F49a95b0c3c9e2A6c77a16C177928294c0F6F04; + address constant SLYX_TOKEN = 0x8a3982f0a7d154d11a5f43eec7f50e52ebbc8f7d; + + 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); + } +} +``` + + + + +:::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 + +**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 +// 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 + +// TRANSFERVALUE type (0x00000001) — sending native LYX uses TRANSFERVALUE, not CALL +const erc725 = new ERC725(LSP6Schema); +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [CONTROLLER_ADDRESS], + value: [['0x00000001', COLD_WALLET, '0xffffffff', '0xffffffff']], // send LYX to cold wallet + }, +]); + +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 CONTROLLER_ADDRESS = '0xYourPayrollController'; +const COLD_WALLET = '0xYourColdWalletAddress'; // the only permitted recipient + +// TRANSFERVALUE type (0x00000001) — sending native LYX uses TRANSFERVALUE, not CALL +const erc725 = new ERC725(LSP6Schema); +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [CONTROLLER_ADDRESS], + value: [['0x00000001', COLD_WALLET, '0xffffffff', '0xffffffff']], // send LYX to cold wallet + }, +]); + +await universalProfile.setData( + encodedAllowedCalls.keys[0], + encodedAllowedCalls.values[0], +); +``` + + + + +```solidity +address constant COLD_WALLET = address(0); // TODO: replace with your cold wallet address + +// 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(0x00000001), // TRANSFERVALUE type — native LYX transfer + 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 +// 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 +// transfer(address,address,uint256,bool,bytes) = 0x760d9bba +const erc725 = new ERC725(LSP6Schema); +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [DEFI_BOT], + value: [ + [ + '0x00000002', + '0xffffffffffffffffffffffffffffffffffffffff', + '0xc52d6008', + '0x760d9bba', + ], + ], // transfer(address,address,uint256,bool,bytes) + }, +]); + +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 DEFI_BOT = '0xYourDeFiBotAddress'; + +// CALL type + any address + LSP7 interface ID + transfer() selector +// transfer(address,address,uint256,bool,bytes) = 0x760d9bba +const erc725 = new ERC725(LSP6Schema); +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [DEFI_BOT], + value: [ + [ + '0x00000002', + '0xffffffffffffffffffffffffffffffffffffffff', + '0xc52d6008', + '0x760d9bba', + ], + ], // transfer(address,address,uint256,bool,bytes) + }, +]); + +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 +// 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 + +// CALL type + NFT contract + LSP8 interface ID + setDataForTokenId() selector +// setDataForTokenId(bytes32,bytes32,bytes) = 0xd6c1407c +const erc725 = new ERC725(LSP6Schema); +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [MARKETING_MANAGER], + value: [['0x00000002', NFT_CONTRACT, '0x3a271706', '0xd6c1407c']], // setDataForTokenId(bytes32,bytes32,bytes) + }, +]); + +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 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 erc725 = new ERC725(LSP6Schema); +const encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [MARKETING_MANAGER], + value: [['0x00000002', NFT_CONTRACT, '0x3a271706', '0xd6c1407c']], // setDataForTokenId(bytes32,bytes32,bytes) + }, +]); + +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 = address(0); // TODO: replace with your LSP8 NFT contract address + +// 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 +); +``` + + + + +--- + +## 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 encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [METADATA_CONTROLLER], + value: [ + ['0x00000002', MY_UP_ADDRESS, '0x629aa694', '0x7f23690c'], + ['0x00000002', MY_UP_ADDRESS, '0x629aa694', '0x97902421'], + ], + }, +]); + +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 encodedAllowedCalls = erc725.encodeData([ + { + keyName: 'AddressPermissions:AllowedCalls:
', + dynamicKeyParts: [METADATA_CONTROLLER], + value: [ + ['0x00000002', MY_UP_ADDRESS, '0x629aa694', '0x7f23690c'], + ['0x00000002', MY_UP_ADDRESS, '0x629aa694', '0x97902421'], + ], + }, +]); + +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 = 0x629aa694; + 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 + +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. + +::: diff --git a/src/components/AllowedCallsBuilder/index.tsx b/src/components/AllowedCallsBuilder/index.tsx new file mode 100644 index 0000000000..41e46f4cee --- /dev/null +++ b/src/components/AllowedCallsBuilder/index.tsx @@ -0,0 +1,635 @@ +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: '0xc52d6008', + selector: '0x760d9bba', + }, + { + useCase: 'LSP8 transfer', + func: 'transfer(address,address,bytes32,bool,bytes)', + callType: '0x00000002', + address: ANY_ADDRESS, + standard: '0x3a271706', + 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 () => { + 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); + }; + + return ( + + + {copied ? ( + + ) : ( + + )} + + + ); +}; + +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), + [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) => [ + ...current, + { + ...createDefaultEntry(), + name: template.useCase, + callType: template.callType, + address: template.address, + standard: template.standard, + selector: template.selector, + }, + ]); + }; + + 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. + + + + 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 } }} + /> + + + + 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 + + + + {(entry.callType === '0x00000001' || + entry.callType === '0x00000003') && ( + + ⚠️ Grants permission to transfer LYX — use only for + payable functions like deposit(address) + + )} + + + + 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: ['${controllerAddress || '0xYourControllerAddress'}'], + value: [ +${entries + .map( + (entry) => + ` ['${entry.callType}', '${entry.address}', '${entry.standard}', '${entry.selector}'], // ${entry.name}`, + ) + .join('\n')} + ], + }, +]); + +console.log(encodedAllowedCalls.values[0]); +// ${compactBytesArray}`} + + )} + + + + ); +} diff --git a/src/components/AllowedCallsReference/index.tsx b/src/components/AllowedCallsReference/index.tsx new file mode 100644 index 0000000000..d8b12985cc --- /dev/null +++ b/src/components/AllowedCallsReference/index.tsx @@ -0,0 +1,381 @@ +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 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.', + }, + { + title: 'Universal Profile', + items: [ + { + 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 external contract on behalf of the UP — the `AllowedCalls` list must be configured to specify which EOA / contract addresses + functions can be called.', + }, + { + title: 'Token Metadata', + items: [ + { + 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: + '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 }) => { + 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 case + + Function + + Call type + + Selector +
+ {item.useCase} + + {item.func} + + + + {item.selector} + +
+
+ + $1'), + }} + /> + +
+
+ ); + })} +
+ ); +}