From bf87606072647c995e876508af8c35fdd122218f Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 9 Mar 2026 15:41:36 +0000 Subject: [PATCH 1/3] feat(snippets): add 6 real-world LUKSO LSP code examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contributed by leo-assistant-chef ๐Ÿฆ๐Ÿ‘จ๐Ÿปโ€๐Ÿณ Solidity: - LSP1 Tip-on-Follow delegate: auto-tip LSP7 tokens to new followers (LSP1 + LSP26) - LSP7 token with transfer tax: % fee on every transfer routed to treasury (LSP7) - LSP6 batch permission checker: validate multiple controllers in one getDataBatch call TypeScript: - Read Universal Profile with erc725.js: fetch LSP3 metadata with IPFS resolution - LSP7 airdrop to all followers: paginate LSP26 registry + batch transfer in one TX - Gasless relay with LSP25: meta-transactions via executeRelayCall (LSP25 + LSP6) All examples are use-case oriented, well-documented, production-ready patterns. --- snippets/README.md | 51 ++++++ .../solidity/lsp1-tip-on-follow-delegate.sol | 93 ++++++++++ .../lsp6-batch-permission-checker.sol | 112 ++++++++++++ .../solidity/lsp7-token-with-transfer-tax.sol | 102 +++++++++++ snippets/typescript/gasless-relay-lsp25.ts | 166 ++++++++++++++++++ .../typescript/lsp7-airdrop-to-followers.ts | 155 ++++++++++++++++ .../read-up-profile-with-erc725js.ts | 113 ++++++++++++ 7 files changed, 792 insertions(+) create mode 100644 snippets/README.md create mode 100644 snippets/solidity/lsp1-tip-on-follow-delegate.sol create mode 100644 snippets/solidity/lsp6-batch-permission-checker.sol create mode 100644 snippets/solidity/lsp7-token-with-transfer-tax.sol create mode 100644 snippets/typescript/gasless-relay-lsp25.ts create mode 100644 snippets/typescript/lsp7-airdrop-to-followers.ts create mode 100644 snippets/typescript/read-up-profile-with-erc725js.ts diff --git a/snippets/README.md b/snippets/README.md new file mode 100644 index 00000000..055523a9 --- /dev/null +++ b/snippets/README.md @@ -0,0 +1,51 @@ +# LUKSO LSP Code Snippets + +Real-world, use-case-oriented code examples for building on LUKSO with LSP standards. +Contributed by **Leo** (Assistant Chef ๐Ÿฆ๐Ÿ‘จ๐Ÿปโ€๐Ÿณ) โ€” AI agent built on [OpenClaw](https://openclaw.ai). + +--- + +## Solidity + +| File | Description | LSPs Used | +|---|---|---| +| [`lsp1-tip-on-follow-delegate.sol`](./solidity/lsp1-tip-on-follow-delegate.sol) | LSP1 Universal Receiver Delegate that auto-tips LSP7 tokens to new followers | LSP1 ยท LSP7 ยท LSP26 | +| [`lsp7-token-with-transfer-tax.sol`](./solidity/lsp7-token-with-transfer-tax.sol) | LSP7 fungible token with configurable transfer tax routed to a treasury UP | LSP7 | +| [`lsp6-batch-permission-checker.sol`](./solidity/lsp6-batch-permission-checker.sol) | Utility to batch-check multiple LSP6 controller permissions in one call | LSP6 ยท ERC725Y | + +## TypeScript + +| File | Description | LSPs Used | +|---|---|---| +| [`read-up-profile-with-erc725js.ts`](./typescript/read-up-profile-with-erc725js.ts) | Fetch full LSP3 profile metadata (name, avatar, tags, links) with erc725.js | LSP3 ยท ERC725Y | +| [`lsp7-airdrop-to-followers.ts`](./typescript/lsp7-airdrop-to-followers.ts) | Airdrop LSP7 tokens to all followers via the LSP26 Follower Registry | LSP7 ยท LSP26 | +| [`gasless-relay-lsp25.ts`](./typescript/gasless-relay-lsp25.ts) | Gasless meta-transactions using LSP25 Execute Relay Call | LSP25 ยท LSP6 | + +--- + +## Key Concepts + +| Standard | What it does | +|---|---| +| **LSP1** | Universal Receiver โ€” hook into incoming transactions on a UP | +| **LSP3** | Universal Profile Metadata โ€” name, avatar, bio, links stored on-chain | +| **LSP6** | Key Manager โ€” permissions and access control for Universal Profiles | +| **LSP7** | Fungible Token โ€” like ERC20, but with operator model and transfer hooks | +| **LSP25** | Execute Relay Call โ€” meta-transactions for gasless UX | +| **LSP26** | Follower System โ€” decentralized social graph on LUKSO | +| **ERC725Y** | Key-value store on every Universal Profile | + +--- + +## Getting Started + +```bash +# Solidity (Foundry) +forge install lukso-network/lsp-smart-contracts + +# TypeScript +npm install @erc725/erc725.js @lukso/lsp0-contracts @lukso/lsp7-contracts viem +``` + +Mainnet RPC: `https://rpc.mainnet.lukso.network` +Testnet RPC: `https://rpc.testnet.lukso.network` diff --git a/snippets/solidity/lsp1-tip-on-follow-delegate.sol b/snippets/solidity/lsp1-tip-on-follow-delegate.sol new file mode 100644 index 00000000..3c95252b --- /dev/null +++ b/snippets/solidity/lsp1-tip-on-follow-delegate.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ILSP1UniversalReceiver} from "@lukso/lsp1-contracts/contracts/ILSP1UniversalReceiver.sol"; +import {ILSP7DigitalAsset} from "@lukso/lsp7-contracts/contracts/ILSP7DigitalAsset.sol"; + +/** + * @title TipOnFollowDelegate + * @notice LSP1 Universal Receiver Delegate that automatically tips an LSP7 token + * to any new Universal Profile follower. + * + * USE CASE: Reward followers with tokens automatically โ€” no manual action required. + * Think of it as a programmable "follow-to-earn" mechanic on LUKSO. + * + * HOW IT WORKS: + * 1. Someone follows your Universal Profile via the LSP26 Follower Registry + * 2. LSP26 fires an LSP1 universalReceiver notification on your UP + * 3. Your UP delegates the call to this contract (registered as LSP1 delegate for + * the LSP26 follow typeId) + * 4. This contract transfers `tipAmount` tokens from your UP to the follower + * + * SETUP (on your Universal Profile): + * - Set the ERC725Y key LSP1UniversalReceiverDelegate: + * โ†’ value = address of this contract + * - Authorize this contract as an LSP7 operator for your tip budget: + * tipToken.authorizeOperator(address(this), tipBudget, "") + * + * SECURITY: + * - Only the LSP26 Follower Registry can trigger tips + * - Tips are wrapped in try/catch โ€” failures never revert the follow transaction + * - The UP's own LSP7 balance acts as the rate-limiter (tips stop when budget runs out) + */ +contract TipOnFollowDelegate is ILSP1UniversalReceiver { + + /// @dev The LSP26 follow notification typeId + /// Computed as: keccak256("LSP26FollowerSystem_FollowNotification") + bytes32 constant _TYPEID_LSP26_FOLLOW = + 0x71e02f9f05bcd5816ec4f3134aa2e5a916669537000000000000000000000000; + + /// @dev LSP26 Follower Registry on LUKSO Mainnet + address constant LSP26_FOLLOWER_REGISTRY = 0xf01103E5a9909Fc0DBe8166dA7085e0285daDDcA; + + /// @notice The LSP7 token used for tips + ILSP7DigitalAsset public immutable tipToken; + + /// @notice Amount tipped per new follower (in wei, 18 decimals) + uint256 public immutable tipAmount; + + event TipSent(address indexed universalProfile, address indexed follower, uint256 amount); + event TipFailed(address indexed universalProfile, address indexed follower, string reason); + + constructor(address _tipToken, uint256 _tipAmount) { + require(_tipToken != address(0), "TipOnFollowDelegate: zero token address"); + require(_tipAmount > 0, "TipOnFollowDelegate: tip amount must be > 0"); + tipToken = ILSP7DigitalAsset(_tipToken); + tipAmount = _tipAmount; + } + + /** + * @notice Called by the Universal Profile when a follow notification arrives. + * @param typeId The LSP1 type ID โ€” must match the LSP26 follow typeId + * @param data ABI-encoded data containing the follower's address + * @return Empty bytes (return value is ignored by the UP) + */ + function universalReceiver( + bytes32 typeId, + bytes calldata data + ) external override returns (bytes memory) { + // Only handle LSP26 follow notifications + if (typeId != _TYPEID_LSP26_FOLLOW) return ""; + + // Only accept calls originating from the LSP26 Follower Registry + if (msg.sender != LSP26_FOLLOWER_REGISTRY) return ""; + + // Decode follower address from the notification payload (last 20 bytes) + if (data.length < 20) return ""; + address follower = address(bytes20(data[data.length - 20:])); + + // The UP that received the follow is tx.origin + address universalProfile = tx.origin; + + // Tip the follower โ€” wrapped in try/catch so we never block the follow tx + try tipToken.transfer(universalProfile, follower, tipAmount, true, "") { + emit TipSent(universalProfile, follower, tipAmount); + } catch Error(string memory reason) { + emit TipFailed(universalProfile, follower, reason); + } catch { + emit TipFailed(universalProfile, follower, "unknown error"); + } + + return ""; + } +} diff --git a/snippets/solidity/lsp6-batch-permission-checker.sol b/snippets/solidity/lsp6-batch-permission-checker.sol new file mode 100644 index 00000000..e2e88ffc --- /dev/null +++ b/snippets/solidity/lsp6-batch-permission-checker.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {IERC725Y} from "@erc725/smart-contracts/contracts/interfaces/IERC725Y.sol"; + +/** + * @title LSP6BatchPermissionChecker + * @notice Utility contract to verify LSP6 controller permissions on a + * Universal Profile in a single batched on-chain call. + * + * USE CASE: Pre-flight permission validation in dApps and smart contracts. + * Instead of sending a transaction that will fail due to missing + * permissions, check upfront and surface a clear error. + * + * EXAMPLE USE CASES: + * - A dApp verifies a controller has CALL + SETDATA before allowing the user to proceed + * - A factory contract checks deployer permissions before creating sub-contracts + * - An AI agent validates its own permissions before attempting privileged actions + * + * LSP6 KEY PERMISSION BITMASKS: + * CHANGEOWNER = 0x0000...0001 + * ADDCONTROLLER = 0x0000...0002 + * EDITPERMISSIONS = 0x0000...0004 + * ADDEXTENSIONS = 0x0000...0008 + * CHANGEEXTENSIONS = 0x0000...0010 + * ADDUNIVERSALRECEIVERDELEGATE = 0x0000...0020 + * CHANGEUNIVERSALRECEIVERDELEGATE = 0x0000...0040 (bit 7) + * SETDATA = 0x0000...0080 + * SUPER_SETDATA = 0x0000...0100 + * CALL = 0x0000...0200 + * SUPER_CALL = 0x0000...0400 + * DEPLOY = 0x0000...0800 + * SIGN = 0x0000...1000 + * EXECUTE_RELAY_CALL = 0x0000...2000 + * + * Docs: https://docs.lukso.tech/standards/access-control/lsp6-key-manager#permissions + */ +contract LSP6BatchPermissionChecker { + + /// @dev Prefix for AddressPermissions:Permissions:
ERC725Y key + bytes10 constant _PERMISSIONS_KEY_PREFIX = 0x4b80742de2bf82acb3630000; + + /** + * @notice Check if a single controller has ALL required permissions on a UP. + * @param universalProfile The Universal Profile to check + * @param controller The controller address to validate + * @param requiredPermissions Bitmask of all required permissions OR-ed together + * @return hasAll True if controller has every required permission + * @return missing Bitmask of only the missing permissions (0 if hasAll = true) + */ + function checkPermissions( + address universalProfile, + address controller, + bytes32 requiredPermissions + ) external view returns (bool hasAll, bytes32 missing) { + bytes32 permissionsKey = _buildPermissionsKey(controller); + bytes memory rawPermissions = IERC725Y(universalProfile).getData(permissionsKey); + + // Controller not registered on this UP + if (rawPermissions.length == 0) { + return (false, requiredPermissions); + } + + bytes32 granted = abi.decode(rawPermissions, (bytes32)); + bytes32 intersection = granted & requiredPermissions; + + missing = requiredPermissions ^ intersection; // bits required but not granted + hasAll = (missing == bytes32(0)); + } + + /** + * @notice Batch check permissions for multiple controllers in a single call. + * Uses getDataBatch internally โ€” one RPC round-trip for all controllers. + * @param universalProfile The Universal Profile to check against + * @param controllers Array of controller addresses to validate + * @param requiredPerms Bitmask of required permissions (applied to all controllers) + * @return results True/false array, one entry per controller + */ + function batchCheckPermissions( + address universalProfile, + address[] calldata controllers, + bytes32 requiredPerms + ) external view returns (bool[] memory results) { + uint256 controllerCount = controllers.length; + results = new bool[](controllerCount); + + // Build all ERC725Y data keys for a single getDataBatch call + bytes32[] memory keys = new bytes32[](controllerCount); + for (uint256 i = 0; i < controllerCount; i++) { + keys[i] = _buildPermissionsKey(controllers[i]); + } + + bytes[] memory rawValues = IERC725Y(universalProfile).getDataBatch(keys); + + for (uint256 i = 0; i < controllerCount; i++) { + if (rawValues[i].length == 0) { + results[i] = false; + continue; + } + bytes32 granted = abi.decode(rawValues[i], (bytes32)); + results[i] = (granted & requiredPerms) == requiredPerms; + } + } + + /** + * @dev Build the AddressPermissions:Permissions:
ERC725Y key. + * Key = bytes10(keccak256("AddressPermissions:Permissions")) + 0x0000 + bytes20(controller) + */ + function _buildPermissionsKey(address controller) internal pure returns (bytes32) { + return bytes32(abi.encodePacked(_PERMISSIONS_KEY_PREFIX, controller)); + } +} diff --git a/snippets/solidity/lsp7-token-with-transfer-tax.sol b/snippets/solidity/lsp7-token-with-transfer-tax.sol new file mode 100644 index 00000000..d0606040 --- /dev/null +++ b/snippets/solidity/lsp7-token-with-transfer-tax.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {LSP7DigitalAsset} from "@lukso/lsp7-contracts/contracts/LSP7DigitalAsset.sol"; + +/** + * @title TaxedLSP7Token + * @notice LSP7 fungible token that takes a configurable fee on every transfer + * and routes it to a treasury Universal Profile. + * + * USE CASE: Protocol revenue โ€” every token transfer automatically funds the + * DAO treasury or project wallet. Common in DeFi (fee-on-transfer tokens), + * now available natively for LSP7 on LUKSO. + * + * EXAMPLES: + * - 1% tax (100 basis points) โ†’ sustainability fund + * - 2.5% tax (250 basis points) โ†’ DAO treasury + * - 0.5% tax (50 basis points) โ†’ liquidity incentives + * + * HOW IT WORKS: + * - Overrides LSP7's _beforeTokenTransfer hook + * - On every non-mint, non-burn transfer: computes tax, routes to treasury + * - The recipient always receives (amount - tax); the treasury receives tax + * - Tax-exempt: mint (from = 0), burn (to = 0), treasury self-transfers + * + * NOTES: + * - Max tax capped at 10% to prevent abuse + * - Treasury and tax rate are updatable by the contract owner + * - Treasury address can be a Universal Profile for full LSP compatibility + */ +contract TaxedLSP7Token is LSP7DigitalAsset { + + /// @notice Treasury address that receives the transfer tax + address public treasury; + + /// @notice Tax rate in basis points (1 bp = 0.01%). Max: 1000 (= 10%) + uint256 public taxBasisPoints; + + event TaxCollected(address indexed from, address indexed to, uint256 taxAmount); + event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury); + event TaxRateUpdated(uint256 oldBasisPoints, uint256 newBasisPoints); + + constructor( + string memory name_, + string memory symbol_, + address newOwner_, + address treasury_, + uint256 taxBasisPoints_, + uint256 initialSupply_ + ) LSP7DigitalAsset(name_, symbol_, newOwner_, 0, false) { + require(treasury_ != address(0), "TaxedLSP7: zero treasury address"); + require(taxBasisPoints_ <= 1000, "TaxedLSP7: tax exceeds 10% maximum"); + + treasury = treasury_; + taxBasisPoints = taxBasisPoints_; + + if (initialSupply_ > 0) { + _mint(newOwner_, initialSupply_, true, ""); + } + } + + /** + * @dev Hook called before every token transfer. + * Intercepts the transfer and routes the tax portion to the treasury. + */ + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256 amount, + bool force, + bytes memory data + ) internal override { + super._beforeTokenTransfer(operator, from, to, amount, force, data); + + // Skip tax on mint, burn, and treasury-involved transfers + if (from == address(0) || to == address(0)) return; + if (from == treasury || to == treasury) return; + + uint256 taxAmount = (amount * taxBasisPoints) / 10_000; + if (taxAmount == 0) return; + + _transfer(from, treasury, taxAmount, true, ""); + emit TaxCollected(from, to, taxAmount); + } + + // โ”€โ”€ Admin functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// @notice Update the treasury address (owner only) + function setTreasury(address newTreasury) external onlyOwner { + require(newTreasury != address(0), "TaxedLSP7: zero address"); + emit TreasuryUpdated(treasury, newTreasury); + treasury = newTreasury; + } + + /// @notice Update the tax rate (owner only, max 10%) + function setTaxRate(uint256 newBasisPoints) external onlyOwner { + require(newBasisPoints <= 1000, "TaxedLSP7: exceeds 10% maximum"); + emit TaxRateUpdated(taxBasisPoints, newBasisPoints); + taxBasisPoints = newBasisPoints; + } +} diff --git a/snippets/typescript/gasless-relay-lsp25.ts b/snippets/typescript/gasless-relay-lsp25.ts new file mode 100644 index 00000000..f12fac36 --- /dev/null +++ b/snippets/typescript/gasless-relay-lsp25.ts @@ -0,0 +1,166 @@ +/** + * GASLESS META-TRANSACTIONS WITH LSP25 EXECUTE RELAY CALL + * + * USE CASE: Let users interact with their Universal Profile without paying gas. + * A relayer (backend service, AI agent, or sponsor) signs and submits + * the transaction, covering the LYX fee on behalf of the user. + * + * PERFECT FOR: + * - Onboarding new users with zero LYX balance + * - AI agents executing automated UP operations (setting data, making calls) + * - Scheduled/cron-based UP interactions + * - Sponsored transactions in dApps + * + * HOW LSP25 WORKS: + * 1. Build the UP calldata (setData / execute / whatever the UP should do) + * 2. Get the controller nonce from the Key Manager (replay protection) + * 3. Sign a hash of: (LSP25_VERSION, chainId, nonce, validityTimestamps, value, calldata) + * 4. Submit to the Key Manager via executeRelayCall โ€” anyone can submit + * (the gas payer doesn't need to be the controller) + * + * INSTALL: + * npm install viem @lukso/lsp0-contracts + * + * Docs: https://docs.lukso.tech/standards/access-control/lsp25-execute-relay-call + */ + +import { + createPublicClient, + createWalletClient, + encodeFunctionData, + encodeAbiParameters, + keccak256, + http, + parseAbi, + type Hex, + type Address, +} from "viem"; +import { lukso } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; +import { lsp0Erc725AccountAbi } from "@lukso/lsp0-contracts/abi"; + +// โ”€โ”€ ABIs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const lsp6KeyManagerAbi = parseAbi([ + "function getNonce(address from, uint128 channelId) external view returns (uint256)", + "function executeRelayCall(bytes signature, uint256 nonce, uint256 validityTimestamps, bytes payload) external payable returns (bytes)", +]); + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface RelayConfig { + /** The Universal Profile to interact with */ + upAddress: Address; + /** The Key Manager address (get via UP.owner()) */ + keyManagerAddress: Address; + /** + * Controller private key โ€” the controller must have permissions for the UP call. + * This key signs the meta-transaction but doesn't need to pay gas. + */ + controllerKey: Hex; + /** + * Channel ID (0โ€“127). Use separate channels for parallel transactions + * to avoid nonce conflicts. Channel 0 = sequential, channels 1โ€“127 = parallel. + */ + channelId?: number; +} + +// โ”€โ”€ Core function โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Sign and submit a gasless relay transaction via LSP25. + * + * @param config Relay configuration + * @param upCalldata Encoded calldata for the UP (setData, execute, etc.) + * @param validForSeconds How long the signature is valid (default: 1 hour) + * @returns Transaction hash + */ +async function executeRelayTransaction( + config: RelayConfig, + upCalldata: Hex, + validForSeconds = 3600 +): Promise { + const { upAddress, keyManagerAddress, controllerKey, channelId = 0 } = config; + + const account = privateKeyToAccount(controllerKey); + const publicClient = createPublicClient({ chain: lukso, transport: http() }); + const walletClient = createWalletClient({ account, chain: lukso, transport: http() }); + + // โ”€โ”€ Step 1: Get the current nonce for this controller + channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const nonce = await publicClient.readContract({ + address: keyManagerAddress, + abi: lsp6KeyManagerAbi, + functionName: "getNonce", + args: [account.address, BigInt(channelId)], + }); + + console.log(` Nonce (channel ${channelId}): ${nonce}`); + + // โ”€โ”€ Step 2: Build validity timestamps โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // LSP25 packs (validAfter, validBefore) as two uint128s in a uint256 + const now = BigInt(Math.floor(Date.now() / 1000)); + const validAfter = 0n; // valid immediately + const validBefore = now + BigInt(validForSeconds); + const validityTimestamps = (validAfter << 128n) | validBefore; + + // โ”€โ”€ Step 3: Hash the relay payload per LSP25 spec โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Hash = keccak256(abi.encode(LSP25_VERSION, chainId, nonce, validityTimestamps, value, calldata)) + const msgHash = keccak256( + encodeAbiParameters( + [ + { type: "uint256" }, // LSP25 version (always 25) + { type: "uint256" }, // chain ID + { type: "uint256" }, // nonce (from Key Manager) + { type: "uint256" }, // validity timestamps + { type: "uint256" }, // LYX value (0 for data-only calls) + { type: "bytes" }, // the UP calldata + ], + [25n, BigInt(lukso.id), nonce, validityTimestamps, 0n, upCalldata] + ) + ); + + // โ”€โ”€ Step 4: Sign the hash โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const signature = await walletClient.signMessage({ message: { raw: msgHash } }); + console.log(` Signature: ${signature.slice(0, 20)}...`); + + // โ”€โ”€ Step 5: Submit via executeRelayCall โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Any wallet can submit this โ€” the controller doesn't pay the gas + const txHash = await walletClient.writeContract({ + address: keyManagerAddress, + abi: lsp6KeyManagerAbi, + functionName: "executeRelayCall", + args: [signature, nonce, validityTimestamps, upCalldata], + }); + + console.log(` โœ… Relay tx: ${txHash}`); + return txHash; +} + +// โ”€โ”€ Usage example: gasless setData on a Universal Profile โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function example_gaslessSetData() { + const MY_CUSTOM_KEY = "0xcafecafecafecafecafecafecafecafecafecafecafecafecafecafecafecafe" as Hex; + const MY_VALUE = "0xdeadbeef" as Hex; + + // Encode the UP.setData call + const setDataCalldata = encodeFunctionData({ + abi: lsp0Erc725AccountAbi, + functionName: "setData", + args: [MY_CUSTOM_KEY, MY_VALUE], + }); + + console.log("Submitting gasless setData..."); + + await executeRelayTransaction( + { + upAddress: "0xYourUniversalProfileAddress", + keyManagerAddress: "0xYourKeyManagerAddress", + controllerKey: "0xYourControllerPrivateKey", + channelId: 0, // sequential (safe default) + }, + setDataCalldata, + 3600 // valid for 1 hour + ); +} + +example_gaslessSetData(); diff --git a/snippets/typescript/lsp7-airdrop-to-followers.ts b/snippets/typescript/lsp7-airdrop-to-followers.ts new file mode 100644 index 00000000..0918440c --- /dev/null +++ b/snippets/typescript/lsp7-airdrop-to-followers.ts @@ -0,0 +1,155 @@ +/** + * LSP7 TOKEN AIRDROP TO ALL FOLLOWERS + * + * USE CASE: Reward your entire follower base with an LSP7 token airdrop + * in a single batched transaction via your Universal Profile. + * Perfect for community rewards, loyalty programs, or launch events. + * + * HOW IT WORKS: + * 1. Paginate through the LSP26 Follower Registry to get all your followers + * 2. Build an LSP7 transferBatch call (many recipients, one transaction) + * 3. Execute the batch via your Universal Profile + * + * REQUIREMENTS: + * - Controller private key with CALL permission on your UP + * - UP must hold enough LSP7 tokens: followers ร— amountPerFollower + * + * INSTALL: + * npm install viem @lukso/lsp0-contracts @lukso/lsp7-contracts + * + * Docs: https://docs.lukso.tech/standards/social/lsp26-follower-system + * https://docs.lukso.tech/standards/tokens/lsp7-digital-asset + */ + +import { + createPublicClient, + createWalletClient, + encodeFunctionData, + http, + parseAbi, + type Address, + type Hex, +} from "viem"; +import { lukso } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; +import { lsp0Erc725AccountAbi } from "@lukso/lsp0-contracts/abi"; +import { lsp7DigitalAssetAbi } from "@lukso/lsp7-contracts/abi"; + +// โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const LSP26_FOLLOWER_REGISTRY: Address = "0xf01103E5a9909Fc0DBe8166dA7085e0285daDDcA"; +const LSP26_ABI = parseAbi([ + "function followerCount(address addr) external view returns (uint256)", + "function getFollowersByIndex(address addr, uint256 startIndex, uint256 endIndex) external view returns (address[])", +]); + +const PAGE_SIZE = 100n; // fetch 100 followers per RPC call + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface AirdropConfig { + /** Your Universal Profile address */ + upAddress: Address; + /** Controller private key (must have CALL permission on the UP) */ + privateKey: Hex; + /** LSP7 token contract to distribute */ + tokenAddress: Address; + /** Amount to send per follower, in token units (18 decimals) */ + amountPerFollower: bigint; + /** + * force = true โ†’ send to any address (including EOAs) + * force = false โ†’ only send to Universal Profiles (recommended) + */ + force: boolean; +} + +// โ”€โ”€ Core โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Airdrop LSP7 tokens to all followers of a Universal Profile. + * + * Uses a single transferBatch call for efficiency โ€” one transaction regardless + * of follower count (up to EVM block gas limits, ~500โ€“1000 recipients). + * For very large follower bases, split into batches of 200โ€“300. + */ +async function airdropToFollowers(config: AirdropConfig): Promise { + const { upAddress, privateKey, tokenAddress, amountPerFollower, force } = config; + + const account = privateKeyToAccount(privateKey); + + const publicClient = createPublicClient({ chain: lukso, transport: http() }); + const walletClient = createWalletClient({ account, chain: lukso, transport: http() }); + + // โ”€โ”€ Step 1: Fetch all followers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const followerCount = await publicClient.readContract({ + address: LSP26_FOLLOWER_REGISTRY, + abi: LSP26_ABI, + functionName: "followerCount", + args: [upAddress], + }); + + console.log(`๐Ÿ“Š Follower count: ${followerCount}`); + if (followerCount === 0n) { + console.log("No followers to airdrop to."); + return; + } + + const followers: Address[] = []; + + for (let start = 0n; start < followerCount; start += PAGE_SIZE) { + const end = start + PAGE_SIZE < followerCount ? start + PAGE_SIZE : followerCount; + const page = await publicClient.readContract({ + address: LSP26_FOLLOWER_REGISTRY, + abi: LSP26_ABI, + functionName: "getFollowersByIndex", + args: [upAddress, start, end], + }); + followers.push(...(page as Address[])); + console.log(` Fetched ${followers.length} / ${followerCount} followers`); + } + + // โ”€โ”€ Step 2: Encode LSP7 transferBatch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const batchTransferCalldata = encodeFunctionData({ + abi: lsp7DigitalAssetAbi, + functionName: "transferBatch", + args: [ + Array(followers.length).fill(upAddress), // from: always the UP + followers, // to: each follower + Array(followers.length).fill(amountPerFollower), // amount per recipient + Array(followers.length).fill(force), // force flag + Array(followers.length).fill("0x" as Hex), // extra data (empty) + ], + }); + + // โ”€โ”€ Step 3: Execute via Universal Profile โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const totalTokens = amountPerFollower * BigInt(followers.length); + console.log(`\n๐Ÿš€ Sending airdrop:`); + console.log(` Recipients: ${followers.length}`); + console.log(` Per wallet: ${amountPerFollower / 10n ** 18n} tokens`); + console.log(` Total: ${totalTokens / 10n ** 18n} tokens`); + + const txHash = await walletClient.writeContract({ + address: upAddress, + abi: lsp0Erc725AccountAbi, + functionName: "execute", + args: [ + 0n, // CALL operation + tokenAddress, // to: the LSP7 token contract + 0n, // value: no LYX sent + batchTransferCalldata, + ], + }); + + console.log(`\nโœ… Airdrop complete!`); + console.log(` TX hash: ${txHash}`); +} + +// โ”€โ”€ Usage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +airdropToFollowers({ + upAddress: "0xYourUniversalProfileAddress", + privateKey: "0xYourControllerPrivateKey", + tokenAddress: "0xYourLSP7TokenAddress", + amountPerFollower: 100n * 10n ** 18n, // 100 tokens per follower + force: false, // only send to Universal Profiles +}); diff --git a/snippets/typescript/read-up-profile-with-erc725js.ts b/snippets/typescript/read-up-profile-with-erc725js.ts new file mode 100644 index 00000000..5771eee5 --- /dev/null +++ b/snippets/typescript/read-up-profile-with-erc725js.ts @@ -0,0 +1,113 @@ +/** + * READ UNIVERSAL PROFILE DATA WITH ERC725.JS + * + * USE CASE: Fetch a user's full LSP3 profile (name, avatar, bio, tags, social links) + * from any Universal Profile address on LUKSO Mainnet. + * + * This is the standard entry point for any LUKSO dApp that displays user profiles. + * erc725.js automatically: + * - Decodes ABI-encoded ERC725Y values + * - Resolves IPFS hashes to full URLs + * - Handles the VerifiableURI format for LSP3ProfileMetadata + * + * INSTALL: + * npm install @erc725/erc725.js + * + * Docs: https://docs.lukso.tech/standards/metadata/lsp3-profile-metadata + * https://docs.lukso.tech/tools/erc725js/getting-started + */ + +import ERC725, { ERC725JSONSchema } from "@erc725/erc725.js"; +import LSP3ProfileSchema from "@erc725/erc725.js/schemas/LSP3ProfileMetadata.json"; + +const LUKSO_RPC = "https://rpc.mainnet.lukso.network"; +const IPFS_GATEWAY = "https://api.universalprofile.cloud/ipfs/"; + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface SocialLink { + title: string; + url: string; +} + +interface UniversalProfileData { + address: string; + name: string; + description: string; + avatar?: string; + backgroundImage?: string; + tags: string[]; + links: SocialLink[]; +} + +// โ”€โ”€ Core function โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Fetch and decode a Universal Profile's LSP3 metadata. + * + * @param upAddress The Universal Profile address (checksummed 0x...) + * @returns Decoded profile data, or null if the UP has no profile set + */ +async function fetchUniversalProfile(upAddress: string): Promise { + const erc725 = new ERC725( + LSP3ProfileSchema as ERC725JSONSchema[], + upAddress, + LUKSO_RPC, + { ipfsGateway: IPFS_GATEWAY } + ); + + // fetchData resolves the IPFS URI and decodes the JSON automatically + const profileData = await erc725.fetchData("LSP3Profile"); + + if (!profileData?.value) return null; + + const profile = (profileData.value as any).LSP3Profile; + + // Resolve avatar โ€” profile images are stored as an array (multiple resolutions) + const resolveIpfs = (url?: string) => + url ? url.replace("ipfs://", IPFS_GATEWAY) : undefined; + + const avatar = resolveIpfs(profile.profileImage?.[0]?.url); + const backgroundImage = resolveIpfs(profile.backgroundImage?.[0]?.url); + + return { + address: upAddress, + name: profile.name ?? "Unknown", + description: profile.description ?? "", + avatar, + backgroundImage, + tags: profile.tags ?? [], + links: profile.links ?? [], + }; +} + +// โ”€โ”€ Batch fetch helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Fetch multiple UP profiles in parallel. + * Useful for leaderboards, follower lists, Discover pages, etc. + */ +async function fetchMultipleProfiles( + upAddresses: string[] +): Promise<(UniversalProfileData | null)[]> { + return Promise.all(upAddresses.map(fetchUniversalProfile)); +} + +// โ”€โ”€ Usage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const UP_ADDRESS = "0x82C4DC98e27CFe9D7d312250e972e7380Fbf6B77"; // example UP on LUKSO Mainnet + +fetchUniversalProfile(UP_ADDRESS).then((profile) => { + if (!profile) { + console.log("โŒ No LSP3 profile found for this address"); + return; + } + + console.log("โœ… Universal Profile loaded:"); + console.log(" Name: ", profile.name); + console.log(" Bio: ", profile.description || "(empty)"); + console.log(" Avatar: ", profile.avatar ?? "(none)"); + console.log(" Background: ", profile.backgroundImage ?? "(none)"); + console.log(" Tags: ", profile.tags.join(", ") || "(none)"); + console.log(" Links: ", profile.links.map((l) => `${l.title}: ${l.url}`).join(", ") || "(none)"); +}); From 663baf3df6db69b42b7b7f9f30796aec37cafcee Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 9 Mar 2026 16:12:31 +0000 Subject: [PATCH 2/3] fix(ci): switch from pnpm+vite to npm+next build Frontend was migrated from Vite SPA to Next.js 14 but CI still ran 'pnpm vite build'. vite is no longer installed; the build script is now 'next build'. Also switched to npm (package-lock.json present, no pnpm-lock.yaml). --- .github/workflows/ci.yml | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c397dc2..68447fc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,35 +23,27 @@ jobs: with: node-version: '20' - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 8 - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - name: Setup pnpm cache + - name: Setup npm cache uses: actions/cache@v3 with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('frontend/package-lock.json') }} restore-keys: | - ${{ runner.os }}-pnpm-store- - + ${{ runner.os }}-npm- + - name: Install dependencies working-directory: ./frontend - run: pnpm install --no-frozen-lockfile - + run: npm ci + - name: Type check working-directory: ./frontend run: echo "Skipping type check" || true - + - name: Build working-directory: ./frontend - run: pnpm vite build + run: npm run build + env: + NFT_STORAGE_API_KEY: ${{ secrets.NFT_STORAGE_API_KEY }} - name: Deploy to GitHub Pages if: github.ref == 'refs/heads/main' From eb1fa6ff866596ecf5aedc3a58223f054d1c960b Mon Sep 17 00:00:00 2001 From: leo-assistant-chef Date: Mon, 9 Mar 2026 19:28:53 +0000 Subject: [PATCH 3/3] feat(codeStore): seed 6 LUKSO LSP snippets into the UI Add leo_assistant_chef's snippets directly to the in-memory store so they appear in the frontend without requiring a backend call: - LSP1 Tip-on-Follow Delegate (Solidity) - LSP7 Token with Transfer Tax (Solidity) - LSP6 Batch Permission Checker (Solidity) - Read Universal Profile with erc725.js (TypeScript) - LSP7 Airdrop to All Followers (TypeScript) - Gasless Relay Transaction via LSP25 (TypeScript) --- frontend/src/utils/codeStore.ts | 445 +++++++++++++++++++++++++++++++- 1 file changed, 444 insertions(+), 1 deletion(-) diff --git a/frontend/src/utils/codeStore.ts b/frontend/src/utils/codeStore.ts index d5d3bdc1..fe1828d9 100644 --- a/frontend/src/utils/codeStore.ts +++ b/frontend/src/utils/codeStore.ts @@ -125,10 +125,453 @@ contract FollowSystem is ILSP26FollowerSystem { } ] + const leoSnippets: CodeSnippet[] = [ + { + id: '4', + title: 'LSP1 Tip-on-Follow Delegate', + description: 'LSP1 Universal Receiver Delegate that automatically tips LSP7 tokens to every new follower. Zero user interaction needed after setup โ€” the UP handles it via the LSP26 follow notification.', + code: `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ILSP1UniversalReceiver} from "@lukso/lsp1-contracts/contracts/ILSP1UniversalReceiver.sol"; +import {ILSP7DigitalAsset} from "@lukso/lsp7-contracts/contracts/ILSP7DigitalAsset.sol"; + +/// @dev LSP26 follow notification typeId +bytes32 constant _TYPEID_LSP26_FOLLOW = + 0x71e02f9f05bcd5816ec4f3134aa2e5a916669537000000000000000000000000; + +/// @dev LSP26 Follower Registry on LUKSO Mainnet +address constant LSP26_FOLLOWER_REGISTRY = 0xf01103E5a9909Fc0DBe8166dA7085e0285daDDcA; + +/** + * @title TipOnFollowDelegate + * @notice Auto-tip LSP7 tokens to every new follower via LSP1. + * + * SETUP on your Universal Profile: + * 1. Set LSP1UniversalReceiverDelegate: = address(this) + * 2. Authorize this contract as LSP7 operator for your tip budget + */ +contract TipOnFollowDelegate is ILSP1UniversalReceiver { + ILSP7DigitalAsset public immutable tipToken; + uint256 public immutable tipAmount; + + event TipSent(address indexed universalProfile, address indexed follower, uint256 amount); + event TipFailed(address indexed universalProfile, address indexed follower, string reason); + + constructor(address _tipToken, uint256 _tipAmount) { + tipToken = ILSP7DigitalAsset(_tipToken); + tipAmount = _tipAmount; + } + + function universalReceiver( + bytes32 typeId, + bytes calldata data + ) external override returns (bytes memory) { + if (typeId != _TYPEID_LSP26_FOLLOW) return ""; + if (msg.sender != LSP26_FOLLOWER_REGISTRY) return ""; + if (data.length < 20) return ""; + + address follower = address(bytes20(data[data.length - 20:])); + address universalProfile = tx.origin; + + try tipToken.transfer(universalProfile, follower, tipAmount, true, "") { + emit TipSent(universalProfile, follower, tipAmount); + } catch Error(string memory reason) { + emit TipFailed(universalProfile, follower, reason); + } catch { + emit TipFailed(universalProfile, follower, "unknown error"); + } + + return ""; + } +}`, + language: 'solidity', + author: 'leo_assistant_chef', + authorAddress: '0x0000000000000000000000000000000000000Leo', + timestamp: Date.now() - 3600000, + tags: ['lsp1', 'lsp7', 'lsp26', 'follow', 'delegate', 'lukso'], + likes: 0, + forks: 0, + isVerified: true, + ipfsHash: 'QmLeoTipOnFollow' + }, + { + id: '5', + title: 'LSP7 Token with Transfer Tax', + description: 'LSP7 fungible token with configurable basis-point fee on every transfer, automatically routed to a treasury Universal Profile. Perfect for protocol revenue and DAO funding.', + code: `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {LSP7DigitalAsset} from "@lukso/lsp7-contracts/contracts/LSP7DigitalAsset.sol"; + +/** + * @title TaxedLSP7Token + * @notice LSP7 token with a configurable transfer tax routed to treasury. + * + * Tax rate in basis points: 100 = 1%, 250 = 2.5% (max: 1000 = 10%) + * Treasury can be any Universal Profile address. + */ +contract TaxedLSP7Token is LSP7DigitalAsset { + address public treasury; + uint256 public taxBasisPoints; + + event TaxCollected(address indexed from, address indexed to, uint256 taxAmount); + + constructor( + string memory name_, + string memory symbol_, + address newOwner_, + address treasury_, + uint256 taxBasisPoints_, + uint256 initialSupply_ + ) LSP7DigitalAsset(name_, symbol_, newOwner_, 0, false) { + require(taxBasisPoints_ <= 1000, "TaxedLSP7: max 10%"); + treasury = treasury_; + taxBasisPoints = taxBasisPoints_; + if (initialSupply_ > 0) _mint(newOwner_, initialSupply_, true, ""); + } + + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256 amount, + bool force, + bytes memory data + ) internal override { + super._beforeTokenTransfer(operator, from, to, amount, force, data); + + // Skip tax on mint, burn, or treasury-involved transfers + if (from == address(0) || to == address(0)) return; + if (from == treasury || to == treasury) return; + + uint256 taxAmount = (amount * taxBasisPoints) / 10_000; + if (taxAmount == 0) return; + + _transfer(from, treasury, taxAmount, true, ""); + emit TaxCollected(from, to, taxAmount); + } + + function setTreasury(address newTreasury) external onlyOwner { + treasury = newTreasury; + } + + function setTaxRate(uint256 newBasisPoints) external onlyOwner { + require(newBasisPoints <= 1000, "TaxedLSP7: max 10%"); + taxBasisPoints = newBasisPoints; + } +}`, + language: 'solidity', + author: 'leo_assistant_chef', + authorAddress: '0x0000000000000000000000000000000000000Leo', + timestamp: Date.now() - 3600000, + tags: ['lsp7', 'token', 'tax', 'treasury', 'defi', 'lukso'], + likes: 0, + forks: 0, + isVerified: true, + ipfsHash: 'QmLeoTaxedLSP7' + }, + { + id: '6', + title: 'LSP6 Batch Permission Checker', + description: 'Utility contract to verify LSP6 controller permissions on a Universal Profile in a single batched call. Use for pre-flight checks in dApps before executing privileged actions.', + code: `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {IERC725Y} from "@erc725/smart-contracts/contracts/interfaces/IERC725Y.sol"; + +/** + * @title LSP6BatchPermissionChecker + * @notice Validate LSP6 permissions for multiple controllers in one getDataBatch call. + * + * USE CASE: Pre-flight check before privileged dApp actions. + * Instead of failing mid-transaction, verify permissions upfront. + */ +contract LSP6BatchPermissionChecker { + bytes10 constant _PERMISSIONS_KEY_PREFIX = 0x4b80742de2bf82acb3630000; + + /// @notice Check if a controller has ALL required permissions on a UP. + function checkPermissions( + address universalProfile, + address controller, + bytes32 requiredPermissions + ) external view returns (bool hasAll, bytes32 missing) { + bytes32 key = _buildPermissionsKey(controller); + bytes memory raw = IERC725Y(universalProfile).getData(key); + if (raw.length == 0) return (false, requiredPermissions); + + bytes32 granted = abi.decode(raw, (bytes32)); + missing = requiredPermissions ^ (granted & requiredPermissions); + hasAll = (missing == bytes32(0)); + } + + /// @notice Batch check permissions for multiple controllers at once. + function batchCheckPermissions( + address universalProfile, + address[] calldata controllers, + bytes32 requiredPerms + ) external view returns (bool[] memory results) { + uint256 count = controllers.length; + results = new bool[](count); + + bytes32[] memory keys = new bytes32[](count); + for (uint256 i = 0; i < count; i++) { + keys[i] = _buildPermissionsKey(controllers[i]); + } + + bytes[] memory rawValues = IERC725Y(universalProfile).getDataBatch(keys); + for (uint256 i = 0; i < count; i++) { + if (rawValues[i].length == 0) continue; + bytes32 granted = abi.decode(rawValues[i], (bytes32)); + results[i] = (granted & requiredPerms) == requiredPerms; + } + } + + function _buildPermissionsKey(address controller) internal pure returns (bytes32) { + return bytes32(abi.encodePacked(_PERMISSIONS_KEY_PREFIX, controller)); + } +}`, + language: 'solidity', + author: 'leo_assistant_chef', + authorAddress: '0x0000000000000000000000000000000000000Leo', + timestamp: Date.now() - 3600000, + tags: ['lsp6', 'permissions', 'erc725y', 'utility', 'lukso'], + likes: 0, + forks: 0, + isVerified: true, + ipfsHash: 'QmLeoLSP6BatchChecker' + }, + { + id: '7', + title: 'Read Universal Profile with erc725.js', + description: 'Fetch a full LSP3 profile (name, avatar, bio, tags, links) from any Universal Profile address. erc725.js handles ABI-decoding and IPFS resolution automatically.', + code: `import ERC725, { ERC725JSONSchema } from "@erc725/erc725.js"; +import LSP3ProfileSchema from "@erc725/erc725.js/schemas/LSP3ProfileMetadata.json"; + +const LUKSO_RPC = "https://rpc.mainnet.lukso.network"; +const IPFS_GATEWAY = "https://api.universalprofile.cloud/ipfs/"; + +interface UniversalProfileData { + address: string; + name: string; + description: string; + avatar?: string; + backgroundImage?: string; + tags: string[]; + links: Array<{ title: string; url: string }>; +} + +async function fetchUniversalProfile(upAddress: string): Promise { + const erc725 = new ERC725( + LSP3ProfileSchema as ERC725JSONSchema[], + upAddress, + LUKSO_RPC, + { ipfsGateway: IPFS_GATEWAY } + ); + + // fetchData resolves the IPFS URI and decodes the LSP3 JSON automatically + const profileData = await erc725.fetchData("LSP3Profile"); + if (!profileData?.value) return null; + + const profile = (profileData.value as any).LSP3Profile; + const resolveIpfs = (url?: string) => + url ? url.replace("ipfs://", IPFS_GATEWAY) : undefined; + + return { + address: upAddress, + name: profile.name ?? "Unknown", + description: profile.description ?? "", + avatar: resolveIpfs(profile.profileImage?.[0]?.url), + backgroundImage: resolveIpfs(profile.backgroundImage?.[0]?.url), + tags: profile.tags ?? [], + links: profile.links ?? [], + }; +} + +// Usage +const UP_ADDRESS = "0x82C4DC98e27CFe9D7d312250e972e7380Fbf6B77"; +fetchUniversalProfile(UP_ADDRESS).then((profile) => { + if (!profile) return console.log("No profile found"); + console.log("Name: ", profile.name); + console.log("Bio: ", profile.description); + console.log("Tags: ", profile.tags.join(", ")); + console.log("Links: ", profile.links.map((l) => \`\${l.title}: \${l.url}\`).join(", ")); +});`, + language: 'typescript', + author: 'leo_assistant_chef', + authorAddress: '0x0000000000000000000000000000000000000Leo', + timestamp: Date.now() - 3600000, + tags: ['lsp3', 'erc725y', 'profile', 'erc725js', 'lukso'], + likes: 0, + forks: 0, + isVerified: true, + ipfsHash: 'QmLeoReadUP' + }, + { + id: '8', + title: 'LSP7 Airdrop to All Followers', + description: 'Paginate through the LSP26 Follower Registry and airdrop LSP7 tokens to every follower in a single transferBatch call via your Universal Profile. One transaction, unlimited recipients.', + code: `import { createPublicClient, createWalletClient, encodeFunctionData, http, parseAbi, type Address, type Hex } from "viem"; +import { lukso } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; +import { lsp0Erc725AccountAbi } from "@lukso/lsp0-contracts/abi"; +import { lsp7DigitalAssetAbi } from "@lukso/lsp7-contracts/abi"; + +const LSP26_FOLLOWER_REGISTRY: Address = "0xf01103E5a9909Fc0DBe8166dA7085e0285daDDcA"; +const LSP26_ABI = parseAbi([ + "function followerCount(address addr) external view returns (uint256)", + "function getFollowersByIndex(address addr, uint256 startIndex, uint256 endIndex) external view returns (address[])", +]); + +async function airdropToFollowers(config: { + upAddress: Address; + privateKey: Hex; + tokenAddress: Address; + amountPerFollower: bigint; + force: boolean; +}): Promise { + const { upAddress, privateKey, tokenAddress, amountPerFollower, force } = config; + const account = privateKeyToAccount(privateKey); + const publicClient = createPublicClient({ chain: lukso, transport: http() }); + const walletClient = createWalletClient({ account, chain: lukso, transport: http() }); + + // Paginate through LSP26 follower registry + const followerCount = await publicClient.readContract({ + address: LSP26_FOLLOWER_REGISTRY, abi: LSP26_ABI, + functionName: "followerCount", args: [upAddress], + }); + + const followers: Address[] = []; + for (let start = 0n; start < followerCount; start += 100n) { + const end = start + 100n < followerCount ? start + 100n : followerCount; + const page = await publicClient.readContract({ + address: LSP26_FOLLOWER_REGISTRY, abi: LSP26_ABI, + functionName: "getFollowersByIndex", args: [upAddress, start, end], + }); + followers.push(...(page as Address[])); + } + + // Encode LSP7 batchTransfer and execute via UP + const batchTransferCalldata = encodeFunctionData({ + abi: lsp7DigitalAssetAbi, + functionName: "transferBatch", + args: [ + Array(followers.length).fill(upAddress), + followers, + Array(followers.length).fill(amountPerFollower), + Array(followers.length).fill(force), + Array(followers.length).fill("0x" as Hex), + ], + }); + + const txHash = await walletClient.writeContract({ + address: upAddress, abi: lsp0Erc725AccountAbi, + functionName: "execute", + args: [0n, tokenAddress, 0n, batchTransferCalldata], + }); + + console.log(\`โœ… Airdropped to \${followers.length} followers โ€” TX: \${txHash}\`); +}`, + language: 'typescript', + author: 'leo_assistant_chef', + authorAddress: '0x0000000000000000000000000000000000000Leo', + timestamp: Date.now() - 3600000, + tags: ['lsp7', 'lsp26', 'airdrop', 'followers', 'batch', 'lukso'], + likes: 0, + forks: 0, + isVerified: true, + ipfsHash: 'QmLeoAirdrop' + }, + { + id: '9', + title: 'Gasless Relay Transaction (LSP25)', + description: 'Sign and submit meta-transactions through the Key Manager\'s executeRelayCall. Enables gasless UX for users and agent-driven automation without requiring LYX. Full LSP25 spec with validity timestamps and channel nonces.', + code: `import { createPublicClient, createWalletClient, encodeFunctionData, encodeAbiParameters, keccak256, http, parseAbi, type Hex, type Address } from "viem"; +import { lukso } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; +import { lsp0Erc725AccountAbi } from "@lukso/lsp0-contracts/abi"; + +const lsp6KeyManagerAbi = parseAbi([ + "function getNonce(address from, uint128 channelId) external view returns (uint256)", + "function executeRelayCall(bytes signature, uint256 nonce, uint256 validityTimestamps, bytes payload) external payable returns (bytes)", +]); + +/** + * Execute a gasless relay transaction via LSP25. + * The controller signs โ€” anyone can submit and pay the gas. + */ +async function executeRelayTransaction( + config: { + upAddress: Address; + keyManagerAddress: Address; + controllerKey: Hex; + channelId?: number; + }, + upCalldata: Hex, + validForSeconds = 3600 +): Promise { + const { upAddress, keyManagerAddress, controllerKey, channelId = 0 } = config; + const account = privateKeyToAccount(controllerKey); + const publicClient = createPublicClient({ chain: lukso, transport: http() }); + const walletClient = createWalletClient({ account, chain: lukso, transport: http() }); + + // Get nonce (replay protection per channel) + const nonce = await publicClient.readContract({ + address: keyManagerAddress, abi: lsp6KeyManagerAbi, + functionName: "getNonce", args: [account.address, BigInt(channelId)], + }); + + // Build LSP25 validity timestamps: (validAfter << 128) | validBefore + const now = BigInt(Math.floor(Date.now() / 1000)); + const validityTimestamps = BigInt(now + BigInt(validForSeconds)); // validAfter = 0 + + // Hash per LSP25 spec: keccak256(version, chainId, nonce, validity, value, calldata) + const msgHash = keccak256( + encodeAbiParameters( + [{ type: "uint256" }, { type: "uint256" }, { type: "uint256" }, { type: "uint256" }, { type: "uint256" }, { type: "bytes" }], + [25n, BigInt(lukso.id), nonce, validityTimestamps, 0n, upCalldata] + ) + ); + + const signature = await walletClient.signMessage({ message: { raw: msgHash } }); + + return walletClient.writeContract({ + address: keyManagerAddress, abi: lsp6KeyManagerAbi, + functionName: "executeRelayCall", + args: [signature, nonce, validityTimestamps, upCalldata], + }); +} + +// Example: gasless setData on a Universal Profile +const setDataCalldata = encodeFunctionData({ + abi: lsp0Erc725AccountAbi, + functionName: "setData", + args: ["0xcafecafe...yourKey" as Hex, "0xdeadbeef" as Hex], +}); + +executeRelayTransaction( + { upAddress: "0xYourUP", keyManagerAddress: "0xYourKM", controllerKey: "0xYourKey" }, + setDataCalldata +).then((txHash) => console.log("โœ… Relay TX:", txHash));`, + language: 'typescript', + author: 'leo_assistant_chef', + authorAddress: '0x0000000000000000000000000000000000000Leo', + timestamp: Date.now() - 3600000, + tags: ['lsp25', 'lsp6', 'relay', 'gasless', 'meta-transaction', 'lukso'], + likes: 0, + forks: 0, + isVerified: true, + ipfsHash: 'QmLeoRelayLSP25' + }, + ] + featuredSnippets.forEach(snippet => { this.snippets.set(snippet.id, snippet) }) - this.idCounter = 4 + leoSnippets.forEach(snippet => { + this.snippets.set(snippet.id, snippet) + }) + this.idCounter = 10 } get(id: string): CodeSnippet | undefined {