Dev Diary II: A Walkthrough of the adChain Registry TCR in Solidity
Recommended reading: I previously dissected the implementation of PLCRVoting.sol developed for adChain. The first article provides helpful background to better understand the adChain Registry walkthrough.
The adChain Registry is a token-curated registry (TCR). Excitingly, adChain’s TCR implementation is highly generic! Just like our partial-lock commit/reveal (PLCR) voting code can be used for token voting with any ERC-20, the adChain Registry is generic to any TCR use case that authenticates listings as strings. If you don’t know what token-curated registries are about in general, here are a few good articles on the topic.
This article is about implementing a TCR. If you read the previous article on PLCR voting, you will be happy to know that implementing the rest of a token-curated registry is much simpler.
What’s in a TCR?
A token-curated registry is, fundamentally, a lookup table. The system’s hottest path is a view function for checking whether or not some key exists in the registry. This can be a simple public mapping for which solc’s generated getter will suffice. The complexity, of course, will be in populating that mapping.
The main game loop in a TCR is its application/challenge process. In this game loop a candidate puts down a deposit in the registry’s intrinsic token to begin an application. After some period if no challenge is raised, the candidate can poke their application into listing status (and the deposit stays with the listing). If a challenge is raised against an application or a listing, a token-weighted vote begins. The voting logic is handled by the PLCR voting contract, while the registry updates listing and application statuses, and disburses rewards to winners on the basis of voting outcomes.
There is also a system for parameterizing the TCR, but we’ll save that for a future blog post.
A token-curated registry takes three constructor arguments to initialize its storage, all of which are contract addresses: the registry’s intrinsic token, the PLCR voting instance it will rely on, and the parameterizer contract that provides the registry’s system parameters. We’ll see later how exactly all these things are used, but note for your own purposes that there are dependencies in the order for which these systems are deployed.
When an unlisted candidate desires to acquire listing status in a registry, they will need to make an application. The apply function takes a string for the domain which is being applied, and an integer amount which is the number of tokens to deposit with the application.
The apply function begins with some require checks. First, we’ll call a helper function to make sure the domain being applied isn’t already whitelisted. Next, we’ll call another helper function to check whether an active application already exists for this domain, and throw an error if one does. Finally, we make sure that the amount of tokens specified as the deposit is at least the minimum specified by the TCR’s parameterizer.
We’ll come back to some of these helper functions later to see how they actually work, but lets consider them abstract for now.
If all the required checks pass, we’ll instantiate a data structure called a Listing for this domain. Applications and Listings use the same data structure, and Listing structs contain a boolean called whitelisted to determine whether the contents of a listing have successfully completed the application process or not.
First, we’ll grab the location in storage for this domain’s Listing struct in the listings mapping, and set the Listing’s owner as the message sender of the function invocation (the address of the account that called the apply function). On line 95 we’ll attempt to transfer the amount of tokens specified by the function caller from their account to the registry contract.
Finally, we’ll set the listing’s application expiry date to the current time plus the parameterizer’s applyStageLen, and set the listing’s unstaked deposit to the specified token amount. The application expiry date is the date after which an application can be poked in whitelisted status if it hasn’t been challenged. The unstaked deposit is the number of tokens in a listing which are not locked up in a challenge at any given time.
We fire an event to notify any curious client applications, and then we’re done! We’ve made an application to the adChain TCR.
Now we’ll look at what happens when a challenge is made against an application.
The challenge method accepts a single argument, the string-encoded name of the domain to be challenged (which is the key to a Listing struct in the listings map). After initializing some local variables, including a pointer to storage for the listing being challenged, we perform some require checks.
First we’ll make sure a Listing struct exists for the provided string. If there isn’t one, there is nothing to challenge! The next check we do is to ensure that only one challenge can exist per listing at any given time. To do this, we’ll grab the listing’s challenge ID and use it to index into a mapping of challenges. Then, using Solidity’s method-like syntax for library data structures, we’ll require that either no challenge exists for the listing, or that it has been resolved if one does.
We learned about Solidity’s facility for pseudo-object-oriented programming with our DLL and AttributeStore data structures in the PLCR voting walkthrough, so we won’t retread too much ground on the mechanics of that here. We use an external library for itsthe Challenges data structure, as because our parameterizer uses the exact same mechanism, so and a library helps us to not write duplicate code. A Challenge struct contains a lot of information.
We have special logic to capture the touch-and-remove TCR edge case beginning on line 180. If the listing’s unstaked deposit is less than the TCR’s current MIN_DEPOSIT, we execute the touch-and-remove by calling a helper function resetListing, which transfers any unstaked deposit in the listing to its owner and deletes the listing from the listings map.
Let’s assume the MIN_DEPOSIT has not changed since application time, so we won’t hit that edge case. Notice though that this is exactly why we allow users to specify deposits greater than MIN_DEPOSIT, so as to guard against situations like that! To continue, on line 187 we transfer tokens from the challenger (the message sender) to the registry contract equal to the required MIN_DEPOSIT.
Next we initialize some data structures. We create a new poll in our PLCR contract and capture its poll ID. Then we initialize a new Challenge struct. Most of the struct members are self-explanatory except for perhaps rewardPool and winningTokens. The rewardPool is the absolute number of tokens which will be available for voter rewards when the challenge is resolved. winningTokens is (eventually) the absolute number of tokens which were committed for the winning side of the vote. We’ll see later how these two struct members are used to compute voter payouts by token weight.
After instantiating those data structures we’ll add the new challenge to the challenged domain’s Listing struct by its challenge ID (which corresponds to a PLCR poll ID), and decrement the listing’s unstaked deposit by the deposit amount of the challenger. This prevents the challenged listing’s owner from withdrawing their stake while the challenge remains unresolved.
Voting is delegated to the registry’s PLCR voting contract, the address of which is stored in the state variable called voting. Client software should find and expose the polls that correspond to open challenges and expose these to users for commit/reveal voting. PLCR voting was discussed in a previous article so we’ll blackbox it and assume the following for the purposes of our walkthrough: a vote was concluded in which two deposits of 100 tokens each were at stake. The challenger won the challenge, with 200 tokens voting in support of the challenge and 100 tokens voting against. The registry’s DISPENSATION_PCT is 50.
Resolving a challenge
Once a Challenge’s reveal stage has ended, the registry’s updateStatus function can be called for the challenged domain.
updateStatus is a general routing function that will branch us off into more specific logic depending on the state of the domain whose status is being updated. The first thing we’re going to check is if the provided domain canBeWhitelisted.
We’re going to check four things in canBeWhitelisted: does an application exist and, if so, has its expiry date passed and, if so, is this not already whitelisted and, if so, does a challenge either not exist or, if one does exist, has it been resolved? If that entire boolean statement evaluates as true, the domain can be whitelisted. Our domain will fail the final test, however: a challenge does exist and it has not been resolved. So we proceed to the next check: challengeCanBeResolved.
challengeCanBeResolved is a simple wrapper function that calls the Challenge’s canBeResolved method. This method checks with the PLCR contract to see whether voting for the challenge’s poll has been concluded and that this challenge has not already been resolved. In our case, this will all be true, so we proceed to our registry’s resolveChallenge function.
The resolveChallenge function is private, and operates on the internal state of a Challenge struct. It’s a somewhat complex function, and dangerous! Lets use it to resolve our challenge.
The first thing we do is get pointers to our Listing and Challenge structs in storage, since we’ll be using them a lot. Then we’ll create a variable called winnerReward which is computed by the challengeWinnerReward method of the Challenge struct.
The first thing challengeWinnerReward does is handle an edge case: if nobody voted in the poll, give all of the loser’s stake to the winner (as opposed to burning the portion that would otherwise be reserved for voters). We don’t hit this edge case in our example, so we return twice the stake minus the amount of the reward pool (which, recall, is the number of tokens reserved for voters on the winning side). So we return the challenge winner’s stake plus their special dispensation of the loser’s stake.
After computing that we’ll set a local bool wasWhitelisted. The only use of this variable will be to help us fire more informative events.
Next we’ll check whether the challenge succeeded or failed using the isPassed method of our PLCR contract. If the application “passed”, that means the challenge failed. In our case the application did not pass, so we’ll jump to the else clause on line 366.
In this clause we reset the listing, which removes the struct from storage (or de-initializes it, really) and transfers any tokens that weren’t staked in the challenge back to the listing owner. Then we transfer the winnerTokens to the challenge winner and fire some events.
Finally, and irrespective of whether the challenge was won or lost, we’ll set the the Challenge’s winningTokens to the result of the PLCR contracts getTotalNumberOfTokensForWinningOption method and set the Challenge’s resolved member to true. Note that even though we’ve deleted the Listing from storage, the Challenge remains!
The aftermath: voters claiming rewards
After a challenge has been resolved, voters on the winning side will want to claim their spoils. Remember that rewards come from the forfeited deposit of the losing party in the challenge, which was always in the custody of the registry contract. To withdraw the principal tokens a voter actually used to vote with, they’ll need to interact with the PLCR contract itself. To claim rewards, voters use the claimReward function, which is a thin wrapper around the Challenge’s claimReward method.
A Challenge struct includes a mapping of addresses to bools called tokenClaims. tokenClaims are initialized as false, and we’ll set them to true as token holders come in to claim their rewards. The first thing we check in a claimReward call is that the voter has not already claimed a reward. We also check that the challenge being claimed for has actually been resolved.
Next we’ll set two local variables. The first is just the number of tokens the voter has committed on the winning side of the vote, and is computed by the PLCR contract’s getNumPassingTokens function. Then we set a variable called reward, computed by the Challenge’s own voterReward method.
voterReward divides the product of the voter’s winning tokens and the reward pool by the absolute number of winning tokens. In our example the reward pool will be 50 tokens, since 100 tokens were at stake and the special dispensation is 50. We’ll say this voter committed 50 out of 200 tokens to the winning side. So the product of their 50 tokens and the reward pool is 2500, divided by the winning tokens (200) is 12.5. We don’t handle fractional amounts, so reward will end up as 12 (a luckier claimant will end up taking that .5 home eventually).
Once we’ve gotten the number of winning tokens the voter committed and calculated their reward, we’ll deduct their tokens from the Challenge’s winningTokens and deduct their reward from the rewardPool. This keeps the proportionality of token allocations correct as future claimants come in to claim their spoils. Finally we transfer the reward to the voter and set the voter’s key in the tokenClaims mapping to true. We’re done!
We didn’t cover absolutely everything the adChain TCR does, but we covered a path that touched most of the codebase and certainly hit the most complex parts.
Improving the adChain TCR
The code reviewed here has been frozen for audit, but we hope to make one significant further improvement to be audited as an increment on the initial report.
The adChain TCR has many different event types. This is to help clients like app.adChain.com populate user views. This approach requires a persistent server that listens for contract events and feeds them to intermittently connected clients. Preferable would be an iterable data structure that a client can call to get the complete set of listed items. The listings map as-is cannot be iterated, so we imagine adding a doubly-linked list like that used in PLCR voting which can be iterated by clients.