Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix game theoretical failure #227

Merged
merged 4 commits into from
Nov 10, 2019
Merged
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
1 change: 1 addition & 0 deletions cfg/dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = {
EVENTS_FIND_LIMIT: 100,
MSGS_FIND_LIMIT: 10,
HEALTH_THRESHOLD_PROMILLES: 950,
HEALTH_UNSIGNABLE_PROMILLES: 750,
PROPAGATION_TIMEOUT: 1000,
FETCH_TIMEOUT: 5000,
LIST_TIMEOUT: 5000,
Expand Down
1 change: 1 addition & 0 deletions cfg/prod.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = {
EVENTS_FIND_LIMIT: 100,
MSGS_FIND_LIMIT: 10,
HEALTH_THRESHOLD_PROMILLES: 970,
HEALTH_UNSIGNABLE_PROMILLES: 770,
PROPAGATION_TIMEOUT: 3000,
FETCH_TIMEOUT: 10000,
LIST_TIMEOUT: 10000,
Expand Down
11 changes: 9 additions & 2 deletions services/validatorWorker/follower.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const BN = require('bn.js')
const { getStateRootHash, onError, toBNMap } = require('./lib')
const { isValidTransition, isHealthy } = require('./lib/followerRules')
const { isValidTransition, getHealthPromilles } = require('./lib/followerRules')
const producer = require('./producer')
const { heartbeat } = require('./heartbeat')
const cfg = require('../../cfg')

async function tick(adapter, iface, channel) {
// @TODO: there's a flaw if we use this in a more-than-two validator setup
Expand Down Expand Up @@ -46,12 +48,17 @@ async function onNewState(adapter, iface, channel, balances, newMsg) {
return onError(iface, { reason: 'InvalidTransition', newMsg })
}

const healthPromilles = getHealthPromilles(channel, balances, proposedBalances)
if (healthPromilles.lt(new BN(cfg.HEALTH_UNSIGNABLE_PROMILLES))) {
return onError(iface, { reason: 'TooLowHealth', newMsg })
}

const signature = await adapter.sign(stateRootRaw)
return iface.propagate([
{
type: 'ApproveState',
stateRoot,
isHealthy: isHealthy(channel, balances, proposedBalances),
isHealthy: healthPromilles.gte(new BN(cfg.HEALTH_THRESHOLD_PROMILLES)),
signature
}
])
Expand Down
9 changes: 2 additions & 7 deletions services/validatorWorker/lib/followerRules.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const BN = require('bn.js')
const cfg = require('../../../cfg')

// Implements constraints described at: https://github.com/AdExNetwork/adex-protocol/blob/master/OUTPACE.md#specification
function isValidTransition(channel, prev, next) {
Expand All @@ -18,7 +17,7 @@ function isValidTransition(channel, prev, next) {
)
}

function getHealth(channel, our, approved) {
function getHealthPromilles(channel, our, approved) {
const sumOur = sumMap(our)
const sumApprovedMins = sumMins(our, approved)
// division by zero can't happen here, because sumApproved >= sumOur
Expand All @@ -35,10 +34,6 @@ function getHealth(channel, our, approved) {
)
}

function isHealthy(channel, our, approved) {
return getHealth(channel, our, approved).gte(new BN(cfg.HEALTH_THRESHOLD_PROMILLES))
}

function sumBNs(bns) {
return bns.reduce((a, b) => a.add(b), new BN(0))
}
Expand All @@ -53,4 +48,4 @@ function sumMins(our, approved) {
return sumBNs(Object.keys(our).map(k => BN.min(our[k], approved[k] || new BN(0))))
}

module.exports = { isValidTransition, getHealth, isHealthy }
module.exports = { isValidTransition, getHealthPromilles }
79 changes: 53 additions & 26 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ const tape = require('tape')

const BN = require('bn.js')
const { Joi } = require('celebrate')
const { isValidTransition, isHealthy } = require('../services/validatorWorker/lib/followerRules')
const {
isValidTransition,
getHealthPromilles
} = require('../services/validatorWorker/lib/followerRules')
const { mergeAggrs } = require('../services/validatorWorker/lib/mergeAggrs')
const eventReducer = require('../services/sentry/lib/eventReducer')
const { getBalancesAfterFeesTree } = require('../services/validatorWorker/lib/fees')
Expand Down Expand Up @@ -89,47 +92,71 @@ tape('isValidTransition: transition to a state with a negative number', function
})

//
// isHealthy
// getHealthPromilles
//
tape('isHealthy: the approved balance tree >= our accounting: HEALTHY', function(t) {
t.equal(isHealthy({ depositAmount: 50 }, { a: new BN(50) }, { a: new BN(50) }), true)
t.equal(isHealthy({ depositAmount: 50 }, { a: new BN(50) }, { a: new BN(60) }), true)
tape('getHealthPromilles: the approved balance tree >= our accounting', function(t) {
t.ok(
getHealthPromilles({ depositAmount: 50 }, { a: new BN(50) }, { a: new BN(50) }).eq(new BN(1000))
)
t.ok(
getHealthPromilles({ depositAmount: 50 }, { a: new BN(50) }, { a: new BN(60) }).eq(new BN(1000))
)
t.end()
})

tape('isHealthy: the approved balance tree is positive, our accounting is 0: HEALTHY', function(t) {
t.equal(isHealthy({ depositAmount: 50 }, {}, { a: new BN(50) }), true)
tape('getHealthPromilles: the approved balance tree is positive, our accounting is 0', function(t) {
t.ok(getHealthPromilles({ depositAmount: 50 }, {}, { a: new BN(50) }).eq(new BN(1000)))
t.end()
})

tape('isHealthy: the approved balance tree has less, but within margin: HEALTHY', function(t) {
t.equal(isHealthy({ depositAmount: 80 }, { a: new BN(80) }, { a: new BN(79) }), true)
t.equal(isHealthy({ depositAmount: 80 }, { a: new BN(2) }, { a: new BN(1) }), true)
tape('getHealthPromilles: the approved balance tree has less, but within margin', function(t) {
t.ok(
getHealthPromilles({ depositAmount: 80 }, { a: new BN(80) }, { a: new BN(79) }).eq(new BN(988))
)
t.ok(
getHealthPromilles({ depositAmount: 80 }, { a: new BN(2) }, { a: new BN(1) }).eq(new BN(988))
)
t.end()
})

tape('isHealthy: the approved balance tree has less: UNHEALTHY', function(t) {
t.equal(isHealthy({ depositAmount: 80 }, { a: new BN(80) }, { a: new BN(70) }), false)
tape('getHealthPromilles: the approved balance tree has significantly less', function(t) {
t.ok(
getHealthPromilles({ depositAmount: 80 }, { a: new BN(80) }, { a: new BN(70) }).eq(new BN(875))
)
t.end()
})

tape('isHealthy: they have the same sum, but different entities are earning', function(t) {
t.equal(isHealthy({ depositAmount: 80 }, { a: new BN(80) }, { b: new BN(80) }), false)
t.equal(
isHealthy({ depositAmount: 80 }, { a: new BN(80) }, { b: new BN(40), a: new BN(40) }),
false
tape('getHealthPromilles: they have the same sum, but different entities are earning', function(t) {
t.ok(
getHealthPromilles({ depositAmount: 80 }, { a: new BN(80) }, { b: new BN(80) }).eq(new BN(0))
)
t.equal(
isHealthy({ depositAmount: 80 }, { a: new BN(80) }, { b: new BN(20), a: new BN(60) }),
false
t.ok(
getHealthPromilles(
{ depositAmount: 80 },
{ a: new BN(80) },
{ b: new BN(40), a: new BN(40) }
).eq(new BN(500))
)
t.equal(
isHealthy({ depositAmount: 80 }, { a: new BN(80) }, { b: new BN(2), a: new BN(78) }),
true
t.ok(
getHealthPromilles(
{ depositAmount: 80 },
{ a: new BN(80) },
{ b: new BN(20), a: new BN(60) }
).eq(new BN(750))
)
t.equal(
isHealthy({ depositAmount: 80 }, { a: new BN(100), b: new BN(1) }, { a: new BN(100) }),
true
t.ok(
getHealthPromilles(
{ depositAmount: 80 },
{ a: new BN(80) },
{ b: new BN(2), a: new BN(78) }
).eq(new BN(975))
)
t.ok(
getHealthPromilles(
{ depositAmount: 80 },
{ a: new BN(100), b: new BN(1) },
{ a: new BN(100) }
).eq(new BN(988))
)
t.end()
})
Expand Down
56 changes: 56 additions & 0 deletions test/integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,62 @@ tape('health works correctly', async function(t) {
t.end()
})

tape('health works correctly: should reject state if health is too different', async function(t) {
const channel = getValidEthChannel()
const channelIface = new SentryInterface(dummyAdapter, channel, { logging: false })

// Submit a new channel; we submit it to both sentries to avoid 404 when propagating messages
await Promise.all([
fetchPost(`${leaderUrl}/channel`, dummyVals.auth.leader, channel),
fetchPost(`${followerUrl}/channel`, dummyVals.auth.follower, channel)
])
const toFollower = 300
const toLeader = 3
const diff = toFollower - toLeader

await Promise.all(
[leaderUrl, followerUrl].map(url =>
postEvents(url, channel.id, genEvents(url === followerUrl ? toFollower : toLeader))
)
)

// wait for the events to be aggregated and new states to be issued
await aggrAndTick()
await forceTick()

const [firstNewState, approve, reject] = await Promise.all([
channelIface.getLatestMsg(dummyVals.ids.leader, 'NewState'),
channelIface.getLatestMsg(dummyVals.ids.follower, 'ApproveState'),
channelIface.getLatestMsg(dummyVals.ids.follower, 'RejectState')
])
if (approve)
t.notEqual(
firstNewState.stateRoot,
approve.stateRoot,
'we are not approving the malicious NewState'
)
t.ok(reject, 'has a RejectState')
if (reject) {
t.equal(reject.stateRoot, firstNewState.stateRoot, 'we have rejected the malicious NewState')
t.equal(reject.reason, 'TooLowHealth', `reason for rejection is TooLowHealth`)
}

// send events to the leader so it catches up
await postEvents(leaderUrl, channel.id, genEvents(diff))
await aggrAndTick()
await forceTick()

// check if healthy
const lastApproveHealthy = await channelIface.getLatestMsg(dummyVals.ids.follower, 'ApproveState')
t.notEqual(
lastApproveHealthy.stateRoot,
firstNewState.stateRoot,
'we are not approving the malicious NewState'
)
t.equal(lastApproveHealthy.isHealthy, true, 'channel is registered as healthy')
t.end()
})

tape('should close channel', async function(t) {
const channel = getValidEthChannel()
const channelIface = new SentryInterface(dummyAdapter, channel, { logging: false })
Expand Down