OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / incentive / lease / index.js

683 lines · javascript ref:
// lease/index.js — paid pin-lease manager (relay side).
//
// Turns the relay's free, opaque-keyed seed/pin into a PAID retention lease
// for the NETWORK case: a publisher paying a relay they do NOT own to keep
// their (encrypted) Hyperdrive online. The economics that make HiveRelay
// sustainable from the demand side, complementing the founder-funded subsidy
// (incentive/subsidy/index.js) which pays operators from the supply side.
//
// Design (see the payment-for-seeding design workflow):
//   - Zero-custody. The relay mints a Lightning invoice on the OPERATOR's OWN
//     node (provider.createInvoice) and verifies it settled (lookupInvoice).
//     Funds land directly in the operator's node — the relay never holds a
//     payer balance. The ONLY durable artifact a payment creates is the lease
//     deadline (entry.retainUntil), enforced by the existing custody-expiry
//     sweep (RelayNode._runCustodyExpiryPass).
//   - Blind model untouched. Pricing keys on (appKey, declared maxStorage,
//     leaseDays) — nothing here reads or inspects content.
//   - Stateless quotes. A quote is a relay-signed, self-contained token
//     (Ed25519 over its body, same digest-then-detached-sign shape as
//     subsidy claims). The relay stores no pending-quote state; only the set
//     of CONSUMED payment hashes is persisted, as a replay-guard.
//   - Self-host stays free: this only gates the publisher-signed seed path
//     (POST /api/v1/seed); operator-API-key seeds (POST /seed) never reach it.
//     Custody / social-recovery seeds are exempt (founder-subsidized, not
//     commercial hosting).
//
// Persistence: <storage>/lease.json via the atomic tmp+rename pattern.

import { EventEmitter } from 'events'
import { randomBytes, createHash } from 'crypto'
import { readFile, writeFile, rename, unlink, mkdir } from 'fs/promises'
import { dirname } from 'path'
import b4a from 'b4a'
import { canonicalJson, signClaim, verifyClaim } from '../subsidy/index.js'
import { BlindMint } from '../payment/blind-mint.js'
import { buildKeyset, decodeToken } from '../payment/cashu.js'

const GIB = 1024 * 1024 * 1024
const DAY_MS = 86_400_000
export const LEASE_SCHEMA_VERSION = 1
const DEFAULT_SATS_PER_GIB_DAY = 10
const DEFAULT_QUOTE_TTL_MS = 60 * 60 * 1000 // 1h to pay; aligned with invoice expiry
const DEFAULT_MIN_DAYS = 1
const DEFAULT_MAX_DAYS = 3650
const DEFAULT_MAX_SATS_PER_GIB_DAY = 1_000_000
const MAX_CONSUMED_RETAINED = 50_000

const num = (x) => (Number.isFinite(x) ? x : 0)

export class LeaseManager extends EventEmitter {
  constructor (opts = {}) {
    super()
    if (!opts.keyPair || !opts.keyPair.publicKey || !opts.keyPair.secretKey) {
      throw new Error('LeaseManager: keyPair is required (quotes are signed)')
    }
    this.keyPair = opts.keyPair
    // PaymentProvider duck-type: needs createInvoice(amountSats, memo) ->
    // { bolt11, rHash } and lookupInvoice(rHash) -> { settled, amount } | null.
    this.provider = opts.provider || null
    this.storagePath = opts.storagePath || null
    this.satsPerGiBDay = Number.isFinite(opts.satsPerGiBDay) && opts.satsPerGiBDay >= 0
      ? opts.satsPerGiBDay
      : DEFAULT_SATS_PER_GIB_DAY
    this.quoteTtlMs = Number.isFinite(opts.quoteTtlMs) && opts.quoteTtlMs > 0
      ? opts.quoteTtlMs
      : DEFAULT_QUOTE_TTL_MS
    this.minDays = Number.isFinite(opts.minDays) && opts.minDays >= 1 ? Math.floor(opts.minDays) : DEFAULT_MIN_DAYS
    this.maxDays = Number.isFinite(opts.maxDays) && opts.maxDays >= 1 ? Math.floor(opts.maxDays) : DEFAULT_MAX_DAYS
    this.maxSatsPerGiBDay = Number.isFinite(opts.maxSatsPerGiBDay) && opts.maxSatsPerGiBDay > 0
      ? Math.floor(opts.maxSatsPerGiBDay)
      : DEFAULT_MAX_SATS_PER_GIB_DAY
    // Operator's own payout destination, informational only — the actual
    // payment instruction is the per-quote bolt11 minted on their node.
    this.payTo = typeof opts.payTo === 'string' ? opts.payTo : null

    // Replay-guard: consumed paymentHash (rHash) -> the quote's expiresAt. A
    // consumed hash can never pass the expiresAt gate again, so once its quote
    // has expired it is safe to drop; we ONLY ever evict already-expired
    // entries, never a still-live one (which would re-open a replay).
    this._consumed = new Map()
    // Payment hashes (and voucher serials) with a settlement check IN FLIGHT.
    // Claimed synchronously before the async lookupInvoice so two concurrent
    // redemptions of the same payment can't both pass the consumed-check, both
    // settle, and both grant a lease (TOCTOU double-spend). Released in finally.
    this._inflight = new Set()
    this.totalLeasedSats = 0
    this.leaseCount = 0
    this._persisting = Promise.resolve()

    // Optional Chaumian-blind mint (full payer↔content unlinkability). The mint
    // key is derived deterministically from this relay's keyPair, so it is
    // stable across restarts with no extra persistence. Blind tokens are
    // single-DENOMINATION: an operator enables them by setting
    // blindDenomination = { maxStorageBytes, leaseDays }; a paid quote for
    // exactly that denomination can be converted to an unlinkable token.
    // (Production: per-denomination keysets + a dedicated spent-secret store.)
    try {
      this.blindMint = opts.blindMint || new BlindMint({ keyPair: this.keyPair })
    } catch (_) {
      this.blindMint = null
    }
    this.blindDenomination = (opts.blindDenomination && Number.isFinite(opts.blindDenomination.maxStorageBytes) && Number.isFinite(opts.blindDenomination.leaseDays))
      ? { maxStorageBytes: Math.floor(opts.blindDenomination.maxStorageBytes), leaseDays: Math.floor(opts.blindDenomination.leaseDays) }
      : null
  }

  async start (opts = {}) {
    await this._load(opts.now)
    if (this.provider && !this.provider.connected && typeof this.provider.connect === 'function') {
      try { await this.provider.connect() } catch (err) { this.emit('provider-error', err) }
    }
  }

  async destroy () {
    await this._persist()
    if (this.provider && typeof this.provider.disconnect === 'function') {
      try { await this.provider.disconnect() } catch {}
    }
  }

  /** Byte-days price: ceil(maxStorageBytes / GiB) * leaseDays * satsPerGiBDay. */
  quoteSats (maxStorageBytes, leaseDays) {
    const gib = Math.max(1, Math.ceil(num(maxStorageBytes) / GIB))
    const days = Math.max(this.minDays, Math.min(this.maxDays, Math.floor(num(leaseDays) || this.minDays)))
    return { amountSats: gib * days * this.satsPerGiBDay, gib, days }
  }

  /**
   * Mint an invoice on the operator's node and return a self-contained,
   * relay-signed quote. The quoteId encodes { body, signature } so it can be
   * verified later with no server-side pending-quote state.
   */
  async createQuote ({ appKey, maxStorageBytes, leaseDays }, now = Date.now()) {
    if (!this.provider || typeof this.provider.createInvoice !== 'function') {
      throw new Error('LEASE_NO_PROVIDER: no payment provider configured')
    }
    const { amountSats, days } = this.quoteSats(maxStorageBytes, leaseDays)
    const memo = `hiverelay-lease:${String(appKey).slice(0, 16)}:${days}d`
    const inv = await this.provider.createInvoice(amountSats, memo, { expirySeconds: Math.floor(this.quoteTtlMs / 1000) })
    const paymentHash = inv.rHash || inv.paymentHash || inv.r_hash || null
    const bolt11 = inv.bolt11 || inv.invoice || inv.payment_request || null
    if (!paymentHash || !bolt11) throw new Error('LEASE_INVOICE_MALFORMED: provider returned no rHash/bolt11')

    const body = {
      v: LEASE_SCHEMA_VERSION,
      appKey: String(appKey).toLowerCase(),
      maxStorageBytes: Math.floor(num(maxStorageBytes)),
      leaseDays: days,
      amountSats,
      paymentHash,
      expiresAt: now + this.quoteTtlMs
    }
    const signature = b4a.toString(signClaim(this.keyPair, body), 'hex')
    const quoteId = b4a.toString(b4a.from(canonicalJson({ body, signature }), 'utf8'), 'base64url')

    this.emit('quote', { appKey: body.appKey, amountSats, leaseDays: days })
    return { amountSats, bolt11, quoteId, expiresAt: body.expiresAt, leaseDays: days, payTo: this.payTo }
  }

  /** Decode + verify the relay's own signature over a quoteId. */
  _decodeQuote (quoteId) {
    if (typeof quoteId !== 'string' || quoteId.length < 1 || quoteId.length > 8192) return null
    let parsed
    try {
      parsed = JSON.parse(b4a.toString(b4a.from(quoteId, 'base64url'), 'utf8'))
    } catch { return null }
    if (!parsed || typeof parsed !== 'object' || !parsed.body || typeof parsed.signature !== 'string') return null
    const ok = verifyClaim({
      body: parsed.body,
      relayPubkey: this.keyPair.publicKey,
      signature: b4a.from(parsed.signature, 'hex')
    })
    if (!ok) return null
    return parsed.body
  }

  /**
   * Run `fn` under a single-use lock on `key`, so a CONCURRENT redemption of
   * the same payment/voucher can't slip through the (async) settlement window.
   * Returns the REPLAY error if the key is already consumed or in flight.
   * @param {string} key  paymentHash or voucher serial guard key
   * @param {() => Promise<object>} fn
   */
  async _withRedemptionLock (key, fn) {
    if (this._consumed.has(key) || this._inflight.has(key)) {
      return { ok: false, error: 'LEASE_REPLAY: this payment was already redeemed', status: 402 }
    }
    this._inflight.add(key)
    try {
      return await fn()
    } finally {
      this._inflight.delete(key)
    }
  }

  /**
   * Verify a paid lease for a resubmitted seed request.
   * @returns {{ ok: true, paidUntil, amountSats, leaseDays } | { ok: false, error, status }}
   */
  async verifyLease ({ appKey, quoteId, maxStorageBytes }, now = Date.now()) {
    const body = this._decodeQuote(quoteId)
    if (!body) return { ok: false, error: 'LEASE_BAD_QUOTE: quote missing or signature invalid', status: 402 }
    if (String(appKey).toLowerCase() !== body.appKey) {
      return { ok: false, error: 'LEASE_QUOTE_APPKEY_MISMATCH: quote is for a different appKey', status: 402 }
    }
    // Bind storage: the seed can't ask for MORE than the quote was priced for
    // (else quote-cheap-at-1GiB, then seed-big). Equal/less is fine.
    if (Number.isFinite(maxStorageBytes) && Math.floor(maxStorageBytes) > body.maxStorageBytes) {
      return { ok: false, error: 'LEASE_STORAGE_EXCEEDS_QUOTE: requested maxStorage exceeds the quoted amount', status: 402 }
    }
    if (!Number.isFinite(body.expiresAt) || body.expiresAt <= now) {
      return { ok: false, error: 'LEASE_QUOTE_EXPIRED: request a fresh quote', status: 402 }
    }
    if (!this.provider || typeof this.provider.lookupInvoice !== 'function') {
      return { ok: false, error: 'LEASE_NO_PROVIDER: cannot verify payment', status: 503 }
    }
    return this._withRedemptionLock(body.paymentHash, async () => {
      let status
      try {
        status = await this.provider.lookupInvoice(body.paymentHash)
      } catch (err) {
        return { ok: false, error: 'LEASE_LOOKUP_FAILED: ' + (err.message || String(err)), status: 503 }
      }
      if (!status || !status.settled) {
        return { ok: false, error: 'LEASE_UNPAID: invoice not settled yet', status: 402 }
      }
      if (num(status.amount) < body.amountSats) {
        return { ok: false, error: 'LEASE_UNDERPAID: settled amount below quote', status: 402 }
      }

      // Overflow trim: drop ONLY entries whose quote already expired (those can
      // never pass the expiresAt gate again, so dropping them cannot re-open a
      // replay). If still at cap with every entry live, refuse rather than evict
      // a still-redeemable hash.
      if (this._consumed.size >= MAX_CONSUMED_RETAINED) {
        for (const [h, exp] of this._consumed) {
          if (!Number.isFinite(exp) || exp <= now) this._consumed.delete(h)
        }
        if (this._consumed.size >= MAX_CONSUMED_RETAINED) {
          return { ok: false, error: 'LEASE_GUARD_FULL: replay-guard saturated; retry shortly', status: 503 }
        }
      }

      // Consume + record. The replay-guard MUST be durable before we admit the
      // lease — if the write fails, roll back so we never admit an un-guarded
      // (replayable) lease.
      this._consumed.set(body.paymentHash, body.expiresAt)
      this.totalLeasedSats += body.amountSats
      this.leaseCount += 1
      try {
        await this._persist()
      } catch (err) {
        this._consumed.delete(body.paymentHash)
        this.totalLeasedSats -= body.amountSats
        this.leaseCount -= 1
        this.emit('persist-error', err)
        return { ok: false, error: 'LEASE_PERSIST_FAILED: could not durably record payment; retry', status: 503 }
      }

      const paidUntil = now + body.leaseDays * DAY_MS
      this.emit('lease-paid', { appKey: body.appKey, amountSats: body.amountSats, leaseDays: body.leaseDays, paidUntil })
      return { ok: true, paidUntil, amountSats: body.amountSats, leaseDays: body.leaseDays }
    })
  }

  // ─── Bearer vouchers (opt-in, payer-unlinkable to content) ─────────
  //
  // The standard quote BINDS the payment to a specific appKey (body.appKey,
  // checked in verifyLease). That binding lets the operator link "who paid"
  // to "what content" — the one linkage the rest of the blind design avoids.
  //
  // A bearer voucher breaks it: the payer buys a voucher (priced for
  // maxStorageBytes × leaseDays) that carries a RANDOM serial and NO appKey.
  // Later they redeem it to seed ANY appKey. verifyBearer never learns which
  // payment funded which content. Replay is guarded by serial, exactly like
  // paymentHash for quotes.
  //
  // Honesty bound: this decouples payment↔appKey (the primary leak). It does
  // NOT by itself blind issuance — an operator could still correlate the
  // invoice it settled at issue() time with the serial it signed. Full
  // issue↔redeem unlinkability requires a blind-signature/ecash provider
  // (e.g. Cashu) issuing the serial; such a provider can be plugged in by
  // having it mint the voucher body and this relay simply honor its
  // signatures. The seam is intentional and documented.

  /**
   * Issue a bearer voucher from an ALREADY-PAID quote. The quote proves
   * settlement (and is consumed here so it can't also be spent as a direct
   * lease); the returned voucher carries only a random serial + the byte-days
   * envelope — never the appKey, never the paymentHash.
   *
   * @returns {{ ok:true, voucherId, amountSats, leaseDays, expiresAt } | { ok:false, error, status }}
   */
  async issueBearerVoucher ({ quoteId }, now = Date.now()) {
    const body = this._decodeQuote(quoteId)
    if (!body) return { ok: false, error: 'LEASE_BAD_QUOTE: quote missing or signature invalid', status: 402 }
    if (!Number.isFinite(body.expiresAt) || body.expiresAt <= now) {
      return { ok: false, error: 'LEASE_QUOTE_EXPIRED: request a fresh quote', status: 402 }
    }
    if (!this.provider || typeof this.provider.lookupInvoice !== 'function') {
      return { ok: false, error: 'LEASE_NO_PROVIDER: cannot verify payment', status: 503 }
    }
    return this._withRedemptionLock(body.paymentHash, async () => {
      let status
      try {
        status = await this.provider.lookupInvoice(body.paymentHash)
      } catch (err) {
        return { ok: false, error: 'LEASE_LOOKUP_FAILED: ' + (err.message || String(err)), status: 503 }
      }
      if (!status || !status.settled) return { ok: false, error: 'LEASE_UNPAID: invoice not settled yet', status: 402 }
      if (num(status.amount) < body.amountSats) return { ok: false, error: 'LEASE_UNDERPAID: settled amount below quote', status: 402 }

      // Consume the paymentHash so it can't be redeemed twice (as a lease or a
      // second voucher). The voucher's own replay-guard is its serial.
      this._consumed.set(body.paymentHash, body.expiresAt)
      // Voucher lives for the lease window (generous): payer may seed later.
      const voucherExpiresAt = now + Math.max(this.maxDays, body.leaseDays) * DAY_MS
      const voucherBody = {
        v: LEASE_SCHEMA_VERSION,
        kind: 'bearer',
        serial: b4a.toString(randomBytes(32), 'hex'),
        maxStorageBytes: body.maxStorageBytes,
        leaseDays: body.leaseDays,
        amountSats: body.amountSats,
        expiresAt: voucherExpiresAt
      }
      const signature = b4a.toString(signClaim(this.keyPair, voucherBody), 'hex')
      const voucherId = b4a.toString(b4a.from(canonicalJson({ body: voucherBody, signature }), 'utf8'), 'base64url')
      try {
        await this._persist()
      } catch (err) {
        this._consumed.delete(body.paymentHash)
        this.emit('persist-error', err)
        return { ok: false, error: 'LEASE_PERSIST_FAILED: could not durably record issuance; retry', status: 503 }
      }
      this.emit('bearer-issued', { amountSats: body.amountSats, leaseDays: body.leaseDays })
      return { ok: true, voucherId, amountSats: body.amountSats, leaseDays: body.leaseDays, expiresAt: voucherExpiresAt }
    })
  }

  /** Decode + verify the relay's own signature over a bearer voucherId. */
  _decodeVoucher (voucherId) {
    if (typeof voucherId !== 'string' || voucherId.length < 1 || voucherId.length > 8192) return null
    let parsed
    try {
      parsed = JSON.parse(b4a.toString(b4a.from(voucherId, 'base64url'), 'utf8'))
    } catch { return null }
    if (!parsed || typeof parsed !== 'object' || !parsed.body || typeof parsed.signature !== 'string') return null
    if (parsed.body.kind !== 'bearer' || typeof parsed.body.serial !== 'string') return null
    const ok = verifyClaim({
      body: parsed.body,
      relayPubkey: this.keyPair.publicKey,
      signature: b4a.from(parsed.signature, 'hex')
    })
    return ok ? parsed.body : null
  }

  /**
   * Redeem a bearer voucher for a lease. Grants the lease WITHOUT inspecting
   * the appKey — payment and content stay unlinked. Single-use via serial.
   * @returns {{ ok:true, paidUntil, amountSats, leaseDays } | { ok:false, error, status }}
   */
  async verifyBearer ({ voucherId, maxStorageBytes }, now = Date.now()) {
    const body = this._decodeVoucher(voucherId)
    if (!body) return { ok: false, error: 'LEASE_BAD_VOUCHER: voucher missing or signature invalid', status: 402 }
    if (Number.isFinite(maxStorageBytes) && Math.floor(maxStorageBytes) > body.maxStorageBytes) {
      return { ok: false, error: 'LEASE_STORAGE_EXCEEDS_VOUCHER: requested maxStorage exceeds the voucher', status: 402 }
    }
    if (!Number.isFinite(body.expiresAt) || body.expiresAt <= now) {
      return { ok: false, error: 'LEASE_VOUCHER_EXPIRED', status: 402 }
    }
    const guardKey = 'bearer:' + body.serial
    if (this._consumed.has(guardKey)) {
      return { ok: false, error: 'LEASE_REPLAY: voucher already redeemed', status: 402 }
    }
    if (this._consumed.size >= MAX_CONSUMED_RETAINED) {
      for (const [h, exp] of this._consumed) {
        if (!Number.isFinite(exp) || exp <= now) this._consumed.delete(h)
      }
      if (this._consumed.size >= MAX_CONSUMED_RETAINED) {
        return { ok: false, error: 'LEASE_GUARD_FULL: replay-guard saturated; retry shortly', status: 503 }
      }
    }
    this._consumed.set(guardKey, body.expiresAt)
    this.totalLeasedSats += body.amountSats
    this.leaseCount += 1
    try {
      await this._persist()
    } catch (err) {
      this._consumed.delete(guardKey)
      this.totalLeasedSats -= body.amountSats
      this.leaseCount -= 1
      this.emit('persist-error', err)
      return { ok: false, error: 'LEASE_PERSIST_FAILED: could not durably record redemption; retry', status: 503 }
    }
    const paidUntil = now + body.leaseDays * DAY_MS
    this.emit('lease-paid', { bearer: true, amountSats: body.amountSats, leaseDays: body.leaseDays, paidUntil })
    return { ok: true, paidUntil, amountSats: body.amountSats, leaseDays: body.leaseDays }
  }

  // ─── Blind tokens (Cashu NUT-00, fully unlinkable — opt-in) ────────
  //
  // Strongest tier: the payer blinds their own secret, so the mint cannot link
  // the settled invoice to the redeemed token. Backed by a real secp256k1 Cashu
  // mint (incentive/payment/blind-mint.js + cashu.js), so a token IS a standard
  // Cashu Proof / `cashuA` token. Enabled only when blindDenomination is set.

  /** Sats price of the configured blind denomination (the Cashu amount). */
  _denomSats () {
    return this.quoteSats(this.blindDenomination.maxStorageBytes, this.blindDenomination.leaseDays).amountSats
  }

  /** The relay's NUT-01/02 keyset for the blind denomination (memoized). */
  _blindKeyset () {
    if (!this._keyset) this._keyset = buildKeyset(this.blindMint, this._denomSats(), 'sat')
    return this._keyset
  }

  /**
   * Mint advertisement for the capability doc, or null when disabled. Includes
   * a real Cashu keyset (id + amount->pubkey) so wallets can recognise it.
   */
  blindMintInfo () {
    if (!this.blindMint || !this.blindDenomination) return null
    return {
      pubkey: this.blindMint.publicKey,
      denomination: { ...this.blindDenomination },
      keyset: this._blindKeyset() // { id, unit, keys: { [sats]: pubkey } }
    }
  }

  /**
   * Convert a settled quote (priced for exactly the configured denomination)
   * into a blind signature over the payer's blinded message. The relay never
   * sees the token secret.
   * @returns {{ ok:true, blindSignature, mintPubkey, leaseDays, maxStorageBytes } | { ok:false, error, status }}
   */
  async issueBlindVoucher ({ quoteId, blinded }, now = Date.now()) {
    if (!this.blindMint || !this.blindDenomination) {
      return { ok: false, error: 'LEASE_BLIND_DISABLED: operator has not enabled blind tokens', status: 400 }
    }
    const body = this._decodeQuote(quoteId)
    if (!body) return { ok: false, error: 'LEASE_BAD_QUOTE: quote missing or signature invalid', status: 402 }
    // Denomination must match exactly — a single-denomination mint can't sign
    // for an arbitrary (storage, days) without leaking it into the token.
    if (body.maxStorageBytes !== this.blindDenomination.maxStorageBytes || body.leaseDays !== this.blindDenomination.leaseDays) {
      return { ok: false, error: 'LEASE_BLIND_DENOMINATION_MISMATCH: quote does not match the blind denomination', status: 402 }
    }
    if (!Number.isFinite(body.expiresAt) || body.expiresAt <= now) {
      return { ok: false, error: 'LEASE_QUOTE_EXPIRED: request a fresh quote', status: 402 }
    }
    if (!this.provider || typeof this.provider.lookupInvoice !== 'function') {
      return { ok: false, error: 'LEASE_NO_PROVIDER: cannot verify payment', status: 503 }
    }
    return this._withRedemptionLock(body.paymentHash, async () => {
      let status
      try {
        status = await this.provider.lookupInvoice(body.paymentHash)
      } catch (err) {
        return { ok: false, error: 'LEASE_LOOKUP_FAILED: ' + (err.message || String(err)), status: 503 }
      }
      if (!status || !status.settled) return { ok: false, error: 'LEASE_UNPAID: invoice not settled yet', status: 402 }
      if (num(status.amount) < body.amountSats) return { ok: false, error: 'LEASE_UNDERPAID: settled amount below quote', status: 402 }

      let blindSignature
      try {
        blindSignature = this.blindMint.blindSign(blinded)
      } catch (err) {
        return { ok: false, error: 'LEASE_BLIND_SIGN_FAILED: ' + (err.message || String(err)), status: 400 }
      }
      // Consume the funding payment so it can't also be spent elsewhere.
      this._consumed.set(body.paymentHash, body.expiresAt)
      try {
        await this._persist()
      } catch (err) {
        this._consumed.delete(body.paymentHash)
        this.emit('persist-error', err)
        return { ok: false, error: 'LEASE_PERSIST_FAILED: could not durably record issuance; retry', status: 503 }
      }
      this.emit('blind-issued', { amountSats: body.amountSats, leaseDays: body.leaseDays })
      const keyset = this._blindKeyset()
      // blindSignature is the NUT-00 C_; the payer unblinds it and assembles a
      // Proof { amount, id, secret, C }. amount/keysetId let them build a token.
      return {
        ok: true,
        blindSignature,
        mintPubkey: this.blindMint.publicKey,
        keysetId: keyset.id,
        amount: this._denomSats(),
        leaseDays: body.leaseDays,
        maxStorageBytes: body.maxStorageBytes
      }
    })
  }

  /**
   * Convenience: redeem a `cashuA` token string (decodes the proof, checks the
   * keyset id + amount match this mint's denomination, then redeems it).
   * Backwards-compatible call shapes:
   *   redeemCashuToken(token, now)
   *   redeemCashuToken(token, { maxStorageBytes }, now)
   * @returns {{ ok:true, paidUntil, leaseDays } | { ok:false, error, status }}
   */
  async redeemCashuToken (token, opts = {}, now = Date.now()) {
    if (Number.isFinite(opts)) {
      now = opts
      opts = {}
    }
    if (!opts || typeof opts !== 'object') opts = {}
    if (!this.blindMint || !this.blindDenomination) {
      return { ok: false, error: 'LEASE_BLIND_DISABLED', status: 400 }
    }
    let proof
    try {
      const decoded = decodeToken(token)
      proof = decoded.proofs && decoded.proofs[0]
    } catch (err) {
      return { ok: false, error: 'LEASE_BLIND_BAD_TOKEN: ' + (err.message || String(err)), status: 400 }
    }
    if (!proof || typeof proof.secret !== 'string' || typeof proof.C !== 'string') {
      return { ok: false, error: 'LEASE_BLIND_BAD_TOKEN: token has no usable proof', status: 400 }
    }
    const keyset = this._blindKeyset()
    if (proof.id && proof.id !== keyset.id) {
      return { ok: false, error: 'LEASE_BLIND_WRONG_KEYSET: token is for a different keyset', status: 402 }
    }
    if (Number.isFinite(proof.amount) && proof.amount !== this._denomSats()) {
      return { ok: false, error: 'LEASE_BLIND_WRONG_AMOUNT: token amount != denomination', status: 402 }
    }
    const maxStorageBytes = Number.isFinite(opts.maxStorageBytes) ? opts.maxStorageBytes : opts.maxStorage
    return this.redeemBlindVoucher({ secret: proof.secret, C: proof.C, maxStorageBytes }, now)
  }

  /**
   * Redeem a blind token (secret, C) for a lease at the configured
   * denomination. Single-use via a spent-secret guard. The relay learns
   * nothing tying this token to which payment funded it.
   * @returns {{ ok:true, paidUntil, leaseDays } | { ok:false, error, status }}
   */
  async redeemBlindVoucher ({ secret, C, maxStorageBytes }, now = Date.now()) {
    if (!this.blindMint || !this.blindDenomination) {
      return { ok: false, error: 'LEASE_BLIND_DISABLED', status: 400 }
    }
    if (typeof secret !== 'string' || typeof C !== 'string') {
      return { ok: false, error: 'LEASE_BLIND_BAD_TOKEN: secret and C required', status: 400 }
    }
    if (Number.isFinite(maxStorageBytes) && Math.floor(maxStorageBytes) > this.blindDenomination.maxStorageBytes) {
      return { ok: false, error: 'LEASE_STORAGE_EXCEEDS_VOUCHER: exceeds the blind denomination', status: 402 }
    }
    if (!this.blindMint.verifyToken(secret, C)) {
      return { ok: false, error: 'LEASE_BLIND_INVALID: token does not verify against the mint', status: 402 }
    }
    // Double-spend guard keyed on a hash of the secret. Blind tokens carry no
    // expiry of their own, so the spent marker must not expire either.
    const guardKey = 'blind:' + createHash('sha256').update(secret).digest('hex').slice(0, 32)
    const guardExpiry = Number.MAX_SAFE_INTEGER
    if (this._consumed.has(guardKey)) {
      return { ok: false, error: 'LEASE_REPLAY: blind token already redeemed', status: 402 }
    }
    if (this._consumed.size >= MAX_CONSUMED_RETAINED) {
      for (const [h, exp] of this._consumed) {
        if (!Number.isFinite(exp) || exp <= now) this._consumed.delete(h)
      }
      if (this._consumed.size >= MAX_CONSUMED_RETAINED) {
        return { ok: false, error: 'LEASE_GUARD_FULL: replay-guard saturated; retry shortly', status: 503 }
      }
    }
    const denomSats = this.quoteSats(this.blindDenomination.maxStorageBytes, this.blindDenomination.leaseDays).amountSats
    this._consumed.set(guardKey, guardExpiry)
    this.totalLeasedSats += denomSats
    this.leaseCount += 1
    try {
      await this._persist()
    } catch (err) {
      this._consumed.delete(guardKey)
      this.totalLeasedSats -= denomSats
      this.leaseCount -= 1
      this.emit('persist-error', err)
      return { ok: false, error: 'LEASE_PERSIST_FAILED: could not durably record redemption; retry', status: 503 }
    }
    const paidUntil = now + this.blindDenomination.leaseDays * DAY_MS
    this.emit('lease-paid', { blind: true, leaseDays: this.blindDenomination.leaseDays, paidUntil })
    return { ok: true, paidUntil, leaseDays: this.blindDenomination.leaseDays }
  }

  getSummary () {
    return {
      enabled: true,
      satsPerGiBDay: this.satsPerGiBDay,
      minDays: this.minDays,
      maxDays: this.maxDays,
      payTo: this.payTo,
      totalLeasedSats: Math.floor(this.totalLeasedSats),
      leaseCount: this.leaseCount,
      provider: this.provider ? (this.provider.constructor && this.provider.constructor.name) || 'unknown' : null,
      providerConnected: !!(this.provider && this.provider.connected)
    }
  }

  setRate (satsPerGiBDay) {
    const rate = this._normalizeRate(satsPerGiBDay)
    this.satsPerGiBDay = rate
    this._persist().catch((err) => { this.emit('persist-error', err) })
    this.emit('rate', this.satsPerGiBDay)
    return this.satsPerGiBDay
  }

  async setRateDurable (satsPerGiBDay) {
    const rate = this._normalizeRate(satsPerGiBDay)
    const previous = this.satsPerGiBDay
    this.satsPerGiBDay = rate
    try {
      await this._persist()
    } catch (err) {
      this.satsPerGiBDay = previous
      this.emit('persist-error', err)
      const wrapped = new Error('failed to persist lease rate')
      wrapped.code = 'LEASE_RATE_PERSIST_FAILED'
      wrapped.cause = err
      throw wrapped
    }
    this.emit('rate', this.satsPerGiBDay)
    return this.satsPerGiBDay
  }

  _normalizeRate (satsPerGiBDay) {
    if (!Number.isFinite(satsPerGiBDay) || satsPerGiBDay < 0) throw new Error('satsPerGiBDay must be a non-negative number')
    if (satsPerGiBDay > this.maxSatsPerGiBDay) throw new Error('satsPerGiBDay exceeds maximum (' + this.maxSatsPerGiBDay + ')')
    return Math.floor(satsPerGiBDay)
  }

  // ─── Persistence (atomic tmp+rename) ───────────────────────────────

  async _load (now = Date.now()) {
    if (!this.storagePath) return
    try {
      const data = JSON.parse(await readFile(this.storagePath, 'utf8'))
      if (data.schemaVersion !== LEASE_SCHEMA_VERSION) return
      // consumed is [ [paymentHash, expiresAt], ... ]. Drop already-expired on
      // load (they can never be replayed) and cap the retained set.
      const pairs = (Array.isArray(data.consumed) ? data.consumed : [])
        .filter(p => Array.isArray(p) && typeof p[0] === 'string' && Number.isFinite(p[1]) && p[1] > now)
        .slice(-MAX_CONSUMED_RETAINED)
      this._consumed = new Map(pairs)
      this.totalLeasedSats = num(data.totalLeasedSats)
      this.leaseCount = num(data.leaseCount)
      if (Number.isFinite(data.satsPerGiBDay) && data.satsPerGiBDay >= 0) this.satsPerGiBDay = data.satsPerGiBDay
    } catch {
      // Missing/corrupt -> start fresh. A lost replay-guard only risks at most
      // one extra redemption per still-live quote window, never silent loss.
    }
  }

  async _persist () {
    if (!this.storagePath) return
    // Propagate write failures to awaiting callers (verifyLease rolls back on
    // failure) WITHOUT poisoning the serialized chain for later writes.
    const next = this._persisting.then(() => this._write(), () => this._write())
    this._persisting = next.catch(() => {})
    return next
  }

  async _write () {
    const tmp = this.storagePath + '.tmp'
    const data = JSON.stringify({
      schemaVersion: LEASE_SCHEMA_VERSION,
      consumed: Array.from(this._consumed.entries()),
      totalLeasedSats: this.totalLeasedSats,
      leaseCount: this.leaseCount,
      satsPerGiBDay: this.satsPerGiBDay
    })
    try {
      await mkdir(dirname(this.storagePath), { recursive: true })
      await writeFile(tmp, data)
      await rename(tmp, this.storagePath)
    } catch (err) {
      try { await unlink(tmp) } catch {}
      this.emit('persist-error', err)
      throw err
    }
  }
}