diff --git a/contracts/ExitHandler.sol b/contracts/ExitHandler.sol index 69dc512..4e6a783 100644 --- a/contracts/ExitHandler.sol +++ b/contracts/ExitHandler.sol @@ -29,6 +29,8 @@ contract ExitHandler is DepositHandler { uint256 amount ); + event LimboExitStarted(bytes32 indexed exitId, uint256 color); + struct Exit { uint256 amount; uint16 color; @@ -36,12 +38,46 @@ contract ExitHandler is DepositHandler { bool finalized; uint32 priorityTimestamp; uint256 stake; + bool isLimbo; + } + + struct LimboExit { + LimboIn[] input; + LimboOut[] output; + bool finalized; + uint256 stake; + address exitor; + bool isValid; + LimboChallenge[] challenge; + } + + struct LimboIn { + address owner; + bool isPegged; + } + + struct LimboOut { + uint256 amount; + address owner; + bool isPegged; + uint256 color; + } + + struct LimboChallenge { + address owner; + uint8 inputNo; + bool resolved; } uint256 public exitDuration; + uint256 public limboPeriod; + uint256 public piggybackStake; + uint256 public challengeStake; uint256 public exitStake; uint256 public nftExitCounter; + uint256 public constant LimboJoinDelay = (12 seconds); + /** * UTXO → Exit mapping. Contains exits for both NFT and ERC20 colors */ @@ -65,6 +101,222 @@ contract ExitHandler is DepositHandler { exitDuration = _exitDuration; } + function startLimboExit(bytes memory inTxData) + public payable returns (bytes32 utxoId) { + require(msg.value >= exitStake, "Not enough ether sent to pay for exit stake"); + TxLib.Tx memory transferTx = TxLib.parseTx(inTxData); + + // assuming tx have one input and one output only + uint8 _outputIndex = 0; + uint8 _inputIndex = 0; + + TxLib.Output memory out = transferTx.outs[_outputIndex]; + LimboOut memory output; + output.owner = out.owner; + output.color = out.color; + output.amount = out.value; + output.isPegged = false; + + mapping(uint8 => LimboIn) public inputs; + mapping(uint8 => LimboOut) public outputs; + + inputs[_inputIndex].isPegged = false; + outputs[_outputIndex] = output; + bytes32 inTxHash = keccak256(inTxData); + + bytes32 utxoId = bytes32(uint256(_outputIndex) << 120 | uint120(uint256(inTxHash))); + uint256 priority; + + if (isNft(out.color)) { + priority = (nftExitCounter << 128) | uint128(uint256(utxoId)); + nftExitCounter++; + } else { + priority = getERC20ExitPriority(*, utxoId, txPos); + } + limboExits[utxoId] = LimboExit({ + output: outputs, + input: inputs, + finalized: false, + stake: exitStake, + exitor: msg.sender, + isValid: true, + challenges:{} + }); + + emit LimboExitStarted( + inTxHash, + out.color + ); + tokens[out.color].insert(priority); + + return utxoId; + } + + function joinLimboExit(bytes32 exitId, uint8 _index) public payable { + require(msg.value >= piggybackStake, "Not enough ether sent to join the exit"); + + address owner = msg.sender; + LimboExit memory limboExit = limboExits[exitId]; + + if (limboExit.input[_index].owner == owner){ + // input is piggybacking + require(limboExit.input[_index].isPegged = false, "Already joined the exit"); + + limboExit.input[_index].isPegged = true; + } else if (limboExit.output[_index].owner == owner) { + // output is piggybacking + require(limboExit.output[_index].isPegged = false, "Already joined the exit"); + + limboExit.output[_index].isPegged = true; + } + } + + function challengeLimboExitByInclusionProof( + bytes32 exitId, + bytes inTxData, uint8 inputNo) + public payable { + require(msg.value >= challengeStake, "Not enough ether sent to challenge exit"); + LimboExit memory limboExit = limboExits[exitId]; + bytes32 inTxHash = keccak256(inTxdata); + require(limboExit.txHash == inTxHash); + require(limboExit.isValid == true); + + require(block.timestamp <= limboExit.timePublished + LimboChallangesDelay); + TxLib.Tx memory transferTx = Tx.parseTx(inTxData); + + // check if this tx is included or not + // TxLib.Tx memory includedTx = checkForValidityAndInclusion(blockNumber, includedTxData, includedProof); + + // not a valid tx because tx is included in the chain + // will block whole tx from exiitng + limboExit.isValid = false; + // payments? + } + + function challengeLimboExitByInputSpend( + bytes32 exitId, + bytes inTxData, uint8 inInputNo, + bytes includedTxData, bytes includedProof, uint8 includedInputNo, uint32 blockNumber) + public payable { + require(msg.value >= challengeStake, "Not enough ether sent to challenge exit"); + LimboExit memory limboExit = limboExits[exitId]; + bytes32 inTxHash = keccak256(inTxdata); + + require(limboExit.txHash == inTxHash); + require(limboExit.isValid == true); + require(block.timestamp <= limboExit.timePublished + LimboChallangesDelay); + + TxLib.Tx memory transferTx = Tx.parseTx(inTxData); + TxLib.Tx memory includedTx = checkForValidityAndInclusion(blockNumber, includedTxData, includedProof); + + require(transferTx.sender == includedTx.sender); + TxLib.Input memory exitingInput = transferTx.inputs[inInputNo]; + TxLib.Input memory includedInput = includedTx.inputs[includedInputNo]; + require(exitingInput.blockNumber == includedInput.blockNumber); + require(exitingInput.amount == includedInput.amount); + + // not a valid tx because canonical + // will block spent inputs from exiitng + limboExit.isValid = false; + // payments? + } + + function challengeLimboExitByOutputSpend( + bytes32 exitId, + bytes inTxData, uint8 inOutputNo, + bytes includedTxData, bytes includedProof, uint8 includedInputNo, uint32 blockNumber) + public payable { + require(msg.value >= challengeStake, "Not enough ether sent to challenge exit"); + LimboExit memory limboExit = limboExits[exitId]; + bytes32 inTxHash = keccak256(inTxdata); + + require(limboExit.txHash == inTxHash); + require(limboExit.isValid == true); + require(block.timestamp <= limboExit.timePublished + LimboChallangesDelay); + + TxLib.Tx memory transferTx = Tx.parseTx(inTxData); + TxLib.Tx memory includedTx = checkForValidityAndInclusion(blockNumber, includedTxData, includedProof); + + require(transferTx.sender == includedTx.sender); + + // which piggybacked output of exit + TxLib.Input memory exitingOutput = transferTx.outputs[inOutputNo]; + TxLib.Input memory includedInput = includedTx.inputs[includedInputNo]; + require(exitingInput.blockNumber == includedInput.blockNumber); + require(exitingOutput.amount == includedInput.amount); + + // not a valid tx because not exitable + // will block spent outputs from exiitng + limboExit.isValid = false; + // payments? + } + + function challengeLimboExitByNonCanonicalInput( + bytes32 exitId, + bytes inTxData, uint8 inInputNo, + bytes includedTxData, bytes includedProof, uint8 includedOutputNo, uint32 blockNumber) + public payable { + require(msg.value >= challengeStake, "Not enough ether sent to challenge exit"); + LimboExit memory limboExit = limboExits[exitId]; + bytes32 inTxHash = keccak256(inTxdata); + + require(limboExit.txHash == inTxHash); + require(limboExit.isValid == true); + require(block.timestamp <= limboExit.timePublished + LimboChallangesDelay); + + TxLib.Tx memory transferTx = Tx.parseTx(inTxData); + TxLib.Tx memory includedTx = checkForValidityAndInclusion(blockNumber, includedTxData, includedProof); + + require(transferTx.sender == includedTx.sender); + + // which piggybacked input of exit + TxLib.Input memory exitingIntput = transferTx.inputs[inIntputNo]; + TxLib.Output memory includedOutput = includedTx.outputs[includedOutputNo]; + require(exitingInput.blockNumber == includedInput.blockNumber); + require(exitingOutput.amount == includedInput.amount); + + // not a valid tx because input was not created by a canonical tx + // will block non canonical inputs from exiitng + limboExit.isValid = false; + // payments? + } + + function resolveChallengeOnInput( + bytes32 exitId, bytes inTxData, uint256 challengeNo, + bytes includedTxData, bytes includedProof, uint8 includedOutputNo, uint32 blockNumber + ) public { + + LimboExit memory limboExit = limboExits[exitId]; + LimboChallenge memory challenge = limboExit.challenge[challengeNo]; + + bytes32 inTxHash = keccak256(inTxdata); + require(limboExit.txHash == inTxHash); + require(limboExit.isValid == true); + + TxLib.Tx memory exitingTx = Tx.parseTx(inTxData); + TxLib.Input memory exitingInput = exitingTx.input[challenge.inputNo]; + + challenge.resolved = true; + // payments? + } + + function finalizeTopLimboExit(uint16 _color) public { + bytes32 utxoId; + uint256 exitableAt; + (utxoId, exitableAt) = getNextExit(_color); + + require(exitableAt <= block.timestamp, "The top exit can not be exited yet"); + require(tokens[_color].currentSize > 0, "The exit queue for color is empty"); + + LimboExit memory currentExit = limboExits[utxoId]; + // if (limboExit.isValid == true){ + + // } else { + + // } + + } + function startExit( bytes32[] memory _youngestInputProof, bytes32[] memory _proof, uint8 _outputIndex, uint8 _inputIndex diff --git a/test/exitHandler.js b/test/exitHandler.js index 8834742..9cb5abf 100644 --- a/test/exitHandler.js +++ b/test/exitHandler.js @@ -147,6 +147,142 @@ contract('ExitHandler', (accounts) => { assert(aliceBalanceBefore.add(new BN(50)).eq(aliceBalanceAfter)); }); + it('Should allow to complete spendTx where input and output are honest and cooperating', async () => { + // const period = await submitNewPeriod(depositTx); + // const _outputIndex = 0; + // const _inputIndex = 0; + + const exitStake = exitHandler.exitStake(); + const piggybackStake = exitHandler.piggybackStake(); + const inTxData = Tx.parseToParams(transferTx); + + // any user can start the exit for this transaction + exitId = await exitHandler.startLimboExit(inTxData, {from: pete, value: exitStake}); + + // Bob piggybacks and joins a Limo exit by its id + await exitHandler.joinLimboExit(exitId, {from: bob, value: piggybackStake}); + + const bobBalanceBefore = await nativeToken.balanceOf(bob); + + // const challengeTime = (await time.latest()) + (2 * time.duration.seconds(limboPeriod/2)); + // await time.increaseTo(challengeTime); + + await exitHandler.finalizeTopLimboExit(nativeTokenColor); + + // const responseTime = (await time.latest()) + (2 * time.duration.seconds(limboPeriod/2)); + // await time.increaseTo(responseTime); + + const bobBalanceAfter = await nativeToken.balanceOf(bob); + + assert(bobBalanceAfter.eq(bobBalanceBefore)); + + }); + + it('Should allow to challenge tx and prevent exit', async () => { + const period = await submitNewPeriod([depositTx, transferTx]); + const transferProof = period.proof(transferTx); + + //bob spends utxo1 by sending it to charlie + const spendTx = Tx.transfer( + [new Input(new Outpoint(transferTx.hash(), 0))], + [new Output(50, charlie)] + ).sign([bobPriv]); + + const exitStake = exitHandler.exitStake(); + const piggybackStake = exitHandler.piggybackStake(); + const challengeStake = exitHandler.challengeStake(); + + const inTxData = Tx.parseToParams(transferTx); + const spendTxData = Tx.parseToParams(spendTx); + + // any user can start the exit for this transaction + exitId = await exitHandler.startLimboExit(inTxData, {from: bob, value: exitStake}); + + // Bob piggybacks and joins a Limo exit by its id + await exitHandler.joinLimboExit(exitId, {from: bob, value: piggybackStake}); + + const bobBalanceBefore = await nativeToken.balanceOf(bob); + await exitHandler.challengeLimboExitByCanonicity(exitId, spendTxData, {from: pete, value: challengeStake}); + + const challengeTime = (await time.latest()) + (2 * time.duration.seconds(limboPeriod/2)); + await time.increaseTo(challengeTime); + + await exitHandler.finalizeTopLimboExit(nativeTokenColor); + + // const responseTime = (await time.latest()) + (2 * time.duration.seconds(limboPeriod/2)); + // await time.increaseTo(responseTime); + + const bobBalanceAfter = await nativeToken.balanceOf(bob); + assert(bobBalanceAfter.eq(bobBalanceBefore)); + }); + + it('Should allow user to challenge double spent inflight tx', async () => { + // const period = await submitNewPeriod([depositTx); + + //alice double spends utxo1 by sending it to charlie + const spendTx = Tx.transfer( + [new Input(new Outpoint(depositTx.hash(), 0))], + [new Output(50, charlie)] + ).sign([alicePriv]); + + const exitStake = exitHandler.exitStake(); + const piggybackStake = exitHandler.piggybackStake(); + const challengeStake = exitHandler.challengeStake(); + + const inTxData = Tx.parseToParams(transferTx); + const spendTxData = Tx.parseToParams(spendTx); + + exitId = await exitHandler.startLimboExit(inTxData, {from: bob, value: exitStake}); + + // Bob piggybacks and joins a Limo exit by its id + await exitHandler.joinLimboExit(exitId, {from: bob, value: piggybackStake}); + + const bobBalanceBefore = await nativeToken.balanceOf(bob); + // challenge to tx's canonicity + await exitHandler.challengeLimboExit(exitId, spendTxData, {from: pete, value: challengeStake}); + + const challengeTime = (await time.latest()) + (2 * time.duration.seconds(limboPeriod/2)); + await time.increaseTo(challengeTime); + + await exitHandler.finalizeTopLimboExit(nativeTokenColor); + + // const responseTime = (await time.latest()) + (2 * time.duration.seconds(limboPeriod/2)); + // await time.increaseTo(responseTime); + + const bobBalanceAfter = await nativeToken.balanceOf(bob); + assert(bobBalanceAfter.eq(bobBalanceBefore)); + }); + + it('Should resolve a challenge and exit', async () => { + const period = await submitNewPeriod([depositTx); + const depositProof = period.proof(depositTx); + + const exitStake = exitHandler.exitStake(); + const piggybackStake = exitHandler.piggybackStake(); + const challengeStake = exitHandler.challengeStake(); + + const inTxData = Tx.parseToParams(transferTx); + + exitId = await exitHandler.startLimboExit(inTxData, {from: bob, value: exitStake}); + + // Alice piggybacks and joins a Limo exit by its id + await exitHandler.joinLimboExit(exitId, {from: alice, value: piggybackStake}); + + const aliceBalanceBefore = await nativeToken.balanceOf(alice); + await exitHandler.challengeLimboExit(exitId, randomTx, {from: pete, value: challengeStake}); + + const challengeTime = (await time.latest()) + (2 * time.duration.seconds(limboPeriod/2)); + await time.increaseTo(challengeTime); + + await exitHandler.resolveChallengeOnInput(exitId, inTxData, 0, depositData, depositProof, 0, blockNo); + + const responseTime = (await time.latest()) + (2 * time.duration.seconds(limboPeriod/2)); + await time.increaseTo(responseTime); + + const aliceBalanceAfter = await nativeToken.balanceOf(alice); + assert(aliceBalanceAfter.eq(aliceBalanceBefore.add(new BN(50)))); + }); + it('Should allow to exit deposit utxo', async () => { const period = await submitNewPeriod([depositTx]);