OverviewFilesCommitsIssuesDocsReleasesPeers

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

479 lines · javascript ref:
/**
 * LeaseManager (paid pin-lease) tests.
 *
 * The demand-side counterpart to the subsidy: a publisher pays a relay they
 * don't own to keep their Hyperdrive pinned for a window. Zero-custody —
 * the relay mints an invoice on the operator's own node and verifies it
 * settled; the only durable artifact is the lease deadline. These tests pin
 * the byte-days quote math, the relay-signed stateless quote, settlement
 * verification, the replay-guard (incl. across restart), and the failure
 * modes (underpaid / expired / appKey-mismatch / forged quote).
 */

import test from 'brittle'
import sodium from 'sodium-universal'
import b4a from 'b4a'
import { mkdtemp } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { LeaseManager } from 'p2p-hiverelay/incentive/lease/index.js'
import { isLeaseExempt, evaluateSeedLease } from 'p2p-hiverelay/incentive/lease/gate.js'
import { assertPurgable } from 'p2p-hiverelay/core/relay-node/eviction.js'
import { MockProvider } from 'p2p-hiverelay/incentive/payment/mock-provider.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 GIB = 1024 * 1024 * 1024
const DAY_MS = 86_400_000
const T0 = Date.UTC(2026, 5, 15, 0, 0, 0)
const APPKEY = 'a'.repeat(64)

async function makeManager (opts = {}) {
  const provider = opts.provider || new MockProvider()
  const lm = new LeaseManager({
    keyPair: opts.keyPair || makeKeyPair(),
    provider,
    storagePath: opts.storagePath || null,
    satsPerGiBDay: opts.satsPerGiBDay != null ? opts.satsPerGiBDay : 10,
    quoteTtlMs: opts.quoteTtlMs != null ? opts.quoteTtlMs : 60 * 60 * 1000,
    minDays: opts.minDays,
    maxDays: opts.maxDays,
    blindDenomination: opts.blindDenomination
  })
  await lm.start({ now: opts.now })
  return { lm, provider }
}

test('byte-days quote math: ceil(bytes/GiB) * days * rate', async (t) => {
  const { lm } = await makeManager({ satsPerGiBDay: 10 })
  // 500 MB rounds up to 1 GiB → 1 * 7 * 10 = 70
  t.is(lm.quoteSats(500 * 1024 * 1024, 7).amountSats, 70)
  // exactly 2 GiB, 3 days, rate 10 → 60
  t.is(lm.quoteSats(2 * GIB, 3).amountSats, 60)
  // 2.5 GiB rounds up to 3 GiB → 3 * 1 * 10 = 30
  t.is(lm.quoteSats(2.5 * GIB, 1).amountSats, 30)
  await lm.destroy()
})

test('createQuote → pay → verify happy path sets paidUntil', async (t) => {
  const { lm, provider } = await makeManager({ satsPerGiBDay: 10 })
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: 2 * GIB, leaseDays: 5 }, T0)
  t.is(quote.amountSats, 100, '2 GiB * 5 days * 10')
  t.ok(quote.bolt11 && quote.quoteId, 'quote carries bolt11 + signed quoteId')
  t.is(quote.expiresAt, T0 + 60 * 60 * 1000)

  // Unpaid yet → rejected.
  const unpaid = await lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId }, T0 + 1000)
  t.is(unpaid.ok, false)
  t.is(unpaid.error.split(':')[0], 'LEASE_UNPAID')

  // Pay the invoice on the operator's (mock) node, then verify.
  const inv = provider.invoices[provider.invoices.length - 1]
  provider.settleInvoice(inv.rHash)
  const ok = await lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId }, T0 + 2000)
  t.is(ok.ok, true)
  t.is(ok.paidUntil, T0 + 2000 + 5 * DAY_MS, 'paidUntil = now + leaseDays')
  t.is(lm.getSummary().totalLeasedSats, 100)
  t.is(lm.getSummary().leaseCount, 1)
  await lm.destroy()
})

test('replay-guard: a settled payment cannot be redeemed twice', async (t) => {
  const { lm, provider } = await makeManager()
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 2 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  const first = await lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId }, T0 + 1000)
  t.is(first.ok, true)
  const second = await lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId }, T0 + 2000)
  t.is(second.ok, false)
  t.is(second.error.split(':')[0], 'LEASE_REPLAY')
  await lm.destroy()
})

test('appKey mismatch: a quote for X cannot pay for Y', async (t) => {
  const { lm, provider } = await makeManager()
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 1 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  const wrong = await lm.verifyLease({ appKey: 'b'.repeat(64), quoteId: quote.quoteId }, T0 + 1000)
  t.is(wrong.ok, false)
  t.is(wrong.error.split(':')[0], 'LEASE_QUOTE_APPKEY_MISMATCH')
  await lm.destroy()
})

test('expired quote is rejected even if paid', async (t) => {
  const { lm, provider } = await makeManager({ quoteTtlMs: 1000 })
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 1 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  const late = await lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId }, T0 + 5000)
  t.is(late.ok, false)
  t.is(late.error.split(':')[0], 'LEASE_QUOTE_EXPIRED')
  await lm.destroy()
})

test('bearer voucher: pay → issue → redeem for ANY appKey (payment unlinked to content)', async (t) => {
  const { lm, provider } = await makeManager({ satsPerGiBDay: 10 })
  // Buy via a normal quote, settle it, then convert to a bearer voucher.
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: 2 * GIB, leaseDays: 5 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  const issued = await lm.issueBearerVoucher({ quoteId: quote.quoteId }, T0 + 1000)
  t.is(issued.ok, true, 'voucher issued from a settled quote')
  t.ok(typeof issued.voucherId === 'string' && issued.voucherId.length > 0)

  // The voucher body carries NO appKey — decode and confirm.
  const decoded = JSON.parse(Buffer.from(issued.voucherId, 'base64url').toString('utf8'))
  t.is(decoded.body.kind, 'bearer')
  t.absent('appKey' in decoded.body, 'voucher is not bound to any appKey')

  // Redeem it for a COMPLETELY DIFFERENT appKey — succeeds (decoupled).
  const otherApp = 'c'.repeat(64)
  const redeemed = await lm.verifyBearer({ voucherId: issued.voucherId, maxStorageBytes: 2 * GIB }, T0 + 2000)
  t.is(redeemed.ok, true, 'redeemable for an unrelated appKey')
  t.is(redeemed.paidUntil, T0 + 2000 + 5 * DAY_MS)
  t.is(otherApp.length, 64) // (appKey never even passed to verifyBearer)

  // Single-use: second redemption is a replay.
  const again = await lm.verifyBearer({ voucherId: issued.voucherId, maxStorageBytes: 2 * GIB }, T0 + 3000)
  t.is(again.ok, false)
  t.is(again.error.split(':')[0], 'LEASE_REPLAY')
  await lm.destroy()
})

test('bearer voucher: the funding quote cannot ALSO be spent as a direct lease', async (t) => {
  const { lm, provider } = await makeManager()
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 2 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  const issued = await lm.issueBearerVoucher({ quoteId: quote.quoteId }, T0 + 1000)
  t.is(issued.ok, true)
  // The paymentHash was consumed at issuance → quote path now replays.
  const dbl = await lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId }, T0 + 2000)
  t.is(dbl.ok, false)
  t.is(dbl.error.split(':')[0], 'LEASE_REPLAY')
  await lm.destroy()
})

test('bearer voucher: gate accepts paymentProof.voucherId', async (t) => {
  const { lm, provider } = await makeManager()
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 1 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  const issued = await lm.issueBearerVoucher({ quoteId: quote.quoteId }, T0 + 1000)
  const outcome = await evaluateSeedLease({
    leaseManager: lm,
    seedingRegistry: null,
    appKey: 'd'.repeat(64),
    opts: { maxStorage: GIB },
    body: { paymentProof: { voucherId: issued.voucherId } }
  })
  t.is(outcome.outcome, 'paid', 'voucher redeemed through the gate for an unrelated app')
  await lm.destroy()
})

test('concurrency: two parallel verifyLease of one payment grant exactly ONE lease', async (t) => {
  const provider = new MockProvider()
  // Make settlement lookup yield, so both calls are genuinely in flight at once
  // (this is the window the in-flight lock must close — without it both pass).
  const origLookup = provider.lookupInvoice.bind(provider)
  provider.lookupInvoice = async (h) => { await new Promise(resolve => setTimeout(resolve, 5)); return origLookup(h) }
  const { lm } = await makeManager({ provider })
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 5 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)

  const [a, b] = await Promise.all([
    lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId, maxStorageBytes: GIB }, T0 + 1000),
    lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId, maxStorageBytes: GIB }, T0 + 1000)
  ])
  t.is([a, b].filter(r => r.ok).length, 1, 'exactly one concurrent redemption succeeds')
  t.is(lm.leaseCount, 1, 'only one lease recorded (no double-spend)')
  await lm.destroy()
})

test('concurrency: two parallel issueBearerVoucher of one payment issue exactly ONE', async (t) => {
  const provider = new MockProvider()
  const origLookup = provider.lookupInvoice.bind(provider)
  provider.lookupInvoice = async (h) => { await new Promise(resolve => setTimeout(resolve, 5)); return origLookup(h) }
  const { lm } = await makeManager({ provider })
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 5 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)

  const [a, b] = await Promise.all([
    lm.issueBearerVoucher({ quoteId: quote.quoteId }, T0 + 1000),
    lm.issueBearerVoucher({ quoteId: quote.quoteId }, T0 + 1000)
  ])
  t.is([a, b].filter(r => r.ok).length, 1, 'exactly one voucher issued from one payment')
  await lm.destroy()
})

test('blind token: pay → blind-sign → unblind → redeem (fully unlinkable, denominated)', async (t) => {
  const { blind, unblind } = await import('p2p-hiverelay/incentive/payment/blind-mint.js')
  const denom = { maxStorageBytes: GIB, leaseDays: 30 }
  const { lm, provider } = await makeManager({ blindDenomination: denom })
  t.ok(lm.blindMintInfo(), 'mint advertised when denomination configured')

  // 1. Buy a quote priced for exactly the denomination, settle it.
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 30 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)

  // 2. Payer blinds their own secret; relay blind-signs (never sees the secret).
  const secret = '11'.repeat(32)
  const { blinded, blindingFactor } = blind(secret)
  const issued = await lm.issueBlindVoucher({ quoteId: quote.quoteId, blinded }, T0 + 1000)
  t.is(issued.ok, true, 'blind signature issued from settled denominated quote')

  // 3. Payer unblinds → token (secret, C).
  const C = unblind(issued.blindSignature, blindingFactor, issued.mintPubkey)

  // 4. Redeem through the gate for an unrelated app.
  const outcome = await evaluateSeedLease({
    leaseManager: lm,
    seedingRegistry: null,
    appKey: 'e'.repeat(64),
    opts: { maxStorage: GIB },
    body: { paymentProof: { blindToken: { secret, C } } }
  })
  t.is(outcome.outcome, 'paid', 'blind token redeemed for an unrelated app')

  // 5. Double-spend rejected.
  const again = await lm.redeemBlindVoucher({ secret, C, maxStorageBytes: GIB }, T0 + 2000)
  t.is(again.ok, false)
  t.is(again.error.split(':')[0], 'LEASE_REPLAY')
  await lm.destroy()
})

test('cashu interop: issue → unblind → cashuA token → redeem through the gate', async (t) => {
  const { blind, unblind } = await import('p2p-hiverelay/incentive/payment/blind-mint.js')
  const { makeProof, encodeToken } = await import('p2p-hiverelay/incentive/payment/cashu.js')
  const { lm, provider } = await makeManager({ blindDenomination: { maxStorageBytes: GIB, leaseDays: 30 } })
  const info = lm.blindMintInfo()
  t.ok(/^00[0-9a-f]{14}$/.test(info.keyset.id), 'mint advertises a NUT-02 keyset id')

  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 30 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  const secret = '22'.repeat(32)
  const { blinded, blindingFactor } = blind(secret)
  const issued = await lm.issueBlindVoucher({ quoteId: quote.quoteId, blinded }, T0 + 1000)
  t.is(issued.keysetId, info.keyset.id, 'issuance returns the keyset id')

  // Payer assembles a standard Cashu proof + cashuA token.
  const C = unblind(issued.blindSignature, blindingFactor, issued.mintPubkey)
  const proof = makeProof(issued.amount, issued.keysetId, secret, C)
  const token = encodeToken({ mint: 'hiverelay', proofs: [proof], unit: 'sat' })
  t.ok(token.startsWith('cashuA'))

  const tooLarge = await evaluateSeedLease({
    leaseManager: lm,
    seedingRegistry: null,
    appKey: 'f'.repeat(64),
    opts: { maxStorage: 2 * GIB },
    body: { paymentProof: { cashuToken: token } }
  })
  t.is(tooLarge.outcome, 'error', 'cashu token cannot buy more storage than its denomination')
  t.is(tooLarge.error.split(':')[0], 'LEASE_STORAGE_EXCEEDS_VOUCHER')

  // Redeem the token through the gate.
  const outcome = await evaluateSeedLease({
    leaseManager: lm,
    seedingRegistry: null,
    appKey: 'f'.repeat(64),
    opts: { maxStorage: GIB },
    body: { paymentProof: { cashuToken: token } }
  })
  t.is(outcome.outcome, 'paid', 'cashuA token redeemed through the gate')

  // Double-spend of the same token is rejected.
  const again = await lm.redeemCashuToken(token, T0 + 2000)
  t.is(again.ok, false)
  t.is(again.error.split(':')[0], 'LEASE_REPLAY')
  await lm.destroy()
})

test('blind token: replay guard survives restart past maxDays', async (t) => {
  const { blind, unblind } = await import('p2p-hiverelay/incentive/payment/blind-mint.js')
  const dir = await mkdtemp(join(tmpdir(), 'hiverelay-lease-'))
  const storagePath = join(dir, 'lease.json')
  const keyPair = makeKeyPair()
  const denom = { maxStorageBytes: GIB, leaseDays: 1 }
  const { lm, provider } = await makeManager({ keyPair, storagePath, blindDenomination: denom, maxDays: 1, now: T0 })

  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 1 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  const secret = '33'.repeat(32)
  const { blinded, blindingFactor } = blind(secret)
  const issued = await lm.issueBlindVoucher({ quoteId: quote.quoteId, blinded }, T0 + 1000)
  t.is(issued.ok, true)
  const C = unblind(issued.blindSignature, blindingFactor, issued.mintPubkey)

  const first = await lm.redeemBlindVoucher({ secret, C, maxStorageBytes: GIB }, T0 + 2000)
  t.is(first.ok, true)
  await lm.destroy()

  const { lm: restarted } = await makeManager({
    keyPair,
    storagePath,
    blindDenomination: denom,
    maxDays: 1,
    now: T0 + 2 * DAY_MS
  })
  const again = await restarted.redeemBlindVoucher({ secret, C, maxStorageBytes: GIB }, T0 + 2 * DAY_MS)
  t.is(again.ok, false)
  t.is(again.error.split(':')[0], 'LEASE_REPLAY')
  await restarted.destroy()
})

test('blind token: denomination mismatch is rejected at issuance', async (t) => {
  const { blind } = await import('p2p-hiverelay/incentive/payment/blind-mint.js')
  const { lm, provider } = await makeManager({ blindDenomination: { maxStorageBytes: GIB, leaseDays: 30 } })
  // Quote for a DIFFERENT denomination (2 GiB) than configured (1 GiB).
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: 2 * GIB, leaseDays: 30 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  const { blinded } = blind('22'.repeat(32))
  const issued = await lm.issueBlindVoucher({ quoteId: quote.quoteId, blinded }, T0 + 1000)
  t.is(issued.ok, false)
  t.is(issued.error.split(':')[0], 'LEASE_BLIND_DENOMINATION_MISMATCH')
  await lm.destroy()
})

test('blind token: disabled by default (no denomination)', async (t) => {
  const { lm } = await makeManager()
  t.is(lm.blindMintInfo(), null, 'not advertised unless operator opts in')
  const res = await lm.redeemBlindVoucher({ secret: 'aa', C: 'bb', maxStorageBytes: GIB })
  t.is(res.ok, false)
  t.is(res.error.split(':')[0], 'LEASE_BLIND_DISABLED')
  await lm.destroy()
})

test('underpaid settlement is rejected', async (t) => {
  // Provider whose lookupInvoice reports a lower settled amount than quoted.
  const provider = new MockProvider()
  await provider.connect()
  provider.lookupInvoice = async () => ({ settled: true, amount: 1 })
  const { lm } = await makeManager({ provider })
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: 2 * GIB, leaseDays: 5 }, T0)
  t.is(quote.amountSats, 100)
  const res = await lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId }, T0 + 1000)
  t.is(res.ok, false)
  t.is(res.error.split(':')[0], 'LEASE_UNDERPAID')
  await lm.destroy()
})

test('forged quote (wrong relay key) fails signature check', async (t) => {
  const { lm: a, provider } = await makeManager()
  const quote = await a.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 1 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  // A different relay (different keyPair) must not accept A's quote.
  const { lm: b } = await makeManager()
  const res = await b.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId }, T0 + 1000)
  t.is(res.ok, false)
  t.is(res.error.split(':')[0], 'LEASE_BAD_QUOTE')
  await a.destroy(); await b.destroy()
})

test('replay-guard survives restart (persisted consumed set)', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'lease-'))
  const storagePath = join(dir, 'lease.json')
  const keyPair = makeKeyPair()
  const provider = new MockProvider()

  const { lm } = await makeManager({ keyPair, provider, storagePath })
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 3 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  const ok = await lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId }, T0 + 1000)
  t.is(ok.ok, true)
  await lm.destroy()

  // New manager, same storage + key + (re-shared) provider state. The
  // consumed paymentHash must have persisted → replay still blocked.
  const lm2 = new LeaseManager({ keyPair, provider, storagePath, satsPerGiBDay: 10 })
  // Reload at a time within the quote window (T0 + 1500); the consumed entry
  // must survive so the replay is still blocked.
  await lm2.start({ now: T0 + 1500 })
  t.is(lm2.getSummary().totalLeasedSats, 30, 'lease totals persisted across restart (1 GiB * 3d * 10)')
  t.is(lm2.getSummary().leaseCount, 1)
  const replay = await lm2.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId }, T0 + 2000)
  t.is(replay.ok, false)
  t.is(replay.error.split(':')[0], 'LEASE_REPLAY')
  await lm2.destroy()
})

test('replay-guard drops consumed entries once their quote has expired (finding #6)', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'lease-'))
  const storagePath = join(dir, 'lease.json')
  const keyPair = makeKeyPair()
  const provider = new MockProvider()
  const { lm } = await makeManager({ keyPair, provider, storagePath, quoteTtlMs: 1000 })
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 1 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  await lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId }, T0 + 100) // consumed; quote expiresAt = T0 + 1000
  await lm.destroy()

  const lm2 = new LeaseManager({ keyPair, provider, storagePath, satsPerGiBDay: 10 })
  await lm2.start({ now: T0 + 5000 }) // load PAST the quote window → entry pruned
  t.is(lm2._consumed.size, 0, 'expired consumed hash pruned on load (bounded memory, no replay risk)')
  await lm2.destroy()
})

// ── Shared gate: exemption can't be forged (review finding #3) ──
test('isLeaseExempt: only a VERIFIED custody intent exempts', (t) => {
  const reg = { getCustodyIntent: (id) => id === 'good' ? { publisherPubkey: 'PUB' } : null }
  // No custodyIntentId → charged.
  t.is(isLeaseExempt({ publisherPubkey: 'PUB' }, { seedingRegistry: reg }), false)
  // Forged storageClass alone → NOT exempt (the fix: declared class is untrusted).
  t.is(isLeaseExempt({ storageClass: 'temporary', publisherPubkey: 'PUB' }, { seedingRegistry: reg }), false)
  t.is(isLeaseExempt({ availabilityClass: 'atomic-handoff', publisherPubkey: 'PUB' }, { seedingRegistry: reg }), false)
  // custodyIntentId that doesn't resolve → NOT exempt.
  t.is(isLeaseExempt({ custodyIntentId: 'bogus', publisherPubkey: 'PUB' }, { seedingRegistry: reg }), false)
  // Resolves but publisher mismatch → NOT exempt.
  t.is(isLeaseExempt({ custodyIntentId: 'good', publisherPubkey: 'OTHER' }, { seedingRegistry: reg }), false)
  // Resolves + publisher matches → exempt.
  t.is(isLeaseExempt({ custodyIntentId: 'good', publisherPubkey: 'PUB' }, { seedingRegistry: reg }), true)
  // No registry → can't verify → NOT exempt.
  t.is(isLeaseExempt({ custodyIntentId: 'good', publisherPubkey: 'PUB' }, {}), false)
})

test('evaluateSeedLease: outcomes the transports map on', async (t) => {
  // No leaseManager → exempt (lease off / self-host).
  t.is((await evaluateSeedLease({ leaseManager: null, appKey: APPKEY, opts: {}, body: {} })).outcome, 'exempt')

  const { lm, provider } = await makeManager({ satsPerGiBDay: 10 })
  // Non-exempt, no proof, no leaseDays → quote (payment required), no quote body.
  const q0 = await evaluateSeedLease({ leaseManager: lm, appKey: APPKEY, opts: { maxStorage: GIB }, body: {} })
  t.is(q0.outcome, 'quote'); t.is(q0.status, 402); t.absent(q0.quote)
  // With leaseDays → a real quote.
  const q1 = await evaluateSeedLease({ leaseManager: lm, appKey: APPKEY, opts: { maxStorage: 2 * GIB }, body: { leaseDays: 5 } })
  t.is(q1.outcome, 'quote'); t.is(q1.quote.amountSats, 100); t.ok(q1.quote.quoteId)
  // Pay it, then resubmit the quoteId as proof → paid.
  provider.settleInvoice(provider.invoices[provider.invoices.length - 1].rHash)
  const paid = await evaluateSeedLease({ leaseManager: lm, appKey: APPKEY, opts: { maxStorage: 2 * GIB }, body: { paymentProof: { quoteId: q1.quote.quoteId } } })
  t.is(paid.outcome, 'paid'); t.ok(paid.retainUntil > Date.now())
  // Bad proof → error.
  const bad = await evaluateSeedLease({ leaseManager: lm, appKey: APPKEY, opts: { maxStorage: GIB }, body: { paymentProof: { quoteId: 'garbage' } } })
  t.is(bad.outcome, 'error'); t.is(bad.status, 402)
  await lm.destroy()
})

test('verifyLease binds maxStorage: cannot quote-cheap then seed-big', async (t) => {
  const { lm, provider } = await makeManager({ satsPerGiBDay: 10 })
  // Quote priced for 1 GiB.
  const quote = await lm.createQuote({ appKey: APPKEY, maxStorageBytes: GIB, leaseDays: 1 }, T0)
  provider.settleInvoice(provider.invoices[0].rHash)
  // Resubmit asking for 10 GiB → rejected.
  const big = await lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId, maxStorageBytes: 10 * GIB }, T0 + 1000)
  t.is(big.ok, false)
  t.is(big.error.split(':')[0], 'LEASE_STORAGE_EXCEEDS_QUOTE')
  // Equal storage is fine (fresh quote since the prior attempt didn't consume).
  const ok = await lm.verifyLease({ appKey: APPKEY, quoteId: quote.quoteId, maxStorageBytes: GIB }, T0 + 1000)
  t.is(ok.ok, true)
  await lm.destroy()
})

// ── Eviction must not shed a paid pin before its lease expires (finding #5) ──
test('assertPurgable: a live paid lease is LEASE_BOUND; expired is purgable', (t) => {
  const now = 1_000_000
  t.exception(() => assertPurgable({ durability: 0, leaseManaged: true, retainUntil: now + 10_000 }, now), /not evictable until/, 'live lease refused')
  t.execution(() => assertPurgable({ durability: 0, leaseManaged: true, retainUntil: now - 1 }, now), 'expired lease purgable')
  t.execution(() => assertPurgable({ durability: 0, leaseManaged: false, retainUntil: now + 10_000 }, now), 'non-lease entry unaffected')
})