OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / integration / custody-sweep-linkage.test.js

154 lines · javascript ref:
/**
 * Regression for the gap Drop hit (2026-05-31): a relay's custody expiry
 * sweep never emitted a non-serving-proof for content seeded over the
 * seed-request channel. Two independent root causes, both fixed in v0.9.2:
 *
 *  1. Linkage. The binary seedRequestEncoding drops custody fields, so the
 *     appRegistry entry lands with custodyIntentId = null even though a signed
 *     intent for that addressKey exists in the registry (published over the
 *     custody channel). The sweep keyed attestation off entry.custodyIntentId,
 *     so it could never link the two. Now it resolves the intent by addressKey
 *     (getCustodyIntentIdByAddressKey) and backfills the entry.
 *
 *  2. Nonce. Even with a custodyIntentId, the sweep called
 *     createCustodyNonServingProof without a challengeNonce, and proof signing
 *     requires a 64-hex nonce — so every auto-attest threw "challengeNonce must
 *     be 64 hex characters". createCustodyNonServingProof now self-generates a
 *     nonce when none is supplied (same for the expiry-witness path).
 *
 * One relay handles two entries in a single sweep pass — a still-live one (gets
 * its custodyIntentId backfilled, not expired) and an expired one (resolved,
 * unseeded, and attested). Entries are registered directly via appRegistry.set
 * so the sweep is exercised deterministically without real content replication.
 */

import test from 'brittle'
import createTestnet from '@hyperswarm/testnet'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import { randomBytes } from 'crypto'
import { mkdir, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'

const STORAGE_BOUND = 1024 * 1024

function keyPair () {
  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 }
}

// A temporary-custody entry as if seeded over the seed-request channel: blind +
// atomic-handoff, but custodyIntentId omitted (the wire dropped it).
function unlinkedEntry (overrides = {}) {
  return {
    drive: { closed: false, closing: false, close: async () => {} },
    discoveryKey: randomBytes(32),
    startedAt: Date.now(),
    type: 'drive',
    blind: true,
    storageClass: 'temporary',
    availabilityClass: 'atomic-handoff',
    custodyIntentId: null,
    anchored: true,
    maxStorage: STORAGE_BOUND,
    ...overrides
  }
}

test('custody sweep: recovers custodyIntentId by addressKey, backfills live entries, and attests expired ones', async (t) => {
  const id = randomBytes(4).toString('hex')
  const baseDir = join(tmpdir(), 'hiverelay-sweep-' + id)
  const testnet = await createTestnet(3)
  let relay = null
  await mkdir(baseDir, { recursive: true })
  await mkdir(join(baseDir, 'relay'), { recursive: true })
  t.teardown(async () => {
    let firstError = null
    if (relay) {
      try { await relay.stop() } catch (err) { if (!firstError) firstError = err }
    }
    try { await testnet.destroy() } catch (err) { if (!firstError) firstError = err }
    try { await rm(baseDir, { recursive: true, force: true }) } catch (err) { if (!firstError) firstError = err }
    if (firstError) throw firstError
  })

  relay = new RelayNode({
    storage: join(baseDir, 'relay'),
    bootstrapNodes: testnet.bootstrap,
    enableSeeding: true,
    enableNetworkDiscovery: false,
    enableHolesail: false,
    custodyExpiryGraceMs: 0
  })
  await relay.start()
  const attestErrors = []
  relay.on('custody-non-serving-attest-error', e => attestErrors.push(e))

  const publisher = keyPair()
  const now = Date.now()

  // ── Entry A: still LIVE (retain window open). Intent only. ──
  const liveKey = b4a.toString(randomBytes(32), 'hex')
  const liveBlind = b4a.toString(randomBytes(32), 'hex')
  const liveRetain = now + 60 * 60 * 1000
  const liveIntent = await relay.seedingRegistry.publishCustodyIntent({
    addressKey: liveKey,
    blindContentId: liveBlind,
    ciphertextRoot: b4a.toString(randomBytes(32), 'hex'),
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: liveRetain
  }, publisher)
  relay.appRegistry.set(liveKey, unlinkedEntry({ retainUntil: liveRetain, blindContentId: liveBlind }))
  relay.storageAdmission.adoptRecovery(`drive:${liveKey}`, STORAGE_BOUND, { kind: 'drive' })

  // ── Entry B: EXPIRED (retain elapsed). Full chain (committed), mirroring
  //    Drop's foundation evidence. The relay's own key signs the receipt so it
  //    is in the committed quorum. ──
  const deadKey = b4a.toString(randomBytes(32), 'hex')
  const deadBlind = b4a.toString(randomBytes(32), 'hex')
  const deadCipher = b4a.toString(randomBytes(32), 'hex')
  const deadRetain = now - 120_000
  const deadIntent = await relay.seedingRegistry.publishCustodyIntent({
    addressKey: deadKey,
    blindContentId: deadBlind,
    ciphertextRoot: deadCipher,
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000, // receipt window open (independent of retainUntil)
    retainUntil: deadRetain // already elapsed → time-path expiry
  }, publisher)
  await relay.seedingRegistry.recordCustodyReceipt({
    intentId: deadIntent.intentId, blindContentId: deadBlind, ciphertextRoot: deadCipher, contentVersion: 1, anchored: true, retainUntil: deadRetain
  }, relay.swarm.keyPair)
  await relay.seedingRegistry.publishCustodyCommit({ intentId: deadIntent.intentId }, publisher)
  relay.appRegistry.set(deadKey, unlinkedEntry({ retainUntil: deadRetain, blindContentId: deadBlind }))
  relay.storageAdmission.adoptRecovery(`drive:${deadKey}`, STORAGE_BOUND, { kind: 'drive' })

  // Both entries start unlinked; the registry can resolve both by addressKey.
  t.is(relay.appRegistry.get(liveKey).custodyIntentId || null, null, 'live entry starts unlinked')
  t.is(relay.appRegistry.get(deadKey).custodyIntentId || null, null, 'expired entry starts unlinked')
  t.is(relay.seedingRegistry.getCustodyIntentIdByAddressKey(liveKey), liveIntent.intentId, 'resolves live intent by addressKey')
  t.is(relay.seedingRegistry.getCustodyIntentIdByAddressKey(deadKey), deadIntent.intentId, 'resolves expired intent by addressKey')

  // One sweep pass handles both entries.
  const result = await relay._runCustodyExpiryPass()
  t.is(attestErrors.length, 0, 'no attest errors: ' + JSON.stringify(attestErrors))
  t.is(result.attested, 1, 'sweep signed exactly one non-serving-proof (the expired entry)')

  // Live entry: backfilled, retained.
  const liveEntry = relay.appRegistry.get(liveKey)
  t.ok(liveEntry, 'live entry retained (not unseeded)')
  t.is(liveEntry.custodyIntentId, liveIntent.intentId, 'live entry.custodyIntentId backfilled from addressKey match')

  // Expired entry: attested + unseeded.
  t.is(relay.seedingRegistry.getCustodyStatus(deadIntent.intentId).nonServingProofCount, 1,
    'non-serving-proof recorded for the expired intent')
  t.absent(relay.appRegistry.get(deadKey), 'expired entry was unseeded')
})