-
Notifications
You must be signed in to change notification settings - Fork 0
add escalation game with not enough tests #52
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| // SPDX-License-Identifier: Unlicense | ||
| pragma solidity 0.8.33; | ||
|
|
||
| import { ReputationToken } from '../ReputationToken.sol'; | ||
| import { Zoltar } from '../Zoltar.sol'; | ||
| import { ISecurityPool } from './interfaces/ISecurityPool.sol'; | ||
| import { YesNoMarkets } from './YesNoMarkets.sol'; | ||
|
|
||
| struct Deposit { | ||
| address depositor; | ||
| uint256 amount; | ||
| uint256 cumulativeAmount; | ||
| } | ||
|
|
||
| uint256 constant escalationTimeLength = 4233600; // 7 weeks | ||
| uint256 constant SCALE = 1e6; | ||
| contract EscalationGame { | ||
| uint256 public startingTime; | ||
| uint256[3] public balances; // outcome -> amount | ||
| mapping(uint8 => Deposit[]) public deposits; // make a fixed array with dynamic | ||
| ISecurityPool public securityPool; | ||
| uint256 public nonDecisionThreshold; | ||
| uint256 public startBond; | ||
| address public owner; | ||
| uint256 public nonDecisionTimestamp; | ||
|
|
||
| event GameStarted(uint256 startingTime, uint256 startBond, uint256 nonDecisionThreshold); | ||
| event DepositOnOutcome(address depositor, YesNoMarkets.Outcome outcome, uint256 amount, uint256 depositIndex, uint256 cumulativeAmount); | ||
| event WithdrawDeposit(address depositor, YesNoMarkets.Outcome winner, uint256 amountToWithdraw, uint256 depositIndex); | ||
| event ClaimDeposit(uint256 amountToWithdraw, uint256 burnAmount); | ||
|
|
||
| constructor(ISecurityPool _securityPool) { | ||
| securityPool = _securityPool; | ||
| owner = msg.sender; | ||
| } | ||
|
|
||
| function start(uint256 _startBond, uint256 _nonDecisionThreshold) public { | ||
| require(owner == msg.sender, 'only owner can start'); | ||
| require(startingTime == 0, 'already started'); | ||
| require(_nonDecisionThreshold > _startBond, 'threshold must exceed start bond'); | ||
| require(_startBond > 0, 'start bond must be positive'); | ||
| startingTime = block.timestamp + 3 days; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is the game start time 3 days after |
||
| nonDecisionThreshold = _nonDecisionThreshold; | ||
| startBond = _startBond; | ||
| emit GameStarted(startingTime, startBond, nonDecisionThreshold); | ||
| } | ||
|
|
||
| function getBalances() public view returns (uint256[3] memory) { | ||
| return [balances[0], balances[1], balances[2]]; | ||
| } | ||
|
|
||
| // TODO, verify that this is never bigger than nonDecisionThreshold and is always increasing or constant in terms of timeSinceStart | ||
| // approx for: attrition cost = start deposit * (nonDecisionThreshold / start deposit) ^ (time since start / time limit) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider renaming Another suggestion GLM 5 had was
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After back and forth with an AI, what made the system "click" for me is realizing that this is basically a race where people fill in pots of money and there is water filling the pots at an exponential rate. As soon as there is only one pot with money above water, that is the winner. If all of the pots overflow, then the game ends in a fork. |
||
| function compute5TermTaylorSeriesAttritionCostApproximation(uint256 timeSinceStart) public view returns (uint256) { | ||
| require(timeSinceStart <= escalationTimeLength, 'Invalid time'); | ||
| uint256 ratio = nonDecisionThreshold * SCALE / startBond; | ||
| require(ratio > SCALE, 'ratio must be > 1'); // since startBond < nonDecisionThreshold | ||
| uint256 z = (ratio - SCALE) * SCALE / (ratio + SCALE); | ||
| uint256 z2 = z * z / SCALE; | ||
| uint256 lnRatio = 2 * (z + z2 * z / (3 * SCALE) + z2 * z2 * z / (5 * SCALE)); | ||
|
|
||
| uint256 tLnX = timeSinceStart * lnRatio / escalationTimeLength; | ||
| // Compute series: 1 + t*ln(x) + (t*ln(x))^2/2! + ... + (t*ln(x))^5/5! | ||
| uint256 series = SCALE; | ||
| uint256 term = tLnX; | ||
| // 1 | ||
| series += term; | ||
| // 2 | ||
| term = term * tLnX / (2 * SCALE); | ||
| series += term; | ||
| // 3 | ||
| term = term * tLnX / (3 * SCALE); | ||
| series += term; | ||
| // 4 | ||
| term = term * tLnX / (4 * SCALE); | ||
| series += term; | ||
| // 5 | ||
| term = term * tLnX / (5 * SCALE); | ||
| series += term; | ||
| return startBond * series / SCALE; | ||
| } | ||
|
|
||
| // todo investigate this function more for errors. This can result in weird errors where you fork just before/after escalation game end | ||
| function computeTimeSinceStartFromAttritionCost(uint256 attritionCost) public view returns (uint256) { | ||
| uint256 low = 0; | ||
| uint256 high = escalationTimeLength; | ||
| if (attritionCost <= startBond) return 0; | ||
| uint256 maxCost = nonDecisionThreshold; | ||
| if (attritionCost >= maxCost) return escalationTimeLength; | ||
|
|
||
| // binary search | ||
| for (uint256 iteration = 0; iteration < 64; iteration++) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is 64 iterations enough? |
||
| uint256 midTime = (low + high) / 2; | ||
|
|
||
| uint256 midCost = compute5TermTaylorSeriesAttritionCostApproximation(midTime); | ||
|
|
||
| if (midCost == attritionCost) return midTime; | ||
| if (midCost < attritionCost) { | ||
| low = midTime + 1; | ||
| } else { | ||
| high = midTime - 1; | ||
| } | ||
| } | ||
| return (low + high) / 2; | ||
| } | ||
|
|
||
| function getEscalationGameEndDate() public view returns (uint256 endTime) { | ||
| if (nonDecisionTimestamp > 0) return nonDecisionTimestamp; | ||
| return startingTime + computeTimeSinceStartFromAttritionCost(getBindingCapital()); | ||
| } | ||
|
|
||
| function totalCost() public view returns (uint256) { | ||
| if (startingTime >= block.timestamp) return 0; | ||
| uint256 timeFromStart = block.timestamp - startingTime; | ||
| if (timeFromStart >= escalationTimeLength) return nonDecisionThreshold; | ||
| return compute5TermTaylorSeriesAttritionCostApproximation(timeFromStart); | ||
| } | ||
|
|
||
| function getMarketResolution() public view returns (YesNoMarkets.Outcome outcome){ | ||
| uint256 currentTotalCost = totalCost(); | ||
| uint8 invalidOver = balances[0] >= currentTotalCost ? 1 : 0; | ||
| uint8 yesOver = balances[1] >= currentTotalCost ? 1 : 0; | ||
| uint8 noOver = balances[2] >= currentTotalCost ? 1 : 0; | ||
| if (invalidOver + yesOver + noOver >= 2) return YesNoMarkets.Outcome.None; // if two or more outcomes are over the total cost, the game is still going | ||
| // the game has ended due to timeout | ||
| // TODO, doesn't handle ties well logically. We could avoid it by checking if tie is happening before deposit and break it there | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also doesn't handle all 3 outcomes getting no deposits particularly well. Currently it falls back to No, but I feel like it should fall back to invalid in that case. |
||
| if (balances[0] > balances[1] && balances[0] > balances[2]) return YesNoMarkets.Outcome.Invalid; | ||
| if (balances[1] > balances[0] && balances[1] > balances[2]) return YesNoMarkets.Outcome.Yes; | ||
| return YesNoMarkets.Outcome.No; | ||
| } | ||
|
|
||
| function hasReachedNonDecision() public view returns (bool) { | ||
| uint8 invalidOver = balances[0] >= nonDecisionThreshold ? 1 : 0; | ||
| uint8 yesOver = balances[1] >= nonDecisionThreshold ? 1 : 0; | ||
| uint8 noOver = balances[2] >= nonDecisionThreshold ? 1 : 0; | ||
| if (invalidOver + yesOver + noOver >= 2) return true; | ||
| return false; | ||
| } | ||
|
|
||
| function getBindingCapital() public view returns (uint256) { | ||
| if ((balances[0] >= balances[1] && balances[0] <= balances[2]) || (balances[0] >= balances[2] && balances[0] <= balances[1])) { | ||
| return balances[0]; | ||
| } else if ((balances[1] >= balances[0] && balances[1] <= balances[2]) || (balances[1] >= balances[2] && balances[1] <= balances[0])) { | ||
| return balances[1]; | ||
| } | ||
| return balances[2]; | ||
| } | ||
|
|
||
| // deposits on market outcome, returns how much user actually ended depositing | ||
| function depositOnOutcome(address depositor, YesNoMarkets.Outcome outcome, uint256 amount) public returns (uint256 depositAmount) { | ||
| require(nonDecisionTimestamp == 0, 'System has already reached a non-decision'); | ||
| require(msg.sender == address(securityPool), 'Only Security Pool can deposit'); | ||
| require(getMarketResolution() == YesNoMarkets.Outcome.None, 'System has already timed out'); | ||
| require(balances[uint256(outcome)] < nonDecisionThreshold, 'Already full'); | ||
| require(amount >= startBond, 'all amounts need to be bigger or equal to start deposit'); // checks that we get start bond and spam protection | ||
| Deposit memory deposit; | ||
| deposit.depositor = depositor; | ||
| balances[uint256(outcome)] += amount; | ||
| if (balances[uint256(outcome)] > nonDecisionThreshold) { | ||
| depositAmount = amount - (balances[uint256(outcome)] - nonDecisionThreshold); | ||
| balances[uint256(outcome)] = nonDecisionThreshold; | ||
| } else { | ||
| depositAmount = amount; | ||
| } | ||
| deposit.amount = depositAmount; | ||
| deposit.cumulativeAmount = balances[uint256(outcome)]; | ||
| deposits[uint8(outcome)].push(deposit); | ||
| emit DepositOnOutcome(depositor, outcome, deposit.amount, deposits[uint8(outcome)].length - 1, deposit.cumulativeAmount); | ||
| if (hasReachedNonDecision()) { | ||
| nonDecisionTimestamp = block.timestamp; | ||
| } | ||
| } | ||
|
|
||
| // todo, this should be calculated against to actual nonDecisionThreshold, not the one set at the start. The actual can be lower than the games treshold but never above | ||
| function claimDepositForWinning(uint256 depositIndex, YesNoMarkets.Outcome outcome) public returns (address depositor, uint256 amountToWithdraw) { | ||
| require(msg.sender == address(securityPool) || msg.sender == address(securityPool.securityPoolForker()), 'Only Security Pool can withdraw'); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| Deposit memory deposit = deposits[uint8(outcome)][depositIndex]; | ||
| deposits[uint8(outcome)][depositIndex].amount = 0; | ||
| depositor = deposit.depositor; | ||
| uint256 maxWithdrawableBalance = getBindingCapital(); | ||
| if (deposit.cumulativeAmount > maxWithdrawableBalance) { | ||
| amountToWithdraw = deposit.amount; | ||
| emit ClaimDeposit(amountToWithdraw, 0); | ||
| } else if (deposit.cumulativeAmount + deposit.amount > maxWithdrawableBalance) { | ||
| uint256 excess = (deposit.cumulativeAmount + deposit.amount - maxWithdrawableBalance); | ||
| uint256 burnAmount = excess * 2 / 5; | ||
| amountToWithdraw = (deposit.amount - excess) + excess * 2 - burnAmount; | ||
| emit ClaimDeposit(amountToWithdraw, burnAmount); | ||
| } else { | ||
| uint256 burnAmount = (deposit.amount * 2) / 5; | ||
| amountToWithdraw = deposit.amount * 2 - burnAmount; | ||
| emit ClaimDeposit(amountToWithdraw, burnAmount); | ||
| } | ||
| } | ||
|
|
||
| // todo, allow withdrawing after someones elses fork as well (game is canceled) | ||
| function withdrawDeposit(uint256 depositIndex) public returns (address depositor, uint256 amountToWithdraw) { | ||
| require(msg.sender == address(securityPool), 'Only Security Pool can withdraw'); | ||
| require(nonDecisionTimestamp == 0, 'System has reached non-decision'); | ||
| // if system hasnt forked, check outcome is winning | ||
| YesNoMarkets.Outcome marketResolution = getMarketResolution(); | ||
| (depositor,amountToWithdraw) = claimDepositForWinning(depositIndex, marketResolution); | ||
| emit WithdrawDeposit(depositor, marketResolution, amountToWithdraw, depositIndex); | ||
| } | ||
|
|
||
| // todo, for the UI, we probably want to retrieve multiple outcomes at once | ||
| function getDepositsByOutcome(YesNoMarkets.Outcome outcome, uint256 startIndex, uint256 numberOfEntries) external view returns (Deposit[] memory returnDeposits) { | ||
| returnDeposits = new Deposit[](numberOfEntries); | ||
| uint256 iterateUntil = startIndex + numberOfEntries > deposits[uint8(outcome)].length ? deposits[uint8(outcome)].length : startIndex + numberOfEntries; | ||
| for (uint256 i = startIndex; i < iterateUntil; i++) { | ||
| returnDeposits[i - startIndex] = deposits[uint8(outcome)][i]; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| // SPDX-License-Identifier: Unlicense | ||
| pragma solidity 0.8.33; | ||
|
|
||
| import { ISecurityPool } from '../interfaces/ISecurityPool.sol'; | ||
| import { EscalationGame } from '../EscalationGame.sol'; | ||
|
|
||
| contract EscalationGameFactory { | ||
| function deployEscalationGame(uint256 startBond, uint256 _nonDecisionThreshold) external returns (EscalationGame) { | ||
| ISecurityPool securityPool = ISecurityPool(payable(msg.sender)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we do something to ensure that this can only be called by "valid" security pools, or is this a situation where we anyone can claim to be a security pool and that isn't a problem? |
||
| EscalationGame game = new EscalationGame{ salt: bytes32(uint256(0x0)) }(securityPool); | ||
| game.start(startBond, _nonDecisionThreshold); | ||
| return game; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import test, { beforeEach, describe } from 'node:test' | ||
| import { getMockedEthSimulateWindowEthereum, MockWindowEthereum } from '../testsuite/simulator/MockWindowEthereum.js' | ||
| import { createWriteClient, WriteClient } from '../testsuite/simulator/utils/viem.js' | ||
| import { TEST_ADDRESSES } from '../testsuite/simulator/utils/constants.js' | ||
| import { contractExists, setupTestAccounts } from '../testsuite/simulator/utils/utilities.js' | ||
| import { QuestionOutcome } from '../testsuite/simulator/types/types.js' | ||
| import assert from 'node:assert' | ||
| import { deployEscalationGame, depositOnOutcome, getBalances, getStartingTime } from '../testsuite/simulator/utils/contracts/escalationGame.js' | ||
| import { ensureZoltarDeployed } from '../testsuite/simulator/utils/contracts/zoltar.js' | ||
| import { ensureInfraDeployed } from '../testsuite/simulator/utils/contracts/deployPeripherals.js' | ||
|
|
||
| describe('Escalation Game Test Suite', () => { | ||
| let mockWindow: MockWindowEthereum | ||
|
|
||
| let client: WriteClient | ||
| const reportBond = 1n * 10n ** 18n | ||
| const nonDecisionThreshold = 1000n * 10n ** 18n | ||
| beforeEach(async () => { | ||
| mockWindow = getMockedEthSimulateWindowEthereum() | ||
| client = createWriteClient(mockWindow, TEST_ADDRESSES[0], 0) | ||
| await setupTestAccounts(mockWindow) | ||
| await ensureZoltarDeployed(client) | ||
| await ensureInfraDeployed(client) | ||
| }) | ||
|
|
||
| test('can start a game', async () => { | ||
| const escalationGame = await deployEscalationGame(client, reportBond, nonDecisionThreshold) | ||
| assert.ok(await contractExists(client, escalationGame), 'game was deployed') | ||
| const outcomeBalances = await getBalances(client, escalationGame) | ||
| assert.strictEqual(outcomeBalances.yes, 0n, 'yes stake') | ||
| assert.strictEqual(outcomeBalances.no, 0n, 'no stake') | ||
| assert.strictEqual(outcomeBalances.invalid, 0n, 'invalid stake') | ||
|
|
||
| const startingTime = await getStartingTime(client, escalationGame) | ||
| assert.strictEqual(startingTime !== 0n, true, 'game was started') | ||
| await depositOnOutcome(client, escalationGame, client.account.address, QuestionOutcome.No, reportBond) | ||
| const outcomeBalancesAfterDeposit = await getBalances(client, escalationGame) | ||
| assert.strictEqual(outcomeBalancesAfterDeposit.yes, 0n, 'yes stake') | ||
| assert.strictEqual(outcomeBalancesAfterDeposit.no, reportBond, 'no stake') | ||
| assert.strictEqual(outcomeBalancesAfterDeposit.invalid, 0n, 'invalid stake') | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import { encodeDeployData, getCreate2Address, numberToBytes } from 'viem' | ||
| import { peripherals_EscalationGame_EscalationGame, peripherals_factories_EscalationGameFactory_EscalationGameFactory } from '../../../../types/contractArtifact.js' | ||
| import { AccountAddress, QuestionOutcome } from '../../types/types.js' | ||
| import { ReadClient, WriteClient } from '../viem.js' | ||
| import { getInfraContractAddresses } from './deployPeripherals.js' | ||
|
|
||
| export const getNonDecisionThreshold = async (client: ReadClient, escalationGame: `0x${ string }`) => { | ||
| return await client.readContract({ | ||
| abi: peripherals_EscalationGame_EscalationGame.abi, | ||
| functionName: 'nonDecisionThreshold', | ||
| address: escalationGame, | ||
| args: [], | ||
| }) | ||
| } | ||
|
|
||
| export const getMarketResolution = async (client: ReadClient, escalationGame: `0x${ string }`) => { | ||
| return (await client.readContract({ | ||
| abi: peripherals_EscalationGame_EscalationGame.abi, | ||
| functionName: 'getMarketResolution', | ||
| address: escalationGame, | ||
| args: [], | ||
| })) as QuestionOutcome | ||
| } | ||
|
|
||
| export const getStartBond = async (client: ReadClient, escalationGame: `0x${ string }`) => { | ||
| return await client.readContract({ | ||
| abi: peripherals_EscalationGame_EscalationGame.abi, | ||
| functionName: 'startBond', | ||
| address: escalationGame, | ||
| args: [], | ||
| }) | ||
| } | ||
|
|
||
| export const getEscalationGameDeposits = async (readClient: ReadClient, escalationGame: AccountAddress, outcome: QuestionOutcome) => { | ||
| let currentIndex = 0n | ||
| const numberOfEntries = 30n | ||
| let pages: { depositIndex: bigint, depositor: `0x${ string }`, amount: bigint, cumulativeAmount: bigint}[] = [] | ||
| do { | ||
| const newDeposits = (await readClient.readContract({ | ||
| abi: peripherals_EscalationGame_EscalationGame.abi, | ||
| functionName: 'getDepositsByOutcome', | ||
| address: escalationGame, | ||
| args: [outcome, currentIndex, numberOfEntries] | ||
| })).map((deposit, index) => ({ ...deposit, depositIndex: currentIndex + BigInt(index) })).filter((deposit) => BigInt(deposit.depositor) !== 0x0n) | ||
| pages.push(...newDeposits) | ||
| if (BigInt(newDeposits.length) !== numberOfEntries) break | ||
| currentIndex += numberOfEntries | ||
| } while(true) | ||
| return pages | ||
| } | ||
|
|
||
| export const deployEscalationGame = async (writeClient: WriteClient, startBond: bigint, nonDecisionThreshold: bigint) => { | ||
| await writeClient.writeContract({ | ||
| abi: peripherals_factories_EscalationGameFactory_EscalationGameFactory.abi, | ||
| functionName: 'deployEscalationGame', | ||
| address: getInfraContractAddresses().escalationGameFactory, | ||
| args: [startBond, nonDecisionThreshold], | ||
| }) | ||
| return getCreate2Address({ | ||
| bytecode: encodeDeployData({ | ||
| abi: peripherals_EscalationGame_EscalationGame.abi, | ||
| bytecode: `0x${ peripherals_EscalationGame_EscalationGame.evm.bytecode.object }`, | ||
| args: [ writeClient.account.address ] | ||
| }), | ||
| from: getInfraContractAddresses().escalationGameFactory, | ||
| salt: numberToBytes(0) | ||
| }) | ||
| } | ||
|
|
||
| export const getBalances = async (client: ReadClient, escalationGame: `0x${ string }`) => { | ||
| const [invalid, yes, no] = await client.readContract({ | ||
| abi: peripherals_EscalationGame_EscalationGame.abi, | ||
| functionName: 'getBalances', | ||
| address: escalationGame, | ||
| args: [], | ||
| }) | ||
| return { invalid, yes, no } | ||
| } | ||
|
|
||
| export const getStartingTime = async (client: ReadClient, escalationGame: `0x${ string }`) => { | ||
| return await client.readContract({ | ||
| abi: peripherals_EscalationGame_EscalationGame.abi, | ||
| functionName: 'startingTime', | ||
| address: escalationGame, | ||
| args: [], | ||
| }) | ||
| } | ||
|
|
||
| export const depositOnOutcome = async (writeClient: WriteClient, escalationGame: `0x${ string }`, depositor: `0x${ string }`, outcome: QuestionOutcome, amount: bigint) => { | ||
| await writeClient.writeContract({ | ||
| abi: peripherals_EscalationGame_EscalationGame.abi, | ||
| functionName: 'depositOnOutcome', | ||
| address: escalationGame, | ||
| args: [depositor, outcome, amount], | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This file doesn't exist.
-- GLM 5