Skip to content

Commit

Permalink
heartbeat challenge (#258)
Browse files Browse the repository at this point in the history
* wip

* more details

* added comments

* some more comments and code

* Ready for PR

* Lint fix.

* Another lint fix.

* fix storage layout

* Clear up comments, change walkProof structure, pay challenge reward to signer.

* Added clarifying comment.

* Clean up walk structure.
  • Loading branch information
johannbarbie authored Dec 5, 2019
1 parent 74d2dc4 commit bc36403
Show file tree
Hide file tree
Showing 2 changed files with 263 additions and 1 deletion.
137 changes: 136 additions & 1 deletion contracts/PoaOperator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,22 @@ contract PoaOperator is Adminable {
emit EpochLength(epochLength);
}

function setHeartbeatParams(uint256 _minimumPulse, uint16 _heartbeatColor) public ifAdmin {
minimumPulse = _minimumPulse;
heartbeatColor = _heartbeatColor;
}

function setEpochLength(uint256 _epochLength) public ifAdmin {
require(_epochLength >= _getLargestSlot() + 1, "Epoch length cannot be less then biggest slot");
epochLength = _epochLength;
emit EpochLength(epochLength);
}

function setSlot(uint256 _slotId, address _signerAddr, bytes32 _tenderAddr) public ifAdmin {
_setSlot(_slotId, _signerAddr, _tenderAddr);
}

function _setSlot(uint256 _slotId, address _signerAddr, bytes32 _tenderAddr) internal {
require(_slotId < epochLength, "out of range slotId");
Slot storage slot = slots[_slotId];

Expand Down Expand Up @@ -156,6 +165,10 @@ contract PoaOperator is Adminable {
}
}

function isSlotActive(Slot memory _slot) internal pure returns (bool) {
return (_slot.signer != address(0) && _slot.activationEpoch == 0);
}

event Submission(
bytes32 indexed blocksRoot,
uint256 indexed slotId,
Expand Down Expand Up @@ -291,6 +304,128 @@ contract PoaOperator is Adminable {
bridge.deletePeriod(_period);
}

// openTime could be derived from openPeriodHash's timestamp, but one has
// to determine what happens in the case that there was a long (~ timeoutTime) time since
// the last submitted period, than it is possible to open and timeout a challenge straight
// away
struct BeatChallenge {
address payable challenger;
uint256 openTime;
bytes32 openPeriodHash;
}

uint256 public minimumPulse; // max amount of periods one can go without a heartbeat
uint16 public heartbeatColor;
mapping(address => BeatChallenge) public beatChallenges;

// challenger claims that there is no hearbeat included in the previous minimumPulse periods
// TODO: figure out what happens in slot rotation
// TODO: must be sure that the surrent slot signer was also signer at minimumPulse periods ago
function challengeBeat(
uint256 _slotId
) public payable {
// check the stake
require(msg.value == vault.exitStake(), "invalid challenge stake");

// check slot exists
require(_slotId < epochLength, "slotId too high");

// get the offending slot
Slot memory slot = slots[_slotId];
require(isSlotActive(slot), "Slot must be active");

bytes32 tip = bridge.tipHash();

// check that challenge doesn't exist yet
// TODO: the slot signer can challenge himself, blocking further challenges for challengeTimeout duration. What are the implications of this?
require(beatChallenges[slot.signer].openTime == 0, "challenge already in progress");

// create challenge object
// TODO: only one challenge per address possible, potential problem if an address holds multiple slots
beatChallenges[slot.signer] = BeatChallenge({
challenger: msg.sender,
openTime: now,
openPeriodHash: tip
});
}

// In case of an invalid challenge, can submit a proof of heartbeat
// TODO: Is the signer we challenged still in control of the Slot? Depends on timeout-time (currently casChallangeDuration) at the least.
function respondBeat(
bytes32[] memory _inclusionProof,
bytes32[] memory _walkProof,
uint256 _slotId
) public {

address slotSigner = slots[_slotId].signer;
BeatChallenge memory chall = beatChallenges[slotSigner];

require(chall.openTime > 0, "No active challenge for this slot.");
// THIS IS CURRENTLY ONLY SAFE IF MINIMUM_PULSE IS SET TO 0!
(bytes32 walkStart, bytes32 walkEnd, uint256 walkLength) = _verifyWalk(_walkProof);
require(walkLength <= minimumPulse, "Walk goes back in time too far");
require(walkStart == _inclusionProof[0], "Walk must start with the period that includes the heartbeat");
require(walkEnd == chall.openPeriodHash, "Walk must end with the openPeriod");

bytes32 txHash;
bytes memory txData;
uint64 txPos;
(txPos, txHash, txData) = TxLib.validateProof(64, _inclusionProof);
bytes32 sigHash = TxLib.getSigHash(txData);
TxLib.Tx memory txn = TxLib.parseTx(txData);

address payable txSigner = address(
uint160(
ecrecover(
sigHash,
txn.ins[0].v,
txn.ins[0].r,
txn.ins[0].s
)
)
);
uint16 txColor = txn.outs[0].color;

require(slotSigner == txSigner, "Heartbeat transation does not belong to slot signer");
require(txColor == heartbeatColor, "The transaction is not the correct color");

delete beatChallenges[slotSigner];
txSigner.transfer(vault.exitStake());
}

// walks on periods in the proof and verifies:
// - they were included in the bridge
// - they reference one another
// also returns start and end period and the length of the walk
// TODO: actually implement this
function _verifyWalk(bytes32[] memory _proof) internal returns (bytes32, bytes32, uint256) {
return (_proof[0], _proof[0], 0);
}

// Challenge time has passed. No counter-example was given. The validator is ruled to have been offline and gets removed.
// TODO: Is the signer we challenged still in control of the Slot? Depends on timeout-time (currently casChallangeDuration) at the least.
function timeoutBeat(uint256 _slotId) public {
address signer = slots[_slotId].signer;
BeatChallenge memory chal = beatChallenges[signer];

// check that challenge exists
require(chal.openTime > 0, "challenge does not exist");
// check time
require(now >= chal.openTime + casChallengeDuration, "time not expired yet");

// refund challenge stake
chal.challenger.transfer(vault.exitStake());

// empty slot
_setSlot(_slotId, address(0), 0);

// Delete the challenge
delete beatChallenges[signer];

// todo: later slash stake here
}


function _isEmpty(Slot memory _slot) internal returns (bool) {
return (_slot.signer == address(0));
}
Expand Down Expand Up @@ -380,5 +515,5 @@ contract PoaOperator is Adminable {
}

// solium-disable-next-line mixedcase
uint256[18] private ______gap;
uint256[15] private ______gap;
}
127 changes: 127 additions & 0 deletions test/heartbeats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@

/**
* Copyright (c) 2018-present, Leap DAO (leapdao.org)
*
* This source code is licensed under the Mozilla Public License, version 2,
* found in the LICENSE file in the root directory of this source tree.
*/

import { Period, Block, Tx, Input, Outpoint, Output } from 'leap-core';
import EVMRevert from './helpers/EVMRevert';

const time = require('./helpers/time');
require('./helpers/setup');

const Bridge = artifacts.require('Bridge');
const PoaOperator = artifacts.require('PoaOperatorMock');
const ExitHandler = artifacts.require('ExitHandler');
const AdminableProxy = artifacts.require('AdminableProxy');

contract('PoaOperator Heartbeats', (accounts) => {
const alice = accounts[0];
const alicePriv = '0x278a5de700e29faae8e40e366ec5012b5ec63d36ec77e8a2417154cc1d25383f';
const admin = accounts[3];
const CAS = '0xc000000000000000000000000000000000000000000000000000000000000000';
const ZERO = '0x0000000000000000000000000000000000000000000000000000000000000000';
const CHALLENGE_DURATION = 3600;
const CHALLENGE_STAKE = '100000000000000000';

describe('Test', () => {
let bridge;
let operator;
let proxy;
let exitHandler
const parentBlockInterval = 0;
const epochLength = 3;
const p = [];
const validatorSlot = 0;
const minimumPulse = 0;
const heartbeatColor = 200;


beforeEach(async () => {
const bridgeCont = await Bridge.new();
let data = await bridgeCont.contract.methods.initialize(parentBlockInterval).encodeABI();
const proxyBridge = await AdminableProxy.new(bridgeCont.address, data, {from: admin});
bridge = await Bridge.at(proxyBridge.address);

const vaultCont = await ExitHandler.new();
data = await vaultCont.contract.methods.initializeWithExit(bridge.address, CHALLENGE_DURATION, CHALLENGE_STAKE).encodeABI();
proxy = await AdminableProxy.new(vaultCont.address, data, {from: admin});
exitHandler = await ExitHandler.at(proxy.address);

const opCont = await PoaOperator.new();
data = await opCont.contract.methods.initialize(bridge.address, exitHandler.address, epochLength, CHALLENGE_DURATION).encodeABI();
proxy = await AdminableProxy.new(opCont.address, data, {from: admin});
operator = await PoaOperator.at(proxy.address);

data = await bridge.contract.methods.setOperator(operator.address).encodeABI();
await proxyBridge.applyProposal(data, {from: admin});
p[0] = await bridge.tipHash();

data = await operator.contract.methods.setSlot(validatorSlot, alice, alice).encodeABI();
await proxy.applyProposal(data, {from: admin});

data = await operator.contract.methods.setHeartbeatParams(minimumPulse, heartbeatColor).encodeABI();
await proxy.applyProposal(data, {from: admin});

});

describe('Heartbeats', () => {
it('Can challenge offline validators', async () => {


// validator has been offline, challenge him
await operator.challengeBeat(validatorSlot, {value: CHALLENGE_STAKE});

// should not be able to complete challenge before timeout
await operator.timeoutBeat(validatorSlot).should.be.rejectedWith(EVMRevert);

const timeoutTime = (await time.latest()) + CHALLENGE_DURATION;
await time.increaseTo(timeoutTime);
await operator.timeoutBeat(validatorSlot);

// the slot is now empty
const slot = await operator.slots(validatorSlot);
assert.notEqual(slot.activationEpoch.toNumber(10), 0);

// the challenge should have been deleted, can not timeout twice
await operator.timeoutBeat(validatorSlot).should.be.rejectedWith(EVMRevert);
});

it('Can defend invalid challenges', async () => {

const input = new Input(new Outpoint(ZERO, 0));
const output = new Output(1, alice, heartbeatColor);
const tx = Tx.transfer([input], [output]);
tx.sign([alicePriv]);

const block = new Block(65);
block.addTx(tx);
const prevPeriodRoot = await bridge.tipHash();
const period = new Period(prevPeriodRoot, [block]);
period.setValidatorData(0, alice, CAS);
const proof = period.proof(tx);

await operator.submitPeriodWithCas(0, prevPeriodRoot, period.merkleRoot(), CAS, { from: alice }).should.be.fulfilled;

// validator has just submitted period with their hearbeat
// now they get wrongly challenged
await operator.challengeBeat(validatorSlot, {value: CHALLENGE_STAKE});

const walkProof = [proof[0]];
// challenge proof
await operator.respondBeat(proof, walkProof, 0);

const timeoutTime = (await time.latest()) + CHALLENGE_DURATION;
await time.increaseTo(timeoutTime);

// challnge was defended
await operator.timeoutBeat(validatorSlot).should.be.rejectedWith(EVMRevert);

});
});
});


});

0 comments on commit bc36403

Please sign in to comment.