UNPKG

@gnosis.pm/conditional-tokens-contracts

Version:

Collection of smart contracts for the conditional tokens

964 lines (875 loc) 34.4 kB
const ethSigUtil = require("eth-sig-util"); const { expectEvent, expectRevert } = require("openzeppelin-test-helpers"); const { toBN, randomHex } = web3.utils; const { getConditionId, getCollectionId, combineCollectionIds, getPositionId } = require("../utils/id-helpers")(web3.utils); const ConditionalTokens = artifacts.require("ConditionalTokens"); const ERC20Mintable = artifacts.require("MockCoin"); const Forwarder = artifacts.require("Forwarder"); const DefaultCallbackHandler = artifacts.require("DefaultCallbackHandler.sol"); const GnosisSafe = artifacts.require("GnosisSafe"); const NULL_BYTES32 = `0x${"0".repeat(64)}`; contract("ConditionalTokens", function(accounts) { const [ minter, oracle, notOracle, eoaTrader, fwdExecutor, safeExecutor, counterparty ] = accounts; beforeEach("deploy ConditionalTokens", async function() { this.conditionalTokens = await ConditionalTokens.new(); }); describe("prepareCondition", function() { it("should not be able to prepare a condition with no outcome slots", async function() { const questionId = randomHex(32); const outcomeSlotCount = 0; await expectRevert( this.conditionalTokens.prepareCondition( oracle, questionId, outcomeSlotCount ), "there should be more than one outcome slot" ); }); it("should not be able to prepare a condition with just one outcome slots", async function() { const questionId = randomHex(32); const outcomeSlotCount = 1; await expectRevert( this.conditionalTokens.prepareCondition( oracle, questionId, outcomeSlotCount ), "there should be more than one outcome slot" ); }); context("with valid parameters", function() { const questionId = randomHex(32); const outcomeSlotCount = toBN(256); const conditionId = getConditionId(oracle, questionId, outcomeSlotCount); beforeEach(async function() { ({ logs: this.logs } = await this.conditionalTokens.prepareCondition( oracle, questionId, outcomeSlotCount )); }); it("should emit an ConditionPreparation event", async function() { expectEvent.inLogs(this.logs, "ConditionPreparation", { conditionId, oracle, questionId, outcomeSlotCount }); }); it("should make outcome slot count available via getOutcomeSlotCount", async function() { ( await this.conditionalTokens.getOutcomeSlotCount(conditionId) ).should.be.bignumber.equal(outcomeSlotCount); }); it("should leave payout denominator unset", async function() { ( await this.conditionalTokens.payoutDenominator(conditionId) ).should.be.bignumber.equal("0"); }); it("should not be able to prepare the same condition more than once", async function() { await expectRevert( this.conditionalTokens.prepareCondition( oracle, questionId, outcomeSlotCount ), "condition already prepared" ); }); }); }); describe("splitting and merging", function() { function shouldSplitAndMergePositions(trader) { const questionId = randomHex(32); const outcomeSlotCount = toBN(2); const conditionId = getConditionId(oracle, questionId, outcomeSlotCount); const collateralTokenCount = toBN(1e19); const splitAmount = toBN(4e18); const mergeAmount = toBN(3e18); function shouldWorkWithSplittingAndMerging({ prepareTokens, doSplit, doMerge, doRedeem, collateralBalanceOf, getPositionForCollection, getExpectedEventCollateralProperties, deeperTests }) { beforeEach(prepareTokens); it("should not split on unprepared conditions", async function() { await doSplit.call( this, conditionId, [0b01, 0b10], splitAmount ).should.be.rejected; }); context("with a condition prepared", async function() { beforeEach(async function() { await this.conditionalTokens.prepareCondition( oracle, questionId, outcomeSlotCount ); }); it("should not split if given index sets aren't disjoint", async function() { await doSplit.call( this, conditionId, [0b11, 0b10], splitAmount ).should.be.rejected; }); it("should not split if partitioning more than condition's outcome slots", async function() { await doSplit.call( this, conditionId, [0b001, 0b010, 0b100], splitAmount ).should.be.rejected; }); it("should not split if given a singleton partition", async function() { await doSplit.call( this, conditionId, [0b11], splitAmount ).should.be.rejected; }); it("should not split if given an incomplete singleton partition", async function() { await doSplit.call( this, conditionId, [0b01], splitAmount ).should.be.rejected; }); context("with valid split", function() { const partition = [0b01, 0b10]; beforeEach(async function() { ({ tx: this.splitTx } = await doSplit.call( this, conditionId, partition, splitAmount )); }); it("should emit a PositionSplit event", async function() { await expectEvent.inTransaction( this.splitTx, ConditionalTokens, "PositionSplit", Object.assign( { stakeholder: trader.address, parentCollectionId: NULL_BYTES32, conditionId, // partition, amount: splitAmount }, getExpectedEventCollateralProperties.call(this) ) ); }); it("should transfer split collateral from trader", async function() { ( await collateralBalanceOf.call(this, trader.address) ).should.be.bignumber.equal( collateralTokenCount.sub(splitAmount) ); ( await collateralBalanceOf.call( this, this.conditionalTokens.address ) ).should.be.bignumber.equal(splitAmount); }); it("should mint amounts in positions associated with partition", async function() { for (const indexSet of partition) { const positionId = getPositionForCollection.call( this, getCollectionId(conditionId, indexSet) ); ( await this.conditionalTokens.balanceOf( trader.address, positionId ) ).should.be.bignumber.equal(splitAmount); } }); it("should not merge if amount exceeds balances in to-be-merged positions", async function() { await doMerge.call( this, conditionId, partition, splitAmount.addn(1) ).should.be.rejected; }); context("with valid merge", function() { beforeEach(async function() { ({ tx: this.mergeTx } = await doMerge.call( this, conditionId, partition, mergeAmount )); }); it("should emit a PositionsMerge event", async function() { await expectEvent.inTransaction( this.mergeTx, ConditionalTokens, "PositionsMerge", Object.assign( { stakeholder: trader.address, parentCollectionId: NULL_BYTES32, conditionId, // partition, amount: mergeAmount }, getExpectedEventCollateralProperties.call(this) ) ); }); it("should transfer split collateral back to trader", async function() { ( await collateralBalanceOf.call(this, trader.address) ).should.be.bignumber.equal( collateralTokenCount.sub(splitAmount).add(mergeAmount) ); ( await collateralBalanceOf.call( this, this.conditionalTokens.address ) ).should.be.bignumber.equal(splitAmount.sub(mergeAmount)); }); it("should burn amounts in positions associated with partition", async function() { for (const indexSet of partition) { const positionId = getPositionForCollection.call( this, getCollectionId(conditionId, indexSet) ); ( await this.conditionalTokens.balanceOf( trader.address, positionId ) ).should.be.bignumber.equal(splitAmount.sub(mergeAmount)); } }); }); describe("transferring, reporting, and redeeming", function() { const transferAmount = toBN(1e18); const payoutNumerators = [toBN(3), toBN(7)]; it("should not allow transferring more than split balance", async function() { const positionId = getPositionForCollection.call( this, getCollectionId(conditionId, partition[0]) ); await trader.execCall( this.conditionalTokens, "safeTransferFrom", trader.address, counterparty, positionId, splitAmount.addn(1), "0x" ).should.be.rejected; }); it("should not allow reporting by incorrect oracle", async function() { await expectRevert( this.conditionalTokens.reportPayouts( questionId, payoutNumerators, { from: notOracle } ), "condition not prepared or found" ); }); it("should not allow report with wrong questionId", async function() { const wrongQuestionId = randomHex(32); await expectRevert( this.conditionalTokens.reportPayouts( wrongQuestionId, payoutNumerators, { from: oracle } ), "condition not prepared or found" ); }); it("should not allow report with no slots", async function() { await expectRevert( this.conditionalTokens.reportPayouts(questionId, [], { from: oracle }), "there should be more than one outcome slot" ); }); it("should not allow report with wrong number of slots", async function() { await expectRevert( this.conditionalTokens.reportPayouts(questionId, [2, 3, 5], { from: oracle }), "condition not prepared or found" ); }); it("should not allow report with zero payouts in all slots", async function() { await expectRevert( this.conditionalTokens.reportPayouts(questionId, [0, 0], { from: oracle }), "payout is all zeroes" ); }); context("with valid transfer and oracle report", function() { beforeEach(async function() { const positionId = getPositionForCollection.call( this, getCollectionId(conditionId, partition[0]) ); ({ tx: this.transferTx } = await trader.execCall( this.conditionalTokens, "safeTransferFrom", trader.address, counterparty, positionId, transferAmount, "0x" )); ({ logs: this.reportLogs } = await this.conditionalTokens.reportPayouts( questionId, payoutNumerators, { from: oracle } )); }); it("should not merge if any amount is short", async function() { await doMerge.call( this, conditionId, partition, splitAmount ).should.be.rejected; }); it("should emit ConditionResolution event", async function() { expectEvent.inLogs(this.reportLogs, "ConditionResolution", { conditionId, oracle, questionId, outcomeSlotCount }); }); it("should make reported payout numerators available", async function() { for (let i = 0; i < payoutNumerators.length; i++) { ( await this.conditionalTokens.payoutNumerators( conditionId, i ) ).should.be.bignumber.equal(payoutNumerators[i]); } }); describe("redeeming", function() { const payoutDenominator = payoutNumerators.reduce( (a, b) => a.add(b), toBN(0) ); const payout = [ splitAmount.sub(transferAmount), splitAmount ].reduce( (acc, amount, i) => acc.add( amount.mul(payoutNumerators[i]).div(payoutDenominator) ), toBN(0) ); beforeEach(async function() { ({ tx: this.redeemTx } = await doRedeem.call( this, conditionId, partition )); }); it("should emit PayoutRedemption event", async function() { await expectEvent.inTransaction( this.redeemTx, ConditionalTokens, "PayoutRedemption", Object.assign( { redeemer: trader.address, parentCollectionId: NULL_BYTES32, conditionId, // indexSets: partition, payout }, getExpectedEventCollateralProperties.call(this) ) ); }); it("should zero out redeemed positions", async function() { for (const indexSet of partition) { const positionId = getPositionForCollection.call( this, getCollectionId(conditionId, indexSet) ); ( await this.conditionalTokens.balanceOf( trader.address, positionId ) ).should.be.bignumber.equal("0"); } }); it("should not affect other's positions", async function() { const positionId = getPositionForCollection.call( this, getCollectionId(conditionId, partition[0]) ); ( await this.conditionalTokens.balanceOf( counterparty, positionId ) ).should.be.bignumber.equal(transferAmount); }); it("should credit payout as collateral", async function() { ( await collateralBalanceOf.call(this, trader.address) ).should.be.bignumber.equal( collateralTokenCount.sub(splitAmount).add(payout) ); }); }); }); }); }); }); if (deeperTests) context("with many conditions prepared", async function() { const conditions = Array.from({ length: 3 }, () => ({ oracle, questionId: randomHex(32), outcomeSlotCount: toBN(4) })); conditions.forEach(condition => { condition.id = getConditionId( condition.oracle, condition.questionId, condition.outcomeSlotCount ); }); beforeEach(async function() { for (const { oracle, questionId, outcomeSlotCount } of conditions) { await this.conditionalTokens.prepareCondition( oracle, questionId, outcomeSlotCount ); } }); context("when trader has collateralized a condition", function() { const condition = conditions[0]; const { oracle, questionId, outcomeSlotCount } = condition; const conditionId = condition.id; const finalReport = [0, 33, 289, 678].map(toBN); const payoutDenominator = finalReport.reduce((a, b) => a.add(b)); const partition = [0b0111, 0b1000]; const positionIndexSet = partition[0]; beforeEach(async function() { await doSplit.call( this, conditionId, partition, collateralTokenCount ); await trader.execCall( this.conditionalTokens, "safeTransferFrom", trader.address, counterparty, getPositionForCollection.call( this, getCollectionId(conditionId, partition[1]) ), collateralTokenCount, "0x" ); }); context( "when trader splits to a deeper position with another condition", function() { const conditionId2 = conditions[1].id; const partition2 = [0b0001, 0b0010, 0b1100]; const deepSplitAmount = toBN(4e18); const parentCollectionId = getCollectionId( conditionId, positionIndexSet ); beforeEach(async function() { ({ tx: this.deepSplitTx } = await doSplit.call( this, conditionId2, partition2, deepSplitAmount, parentCollectionId )); }); it("combines collection IDs", async function() { for (const indexSet of partition2) { ( await this.conditionalTokens.getCollectionId( parentCollectionId, conditionId2, indexSet ) ).should.be.equal( combineCollectionIds([ parentCollectionId, getCollectionId(conditionId2, indexSet) ]) ); } }); it("emits PositionSplit event", async function() { await expectEvent.inTransaction( this.deepSplitTx, ConditionalTokens, "PositionSplit", Object.assign( { stakeholder: trader.address, parentCollectionId, conditionId: conditionId2, // partition: partition2, amount: deepSplitAmount }, getExpectedEventCollateralProperties.call(this) ) ); }); it("burns value in the parent position", async function() { ( await this.conditionalTokens.balanceOf( trader.address, getPositionForCollection.call(this, parentCollectionId) ) ).should.be.bignumber.equal( collateralTokenCount.sub(deepSplitAmount) ); }); it("mints values in the child positions", async function() { for (const indexSet of partition2) { const positionId = getPositionForCollection.call( this, combineCollectionIds([ parentCollectionId, getCollectionId(conditionId2, indexSet) ]) ); ( await this.conditionalTokens.balanceOf( trader.address, positionId ) ).should.be.bignumber.equal(deepSplitAmount); } }); } ); context("with valid report", function() { beforeEach(async function() { ({ logs: this.reportLogs } = await this.conditionalTokens.reportPayouts( questionId, finalReport, { from: oracle } )); }); it("should emit ConditionResolution event", function() { expectEvent.inLogs(this.reportLogs, "ConditionResolution", { conditionId, oracle, questionId, outcomeSlotCount }); }); it("should reflect report via payoutNumerators", async function() { for (let i = 0; i < finalReport.length; i++) { ( await this.conditionalTokens.payoutNumerators( conditionId, i ) ).should.be.bignumber.equal(finalReport[i]); } }); it("should not allow an update to the report", async function() { const badUpdateReport = finalReport.map((x, i) => i === 1 ? x : toBN(0) ); await expectRevert( this.conditionalTokens.reportPayouts( questionId, badUpdateReport, { from: oracle } ), "payout denominator already set" ); }); context("with valid redemption", async function() { const payout = collateralTokenCount .mul( finalReport.reduce( (acc, term, i) => positionIndexSet & (1 << i) ? acc.add(term) : acc, toBN(0) ) ) .div(payoutDenominator); beforeEach(async function() { ({ tx: this.redeemTx } = await doRedeem.call( this, conditionId, [positionIndexSet] )); }); it("should emit PayoutRedemption event", async function() { await expectEvent.inTransaction( this.redeemTx, ConditionalTokens, "PayoutRedemption", Object.assign( { redeemer: trader.address, parentCollectionId: NULL_BYTES32, conditionId, // indexSets: partition, payout }, getExpectedEventCollateralProperties.call(this) ) ); }); }); }); }); }); } context("with an ERC-20 collateral allowance", function() { shouldWorkWithSplittingAndMerging({ async prepareTokens() { this.collateralToken = await ERC20Mintable.new({ from: minter }); await this.collateralToken.mint( trader.address, collateralTokenCount, { from: minter } ); await trader.execCall( this.collateralToken, "approve", this.conditionalTokens.address, collateralTokenCount ); }, async doSplit(conditionId, partition, amount, parentCollectionId) { return await trader.execCall( this.conditionalTokens, "splitPosition", this.collateralToken.address, parentCollectionId || NULL_BYTES32, conditionId, partition, amount ); }, async doMerge(conditionId, partition, amount, parentCollectionId) { return await trader.execCall( this.conditionalTokens, "mergePositions", this.collateralToken.address, parentCollectionId || NULL_BYTES32, conditionId, partition, amount ); }, async doRedeem(conditionId, indexSets, parentCollectionId) { return await trader.execCall( this.conditionalTokens, "redeemPositions", this.collateralToken.address, parentCollectionId || NULL_BYTES32, conditionId, indexSets ); }, async collateralBalanceOf(address) { return await this.collateralToken.balanceOf(address); }, getPositionForCollection(collectionId) { return getPositionId(this.collateralToken.address, collectionId); }, getExpectedEventCollateralProperties() { return { collateralToken: this.collateralToken.address }; }, deeperTests: true }); }); } context("with an EOA", function() { shouldSplitAndMergePositions({ address: eoaTrader, async execCall(contract, method, ...args) { return await contract[method](...args, { from: eoaTrader }); } }); }); context.skip("with a Forwarder", function() { let trader = {}; before(async function() { const forwarder = await Forwarder.new(); async function forwardCall(contract, method, ...args) { // ???: why is reformatting the args necessary here? args = args.map(arg => Array.isArray(arg) ? arg.map(a => a.toString()) : arg.toString() ); return await forwarder.call( contract.address, contract.contract.methods[method](...args).encodeABI(), { from: fwdExecutor } ); } trader.address = forwarder.address; trader.execCall = forwardCall; }); shouldSplitAndMergePositions(trader); }); context.skip("with a Gnosis Safe", function() { let trader = {}; before(async function() { const zeroAccount = `0x${"0".repeat(40)}`; const safeOwners = Array.from({ length: 2 }, () => web3.eth.accounts.create() ); safeOwners.sort(({ address: a }, { address: b }) => a.toLowerCase() < b.toLowerCase() ? -1 : a === b ? 0 : 1 ); const callbackHandler = await DefaultCallbackHandler.new(); const gnosisSafe = await GnosisSafe.new(); await gnosisSafe.setup( safeOwners.map(({ address }) => address), safeOwners.length, zeroAccount, "0x", callbackHandler.address, zeroAccount, 0, zeroAccount ); const gnosisSafeTypedDataCommon = { types: { EIP712Domain: [{ name: "verifyingContract", type: "address" }], SafeTx: [ { name: "to", type: "address" }, { name: "value", type: "uint256" }, { name: "data", type: "bytes" }, { name: "operation", type: "uint8" }, { name: "safeTxGas", type: "uint256" }, { name: "baseGas", type: "uint256" }, { name: "gasPrice", type: "uint256" }, { name: "gasToken", type: "address" }, { name: "refundReceiver", type: "address" }, { name: "nonce", type: "uint256" } ], SafeMessage: [{ name: "message", type: "bytes" }] }, domain: { verifyingContract: gnosisSafe.address } }; async function gnosisSafeCall(contract, method, ...args) { const safeOperations = { CALL: 0, DELEGATECALL: 1, CREATE: 2 }; const nonce = await gnosisSafe.nonce(); // ???: why is reformatting the args necessary here? args = args.map(arg => Array.isArray(arg) ? arg.map(a => a.toString()) : arg.toString() ); const txData = contract.contract.methods[method](...args).encodeABI(); const signatures = safeOwners.map(safeOwner => ethSigUtil.signTypedData( Buffer.from(safeOwner.privateKey.replace("0x", ""), "hex"), { data: Object.assign( { primaryType: "SafeTx", message: { to: contract.address, value: 0, data: txData, operation: safeOperations.CALL, safeTxGas: 0, baseGas: 0, gasPrice: 0, gasToken: zeroAccount, refundReceiver: zeroAccount, nonce } }, gnosisSafeTypedDataCommon ) } ) ); const tx = await gnosisSafe.execTransaction( contract.address, 0, txData, safeOperations.CALL, 0, 0, 0, zeroAccount, zeroAccount, `0x${signatures.map(s => s.replace("0x", "")).join("")}`, { from: safeExecutor } ); if (tx.logs[0] && tx.logs[0].event === "ExecutionFailed") throw new Error(`Safe transaction ${method}(${args}) failed`); return tx; } trader.address = gnosisSafe.address; trader.execCall = gnosisSafeCall; }); shouldSplitAndMergePositions(trader); }); }); });