Skip to content

Commit 43a67a5

Browse files
authored
Loyalty card macro audit fixes (#414)
* rename account factory callbacks * Create DynamicNFT prebuilt contract * Rename terminology to LoyaltyCard * Check for flat platform fee * Add test cases for loyalty card * Rename cancel and revoke loyalty, and reintroduce BURN_ROLE * rename BURN_ROLE -> REVOKE_ROLE * docs update * Fix [H-1] Mint price is determined by quantity * Fix [Q-1] ReentrancyGuardUpgradeable not initialized * Fix [I-1] Transfers are enabled by default * Add ascii and description * add audit report
1 parent 63360f3 commit 43a67a5

File tree

12 files changed

+2680
-0
lines changed

12 files changed

+2680
-0
lines changed

audit-reports/audit-13.pdf

416 KB
Binary file not shown.

contracts/LoyaltyCard.sol

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.11;
3+
4+
/// @author thirdweb
5+
6+
// $$\ $$\ $$\ $$\ $$\
7+
// $$ | $$ | \__| $$ | $$ |
8+
// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\
9+
// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\
10+
// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ |
11+
// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ |
12+
// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ |
13+
// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/
14+
15+
// Interface
16+
import "./interfaces/ILoyaltyCard.sol";
17+
18+
// Base
19+
import "./eip/ERC721AVirtualApproveUpgradeable.sol";
20+
21+
// Lib
22+
import "./lib/CurrencyTransferLib.sol";
23+
24+
// Extensions
25+
import "./extension/NFTMetadata.sol";
26+
import "./extension/SignatureMintERC721Upgradeable.sol";
27+
import "./extension/ContractMetadata.sol";
28+
import "./extension/Ownable.sol";
29+
import "./extension/Royalty.sol";
30+
import "./extension/PrimarySale.sol";
31+
import "./extension/PlatformFee.sol";
32+
import "./extension/Multicall.sol";
33+
import "./extension/PermissionsEnumerable.sol";
34+
import "./extension/DefaultOperatorFiltererUpgradeable.sol";
35+
import "./openzeppelin-presets/metatx/ERC2771ContextUpgradeable.sol";
36+
import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";
37+
38+
/**
39+
* @title LoyaltyCard
40+
*
41+
* @custom:description This contract is a loyalty card NFT collection. Each NFT represents a loyalty card, and the NFT's metadata
42+
* contains the loyalty card's information. A loyalty card's metadata can be updated by an admin of the contract.
43+
* A loyalty card can be cancelled (i.e. 'burned') by its owner or an approved operator. A loyalty card can be revoked
44+
* (i.e. 'burned') without its owner's approval, by an admin of the contract.
45+
*/
46+
contract LoyaltyCard is
47+
ILoyaltyCard,
48+
ContractMetadata,
49+
Ownable,
50+
Royalty,
51+
PrimarySale,
52+
PlatformFee,
53+
Multicall,
54+
PermissionsEnumerable,
55+
ReentrancyGuardUpgradeable,
56+
DefaultOperatorFiltererUpgradeable,
57+
ERC2771ContextUpgradeable,
58+
NFTMetadata,
59+
SignatureMintERC721Upgradeable,
60+
ERC721AUpgradeable
61+
{
62+
/*///////////////////////////////////////////////////////////////
63+
State variables
64+
//////////////////////////////////////////////////////////////*/
65+
66+
/// @dev Only TRANSFER_ROLE holders can have tokens transferred from or to them, during restricted transfers.
67+
bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE");
68+
/// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s.
69+
bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE");
70+
/// @dev Only METADATA_ROLE holders can update NFT metadata.
71+
bytes32 private constant METADATA_ROLE = keccak256("METADATA_ROLE");
72+
/// @dev Only REVOKE_ROLE holders can revoke a loyalty card.
73+
bytes32 private constant REVOKE_ROLE = keccak256("REVOKE_ROLE");
74+
75+
/// @dev Max bps in the thirdweb system.
76+
uint256 private constant MAX_BPS = 10_000;
77+
78+
/*///////////////////////////////////////////////////////////////
79+
Constructor + initializer
80+
//////////////////////////////////////////////////////////////*/
81+
82+
constructor() initializer {}
83+
84+
/// @dev Initiliazes the contract, like a constructor.
85+
function initialize(
86+
address _defaultAdmin,
87+
string memory _name,
88+
string memory _symbol,
89+
string memory _contractURI,
90+
address[] memory _trustedForwarders,
91+
address _saleRecipient,
92+
address _royaltyRecipient,
93+
uint128 _royaltyBps,
94+
uint128 _platformFeeBps,
95+
address _platformFeeRecipient
96+
) external initializer {
97+
// Initialize inherited contracts, most base-like -> most derived.
98+
__ERC2771Context_init(_trustedForwarders);
99+
__ERC721A_init(_name, _symbol);
100+
__DefaultOperatorFilterer_init();
101+
__SignatureMintERC721_init();
102+
__ReentrancyGuard_init();
103+
104+
_setupContractURI(_contractURI);
105+
_setupOwner(_defaultAdmin);
106+
_setOperatorRestriction(true);
107+
108+
_setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin);
109+
_setupRole(MINTER_ROLE, _defaultAdmin);
110+
_setupRole(TRANSFER_ROLE, _defaultAdmin);
111+
112+
_setupRole(METADATA_ROLE, _defaultAdmin);
113+
_setRoleAdmin(METADATA_ROLE, METADATA_ROLE);
114+
115+
_setupRole(REVOKE_ROLE, _defaultAdmin);
116+
_setRoleAdmin(REVOKE_ROLE, REVOKE_ROLE);
117+
118+
_setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps);
119+
_setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps);
120+
_setupPrimarySaleRecipient(_saleRecipient);
121+
}
122+
123+
/*///////////////////////////////////////////////////////////////
124+
ERC 165 / 721 / 2981 logic
125+
//////////////////////////////////////////////////////////////*/
126+
127+
/// @dev Returns the URI for a given tokenId.
128+
function tokenURI(uint256 _tokenId) public view override returns (string memory) {
129+
return _getTokenURI(_tokenId);
130+
}
131+
132+
/// @dev See ERC 165
133+
function supportsInterface(bytes4 interfaceId)
134+
public
135+
view
136+
virtual
137+
override(ERC721AUpgradeable, IERC165)
138+
returns (bool)
139+
{
140+
return super.supportsInterface(interfaceId) || type(IERC2981).interfaceId == interfaceId;
141+
}
142+
143+
/*///////////////////////////////////////////////////////////////
144+
External functions
145+
//////////////////////////////////////////////////////////////*/
146+
147+
/// @dev Mints an NFT according to the provided mint request. Always mints 1 NFT.
148+
function mintWithSignature(MintRequest calldata _req, bytes calldata _signature)
149+
external
150+
payable
151+
nonReentrant
152+
returns (address signer)
153+
{
154+
require(_req.quantity == 1, "LoyaltyCard: only 1 NFT can be minted at a time.");
155+
156+
signer = _processRequest(_req, _signature);
157+
address receiver = _req.to;
158+
uint256 tokenIdMinted = _mintTo(receiver, _req.uri);
159+
160+
// Set royalties, if applicable.
161+
if (_req.royaltyRecipient != address(0) && _req.royaltyBps != 0) {
162+
_setupRoyaltyInfoForToken(tokenIdMinted, _req.royaltyRecipient, _req.royaltyBps);
163+
}
164+
165+
_collectPrice(_req.primarySaleRecipient, _req.quantity, _req.currency, _req.pricePerToken);
166+
167+
emit TokensMintedWithSignature(signer, receiver, tokenIdMinted, _req);
168+
}
169+
170+
/// @dev Lets an account with MINTER_ROLE mint an NFT. Always mints 1 NFT.
171+
function mintTo(address _to, string calldata _uri) external onlyRole(MINTER_ROLE) returns (uint256 tokenIdMinted) {
172+
tokenIdMinted = _mintTo(_to, _uri);
173+
emit TokensMinted(_to, tokenIdMinted, _uri);
174+
}
175+
176+
/// @dev Burns `tokenId`. See {ERC721-_burn}.
177+
function cancel(uint256 tokenId) external virtual override {
178+
// note: ERC721AUpgradeable's `_burn(uint256,bool)` internally checks for token approvals.
179+
_burn(tokenId, true);
180+
}
181+
182+
/// @dev Burns `tokenId`. See {ERC721-_burn}.
183+
function revoke(uint256 tokenId) external virtual override onlyRole(REVOKE_ROLE) {
184+
_burn(tokenId);
185+
}
186+
187+
/*///////////////////////////////////////////////////////////////
188+
Operator filter overrides
189+
//////////////////////////////////////////////////////////////*/
190+
191+
/// @dev See {ERC721-setApprovalForAll}.
192+
function setApprovalForAll(address operator, bool approved) public override onlyAllowedOperatorApproval(operator) {
193+
super.setApprovalForAll(operator, approved);
194+
}
195+
196+
/// @dev See {ERC721-approve}.
197+
function approve(address operator, uint256 tokenId) public override onlyAllowedOperatorApproval(operator) {
198+
super.approve(operator, tokenId);
199+
}
200+
201+
/// @dev See {ERC721-_transferFrom}.
202+
function transferFrom(
203+
address from,
204+
address to,
205+
uint256 tokenId
206+
) public override(ERC721AUpgradeable) onlyAllowedOperator(from) {
207+
super.transferFrom(from, to, tokenId);
208+
}
209+
210+
/// @dev See {ERC721-_safeTransferFrom}.
211+
function safeTransferFrom(
212+
address from,
213+
address to,
214+
uint256 tokenId
215+
) public override(ERC721AUpgradeable) onlyAllowedOperator(from) {
216+
super.safeTransferFrom(from, to, tokenId);
217+
}
218+
219+
/// @dev See {ERC721-_safeTransferFrom}.
220+
function safeTransferFrom(
221+
address from,
222+
address to,
223+
uint256 tokenId,
224+
bytes memory data
225+
) public override(ERC721AUpgradeable) onlyAllowedOperator(from) {
226+
super.safeTransferFrom(from, to, tokenId, data);
227+
}
228+
229+
/*///////////////////////////////////////////////////////////////
230+
Miscellaneous
231+
//////////////////////////////////////////////////////////////*/
232+
233+
/**
234+
* @dev Returns the total amount of tokens minted in the contract.
235+
*/
236+
function totalMinted() external view returns (uint256) {
237+
unchecked {
238+
return _currentIndex - _startTokenId();
239+
}
240+
}
241+
242+
/// @dev The tokenId of the next NFT that will be minted / lazy minted.
243+
function nextTokenIdToMint() external view returns (uint256) {
244+
return _currentIndex;
245+
}
246+
247+
/*///////////////////////////////////////////////////////////////
248+
Internal functions
249+
//////////////////////////////////////////////////////////////*/
250+
251+
/// @dev Collects and distributes the primary sale value of NFTs being claimed.
252+
function _collectPrice(
253+
address _primarySaleRecipient,
254+
uint256 _quantityToClaim,
255+
address _currency,
256+
uint256 _pricePerToken
257+
) internal {
258+
if (_pricePerToken == 0) {
259+
return;
260+
}
261+
262+
uint256 totalPrice = _quantityToClaim * _pricePerToken;
263+
264+
bool validMsgValue;
265+
if (_currency == CurrencyTransferLib.NATIVE_TOKEN) {
266+
validMsgValue = msg.value == totalPrice;
267+
} else {
268+
validMsgValue = msg.value == 0;
269+
}
270+
require(validMsgValue, "Invalid msg value");
271+
272+
address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient;
273+
274+
uint256 fees;
275+
address feeRecipient;
276+
277+
PlatformFeeType feeType = getPlatformFeeType();
278+
if (feeType == PlatformFeeType.Flat) {
279+
(feeRecipient, fees) = getFlatPlatformFeeInfo();
280+
} else {
281+
uint16 platformFeeBps;
282+
(feeRecipient, platformFeeBps) = getPlatformFeeInfo();
283+
fees = (totalPrice * platformFeeBps) / MAX_BPS;
284+
}
285+
286+
require(totalPrice >= fees, "Fees greater than price");
287+
288+
CurrencyTransferLib.transferCurrency(_currency, _msgSender(), feeRecipient, fees);
289+
CurrencyTransferLib.transferCurrency(_currency, _msgSender(), saleRecipient, totalPrice - fees);
290+
}
291+
292+
/// @dev Mints an NFT to `to`
293+
function _mintTo(address _to, string calldata _uri) internal returns (uint256 tokenIdToMint) {
294+
tokenIdToMint = _currentIndex;
295+
296+
_setTokenURI(tokenIdToMint, _uri);
297+
_safeMint(_to, 1);
298+
}
299+
300+
/// @dev See {ERC721-_beforeTokenTransfer}.
301+
function _beforeTokenTransfers(
302+
address from,
303+
address to,
304+
uint256 startTokenId,
305+
uint256 quantity
306+
) internal virtual override {
307+
super._beforeTokenTransfers(from, to, startTokenId, quantity);
308+
309+
// if transfer is restricted on the contract, we still want to allow burning and minting
310+
if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) {
311+
if (!hasRole(TRANSFER_ROLE, from) && !hasRole(TRANSFER_ROLE, to)) {
312+
revert("!Transfer-Role");
313+
}
314+
}
315+
}
316+
317+
/// @dev Checks whether platform fee info can be set in the given execution context.
318+
function _canSetPlatformFeeInfo() internal view override returns (bool) {
319+
return hasRole(DEFAULT_ADMIN_ROLE, _msgSender());
320+
}
321+
322+
/// @dev Checks whether primary sale recipient can be set in the given execution context.
323+
function _canSetPrimarySaleRecipient() internal view override returns (bool) {
324+
return hasRole(DEFAULT_ADMIN_ROLE, _msgSender());
325+
}
326+
327+
/// @dev Checks whether owner can be set in the given execution context.
328+
function _canSetOwner() internal view override returns (bool) {
329+
return hasRole(DEFAULT_ADMIN_ROLE, _msgSender());
330+
}
331+
332+
/// @dev Checks whether royalty info can be set in the given execution context.
333+
function _canSetRoyaltyInfo() internal view override returns (bool) {
334+
return hasRole(DEFAULT_ADMIN_ROLE, _msgSender());
335+
}
336+
337+
/// @dev Checks whether contract metadata can be set in the given execution context.
338+
function _canSetContractURI() internal view override returns (bool) {
339+
return hasRole(DEFAULT_ADMIN_ROLE, _msgSender());
340+
}
341+
342+
/// @dev Returns whether a given address is authorized to sign mint requests.
343+
function _isAuthorizedSigner(address _signer) internal view override returns (bool) {
344+
return hasRole(MINTER_ROLE, _signer);
345+
}
346+
347+
/// @dev Returns whether metadata can be set in the given execution context.
348+
function _canSetMetadata() internal view virtual override returns (bool) {
349+
return hasRole(METADATA_ROLE, _msgSender());
350+
}
351+
352+
/// @dev Returns whether operator restriction can be set in the given execution context.
353+
function _canSetOperatorRestriction() internal virtual override returns (bool) {
354+
return hasRole(DEFAULT_ADMIN_ROLE, _msgSender());
355+
}
356+
357+
function _msgSender()
358+
internal
359+
view
360+
virtual
361+
override(ContextUpgradeable, ERC2771ContextUpgradeable)
362+
returns (address sender)
363+
{
364+
return ERC2771ContextUpgradeable._msgSender();
365+
}
366+
367+
function _msgData()
368+
internal
369+
view
370+
virtual
371+
override(ContextUpgradeable, ERC2771ContextUpgradeable)
372+
returns (bytes calldata)
373+
{
374+
return ERC2771ContextUpgradeable._msgData();
375+
}
376+
}

contracts/eip/interface/IERC4906.sol

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.11;
3+
4+
import "./IERC165.sol";
5+
import "./IERC721.sol";
6+
7+
interface IERC4906 is IERC165 {
8+
/// @dev This event emits when the metadata of a token is changed.
9+
/// So that the third-party platforms such as NFT market could
10+
/// timely update the images and related attributes of the NFT.
11+
event MetadataUpdate(uint256 _tokenId);
12+
13+
/// @dev This event emits when the metadata of a range of tokens is changed.
14+
/// So that the third-party platforms such as NFT market could
15+
/// timely update the images and related attributes of the NFTs.
16+
event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId);
17+
}

0 commit comments

Comments
 (0)