diff --git a/chain/README.md b/chain/README.md new file mode 100644 index 0000000..145d423 --- /dev/null +++ b/chain/README.md @@ -0,0 +1,13 @@ +# ScorchedEarth Contracts + +From the root repository, navigate to the `chain` subdirectory, and use `npm` to execute the test suite: + +```bash +cd chain +npm install +npm test +``` + +Components: + +* `ScorchedEarth.sol` — Implementation of the [ForceMoveApp](https://docs.statechannels.org/docs/contract-api/natspec/forcemoveapp) protocol for ScorchedEarth mechanics. \ No newline at end of file diff --git a/chain/contracts/ScorchedEarth.sol b/chain/contracts/ScorchedEarth.sol index 125f5df..5c02264 100644 --- a/chain/contracts/ScorchedEarth.sol +++ b/chain/contracts/ScorchedEarth.sol @@ -9,14 +9,184 @@ import "@openzeppelin/contracts-ethereum-package/contracts/math/SafeMath.sol"; contract ScorchedEarth is ForceMoveApp { using SafeMath for uint256; + enum Phase { + Suggest, + React + } + + enum Reaction { + None, // Not applicable in Suggest phase + Reward, + Punish + } + + struct SEData { + uint256 payment; + uint256 suggesterBurn; + uint256 userBurn; + Phase phase; + Reaction reaction; + string suggestion; + } + constructor () public { } + function appData(bytes memory appDataBytes) internal pure returns (SEData memory) { + return abi.decode(appDataBytes, (SEData)); + } + function validTransition( VariablePart memory _fromPart, VariablePart memory _toPart, uint48, // turnNumB - uint256 // nParticipants + uint256 nParticipants ) public pure override returns (bool) { + require(2 == nParticipants, "ScorchedEarth: Must have 2 participants"); + + Outcome.AllocationItem[] memory fromAllocation = extractAllocation(_fromPart); + Outcome.AllocationItem[] memory toAllocation = extractAllocation(_toPart); + + requireDestinationsUnchanged(fromAllocation, toAllocation); + + // decode ScorchedEarth specific data + SEData memory fromData = appData(_fromPart.appData); + SEData memory toData = appData(_toPart.appData); + + requireInternalCoherence(fromData); + requireInternalCoherence(toData); + requireCoreParametersUnchanged(fromData, toData); + requirePhaseToggle(fromData, toData); + requireProperAllocations(fromAllocation, toAllocation, toData); + return true; } + + function extractAllocation(VariablePart memory _variablePart) + private + pure + returns (Outcome.AllocationItem[] memory) + { + Outcome.OutcomeItem[] memory outcome = abi.decode(_variablePart.outcome, (Outcome.OutcomeItem[])); + require(outcome.length == 1, 'ScorchedEarth: Only one asset allowed'); + + Outcome.AssetOutcome memory assetOutcome = abi.decode( + outcome[0].assetOutcomeBytes, + (Outcome.AssetOutcome) + ); + + require( + assetOutcome.assetOutcomeType == uint8(Outcome.AssetOutcomeType.Allocation), + 'ScorchedEarth: AssetOutcomeType must be Allocation' + ); + + Outcome.AllocationItem[] memory allocation = abi.decode( + assetOutcome.allocationOrGuaranteeBytes, + (Outcome.AllocationItem[]) + ); + + require( + allocation.length == 3, + 'ScorchedEarth: Allocation length must be 3 (Suggester, Sender, Burner)' + ); + + return allocation; + } + + function requireDestinationsUnchanged( + Outcome.AllocationItem[] memory _fromAllocation, + Outcome.AllocationItem[] memory _toAllocation + ) private pure + { + require( + _toAllocation[0].destination == _fromAllocation[0].destination, + 'ScorchedEarth: Destination for User may not change' + ); + + require( + _toAllocation[1].destination == _fromAllocation[1].destination, + 'ScorchedEarth: Destination for Suggester may not change' + ); + + require( + _toAllocation[2].destination == _fromAllocation[2].destination, + 'ScorchedEarth: Destination for Burner may not change' + ); + } + + function requireInternalCoherence(SEData memory _data) private pure { + if (_data.phase == Phase.Suggest) { + require(_data.reaction == Reaction.None, + 'ScorchedEarth: Suggest Phase must not have Reaction'); + + require(bytes(_data.suggestion).length > 0, + 'ScorchedEarth: Suggest Phase must have suggestion'); + } + else if (_data.phase == Phase.React) { + require(_data.reaction != Reaction.None, + 'ScorchedEarth: React Phase must have Reaction'); + + require(bytes(_data.suggestion).length == 0, + 'ScorchedEarth: React Phase must not have suggestion'); + } else { + revert('ScorchedEarth: Invalid Phase'); + } + } + + function requireCoreParametersUnchanged( + SEData memory _fromData, + SEData memory _toData + ) private pure + { + require( + _fromData.payment == _toData.payment && + _fromData.suggesterBurn == _toData.suggesterBurn && + _fromData.userBurn == _toData.userBurn, + 'ScorchedEarth: Core parameters must not change' + ); + } + + function requirePhaseToggle( + SEData memory fromData, + SEData memory toData + ) private pure + { + require(fromData.phase != toData.phase, + 'ScorchedEarth: Phase must toggle'); + } + + function requireProperAllocations( + Outcome.AllocationItem[] memory _fromAllocation, + Outcome.AllocationItem[] memory _toAllocation, + SEData memory _toData + ) private pure + { + if (_toData.phase == Phase.Suggest) { + bool didBurnUser = ( _toAllocation[0].amount == (_fromAllocation[0].amount.sub(_toData.payment).sub(_toData.userBurn)) ); + bool didBurnSuggester = ( _toAllocation[1].amount == (_fromAllocation[1].amount.sub(_toData.suggesterBurn)) ); + bool didPayBurner = ( _toAllocation[2].amount == (_fromAllocation[2].amount.add(_toData.payment).add(_toData.userBurn).add(_toData.suggesterBurn)) ); + + require(didBurnUser && didBurnSuggester && didPayBurner, + 'ScorchedEarth: Suggest Phase must burn funds'); + } else if (_toData.phase == Phase.React) { + if (_toData.reaction == Reaction.Reward) { + bool didUserPay = ( _toAllocation[0].amount == (_fromAllocation[0].amount.add(_toData.userBurn)) ); + bool didPaySuggester = ( _toAllocation[1].amount == (_fromAllocation[1].amount.add(_toData.suggesterBurn).add(_toData.payment)) ); + bool didUndoBurner = ( _toAllocation[2].amount == (_fromAllocation[2].amount.sub(_toData.payment).sub(_toData.userBurn).sub(_toData.suggesterBurn)) ); + + require(didUserPay && didPaySuggester && didUndoBurner, + 'ScorchedEarth: Reward Reaction must pay'); + } else if (_toData.reaction == Reaction.Punish) { + require( + _toAllocation[0].amount == _fromAllocation[0].amount && + _toAllocation[1].amount == _fromAllocation[1].amount && + _toAllocation[2].amount == _fromAllocation[2].amount, + 'ScorchedEarth: Punish Reaction must burn' + ); + } else { + require(false, 'ScorchedEarth: Invalid reaction'); + } + } else { + require(false, 'ScorchedEarth: Invalid Phase'); + } + } } diff --git a/chain/test/OutcomeBuilder.ts b/chain/test/OutcomeBuilder.ts new file mode 100644 index 0000000..0cfc4a3 --- /dev/null +++ b/chain/test/OutcomeBuilder.ts @@ -0,0 +1,46 @@ +import { + Allocation, + Outcome, + AssetOutcomeShortHand, + replaceAddressesAndBigNumberify, + encodeOutcome, +} from '@statechannels/nitro-protocol'; + +import { ethers } from 'ethers'; + +type Address = string; +type AddressMap = {user: Address, suggester: Address, burner: Address}; +type Balance = number; +type BalanceMap = {user: Balance, suggester: Balance, burner: Balance}; + +class OutcomeBuilder { + + paddedAddresses: AddressMap; + + constructor(addresses: AddressMap) { + this.paddedAddresses = { + user: ethers.utils.hexZeroPad(addresses.user, 32), + suggester: ethers.utils.hexZeroPad(addresses.suggester, 32), + burner: ethers.utils.hexZeroPad(addresses.burner, 32), + }; + } + + createOutcome(balances: BalanceMap, assetHolder: Address = ethers.constants.AddressZero): Outcome { + const bigBalances = replaceAddressesAndBigNumberify(balances, this.paddedAddresses) as AssetOutcomeShortHand; + + const allocation: Allocation = []; + Object.keys(bigBalances).forEach( key => { + allocation.push({destination: key, amount: bigBalances[key] as string}); + }); + + const outcome = [{assetHolderAddress: assetHolder, allocationItems: allocation}]; + return outcome; + } + + createEncodedOutcome(balances: BalanceMap, assetHolder: Address = ethers.constants.AddressZero): string { + const outcome = this.createOutcome(balances, assetHolder); + return encodeOutcome(outcome); + } +} + +export default OutcomeBuilder; diff --git a/chain/test/SEData.ts b/chain/test/SEData.ts new file mode 100644 index 0000000..a04e9ea --- /dev/null +++ b/chain/test/SEData.ts @@ -0,0 +1,84 @@ +import { ethers } from 'ethers'; + +enum Phase { + Suggest, + React, +} + +enum Reaction { + None, // Not applicable in Suggest phase + Reward, + Punish, +} + +interface SEData { + payment: string; // uint256 + userBurn: string; // uint256 + suggesterBurn: string // uint256 + phase: Phase; + reaction: Reaction; + suggestion: string; +} + +function encodeSEData(seData: SEData): string { + return ethers.utils.defaultAbiCoder.encode( + [ + 'tuple(uint256 payment, uint256 suggesterBurn, uint256 userBurn, uint8 phase, uint8 reaction, string suggestion)', + ], + [seData] + ); +} + +class SEDataBuilder { + + constants: { + payment: string, + userBurn: string, + suggesterBurn: string + }; + + get parameters() { + return { + payment: parseInt(this.constants.payment), + userBurn: parseInt(this.constants.userBurn), + suggesterBurn: parseInt(this.constants.suggesterBurn), + }; + } + + constructor(constants: { + payment: number, + userBurn: number, + suggesterBurn: number}) + { + this.constants = { + payment: ethers.BigNumber.from(constants.payment).toString(), + userBurn: ethers.BigNumber.from(constants.userBurn).toString(), + suggesterBurn: ethers.BigNumber.from(constants.suggesterBurn).toString(), + }; + } + + createSEData(params: { + phase: Phase, + reaction: Reaction, + suggestion: string, + }): SEData + { + return { + ...this.constants, + ...params, + }; + } + + createEncodedSEData(params: { + phase: Phase, + reaction: Reaction, + suggestion: string, + }): string + { + const data = this.createSEData(params); + return encodeSEData(data); + } +} + + +export { SEData, Phase, Reaction, encodeSEData, SEDataBuilder }; diff --git a/chain/test/scorched-earth-force-move-test.ts b/chain/test/scorched-earth-force-move-test.ts index 5e2b79a..2914a07 100644 --- a/chain/test/scorched-earth-force-move-test.ts +++ b/chain/test/scorched-earth-force-move-test.ts @@ -1,17 +1,30 @@ import { accounts, contract, web3 } from '@openzeppelin/test-environment'; -import { } from '@openzeppelin/test-helpers'; +import { expectRevert } from '@openzeppelin/test-helpers'; import { expect } from 'chai'; import { ethers } from 'ethers'; +import { + Allocation, + Outcome, + encodeOutcome, + VariablePart, +} from '@statechannels/nitro-protocol'; + +import OutcomeBuilder from './OutcomeBuilder'; +import { Phase, Reaction, SEDataBuilder } from './SEData'; + const ScorchedEarth = contract.fromArtifact('ScorchedEarth'); +const suggestion = 'https://ethereum.org/static/22580a5e7d69e200d6b2d2131904fbdf/32411/doge_computer.png'; describe('ScorchedEarth Force Move Implementation', () => { - const [ sender ] = accounts; + const [ sender, user, suggester, burner ] = accounts; let provider: ethers.providers.Web3Provider; let senderWallet: ethers.Wallet; let instance: ethers.Contract; let keys: {private_keys: string}; + let outcomeBuilder = new OutcomeBuilder({user, suggester, burner,}); + let dataBuilder = new SEDataBuilder({payment: 5, userBurn: 2, suggesterBurn: 2}); before(async () => { // Test the provider and make sure ganache is running before loading keys @@ -32,8 +45,870 @@ describe('ScorchedEarth Force Move Implementation', () => { instance = await factory.deploy(); }); - it('should see the deployed ScorchedEarth, adjudicator, & asset holder contracts', async () => { + it('should see the deployed ScorchedEarth contract', async () => { expect(instance.address.startsWith('0x')).to.be.true; expect(instance.address.length).to.equal(42); }); + + it('should not allow more than 2 participants', async () => { + const appData = ethers.utils.defaultAbiCoder.encode([], []); + + const variablePart: VariablePart = { + outcome: encodeOutcome([]), + appData: appData, + }; + + let validationTx = instance.validTransition(variablePart, variablePart, 4, 3); + + await expectRevert( + validationTx, + "ScorchedEarth: Must have 2 participants", + ); + }); + + it('should not allow an outcome with more than one asset allocation', async () => { + const fromOutcome: Outcome = [ + {assetHolderAddress: ethers.constants.AddressZero, allocationItems: []}, + {assetHolderAddress: ethers.constants.AddressZero, allocationItems: []}, // second asset allocation + ]; + + const toOutcome: Outcome = [ + {assetHolderAddress: ethers.constants.AddressZero, allocationItems: []}, + ]; + + const appData = ethers.utils.defaultAbiCoder.encode([], []); + + const fromVariablePart: VariablePart = { + outcome: encodeOutcome(fromOutcome), + appData: appData, + }; + + const toVariablePart: VariablePart = { + outcome: encodeOutcome(toOutcome), + appData: appData, + }; + + let validationTx = instance.validTransition(fromVariablePart, toVariablePart, 4, 2); + + await expectRevert( + validationTx, + "ScorchedEarth: Only one asset allowed", + ); + }); + + it('should not allow an outcome with 0 allocations', async () => { + const fromOutcome: Outcome = [ + {assetHolderAddress: ethers.constants.AddressZero, allocationItems: []}, + ]; + + + const toOutcome: Outcome = [ + {assetHolderAddress: ethers.constants.AddressZero, allocationItems: []}, + ]; + + const appData = ethers.utils.defaultAbiCoder.encode([], []); + + const fromVariablePart: VariablePart = { + outcome: encodeOutcome(fromOutcome), + appData: appData, + }; + + const toVariablePart: VariablePart = { + outcome: encodeOutcome(toOutcome), + appData: appData, + }; + + let validationTx = instance.validTransition(fromVariablePart, toVariablePart, 4, 2); + + await expectRevert( + validationTx, + "ScorchedEarth: Allocation length must be 3 (Suggester, Sender, Burner)", + ); + }); + + it('should not allow transitions where destinations change', async () => { + const balances = {user: 100, suggester: 100, burner: 0}; + const appData = ethers.utils.defaultAbiCoder.encode([], []); + + const fromOutcome = outcomeBuilder.createEncodedOutcome(balances); + + // Switch User and Suggester + const switchedBuilder = new OutcomeBuilder({user: suggester, suggester: user, burner: burner}); + const switchedOutcome = switchedBuilder.createEncodedOutcome(balances); + + let switchedTx = instance.validTransition( + {outcome: fromOutcome, appData}, + {outcome: switchedOutcome, appData}, + 4, + 2 + ); + + await expectRevert( + switchedTx, + "ScorchedEarth: Destination for User may not change", + ); + + // Totally change the Burner + const changedBuilder = new OutcomeBuilder({user, suggester, burner: sender}); + const changedOutcome = changedBuilder.createEncodedOutcome(balances); + + let changedTx = instance.validTransition( + {outcome: fromOutcome, appData}, + {outcome: changedOutcome, appData}, + 4, + 2, + ); + + await expectRevert( + changedTx, + "ScorchedEarth: Destination for Burner may not change", + ); + }); + + it('should not accept a share phase that has a reaction', async () => { + const outcome = outcomeBuilder.createEncodedOutcome({user: 100, suggester: 100, burner: 0}); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.Reward, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Punish, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome, appData: fromData}, + {outcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Suggest Phase must not have Reaction", + ); + }); + + it('should not accept a react phase that has no reaction', async () => { + const outcome = outcomeBuilder.createEncodedOutcome({user: 100, suggester: 100, burner: 0}); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.None, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome, appData: fromData}, + {outcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: React Phase must have Reaction", + ); + }); + + it('should not accept a suggest phase that has no suggestion', async () => { + const outcome = outcomeBuilder.createEncodedOutcome({user: 100, suggester: 100, burner: 0}); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: '', + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Punish, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome, appData: fromData}, + {outcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Suggest Phase must have suggestion", + ); + }); + + it('should not accept a react phase that has a suggestion', async () => { + const outcome = outcomeBuilder.createEncodedOutcome({user: 100, suggester: 100, burner: 0}); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Punish, + suggestion: suggestion, + }); + + let validationTx = instance.validTransition( + {outcome, appData: fromData}, + {outcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: React Phase must not have suggestion", + ); + }); + + it('should not allow payment parameter to change between turns', async () => { + const outcome = outcomeBuilder.createEncodedOutcome({user: 100, suggester: 100, burner: 0}); + const badDataBuilder = new SEDataBuilder({payment: 4, userBurn: 2, suggesterBurn: 2}); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = badDataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Punish, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome, appData: fromData}, + {outcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Core parameters must not change", + ); + }); + + it('should not allow userBurn parameter to change between turns', async () => { + const outcome = outcomeBuilder.createEncodedOutcome({user: 100, suggester: 100, burner: 0}); + const badDataBuilder = new SEDataBuilder({payment: 5, userBurn: 3, suggesterBurn: 2}); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = badDataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Punish, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome, appData: fromData}, + {outcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Core parameters must not change", + ); + }); + + it('should not allow suggesterBurn parameter to change between turns', async () => { + const outcome = outcomeBuilder.createEncodedOutcome({user: 100, suggester: 100, burner: 0}); + const badDataBuilder = new SEDataBuilder({payment: 5, userBurn: 2, suggesterBurn:1}); + + const fromData = badDataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Punish, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome, appData: fromData}, + {outcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Core parameters must not change", + ); + }); + + it('should not accept two suggest phases in a row', async () => { + const outcome = outcomeBuilder.createEncodedOutcome({user: 100, suggester: 100, burner: 0}); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + let validationTx = instance.validTransition( + {outcome, appData: fromData}, + {outcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Phase must toggle", + ); + }); + + it('should not accept two react phases in a row', async () => { + const outcome = outcomeBuilder.createEncodedOutcome({user: 100, suggester: 100, burner: 0}); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Punish, + suggestion: '', + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Reward, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome, appData: fromData}, + {outcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Phase must toggle", + ); + }); + + it('should not accept if the suggest phase does not assume fund burn', async () => { + const outcome = outcomeBuilder.createEncodedOutcome({user: 100, suggester: 100, burner: 0}); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Reward, + suggestion: '', + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + let validationTx = instance.validTransition( + {outcome, appData: fromData}, + {outcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Suggest Phase must burn funds", + ); + }); + + it('should not accept if the suggest phase does not pay the burner', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome(initialAllocations); + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner, // WRONG + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Reward, + suggestion: '', + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + let validationTx = instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Suggest Phase must burn funds", + ); + }); + + it('should not accept if the suggest phase does not burn the suggester', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome(initialAllocations); + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester, // WRONG + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Reward, + suggestion: '', + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + let validationTx = instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Suggest Phase must burn funds", + ); + }); + + it('should not accept if the suggest phase does not burn the user', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome(initialAllocations); + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user, // WRONG + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Reward, + suggestion: '', + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + let validationTx = instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Suggest Phase must burn funds", + ); + }); + + it('should validate a suggest phase that correctly assumes fund burn', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome(initialAllocations); + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Reward, + suggestion: '', + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + let isValid = await instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + expect(isValid).to.be.true; + }); + + it('should not accept a react phase that does not pay the suggester', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment, + suggester: initialAllocations.suggester, // WRONG + burner: initialAllocations.burner, + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Reward, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Reward Reaction must pay", + ); + }); + + it('should not accept a react phase that does not spend user funds', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user, // WRONG + suggester: initialAllocations.suggester + parameters.payment, + burner: initialAllocations.burner, + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Reward, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Reward Reaction must pay", + ); + }); + + it('should not accept a react phase that sends the burner funds', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment, + suggester: initialAllocations.suggester + parameters.payment, + burner: initialAllocations.burner + 1, // WRONG + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Reward, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Reward Reaction must pay", + ); + }); + + it('should validate a react phase that correctly rewards the suggester', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment, + suggester: initialAllocations.suggester + parameters.payment, + burner: initialAllocations.burner, + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Reward, + suggestion: '', + }); + + let isValid = await instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + expect(isValid).to.be.true; + }); + + it('should not accept a react phase that does not burn user funds', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment, // WRONG + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn, + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Punish, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Punish Reaction must burn", + ); + }); + + it('should not accept a react phase that does not burn suggester funds', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester, // WRONG + burner: initialAllocations.burner + parameters.payment + parameters.userBurn, + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Punish, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Punish Reaction must burn", + ); + }); + + it('should not accept a react phase that does send burner funds', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner, // WRONG + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Punish, + suggestion: '', + }); + + let validationTx = instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + await expectRevert( + validationTx, + "ScorchedEarth: Punish Reaction must burn", + ); + }); + + + it('should validate a react phase that correctly punishes the suggester', async () => { + const initialAllocations = {user: 100, suggester: 100, burner: 0}; + const parameters = dataBuilder.parameters; + + const fromOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const toOutcome = outcomeBuilder.createEncodedOutcome({ + user: initialAllocations.user - parameters.payment - parameters.userBurn, + suggester: initialAllocations.suggester - parameters.suggesterBurn, + burner: initialAllocations.burner + parameters.payment + parameters.suggesterBurn + parameters.userBurn, + }); + + const fromData = dataBuilder.createEncodedSEData({ + phase: Phase.Suggest, + reaction: Reaction.None, + suggestion: suggestion, + }); + + const toData = dataBuilder.createEncodedSEData({ + phase: Phase.React, + reaction: Reaction.Punish, + suggestion: '', + }); + + let isValid = await instance.validTransition( + {outcome: fromOutcome, appData: fromData}, + {outcome: toOutcome, appData: toData}, + 4, + 2, + ); + + expect(isValid).to.be.true; + }); }); diff --git a/chain/test/tsconfig.json b/chain/test/tsconfig.json index 3cf9bf5..f49b259 100644 --- a/chain/test/tsconfig.json +++ b/chain/test/tsconfig.json @@ -1,4 +1,7 @@ { + "compilerOptions": { + "target": "ES5" + }, "include": [ "./*-test.ts" ]