aboutsummaryrefslogtreecommitdiffstats
path: root/routes/preimages.ts
blob: 49673d17d4a96119d5d601341c8d1654d294a7ca (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import { createHash } from "node:crypto";
import { db, g } from "$lib/db";
import { parseEntry } from "$lib/invoices";
import { bail, fail } from "$lib/utils";

// Merchants pre-load payment preimages here, then have buyers create invoices
// with usePreimage: true — each such invoice consumes the next queued preimage
// so its payment hash is sha256(preimage). Once paid, the preimage is exposed
// on the invoice record, letting a static storefront sell decryption secrets
// with no backend of its own. An optional per-batch price — sats, or a fiat
// amount converted at invoice-creation time — is enforced at invoice creation;
// creation is unauthenticated, so without it a buyer could name their own
// price for the secret.

const MAX_QUEUE = 1000;
const key = (uid) => `${uid}:preimages`;
const sha256 = (hex) =>
  createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex");

export default {
  async add(req, res) {
    try {
      const { preimages, price = null, fiat = null } = req.body;
      let { currency = null } = req.body;
      if (!Array.isArray(preimages) || !preimages.length)
        fail("preimages array required");
      if (
        !preimages.every(
          (p) => typeof p === "string" && /^[0-9a-f]{64}$/i.test(p),
        )
      )
        fail("preimages must be 64-character hex strings");
      if (price !== null && fiat !== null)
        fail("specify price (sats) or fiat, not both");
      if (price !== null && (!Number.isInteger(price) || price <= 0))
        fail("price must be a positive integer amount of sats");
      if (fiat !== null) {
        if (typeof fiat !== "number" || !(fiat > 0))
          fail("fiat must be a positive number");
        currency ||= req.user.currency;
        const rates = await g("rates");
        if (!rates?.[currency]) fail(`unsupported currency ${currency}`);
      } else {
        currency = null;
      }

      const k = key(req.user.id);
      const existing = await db.lRange(k, 0, -1);
      const seen = new Set(existing.map((e) => parseEntry(e).preimage));
      const fresh = [];
      for (let p of preimages) {
        p = p.toLowerCase();
        if (seen.has(p)) continue;
        seen.add(p);
        fresh.push(p);
      }

      if (existing.length + fresh.length > MAX_QUEUE)
        fail(`queue limited to ${MAX_QUEUE} preimages`);
      if (fresh.length)
        await db.rPush(
          k,
          fresh.map((preimage) =>
            JSON.stringify({ preimage, price, fiat, currency }),
          ),
        );

      res.send({
        queued: existing.length + fresh.length,
        added: fresh.map((preimage) => ({
          preimage,
          hash: sha256(preimage),
          price,
          fiat,
          currency,
        })),
      });
    } catch (e) {
      bail(res, e.message);
    }
  },

  async list(req, res) {
    try {
      const entries = await db.lRange(key(req.user.id), 0, -1);
      res.send({
        queued: entries.length,
        preimages: entries.map((e) => {
          const { preimage, price, fiat, currency } = parseEntry(e);
          return { preimage, hash: sha256(preimage), price, fiat, currency };
        }),
      });
    } catch (e) {
      bail(res, e.message);
    }
  },

  async clear(req, res) {
    try {
      await db.del(key(req.user.id));
      res.send({ queued: 0 });
    } catch (e) {
      bail(res, e.message);
    }
  },
};