ERC-7531: Staked ERC-721 Ownership Recognition
When NFTs are staked into a contract (for staking rewards, lending, yield-farming), the staking contract becomes the literal ownerOf — but tools that compute ownership snapshots (governance, airdrops, soulbound badges) typically want to credit the original owner. ERC-7531 standardises a originalOwnerOf(tokenId) view that unwraps the staking layer and returns the underlying owner.
Used by:
- Snapshot-based airdrops that want to credit stakers, not staking-contract addresses.
- Governance that counts staked NFTs for the original owner's voting weight.
- NFT-gated communities where staked NFTs still grant access to the original owner.
Required Interface
solidity
interface IERC7531 {
function originalOwnerOf(uint256 tokenId) external view returns (address);
}The implementation is on the staking contract, not on the NFT contract — the staking contract knows who deposited what. When asked "who really owns tokenId N", the staking contract returns the depositor's address; otherwise it returns its own (i.e. it actually holds the NFT).
Neo Equivalent: Staking Contract View
csharp
[DisplayName("NFTStaking")]
public class NFTStaking : SmartContract
{
private const byte Prefix_Stake = 0x10;
[Safe]
public static UInt160 OriginalOwnerOf(UInt160 nftContract, ByteString tokenId)
{
var raw = Storage.Get(Storage.CurrentContext, StakeKey(nftContract, tokenId));
if (raw is null) return null; // not staked here
return (UInt160)raw;
}
public static void Stake(UInt160 nftContract, ByteString tokenId)
{
var depositor = Runtime.CallingScriptHash;
if (!Runtime.CheckWitness(depositor)) throw new Exception("STK:NoAuth");
// Pull NFT into custody.
Contract.Call(nftContract, "transfer", CallFlags.All,
new object[] { Runtime.ExecutingScriptHash, tokenId, null });
Storage.Put(Storage.CurrentContext, StakeKey(nftContract, tokenId), depositor);
}
public static void Withdraw(UInt160 nftContract, ByteString tokenId)
{
var depositor = (UInt160)Storage.Get(Storage.CurrentContext, StakeKey(nftContract, tokenId));
if (!Runtime.CheckWitness(depositor)) throw new Exception("STK:NotDepositor");
Storage.Delete(Storage.CurrentContext, StakeKey(nftContract, tokenId));
Contract.Call(nftContract, "transfer", CallFlags.All,
new object[] { depositor, tokenId, null });
}
private static byte[] StakeKey(UInt160 nft, ByteString tokenId)
=> new byte[] { Prefix_Stake }.Concat(nft).Concat(tokenId);
}| ERC-7531 (Ethereum) | Neo Equivalent | Notes |
|---|---|---|
originalOwnerOf(tokenId) (on staking contract) | OriginalOwnerOf(nftContract, tokenId) view | Multi-collection staking |
| Returns address(0) / null when not staked | Returns null when not staked | Same convention |
| Snapshot tool unwrap logic | Same — query staking contract first, fallback to NFT contract | Direct port |
Snapshot Tool Pattern
typescript
async function effectiveOwnerOf(nftContract: string, tokenId: string): Promise<string> {
// First check known staking contracts.
for (const staking of KNOWN_STAKING_CONTRACTS) {
const original = await rpc.invokeFunction(staking, 'originalOwnerOf', [nftContract, tokenId]);
if (original.state === 'HALT' && original.stack[0].value !== null) {
return original.stack[0].value;
}
}
// Fall back to direct ownership.
const result = await rpc.invokeFunction(nftContract, 'ownerOf', [tokenId]);
return result.stack[0].value;
}Composition
- ERC-7066 — lockable. Staking via lock (no custody transfer) is an alternative; pair with ERC-7531 if both models are supported.
- ERC-5805 — voting. Combine: NFT-bound voting weight should use
effectiveOwnerOffor snapshots.
Migration Notes
For NFT staking platforms / snapshot tools:
- Staking contracts implement the view — registry of which collections each staking contract supports.
- Snapshot tools maintain a list of known staking contracts and query them in order.
- For ambiguous cases (NFT could be in multiple staking contracts simultaneously — should never happen but defensive code), the first stakers wins.
