Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement SBT signaling standard #6

Merged
merged 3 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions contracts/BasicGuildRewardNFT.sol
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ contract BasicGuildRewardNFT is

_safeMint(receiver, tokenId);

emit Locked(tokenId);

emit Claimed(receiver, tokenId);
}

Expand Down
4 changes: 0 additions & 4 deletions contracts/interfaces/IBasicGuildRewardNFT.sol
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,4 @@ interface IBasicGuildRewardNFT {

/// @notice Error thrown when the supplied signature is invalid.
error IncorrectSignature();

/// @notice Error thrown when trying to query info about a token that's not (yet) minted.
/// @param tokenId The queried id.
error NonExistentToken(uint256 tokenId);
}
20 changes: 20 additions & 0 deletions contracts/interfaces/IERC5192.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0;

interface IERC5192 {
/// @notice Emitted when the locking status is changed to locked.
/// @dev If a token is minted and the status is locked, this event should be emitted.
/// @param tokenId The identifier for a token.
event Locked(uint256 tokenId);

/// @notice Emitted when the locking status is changed to unlocked.
/// @dev If a token is minted and the status is unlocked, this event should be emitted.
/// @param tokenId The identifier for a token.
event Unlocked(uint256 tokenId);

/// @notice Returns the locking status of an Soulbound Token
/// @dev SBTs assigned to zero address are considered invalid, and queries
/// about them do throw.
/// @param tokenId The identifier for an SBT.
function locked(uint256 tokenId) external view returns (bool);
}
17 changes: 15 additions & 2 deletions contracts/token/SoulboundERC721.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pragma solidity 0.8.19;

/* solhint-disable max-line-length */

import { IERC5192 } from "../interfaces/IERC5192.sol";
import { ERC721Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import { IERC721Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol";
import { ERC721EnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol";
Expand All @@ -14,7 +15,11 @@ import { IERC721EnumerableUpgradeable } from "@openzeppelin/contracts-upgradeabl
/// @notice Allowance and transfer-related functions are disabled.
/// @dev Inheriting from upgradeable contracts here - even though we're using it in a non-upgradeable way,
/// we still want it to be initializable
contract SoulboundERC721 is ERC721Upgradeable, ERC721EnumerableUpgradeable {
contract SoulboundERC721 is ERC721Upgradeable, ERC721EnumerableUpgradeable, IERC5192 {
/// @notice Error thrown when trying to query info about a token that's not (yet) minted.
/// @param tokenId The queried id.
error NonExistentToken(uint256 tokenId);

/// @notice Error thrown when a function's execution is not possible, because this is a soulbound NFT.
error Soulbound();

Expand All @@ -28,7 +33,15 @@ contract SoulboundERC721 is ERC721Upgradeable, ERC721EnumerableUpgradeable {
function supportsInterface(
bytes4 interfaceId
) public view virtual override(ERC721EnumerableUpgradeable, ERC721Upgradeable) returns (bool) {
return interfaceId == type(IERC721EnumerableUpgradeable).interfaceId || super.supportsInterface(interfaceId);
return
interfaceId == type(IERC5192).interfaceId ||
interfaceId == type(IERC721EnumerableUpgradeable).interfaceId ||
super.supportsInterface(interfaceId);
}

function locked(uint256 tokenId) external view returns (bool) {
if (!_exists(tokenId)) revert NonExistentToken(tokenId);
return true;
}

function approve(
Expand Down
14 changes: 0 additions & 14 deletions docs/contracts/interfaces/IBasicGuildRewardNFT.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,17 +220,3 @@ error IncorrectSignature()

Error thrown when the supplied signature is invalid.

### NonExistentToken

```solidity
error NonExistentToken(uint256 tokenId)
```

Error thrown when trying to query info about a token that's not (yet) minted.

#### Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| tokenId | uint256 | The queried id. |

60 changes: 60 additions & 0 deletions docs/contracts/interfaces/IERC5192.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# IERC5192

## Functions

### locked

```solidity
function locked(
uint256 tokenId
) external returns (bool)
```

Returns the locking status of an Soulbound Token

SBTs assigned to zero address are considered invalid, and queries
about them do throw.

#### Parameters

| Name | Type | Description |
| :--- | :--- | :---------- |
| `tokenId` | uint256 | The identifier for an SBT. |

## Events

### Locked

```solidity
event Locked(
uint256 tokenId
)
```

Emitted when the locking status is changed to locked.

If a token is minted and the status is locked, this event should be emitted.

#### Parameters

| Name | Type | Description |
| :--- | :--- | :---------- |
| `tokenId` | uint256 | The identifier for a token. |
### Unlocked

```solidity
event Unlocked(
uint256 tokenId
)
```

Emitted when the locking status is changed to unlocked.

If a token is minted and the status is unlocked, this event should be emitted.

#### Parameters

| Name | Type | Description |
| :--- | :--- | :---------- |
| `tokenId` | uint256 | The identifier for a token. |

33 changes: 33 additions & 0 deletions docs/contracts/token/SoulboundERC721.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ See {IERC165-supportsInterface}.
| :--- | :--- | :---------- |
| `interfaceId` | bytes4 | |

### locked

```solidity
function locked(
uint256 tokenId
) external returns (bool)
```

Returns the locking status of an Soulbound Token

SBTs assigned to zero address are considered invalid, and queries
about them do throw.

#### Parameters

| Name | Type | Description |
| :--- | :--- | :---------- |
| `tokenId` | uint256 | The identifier for an SBT. |

### approve

```solidity
Expand Down Expand Up @@ -169,6 +188,20 @@ Still used for minting/burning.

## Custom errors

### NonExistentToken

```solidity
error NonExistentToken(uint256 tokenId)
```

Error thrown when trying to query info about a token that's not (yet) minted.

#### Parameters

| Name | Type | Description |
| ---- | ---- | ----------- |
| tokenId | uint256 | The queried id. |

### Soulbound

```solidity
Expand Down
7 changes: 7 additions & 0 deletions test/BasicGuildRewardNFT.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,13 @@ describe("BasicGuildRewardNFT", () => {
expect(await nft.ownerOf(tokenId)).to.eq(wallet0.address);
});

it("should emit Locked event when minting", async () => {
const tokenId = await nft.totalSupply();
await expect(nft.claim(wallet0.address, sampleUserId, sampleSignature, { value: fee + price }))
.to.emit(nft, "Locked")
.withArgs(tokenId);
});

it("should emit Claimed event", async () => {
await expect(
nft.claim(wallet0.address, sampleUserId, sampleSignature, {
Expand Down
7 changes: 7 additions & 0 deletions test/SoulboundERC721.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,11 @@ describe("SoulboundERC721", () => {
nft["safeTransferFrom(address,address,uint256,bytes)"](wallet0.address, randomWallet.address, 0, ethers.ZeroHash)
).to.be.revertedWithCustomError(BasicGuildRewardNFT, "Soulbound");
});

it("should have a locked function that throws for not minted tokens", async () => {
const tokenId = 1;
await expect(nft.locked(tokenId))
.to.be.revertedWithCustomError(BasicGuildRewardNFT, "NonExistentToken")
.withArgs(tokenId);
});
});