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
Full code examples are available in the πΎ 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)
- A controller address already granted
CALLpermission (andTRANSFERVALUEif the controller needs to send LYX) (see Grant Permissions)
How Allowed Calls workβ
Each Allowed Calls entry is a 32-byte packed value stored under the AddressPermissions:AllowedCalls:<controllerAddress> 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.
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>.
Enter the address of the controller to compute the ERC725Y data key
20-byte address or 0xffff...ffff for any address
4-byte interface ID or 0xffffffff for any standard
4-byte selector or 0xffffffff for any function
Packed 32-byte entry
0x00000002ffffffffffffffffffffffffffffffffffffffffffffffffffffffff
CompactBytesArray output
0x002000000002ffffffffffffffffffffffffffffffffffffffffffffffffffffffff
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:<address>',
dynamicKeyParts: ['0xYourControllerAddress'],
value: [
['0x00000002', '0xffffffffffffffffffffffffffffffffffffffff', '0xffffffff', '0xffffffff'], // Entry 1
],
},
]);
console.log(encodedAllowedCalls.values[0]);
// 0x002000000002ffffffffffffffffffffffffffffffffffffffffffffffffffffffff
Common selectors and packed values referenceβ
| Use case | Function | Call type | Selector |
|---|---|---|---|
| Transfer NFT | transfer(address,address,bytes32,bool,bytes) | CALL (0x00000002) | 0x511b6952 |
| Update NFT tokenId metadata | setDataForTokenId(bytes32,bytes32,bytes) | CALL (0x00000002) | 0xd6c1407c |
| Mint NFT | mint(address,bytes32,bool,bytes) | CALL (0x00000002) | 0xaf255b61 |
| Burn NFT | burn(bytes32,bytes) | CALL (0x00000002) | 0x6c79b70b |
| Use case | Function | Call type | Selector |
|---|---|---|---|
| Execute external call | execute(uint256,address,uint256,bytes) | CALL (0x00000002) | 0x44c028fe |
| Batch execute external calls | executeBatch(uint256[],address[],uint256[],bytes[]) | CALL (0x00000002) | 0x31858452 |
| Set single data key | setData(bytes32,bytes) | CALL (0x00000002) | 0x7f23690c |
| Set multiple data keys | setDataBatch(bytes32[],bytes[]) | CALL (0x00000002) | 0x97902421 |
| Use case | Function | Call type | Selector |
|---|---|---|---|
| Update LSP7 token metadata | setData(bytes32,bytes) | CALL (0x00000002) | 0x7f23690c |
| Update LSP8 collection metadata | setDataBatch(bytes32[],bytes[]) | CALL (0x00000002) | 0x97902421 |
| Use case | Function | Call type | Selector |
|---|---|---|---|
| Deposit LYX | deposit(address) | TRANSFERVALUE + CALL (0x00000003) | 0xf340fa01 |
| Request withdrawal | withdraw(uint256,address) | CALL (0x00000002) | 0x00f714ce |
| Claim withdrawal | claim(uint256,address) | CALL (0x00000002) | 0xddd5e1b2 |
| Transfer stake to sLYX recipient | transferStake(address,uint256,bytes) | CALL (0x00000002) | 0xf2f1042f |
Example 1: Staking-only controllerβ
Context: You want an automated bot to stake LYX on Stakingverse without being able to drain your UP or interact with any other contract.
The bot is granted CALL and TRANSFERVALUE permissions (see Grant Permissions), and its Allowed Calls list is restricted to a single entry: deposit(address) on the Stakingverse vault.
- viem
- ethers
- Solidity
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:<address>',
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]],
});
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:<address>',
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],
);
// 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:<controllerAddress>
bytes32 dataKey = bytes32(
abi.encodePacked(
bytes12(0x4b80742de2bf393a64c70000), // AddressPermissions:AllowedCalls:<address> 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.
- viem
- ethers
- Solidity
// 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:<address>',
dynamicKeyParts: [STAKING_BOT],
value: [['0x00000003', STAKING_VAULT, '0xffffffff', '0xf340fa01']], // deposit(address)
},
{
keyName: 'AddressPermissions:AllowedCalls:<address>',
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],
});
// 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:<address>',
dynamicKeyParts: [STAKING_BOT],
value: [['0x00000003', STAKING_VAULT, '0xffffffff', '0xf340fa01']], // deposit(address)
},
{
keyName: 'AddressPermissions:AllowedCalls:<address>',
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);
// 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 on the Stakingverse vault to convert your staked LYX into liquid sLYX tokens (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
- viem
- ethers
- Solidity
// 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:<address>',
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]],
});
// 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:<address>',
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]);
// 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<controllerAddress>
// MappingWithGrouping: bytes6 hash + bytes4 hash + bytes2(0x0000) + address
bytes32 allowedCallsKey = bytes32(
abi.encodePacked(
bytes12(0x4b80742de2bf393a64c70000), // AddressPermissions:AllowedCalls:<address> full prefix
LIQUID_STAKING_CONTROLLER
)
);
IERC725Y(universalProfile).setData(allowedCallsKey, compactEncoded);
}
}
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.
- viem
- ethers
- Solidity
// 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:<address>',
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]],
});
// 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:<address>',
dynamicKeyParts: [CONTROLLER_ADDRESS],
value: [['0x00000001', COLD_WALLET, '0xffffffff', '0xffffffff']], // send LYX to cold wallet
},
]);
await universalProfile.setData(
encodedAllowedCalls.keys[0],
encodedAllowedCalls.values[0],
);
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.
- viem
- ethers
- Solidity
// 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:<address>',
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]],
});
// 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:<address>',
dynamicKeyParts: [DEFI_BOT],
value: [
[
'0x00000002',
'0xffffffffffffffffffffffffffffffffffffffff',
'0xc52d6008',
'0x760d9bba',
],
], // transfer(address,address,uint256,bool,bytes)
},
]);
await universalProfile.setData(
encodedAllowedCalls.keys[0],
encodedAllowedCalls.values[0],
);
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.
- viem
- ethers
- Solidity
// 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:<address>',
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]],
});
// 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:<address>',
dynamicKeyParts: [MARKETING_MANAGER],
value: [['0x00000002', NFT_CONTRACT, '0x3a271706', '0xd6c1407c']], // setDataForTokenId(bytes32,bytes32,bytes)
},
]);
await universalProfile.setData(
encodedAllowedCalls.keys[0],
encodedAllowedCalls.values[0],
);
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.
- viem
- ethers
- Solidity
// 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:<address>',
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]],
});
// 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:<address>',
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],
);
// 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β
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.
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.
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.
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.