Accept PYUSD
We can now modify our free mint NFT so that it accepts PYUSD as payment.
The PYUSD token
PYUSD is an ERC-20 token. As protocol developers, all we need to know is its address, and we can pull it in and use it just like any other ERC-20.
What is ERC-20
Similar to the ERC-721 we're building in this walkthrough, an ERC-20 token adheres to certain interface and behaviors to define a fungible token. ERC stands for "Ethereum Request for Comments" and is a process by which essential standards are defined.
Let's take note of the PYUSD addresses we'll need:
- Sepolia Testnet
0xCaC524BcA292aaade2DF8A05cC58F0a65B1B3bB9
- Ethereum Mainnet
0x6c3ea9036406852006290770BEdFcAbA0e23A0e8
Mock token for testing
When writing unit tests, we don't have a deployed version of PYUSD in our test runner's simulated blockchain. We'll create a mock PYUSD for local tests. Create a test/MockPYUSD.sol
file that inherits from solmate's ERC20:
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import {ERC20} from "solmate/tokens/ERC20.sol";
contract MockPYUSD is ERC20 {
constructor() ERC20("MockPYUSD", "mockPYUSD", 6) {}
function mint(address to, uint256 value) public virtual {
_mint(to, value);
}
}
This is a simple ERC-20 contract that we can use to mint any number of tokens. We'll use this in our tests to simulate PYUSD.
NOTE
For complex integrations, we can instead use forge's forking functionality to fork a live network, which lets us write our tests against the deployed contract logic and state.
Let's now commit our MockPYUSD.sol
contract:
git add test/MockPYUSD.sol
git commit -m 'add MockPYUSD ERC-20 contract for local tests'
How to accept PYUSD
Unlike native ETH values which can be specified as the value
part of a call to a function, ERC-20s require a two step process to be transferred as part of contract calls:
- Token holder approves a spender (the contract) a maximum amount (allowance) available to be spent.
- Token holder makes a call the spender, which then transfers an amount from the holder.
"Pull" payments
This pattern is sometimes referred to as pull payments; the payee pulls the value from the payer.
TDDing the payment process
We can modify our test so that our testMint()
function checks the mint price and approves our mock PYUSD to be spent by the mint()
function.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {HelloPYUSD} from "../src/HelloPYUSD.sol";
import {MockPYUSD} from "./MockPYUSD.sol";
contract HelloPYUSDTest is Test {
MockPYUSD public pyusd;
HelloPYUSD public helloPYUSD;
uint256 constant MINT_PRICE = 100e6;
function setUp() public {
pyusd = new MockPYUSD();
helloPYUSD = new HelloPYUSD(address(pyusd), MINT_PRICE);
}
function testCreate() public {
assertNotEq(address(helloPYUSD), address(0));
}
function testMint() public {
pyusd.mint(address(this), MINT_PRICE);
pyusd.approve(address(helloPYUSD), MINT_PRICE);
helloPYUSD.mint();
assertEq(pyusd.balanceOf(address(this)), 0);
assertEq(pyusd.balanceOf(address(helloPYUSD)), MINT_PRICE);
assertEq(helloPYUSD.balanceOf(address(this)), 1);
assertEq(helloPYUSD.totalIssued(), 1);
}
}
We're doing a few things here, and it's a bit too much to step though everything, but note that in our setUp()
we're creating a MockPYUSD
contract, and we're using its address to construct our HelloPYUSD
contract, along with a new MINT_PRICE
.
If we're running our tests – forge test -vw
is pretty handy, right? – this change will break our build with a compile error. We're trying to call a non-existent mintPrice()
function! So let's go fix up our contract:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import {ERC721} from "solmate/tokens/ERC721.sol";
import {ERC20} from "solmate/tokens/ERC20.sol";
contract HelloPYUSD is ERC721 {
uint256 public totalIssued;
ERC20 public immutable mintToken;
uint256 public immutable mintPrice;
constructor(address _mintToken, uint256 _mintPrice) ERC721("HelloPYUSD", "HIPYPL") {
mintToken = ERC20(_mintToken);
mintPrice = _mintPrice;
}
function mint() external {
mintToken.transferFrom(msg.sender, address(this), mintPrice);
_mint(msg.sender, ++totalIssued);
}
function tokenURI(uint256) public pure override returns (string memory) {
return "";
}
}
Now our tests should be passing again. Lovely!
NOTE
The behavior of the ERC-20 token depends on how it is implemented. Correctly implemented ERC-20s will now allow insufficient balances to be spent, nor will allow spenders to go over their allowance.
While it's outside the purview of our own contract (and is the responsibility of PYUSD, which of course does everything correctly), if you want to see these error cases in action, you can add some failure tests:
function testFail_MintWithoutApproval() public {
helloPYUSD.mint();
}
function testFail_MintWithInsufficientBalance() public {
pyusd.approve(address(helloPYUSD), MINT_PRICE);
helloPYUSD.mint();
}
Let's commit our latest work here:
git commit -am 'accept ERC-20 token for mint payment'
Who gets the PYUSD?
In the contract we've created, all the PYUSD is deposited to the contract itself. Once there, it's never usable for anything, so it's essentially locked away! There's currently no way for an actual human to receive it.
Let's fix this by letting an owner withdraw ETH and ERC-20s from this contract. In our tests, let's add failing tests:
function testOwnerWithdrawToken() public {
pyusd.mint(address(helloPYUSD), MINT_PRICE);
helloPYUSD.withdrawToken(address(pyusd), address(this));
assertEq(pyusd.balanceOf(address(this)), MINT_PRICE);
}
function testFail_nonOwnerWithdrawToken() public {
vm.prank(address(0xabcd));
helloPYUSD.withdrawToken(address(pyusd), address(this));
}
function testOwnerWithdraw() public {
address recipient = address(0x1234);
vm.deal(address(helloPYUSD), 1 ether);
helloPYUSD.withdraw(recipient);
assertEq(recipient.balance, 1 ether);
}
function testFail_nonOwnerWithdraw() public {
vm.prank(address(0xabcd));
helloPYUSD.withdraw(address(this));
}
These will break our build, so let's go ahead and implement withdrawToken
and withdraw
. First, import Owned
and SafeTransferLib
in our contract:
import {Owned} from "solmate/auth/Owned.sol";
import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol";
Now ensure we are inheriting from Owned
and defaulting the owner to the contract deployer.
contract HelloPYUSD is ERC721, Owned {
// ...
constructor(address _mintToken, uint256 _mintPrice) ERC721("HelloPYUSD", "HIPYPL") Owned(msg.sender) {
//...
}
// ...
}
Finally, we'll add our withdraw functions:
function withdrawToken(address token, address to) external onlyOwner {
SafeTransferLib.safeTransfer(ERC20(token), to, ERC20(token).balanceOf(address(this)));
}
function withdraw(address to) external onlyOwner {
SafeTransferLib.safeTransferETH(to, address(this).balance);
}
At this point, tests should be passing and we can commit:
git commit -am 'add withdraw functions'