Skip to main content

Create a Token Forwarder

This guide will teach you how to create a basic custom Universal Receiver Delegate contract for the following use-case:

"As a Universal Profile (UP) owner, I want to transfer part of the tokens I received to another UP".

We will this contract an LSP1 Forwarder. Every time our πŸ†™ will receive a specific LSP7 token, this contract will automatically transfer a certain percentage to another address we have defined.

An example scenario could be: "each time I receive USDT, I want to automatically transfer 20% to my wife's UP".

Setup & Requirements​

Tips

If you want to follow this guide using not an existing token, but a new token that you want to create and deploy yourself, check our guide "Create a Custom LSP7 Token".

info

This guide is working with version above 0.14.0 of the @lukso/lsp-smart-contracts package.

In order to follow this guide, you will need the followings:

  1. Download and install the UP Browser extension.
  2. Fund the main EOA controller of your πŸ†™ (See Step 1 bullet point 3 to retrieve its address) using the Testnet Faucet.
  3. The address of the LSP7 token that you want to use to forward of portion of the amount received.
  4. The v0.14.0 @lukso/lsp-smart-contracts library installed.
  5. The erc725.js library installed to encode the data key / value to register our LSP1 Forwarder.
  6. The dotenv package to load our main EOA controller private key into our script.
npm i @lukso/[email protected] @erc725/erc725.js dotenv

Step 1 - Enable your controller to Add & Edit a Universal Receiver​

First, we will need to enable adding a Universal Receiver for the main controller of our UP. To do that:

  1. Open the UP Browser Extension in your web browser.
  2. Click on the "Controller" tab.
  3. Select "UP Extension" which.

This will bring the controller information page that you see below. From there:

  1. Scroll down to the "Administration & Ownership" part
  2. Toggle ON the "Add notifications & automation" + "Edit notifications & automation" permission.
  3. Confirm the changes and submit the transaction.

Animation to show how to enable adding and editing Universal Receiver Delegate in UP Browser Extension

Step 2 - Create LSP1 Forwarder contract in Solidity​

We can make our LSP1 Forwarder contract to perform this action in 2 different ways, via 2 different interaction flows.

Two Design Options​

To re-transfer a portion of the tokens received, we can instruct the LSP1 Forwarder contract to re-call the transfer(...) function on the LSP7 Token contract in 2 ways:

For method 1 to work, the LSP1 Forwarder contract will need the permissions SUPER_CALL + REENTRANCY on the UP.

For method 2 to work, the LSP1 Forwarder contract needs to be authorized as an operator at the LSP7 level (using authorizeOperator) with unlimited amount (type(uint256).max).

Both methods have their advantages and disadvantages, as summarized below.

Design MethodAdvantages πŸ‘πŸ»Disadvantages πŸ‘ŽπŸ»
Method 1: via πŸ†™ execute(...) functionDoes not requires additional setup (authorizeOperator operation)
Can trace the transfer from your UP transactions' activity tab
Cost a bit more gas (+/- 23k) compared to method 2 on the re-transfer transaction.
Method 2: via authorizeOperator(...) on LSP7 Token contract πŸͺ™More gas efficient.You have to authorize your URD to spend your LSP7 token for an unlimited amount

Solidity code​

Select one of the two tabs below to see the Solidity implementation of each design.

The code is commented enough to be self explanatory, but let's dive a bit more into some interesting bits.

Tips

This method leverages the Key Manager's permissions instead of token operator approval.

It is theΒ recommended method, making managing which tokens the LSP1 Forwarder can re-transfer easier.

LSP1URDForwarder.sol
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.11;

// interfaces
import { IERC725X } from "@erc725/smart-contracts/contracts/interfaces/IERC725X.sol";
import { ILSP1UniversalReceiverDelegate as ILSP1Delegate } from "@lukso/lsp-smart-contracts/contracts/LSP1UniversalReceiver/ILSP1UniversalReceiverDelegate.sol";
import { ILSP7DigitalAsset } from "@lukso/lsp-smart-contracts/contracts/LSP7DigitalAsset/ILSP7DigitalAsset.sol";

// modules
import { ERC165 } from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

// constants
import { _TYPEID_LSP7_TOKENSRECIPIENT } from "@lukso/lsp-smart-contracts/contracts/LSP7DigitalAsset/LSP7Constants.sol";
import "@lukso/lsp-smart-contracts/contracts/LSP1UniversalReceiver/LSP1Constants.sol";
import "@lukso/lsp-smart-contracts/contracts/LSP0ERC725Account/LSP0Constants.sol";

// errors
import "@lukso/lsp-smart-contracts/contracts/LSP1UniversalReceiver/LSP1Errors.sol";

contract LSP1Forwarder is ERC165, ILSP1Delegate {

// CHECK onlyOwner
modifier onlyOwner {
require(msg.sender == owner, "Not the owner");
_;
}

// Owner
address owner;

// Set a recipient
address public recipient;

// Set a percentage to send to recipient
uint256 public percentage;

// Set a mapping of authorized LSP7 tokens
mapping (address => bool) allowlist;

// we set the recipient & percentage & allowedAddresses of the deployer in the constructor for simplicity
constructor(address _recipient, uint256 _percentage, address[] memory tokenAddresses) {
require(_percentage < 100, "Percentage should be < 100");
recipient = _recipient;
percentage = _percentage;
owner = msg.sender;

for (uint256 i = 0; i < tokenAddresses.length; i++) {
allowlist[tokenAddresses[i]] = true;
}
}

function addAddress(address token) public onlyOwner {
allowlist[token] = true;
}

function setRecipient(address _recipient) public onlyOwner {
recipient = _recipient;
}

function setPercentage(uint256 _percentage) public onlyOwner {
require(_percentage < 100, "Percentage should be < 100");
percentage = _percentage;
}

function removeAddress(address token) public onlyOwner {
allowlist[token] = false;
}

function getAddressStatus(address token) public view returns (bool) {
return allowlist[token];
}

function universalReceiverDelegate(
address notifier,
uint256 value,
bytes32 typeId,
bytes memory data
) public virtual returns (bytes memory) {
// CHECK that the caller is an a UniversalProfile
// by checking it supports the LSP0ERC725Account interface
if (
!ERC165Checker.supportsERC165InterfaceUnchecked(
msg.sender,
_INTERFACEID_LSP0
)
) {
return "Caller is not a LSP0";
}

// CHECK that notifier is a contract with a `balanceOf` method
// and that msg.sender (the UP) has a positive balance
if (notifier.code.length > 0) {
try ILSP7DigitalAsset(notifier).balanceOf(msg.sender) returns (
uint256 balance
) {
if (balance == 0) {
return "LSP1: balance is zero";
}
} catch {
return "LSP1: `balanceOf(address)` function not found";
}
}

// CHECK that the address of the LSP7 is whitelisted
if (!allowlist[notifier]) {
return "Token not in allowlist";
}

// extract data (we only need the amount that was transfered / minted)
(, , , uint256 amount, ) = abi.decode(
data,
(address, address, address, uint256, bytes)
);

// CHECK that amount is not too low
if (amount < 100) {
return "Amount is too low (< 100)";
} else {
uint256 tokensToTransfer = (amount * percentage) / 100;

bytes memory tokenTransferCalldata = abi.encodeCall(
ILSP7DigitalAsset.transfer,
msg.sender,
recipient,
tokensToTransfer,
true,
""
);
IERC725X(msg.sender).execute(0, notifier, 0, tokenTransferCalldata);
return "";
}
}

function supportsInterface(
bytes4 interfaceId
) public view virtual override returns (bool) {
return
interfaceId == _INTERFACEID_LSP1_DELEGATE ||
super.supportsInterface(interfaceId);
}
}

Let's dive in some of the details of the universalReceiverDelegate(...) function. The flow works as follow:

  1. (lines 83-90) We first verify that the caller msg.sender is a Universal Profile.

  2. (lines 94-103) We checked that we are being notified by a smart contract. If we manage to call the balanceOf(address) function, we assume it is an LSP7 Token contract.

  3. (lines 107-109) We then ensure that this token is in our list of tokens to transfer some percentage of to another address.

  4. (lines 112-121) The LSP7 Token contract sent us in the notification data (function param line 79) the amount of tokens that were transferred.

    1. (line 112) We extract only this amount from the data received . The other infos in the data are not necessary so not used.
    2. (line 121) We calculate the proportion to re-transfer (local variable tokensToTransfer,) according to the percentage set (state variable line 37).
  5. (line 123-129) We encode a call to the transfer(...) function on the LSP7 Token contract, using the Solidity built-in function encodeCall. The 4 parameters being encoded for the function call are:

    • from: (msg.sender) = this UP that received the tokens.
    • to: (recipient) = the address that will receives the percentage of tokens.
    • amount: (tokensToTransfer) = the calculated percentage of the total amount received.
    • allowNonLSP1Recipient: indicates if we can transfer to any address (true), or if it must be an LSP1 enabled one (false).
    • data: no additional data
  6. (line 131) After having saved this abi-encoded calldata, we execute this call via the UP. This is done by using the execute(...) function on the πŸ†™ (a generic execution function). We know from step 1 that the msg.sender is a Universal Profile. So we can safely explicitly cast msg.sender to an ERC725X contract to use the execute(...) function. The parameters passed are:

    • operationType: 0 = CALL operation
    • target: the notifier (function parameter, line 76) = our LSP7 contract
    • value: 0 = no LYX are sent
    • data: the tokenTransferCalldata variable. This is our encoded call to the transfer(...) function on the LSP7 token, generated in step 5.

Step 3 - Deploy our LSP1 Forwarder​

Now that we have created our custom LSP1 Delegate Forwarder contract, we will deploy it on LUKSO Testnet.

Let's first compile our contract to generate its ABI and bytecode.

hardhat compile

Setup the LUKSO Testnet network in your hardhat.config.ts.

hardhat.config.ts
// ...
const config: HardhatUserConfig = {
// ...
networks: {
luksoTestnet: {
live: true,
url: '**https**://rpc.testnet.lukso.network',
chainId: 4201,
saveDeployments: true,
},
},
// ...
};
// ...
export default config;

We will use a Hardhat script to deploy our LSP1 Forwarder contract. We will use our main controller address by exporting its private key from the UP Browser Extension. Create the following .env file and add the main controller private key exported from the πŸ†™ Browser Extension:

.env
PRIVATE_KEY=""

Create the following file under the scripts/ folder in your Hardhat project.

scripts/deployLSP1Forwarder.ts
import hre from 'hardhat';
import { ethers } from 'hardhat';
import * as dotenv from 'dotenv';
import LSP1URDForwarder from "../artifacts/contracts/Tokens/LSP1URDForwarder.sol/LSP1URDForwarder.json';";

// load env vars
dotenv.config();

// setup provider
const provider = new ethers.JsonRpcProvider(
'https://rpc.testnet.lukso.network',
);

// You can update the value of the allowed LSP7 token
const MY_USDC_TOKEN = '0x63890ea231c6e966142288d805b9f9de7e0e5927';

// setup signer (the browser extension main controller)
const signer = new ethers.Wallet(process.env.PRIVATE_KEY as string, provider);
console.log('Main πŸ†™ Controller address (EOA πŸ”‘): ', signer.address);

// -----------------------------
// DEPLOY LSP1 Delegate contract
// -----------------------------

const lsp1ForwarderFactory = new ethers.ContractFactory(
LSP1URDForwarder.abi,
LSP1URDForwarder.bytecode,
signer,
);

const lsp1Forwarder = await lsp1ForwarderFactory.deploy(
'0xd33D2Cd7035e508043983283CD8E870dfAbEA844', // Token recipient
'20', // Percentage % of token to re-transfer to Token Recipient
[MY_USDC_TOKEN],
);
console.log(
'βœ… Custom URD successfully deployed at address: ',
lsp1Forwarder.address,
);

Run the command below to deploy our custom LSP1 Forwarder contract on LUKSO Testnet:

npx hardhat run scripts/deployLSP1Forwarder.ts --network luksoTestnet

Step 4 - Setup our LSP1 Forwarder​

Now that we have deployed our custom LSP1 Forwarder contract, we will register it and set it up on our Universal Profile.

4.1 - Register on the UP​

We will register this LSP1 Forwarder for the LSP1 Type Id LSP7Tokens_RecipientNotification. This type Id is used to notify the Universal Profile that it received some new tokens.

To do that, use the LSP1UniversalReceiverDelegate:<bytes32> Mapping data key, where the <bytes32> part will be the type Id. The erc725.js library will enable us to do that easily.

import ethers from 'ethers';
import ERC725 from '@erc725/erc725.js';
import LSP1Schema from '@erc725/erc725js/schemas/LSP1UniversalReceiver.json';
import LSP6Schema from '@erc725/erc725js/schemas/LSP6KeyManager.json';

import { LSP1_TYPE_IDS } from '@lukso/lsp-smart-contracts';
import UniversalProfile from '@lukso/lsp-smart-contracts/artifacts/UniversalProfile.json';

// code from previous steps here...
// including the instance of the `lsp1Forwarder` contract.

const lsp1Forwarder = await lsp1ForwarderFactory.deploy(
'0xd33D2Cd7035e508043983283CD8E870dfAbEA844', // Token recipient
'20', // Percentage % of token to re-transfer to Token Recipient
[MY_USDC_TOKEN],
);

const erc725 = new ERC725(LSP1Schema);

const { keys, values } = erc725.encodeData([
{
keyName: 'LSP1UniversalReceiverDelegate:<bytes32>',
dynamicKeyPart: LSP1_TYPE_IDS.LSP7Tokens_RecipientNotification,
value: lsp1Forwarder.address,
},
]);

const universalProfile = new ethers.Contract(
'0x...', // Universal Profile address
UniversalProfile.abi,
signer,
);

// register the LSP1 Forwarder for the notification type when we receive new LSP7 tokens
const setDataTx = await universalProfile.setData(keys[0], values[0]);

await setDataTx.wait();
console.log('βœ… Custom LSP1 Delegate has been correctly registered on the UP');

4.2 - Setup permissions / operator​

Depending on the design / method selected in step 2, we will have to setup our LSP1 Forwarder contract differently:

MethodSetup Required
Method 1: via πŸ†™ execute(...) functionGrant the permissions SUPER_CALL + REENTRANCY to the LSP1 Forwarder contract so that it can re-call the πŸ†™.
Method 2: via authorizeOperator(...) on LSP7 Token contract πŸͺ™Authorize the address of the LSP1 Forwarder contract as an operator, to spend tokens on behalf of the UP. This using the authorizeOperator() function on the LSP7 token contract.

With this method, we will set the permission SUPER_CALL and REENTRANCY on our πŸ†™ for the LSP1 Forwarder.

import ERC725 from '@erc725/erc725.js';
import LSP6Schema from '@erc725/erc725js/schemas/LSP6KeyManager.json';

import { PERMISSIONS } from '@lukso/lsp-smart-contracts';
import UniversalProfile from '@lukso/lsp-smart-contracts/artifacts/UniversalProfile.json';

// code from previous step here...
// including the instance of the `lsp1Forwarder` contract.

const lsp1Forwarder = await lsp1ForwarderFactory.deploy(
'0xd33D2Cd7035e508043983283CD8E870dfAbEA844', // Token recipient
'20', // Percentage % of token to re-transfer to Token Recipient
[MY_USDC_TOKEN],
);

const erc725 = new ERC725(LSP1Schema);

const { keys, values } = erc725.encodeData([
{
keyName: 'AddressPermissions:Permissions:<address>',
value: ERC725.encodePermissions({
SUPER_CALL: true,
REENTRANCY: true,
}),
},
]);

// Create an instance of our Universal Profile
const universalProfile = new ethers.Contract(
'0x...', // Universal Profile address
UniversalProfile.abi,
signer,
);

// Set the permissions of the LSP1 Forwarder on our UP
const setPermissionsTx = await universalProfile.setData(keys[0], values[0]);

await setPermissionsTx.wait();
console.log('βœ… Custom LSP1 Forwarder permissions have been set successfully');

πŸ§ͺ Testing our LSP1 Forwarder​

Now that all the pieces are connected, we can try it out!

The expected behaviour is that everytime the UP on which the custom LSP1 Forwarder contract has been set receives an allowed token (either through transfer or mint), it will automatically send a percentage to the specified recipient.

Here are the test data:

  • I set up the custom LSP1 Delegate contract on a test UP (neo: 0xD62940E95A7A4a760c96B1Ec1434092Ac2C4855E)
  • I created a custom LSP7 token named "My USDC" with symbol "MUSDC" (LSP7: 0x63890ea231c6e966142288d805b9f9de7e0e5927 / owner neo / 20k pre-minted to neo)
  • The custom LSP1 Delegate contract will send 20% of the received (transfer or mint) MUSDC
  • The recipient will be another test UP (karasu: 0xe5B9B2C3f72bA13fF43A6CfC6205b5147F0BEe84)
  • The custom LSP1 Delegate contract is deployed at address 0x4f614ebd07b81b42373b136259915b74565fedf5

Let's go to the test dapp and connect with neo's profile.

TestConnectNeo

Click on "Refresh tokens" to see our MUSDC balance.

TestRefreshTokensTestPreMint

Use the "Mint" box to mint an additional 10k MUSDC to ourself (to neo's UP). This should trigger the custom LSP1 Delegate forwarder and send 20% of 10k (= 2k) to karasu.

TestMintTx

We will then disconnect neo's profile from the test dapp.

note

There is a bug currently on the test dapp where the disconnect button doesn't work properly. In order to disconnect from the dapp, we need to remove the connection from the "connections" tab by clicking the ❌ icon on the right.

TestDisconnectNeo

We connect karasu's profile to the test dapp

TestConnectKarasu

... click on "Refresh tokens" and ...

TestSuccess

... Success πŸŽ‰ ! Our custom LSP1 Delegate forwarder worked as expected!

Congratulations πŸ₯³β€‹

You now have a fully functional custom LSP1 Delegate contract that will automatically forward a certain amount of the allowed received tokens to another UP!