Join our Telegram channel to learn more about web3 development!
We will consider CTF within the framework of a prediction market implemented on a blockchain, so it is important to understand that the main logic will be executed through smart contracts.
You can take a look at the diagram below to get a simplified understanding of what a prediction market looks like.
The prediction market features the event “GPT-5 released in 2024?”. The user votes for one of the outcomes, either “Yes” or “No”. In essence, they are placing a bet. Under the hood, they will be purchasing a certain amount of share tokens using a base token. The share token defines each of the outcomes. If their bet is successful, they will receive a reward, essentially a payout.
Since a prediction market is an isolated system implemented through blockchain smart contracts, the data regarding the event’s results will be provided by a special oracle trusted by the protocol and by us as well.
It’s also worth noting that a user who has acquired a share token can, at any moment before the event is resolved and the results are available, sell and exchange the share token back for their base asset. I try to avoid using this word in reviews, but it turns out this is a basis for trading. I’m not a trader myself, and I’m not encouraging you to be either.
Essentially, CTF is used to tokenize prediction outcomes. For the event “GPT-5 released in 2024?”, which we examined above, only two outcomes are defined. We will give the outcomes the code names “Y” and “N.”
However, a prediction market can be large, and there can be many events within it. Let’s look at another prediction: “Will GPT limit requests to 10 per hour until August?”. This prediction also has two outcomes. Let’s give them the code names “B” and “A”.
These two predictions are directly related, and we can look at all possible outcomes within the scope of two events.
Also, don’t forget about the reverse order of combinations.
Let’s assume the user participates in voting for both predictions at once. As a result, we have eight different user behavior options, considering the order of their actions (first, the first prediction, then the second, and vice versa). In the classic approach to building a prediction market, this really results in eight variations.
Now, here’s where CTF comes into play, allowing us to simplify the number of combinations down to 4, because everything happens on a single contract, and interdependent conditions are processed under the hood — more precisely, opposite combinations are merged.
Within the CTF framework, each of the four combinations is called a position. This means that each position can represent a user’s prediction for several events at once. Moreover, this approach allows for the creation of forecasts that not only correlate but also depend on each other. For example, “What will Bitcoin’s price be if Trump wins the election?”
Everything we’ve discussed above leads us to the idea that the conditional token framework can only be used for prediction markets. And indeed, that’s true. The user expresses their opinion by voting with an asset, and this allows for gathering the most accurate prediction from a group of users. This prediction is highly likely to be close to the truth.
However, what is not immediately noticeable is what lies behind the voting process. It’s the purchase of a share token at a specific market price. Moreover, prediction markets offer not just buying; holders of share tokens can sell them at any time. Selling also happens at market price. Do you catch the connection? Add a couple of tools like limit orders, and you’ve already got something like an exchange based on a prediction market.
The protocol itself believes that CTF is not limited to prediction markets and suggests it can be applied in fields such as:
- GameFi. In a gaming context, tokens can be used to create rewards or achievements that depend on meeting certain conditions within the game. For example, players can receive tokens for completing difficult tasks or reaching certain levels in the game.
- Payment systems. For example, tokens can be used to implement purchase agreements, where payments are only made upon fulfillment of predefined conditions, such as the delivery of goods or completion of a service.
- Options. For example, a token can be created to implement an option that activates if an asset’s price reaches a certain level.
- And so on. Take any subject area, and add a “condition” to it — if the token fulfills the condition, something works in that subject area.
However, I am skeptical about other use cases. To me, they seem forced, and I believe the best application is prediction markets with the ability to buy or sell share tokens. If you don’t believe me, read what Gnosis itself writes about this and draw your own conclusions.
What does AMM have to do with this, you ask? After all, a prediction market isn’t a dex. However, to be able to buy and sell share tokens, associating them with your prediction, bet, or position, it’s necessary to calculate the value of a share token.
Let’s imagine that there is an event in the prediction market: “Will the Portugal national team win the World Cup this year?” This event has 100 “for” share tokens and 100 “against” share tokens. I don’t believe it, so I vote against it and buy 70 corresponding share tokens. Anyone else might think like I do, and at some point, there could be a situation where there aren’t enough tokens available for a new user to vote. To avoid this situation, we use an Automated Market Maker. This is the same technology used for automatic asset pricing in decentralized exchanges (dex). The price calculation is based on the ratio of the asset reserves in the pool (on the smart contract).
In essence, the market maker monitors, using algorithms and math, to ensure that the reserves of both assets in the pool don’t run out. So, when one asset becomes less available than the other, the price of the first asset rises. This is true for the second asset as well.
Gnosis offers two market makers:
- CPMM (Constant Product Market Maker)
- LMSR (Logarithmic Market Scoring Rule)
It uses the same algorithm as the Uniswap and Balancer pools. It follows the same formula x * y = k
;
Where: x — the amount of one asset in the liquidity pool
y — the amount of the other token in the liquidity pool
k — a constant value representing the product of the two asset amounts x and y in the pool. The value of k remains unchanged with every transaction, which is the key invariant of this model.
The advantage of this market maker is that its math is simple. There’s no need to program logarithms.
Initially, LMSR was a pricing mechanism developed for use in prediction markets. It uses a logarithmic function to determine the probability of an outcome. The more users vote on a specific outcome, the higher the probability (and, accordingly, the cost) of that outcome.
Where:
- C(q) — the cost function.
- q[i] — the number of bets on the i-th outcome of the event
- b — the liquidity parameter, which adjusts how quickly prices change as the volume of bets changes.
LMSR is more scientific and better researched compared to CPMM. It allows for better risk and volatility control of assets by limiting maximum losses through the b liquidity parameter.
From everything we’ve discussed above, it becomes clear that the framework is composite and includes two major modules:
- Conditional tokens. These represent a conditional token based on ERC-1155. It includes the logic for calculating a user’s position on events.
- AMM for conditional tokens. Essentially, this is the liquidity responsible for trading position tokens and for price formation.
These two major modules are located in separate repositories. Additionally, there is a separate repository containing utils that are used for AMMs.
The story behind util-contracts is interesting. This repository is used in the market maker factory smart contracts. For example, in FixedProductMarketMakerFactory.sol.
import { ConstructedCloneFactory } from "@gnosis.pm/util-contracts/contracts/ConstructedCloneFactory.sol";
However, if we check the util-contracts repository itself, we won’t find the ConstructedCloneFactory.sol
smart contract there. Apparently, something went wrong, and it didn’t survive the alpha version, but it’s possible to find it. You can pull it from the npm versions. Here’s the link.
Below, we’ll break down the code of each framework module separately. This section is intended for those familiar with development.
Conditional tokens organize the process of working with share tokens, allowing users to make predictions. They enable the combination of predictions, reflecting the user’s opinion.
The logic of conditional tokens is implemented in this repository.
The main smart contract we need is ConditionalTokens.sol. It is the entry point from which you should begin your study. The smart contract inherits from ERC1155.sol and uses the CTHelpers.sol library to store auxiliary functions.
❗️ To move further, we need to discuss terminology. This might get a bit tricky, but it’s very important for the further understanding of the article.
Condition and outcomes
First and foremost, let’s talk about a prediction, also known as a forecast, event, statement, or question. In the code, we will refer to all of these as condition, and its possible outcomes as outcomes.
In essence, a condition is a question posed to an oracle, which it must answer. Such a condition requires its own identifier (conditionId), which is the result of hashing three parameters:
- oracle. The address of the oracle that will compute the result of the event.
- questionId. An external identifier for the condition. It is assumed to be of the
bytes32
type and is left to the discretion of the user creating the prediction via the smart contract call. It could be a counter where each new prediction increments the counter by one, or it could use a more complex scheme, such as hashing the text and other data. It will be used by the oracle. - outcomeSlotCount. The number of outcomes for the prediction.
To obtain the prediction identifier, the function getConditionId() is used.
Outcome information (true or false) will be encoded using a bit array called the Index set. For example, there are three outcomes: A, B, C. Then the truth of A and B can be encoded starting from the least significant bit as 0xCBA -> 0x011
, where a one means the outcome is true, and a zero means false.
The Index Set allows encoding the event outcomes to show which outcome the user is voting for. For each of these binary encodings, a decimal representation can be derived. For example, 0x011 -> 3
. To ensure the smart contract can precisely determine that this index set refers to a specific event, a collectionId will be generated based on:
- The
indexSet
, expressed in decimal. - The
conditionId
. - The
parentConditionId
.
In essence, the index set allows you to describe all the possible combinations a user can vote for. Each of these combinations will have its own unique identifier called collectionId
.
To obtain the outcome collection identifier, the function getCollectionId() is used.
The last term we need to discuss is position. This is simply a combination of the outcomes chosen by the user (which are encoded using the index set) and the collateral asset for the position. The collateral asset is the base token of the prediction market, used to purchase share tokens for a specific outcome.
A position, similarly, has an identifier. It is called positionId
. The token identifier (token ID) for ERC-1155 corresponds to the positionId
. Since it follows the ERC-1155 standard, there is a set of functions (safeTransferFrom()
, setApprovalForAll()
) that allow transferring share tokens and, along with them, the ownership of the position to other accounts.
To get the position identifier, the function getPositionId() is used.
Prepare
For the ConditionalTokens.sol
smart contract to recognize a new condition, you need to inform it. This process is called “preparing the event” and involves calling the prepareCondition() function.
function prepareCondition(address oracle, bytes32 questionId, uint outcomeSlotCount) external {
// Limitation on the number of outcomes
require(outcomeSlotCount <= 256, "too many outcome slots");
require(outcomeSlotCount > 1, "there should be more than one outcome slot");// Generating the identifier for the prediction
bytes32 conditionId = CTHelpers.getConditionId(oracle, questionId, outcomeSlotCount);
// Checking that this prediction has not been created yet
require(payoutNumerators[conditionId].length == 0, "condition already prepared");
// Creating an array of outcome slots linked to the prediction identifier
payoutNumerators[conditionId] = new uint[](outcomeSlotCount);
emit ConditionPreparation(conditionId, oracle, questionId, outcomeSlotCount);
}
// Creating an array of outcome slots linked to the prediction identifier
mapping(bytes32 => uint[]) public payoutNumerators;
mapping(bytes32 => uint) public payoutDenominator;
In the first payoutNumerators
mapping, the oracle will record the result of each outcome. The result for each outcome will indicate 0
for a false outcome and 1
for a correct outcome. For three outcomes, there may be a situation where two out of three are true. In this case, the oracle will return the result for each outcome as follows: [0.5, 0.5, 0]
.
Solidity cannot work with fractional numbers. Therefore, a classic approach is used, similar to ERC-20, where the concept of decimal
is introduced. This is the number by which you need to divide your balance to get the result as a whole and fractional value. In our case, instead of decimal
, we use the second mapping payoutDenominator
, which stores the number by which the numerator from payoutNumerators
will be divided.
Reporting
After an event is added to the smart contracts, it can be executed by the oracle once the time has passed. To ensure that the event can only be executed by a specific oracle, the oracle’s address is included in the conditionId
during the prepare
stage. When the oracle calls the reportPayouts() function, the conditionId
will be generated again, but instead of the oracle’s address, msg.sender
will be used.
bytes32 conditionId = CTHelpers.getConditionId(msg.sender, questionId, outcomeSlotCount);
If msg.sender
is a different address than the one initially set as the oracle’s address, then the conditionId
will also be generated incorrectly.
The full code for the reportPayouts()
function is below.
// payouts - the list of results for each outcome
function reportPayouts(bytes32 questionId, uint[] calldata payouts) external {
// The number of results must match the number of outcomes
uint outcomeSlotCount = payouts.length;
require(outcomeSlotCount > 1, "there should be more than one outcome slot");
// Generate the prediction identifier, which must match the target prediction identifier
bytes32 conditionId = CTHelpers.getConditionId(msg.sender, questionId, outcomeSlotCount);
require(payoutNumerators[conditionId].length == outcomeSlotCount, "condition not prepared or found");
require(payoutDenominator[conditionId] == 0, "payout denominator already set");// Save the oracle's results and calculate the denominator for the values
uint den = 0;
for (uint i = 0; i < outcomeSlotCount; i++) {
uint num = payouts[i];
den = den.add(num);
require(payoutNumerators[conditionId][i] == 0, "payout numerator already set");
payoutNumerators[conditionId][i] = num;
}
require(den > 0, "payout is all zeroes");
payoutDenominator[conditionId] = den;
emit ConditionResolution(conditionId, msg.sender, questionId, outcomeSlotCount, payoutNumerators[conditionId]);
}
Splitting
Somewhere between the event preparation and the moment when the oracle calculates the event, there is a process where the user can vote on the selected outcome.
This process is called splitting, because it involves dividing the user’s asset across multiple positions and receiving a share token portion in each position. The process includes two types of splitting:
- Splitting collateral. This is when the user buys share tokens, votes by transferring the base asset, and places a bet on one or more outcomes.
- Splitting position. This is when the user divides their position into several components that make up the position.
Dealing with the base asset is fairly straightforward: you deposit the specified amount into the smart contract, and it is allocated to the selected positions.
Splitting a position is a bit more interesting. You can only split composite positions, which contain multiple outcomes.
However, there are scenarios where splitting is not possible. For example, you cannot split a position that combines two events into two independent positions for each event. Or you cannot split the base asset in such a way that it covers all possible outcomes, ensuring a guaranteed profit in any scenario. More examples of invalid splits can be found in the documentation.
To perform splitting, you need to call the splitPosition() function.
function splitPosition(
IERC20 collateralToken,
bytes32 parentCollectionId,
bytes32 conditionId,
uint[] calldata partition,
uint amount
) external {
// Checking that an array of positions for splitting has been provided
require(partition.length > 1, "got empty or singleton partition");
uint outcomeSlotCount = payoutNumerators[conditionId].length;
// Checking that an event with this identifier exists
require(outcomeSlotCount > 0, "condition not prepared yet");// Preparing a mask based on the number of outcomes. For 4 outcomes, it will be 0x1111, for five 0x11111
uint fullIndexSet = (1 << outcomeSlotCount) - 1;
// Will be responsible for the result of the positions the user is splitting
uint freeIndexSet = fullIndexSet;
uint[] memory positionIds = new uint[](partition.length);
uint[] memory amounts = new uint[](partition.length);
for (uint i = 0; i < partition.length; i++) {
uint indexSet = partition[i];
// Checks that the indexSet is within the range of possible combinations for the event
require(indexSet > 0 && indexSet < fullIndexSet, "got invalid index set");
require((indexSet & freeIndexSet) == indexSet, "partition not disjoint");
freeIndexSet ^= indexSet;
// Obtaining a new position identifier for the index set
positionIds[i] = CTHelpers.getPositionId(collateralToken, CTHelpers.getCollectionId(parentCollectionId, conditionId, indexSet));
amounts[i] = amount;
}
if (freeIndexSet == 0) {
// Splitting the collateral asset
if (parentCollectionId == bytes32(0)) {
// Transferring the collateral asset from the user to the smart contract
require(collateralToken.transferFrom(msg.sender, address(this), amount), "could not receive collateral tokens");
} else {
// Burning share tokens of the current position
_burn(
msg.sender,
CTHelpers.getPositionId(collateralToken, parentCollectionId),
amount
);
}
} else {
// Splitting the position
// Involves burning the current position
_burn(
msg.sender,
CTHelpers.getPositionId(collateralToken,
CTHelpers.getCollectionId(parentCollectionId, conditionId, fullIndexSet ^ freeIndexSet)),
amount
);
}
// Minting share tokens for the new positions
_batchMint(
msg.sender,
positionIds,
amounts,
""
);
emit PositionSplit(msg.sender, collateralToken, parentCollectionId, conditionId, partition, amount);
}
Merging
When a user votes, it is equivalent to the term “splitting a position” or “splitting collateral,” where they transfer a certain amount of the base asset to the smart contract.
To exit the selected voting positions and withdraw the base asset back, there is a process called merging. This process involves not only merging back to the base asset but also to a specific intermediate position.
It can be confidently stated that mergePositions()
is the exact reverse function of splitPosition()
.
To initiate this process, you need to call the mergePositions() function.
function mergePositions(
IERC20 collateralToken,
bytes32 parentCollectionId,
bytes32 conditionId,
uint[] calldata partition,
uint amount
) external {
// Checking that an array of positions for merging has been provided
require(partition.length > 1, "got empty or singleton partition");
uint outcomeSlotCount = payoutNumerators[conditionId].length;
// Checking that an event with this identifier exists
require(outcomeSlotCount > 0, "condition not prepared yet");// Preparing a mask based on the number of outcomes. For 4 outcomes, it will be 0x1111, for 5 outcomes it will be 0x11111
uint fullIndexSet = (1 << outcomeSlotCount) - 1;
uint freeIndexSet = fullIndexSet;
uint[] memory positionIds = new uint[](partition.length);
uint[] memory amounts = new uint[](partition.length);
for (uint i = 0; i < partition.length; i++) {
uint indexSet = partition[i];
// Checks that the indexSet is within the range of possible combinations for the event
require(indexSet > 0 && indexSet < fullIndexSet, "got invalid index set");
require((indexSet & freeIndexSet) == indexSet, "partition not disjoint");
freeIndexSet ^= indexSet;
// Obtaining a new position identifier for the index set
positionIds[i] = CTHelpers.getPositionId(collateralToken, CTHelpers.getCollectionId(parentCollectionId, conditionId, indexSet));
amounts[i] = amount;
}
// Burning share tokens according to the provided positions
_batchBurn(
msg.sender,
positionIds,
amounts
);
if (freeIndexSet == 0) {
// Merging to the base asset
if (parentCollectionId == bytes32(0)) {
// Sending the base asset to the user
require(collateralToken.transfer(msg.sender, amount), "could not send collateral tokens");
} else {
_mint(
msg.sender,
CTHelpers.getPositionId(collateralToken, parentCollectionId),
amount,
""
);
}
} else {
// Merging to an intermediate position
_mint(
msg.sender,
CTHelpers.getPositionId(collateralToken,
CTHelpers.getCollectionId(parentCollectionId, conditionId, fullIndexSet ^ freeIndexSet)),
amount,
""
);
}
emit PositionsMerge(msg.sender, collateralToken, parentCollectionId, conditionId, partition, amount);
}
Redeem Position
The last thing to consider here is receiving the reward after the voting has ended and the oracle has calculated the event. Redeeming a position will only be possible when the outcome of the event is deemed true.
To claim (redeem) your reward, you need to call the redeemPosition() function.
// The parameter indexSets is not called partition because there is no need to pass the encoded split of the position; you can pass a list of all positions
function redeemPositions(IERC20 collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint[] calldata indexSets) external {
// Checking that the event exists and has been settled
// The denominator will be set by the oracle when it calls the reportPayouts() function
uint den = payoutDenominator[conditionId];
require(den > 0, "result for condition not received yet");
uint outcomeSlotCount = payoutNumerators[conditionId].length;
require(outcomeSlotCount > 0, "condition not prepared yet");uint totalPayout = 0;
// Preparing a mask based on the number of outcomes. For 4 outcomes, it will be 0x1111, for 5 outcomes it will be 0x11111
uint fullIndexSet = (1 << outcomeSlotCount) - 1;
// Looping through all provided positions
for (uint i = 0; i < indexSets.length; i++) {
uint indexSet = indexSets[i];
// Checks that the indexSet is within the range of possible combinations for the event
require(indexSet > 0 && indexSet < fullIndexSet, "got invalid index set");
// Retrieving the position identifier
uint positionId = CTHelpers.getPositionId(collateralToken,
CTHelpers.getCollectionId(parentCollectionId, conditionId, indexSet));
uint payoutNumerator = 0;
for (uint j = 0; j < outcomeSlotCount; j++) {
if (indexSet & (1 << j) != 0) {
payoutNumerator = payoutNumerator.add(payoutNumerators[conditionId][j]);
}
}
uint payoutStake = balanceOf(msg.sender, positionId);
if (payoutStake > 0) {
// Calculating the reward amount
totalPayout = totalPayout.add(payoutStake.mul(payoutNumerator).div(den));
// Burning share tokens for the position
_burn(msg.sender, positionId, payoutStake);
}
}
// Paying out the reward or minting share tokens if a parent collection is specified
if (totalPayout > 0) {
if (parentCollectionId == bytes32(0)) {
require(collateralToken.transfer(msg.sender, totalPayout), "could not transfer payout to message sender");
} else {
_mint(msg.sender, CTHelpers.getPositionId(collateralToken, parentCollectionId), totalPayout, "");
}
}
emit PayoutRedemption(msg.sender, collateralToken, parentCollectionId, conditionId, indexSets, totalPayout);
}
The protocol implements both types of market makers on smart contracts, CPMM and LMSR. In this section, we’ll look at the code for only one of them — CPMM. It’s simpler to understand from a mathematical perspective.
The name of the smart contract that implements CPMM is FixedProductMarketMaker.sol. Each market maker has its own factory. For FixedProductMarketMaker, logically, the factory is called FixedProductMarketMakerFactory.sol.
Here’s how it works:
To create a new event, where the market will operate under CPMM rules, you need to call the function createFixedProductMarketMaker(), which, using the ConstructedCloneFactory.sol factory extension, will create and deploy a smart contract for the new market.
The market maker will act as an intermediate recipient of the ERC-1155 token. The token will be sent to the user who initiated the transaction. To be able to receive ERC-1155 tokens, FixedProductMarketMakerFactory.sol
has two functions: onERC1155Received() and onERC1155BatchReceived().
In this way, the market maker serves as a middle layer between the user and the conditionalTokens.
Liquidity
Since this is an AMM, the first thing to understand is that it represents a pool that requires liquidity. This includes all the typical components: liquidity providers, LP tokens, and the ability for the first provider to set the asset price. In our case, it’s not the price that is set but the ratio of probabilities for each outcome.
The liquidity from the provider will be distributed across all the condition tokens, normalized according to the token ratio for each outcome. This means that when a provider adds liquidity, a certain amount of condition tokens will be issued for all possible outcomes.
The distribution of liquidity is calculated based on the condition token balances, which the smart contract retrieves through the private function getPoolBalances().
Technically, to add liquidity, you need to use the function addFunding(). Let’s take a closer look at it.
function addFunding(uint addedFunds, uint[] calldata distributionHint)
external
{
// Checking that the liquidity amount to be added is specified
require(addedFunds > 0, "funding must be non-zero");uint[] memory sendBackAmounts = new uint[](positionIds.length);
uint poolShareSupply = totalSupply();
uint mintAmount;
if(poolShareSupply > 0) {
// If liquidity is not being added for the first time, distributionHint should be empty
require(distributionHint.length == 0, "cannot use distribution hint after initial funding");
uint[] memory poolBalances = getPoolBalances();
uint poolWeight = 0;
// The pool with the maximum balance is calculated, necessary for distributing liquidity across condition tokens
for(uint i = 0; i < poolBalances.length; i++) {
uint balance = poolBalances[i];
if(poolWeight < balance)
poolWeight = balance;
}
for(uint i = 0; i < poolBalances.length; i++) {
uint remaining = addedFunds.mul(poolBalances[i]) / poolWeight;
// Calculating the amount of condition tokens that the liquidity provider will receive
sendBackAmounts[i] = addedFunds.sub(remaining);
}
// Calculating the amount of LP tokens that can be received
mintAmount = addedFunds.mul(poolShareSupply) / poolWeight;
} else {
// If liquidity is being added for the first time, distributionHint will set the liquidity distribution rule across pools
if(distributionHint.length > 0) {
require(distributionHint.length == positionIds.length, "hint length off");
uint maxHint = 0;
for(uint i = 0; i < distributionHint.length; i++) {
uint hint = distributionHint[i];
if(maxHint < hint)
maxHint = hint;
}
for(uint i = 0; i < distributionHint.length; i++) {
uint remaining = addedFunds.mul(distributionHint[i]) / maxHint;
require(remaining > 0, "must hint a valid distribution");
// Calculating the amount of condition tokens that the liquidity provider will receive
sendBackAmounts[i] = addedFunds.sub(remaining);
}
}
mintAmount = addedFunds;
}
// Transferring assets from the user to the smart contract
require(collateralToken.transferFrom(msg.sender, address(this), addedFunds), "funding transfer failed");
require(collateralToken.approve(address(conditionalTokens), addedFunds), "approval for splits failed");
// Creating a position for the liquidity provider
splitPositionThroughAllConditions(addedFunds);
// Minting the LP token
_mint(msg.sender, mintAmount);
// Transferring condition tokens to the transaction caller
conditionalTokens.safeBatchTransferFrom(address(this), msg.sender, positionIds, sendBackAmounts, "");
for (uint i = 0; i < sendBackAmounts.length; i++) {
sendBackAmounts[i] = addedFunds.sub(sendBackAmounts[i]);
}
emit FPMMFundingAdded(msg.sender, sendBackAmounts, mintAmount);
}
To withdraw liquidity, you need to call the removeFunding() function. The LP tokens will be burned, and the provider will receive their base asset back.
Why is liquidity needed? The more liquidity in the pool, the more resistant the market is to large trades and price manipulation. Allowing users to add liquidity gives them the opportunity to earn from fees.
Buy and sell
function buy(uint investmentAmount, uint outcomeIndex, uint minOutcomeTokensToBuy) external {
// Calculating the purchase amount of outcome tokens (share tokens for a specific outcome)
uint outcomeTokensToBuy = calcBuyAmount(investmentAmount, outcomeIndex);
// Checking if the minimum amount we are willing to accept is valid
require(outcomeTokensToBuy >= minOutcomeTokensToBuy, "minimum buy amount not reached");// Transferring the base asset to the market maker smart contract
require(collateralToken.transferFrom(msg.sender, address(this), investmentAmount), "cost transfer failed");
// Calculating the fee in the base asset
uint feeAmount = investmentAmount.mul(fee) / ONE;
feePoolWeight = feePoolWeight.add(feeAmount);
uint investmentAmountMinusFees = investmentAmount.sub(feeAmount);
require(collateralToken.approve(address(conditionalTokens), investmentAmountMinusFees), "approval for splits failed");
// Splitting into all positions
splitPositionThroughAllConditions(investmentAmountMinusFees);
// Transferring position tokens to the transaction sender
conditionalTokens.safeTransferFrom(address(this), msg.sender, positionIds[outcomeIndex], outcomeTokensToBuy, "");
emit FPMMBuy(msg.sender, investmentAmount, feeAmount, outcomeIndex, outcomeTokensToBuy);
}
Here, a question might arise: what is splitting into all positions (splitPositionThroughAllConditions)? It’s very simple. The bet is made on all possible outcomes.
function splitPositionThroughAllConditions(uint amount)
private
{
for(uint i = conditionIds.length - 1; int(i) >= 0; i--) {
uint[] memory partition = generateBasicPartition(outcomeSlotCounts[i]);
for(uint j = 0; j < collectionIds[i].length; j++) {
conditionalTokens.splitPosition(collateralToken, collectionIds[i][j], conditionIds[i], partition, amount);
}
}
}
There is a similar call for selling conditional tokens. The function responsible for this is sell(). But instead of splitting into all positions, the function mergePositionsThroughAllConditions() is called.
function sell(uint returnAmount, uint outcomeIndex, uint maxOutcomeTokensToSell) external {
// Calculating the amount of outcome tokens that will be exchanged for the base asset
uint outcomeTokensToSell = calcSellAmount(returnAmount, outcomeIndex);
require(outcomeTokensToSell <= maxOutcomeTokensToSell, "maximum sell amount exceeded");// Transferring outcome tokens to the market maker smart contract
conditionalTokens.safeTransferFrom(msg.sender, address(this), positionIds[outcomeIndex], outcomeTokensToSell, "");
// Calculating the fee
uint feeAmount = returnAmount.mul(fee) / (ONE.sub(fee));
feePoolWeight = feePoolWeight.add(feeAmount);
uint returnAmountPlusFees = returnAmount.add(feeAmount);
// Merging positions to the base asset
mergePositionsThroughAllConditions(returnAmountPlusFees);
// Sending the base assets to the transaction sender
require(collateralToken.transfer(msg.sender, returnAmount), "return transfer failed");
emit FPMMSell(msg.sender, returnAmount, feeAmount, outcomeIndex, outcomeTokensToSell);
}
It’s time to say who actually needs all of this. The protocol gives three project examples that use CTF:
I don’t know much about these protocols, but I do know another example — the prediction market Polymarket. They successfully implemented CTF and built additional logic on top of it, related to limit orders. This is quite a remarkable case because Polymarket is one of the most popular markets and has a substantial user base.
In summary, CTF is a powerful tool for implementing combinations of event outcomes and various manipulations with them. However, it’s not easy to understand. It requires a deep dive into combinatorics, working with bits of information, and so on. At the same time, it is almost the only solution on which a prediction market can be built. Gnosis is still open to new applications of its framework, but it should be noted that not many use cases have been invented since its inception.