Skip to main content

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 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​

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:

FieldSizeDescription
Call type4 bytesBit flags: TRANSFERVALUE (0x00000001), CALL (0x00000002), TRANSFERVALUE|CALL (0x00000003), STATICCALL (0x00000004), DELEGATECALL (0x00000008)
Address20 bytesTarget contract address, or 0xffffffffffffffffffffffffffffffffffffffff for any
Standard4 bytesInterface ID the target contract must support, or 0xffffffff for any
Function4 bytesFunction 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

Entry 1

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 caseFunctionCall typeSelector
Transfer tokenstransfer(address,address,uint256,bool,bytes)
CALL (0x00000002)
0x760d9bba
Authorize operatorauthorizeOperator(address,uint256,bytes)
CALL (0x00000002)
0xb49506fd
Revoke operatorrevokeOperator(address,bool,bytes)
CALL (0x00000002)
0x4521748e
Mint tokensmint(address,uint256,bool,bytes)
CALL (0x00000002)
0x7580d920
Burn tokensburn(address,uint256,bytes)
CALL (0x00000002)
0x44d17187

Use caseFunctionCall typeSelector
Transfer NFTtransfer(address,address,bytes32,bool,bytes)
CALL (0x00000002)
0x511b6952
Update NFT tokenId metadatasetDataForTokenId(bytes32,bytes32,bytes)
CALL (0x00000002)
0xd6c1407c
Mint NFTmint(address,bytes32,bool,bytes)
CALL (0x00000002)
0xaf255b61
Burn NFTburn(bytes32,bytes)
CALL (0x00000002)
0x6c79b70b

Use caseFunctionCall typeSelector
Execute external callexecute(uint256,address,uint256,bytes)
CALL (0x00000002)
0x44c028fe
Batch execute external callsexecuteBatch(uint256[],address[],uint256[],bytes[])
CALL (0x00000002)
0x31858452
Set single data keysetData(bytes32,bytes)
CALL (0x00000002)
0x7f23690c
Set multiple data keyssetDataBatch(bytes32[],bytes[])
CALL (0x00000002)
0x97902421

Use caseFunctionCall typeSelector
Update LSP7 token metadatasetData(bytes32,bytes)
CALL (0x00000002)
0x7f23690c
Update LSP8 collection metadatasetDataBatch(bytes32[],bytes[])
CALL (0x00000002)
0x97902421

Use caseFunctionCall typeSelector
Deposit LYXdeposit(address)
TRANSFERVALUE + CALL (0x00000003)
0xf340fa01
Request withdrawalwithdraw(uint256,address)
CALL (0x00000002)
0x00f714ce
Claim withdrawalclaim(uint256,address)
CALL (0x00000002)
0xddd5e1b2
Transfer stake to sLYX recipienttransferStake(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.

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

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.

// 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],
});

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

// 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]],
});
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.

// 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]],
});

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.

// 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]],
});

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.

// 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]],
});

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.

// 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]],
});

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​

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.

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.

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.

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.