OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / proof-of-relay.test.js

387 lines · javascript ref:
import test from 'brittle'
import b4a from 'b4a'
import c from 'compact-encoding'
import sodium from 'sodium-universal'
import { ProofOfRelay } from 'p2p-hiverelay/core/protocol/proof-of-relay.js'
import {
  MAX_PROOF_BLOCK_BYTES,
  MAX_PROOF_MERKLE_PROOF_BYTES,
  proofChallengeEncoding,
  proofResponseEncoding
} from 'p2p-hiverelay/core/protocol/messages.js'

function randomKey () {
  const buf = b4a.alloc(32)
  sodium.randombytes_buf(buf)
  return buf
}

function encodeFrame (encoding, msg) {
  const state = { start: 0, end: 0, buffer: null }
  encoding.preencode(state, msg)
  state.buffer = b4a.alloc(state.end)
  state.start = 0
  encoding.encode(state, msg)
  return state.buffer
}

function declaredProofResponseFrame ({ blockLen, blockPayload = null, proofLen, proofPayload = null }) {
  const coreKey = b4a.alloc(32, 0x11)
  const nonce = b4a.alloc(32, 0x22)
  const state = { start: 0, end: 0, buffer: null }
  c.fixed32.preencode(state, coreKey)
  c.uint.preencode(state, 7)
  c.uint.preencode(state, blockLen)
  state.end += blockPayload ? blockPayload.byteLength : 0
  c.uint.preencode(state, proofLen)
  state.end += proofPayload ? proofPayload.byteLength : 0
  c.fixed32.preencode(state, nonce)
  state.buffer = b4a.alloc(state.end)
  c.fixed32.encode(state, coreKey)
  c.uint.encode(state, 7)
  c.uint.encode(state, blockLen)
  if (blockPayload) {
    blockPayload.copy(state.buffer, state.start)
    state.start += blockPayload.byteLength
  }
  c.uint.encode(state, proofLen)
  if (proofPayload) {
    proofPayload.copy(state.buffer, state.start)
    state.start += proofPayload.byteLength
  }
  c.fixed32.encode(state, nonce)
  return state.buffer
}

function truncatedProofResponseFrame (blockLen) {
  const coreKey = b4a.alloc(32, 0x11)
  const state = { start: 0, end: 0, buffer: null }
  c.fixed32.preencode(state, coreKey)
  c.uint.preencode(state, 7)
  c.uint.preencode(state, blockLen)
  state.buffer = b4a.alloc(state.end)
  c.fixed32.encode(state, coreKey)
  c.uint.encode(state, 7)
  c.uint.encode(state, blockLen)
  return state.buffer
}

test('proof encodings: round-trip valid challenge and response frames', (t) => {
  const challenge = {
    coreKey: b4a.alloc(32, 0x01),
    blockIndex: 42,
    nonce: b4a.alloc(32, 0x02),
    maxLatencyMs: 5000
  }
  const response = {
    coreKey: challenge.coreKey,
    blockIndex: challenge.blockIndex,
    blockData: b4a.from('block-data'),
    merkleProof: b4a.from('legacy-proof'),
    nonce: challenge.nonce
  }

  const challengeFrame = encodeFrame(proofChallengeEncoding, challenge)
  const responseFrame = encodeFrame(proofResponseEncoding, response)

  t.alike(proofChallengeEncoding.decode({ buffer: challengeFrame, start: 0, end: challengeFrame.length }), challenge)
  t.alike(proofResponseEncoding.decode({ buffer: responseFrame, start: 0, end: responseFrame.length }), response)
})

test('proof encodings: reject bad outbound frames before allocation growth', (t) => {
  const badChallenge = { start: 0, end: 0, buffer: null }
  t.exception(() => {
    proofChallengeEncoding.preencode(badChallenge, {
      coreKey: b4a.alloc(31),
      blockIndex: 1,
      nonce: b4a.alloc(32),
      maxLatencyMs: 5000
    })
  }, /coreKey/, 'bad challenge core key rejected')
  t.is(badChallenge.end, 0, 'bad challenge does not grow state.end')

  const badBlock = { start: 0, end: 0, buffer: null }
  t.exception(() => {
    proofResponseEncoding.preencode(badBlock, {
      coreKey: b4a.alloc(32),
      blockIndex: 1,
      blockData: b4a.alloc(MAX_PROOF_BLOCK_BYTES + 1),
      merkleProof: b4a.alloc(0),
      nonce: b4a.alloc(32)
    })
  }, /blockData too large/, 'oversized block data rejected')
  t.is(badBlock.end, 0, 'oversized block data does not grow state.end')

  const badProof = { start: 0, end: 0, buffer: null }
  t.exception(() => {
    proofResponseEncoding.preencode(badProof, {
      coreKey: b4a.alloc(32),
      blockIndex: 1,
      blockData: b4a.from('ok'),
      merkleProof: b4a.alloc(MAX_PROOF_MERKLE_PROOF_BYTES + 1),
      nonce: b4a.alloc(32)
    })
  }, /merkleProof too large/, 'oversized legacy merkle proof rejected')
  t.is(badProof.end, 0, 'oversized merkle proof does not grow state.end')
})

test('proof encodings: reject oversized declared inbound frames before materializing them', (t) => {
  const hugeBlock = declaredProofResponseFrame({
    blockLen: MAX_PROOF_BLOCK_BYTES + 1,
    proofLen: 0
  })
  const blockOut = proofResponseEncoding.decode({ buffer: hugeBlock, start: 0, end: hugeBlock.length })
  t.is(blockOut.error, 'blockData too large')
  t.absent(blockOut.blockData)

  const hugeProof = declaredProofResponseFrame({
    blockLen: 2,
    blockPayload: b4a.from('ok'),
    proofLen: MAX_PROOF_MERKLE_PROOF_BYTES + 1
  })
  const proofOut = proofResponseEncoding.decode({ buffer: hugeProof, start: 0, end: hugeProof.length })
  t.is(proofOut.error, 'merkleProof too large')
  t.absent(proofOut.merkleProof)
})

test('proof encodings: reject malformed and truncated frames without throwing', (t) => {
  let challengeOut = null
  t.execution(() => {
    challengeOut = proofChallengeEncoding.decode({ buffer: b4a.alloc(31), start: 0, end: 31 })
  }, 'truncated proof challenge does not throw')
  t.is(challengeOut.error, 'malformed proof challenge')

  let responseOut = null
  t.execution(() => {
    const frame = truncatedProofResponseFrame(16)
    responseOut = proofResponseEncoding.decode({ buffer: frame, start: 0, end: frame.length })
  }, 'truncated proof response block data does not throw')
  t.is(responseOut.error, 'malformed proof response')
})

test('ProofOfRelay handlers ignore decoded proof protocol errors without throwing', async (t) => {
  const por = new ProofOfRelay()
  const invalid = { challenge: [], response: [] }
  por.on('invalid-challenge', event => invalid.challenge.push(event.reason))
  por.on('invalid-response', event => invalid.response.push(event.reason))

  await por._onChallenge({ stream: {} }, { error: 'malformed proof challenge' })
  t.execution(() => por._onResponse(null, { error: 'blockData too large' }), 'response error handled')
  t.alike(invalid.challenge, ['malformed proof challenge'])
  t.alike(invalid.response, ['blockData too large'])

  por.destroy()
})

test('ProofOfRelay - challenge stores pending entry', async (t) => {
  const por = new ProofOfRelay({ maxLatencyMs: 5000 })

  const mockChannel = {
    opened: true,
    _hiverelay: {
      challengeMsg: { send () {} },
      responseMsg: { send () {} }
    }
  }

  por.challenge(mockChannel, randomKey(), 0, randomKey())
  t.is(por.pendingChallenges.size, 1)

  por.destroy()
})

test('ProofOfRelay - pending challenge cap rejects new single challenges', async (t) => {
  const por = new ProofOfRelay({ maxLatencyMs: 5000, maxPendingChallenges: 1 })
  const rejected = []
  por.on('challenge-rejected', (evt) => rejected.push(evt))

  const mockChannel = {
    opened: true,
    _hiverelay: {
      challengeMsg: { send () {} },
      responseMsg: { send () {} }
    }
  }

  const first = por.challenge(mockChannel, randomKey(), 0, randomKey())
  const second = por.challenge(mockChannel, randomKey(), 1, randomKey())

  t.is(first, true, 'first challenge accepted')
  t.is(second, false, 'second challenge rejected at cap')
  t.is(por.pendingChallenges.size, 1, 'pending map stays at cap')
  t.is(rejected.length, 1)
  t.is(rejected[0].reason, 'pending-capacity-exceeded')

  por.destroy()
})

test('ProofOfRelay - oversized batch is rejected before allocating pending entries', async (t) => {
  const por = new ProofOfRelay({ maxLatencyMs: 5000, maxBatchSize: 2 })
  const rejected = []
  por.on('batch-challenge-rejected', (evt) => rejected.push(evt))

  const mockChannel = {
    opened: true,
    _hiverelay: {
      challengeMsg: { send () {} },
      responseMsg: { send () {} }
    }
  }

  const batchId = por.challengeBatch(mockChannel, randomKey(), [0, 1, 2], randomKey())

  t.is(batchId, null, 'oversized batch rejected')
  t.is(por.pendingChallenges.size, 0, 'no batch state allocated')
  t.is(rejected.length, 1)
  t.is(rejected[0].reason, 'batch-too-large')
  t.is(rejected[0].maxBatchSize, 2)

  por.destroy()
})

test('ProofOfRelay - batch reserves one batch slot plus per-index slots', async (t) => {
  const por = new ProofOfRelay({ maxLatencyMs: 5000, maxBatchSize: 4, maxPendingChallenges: 3 })
  const rejected = []
  por.on('batch-challenge-rejected', (evt) => rejected.push(evt))

  const mockChannel = {
    opened: true,
    _hiverelay: {
      challengeMsg: { send () {} },
      responseMsg: { send () {} }
    }
  }

  const accepted = por.challengeBatch(mockChannel, randomKey(), [0, 1], randomKey())
  t.ok(accepted, 'two-block batch fits in three slots')
  t.is(por.pendingChallenges.size, 3, 'batch stores batch id plus two per-index entries')

  const denied = por.challengeBatch(mockChannel, randomKey(), [2], randomKey())
  t.is(denied, null, 'next batch rejected at pending cap')
  t.is(por.pendingChallenges.size, 3, 'pending map does not grow past cap')
  t.is(rejected.length, 1)
  t.is(rejected[0].reason, 'pending-capacity-exceeded')

  por.destroy()
})

test('ProofOfRelay - valid response scores a pass', async (t) => {
  t.plan(2)
  const por = new ProofOfRelay({ maxLatencyMs: 5000 })
  const coreKey = randomKey()
  const relayPubkey = randomKey()
  const nonce = b4a.alloc(32)
  sodium.randombytes_buf(nonce)

  // Manually insert a pending challenge
  por.pendingChallenges.set(b4a.toString(nonce, 'hex'), {
    coreKey: b4a.toString(coreKey, 'hex'),
    blockIndex: 5,
    sentAt: Date.now(),
    relayPubkey: b4a.toString(relayPubkey, 'hex')
  })

  por.on('proof-result', (result) => {
    t.ok(result.passed, 'challenge passed')
  })

  // Simulate response
  por._onResponse(null, {
    coreKey,
    blockIndex: 5,
    blockData: Buffer.from('block-data'),
    merkleProof: Buffer.from('proof'),
    nonce
  })

  const score = por.getScore(b4a.toString(relayPubkey, 'hex'))
  t.is(score.passes, 1)

  por.destroy()
})

test('ProofOfRelay - latency exceeded scores a fail', async (t) => {
  const por = new ProofOfRelay({ maxLatencyMs: 1 })
  const coreKey = randomKey()
  const relayPubkey = randomKey()
  const nonce = b4a.alloc(32)
  sodium.randombytes_buf(nonce)

  por.pendingChallenges.set(b4a.toString(nonce, 'hex'), {
    coreKey: b4a.toString(coreKey, 'hex'),
    blockIndex: 0,
    sentAt: Date.now() - 1000, // sent 1s ago, max is 1ms
    relayPubkey: b4a.toString(relayPubkey, 'hex')
  })

  por._onResponse(null, {
    coreKey,
    blockIndex: 0,
    blockData: Buffer.from('data'),
    merkleProof: Buffer.from('proof'),
    nonce
  })

  const score = por.getScore(b4a.toString(relayPubkey, 'hex'))
  t.is(score.fails, 1)

  por.destroy()
})

test('ProofOfRelay - unknown nonce emits unexpected-response', async (t) => {
  t.plan(1)
  const por = new ProofOfRelay()

  por.on('unexpected-response', () => {
    t.pass('unexpected-response emitted')
  })

  por._onResponse(null, {
    coreKey: randomKey(),
    blockIndex: 0,
    blockData: Buffer.from('data'),
    merkleProof: Buffer.from('proof'),
    nonce: randomKey()
  })

  por.destroy()
})

test('ProofOfRelay - getReliability', async (t) => {
  const por = new ProofOfRelay({ maxLatencyMs: 5000 })
  const relay = b4a.toString(randomKey(), 'hex')

  // Simulate 4 passes and 1 fail via _updateScore
  for (let i = 0; i < 4; i++) por._updateScore(relay, true, 100)
  por._updateScore(relay, false, 0)

  t.is(por.getReliability(relay), 0.8)

  por.destroy()
})

test('ProofOfRelay - stale challenge cleanup', async (t) => {
  const por = new ProofOfRelay({ maxLatencyMs: 50 })

  por.pendingChallenges.set('stale', {
    coreKey: 'abc',
    blockIndex: 0,
    sentAt: Date.now() - 200, // well past 2 * 50ms
    relayPubkey: 'def'
  })

  por.pendingChallenges.set('fresh', {
    coreKey: 'abc',
    blockIndex: 1,
    sentAt: Date.now(),
    relayPubkey: 'def'
  })

  por._cleanupStale()

  t.is(por.pendingChallenges.size, 1)
  t.ok(por.pendingChallenges.has('fresh'))

  por.destroy()
})