diff --git a/contracts/ERC1238/extensions/ERC1238Holdable.sol b/contracts/ERC1238/extensions/ERC1238Holdable.sol new file mode 100644 index 0000000..31b2819 --- /dev/null +++ b/contracts/ERC1238/extensions/ERC1238Holdable.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../ERC1238.sol"; +import "./IERC1238Holdable.sol"; +import "./IERC1238Holder.sol"; + +/** + * @dev Proposal for ERC1238 tokens extension that allow addresses + * to hold tokens on behalf of others. + */ +abstract contract ERC1238Holdable is IERC1238Holdable, ERC1238 { + using Address for address; + + // Mapping holder => id => balance + mapping(address => mapping(uint256 => uint256)) private _heldBalances; + + function heldBalance(address holder, uint256 id) public view override returns (uint256) { + return _heldBalances[holder][id]; + } + + /** + * @dev Hooks into the minting flow to set the token recipient as first holder + * by default when tokens are minted. + */ + function _beforeMint( + address, + address to, + uint256 id, + uint256 amount, + bytes memory + ) internal virtual override { + _heldBalances[to][id] += amount; + } + + /** + * @dev Burns `amount` of tokens that are held by `holder` and owned by `from`. + * If `holder` is a smart contract and inherits {IERC1238Holder}, it notifies it to give it a chance to + * react to the burn and handle the operation how it sees fit. + * + * Requirements: + * - `holder` should hold at least the `amount` of tokens with the `id` passed + */ + function _burnHeldTokens( + address burner, + address holder, + address from, + uint256 id, + uint256 amount + ) internal virtual { + require(_heldBalances[holder][id] >= amount, "ERC1238Holdable: Amount to burn exceeds amount held"); + + if (holder.isContract()) { + try IERC1238Holder(holder).onBurn(id, amount) returns (bool isBurnAcknowledged) { + if (!isBurnAcknowledged) emit BurnAcknowledgmentFailed(holder, burner, from, id, amount); + } catch { + emit BurnAcknowledgmentFailed(holder, burner, from, id, amount); + } + } + + super._burn(from, id, amount); + + _heldBalances[holder][id] -= amount; + } + + /** + * @dev Lets sender entrusts `to` with `amount` + * of tokens which gets transferred between their respective heldBalances + */ + function _entrust( + address to, + uint256 id, + uint256 amount + ) internal virtual { + address from = msg.sender; + + uint256 fromBalance = _heldBalances[from][id]; + require(fromBalance >= amount, "ERC1238Holdable: amount exceeds balance held"); + + _heldBalances[from][id] -= amount; + _heldBalances[to][id] += amount; + + emit Entrust(from, to, id, amount); + } + + // TODO: Add a function to provide a safer alternative which + // makes sure the recipient is a IERC1238Holder contract (same as idea as in IERC1238Receiver) +} diff --git a/contracts/ERC1238/extensions/IERC1238Holdable.sol b/contracts/ERC1238/extensions/IERC1238Holdable.sol new file mode 100644 index 0000000..ba00afa --- /dev/null +++ b/contracts/ERC1238/extensions/IERC1238Holdable.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../IERC1238.sol"; + +/** + * @dev Proposal of an interface for ERC1238 tokens that can be held by another address than + * than their owner or staked in a smart contract. + */ +interface IERC1238Holdable is IERC1238 { + /** + * @dev Event emitted when `from` entrusts `to` with `amount` of tokens with token `id`. + */ + event Entrust(address from, address to, uint256 indexed id, uint256 amount); + + /** + * @dev Event emitted when tokens are burnt and the holder fails to acknowledge the burn. + */ + event BurnAcknowledgmentFailed(address holder, address burner, address from, uint256 indexed id, uint256 amount); + + /** + * @dev Returns the balance of a token holder for a given `id`. + */ + function heldBalance(address holder, uint256 id) external view returns (uint256); + + /** + * @dev Lets sender entrusts `to` with `amount` + * of tokens which gets transferred between their respective balances + * of tokens held. + */ + function entrust( + address to, + uint256 id, + uint256 amount + ) external; +} diff --git a/contracts/ERC1238/extensions/IERC1238Holder.sol b/contracts/ERC1238/extensions/IERC1238Holder.sol new file mode 100644 index 0000000..3e8ca40 --- /dev/null +++ b/contracts/ERC1238/extensions/IERC1238Holder.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../IERC1238.sol"; +import "../IERC1238Receiver.sol"; + +/** + * @dev Interface proposal for contracts that need to hold ERC1238 tokens. + */ +interface IERC1238Holder is IERC1238Receiver { + /** + * @dev This function is called when tokens with id `id` are burnt. + */ + function onBurn(uint256 id, uint256 amount) external returns (bool); +} diff --git a/contracts/mocks/ERC1238HoldableMock.sol b/contracts/mocks/ERC1238HoldableMock.sol new file mode 100644 index 0000000..84ce71c --- /dev/null +++ b/contracts/mocks/ERC1238HoldableMock.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../ERC1238/ERC1238.sol"; +import "../ERC1238/extensions/ERC1238Holdable.sol"; + +/** + * @dev Mock contract for ERC1238 tokens using ERC1238Holdable extension + */ +contract ERC1238HoldableMock is ERC1238, ERC1238Holdable { + constructor(string memory uri) ERC1238(uri) {} + + function _beforeMint( + address minter, + address to, + uint256 id, + uint256 amount, + bytes memory data + ) internal override(ERC1238, ERC1238Holdable) { + super._beforeMint(minter, to, id, amount, data); + } + + function mintToContract( + address to, + uint256 id, + uint256 amount, + bytes memory data + ) public { + _mintToContract(to, id, amount, data); + } + + function mintBatchToContract( + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) public { + _mintBatchToContract(to, ids, amounts, data); + } + + function burnHeldTokens( + address holder, + address from, + uint256 id, + uint256 amount + ) public { + _burnHeldTokens(msg.sender, holder, from, id, amount); + } + + function burnBatch( + address owner, + uint256[] memory ids, + uint256[] memory amounts + ) public { + _burnBatch(owner, ids, amounts); + } + + function entrust( + address to, + uint256 id, + uint256 amount + ) public override { + _entrust(to, id, amount); + } +} diff --git a/contracts/mocks/ERC1238HolderMock.sol b/contracts/mocks/ERC1238HolderMock.sol new file mode 100644 index 0000000..a720fe9 --- /dev/null +++ b/contracts/mocks/ERC1238HolderMock.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../ERC1238/IERC1238Receiver.sol"; +import "../ERC1238/extensions/IERC1238Holdable.sol"; +import "../ERC1238/extensions/IERC1238Holder.sol"; + +import "./ERC1238HoldableMock.sol"; + +// This is a dummy example of a ERC1238Holder with arbitrary rules. +// It will reject non-transferable tokens if the token id is 0 in the case of a single token mint +// or if the first token id is 0 for a batch mint. +// +// It will also acknowledge and emit an event when tokens are burnt. +contract ERC1238HolderMock is IERC1238Receiver, IERC1238Holder { + // bytes4(keccak256("onERC1238Mint(address,uint256,uint256,bytes)")) + bytes4 public constant ERC1238_ON_MINT = 0x45ed75d5; + + // bytes4(keccak256("onERC1238BatchMint(address,uint256[],uint256[],bytes)")) + bytes4 public constant ERC1238_ON_BATCH_MINT = 0xc0bfec68; + + event TokenBurnt(uint256 id, uint256 amount); + + function onERC1238Mint( + address, + uint256 id, + uint256, + bytes calldata + ) external pure override returns (bytes4) { + if (id == 0) { + return bytes4(0); + } + + return ERC1238_ON_MINT; + } + + function onERC1238BatchMint( + address, + uint256[] calldata ids, + uint256[] calldata, + bytes calldata + ) external pure override returns (bytes4) { + if (ids[0] == 0) { + return bytes4(0); + } + + return ERC1238_ON_BATCH_MINT; + } + + function entrust( + address targetContract, + address to, + uint256 id, + uint256 amount + ) external { + IERC1238Holdable(targetContract).entrust(to, id, amount); + } + + function onBurn(uint256 id, uint256 amount) public override returns (bool) { + emit TokenBurnt(id, amount); + + return true; + } +} diff --git a/contracts/mocks/ERC1238ReceiverHoldableMock.sol b/contracts/mocks/ERC1238ReceiverHoldableMock.sol new file mode 100644 index 0000000..a46e30e --- /dev/null +++ b/contracts/mocks/ERC1238ReceiverHoldableMock.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../ERC1238/IERC1238Receiver.sol"; +import "../ERC1238/extensions/IERC1238Holdable.sol"; + +import "./ERC1238HoldableMock.sol"; + +// This is a dummy example of a ERC1238Receiver with arbitrary rules. +// It will reject non-transferable tokens if the token id is 0 in the case of a single token mint +// or if the first token id is 0 for a batch mint. +contract ERC1238ReceiverHoldableMock is IERC1238Receiver { + // bytes4(keccak256("onERC1238Mint(address,uint256,uint256,bytes)")) + bytes4 public constant ERC1238_ON_MINT = 0x45ed75d5; + + // bytes4(keccak256("onERC1238BatchMint(address,uint256[],uint256[],bytes)")) + bytes4 public constant ERC1238_ON_BATCH_MINT = 0xc0bfec68; + + function onERC1238Mint( + address, + uint256 id, + uint256, + bytes calldata + ) external pure override returns (bytes4) { + if (id == 0) { + return bytes4(0); + } + + return ERC1238_ON_MINT; + } + + function onERC1238BatchMint( + address, + uint256[] calldata ids, + uint256[] calldata, + bytes calldata + ) external pure override returns (bytes4) { + if (ids[0] == 0) { + return bytes4(0); + } + + return ERC1238_ON_BATCH_MINT; + } + + function entrust( + address targetContract, + address to, + uint256 id, + uint256 amount + ) external { + IERC1238Holdable(targetContract).entrust(to, id, amount); + } + + function burnHeldTokens( + address targetContract, + address holder, + uint256 id, + uint256 amount + ) external { + ERC1238HoldableMock(targetContract).burnHeldTokens(holder, address(this), id, amount); + } +} diff --git a/test/ERC1238/extensions/ERC1238Holdable.ts b/test/ERC1238/extensions/ERC1238Holdable.ts new file mode 100644 index 0000000..94db8e3 --- /dev/null +++ b/test/ERC1238/extensions/ERC1238Holdable.ts @@ -0,0 +1,254 @@ +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { artifacts, ethers, waffle } from "hardhat"; +import type { Artifact } from "hardhat/types"; +import type { ERC1238HoldableMock } from "../../../src/types/ERC1238HoldableMock"; +import type { ERC1238HolderMock } from "../../../src/types/ERC1238HolderMock"; +import type { ERC1238ReceiverHoldableMock } from "../../../src/types/ERC1238ReceiverHoldableMock"; +import { ZERO_ADDRESS } from "../../utils/test-utils"; + +const BASE_URI = "https://token-cdn-domain/{id}.json"; + +describe("ERC1238URIHoldable", function () { + let erc1238Holdable: ERC1238HoldableMock; + let admin: SignerWithAddress; + let tokenOwnerContract: ERC1238ReceiverHoldableMock; + let eoa1: SignerWithAddress; + let eoa2: SignerWithAddress; + + const tokenId = 888888; + const mintAmount = 98765432; + const data = "0x12345678"; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + admin = signers[0]; + eoa1 = signers[1]; + eoa2 = signers[2]; + }); + + beforeEach(async function () { + const ERC1238HoldableMockArtifact: Artifact = await artifacts.readArtifact("ERC1238HoldableMock"); + erc1238Holdable = await waffle.deployContract(admin, ERC1238HoldableMockArtifact, [BASE_URI]); + + const ERC1238ReceiverHoldableMockArtifact: Artifact = await artifacts.readArtifact("ERC1238ReceiverHoldableMock"); + tokenOwnerContract = ( + await waffle.deployContract(admin, ERC1238ReceiverHoldableMockArtifact) + ); + }); + + describe("Minting", () => { + it("should set the token recipient as first holder", async () => { + await erc1238Holdable.mintToContract(tokenOwnerContract.address, tokenId, mintAmount, data); + + expect(await erc1238Holdable.heldBalance(tokenOwnerContract.address, tokenId)).to.eq(mintAmount); + }); + + it("should update the balance held when minting multiple times", async () => { + const firstAmount = 1000; + const secondAmount = 200; + await erc1238Holdable.mintToContract(tokenOwnerContract.address, tokenId, firstAmount, data); + + expect(await erc1238Holdable.heldBalance(tokenOwnerContract.address, tokenId)).to.eq(firstAmount); + + await erc1238Holdable.mintToContract(tokenOwnerContract.address, tokenId, secondAmount, data); + + expect(await erc1238Holdable.heldBalance(tokenOwnerContract.address, tokenId)).to.eq(firstAmount + secondAmount); + }); + }); + + describe("Holding", () => { + it("should allow locking tokens forever at the zero address", async () => { + await erc1238Holdable.mintToContract(tokenOwnerContract.address, tokenId, mintAmount, data); + + await tokenOwnerContract.entrust(erc1238Holdable.address, ZERO_ADDRESS, tokenId, mintAmount); + + expect(await erc1238Holdable.heldBalance(ZERO_ADDRESS, tokenId)).to.eq(mintAmount); + }); + + context("Staking all tokens", () => { + it("should let a token owner stake all of their tokens", async () => { + await erc1238Holdable.mintToContract(tokenOwnerContract.address, tokenId, mintAmount, data); + + // tokenOwnerContract entrusts eoa1 with their tokens + await tokenOwnerContract.entrust(erc1238Holdable.address, eoa1.address, tokenId, mintAmount); + + // tokenOwnerContract does not hold the tokens anymore + expect(await erc1238Holdable.heldBalance(tokenOwnerContract.address, tokenId)).to.eq(0); + // eoa1 does hold them + expect(await erc1238Holdable.heldBalance(eoa1.address, tokenId)).to.eq(mintAmount); + + // tokenOwnerContract is still the owner of these tokens + expect(await erc1238Holdable.balanceOf(tokenOwnerContract.address, tokenId)).to.eq(mintAmount); + }); + + it("should let a holder transfer tokens to another holder", async () => { + await erc1238Holdable.mintToContract(tokenOwnerContract.address, tokenId, mintAmount, data); + + // tokenOwnerContract entrusts eoa1 with their tokens + await tokenOwnerContract.entrust(erc1238Holdable.address, eoa1.address, tokenId, mintAmount); + // eoa1 entrusts eoa2 with these same tokens belonging to tokenOwnerContract + await erc1238Holdable.connect(eoa1).entrust(eoa2.address, tokenId, mintAmount); + + // tokenOwnerContract does not hold the tokens anymore + expect(await erc1238Holdable.heldBalance(tokenOwnerContract.address, tokenId)).to.eq(0); + // eoa1 does not hold them + expect(await erc1238Holdable.heldBalance(eoa1.address, tokenId)).to.eq(0); + // eoa2 does hold them + expect(await erc1238Holdable.heldBalance(eoa2.address, tokenId)).to.eq(mintAmount); + // tokenOwnerContract is still the owner of these tokens + expect(await erc1238Holdable.balanceOf(tokenOwnerContract.address, tokenId)).to.eq(mintAmount); + }); + + it("should let a holder transfer tokens back to their owner", async () => { + await erc1238Holdable.mintToContract(tokenOwnerContract.address, tokenId, mintAmount, data); + + // tokenOwnerContract entrusts eoa1 with their tokens + await tokenOwnerContract.entrust(erc1238Holdable.address, eoa1.address, tokenId, mintAmount); + // eoa1 transfers them back to tokenOwnerContract + await erc1238Holdable.connect(eoa1).entrust(tokenOwnerContract.address, tokenId, mintAmount); + + // eoa1 does not hold the tokens anymore + expect(await erc1238Holdable.heldBalance(eoa1.address, tokenId)).to.eq(0); + // tokenOwnerContract does hold them + expect(await erc1238Holdable.heldBalance(tokenOwnerContract.address, tokenId)).to.eq(mintAmount); + // tokenOwnerContract is still the owner of these tokens + expect(await erc1238Holdable.balanceOf(tokenOwnerContract.address, tokenId)).to.eq(mintAmount); + }); + }); + + context("Partial Staking", () => { + const stakedAmount = mintAmount - 1000; + it("should let a token owner put some of their token at stake", async () => { + await erc1238Holdable.mintToContract(tokenOwnerContract.address, tokenId, mintAmount, data); + + // tokenOwnerContract entrusts eoa1 with some of their tokens + await tokenOwnerContract.entrust(erc1238Holdable.address, eoa1.address, tokenId, stakedAmount); + + // tokenOwnerContract holds the remaining amount of tokens + expect(await erc1238Holdable.heldBalance(tokenOwnerContract.address, tokenId)).to.eq(mintAmount - stakedAmount); + // eoa1 holds the staked amount + expect(await erc1238Holdable.heldBalance(eoa1.address, tokenId)).to.eq(stakedAmount); + // tokenOwnerContract is still the owner of all the tokens + expect(await erc1238Holdable.balanceOf(tokenOwnerContract.address, tokenId)).to.eq(mintAmount); + }); + + it("should let a token holder transfer the staked amount", async () => { + await erc1238Holdable.mintToContract(tokenOwnerContract.address, tokenId, mintAmount, data); + + // tokenOwnerContract entrusts eoa1 with some their tokens + await tokenOwnerContract.entrust(erc1238Holdable.address, eoa1.address, tokenId, stakedAmount); + // eoa1 entrusts eoa2 with these tokens + await erc1238Holdable.connect(eoa1).entrust(eoa2.address, tokenId, stakedAmount); + + // tokenOwnerContract holds the remaining amount of tokens + expect(await erc1238Holdable.heldBalance(tokenOwnerContract.address, tokenId)).to.eq(mintAmount - stakedAmount); + // eoa1 does not hold any tokens + expect(await erc1238Holdable.heldBalance(eoa1.address, tokenId)).to.eq(0); + // eoa2 does hold some of them + expect(await erc1238Holdable.heldBalance(eoa2.address, tokenId)).to.eq(stakedAmount); + // tokenOwnerContract is still the owner of these tokens + expect(await erc1238Holdable.balanceOf(tokenOwnerContract.address, tokenId)).to.eq(mintAmount); + }); + }); + }); + + describe("Burning", () => { + beforeEach(async () => { + await erc1238Holdable.mintToContract(tokenOwnerContract.address, tokenId, mintAmount, data); + }); + + it("should let a token owner burn all of their tokens", async () => { + await tokenOwnerContract.burnHeldTokens(erc1238Holdable.address, tokenOwnerContract.address, tokenId, mintAmount); + + expect(await erc1238Holdable.heldBalance(tokenOwnerContract.address, tokenId)).to.eq(0); + expect(await erc1238Holdable.balanceOf(tokenOwnerContract.address, tokenId)).to.eq(0); + }); + + it("should let a token owner burn some of their tokens", async () => { + const amountToBurn = mintAmount - 1000; + + await tokenOwnerContract.burnHeldTokens( + erc1238Holdable.address, + tokenOwnerContract.address, + tokenId, + amountToBurn, + ); + + expect(await erc1238Holdable.heldBalance(tokenOwnerContract.address, tokenId)).to.eq(mintAmount - amountToBurn); + expect(await erc1238Holdable.balanceOf(tokenOwnerContract.address, tokenId)).to.eq(mintAmount - amountToBurn); + }); + + it("should let tokens held by an EOA be burnt", async () => { + const amountToBurn = mintAmount - 100; + + await tokenOwnerContract.entrust(erc1238Holdable.address, eoa1.address, tokenId, amountToBurn); + + await erc1238Holdable.burnHeldTokens(eoa1.address, tokenOwnerContract.address, tokenId, amountToBurn); + + expect(await erc1238Holdable.heldBalance(eoa1.address, tokenId)).to.eq(0); + expect(await erc1238Holdable.balanceOf(tokenOwnerContract.address, tokenId)).to.eq(mintAmount - amountToBurn); + }); + + it("should revert when trying to burn more tokens that what the holder passed holds", async () => { + const stakedAmount = mintAmount; + + await tokenOwnerContract.entrust(erc1238Holdable.address, eoa1.address, tokenId, stakedAmount); + + await expect( + erc1238Holdable.burnHeldTokens(eoa1.address, tokenOwnerContract.address, tokenId, stakedAmount + 1), + ).to.be.revertedWith("ERC1238Holdable: Amount to burn exceeds amount held"); + }); + }); + + describe("Burning notification", () => { + const amountToBurn = mintAmount - 1000; + let tokenOwnerContract2: ERC1238ReceiverHoldableMock; + + beforeEach(async () => { + await erc1238Holdable.mintToContract(tokenOwnerContract.address, tokenId, mintAmount, data); + + const ERC1238ReceiverHoldableMockArtifact: Artifact = await artifacts.readArtifact("ERC1238ReceiverHoldableMock"); + + tokenOwnerContract2 = ( + await waffle.deployContract(admin, ERC1238ReceiverHoldableMockArtifact) + ); + }); + + it("should emit an event if burning acknowledgment failed", async () => { + await tokenOwnerContract.entrust(erc1238Holdable.address, tokenOwnerContract2.address, tokenId, amountToBurn); + + const burnTx = await erc1238Holdable.burnHeldTokens( + tokenOwnerContract2.address, + tokenOwnerContract.address, + tokenId, + amountToBurn, + ); + + const receipt = await burnTx.wait(); + + // Checks that a BurnAcknowledgmentFailed event was emitted + expect(receipt.events?.findIndex(eventObject => eventObject.event === "BurnAcknowledgmentFailed")).to.not.eq(-1); + }); + + it("should not emit an event if a burn was acknowledged", async () => { + const ERC1238HolderMockArtifact: Artifact = await artifacts.readArtifact("ERC1238HolderMock"); + + const holderContract = await waffle.deployContract(admin, ERC1238HolderMockArtifact); + + await tokenOwnerContract.entrust(erc1238Holdable.address, holderContract.address, tokenId, amountToBurn); + + const burnTx = await erc1238Holdable.burnHeldTokens( + holderContract.address, + tokenOwnerContract.address, + tokenId, + amountToBurn, + ); + + const receipt = await burnTx.wait(); + + // Checks that a BurnAcknowledgmentFailed event was NOT emitted + expect(receipt.events?.findIndex(eventObject => eventObject.event === "BurnAcknowledgmentFailed")).to.eq(-1); + }); + }); +});