feat: add token bound account handling

This commit is contained in:
Zitao Xiong
2024-06-11 01:30:46 +08:00
parent 9996168120
commit d1390b73e0
11 changed files with 846 additions and 171 deletions

View File

@@ -2,4 +2,6 @@ ds-test/=lib/openzeppelin-contracts-upgradeable/lib/forge-std/lib/ds-test/src/
erc4626-tests/=lib/openzeppelin-contracts-upgradeable/lib/erc4626-tests/
forge-std/=lib/forge-std/src/
@openzeppelin/=lib/openzeppelin-contracts/contracts/
@openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
@openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
erc6551/=lib/reference/src
openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/

View File

@@ -1,4 +1,4 @@
// SPDX-License-Identifier: UNLICENSED
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";

67
src/ERC20Wad.sol Normal file
View File

@@ -0,0 +1,67 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
library ERC20Wad {
using SafeERC20 for IERC20;
using SafeERC20 for IERC20Metadata;
using SafeERC20 for ERC20Burnable;
function wad_safeTransfer(IERC20Metadata _token, address _to, uint256 _amount) internal {
if (_amount > 0) {
_token.safeTransfer(_to, _amount / (10 ** (18 - _token.decimals())));
}
}
function wad_safeTransferFrom(IERC20Metadata _token, address _from, address _to, uint256 _amount) internal {
if (_amount > 0) {
_token.safeTransferFrom(_from, _to, _amount / (10 ** (18 - _token.decimals())));
}
}
function wad_balanceOf(IERC20Metadata _token, address _owner) internal view returns (uint256) {
return _token.balanceOf(_owner) * (10 ** (18 - _token.decimals()));
}
function wad_totalSupply(IERC20Metadata _token) internal view returns (uint256) {
return _token.totalSupply() * (10 ** (18 - _token.decimals()));
}
function wad_allowance(IERC20Metadata _token, address _owner, address _spender) internal view returns (uint256) {
return _token.allowance(_owner, _spender) * (10 ** (18 - _token.decimals()));
}
function wad_forceApprove(IERC20Metadata _token, address _spender, uint256 _amount) internal returns (bool) {
_token.forceApprove(_spender, _amount / (10 ** (18 - _token.decimals())));
return true;
}
function wad_safeIncreaseAllowance(IERC20Metadata _token, address _spender, uint256 _addedValue)
internal
returns (bool)
{
_token.safeIncreaseAllowance(_spender, _addedValue / (10 ** (18 - _token.decimals())));
return true;
}
function wad_safeDecreaseAllowance(IERC20Metadata _token, address _spender, uint256 _subtractedValue)
internal
returns (bool)
{
_token.safeDecreaseAllowance(_spender, _subtractedValue / (10 ** (18 - _token.decimals())));
return true;
}
function wad_burn(ERC20Burnable _token, uint256 _amount) internal {
_token.burn(_amount / (10 ** (18 - _token.decimals())));
}
function wad_burnFrom(ERC20Burnable _token, address _from, uint256 _amount) internal {
_token.burnFrom(_from, _amount / (10 ** (18 - _token.decimals())));
}
}

View File

@@ -17,6 +17,7 @@ import "@openzeppelin/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol";
import "./interfaces/IGiftedAccountGuardian.sol";
import "./interfaces/IGiftedAccount.sol";
import "./interfaces/IGiftedBox.sol";
error UntrustedImplementation();
error NotAuthorized();
@@ -44,9 +45,15 @@ contract GiftedAccount is
function initialize(address guardian) public initializer {
_guardian = IGiftedAccountGuardian(guardian);
}
/// events
event CallPermit(address indexed owner, address indexed to, uint256 nonce, uint256 deadline);
event CallPermit(
address indexed owner,
address indexed to,
uint256 nonce,
uint256 deadline
);
// Event to log the transfer of an ERC1155 token with a permit
event CallTransferERC1155Permit(
@@ -64,9 +71,35 @@ contract GiftedAccount is
/// @param sender The address that sent the ETH
/// @param amount The amount of ETH sent
/// @param newBalance The new balance of the account
event ReceivedEther(address indexed sender, uint256 amount, uint256 newBalance);
event ReceivedEther(
address indexed sender,
uint256 amount,
uint256 newBalance
);
event AccountGuardianUpgraded(address indexed previousGuardian, address indexed newGuardian);
event AccountGuardianUpgraded(
address indexed previousGuardian,
address indexed newGuardian
);
event GiftedAccountERC1155Received(
address operator,
address from,
uint256 erc1155TokenId,
uint256 erc1155Tokenvalue,
address erc1155Contract,
address giftedBoxContract,
uint256 giftedBoxTokenId
);
event GiftedAccountERC721Received(
address operator,
address from,
uint256 erc721TokenId,
address erc721Contract,
address giftedBoxContract,
uint256 giftedBoxTokenId
);
/// modifier
/// @dev reverts if caller is not authorized to execute on this account
@@ -85,12 +118,11 @@ contract GiftedAccount is
emit ReceivedEther(msg.sender, msg.value, address(this).balance);
}
function executeCall(address to, uint256 value, bytes calldata data)
external
payable
onlyAuthorized
returns (bytes memory result)
{
function executeCall(
address to,
uint256 value,
bytes calldata data
) external payable onlyAuthorized returns (bytes memory result) {
return call(to, value, data);
}
@@ -103,23 +135,31 @@ contract GiftedAccount is
}
function owner() public view returns (address) {
(uint256 chainId, address tokenContract, uint256 tokenId) = this.token();
if (chainId != block.chainid) revert("!chainid-not-equal-block-chainid");
(uint256 chainId, address tokenContract, uint256 tokenId) = this
.token();
if (chainId != block.chainid)
revert("!chainid-not-equal-block-chainid");
return IERC721(tokenContract).ownerOf(tokenId);
}
// ======== ERC165 Interface ========
function supportsInterface(bytes4 interfaceId) public pure returns (bool) {
return (
interfaceId == type(IERC165).interfaceId || interfaceId == type(IERC1155Receiver).interfaceId
|| interfaceId == type(IERC6551Account).interfaceId
);
return (interfaceId == type(IERC165).interfaceId ||
interfaceId == type(IERC1155Receiver).interfaceId ||
interfaceId == type(IERC6551Account).interfaceId);
}
// ======== ERC1271 Interface ========
function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4 magicValue) {
bool isValid = SignatureChecker.isValidSignatureNow(owner(), hash, signature);
function isValidSignature(
bytes32 hash,
bytes memory signature
) external view returns (bytes4 magicValue) {
bool isValid = SignatureChecker.isValidSignatureNow(
owner(),
hash,
signature
);
if (isValid) {
return IERC1271.isValidSignature.selector;
@@ -128,28 +168,117 @@ contract GiftedAccount is
}
// ============ ERC721Receiver Interface ============
function onERC721Received(address, address, uint256, bytes calldata) external pure override returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
function onERC721Received(
address _operator,
address _from,
uint256 _tokenId,
bytes calldata
) external override returns (bytes4) {
address _owner = owner();
(uint256 chainId, address tokenContract, uint256 tokenId) = this
.token();
if (chainId != block.chainid)
revert("!chainid-not-equal-block-chainid");
if (
_owner == _from ||
_owner == _operator ||
isAuthorizedSender(_from, _operator, tokenContract, tokenId)
) {
emit GiftedAccountERC721Received(
_operator,
_from,
_tokenId,
msg.sender,
tokenContract,
tokenId
);
return IERC721Receiver.onERC721Received.selector;
}
revert("!sender-not-authorized");
}
function isAuthorizedSender(
address _from,
address _operator,
address tokenContract,
uint256 tokenId
) internal view returns (bool) {
GiftingRecord memory record = IGiftedBox(tokenContract)
.getGiftingRecord(tokenId);
return (record.sender == _from || record.sender == _operator);
}
// ============ ERC1155Receiver Interface ============
function onERC1155Received(address, address, uint256, uint256, bytes calldata)
external
pure
override
returns (bytes4)
{
return IERC1155Receiver.onERC1155Received.selector;
function onERC1155Received(
address _operator,
address _from,
uint256 _id,
uint256 _value,
bytes calldata
) external override returns (bytes4) {
address _owner = owner();
(uint256 chainId, address tokenContract, uint256 tokenId) = this
.token();
if (chainId != block.chainid)
revert("!chainid-not-equal-block-chainid");
if (
_owner == _from ||
_owner == _operator ||
isAuthorizedSender(_from, _operator, tokenContract, tokenId)
) {
emit GiftedAccountERC1155Received(
_operator,
_from,
_id,
_value,
msg.sender,
tokenContract,
tokenId
);
return IERC1155Receiver.onERC1155Received.selector;
}
revert("!sender-not-authorized");
}
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
external
pure
override
returns (bytes4)
{
return IERC1155Receiver.onERC1155BatchReceived.selector;
function onERC1155BatchReceived(
address _operator,
address _from,
uint256[] calldata _ids,
uint256[] calldata _values,
bytes calldata
) external override returns (bytes4) {
address _owner = owner();
(uint256 chainId, address tokenContract, uint256 tokenId) = this
.token();
if (chainId != block.chainid)
revert("!chainid-not-equal-block-chainid");
if (
_owner == _from ||
_owner == _operator ||
isAuthorizedSender(_from, _operator, tokenContract, tokenId)
) {
for (uint256 i = 0; i < _ids.length; i++) {
emit GiftedAccountERC1155Received(
_operator,
_from,
_ids[i],
_values[i],
msg.sender,
tokenContract,
tokenId
);
}
return IERC1155Receiver.onERC1155BatchReceived.selector;
}
revert("!sender-not-authorized");
}
/// EIP 712
/// domain separator
@@ -159,15 +288,18 @@ contract GiftedAccount is
// Returns the domain separator, updating it if chainID changes
function domainSeparator() public view returns (bytes32) {
return keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes(name())),
keccak256(bytes("1")),
block.chainid,
address(this)
)
);
return
keccak256(
abi.encode(
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
),
keccak256(bytes(name())),
keccak256(bytes("1")),
block.chainid,
address(this)
)
);
}
/// internal
@@ -175,7 +307,8 @@ contract GiftedAccount is
function isAuthorized(address caller) public view returns (bool) {
if (caller == owner()) return true;
if (address(_guardian) != address(0) && _guardian.isExecutor(caller)) return true;
if (address(_guardian) != address(0) && _guardian.isExecutor(caller))
return true;
return false;
}
@@ -199,7 +332,11 @@ contract GiftedAccount is
_nonce++;
}
function call(address to, uint256 value, bytes calldata data) internal returns (bytes memory result) {
function call(
address to,
uint256 value,
bytes calldata data
) internal returns (bytes memory result) {
_incrementNonce();
emit TransactionExecuted(to, value, data);
@@ -214,7 +351,12 @@ contract GiftedAccount is
}
}
function _recover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) internal pure returns (address) {
function _recover(
bytes32 hash,
uint8 v,
bytes32 r,
bytes32 s
) internal pure returns (address) {
// EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature
// unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines
// the valid range for s in (281): 0 < s < secp256k1n ÷ 2 + 1, and for v in (282): v ∈ {27, 28}. Most
@@ -224,7 +366,10 @@ contract GiftedAccount is
// with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or
// vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept
// these malleable signatures as well.
if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
if (
uint256(s) >
0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0
) {
revert("ECDSA: invalid signature 's' value");
}
@@ -241,18 +386,25 @@ contract GiftedAccount is
// d83869c5bb54ba35eb2fa505a0206fde32206a3325ac92b027126dca04d8cdae
bytes32 public constant CALL_PERMIT_TYPEHASH =
keccak256("CallPermit(address to, uint256 value, byte data, uint256 deadline, uint256 nonce)");
keccak256(
"CallPermit(address to, uint256 value, byte data, uint256 deadline, uint256 nonce)"
);
/// external
function getTypedCallPermitHash(address to, uint256 value, bytes calldata data, uint256 deadline)
public
view
returns (bytes32 callHash)
{
bytes32 hashStruct = keccak256(abi.encode(CALL_PERMIT_TYPEHASH, to, value, data, deadline, nonce()));
function getTypedCallPermitHash(
address to,
uint256 value,
bytes calldata data,
uint256 deadline
) public view returns (bytes32 callHash) {
bytes32 hashStruct = keccak256(
abi.encode(CALL_PERMIT_TYPEHASH, to, value, data, deadline, nonce())
);
bytes32 eip712DomainHash = domainSeparator();
callHash = keccak256(abi.encodePacked(uint16(0x1901), eip712DomainHash, hashStruct));
callHash = keccak256(
abi.encodePacked(uint16(0x1901), eip712DomainHash, hashStruct)
);
}
function getTypedCallPermitHash(
@@ -262,9 +414,20 @@ contract GiftedAccount is
uint256 deadline,
uint256 encodeNonce
) public view returns (bytes32 callHash) {
bytes32 hashStruct = keccak256(abi.encode(CALL_PERMIT_TYPEHASH, to, value, data, deadline, encodeNonce));
bytes32 hashStruct = keccak256(
abi.encode(
CALL_PERMIT_TYPEHASH,
to,
value,
data,
deadline,
encodeNonce
)
);
bytes32 eip712DomainHash = domainSeparator();
callHash = keccak256(abi.encodePacked(uint16(0x1901), eip712DomainHash, hashStruct));
callHash = keccak256(
abi.encodePacked(uint16(0x1901), eip712DomainHash, hashStruct)
);
}
function executeTypedCallPermit(
@@ -307,43 +470,59 @@ contract GiftedAccount is
bytes32 s
) external {
require(block.timestamp <= deadline, "!call-permit-expired");
string memory message = getTransferNFTPermitMessage(tokenContract, tokenId, to, deadline);
string memory message = getTransferNFTPermitMessage(
tokenContract,
tokenId,
to,
deadline
);
bytes32 signHash = toEthPersonalSignedMessageHash(bytes(message));
address signer = _recover(signHash, v, r, s);
require(signer == owner(), "!transfer-permit-invalid-signature");
IERC721(tokenContract).safeTransferFrom(address(this), to, tokenId);
emit CallTransferNFTPermit(address(this), to, tokenContract, tokenId, deadline, nonce(), signer, msg.sender);
emit CallTransferNFTPermit(
address(this),
to,
tokenContract,
tokenId,
deadline,
nonce(),
signer,
msg.sender
);
}
function getTransferNFTPermitMessage(address tokenContract, uint256 tokenId, address to, uint256 deadline)
public
view
returns (string memory)
{
return string.concat(
"I want to transfer NFT",
"\n From: ",
address(this).toHexString(),
"\n NFT: ",
tokenContract.toHexString(),
"\n TokenId: ",
tokenId.toString(),
"\n To: ",
to.toHexString(),
"\n Before: ",
deadline.toString(),
".",
"\n Nonce: ",
nonce().toString(),
"\n Chain ID: ",
block.chainid.toString(),
"\n BY: ",
name(),
"\n Version: ",
"0.0.1"
);
function getTransferNFTPermitMessage(
address tokenContract,
uint256 tokenId,
address to,
uint256 deadline
) public view returns (string memory) {
return
string.concat(
"I want to transfer NFT",
"\n From: ",
address(this).toHexString(),
"\n NFT: ",
tokenContract.toHexString(),
"\n TokenId: ",
tokenId.toString(),
"\n To: ",
to.toHexString(),
"\n Before: ",
deadline.toString(),
".",
"\n Nonce: ",
nonce().toString(),
"\n Chain ID: ",
block.chainid.toString(),
"\n BY: ",
name(),
"\n Version: ",
"0.0.1"
);
}
// Method to transfer ERC1155 tokens with a permit
@@ -358,15 +537,35 @@ contract GiftedAccount is
bytes32 s
) external {
require(block.timestamp <= deadline, "!call-permit-expired");
string memory message = getTransferERC1155PermitMessage(tokenContract, tokenId, amount, to, deadline);
string memory message = getTransferERC1155PermitMessage(
tokenContract,
tokenId,
amount,
to,
deadline
);
bytes32 signHash = toEthPersonalSignedMessageHash(bytes(message));
address signer = ECDSA.recover(signHash, v, r, s);
require(signer == owner(), "!transfer-permit-invalid-signature");
IERC1155(tokenContract).safeTransferFrom(address(this), to, tokenId, amount, "");
IERC1155(tokenContract).safeTransferFrom(
address(this),
to,
tokenId,
amount,
""
);
emit CallTransferERC1155Permit(
address(this), to, tokenContract, tokenId, amount, deadline, nonce(), signer, msg.sender
address(this),
to,
tokenContract,
tokenId,
amount,
deadline,
nonce(),
signer,
msg.sender
);
}
@@ -378,30 +577,39 @@ contract GiftedAccount is
address to,
uint256 deadline
) public view returns (string memory) {
return string.concat(
"I authorize the transfer of ERC1155 tokens",
"\n Token Contract: ",
Strings.toHexString(uint256(uint160(tokenContract)), 20),
"\n Token ID: ",
Strings.toString(tokenId),
"\n Amount: ",
Strings.toString(amount),
"\n To: ",
Strings.toHexString(uint256(uint160(to)), 20),
"\n Deadline: ",
Strings.toString(deadline),
"\n Nonce: ",
nonce().toString(),
"\n Chain ID: ",
block.chainid.toString(),
"\n BY: ",
name(),
"\n Version: ",
"0.0.1"
);
return
string.concat(
"I authorize the transfer of ERC1155 tokens",
"\n Token Contract: ",
Strings.toHexString(uint256(uint160(tokenContract)), 20),
"\n Token ID: ",
Strings.toString(tokenId),
"\n Amount: ",
Strings.toString(amount),
"\n To: ",
Strings.toHexString(uint256(uint160(to)), 20),
"\n Deadline: ",
Strings.toString(deadline),
"\n Nonce: ",
nonce().toString(),
"\n Chain ID: ",
block.chainid.toString(),
"\n BY: ",
name(),
"\n Version: ",
"0.0.1"
);
}
function toEthPersonalSignedMessageHash(bytes memory _msg) public pure returns (bytes32 signHash) {
signHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", _msg.length.toString(), _msg));
function toEthPersonalSignedMessageHash(
bytes memory _msg
) public pure returns (bytes32 signHash) {
signHash = keccak256(
abi.encodePacked(
"\x19Ethereum Signed Message:\n",
_msg.length.toString(),
_msg
)
);
}
}

View File

@@ -1,4 +1,4 @@
// SPDX-License-Identifier: UNLICENSED
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/access/Ownable2Step.sol";
@@ -10,10 +10,6 @@ contract GiftedAccountGuardian is Ownable2Step, IGiftedAccountGuardian {
mapping(address => bool) private _isExecutor;
address private _implementation;
mapping(address => address) _customAccountImplementation;
event CustomAccountImplementationUpdated(address indexed account, address implementation);
constructor() Ownable(msg.sender) {}
function setExecutor(address executor, bool trusted) external onlyOwner {
@@ -39,20 +35,4 @@ contract GiftedAccountGuardian is Ownable2Step, IGiftedAccountGuardian {
if (executor == owner()) return true;
return false;
}
// @dev this function can only be called by the owner of the token,
// not by anyone else, even if they are an executor, which yield the
// control to the token holder.
function setCustomAccountImplementation(address account, address implementation) external {
require(
implementation != address(0) && implementation.code.length != 0, "!implementation-is-not-a-contract-or-zero"
);
require(IGiftedAccount(account).isOwner(msg.sender));
_customAccountImplementation[account] = implementation;
emit CustomAccountImplementationUpdated(account, implementation);
}
function getCustomAccountImplementation(address account) external view returns (address) {
return _customAccountImplementation[account];
}
}

View File

@@ -22,11 +22,6 @@ contract GiftedAccountProxy is Proxy {
// the override can only be controlled by the token holder, not the proxy itself or account guardian.
// @return address of the implementation
function _implementation() internal view virtual override returns (address) {
address customImpl = _accountGuardian.getCustomAccountImplementation(address(this));
if (customImpl != address(0)) {
return customImpl;
}
return _accountGuardian.getImplementation();
}
}

View File

@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.20;
import "@openzeppelin/utils/Address.sol";
import "@openzeppelin-contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin-contracts-upgradeable/token/ERC721/extensions/ERC721PausableUpgradeable.sol";
import "@openzeppelin-contracts-upgradeable/access/AccessControlUpgradeable.sol";
@@ -9,9 +9,15 @@ import "@openzeppelin-contracts-upgradeable/token/ERC721/extensions/ERC721Burnab
import "@openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin-contracts-upgradeable/token/ERC721/utils/ERC721HolderUpgradeable.sol";
import {GiftedAccount} from "./GiftedAccount.sol";
import "./GiftedAccountGuardian.sol";
import "./interfaces/IGasSponsorBook.sol";
import "./interfaces/IGiftedBox.sol";
import "erc6551/ERC6551Registry.sol";
/// @custom:security-contact zitao@placeholdersoft.com
contract GiftedBox is
IGiftedBox,
Initializable,
ERC721HolderUpgradeable,
ERC721Upgradeable,
@@ -20,25 +26,58 @@ contract GiftedBox is
ERC721BurnableUpgradeable,
UUPSUpgradeable
{
using Address for address payable;
// region defines
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE");
bytes32 public constant CLAIM_ADMIN_ROLE = keccak256("CLAIM_ADMIN_ROLE");
struct GiftingRecord {
address sender;
address recipient;
}
event GiftedBoxSentToVault(
address indexed from,
address indexed to,
uint256 tokenId
);
event GiftedBoxClaimed(
uint256 tokenId,
address indexed claimer,
bool asSender
);
event GiftedBoxClaimedByAdmin(
uint256 tokenId,
address indexed claimer,
bool asSender,
address indexed admin
);
event GiftedBoxSentToVault(address indexed from, address indexed to, uint256 tokenId);
event GiftBoxClaimed(uint256 tokenId, address indexed claimer, bool asSender);
event GiftBoxClaimedByAdmin(uint256 tokenId, address indexed claimer, bool asSender, address indexed admin);
event AccountImplUpdated(address indexed newAccountImpl);
event RegistryUpdated(address indexed newRegistry);
event GuardianUpdated(address indexed newGuardian);
event GasSponsorBookUpdated(address indexed newGasSponsorBook);
event TransferEtherToAccount(
address indexed account,
address indexed from,
uint256 value
);
event SponsorEnabled(
address indexed account,
uint256 tokenId,
uint256 ticket
);
event SponsorTicketAdded(
address indexed account,
uint256 ticket,
uint256 value
);
// endregion
// region storage
uint256 private _nextTokenId;
mapping(uint256 => GiftingRecord) public giftingRecords;
GiftedAccount public accountImpl;
ERC6551Registry public registry;
GiftedAccountGuardian public guardian;
IGasSponsorBook public gasSponsorBook;
// endregion
@@ -49,7 +88,7 @@ contract GiftedBox is
}
function initialize(address defaultAdmin) public initializer {
__ERC721_init("GiftBoxV2", "GB");
__ERC721_init("GiftedBoxV2", "GB");
__ERC721Pausable_init();
__AccessControl_init();
__ERC721Burnable_init();
@@ -70,12 +109,18 @@ contract GiftedBox is
_unpause();
}
function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADER_ROLE) {}
function _authorizeUpgrade(
address newImplementation
) internal override onlyRole(UPGRADER_ROLE) {}
// endregion
// region overrides
function _update(address to, uint256 tokenId, address auth)
function _update(
address to,
uint256 tokenId,
address auth
)
internal
override(ERC721Upgradeable, ERC721PausableUpgradeable)
returns (address)
@@ -83,7 +128,9 @@ contract GiftedBox is
return super._update(to, tokenId, auth);
}
function supportsInterface(bytes4 interfaceId)
function supportsInterface(
bytes4 interfaceId
)
public
view
override(ERC721Upgradeable, AccessControlUpgradeable)
@@ -94,6 +141,140 @@ contract GiftedBox is
// endregion
// region config
function setAccountImpl(
address payable newAccountImpl
) public onlyRole(DEFAULT_ADMIN_ROLE) {
accountImpl = GiftedAccount(newAccountImpl);
emit AccountImplUpdated(address(newAccountImpl));
}
function setRegistry(
address newRegistry
) public onlyRole(DEFAULT_ADMIN_ROLE) {
registry = ERC6551Registry(newRegistry);
emit RegistryUpdated(address(newRegistry));
}
function setAccountGuardian(
address newGuardian
) public onlyRole(DEFAULT_ADMIN_ROLE) {
guardian = GiftedAccountGuardian(newGuardian);
emit GuardianUpdated(address(newGuardian));
}
function setGasSponsorBook(
address newGasSponsorBook
) public onlyRole(DEFAULT_ADMIN_ROLE) {
gasSponsorBook = IGasSponsorBook(newGasSponsorBook);
emit GasSponsorBookUpdated(address(newGasSponsorBook));
}
// endregion
// region view
function tokenAccountAddress(
uint256 tokenId
) public view returns (address) {
return
registry.account(
address(accountImpl),
block.chainid,
address(this),
tokenId,
0
);
}
function generateTicketID(address account) public pure returns (uint256) {
return uint256(keccak256(abi.encodePacked(account)));
}
function getGiftingRecord(
uint256 tokenId
) public view returns (GiftingRecord memory) {
return giftingRecords[tokenId];
}
// endregion
// region internal functions
function createAccountIfNeeded(uint256 tokenId, address tokenAccount) internal {
if (tokenAccount.code.length == 0) {
registry.createAccount(
address(accountImpl),
block.chainid,
address(this),
tokenId,
0,
abi.encodeWithSignature("initialize(address)", address(guardian))
);
}
}
// endregion
// region gas sponsorship
function handleSponsorshipAndTransfer(
address tokenAccount,
uint256 tokenId
) internal {
if (
address(gasSponsorBook) != address(0) &&
msg.value >= gasSponsorBook.feePerSponsorTicket()
) {
uint256 sponserFee = gasSponsorBook.feePerSponsorTicket();
uint256 ticket = generateTicketID(address(tokenAccount));
gasSponsorBook.addSponsorTicket{value: sponserFee}(ticket);
uint256 left = msg.value - sponserFee;
if (left > 0) {
payable(tokenAccount).sendValue(left);
emit TransferEtherToAccount(tokenAccount, msg.sender, left);
}
emit SponsorEnabled(tokenAccount, tokenId, ticket);
} else if (msg.value > 0) {
uint256 value = msg.value;
emit TransferEtherToAccount(tokenAccount, msg.sender, value);
payable(tokenAccount).sendValue(value);
}
}
/**
* Adds a sponsor ticket for the given account and token ID, paying the sponsor ticket fee.
* A sponsor ticket allows the account holder to sponsor a gas refund for transfers of the token ID.
* The sponsor ticket ID is generated and stored in the gas sponsor book along with the sponsor funds.
* Emits a SponsorTicketAdded event with details.
*/
function addSponsorTicket(address account) external payable {
require(
msg.value >= gasSponsorBook.feePerSponsorTicket(),
"Insufficient funds for sponsor ticket"
);
uint256 ticket = generateTicketID(account);
gasSponsorBook.addSponsorTicket{value: msg.value}(ticket);
emit SponsorTicketAdded(account, ticket, msg.value);
}
/**
* @dev Checks if a given NFT token has a sponsor ticket.
* @param tokenId The ID of the NFT token.
* @return A boolean indicating whether the NFT token has a sponsor ticket or not.
*/
function hasSponsorTicket(uint256 tokenId) public view returns (bool) {
if (address(gasSponsorBook) == address(0)) {
return false;
}
address tokenAccount = registry.account(
address(accountImpl),
block.chainid,
address(this),
tokenId,
0
);
uint256 ticket = generateTicketID(tokenAccount);
return gasSponsorBook.sponsorTickets(ticket) > 0;
}
// endregion
// region Gifting Actions
/**
@@ -101,14 +282,24 @@ contract GiftedBox is
* @dev Mints a new token, updates the gifting records, and emits an event.
* @param recipient The address of the recipient who will receive the gift.
*/
function sendGift(address recipient) public {
function sendGift(address recipient) public payable whenNotPaused {
uint256 tokenId = _nextTokenId++;
_safeMint(recipient, tokenId);
_update(address(this), tokenId, recipient);
giftingRecords[tokenId] = GiftingRecord({sender: msg.sender, recipient: recipient});
giftingRecords[tokenId] = GiftingRecord({
sender: msg.sender,
recipient: recipient
});
address tokenAccount = registry.account(address(accountImpl), block.chainid, address(this), tokenId, 0);
createAccountIfNeeded(tokenId, tokenAccount);
handleSponsorshipAndTransfer(tokenAccount, tokenId);
emit GiftedBoxSentToVault(msg.sender, recipient, tokenId);
// 1 -> sendGift : got tokenId + address 100
// 2 -> transferNFT to tokenId's address: 100
}
/**
@@ -119,7 +310,10 @@ contract GiftedBox is
function resendGift(uint256 tokenId, address recipient) public {
safeTransferFrom(address(msg.sender), address(this), tokenId);
giftingRecords[tokenId] = GiftingRecord({sender: msg.sender, recipient: recipient});
giftingRecords[tokenId] = GiftingRecord({
sender: msg.sender,
recipient: recipient
});
emit GiftedBoxSentToVault(msg.sender, recipient, tokenId);
}
@@ -139,7 +333,7 @@ contract GiftedBox is
delete giftingRecords[tokenId];
_update(msg.sender, tokenId, address(this));
emit GiftBoxClaimed(tokenId, msg.sender, asSender);
emit GiftedBoxClaimed(tokenId, msg.sender, asSender);
}
/**
@@ -148,7 +342,11 @@ contract GiftedBox is
* @param claimer The address of the user claiming the gift.
* @param toSender A boolean indicating if the gift should be claimed to the sender.
*/
function claimGiftByAdmin(uint256 tokenId, address claimer, bool toSender) public onlyRole(CLAIM_ADMIN_ROLE) {
function claimGiftByAdmin(
uint256 tokenId,
address claimer,
bool toSender
) public onlyRole(CLAIM_ADMIN_ROLE) {
GiftingRecord memory record = giftingRecords[tokenId];
if (toSender) {
require(record.sender == claimer, "!not-sender");
@@ -158,9 +356,8 @@ contract GiftedBox is
delete giftingRecords[tokenId];
_update(claimer, tokenId, address(this));
emit GiftBoxClaimed(tokenId, claimer, toSender);
emit GiftBoxClaimedByAdmin(tokenId, claimer, toSender, msg.sender);
emit GiftedBoxClaimed(tokenId, claimer, toSender);
emit GiftedBoxClaimedByAdmin(tokenId, claimer, toSender, msg.sender);
}
// endregion
}

View File

@@ -1,4 +1,4 @@
// SPDX-License-Identifier: UNLICENSED
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IGiftedAccountGuardian {
@@ -10,7 +10,4 @@ interface IGiftedAccountGuardian {
function setExecutor(address executor, bool trusted) external;
function getImplementation() external view returns (address);
function getCustomAccountImplementation(address account) external view returns (address);
function setGiftedAccountImplementation(address newImplementation) external;
}

View File

@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
struct GiftingRecord {
address sender;
address recipient;
}
interface IGiftedBox {
function getGiftingRecord(
uint256 tokenId
) external view returns (GiftingRecord memory);
}

View File

@@ -6,7 +6,12 @@ import "forge-std/console.sol";
import "forge-std/Vm.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "../src/GiftedBox.sol";
import {GiftedAccount, IERC6551Account} from "../src/GiftedAccount.sol";
import "../src/GiftedAccountGuardian.sol";
import "../src/GiftedAccountProxy.sol";
import "erc6551/ERC6551Registry.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin-contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
@@ -18,22 +23,67 @@ contract MockERC721 is ERC721 {
}
}
contract MockERC1155 is ERC1155 {
constructor() ERC1155("") {}
function mint(address to, uint256 tokenId, uint256 amount) public {
_mint(to, tokenId, amount, "");
}
function mintBatch(
address to,
uint256[] memory tokenIds,
uint256[] memory amounts
) public {
_mintBatch(to, tokenIds, amounts, "");
}
}
interface TestEvents {
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Transfer(
address indexed from,
address indexed to,
uint256 indexed tokenId
);
}
contract GiftedBoxTest is Test, TestEvents {
MockERC721 mockNFT;
GiftedBox giftedBox;
MockERC721 internal mockNFT;
MockERC1155 internal mockERC1155;
GiftedBox internal giftedBox;
ERC6551Registry internal registry;
GiftedAccountGuardian internal guardian = new GiftedAccountGuardian();
GiftedAccount internal giftedAccount;
// region Setup
function setUp() public {
mockNFT = new MockERC721();
mockERC1155 = new MockERC1155();
GiftedAccount giftedAccountImpl = new GiftedAccount();
guardian.setGiftedAccountImplementation(address(giftedAccountImpl));
GiftedAccountProxy accountProxy = new GiftedAccountProxy(
address(guardian)
);
giftedAccount = GiftedAccount(payable(address(accountProxy)));
registry = new ERC6551Registry();
address implementation = address(new GiftedBox());
bytes memory data = abi.encodeCall(GiftedBox.initialize, address(this));
address proxy = address(new ERC1967Proxy(implementation, data));
giftedBox = GiftedBox(proxy);
giftedBox.setAccountImpl(payable(address(giftedAccount)));
giftedBox.setRegistry(address(registry));
giftedBox.setGasSponsorBook(address(0));
giftedBox.setAccountGuardian(address(guardian));
}
// endregion
// region Gifting Actions
function testSendGift() public {
uint256 tokenId = 0;
address giftSender = vm.addr(1);
@@ -95,7 +145,9 @@ contract GiftedBoxTest is Test, TestEvents {
assertEq(giftSender, IERC721(giftedBox).ownerOf(tokenId));
{
(address sender, address recipient) = giftedBox.giftingRecords(tokenId);
(address sender, address recipient) = giftedBox.giftingRecords(
tokenId
);
assertEq(sender, address(0));
assertEq(recipient, address(0));
}
@@ -111,7 +163,9 @@ contract GiftedBoxTest is Test, TestEvents {
giftedBox.claimGiftByAdmin(tokenId, giftRecipient, false);
assertEq(giftRecipient, IERC721(giftedBox).ownerOf(tokenId));
{
(address sender, address recipient) = giftedBox.giftingRecords(tokenId);
(address sender, address recipient) = giftedBox.giftingRecords(
tokenId
);
assertEq(sender, address(0));
assertEq(recipient, address(0));
}
@@ -133,7 +187,9 @@ contract GiftedBoxTest is Test, TestEvents {
assertEq(address(giftedBox), IERC721(giftedBox).ownerOf(tokenId));
{
(address sender, address recipient) = giftedBox.giftingRecords(tokenId);
(address sender, address recipient) = giftedBox.giftingRecords(
tokenId
);
assertEq(sender, giftSender);
assertEq(recipient, giftRecipient);
}
@@ -141,9 +197,170 @@ contract GiftedBoxTest is Test, TestEvents {
vm.prank(giftRecipient);
giftedBox.claimGift(tokenId, false);
{
(address sender, address recipient) = giftedBox.giftingRecords(tokenId);
(address sender, address recipient) = giftedBox.giftingRecords(
tokenId
);
assertEq(address(0), sender);
assertEq(address(0), recipient);
}
}
// endregion Gifting Actions
// region TokenBound Account
function testTokenBoundAccountCallDirectly() public {
uint256 tokenId = 0;
address giftSender = vm.addr(1);
address giftRecipient = vm.addr(2);
vm.prank(giftSender);
giftedBox.sendGift(giftRecipient);
address account = registry.account(
address(giftedAccount),
block.chainid,
address(giftedBox),
tokenId,
0
);
address tokenAccount = giftedBox.tokenAccountAddress(tokenId);
assertTrue(account != address(0));
assertTrue(account != vm.addr(1));
assertEq(account, tokenAccount);
IERC6551Account accountInstance = IERC6551Account(payable(account));
vm.prank(vm.addr(1));
giftedBox.claimGift(tokenId, true);
assertEq(accountInstance.owner(), vm.addr(1));
vm.deal(account, 1 ether);
vm.prank(vm.addr(1));
accountInstance.executeCall(payable(vm.addr(2)), 0.5 ether, "");
assertEq(account.balance, 0.5 ether);
assertEq(vm.addr(2).balance, 0.5 ether);
assertEq(accountInstance.nonce(), 1);
vm.prank(vm.addr(1));
giftedBox.transferFrom(vm.addr(1), vm.addr(2), tokenId);
assertEq(accountInstance.owner(), vm.addr(2));
vm.prank(vm.addr(1));
vm.expectRevert();
accountInstance.executeCall(payable(vm.addr(2)), 0.5 ether, "");
vm.prank(vm.addr(2));
accountInstance.executeCall(payable(vm.addr(2)), 0.5 ether, "");
assertEq(vm.addr(2).balance, 1 ether);
assertEq(accountInstance.nonce(), 2);
}
function testTokenBoundAccountERC721() public {
uint256 tokenId = 0;
address giftSender = vm.addr(1);
address giftRecipient = vm.addr(2);
address randomAccount = vm.addr(3);
vm.prank(giftSender);
giftedBox.sendGift(giftRecipient);
address tokenAccount = giftedBox.tokenAccountAddress(tokenId);
// !important: only the token owner can transfer the token to tokenBound account
// to prevent front running attack
mockNFT.mint(randomAccount, 100);
vm.expectRevert("!sender-not-authorized");
vm.prank(randomAccount);
mockNFT.safeTransferFrom(randomAccount, tokenAccount, 100);
// sender is able to transfer the token to tokenBound account
mockNFT.mint(giftSender, 101);
vm.prank(giftSender);
mockNFT.safeTransferFrom(giftSender, tokenAccount, 101);
// recipient is not able to transfer the token to tokenBound account
mockNFT.mint(giftRecipient, 102);
vm.expectRevert("!sender-not-authorized");
vm.prank(giftRecipient);
mockNFT.safeTransferFrom(giftRecipient, tokenAccount, 102);
}
function testTokenBoundAccountERC1155() public {
uint256 tokenId = 0;
address giftSender = vm.addr(1);
address giftRecipient = vm.addr(2);
address randomAccount = vm.addr(3);
vm.prank(giftSender);
giftedBox.sendGift(giftRecipient);
address tokenAccount = giftedBox.tokenAccountAddress(tokenId);
// !important: only the token owner can transfer the token to tokenBound account
// to prevent front running attack
mockERC1155.mint(randomAccount, 100, 1);
vm.expectRevert("!sender-not-authorized");
vm.prank(randomAccount);
mockERC1155.safeTransferFrom(randomAccount, tokenAccount, 100, 1, "");
// sender is able to transfer the token to tokenBound account
mockERC1155.mint(giftSender, 101, 1);
vm.prank(giftSender);
mockERC1155.safeTransferFrom(giftSender, tokenAccount, 101, 1, "");
// recipient is not able to transfer the token to tokenBound account
mockERC1155.mint(giftRecipient, 102, 1);
vm.expectRevert("!sender-not-authorized");
vm.prank(giftRecipient);
mockERC1155.safeTransferFrom(giftRecipient, tokenAccount, 102, 1, "");
// Test cases for onERC1155BatchReceived
uint256[] memory ids = new uint256[](2);
uint256[] memory amounts = new uint256[](2);
ids[0] = 200;
ids[1] = 201;
amounts[0] = 1;
amounts[1] = 2;
// randomAccount tries to transfer batch tokens to tokenBound account
mockERC1155.mintBatch(randomAccount, ids, amounts);
vm.expectRevert("!sender-not-authorized");
vm.prank(randomAccount);
mockERC1155.safeBatchTransferFrom(
randomAccount,
tokenAccount,
ids,
amounts,
""
);
// giftSender transfers batch tokens to tokenBound account
mockERC1155.mintBatch(giftSender, ids, amounts);
vm.prank(giftSender);
mockERC1155.safeBatchTransferFrom(
giftSender,
tokenAccount,
ids,
amounts,
""
);
// giftRecipient tries to transfer batch tokens to tokenBound account
mockERC1155.mintBatch(giftRecipient, ids, amounts);
vm.expectRevert("!sender-not-authorized");
vm.prank(giftRecipient);
mockERC1155.safeBatchTransferFrom(
giftRecipient,
tokenAccount,
ids,
amounts,
""
);
}
// endregion TokenBound Account
}