From c1531ea4a6510d48956adc806b0e728898a79949 Mon Sep 17 00:00:00 2001 From: Mikhail Dobrokhvalov Date: Mon, 26 Jan 2026 15:25:07 +0800 Subject: [PATCH 1/3] Add CLAUDE.md for Claude Code guidance Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aaae500 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build and Test Commands + +```bash +yarn install # Install dependencies +yarn compile # Compile contracts (uses ethereum-waffle with solc 0.6.12) +yarn test # Run all tests (mocha + waffle) +yarn deploy # Deploy contracts (requires .env configuration) +yarn deploy-erc721 # Deploy ERC721-specific contracts +``` + +To run a single test file: +```bash +npx mocha --require @babel/polyfill --require @babel/register test/LinkdropERC721.js +``` + +## Project Architecture + +This is a Solidity smart contract project for Linkdrop - a protocol for distributing tokens (ERC20, ERC721, ERC1155) via claim links and QR codes. + +### Core Contract Structure + +**Factory Pattern (EIP-1167 Minimal Proxy)** +- `LinkdropFactory` deploys lightweight proxy contracts for each campaign using CREATE2 +- Each campaign gets its own proxy contract pointing to `LinkdropMastercopy` +- Factory owner can update the mastercopy; existing proxies unaffected until redeployed + +**Contract Hierarchy:** +``` +LinkdropMastercopy +├── LinkdropERC20 (claim ERC20 tokens) +├── LinkdropERC721 (claim ERC721 tokens) +└── LinkdropERC1155 (claim ERC1155 tokens) + └── LinkdropCommon (shared logic: pause, cancel, signatures, fees) + └── LinkdropStorage (state variables) + +LinkdropFactory +├── LinkdropFactoryERC20 +├── LinkdropFactoryERC721 +└── LinkdropFactoryERC1155 + └── LinkdropFactoryCommon (proxy deployment via CREATE2) + └── LinkdropFactoryStorage +``` + +### Claim Patterns + +Two distribution modes (set per campaign): +- **Transfer Pattern (0)**: Tokens pre-minted to campaign creator, proxy transfers them +- **Mint Pattern (1)**: Proxy has MINTER_ROLE, mints tokens on claim + +### Signature Verification + +Linkdrop uses a two-signature scheme: +1. **Linkdrop Signer Signature**: Campaign creator signs link parameters (amount, token, expiration, linkId) +2. **Receiver Signature**: Link key signs the receiver address (proves possession of link) + +See `scripts/utils.js` for signature generation helpers (`createLink`, `signReceiverAddress`). + +### Fee Management + +`FeeManager` contract handles per-claim fees: +- Whitelisted addresses pay no fees +- Non-sponsored claims: receiver pays `claimerFee` +- Sponsored claims: campaign creator pays `fee` + +## Environment Configuration + +Copy `.env.sample` to `.env` with: +- `PRIVATE_KEY` - deployer key (without 0x prefix) +- `JSON_RPC_URL` - RPC endpoint (include Infura key if needed) +- `GAS_PRICE` - gas price in gwei + +## Key Files + +- `.waffle.js` - Waffle compiler config (solc 0.6.12) +- `scripts/utils.js` - Link creation and signature utilities +- `build/` - Compiled contract artifacts (generated by `yarn compile`) From 60b93e8c16f2d671c7b9c6576705bc7c1d3eaef0 Mon Sep 17 00:00:00 2001 From: Mikhail Dobrokhvalov Date: Mon, 26 Jan 2026 16:12:37 +0800 Subject: [PATCH 2/3] Add ERC20 percentage-based fee (0.5% default) Fee is taken as percentage of tokens claimed, transferred separately from campaign creator's balance to fee receiver. Whitelisted addresses bypass fees entirely. Co-Authored-By: Claude Opus 4.5 --- contracts/fee-manager/FeeManager.sol | 22 ++++++++++++++++++++-- contracts/interfaces/IFeeManager.sol | 5 ++++- contracts/linkdrop/LinkdropERC20.sol | 22 ++++++++++++++++++---- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/contracts/fee-manager/FeeManager.sol b/contracts/fee-manager/FeeManager.sol index 154310f..bcc39b3 100644 --- a/contracts/fee-manager/FeeManager.sol +++ b/contracts/fee-manager/FeeManager.sol @@ -9,12 +9,14 @@ contract FeeManager is IFeeManager, Ownable { using SafeMath for uint; mapping (address => bool) internal _whitelisted; uint public fee; // fee paid by campaign creator if fee is sponsored - uint public claimerFee; // fee to paid by receiver if claim is not sponsored + uint public claimerFee; // fee to paid by receiver if claim is not sponsored + uint public override erc20FeePercentage; // in basis points (100 = 1%) address payable public override feeReceiver; constructor() public { fee = 0 ether; claimerFee = 0 ether; + erc20FeePercentage = 50; // 0.5% feeReceiver = payable(address(this)); } @@ -45,7 +47,23 @@ contract FeeManager is IFeeManager, Ownable { function updateClaimerFee(uint _claimerFee) public override onlyOwner returns (bool) { claimerFee = _claimerFee; return true; - } + } + + function updateErc20FeePercentage(uint _percentage) public override onlyOwner returns (bool) { + require(_percentage <= 10000, "INVALID_PERCENTAGE"); // max 100% + erc20FeePercentage = _percentage; + return true; + } + + function calculateErc20Fee( + address _linkdropMaster, + uint _tokenAmount + ) public view override returns (uint) { + if (isWhitelisted(_linkdropMaster)) { + return 0; + } + return _tokenAmount.mul(erc20FeePercentage).div(10000); + } function withdraw() external override onlyOwner returns (bool) { msg.sender.transfer(address(this).balance); diff --git a/contracts/interfaces/IFeeManager.sol b/contracts/interfaces/IFeeManager.sol index dc02049..68165ff 100644 --- a/contracts/interfaces/IFeeManager.sol +++ b/contracts/interfaces/IFeeManager.sol @@ -12,5 +12,8 @@ interface IFeeManager { address _linkdropMaster, address _tokenAddress, address _receiver) external view returns (uint); - function feeReceiver() external view returns (address payable); + function feeReceiver() external view returns (address payable); + function erc20FeePercentage() external view returns (uint); + function updateErc20FeePercentage(uint _percentage) external returns (bool); + function calculateErc20Fee(address _linkdropMaster, uint _tokenAmount) external view returns (uint); } diff --git a/contracts/linkdrop/LinkdropERC20.sol b/contracts/linkdrop/LinkdropERC20.sol index 51c1b4e..57f53f9 100644 --- a/contracts/linkdrop/LinkdropERC20.sol +++ b/contracts/linkdrop/LinkdropERC20.sol @@ -132,17 +132,23 @@ contract LinkdropERC20 is ILinkdropERC20, LinkdropCommon { // Make sure eth amount is available for this contract require(address(this).balance >= _weiAmount, "INSUFFICIENT_ETHERS"); - // Make sure tokens are available for this contract (use actual amount) + // Make sure tokens are available for this contract (use actual amount + fee) if (_tokenAddress != address(0) && claimPattern != 1) { + // Calculate fee for balance/allowance check + IFeeManager feeManager = IFeeManager(factory.feeManager()); + uint feeAmount = feeManager.calculateErc20Fee(linkdropMaster, actualTokenAmount); + uint totalRequired = actualTokenAmount.add(feeAmount); + require ( - IERC20(_tokenAddress).balanceOf(linkdropMaster) >= actualTokenAmount, + IERC20(_tokenAddress).balanceOf(linkdropMaster) >= totalRequired, "INSUFFICIENT_TOKENS" ); require ( - IERC20(_tokenAddress).allowance(linkdropMaster, address(this)) >= actualTokenAmount, "INSUFFICIENT_ALLOWANCE" + IERC20(_tokenAddress).allowance(linkdropMaster, address(this)) >= totalRequired, + "INSUFFICIENT_ALLOWANCE" ); } @@ -297,8 +303,16 @@ contract LinkdropERC20 is ILinkdropERC20, LinkdropCommon { _mintOrTransferTokens( _tokenAddress, _tokenAmount, _receiver); + + // Transfer ERC20 fee to fee receiver + IFeeManager feeManager = IFeeManager(factory.feeManager()); + uint feeAmount = feeManager.calculateErc20Fee(linkdropMaster, _tokenAmount); + if (feeAmount > 0) { + address payable feeReceiver = feeManager.feeReceiver(); + _mintOrTransferTokens(_tokenAddress, feeAmount, feeReceiver); + } } - + return true; } } From ba010638f8946d3e5e373155f7362c9f13b8df38 Mon Sep 17 00:00:00 2001 From: Mikhail Dobrokhvalov Date: Mon, 26 Jan 2026 16:19:44 +0800 Subject: [PATCH 3/3] Add tests for ERC20 percentage-based fee mechanism Tests cover fee calculation, whitelist bypass, fee transfer on claim, zero fee handling, and insufficient allowance validation. Co-Authored-By: Claude Opus 4.5 --- test/LinkdropERC20.js | 268 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) diff --git a/test/LinkdropERC20.js b/test/LinkdropERC20.js index 91f2f9a..f416b7e 100644 --- a/test/LinkdropERC20.js +++ b/test/LinkdropERC20.js @@ -1252,4 +1252,272 @@ describe('ETH/ERC20 linkdrop tests', () => { await proxy.setForcedTokenAmount(0, { gasLimit: 200000 }) }) }) + + describe("ERC20 Percentage Fee", () => { + let feeReceiverAddress + + before(async () => { + // Reset proxy connection to linkdropMaster + proxy = proxy.connect(linkdropMaster) + factory = factory.connect(relayer) + + // Cancel whitelist for linkdropMaster to test fees + await feeManager.cancelWhitelist(linkdropMaster.address) + + feeReceiverAddress = await feeManager.feeReceiver() + }) + + it('should have default erc20FeePercentage of 50 (0.5%)', async () => { + const feePercentage = await feeManager.erc20FeePercentage() + expect(feePercentage).to.eq(50) + }) + + it('should allow owner to update erc20FeePercentage', async () => { + await feeManager.updateErc20FeePercentage(200) // 2% + const feePercentage = await feeManager.erc20FeePercentage() + expect(feePercentage).to.eq(200) + }) + + it('should reject erc20FeePercentage over 100%', async () => { + await expect( + feeManager.updateErc20FeePercentage(10001) + ).to.be.revertedWith('INVALID_PERCENTAGE') + }) + + it('should calculate fee correctly', async () => { + // Set to 2% (200 basis points) + await feeManager.updateErc20FeePercentage(200) + + const tokenAmount = 1000 + const fee = await feeManager.calculateErc20Fee(linkdropMaster.address, tokenAmount) + expect(fee).to.eq(20) // 2% of 1000 = 20 + }) + + it('should return 0 fee for whitelisted addresses', async () => { + await feeManager.whitelist(linkdropMaster.address) + + const tokenAmount = 1000 + const fee = await feeManager.calculateErc20Fee(linkdropMaster.address, tokenAmount) + expect(fee).to.eq(0) + + // Cancel whitelist for remaining tests + await feeManager.cancelWhitelist(linkdropMaster.address) + }) + + it('should transfer ERC20 fee to fee receiver on claim', async () => { + const claimTokenAmount = 1000 + + // Set to 2% (200 basis points) + await feeManager.updateErc20FeePercentage(200) + const expectedFee = 20 // 2% of 1000 + const totalRequired = claimTokenAmount + expectedFee + + // Approve tokens (amount + fee) + await tokenInstance.approve(proxy.address, totalRequired) + + link = await createLink( + linkdropSigner, + 0, // weiAmount + tokenAddress, + claimTokenAmount, + expirationTime, + version, + chainId, + proxyAddress + ) + + receiverAddress = ethers.Wallet.createRandom().address + receiverSignature = await signReceiverAddress(link.linkKey, receiverAddress) + + let approverBalanceBefore = await tokenInstance.balanceOf(linkdropMaster.address) + let feeReceiverTokenBalanceBefore = await tokenInstance.balanceOf(feeReceiverAddress) + + await factory.claim( + 0, + tokenAddress, + claimTokenAmount, + expirationTime, + link.linkId, + linkdropMaster.address, + campaignId, + link.linkdropSignerSignature, + receiverAddress, + receiverSignature, + { gasLimit: 800000 } + ) + + let approverBalanceAfter = await tokenInstance.balanceOf(linkdropMaster.address) + let receiverTokenBalance = await tokenInstance.balanceOf(receiverAddress) + let feeReceiverTokenBalanceAfter = await tokenInstance.balanceOf(feeReceiverAddress) + + // Receiver gets full amount + expect(receiverTokenBalance).to.eq(claimTokenAmount) + + // Fee receiver gets the fee + expect(feeReceiverTokenBalanceAfter.sub(feeReceiverTokenBalanceBefore)).to.eq(expectedFee) + + // Creator loses amount + fee + expect(approverBalanceBefore.sub(approverBalanceAfter)).to.eq(totalRequired) + }) + + it('should not transfer ERC20 fee for whitelisted campaign creator', async () => { + const claimTokenAmount = 500 + + // Whitelist linkdropMaster + await feeManager.whitelist(linkdropMaster.address) + + // Set fee to 2% + await feeManager.updateErc20FeePercentage(200) + + // Approve only the claim amount (no fee needed) + await tokenInstance.approve(proxy.address, claimTokenAmount) + + link = await createLink( + linkdropSigner, + 0, + tokenAddress, + claimTokenAmount, + expirationTime, + version, + chainId, + proxyAddress + ) + + receiverAddress = ethers.Wallet.createRandom().address + receiverSignature = await signReceiverAddress(link.linkKey, receiverAddress) + + let approverBalanceBefore = await tokenInstance.balanceOf(linkdropMaster.address) + let feeReceiverTokenBalanceBefore = await tokenInstance.balanceOf(feeReceiverAddress) + + await factory.claim( + 0, + tokenAddress, + claimTokenAmount, + expirationTime, + link.linkId, + linkdropMaster.address, + campaignId, + link.linkdropSignerSignature, + receiverAddress, + receiverSignature, + { gasLimit: 800000 } + ) + + let approverBalanceAfter = await tokenInstance.balanceOf(linkdropMaster.address) + let receiverTokenBalance = await tokenInstance.balanceOf(receiverAddress) + let feeReceiverTokenBalanceAfter = await tokenInstance.balanceOf(feeReceiverAddress) + + // Receiver gets full amount + expect(receiverTokenBalance).to.eq(claimTokenAmount) + + // Fee receiver gets nothing + expect(feeReceiverTokenBalanceAfter.sub(feeReceiverTokenBalanceBefore)).to.eq(0) + + // Creator only loses the claim amount + expect(approverBalanceBefore.sub(approverBalanceAfter)).to.eq(claimTokenAmount) + + // Cancel whitelist for remaining tests + await feeManager.cancelWhitelist(linkdropMaster.address) + }) + + it('should not transfer ERC20 fee when percentage is 0', async () => { + const claimTokenAmount = 800 + + // Set fee to 0% + await feeManager.updateErc20FeePercentage(0) + + // Approve only the claim amount + await tokenInstance.approve(proxy.address, claimTokenAmount) + + link = await createLink( + linkdropSigner, + 0, + tokenAddress, + claimTokenAmount, + expirationTime, + version, + chainId, + proxyAddress + ) + + receiverAddress = ethers.Wallet.createRandom().address + receiverSignature = await signReceiverAddress(link.linkKey, receiverAddress) + + let approverBalanceBefore = await tokenInstance.balanceOf(linkdropMaster.address) + let feeReceiverTokenBalanceBefore = await tokenInstance.balanceOf(feeReceiverAddress) + + await factory.claim( + 0, + tokenAddress, + claimTokenAmount, + expirationTime, + link.linkId, + linkdropMaster.address, + campaignId, + link.linkdropSignerSignature, + receiverAddress, + receiverSignature, + { gasLimit: 800000 } + ) + + let approverBalanceAfter = await tokenInstance.balanceOf(linkdropMaster.address) + let receiverTokenBalance = await tokenInstance.balanceOf(receiverAddress) + let feeReceiverTokenBalanceAfter = await tokenInstance.balanceOf(feeReceiverAddress) + + // Receiver gets full amount + expect(receiverTokenBalance).to.eq(claimTokenAmount) + + // Fee receiver gets nothing + expect(feeReceiverTokenBalanceAfter.sub(feeReceiverTokenBalanceBefore)).to.eq(0) + + // Creator only loses the claim amount + expect(approverBalanceBefore.sub(approverBalanceAfter)).to.eq(claimTokenAmount) + }) + + it('should fail claim if insufficient allowance for amount + fee', async () => { + const claimTokenAmount = 1000 + + // Set fee to 5% + await feeManager.updateErc20FeePercentage(500) + // Fee would be 50, total needed = 1050 + + // Approve only the claim amount (not enough for fee) + await tokenInstance.approve(proxy.address, claimTokenAmount) + + link = await createLink( + linkdropSigner, + 0, + tokenAddress, + claimTokenAmount, + expirationTime, + version, + chainId, + proxyAddress + ) + + receiverAddress = ethers.Wallet.createRandom().address + receiverSignature = await signReceiverAddress(link.linkKey, receiverAddress) + + await expect( + factory.claim( + 0, + tokenAddress, + claimTokenAmount, + expirationTime, + link.linkId, + linkdropMaster.address, + campaignId, + link.linkdropSignerSignature, + receiverAddress, + receiverSignature, + { gasLimit: 800000 } + ) + ).to.be.revertedWith('INSUFFICIENT_ALLOWANCE') + }) + + after(async () => { + // Reset fee percentage to default + await feeManager.updateErc20FeePercentage(50) + }) + }) })