OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / subsidy.test.js

231 lines · javascript ref:
/**
 * SubsidyAccrual (Phase 1) tests.
 *
 * The relay-local half of the operator subsidy: capped accrual estimate,
 * payout-destination validation (non-custodial — operator's own address),
 * Ed25519-signed claims for the coordinator, atomic persistence. No money
 * moves anywhere in this module; these tests pin that the *evidence and
 * estimate* layer is deterministic and crash-safe.
 */

import test from 'brittle'
import sodium from 'sodium-universal'
import b4a from 'b4a'
import { mkdtemp, readFile, readdir } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
  SubsidyAccrual,
  validatePayoutDestination,
  canonicalJson,
  signClaim,
  verifyClaim,
  claimDigest,
  SUBSIDY_SCHEMA_VERSION
} from 'p2p-hiverelay/incentive/subsidy/index.js'

function makeKeyPair () {
  const publicKey = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
  const secretKey = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
  sodium.crypto_sign_keypair(publicKey, secretKey)
  return { publicKey, secretKey }
}

const DAY_MS = 86_400_000
const T0 = Date.UTC(2026, 5, 11, 0, 0, 0) // fixed epoch for determinism

function makeAccrual (opts = {}) {
  return new SubsidyAccrual({
    keyPair: makeKeyPair(),
    statsFn: opts.statsFn || (() => ({ connections: 9, seededApps: 800, appRegistry: { anchored: 750 } })),
    rateSatsPerDay: opts.rateSatsPerDay != null ? opts.rateSatsPerDay : 500,
    epochMs: opts.epochMs != null ? opts.epochMs : 60 * 60 * 1000, // 1h epochs for easy math
    storagePath: opts.storagePath || null,
    payoutDestination: opts.payoutDestination
  })
}

// ─── destination validation ────────────────────────────────────────

test('validatePayoutDestination: accepts the three rails', (t) => {
  t.alike(validatePayoutDestination('Satoshi@GetAlby.com'),
    { type: 'lightning-address', value: 'satoshi@getalby.com' }, 'lightning address (lowercased)')
  const lno = 'lno1' + 'qcp4256ypq'.repeat(4)
  t.alike(validatePayoutDestination(lno), { type: 'bolt12', value: lno }, 'BOLT12 offer')
  t.alike(validatePayoutDestination('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'),
    { type: 'onchain', value: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4' }, 'bech32 onchain')
  t.alike(validatePayoutDestination('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'),
    { type: 'onchain', value: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa' }, 'base58 onchain')
})

test('validatePayoutDestination: rejects junk', (t) => {
  t.is(validatePayoutDestination(''), null, 'empty')
  t.is(validatePayoutDestination('not a destination'), null, 'free text')
  t.is(validatePayoutDestination('user@nodomain'), null, 'lightning address without TLD')
  t.is(validatePayoutDestination(12345), null, 'non-string')
  t.is(validatePayoutDestination('lno1'), null, 'bolt12 too short')
  t.is(validatePayoutDestination('x'.repeat(400)), null, 'absurd length')
})

test('constructor: invalid configured destination throws (no silent drop)', (t) => {
  t.exception(() => makeAccrual({ payoutDestination: 'garbage' }), /invalid payoutDestination/)
  t.exception(() => new SubsidyAccrual({}), /keyPair is required/)
})

// ─── canonical JSON + claim signing ────────────────────────────────

test('canonicalJson: key order does not change the byte string', (t) => {
  const a = canonicalJson({ b: 1, a: { d: 2, c: [3, { f: 4, e: 5 }] } })
  const b = canonicalJson({ a: { c: [3, { e: 5, f: 4 }], d: 2 }, b: 1 })
  t.is(a, b, 'same logical object -> same canonical string')
})

test('claim signing round-trips; tamper breaks verification', (t) => {
  const keyPair = makeKeyPair()
  const body = { schemaVersion: SUBSIDY_SCHEMA_VERSION, accruedSatsEstimate: 42, periodStart: T0, periodEnd: T0 + DAY_MS }
  const signature = signClaim(keyPair, body)
  t.ok(verifyClaim({ body, relayPubkey: keyPair.publicKey, signature }), 'valid claim verifies')
  const tampered = { ...body, accruedSatsEstimate: 9999 }
  t.absent(verifyClaim({ body: tampered, relayPubkey: keyPair.publicKey, signature }), 'tampered amount fails')
  const otherKey = makeKeyPair()
  t.absent(verifyClaim({ body, relayPubkey: otherKey.publicKey, signature }), 'wrong pubkey fails')
  t.absent(verifyClaim({ body, relayPubkey: keyPair.publicKey.subarray(0, 16), signature }), 'short pubkey rejected')
})

// ─── accrual math ──────────────────────────────────────────────────

test('accrues pro-rata per epoch and caps per UTC day', async (t) => {
  const sub = makeAccrual({ rateSatsPerDay: 240, epochMs: 60 * 60 * 1000 }) // 10 sats/hour
  for (let h = 1; h <= 30; h++) await sub._tick(T0 + h * 3600_000) // 30 ticks across day boundary
  // Day 1: ticks at h=1..23 land on day one (24 ticks incl h=0? first tick at T0+1h)
  // What matters: no UTC day may exceed the cap, and the total reflects it.
  const summary = sub.getSummary()
  t.ok(summary.accruedSatsTotal <= 300, 'total bounded (no runaway)')
  t.ok(summary.accruedSatsToday <= 240, 'daily cap respected')
  t.is(summary.epochCount, 30, 'all epochs recorded as evidence')
  t.is(summary.estimate, true, 'summary is explicitly an estimate')
  await sub.destroy()
})

test('day rollover resets the daily counter', async (t) => {
  const sub = makeAccrual({ rateSatsPerDay: 240, epochMs: 60 * 60 * 1000 })
  for (let h = 1; h <= 23; h++) await sub._tick(T0 + h * 3600_000)
  const day1 = sub.getSummary().accruedSatsToday
  await sub._tick(T0 + DAY_MS + 3600_000) // first tick of next UTC day
  const day2 = sub.getSummary().accruedSatsToday
  t.ok(day1 > day2, 'today counter reset on rollover')
  t.is(day2, 10, 'new day accrues from zero at the hourly rate')
  await sub.destroy()
})

test('zero rate accrues nothing but still records evidence', async (t) => {
  const sub = makeAccrual({ rateSatsPerDay: 0 })
  await sub._tick(T0 + 3600_000)
  t.is(sub.getSummary().accruedSatsTotal, 0, 'no sats at zero rate')
  t.is(sub.getSummary().epochCount, 1, 'epoch evidence still recorded')
  await sub.destroy()
})

// ─── claims ────────────────────────────────────────────────────────

test('buildClaim: verifiable envelope with evidence + destination', async (t) => {
  const sub = makeAccrual({ rateSatsPerDay: 240, epochMs: 3600_000 })
  await sub.setPayoutDestination('op@getalby.com')
  for (let h = 1; h <= 5; h++) await sub._tick(T0 + h * 3600_000)
  const claim = sub.buildClaim(T0 + 6 * 3600_000)
  t.is(claim.body.epochCount, 5, 'epoch count in claim')
  t.is(claim.body.uptimeMs, 5 * 3600_000, 'uptime totalled from epochs')
  t.is(claim.body.accruedSatsEstimate, 50, '5h at 10 sats/h')
  t.is(claim.body.evidence.seededAppsAtEnd, 800, 'evidence snapshot present')
  t.alike(claim.body.payoutDestination, { type: 'lightning-address', value: 'op@getalby.com' }, 'destination rides in the claim')
  const ok = verifyClaim({
    body: claim.body,
    relayPubkey: b4a.from(claim.relayPubkey, 'hex'),
    signature: b4a.from(claim.signature, 'hex')
  })
  t.ok(ok, 'exported claim verifies from its hex envelope')
  await sub.destroy()
})

// ─── persistence ───────────────────────────────────────────────────

test('persists atomically and reloads (no leftover .tmp)', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'subsidy-'))
  const path = join(dir, 'subsidy.json')
  const keyPair = makeKeyPair()
  const sub = new SubsidyAccrual({ keyPair, statsFn: () => ({ connections: 1 }), rateSatsPerDay: 240, epochMs: 3600_000, storagePath: path })
  await sub.start()
  for (let h = 1; h <= 3; h++) await sub._tick(T0 + h * 3600_000)
  await sub.setPayoutDestination('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4')
  await sub.destroy()

  const files = await readdir(dir)
  t.alike(files, ['subsidy.json'], 'only the real file remains — no .tmp orphan')
  const onDisk = JSON.parse(await readFile(path, 'utf8'))
  t.is(onDisk.schemaVersion, SUBSIDY_SCHEMA_VERSION, 'schema stamped')

  const sub2 = new SubsidyAccrual({ keyPair, statsFn: () => ({}), rateSatsPerDay: 240, epochMs: 3600_000, storagePath: path })
  await sub2.start()
  t.is(sub2.getSummary().epochCount, 3, 'epochs reloaded')
  t.is(sub2.getSummary().accruedSatsTotal, 30, 'accrual reloaded')
  t.is(sub2.getSummary().payoutDestination.type, 'onchain', 'destination reloaded')
  await sub2.destroy()
})

test('corrupt persistence file starts fresh instead of crashing', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'subsidy-'))
  const path = join(dir, 'subsidy.json')
  const { writeFile } = await import('fs/promises')
  await writeFile(path, '{ truncated garbag')
  const sub = makeAccrual({ storagePath: path })
  await sub.start()
  t.is(sub.getSummary().epochCount, 0, 'fresh state from corrupt file')
  await sub.destroy()
})

test('setPayoutDestination: rejects junk, keeps previous value', async (t) => {
  const sub = makeAccrual()
  await sub.setPayoutDestination('op@getalby.com')
  await t.exception(() => sub.setPayoutDestination('junk'), /Unrecognized payout destination/)
  t.is(sub.getSummary().payoutDestination.value, 'op@getalby.com', 'previous destination intact')
  await sub.destroy()
})

test('setPayoutDestination: clears destination with blank or null', async (t) => {
  const sub = makeAccrual()
  await sub.setPayoutDestination('op@getalby.com')
  t.is(sub.getSummary().payoutDestination.value, 'op@getalby.com', 'destination set')
  t.is(await sub.setPayoutDestination(''), null, 'blank clears')
  t.is(sub.getSummary().payoutDestination, null, 'destination cleared')
  await sub.setPayoutDestination('op@getalby.com')
  t.is(await sub.setPayoutDestination(null), null, 'null clears')
  t.is(sub.getSummary().payoutDestination, null, 'destination cleared again')
  await sub.destroy()
})

test('setPayoutDestination: persistence failure rejects and keeps previous value', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'subsidy-'))
  const path = join(dir, 'subsidy.json')
  const sub = makeAccrual({ storagePath: path })
  await sub.setPayoutDestination('old@example.com')

  const errors = []
  sub.on('persist-error', (err) => { errors.push(err) })
  sub._write = async () => { throw new Error('disk full') }

  await t.exception(() => sub.setPayoutDestination('new@example.com'), /disk full/)
  t.is(sub.getSummary().payoutDestination.value, 'old@example.com', 'previous destination intact after failed write')
  t.is(errors.length, 1, 'background persistence error event emitted')
})

// ─── claim digest stability (cross-version pin) ────────────────────

test('claimDigest is stable for a fixed input', (t) => {
  const pub = b4a.alloc(32, 7)
  const digest = claimDigest(pub, { a: 1, b: 'two' })
  t.is(b4a.toString(digest, 'hex'),
    b4a.toString(claimDigest(pub, { b: 'two', a: 1 }), 'hex'),
    'digest independent of key order')
  t.is(digest.byteLength, 32, 'sha256 length')
})