Skip to content
Open
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"setup-contracts": "cd solidity && npm run setup",
"compile-contracts": "cd solidity && npm run compile-contracts",
"test": "cd solidity && npm run test",
"test-peripherals": "cd solidity && npm run compile-contracts && npm run test-peripherals"
"test-peripherals": "cd solidity && npm run compile-contracts && npm run test-peripherals",
"test-escalationGame": "cd solidity && npm run compile-contracts && npm run test-escalationGame"
}
}
214 changes: 214 additions & 0 deletions solidity/contracts/peripherals/EscalationGame.sol
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';
Copy link
Member

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


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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the game start time 3 days after start is called?

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider renaming attrition cost throughout to escalation threshold. I don't think attrition is the right word here, and I found it confusing when trying to understand how the system works. Attrition usually means slow eating losses over time, but IIUC this represents a threshold one needs to reach in order to achieve some goal.

Another suggestion GLM 5 had was resolution floor.

Copy link
Member

Choose a reason for hiding this comment

The 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++) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 64 iterations enough?
-- GLM 5

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
Copy link
Member

Choose a reason for hiding this comment

The 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');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

securityPoolForker doesn't exist on ISecurityPool.
-- GLM 5

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];
}
}
}
14 changes: 14 additions & 0 deletions solidity/contracts/peripherals/factories/EscalationGameFactory.sol
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));
Copy link
Member

Choose a reason for hiding this comment

The 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?
-- GLM 5

EscalationGame game = new EscalationGame{ salt: bytes32(uint256(0x0)) }(securityPool);
game.start(startBond, _nonDecisionThreshold);
return game;
}
}
3 changes: 2 additions & 1 deletion solidity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"setup": "npm ci --ignore-scripts && npm run compile-contracts",
"compile-contracts": "tsc --project tsconfig-compile.json && node ./js/compile.js",
"test": "npx tsc && node --test",
"test-peripherals": "npx tsc && node --test ./js/tests/testPeripherals.js"
"test-peripherals": "npx tsc && node --test ./js/tests/testPeripherals.js",
"test-escalationGame": "npx tsc && node --test ./js/tests/testEscalationGame.js"
},
"keywords": [],
"author": "",
Expand Down
42 changes: 42 additions & 0 deletions solidity/ts/tests/testEscalationGame.ts
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')
})
})
96 changes: 96 additions & 0 deletions solidity/ts/testsuite/simulator/utils/contracts/escalationGame.ts
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],
})
}