I was seeking to trade NFTs between two untrusted parties one day and decided to put together a solution in solidity. Check out my reasonings and code below and let me know what you’d do differently for this use case.
Case: Two parties don’t trust each other, but want to trade NFTs.
Givens: Each NFT is of the same collection. Each party knows their NFT ID and the counterparty’s NFT ID.
First import a couple of OpenZeppelin packages and our header:
// SPDX-License-Identifier: MIT LICENSE
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
Then we’ll create a basic ERC721 token and mint token #1 to address 0x5B and token #2 to address 0xAB on deployment.
contract Token is ERC721Enumerable {
constructor() ERC721("Trade Token", "TTX") {
_safeMint(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4, 1);
_safeMint(0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2, 2);
}
}
From here we’ll start on the trading contract. First we pass the NFT token contract address and create a reference to the token contract so we can use its functions later.
contract Trade is IERC721Receiver {
// pass the token contract address here to reference
constructor(Token _token) {
token = _token;
}
// reference to the NFT contract
Token token;
using Strings for uint256;
Now we create and map a data structure for the escrow information about each trade. We’ll collect the proposer (who initiated the trade), their tokenId to trade, which tokenId they want to receive, who owns that tokenId, and also a timestamp.
struct Escrow {
uint24 proposerToken;
uint24 accepterToken;
uint48 timestamp;
address proposer;
address accepter;
}
// maps proposerToken to escrow
mapping(uint256 => Escrow) public escrow;
In order for our trade contract to hold our NFTs we need to utilize the ERC721Receiver
function onERC721Received(address, address from, uint256, bytes calldata) external pure override returns (bytes4) {
require(from != address(0x0));
return IERC721Receiver.onERC721Received.selector;
}
Now is when we start with the initial function for trading, proposing a trade. Here we pass two arguments, the proposerToken (the token of the proposer), and the accecpterToken (the token the proposer will receive). The function checks who owns the accepter NFT, it transfers the proposer NFT to the contract for holding, and then logs all of the relevant data into to the mapping we created previously.
function proposeTrade(uint256 proposerToken, uint256 accepterToken) public {
address accepter = token.ownerOf(accepterToken);
token.transferFrom(msg.sender, address(this), proposerToken);
escrow[proposerToken] = Escrow({
proposerToken: uint24(proposerToken),
accepterToken: uint24(accepterToken),
proposer: msg.sender,
accepter: address(accepter),
timestamp: uint48(block.timestamp)
});
}
Next it’s time for the accepter to decide if they would like to accept the trade or cancel the trade. If the accepter accepts by calling the acceptTrade function, we’ll call the finalizeTrade function — completing the transfer.
If they cancel by calling the cancelTrade function, we’ll send the proposerToken back to the proposer and delete the escrow mapping entry.
function acceptTrade(uint256 proposerToken) public {
_finalizeTrade(proposerToken, msg.sender);
}
function _finalizeTrade(uint256 proposerToken, address accepter) internal {
Escrow memory escrowed = escrow[proposerToken];
uint256 accepterToken = escrowed.accepterToken;
address proposer = escrowed.proposer;
token.transferFrom(address(this), accepter, proposerToken);
token.transferFrom(accepter, proposer, accepterToken);
delete escrow[proposerToken];
}
function cancelTrade(uint256 proposerToken) public { Escrow memory escrowed = escrow[proposerToken]; address accepter = escrowed.accepter; address proposer = escrowed.proposer; require(msg.sender == proposer || msg.sender == accepter, "not approved to make that decision"); token.transferFrom(address(this), proposer, proposerToken); delete escrow[proposerToken]; }