Token Reputation: Chain-based Tracking using NFTs

Over the past few months, Tableland has been experimenting with quite a lot! One particular focus was thinking more broadly about token reputation, even so far as considering authoring an EIP that extends the base ERC-721. Ultimately, we’re sticking with our current design due to a few reasons that we’ll touch on, but we felt it was still worthwhile sharing the research! Generally, you can think of NFTs as a proxy for reputation.

A brief of Rigs’ design

When we first set down this path, the idea stemmed from our Tableland Rigs NFT. There is a key feature wherein the token owner can currently do the following:

  • “Train” a Rig for a period of 172800 blocks (~30 days).

  • Once trained, they can “pilot” the Rig with a custom NFT, where the NFT contract & token ID pointers are stored only in the metadata.

  • While training or piloting, the token is earning “Flight Time” (or “FT”) and cannot be sold on marketplaces.

  • At any time, the owner can “park” the Rig and stop accruing flight time, allowing it to be sold.

  • These actions can be viewed as user sessions where a Tableland table stores the session ID, Rig token ID, starting block number, ending block number, and pilot contract address/token ID.

Taking a step back, what’s really happening here is a concept of “soft” staking. The owner always maintains ownership of the token. The associated FT being earned is, essentially, a reputation for staking the token as it signifies belief in the core project since the token cannot be sold.

Thinking reputation

Let’s review some of the technical specifics.

The Rigs contract has three primary methods: trainRig, parkRig, and pilotRig. In a more generalized approach, you can sort of combine the train and park methods into the soft stake. The parking method can be thought of as unstaking. When these methods are called, they emit information about the action, including the Rig token ID and pilot information. And for those curious, the transfer blocking simply comes from the _beforeTokenTransfers hook where there’s a check for if the token’s session is active (i.e., pilotStartTime greater than zero). Once a user parks the Rig, this is the end of the soft staking session.

This setup is a bit specialized for the Rigs use case because the staking action needs to take additional inputs for the owner’s custom pilot (contract address and token ID). In a standard staking case, it’s typically just the token ID. When this happens, that data is not only emitted in an event but also passes SQL statements to the Tableland Registry contract. Below is an example of how you can think about this setup, which disregards the piloting aspect for simplicity.

In the broader sense, you could imagine how you can use reputation for, really, anything by using ERC-721 tokens (and their metdata!) as the core primitive. The overarching actions the owner takes using that token are all on-chain, so all of that data can be used, as in the Rigs use case, a simple aggregation of the block-based FT earned. But, this could be extended to other use cases that aren’t about NFTs as collectibles (e.g., DeFi, AI/ML, etc.).

Note: for a full reputation contract example, review the contract at the bottom of this writeup.

Example SQL setup

Upon contract deployment, you could set up a token_reputation table to track the aforementioned session data. With staking/unstaking actions, you either insert into the table with starting session data or update that row with ending session data. Thus, you can query the total reputation earned for a single owner/token and subsequently use that off-chain (e.g., stored in metadata, community building, etc.).

/* Example: tracking reputation using sessions */ /* Create a table to track reputation */ CREATE TABLE token_reputation ( id INTEGER PRIMARY KEY, /* Session ID */ token_id INTEGER NOT NULL, /* NFT token ID */ owner TEXT NOT NULL, /* Token owner's address */ start_time INTEGER NOT NULL, /* Starting block number */ end_time INTEGER /* Ending block number */ ); /* Process event at `block.number` value of `100`, and also emit event */ INSERT INTO token_reputation (token_id, owner, start_time) VALUES (1, '0x1234...', 100); /* Process event at `block.number` value of `175`, and also emit event */ UPDATE token_reputation SET end_time = 175 WHERE token_id = 1 AND owner = '0x1234...' AND end_time IS NULL; /* Query reputation for address `0x1234...` */ SELECT SUM(end_time - start_time) FROM token_reputation WHERE owner = '0x1234...'; /* Returns a reputation value of: 75 */

Example Solidity setup

On the Solidity side of things, there’s a bit more nuance. But, we can look at another simplification from the Rigs contract to understand how this SQL works on-chain. Take, for example, a method that ends the staking session—this is how the Rigs parkRig method works. This example makes use of OpenZeppelin’s strings library for casting data within the SQL statement and some additional Tableland-specific helpers:

  • A setters string that stores the column to update.

  • A filters string to ensure only the correct session is updated—there is a bit of storage here with the startTime variable that stores when a reputation session begins (e.g., getter to a mapping or similar).

  • These are passed to TablelandDeployments.get().mutate(). The get() method returns an interface with the correct Tableland Registry address, allowing you to call the mutate() method that passes a mutating SQL statement—in this case, an UPDATE statement.

  • There are contract variables stored for _TOKEN_REPUTATION_PREFIX and _tokenReputationTableId. The prefix is a custom string the table creator sets, e.g., token_reputation. All tables have a globally unique name in the format {prefix}_{chainId}_{tableId}. The chain ID is inferred. Thus, the only missing piece is the unique table ID, stored as _tokenReputationTableId.

  • The SQLHelpers library takes these inputs and concatenates the inputs into an UPDATE statement to make the process a bit smoother.

uint64 startTime = sessionStartTime(tokenId); // Getter for start time, stored in contract string memory setters = string.concat( "end_time=", Strings.toString(uint64(block.number)) ); // Only update the row with the matching `token_id` and `start_time` string memory filters = string.concat( "token_id=", Strings.toString(uint16(tokenId)), " and ", "start_time=", Strings.toString(startTime) ); // Update the pilot information in the token_reputation sessions table TablelandDeployments.get().mutate( address(this), _pilotSessionsTableId, SQLHelpers.toUpdate( _TOKEN_REPUTATION_PREFIX, _tokenReputationTableId, setters, filters ) ); // Emit some sort of event to indicate the reputation session has ended emit Unstaked(tokenId);

With SQL mutating the off-chain Tableland database, all of the on-chain SQL can be read using Tableland’s Gateway API, SDK, CLI, etc. For example, maybe you want to compose this reputation data via a read query and return ERC-721 compatible metadata. The SQL statement needed to generate this is a little dependent on each specific setup, but using the example SQL SELECT statement above along with SQLite’s built-in json_group_array and json_object functions. Generally, ERC-721 queries might look like this example where you could add an additional JOIN to combine the standard NFT attributes with the reputation attribute.

Thus, your NFT’s attributes could be extended to include something like the following, which aggregates reputation data over all sessions.

{ "attributes": [ { "display_type": "number", "trait_type": "Reputation", "value": 75 } ] }

Existing EIPs

When starting down the EIP path, we took a look at a few existing EIPs:

  • EIP-5058: Designed with lock/unlock methods and events, and it includes other methods like setting time locked expiration and operator approvals.

  • EIP-5192: Minimally designed for non-transferrable "soulbound" tokens ("SBT") such that it’s largely event-based (emits Locked or Unlocked) and only has a locked getter method.

  • EIP-5753: Although EIP-5753 is currently a draft and yet to be accepted, it is rather similar to EIP-5192 but adds additional features with lock and unlock methods, rather than only events and a getter.

  • EIP-6982: This is a minimal interface that includes similar functionality to those mentioned but focuses on “default” locking (upon contract deployment) and a single locked method that emits true or false for the locked status.

Ultimately, our potential EIP ended up being quite similar to these, so we felt it was better to, potentially, consider one of these in the future for composability purposes. Even though none of them were reputation-specific, they all speak to the core concept of staking/unstaking or locking/unlocking.

One interesting tidbit we learned here was about how marketplaces leverage these types of events. For example, OpenSea uses both of the events noted in EIP-5192 and EIP-5753 to disable trading for staked/locked tokens. There are other accepted events by OpenSea, including the Stake and Unstake events. OpenSea noted the marketplace should respond to the Stake and Unstake event as long as they emit an indexed tokenID param. Namely, it appears their marketplace implements this functionality not with the event topic but the event name and arguments.

Key differences & data availability

Our approach was thinking about reputation from an on-chain driven, off-chain materialized perspective. We’ve designed Rigs in that we believe not all data needs to be stored on-chain in storage, as long as it exists in the Data Availability (”DA”) layer. If you take a look at the example SQL & Solidity we walked through above, you might notice that the total reputation earned isn’t stored in the contract.

Whenever you start or end a session, this stores only the starting time of the session to help decrease storage costs. Instead, Tableland is materializing the SQL. Any valid SQL statement that’s sent to the Registry contract will be emitted as a RunSQL event, which includes pertinent information like the statement itself and access control rules. Here, the DA layer is capturing all of the token’s reputation such that anyone can recreate the token’s reputation state. This is an important callout because it’s a core principle with not only Rigs but the Tableland protocol, in general.

Final thoughts

Due to this design, it didn’t make the most sense to try and force an EIP into the mix since it was rather dependent on Tableland functionality. Albeit, the idea still holds! Taking some of the EIPs noted above, you could imagine implementing their lock/unlock or stake/unstake methods and associated events—and within these methods, use Tableland to store reputation-related data.

Generalized reputation contract

A final word—perhaps, implementing your own token reputation contract would look something like this! The example doesn’t implement a specific EIP but could use a custom interface with stake/unstake methods and events (with an indexed tokenId argument)—which, as mentioned, is something marketplaces like OpenSea actually use today.

// SPDX-License-Identifier: Unlicense pragma solidity >=0.8.10 <0.9.0; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts/utils/Counters.sol"; import "@openzeppelin/contracts/utils/Strings.sol"; import "@tableland/evm/contracts/utils/SQLHelpers.sol"; import "@tableland/evm/contracts/utils/TablelandDeployments.sol"; // You could implement some simply custom interface, such as stake/unstake methods and events // Or, use one of the noted EIPs and alter the functionality accordingly import "./ITokenReputation.sol"; contract Reputatoin is ERC721, ITokenReputation { using Counters for Counters.Counter; Counters.Counter private _tokenIdCounter; // Incrementor for the token ID mapping(uint256 => bool) private _status; // Track the token's staking status: `true` (staked) or `false` (unstaked) uint256 private _tokenRepTableId; // Some reference used off-chain to store reputation data string private constant _REPUTATION_PREFIX = "token_reputation"; // Used off-chain but stored for interoperability purposes string private _baseURIString; // Base URI for metadata (set on deployment to https://tableland.network with URL params) constructor() ERC721("Tableland", "TBL") { _tokenRepTableId = TablelandDeployments.get().create( address(this), SQLHelpers.toCreateFromSchema( // Some off-chain SQL database table schema "id INTEGER PRIMARY KEY," // Track a staking session ID "token_id INTEGER NOT NULL," // The specific token ID "owner text NOT NULL," // The address that owns the token at that point in time "start_time INTEGER NOT NULL," // Starting block for staking activity via `stake()` "end_time INTEGER", // Ending block for staking activity via `unstake()` _REPUTATION_PREFIX // Some off-chain SQL database table name ) ); _baseURIString = "https://tableland.network/api/v1/query?unwrap=true&extract=true&statement="; } function _baseURI() internal view override returns (string memory) { return string.concat( _baseURIString, "select%20json_object(%27attributes%27,json_array(json_object(%27display_type%27,%27number%27,%27trait_type%27,%27Reputation%27,%27value%27,SUM(end_time-start_time))))%20from%20", SQLHelpers.toNameFromId(_REPUTATION_PREFIX, _tokenRepTableId), "%20where%20token_id%20%3D%20" ); } function mint() external payable { uint256 tokenId = _tokenIdCounter.current(); _tokenIdCounter.increment(); _safeMint(_msgSender(), tokenId); } function stake(uint256 tokenId) external { address tokenOwner = _ownerOf(tokenId); require(_msgSender() == tokenOwner, "UNAUTHORIZED"); // Token owner or delegate can stake require(_status[tokenId] == false, "INVALID STATUS"); // Only currently unstaked tokens can be staked // Track the staking session with some off-chain metadata TablelandDeployments.get().mutate( address(this), _tokenRepTableId, SQLHelpers.toInsert( _REPUTATION_PREFIX, _tokenRepTableId, "token_id,owner,start_time", string.concat( Strings.toString(uint64(tokenId)), // Some implementation-specific casting ",", SQLHelpers.quote(Strings.toHexString(tokenOwner)), // Track reputation for the token owner ",", Strings.toString(uint64(block.number)) // Track the staking block number starting point ) ) ); _status[tokenId] = true; emit Stake(tokenId, tokenOwner); } function unstake(uint256 tokenId) external { address tokenOwner = _ownerOf(tokenId); require(_msgSender() == tokenOwner, "UNAUTHORIZED"); // Token owner or delegate can unstake require(_status[tokenId] == true, "INVALID STATUS"); // Only currently staked tokens can be unstaked // Update the metadata's `end_time` to accrue block-based reputation, ending at the current `block.number` string memory setters = string.concat( "end_time=", Strings.toString(uint64(block.number)) ); // Only update the row with the matching `token_id` and without an `end_time` (i.e., unfinished staking session) string memory filters = string.concat( "token_id=", Strings.toString(uint64(tokenId)), " AND ", "owner=", SQLHelpers.quote(Strings.toHexString(tokenOwner)), " AND ", "end_time IS NULL" ); // Update the token reputation data by ending the current block-based staking session TablelandDeployments.get().mutate( address(this), _tokenRepTableId, SQLHelpers.toUpdate( _REPUTATION_PREFIX, _tokenRepTableId, setters, filters ) ); _status[tokenId] = false; emit Unstake(tokenId, tokenOwner); } function onERC721Received( address, address, uint256, bytes calldata ) public pure returns (bytes4) { return 0x150b7a02; // Allows this reputation contract to own NFTs, which may be useful in certain cases } }

Textile Blog & Newsletter logo
Subscribe to Textile Blog & Newsletter and never miss a post.
  • Loading comments...