From 153d0a115d84ad3af6e975acfdbfc2e9a5f905cd Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Wed, 23 Sep 2026 15:45:44 +0200 Subject: [PATCH] 01a0ae9e - Pay a one-time Welcome gift of 1 USD (#47) * Pay a one-time Welcome gift of 1 USD kind welcome pays once per Lightning Address, comment Welcome, on the forum post messageId. Daily roster and grants are not required. * Keep single quotes on the welcome gift path Restore repo quote style and add welcome ping and runDay tests. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- README.md | 22 +- src/__tests__/run.test.ts | 700 +++++++++++++++++++++++------------ src/__tests__/server.test.ts | 193 +++++++++- src/__tests__/state.test.ts | 80 +++- src/run.ts | 102 +++-- src/server.ts | 207 ++++++++--- src/state.ts | 25 +- 7 files changed, 993 insertions(+), 336 deletions(-) diff --git a/README.md b/README.md index e1cdb4b..a4fa2fb 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ Ping-triggered Lightning gift payouts plus a tiny HTTP dashboard. This process * 2. LNDHub `payinvoice` on lightning.space 3. `POST {GIFTS_API_URL}/invoices/proof` with the **preimage** (`sha256` = payment hash) -Recipient amounts are **USD**. Each daily payout (`POST /ping` with `kind` omitted or `"daily"`, or CLI) reads the live roster `STATE_DIR/recipients.json`, fetches Coinbase BTC-USD spot, and pays `round(usd / btcUsd * 1e8)` sats. `kind: "moderator"` pays the amount listed for that address on the moderator roster (see Moderator below). Missing or unusable spot, or a conversion under 1 sat, is fail-closed (exit `3`). The optional `amountSats` field in a seed JSON is a snapshot only — the process does not read it. +Recipient amounts are **USD**. Each daily payout (`POST /ping` with `kind` omitted or `"daily"`, or CLI) reads the live roster `STATE_DIR/recipients.json`, fetches Coinbase BTC-USD spot, and pays `round(usd / btcUsd * 1e8)` sats. `kind: "moderator"` pays the amount listed for that address on the moderator roster (see Moderator below). `kind: "welcome"` pays **1 USD** once per Lightning Address (see Welcome below). Missing or unusable spot, or a conversion under 1 sat, is fail-closed (exit `3`). The optional `amountSats` field in a seed JSON is a snapshot only — the process does not read it. -The long-running server (`bun src/server.ts`) serves the dashboard, `POST /ping`, and `/healthz`. It does **not** pay the roster at UTC midnight, on boot catch-up, or on a 15-minute retry. There is no in-process midnight scheduler. A UTC day still starts at 00:00 UTC (existing JSONL day files). A daily payout (`kind` omitted or `"daily"`) happens when the 21.gifts API `POST`s `/ping` for a Lightning Address that is on the live roster — or unlisted with `GET /invoices/eligible` grant `status` `admitted` or `trial` (1 USD, new-member handbook cap) — has no `paid` or persisted `failed` row for today, and today's JSONL has no `uncertain` row for that address, any other live recipient, or `*halt*`. `kind: "moderator"` follows the Moderator section. Replies never reach this process; the API pings only for top-level posts. `SPEND_LIVE=true` pays; otherwise the queued run is dry-run. +The long-running server (`bun src/server.ts`) serves the dashboard, `POST /ping`, and `/healthz`. It does **not** pay the roster at UTC midnight, on boot catch-up, or on a 15-minute retry. There is no in-process midnight scheduler. A UTC day still starts at 00:00 UTC (existing JSONL day files). A daily payout (`kind` omitted or `"daily"`) happens when the 21.gifts API `POST`s `/ping` for a Lightning Address that is on the live roster — or unlisted with `GET /invoices/eligible` grant `status` `admitted` or `trial` (1 USD, new-member handbook cap) — has no `paid` or persisted `failed` row for today, and today's JSONL has no `uncertain` row for that address, any other live recipient, or `*halt*`. `kind: "moderator"` follows the Moderator section. `kind: "welcome"` follows the Welcome section. Replies never reach this process; the API pings only for top-level posts. `SPEND_LIVE=true` pays; otherwise the queued run is dry-run. `GET /` is the only UI page. It always shows the Spend block: @@ -28,7 +28,7 @@ Live roster: `STATE_DIR/recipients.json` (`comment`, `recipients`, `moderators`, Auth: `Authorization: Bearer` matching `GIFTS_API_TOKEN`. Missing or mismatch → `401` `{ "error": "Unauthorized" }`. No Origin / same-origin check. -Two kinds. `kind` omitted or `"daily"` is the living-room gift. `"kind": "moderator"` is the moderator stipend: a USD amount per address from the `moderators` list of the live file, independent of the daily roster. Invalid JSON or missing `address` → `400` `{ "error": "Expected a JSON body with address" }`. A `kind` that is neither `"daily"` nor `"moderator"` → `400` `{ "error": "Expected a JSON body with address and kind" }`. The address is trimmed; it must look like `name@domain`, else `400` `{ "error": "Not a valid Lightning Address (expected name@domain)" }`. +Three kinds. `kind` omitted or `"daily"` is the living-room gift. `"kind": "moderator"` is the moderator stipend: a USD amount per address from the `moderators` list of the live file, independent of the daily roster. `"kind": "welcome"` is a once-per-Lightning-Address lifetime 1 USD gift (see Welcome below). Invalid JSON or missing `address` → `400` `{ "error": "Expected a JSON body with address" }`. A `kind` that is neither `"daily"` nor `"moderator"` nor `"welcome"` → `400` `{ "error": "Expected a JSON body with address and kind" }`. The address is trimmed; it must look like `name@domain`, else `400` `{ "error": "Not a valid Lightning Address (expected name@domain)" }`. ### Daily @@ -50,6 +50,12 @@ State is a separate JSONL: `STATE_DIR/YYYY-MM-DD.moderator.jsonl` and `YYYY-MM-D Amount is the listed `amountUsd` with comment `21gifts moderator`. Same in-process payout gate as daily (one run at a time). Scheduler / catch-up stay no-ops; this is ping-triggered only. Otherwise `202` `{ "status": "accepted" }` without waiting for Lightning. +### Welcome + +Body: JSON `{ "address": string, "kind": "welcome", "messageId": string }`. `messageId` is a required UUID (same 400 as daily): missing or invalid → `400` `{ "error": "Expected a JSON body with address and messageId" }`. A stray `groupMessageId` is ignored. After address parsing, load the live file for the daily `paymentsEnabled` switch only; an unreadable list → `500` `{ "error": "Recipient list is unreadable" }`. Not gated by the daily roster or the funding grant. Off-roster is still accepted. Amount is **1 USD** with invoice comment **`Welcome`**, once per Lightning Address **lifetime** (`STATE_DIR/welcome.jsonl`, optional `welcome.finished`). Daily `paymentsEnabled` applies (`payments_disabled` when off). Independent of `moderatorPaymentsEnabled`. Passkey + living-room `hasMedia` like daily (`no_media` skip, no JSONL). No `postedAt` UTC-day match. No `GET /invoices/eligible` on the payout. The gifts API renders the gift reply from that comment. + +Match `welcome.jsonl` case-insensitively; the first persisted address is the payout key, otherwise the parsed address. Own-address `paid` / persisted `failed` / own-address `uncertain` in `welcome.jsonl` skip with that reason; daily and moderator files do not skip a welcome ping. A `*halt*` row or another address's `uncertain` in `welcome.jsonl` does not skip this ping. After those skips, `paymentsEnabled` `false` → `200` `{ "status": "skipped", "reason": "payments_disabled" }` (does not write JSONL and does not start a payout). Otherwise `202` `{ "status": "accepted" }` without waiting for Lightning. + ## Branches Open every feature PR against **`develop`**, not `main`. @@ -108,14 +114,14 @@ Deploy workflows require GitHub Actions secrets `DOCKER_USERNAME`, `DOCKER_PASSW - JSONL appends are `fsync`'d so a restart does not lose a just-written `paid` row - Coinbase BTC-USD spot is required before any invoice. Recipients are USD; sats are computed at that spot - Balance preflight before the first pay (need remaining amount + `max(100 sats, 1%)` fee margin). LNDHub `balance` is sats as returned — not divided by 1000 -- Every run takes an exclusive `STATE_DIR/YYYY-MM-DD.lock` (`O_EXCL`) for the process lifetime. A concurrent second run exits `3`. After a normal exit the lock file is removed only if it still holds this process's token. A leftover lock is stolen when its owner pid is dead, when the pid is this process but the lock was written before this process started (container PID reuse), or when the file has no pid and is older than 10 minutes. Daily same-UTC-day re-entry is gated by JSONL (`paid` / `uncertain` / `*halt*`) and `YYYY-MM-DD.finished` (survives restart). Moderator re-entry is own-address rows in `YYYY-MM-DD.moderator.jsonl` plus `YYYY-MM-DD.moderator.finished` (`*halt*` does not gate). `POST /ping` skips `paid` and persisted `failed` for the pinged address. For daily / omitted `kind`, it also skips `uncertain` when that address, any other live recipient, or `*halt*` is already `uncertain` today. For `kind: "moderator"`, skip is own-address paid / persisted failed / own-address uncertain only; a `*halt*` row or another address's uncertain does not skip (see Moderator). For daily, `markFinished` still requires every live-roster recipient to be settled; a single daily ping does not finish the UTC day. For moderator, `markFinished` is against the synthetic stipend list of that ping and writes `YYYY-MM-DD.moderator.finished` once that address is paid/uncertain/failed +- Every run takes an exclusive `STATE_DIR/YYYY-MM-DD.lock` (`O_EXCL`) for the process lifetime. A concurrent second run exits `3`. After a normal exit the lock file is removed only if it still holds this process's token. A leftover lock is stolen when its owner pid is dead, when the pid is this process but the lock was written before this process started (container PID reuse), or when the file has no pid and is older than 10 minutes. Daily same-UTC-day re-entry is gated by JSONL (`paid` / `uncertain` / `*halt*`) and `YYYY-MM-DD.finished` (survives restart). Moderator re-entry is own-address rows in `YYYY-MM-DD.moderator.jsonl` plus `YYYY-MM-DD.moderator.finished` (`*halt*` does not gate). Welcome re-entry is own-address rows in `welcome.jsonl` plus optional `welcome.finished` (`*halt*` does not gate; lifetime, not per UTC day). `POST /ping` skips `paid` and persisted `failed` for the pinged address. For daily / omitted `kind`, it also skips `uncertain` when that address, any other live recipient, or `*halt*` is already `uncertain` today. For `kind: "moderator"` and `kind: "welcome"`, skip is own-address paid / persisted failed / own-address uncertain only; a `*halt*` row or another address's uncertain does not skip (see Moderator / Welcome). For daily, `markFinished` still requires every live-roster recipient to be settled; a single daily ping does not finish the UTC day. For moderator, `markFinished` is against the synthetic stipend list of that ping and writes `YYYY-MM-DD.moderator.finished` once that address is paid/uncertain/failed. For welcome, `markFinished` writes `welcome.finished` (never the daily `${day}.finished`) - Invoice-create network/5xx **before any pay** (no invoice id) is `invoice_unreachable`: skipped, not persisted, no `*halt*`, no `.finished`, exit `3`. Later recipients in the same run are still attempted. A later `POST /ping` for that address can retry -- `uncertain` covers two cases that both halt the rest of a **daily** live run (`--live` or `SPEND_LIVE=true`), append a `*halt*` JSONL row, `markFinished`, exit `4`, and are **not** retried the same UTC day: (1) invoice-create parse failures (`malformed invoice response` / `malformed paymentHash`) without a pay; (2) after an invoice id / pay attempt (amount mismatch, lndhub.pay error, missing/mismatched preimage, proof failure). Moderator live runs do not append `*halt*` and do not stop later addresses in the same run +- `uncertain` covers two cases that both halt the rest of a **daily** live run (`--live` or `SPEND_LIVE=true`), append a `*halt*` JSONL row, `markFinished`, exit `4`, and are **not** retried the same UTC day: (1) invoice-create parse failures (`malformed invoice response` / `malformed paymentHash`) without a pay; (2) after an invoice id / pay attempt (amount mismatch, lndhub.pay error, missing/mismatched preimage, proof failure). Moderator and welcome live runs do not append `*halt*` and do not stop later addresses in the same run - Persisted `failed` (invoice 4xx) is not paid again the same UTC day, does not count in the balance preflight sum, and for daily, when every live recipient is paid/uncertain/failed, `.finished` is set. For moderator, `.moderator.finished` is set when the synthetic stipend list of that run is settled -- Only Lightning Addresses that currently have a passkey on 21.gifts and a live non-profile forum post are paid. Daily also requires a photo or video on that post (`GET /invoices/posted` `hasMedia: true`). The moderator $5 stipend is unchanged: living-room `hasPosted` plus `postedAt` on this UTC day, and **no** `hasMedia` requirement. CLI and other non-ping roster runs also require `{ eligible: true }` from `GET /invoices/eligible` (live `GET /invoices/passkey` and `GET /invoices/posted` each run; eligible only when `checkFundingEligible` is not false). Listed daily pings and all moderator pings still do not call `GET /invoices/eligible` — the gifts API already gated `eligibleToday` before pinging, and `checkFundingEligible` stays false on ping payout. Unlisted daily pings call `GET /invoices/eligible` only to read grant `status` (`admitted`/`trial` → 1 USD; other statuses → `not_listed`; lookup fail → skip `eligible_unreachable`, HTTP 200, not process exit 3). CLI / roster runs unchanged: still require `{ eligible: true }` when `checkFundingEligible` is not false; lookup fail on those runs remains abort exit `3` `eligible_unreachable`. Addresses without a passkey (or without an account) are skipped as `no_passkey` with no JSONL row so a later ping can retry the same UTC day; addresses with a passkey but no live forum post are skipped as `no_post` the same way; daily addresses with a passkey and a live forum post but without photo/video (`hasMedia` not true) skip as `no_media` the same way (no JSONL, retry later; missing or non-true `hasMedia` is that skip, not `posted_unreachable`); addresses the api reports as not eligible today are skipped as `not_eligible` the same way. Invoice-create `403` `Forum post required` is the same `no_post` skip (not persisted `failed`). A failed passkey lookup aborts the whole run with exit `3` (`passkey_unreachable`) and pays no one; a failed posted lookup does the same as `posted_unreachable`; a failed eligible lookup on a CLI/roster run does the same as `eligible_unreachable` -- If a live process crashes, the next run steals the leftover lock once the owner pid is gone, or when this process reused the pid but the lock timestamp predates this incarnation. A lock whose pid belongs to a different live process is never stolen. Steal is serialized by a virgin `O_EXCL` `{day}.taking` file; an existing taking file is never replaced. Remove a lock or taking file by hand only after checking that no spend is running and inspecting `STATE_DIR/YYYY-MM-DD.jsonl` and `STATE_DIR/YYYY-MM-DD.moderator.jsonl` +- Only Lightning Addresses that currently have a passkey on 21.gifts and a live non-profile forum post are paid. Daily also requires a photo or video on that post (`GET /invoices/posted` `hasMedia: true`). Welcome uses the same passkey + `hasMedia` checks (no `postedAt` UTC-day match). The moderator $5 stipend is unchanged: living-room `hasPosted` plus `postedAt` on this UTC day, and **no** `hasMedia` requirement. CLI and other non-ping roster runs also require `{ eligible: true }` from `GET /invoices/eligible` (live `GET /invoices/passkey` and `GET /invoices/posted` each run; eligible only when `checkFundingEligible` is not false). Listed daily pings, all moderator pings, and welcome pings still do not call `GET /invoices/eligible` — the gifts API already gated `eligibleToday` before pinging, and `checkFundingEligible` stays false on ping payout (welcome never calls eligible even if that option is omitted or true). Unlisted daily pings call `GET /invoices/eligible` only to read grant `status` (`admitted`/`trial` → 1 USD; other statuses → `not_listed`; lookup fail → skip `eligible_unreachable`, HTTP 200, not process exit 3). Welcome pings do not look up the grant. CLI / roster runs unchanged: still require `{ eligible: true }` when `checkFundingEligible` is not false; lookup fail on those runs remains abort exit `3` `eligible_unreachable`. Addresses without a passkey (or without an account) are skipped as `no_passkey` with no JSONL row so a later ping can retry the same UTC day; addresses with a passkey but no live forum post are skipped as `no_post` the same way; daily and welcome addresses with a passkey and a live forum post but without photo/video (`hasMedia` not true) skip as `no_media` the same way (no JSONL, retry later; missing or non-true `hasMedia` is that skip, not `posted_unreachable`); addresses the api reports as not eligible today are skipped as `not_eligible` the same way. Invoice-create `403` `Forum post required` is the same `no_post` skip (not persisted `failed`). A failed passkey lookup aborts the whole run with exit `3` (`passkey_unreachable`) and pays no one; a failed posted lookup does the same as `posted_unreachable`; a failed eligible lookup on a CLI/roster run does the same as `eligible_unreachable` +- If a live process crashes, the next run steals the leftover lock once the owner pid is gone, or when this process reused the pid but the lock timestamp predates this incarnation. A lock whose pid belongs to a different live process is never stolen. Steal is serialized by a virgin `O_EXCL` `{day}.taking` file; an existing taking file is never replaced. Remove a lock or taking file by hand only after checking that no spend is running and inspecting `STATE_DIR/YYYY-MM-DD.jsonl`, `STATE_DIR/YYYY-MM-DD.moderator.jsonl`, and `STATE_DIR/welcome.jsonl` - Unreadable JSONL (truncated/corrupt line) aborts with exit `4` (`corrupt_state`) so a damaged `paid`/`uncertain` row cannot be ignored -- State: `STATE_DIR/YYYY-MM-DD.jsonl`, `STATE_DIR/YYYY-MM-DD.finished`, `STATE_DIR/YYYY-MM-DD.moderator.jsonl`, `STATE_DIR/YYYY-MM-DD.moderator.finished`, `STATE_DIR/YYYY-MM-DD.lock` while a run is in progress, and `STATE_DIR/YYYY-MM-DD.taking` (`O_EXCL`, owner pid) briefly while a leftover lock is stolen +- State: `STATE_DIR/YYYY-MM-DD.jsonl`, `STATE_DIR/YYYY-MM-DD.finished`, `STATE_DIR/YYYY-MM-DD.moderator.jsonl`, `STATE_DIR/YYYY-MM-DD.moderator.finished`, `STATE_DIR/welcome.jsonl`, `STATE_DIR/welcome.finished`, `STATE_DIR/YYYY-MM-DD.lock` while a run is in progress, and `STATE_DIR/YYYY-MM-DD.taking` (`O_EXCL`, owner pid) briefly while a leftover lock is stolen Exit codes: `0` ok, `1` drain timeout after SIGTERM/SIGINT (55s cap), `2` config, `3` preflight/balance/lock/spot/invoice-create unreachable, `4` failed, uncertain, or halted. diff --git a/src/__tests__/run.test.ts b/src/__tests__/run.test.ts index 15b710b..e5767fb 100644 --- a/src/__tests__/run.test.ts +++ b/src/__tests__/run.test.ts @@ -88,21 +88,26 @@ function giftsFetch( }; } -function memoryState(existing = ''): DayState { +function memoryState(existing = '', bucket: 'daily' | 'moderator' | 'welcome' = 'daily'): DayState { let file = existing; let finished = false; - return new DayState('/tmp', '2026-08-23', { - exists: (path) => (path.endsWith('.finished') ? finished : file !== ''), - read: () => file, - append: (path, data) => { - if (path.endsWith('.finished')) { - finished = true; - return; - } - file += data; + return new DayState( + '/tmp', + '2026-08-23', + { + exists: (path) => (path.endsWith('.finished') ? finished : file !== ''), + read: () => file, + append: (path, data) => { + if (path.endsWith('.finished')) { + finished = true; + return; + } + file += data; + }, + mkdir: () => undefined, }, - mkdir: () => undefined, - }); + bucket, + ); } const openLock = { @@ -127,7 +132,13 @@ describe('runDay', () => { const dry = await runDay( { ...config, recipients: [config.recipients[0]!] }, { live: false, day: '2026-08-23' }, - { gifts: failing, lndhub: new LndhubClient(target), state, lock: openLock, btcUsd: async () => 100_000 }, + { + gifts: failing, + lndhub: new LndhubClient(target), + state, + lock: openLock, + btcUsd: async () => 100_000, + }, ); expect(dry.exitCode).toBe(3); expect(state.load().some((row) => row.status === 'uncertain')).toBe(false); @@ -149,7 +160,9 @@ describe('runDay', () => { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } return new Response(JSON.stringify({ payment_preimage: PREIMAGE }), { status: 200 }); }); @@ -185,19 +198,25 @@ describe('runDay', () => { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } paid += 1; return new Response('{}', { status: 200 }); }); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: false, day: '2026-08-23' }, { - gifts, - lndhub, - state: memoryState(), - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: false, day: '2026-08-23' }, + { + gifts, + lndhub, + state: memoryState(), + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(0); expect(paid).toBe(0); @@ -224,7 +243,9 @@ describe('runDay', () => { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } return new Response(JSON.stringify({ payment_preimage: PREIMAGE }), { status: 200 }); }); @@ -280,7 +301,9 @@ describe('runDay', () => { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } return new Response(JSON.stringify({ payment_preimage: PREIMAGE }), { status: 200 }); }); @@ -700,7 +723,10 @@ describe('runDay', () => { if (href.endsWith('/proof')) { return new Response(JSON.stringify({ status: 'paid' }), { status: 200 }); } - const body = JSON.parse(String(init?.body ?? '{}')) as { address?: string; amountMsat?: number }; + const body = JSON.parse(String(init?.body ?? '{}')) as { + address?: string; + amountMsat?: number; + }; if (body.address === 'c@d.com') { bobInvoices += 1; } @@ -719,7 +745,9 @@ describe('runDay', () => { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } return new Response(JSON.stringify({ payment_preimage: PREIMAGE }), { status: 200 }); }); @@ -737,13 +765,17 @@ describe('runDay', () => { status: 'uncertain', })}\n`; const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: true, day: '2026-08-23', bucket: 'moderator' }, { - gifts, - lndhub, - state: memoryState(prior), - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23', bucket: 'moderator' }, + { + gifts, + lndhub, + state: memoryState(prior), + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.summary.reason).not.toBe('halted'); expect(result.summary.skipped).toEqual([ @@ -828,7 +860,9 @@ describe('runDay', () => { if (String(url).endsWith('/auth')) { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); }); const paid = `${JSON.stringify({ ts: 't', @@ -869,7 +903,9 @@ describe('runDay', () => { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } return new Response(JSON.stringify({ payment_preimage: PREIMAGE }), { status: 200 }); }); @@ -882,22 +918,24 @@ describe('runDay', () => { })}\n`; const state = memoryState(prior); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(0); expect(invoices).toBe(1); expect(result.summary.skipped).toEqual([ expect.objectContaining({ address: 'a@b.com', reason: 'failed' }), ]); - expect(result.summary.paid).toEqual([ - expect.objectContaining({ address: 'c@d.com' }), - ]); + expect(result.summary.paid).toEqual([expect.objectContaining({ address: 'c@d.com' })]); expect(state.isFinished()).toBe(true); }); @@ -935,13 +973,17 @@ describe('runDay', () => { })}\n`; const state = memoryState(prior); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(0); expect(result.summary.reason).not.toBe('insufficient_balance'); @@ -949,9 +991,7 @@ describe('runDay', () => { expect(result.summary.skipped).toEqual([ expect.objectContaining({ address: 'a@b.com', reason: 'failed' }), ]); - expect(result.summary.paid).toEqual([ - expect.objectContaining({ address: 'c@d.com' }), - ]); + expect(result.summary.paid).toEqual([expect.objectContaining({ address: 'c@d.com' })]); expect(state.isFinished()).toBe(true); }); @@ -993,13 +1033,17 @@ describe('runDay', () => { neededInPreflight = parsed.needed; } }); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(0); expect(result.summary.reason).toBeUndefined(); @@ -1008,9 +1052,7 @@ describe('runDay', () => { expect(result.summary.skipped).toEqual([ expect.objectContaining({ address: 'a@b.com', reason: 'no_passkey' }), ]); - expect(result.summary.paid).toEqual([ - expect.objectContaining({ address: 'c@d.com' }), - ]); + expect(result.summary.paid).toEqual([expect.objectContaining({ address: 'c@d.com' })]); expect(state.load().some((row) => row.address === 'a@b.com')).toBe(false); expect(state.isFinished()).toBe(false); }); @@ -1027,7 +1069,9 @@ describe('runDay', () => { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } payCalls += 1; return new Response(JSON.stringify({ payment_preimage: PREIMAGE }), { status: 200 }); @@ -1061,7 +1105,9 @@ describe('runDay', () => { if (String(url).endsWith('/auth')) { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); }); const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); @@ -1121,13 +1167,17 @@ describe('runDay', () => { neededInPreflight = parsed.needed; } }); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(0); expect(result.summary.reason).toBeUndefined(); @@ -1136,9 +1186,7 @@ describe('runDay', () => { expect(result.summary.skipped).toEqual([ expect.objectContaining({ address: 'a@b.com', reason: 'no_post' }), ]); - expect(result.summary.paid).toEqual([ - expect.objectContaining({ address: 'c@d.com' }), - ]); + expect(result.summary.paid).toEqual([expect.objectContaining({ address: 'c@d.com' })]); expect(state.load().some((row) => row.address === 'a@b.com')).toBe(false); expect(state.isFinished()).toBe(false); }); @@ -1148,14 +1196,18 @@ describe('runDay', () => { const gifts = new GiftsApi( 'https://api.21.gifts', 'tok', - giftsFetch(async () => new Response('{}', { status: 500 }), undefined, { 'a@b.com': 'throw' }), + giftsFetch(async () => new Response('{}', { status: 500 }), undefined, { + 'a@b.com': 'throw', + }), ); const lndhub = new LndhubClient(target, async (url) => { if (String(url).endsWith('/auth')) { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } payCalls += 1; return new Response(JSON.stringify({ payment_preimage: PREIMAGE }), { status: 200 }); @@ -1189,7 +1241,9 @@ describe('runDay', () => { if (String(url).endsWith('/auth')) { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); }); const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); @@ -1250,13 +1304,17 @@ describe('runDay', () => { neededInPreflight = parsed.needed; } }); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(0); expect(result.summary.reason).toBeUndefined(); @@ -1265,9 +1323,7 @@ describe('runDay', () => { expect(result.summary.skipped).toEqual([ expect.objectContaining({ address: 'a@b.com', reason: 'not_eligible' }), ]); - expect(result.summary.paid).toEqual([ - expect.objectContaining({ address: 'c@d.com' }), - ]); + expect(result.summary.paid).toEqual([expect.objectContaining({ address: 'c@d.com' })]); expect(state.load().some((row) => row.address === 'a@b.com')).toBe(false); expect(state.isFinished()).toBe(false); }); @@ -1286,7 +1342,9 @@ describe('runDay', () => { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } payCalls += 1; return new Response(JSON.stringify({ payment_preimage: PREIMAGE }), { status: 200 }); @@ -1310,9 +1368,14 @@ describe('runDay', () => { const gifts = new GiftsApi( 'https://api.21.gifts', 'tok', - giftsFetch(async () => new Response('{}', { status: 500 }), undefined, { 'a@b.com': false }, { - 'a@b.com': 'throw', - }), + giftsFetch( + async () => new Response('{}', { status: 500 }), + undefined, + { 'a@b.com': false }, + { + 'a@b.com': 'throw', + }, + ), ); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const result = await runDay( @@ -1413,7 +1476,9 @@ describe('runDay', () => { if (href.includes('/invoices/posted')) { const address = new URL(href).searchParams.get('address') ?? ''; if (address === 'a@b.com') { - return new Response(JSON.stringify({ hasPosted: true, hasMedia: false }), { status: 200 }); + return new Response(JSON.stringify({ hasPosted: true, hasMedia: false }), { + status: 200, + }); } return new Response(JSON.stringify({ hasPosted: true, hasMedia: true }), { status: 200 }); } @@ -1452,13 +1517,17 @@ describe('runDay', () => { neededInPreflight = parsed.needed; } }); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(0); expect(result.summary.reason).toBeUndefined(); @@ -1467,9 +1536,7 @@ describe('runDay', () => { expect(result.summary.skipped).toEqual([ expect.objectContaining({ address: 'a@b.com', reason: 'no_media' }), ]); - expect(result.summary.paid).toEqual([ - expect.objectContaining({ address: 'c@d.com' }), - ]); + expect(result.summary.paid).toEqual([expect.objectContaining({ address: 'c@d.com' })]); expect(state.load().some((row) => row.address === 'a@b.com')).toBe(false); expect(state.isFinished()).toBe(false); }); @@ -1522,9 +1589,14 @@ describe('runDay', () => { const gifts = new GiftsApi( 'https://api.21.gifts', 'tok', - giftsFetch(async () => new Response('{}', { status: 500 }), undefined, { 'a@b.com': false }, { - 'a@b.com': 'throw', - }), + giftsFetch( + async () => new Response('{}', { status: 500 }), + undefined, + { 'a@b.com': false }, + { + 'a@b.com': 'throw', + }, + ), ); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const result = await runDay( @@ -1663,17 +1735,21 @@ describe('runDay', () => { }); const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts: new GiftsApi( - 'https://api.21.gifts', - 'tok', - giftsFetch(async () => new Response('{}', { status: 500 })), - ), - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts: new GiftsApi( + 'https://api.21.gifts', + 'tok', + giftsFetch(async () => new Response('{}', { status: 500 })), + ), + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(3); expect(result.summary).toMatchObject({ @@ -1703,17 +1779,23 @@ describe('runDay', () => { if (String(url).endsWith('/auth')) { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); }); const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(3); expect(invoices).toBe(2); @@ -1740,17 +1822,23 @@ describe('runDay', () => { if (String(url).endsWith('/auth')) { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); }); const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(3); expect(state.load()).toEqual([]); @@ -1765,18 +1853,21 @@ describe('runDay', () => { const gifts = new GiftsApi( 'https://api.21.gifts', 'tok', - giftsFetch(async () => - new Response( - JSON.stringify({ id: 'id1', pr: 'lnbc1', paymentHash: 'nope', amountMsat: 1_000_000 }), - { status: 200 }, - ), + giftsFetch( + async () => + new Response( + JSON.stringify({ id: 'id1', pr: 'lnbc1', paymentHash: 'nope', amountMsat: 1_000_000 }), + { status: 200 }, + ), ), ); const lndhub = new LndhubClient(target, async (url) => { if (String(url).endsWith('/auth')) { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); }); const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); @@ -1795,14 +1886,14 @@ describe('runDay', () => { expect(result.exitCode).toBe(4); expect(state.isFinished()).toBe(true); expect( - state.load().some( - (row) => - (row.address === 'a@b.com' || row.address === '*halt*') && row.status === 'uncertain', - ), + state + .load() + .some( + (row) => + (row.address === 'a@b.com' || row.address === '*halt*') && row.status === 'uncertain', + ), ).toBe(true); - expect(result.summary.uncertain).toEqual([ - expect.objectContaining({ address: 'a@b.com' }), - ]); + expect(result.summary.uncertain).toEqual([expect.objectContaining({ address: 'a@b.com' })]); }); it('retries invoice_unreachable on a later live run after a partial success', async () => { @@ -1835,43 +1926,59 @@ describe('runDay', () => { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } return new Response(JSON.stringify({ payment_preimage: PREIMAGE }), { status: 200 }); }); const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const first = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const first = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); expect(first.exitCode).toBe(3); expect(first.summary.reason).toBeUndefined(); expect(first.summary.skipped).toEqual([ expect.objectContaining({ address: 'a@b.com', reason: 'invoice_unreachable' }), ]); - expect(state.load().filter((row) => row.status === 'paid').map((row) => row.address)).toEqual([ - 'c@d.com', - ]); + expect( + state + .load() + .filter((row) => row.status === 'paid') + .map((row) => row.address), + ).toEqual(['c@d.com']); expect(state.isFinished()).toBe(false); - const second = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const second = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(second.exitCode).toBe(0); expect(state.isFinished()).toBe(true); - expect(state.load().filter((row) => row.status === 'paid').map((row) => row.address).sort()).toEqual([ - 'a@b.com', - 'c@d.com', - ]); + expect( + state + .load() + .filter((row) => row.status === 'paid') + .map((row) => row.address) + .sort(), + ).toEqual(['a@b.com', 'c@d.com']); }); it('treats gifts API 401 as failed and continues to the next recipient', async () => { @@ -1888,17 +1995,23 @@ describe('runDay', () => { if (String(url).endsWith('/auth')) { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); }); const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(4); expect(invoices).toBe(2); @@ -1909,7 +2022,9 @@ describe('runDay', () => { const gifts = new GiftsApi( 'https://api.21.gifts', 'tok', - giftsFetch(async () => new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })), + giftsFetch( + async () => new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }), + ), ); const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); @@ -1951,7 +2066,9 @@ describe('runDay', () => { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } return new Response(JSON.stringify({ payment_preimage: '22'.repeat(32) }), { status: 200 }); }); @@ -1968,13 +2085,17 @@ describe('runDay', () => { it('aborts live when the day JSONL is corrupt', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts: new GiftsApi('https://api.21.gifts', 'tok'), - lndhub: new LndhubClient(target), - state: memoryState('not-json\n'), - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts: new GiftsApi('https://api.21.gifts', 'tok'), + lndhub: new LndhubClient(target), + state: memoryState('not-json\n'), + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(4); }); @@ -1982,13 +2103,17 @@ describe('runDay', () => { it('exits 3 when the live day lock is held', async () => { const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts: new GiftsApi('https://api.21.gifts', 'tok'), - lndhub: new LndhubClient(target), - state, - lock: heldLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts: new GiftsApi('https://api.21.gifts', 'tok'), + lndhub: new LndhubClient(target), + state, + lock: heldLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(3); expect(state.isFinished()).toBe(false); @@ -2006,7 +2131,12 @@ describe('runDay', () => { invoices += 1; const bodyAmount = invoices === 1 ? 1_000_000 : 500_000; return new Response( - JSON.stringify({ id: `id${invoices}`, pr: 'lnbc1', paymentHash: HASH, amountMsat: bodyAmount }), + JSON.stringify({ + id: `id${invoices}`, + pr: 'lnbc1', + paymentHash: HASH, + amountMsat: bodyAmount, + }), { status: 200 }, ); }), @@ -2016,30 +2146,42 @@ describe('runDay', () => { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } if (String(url).endsWith('/balance')) { - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); } throw new Error('pay failed'); }); const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const first = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const first = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); expect(first.exitCode).toBe(4); expect(invoices).toBe(1); - expect(state.load().some((row) => row.address === '*halt*' && row.status === 'uncertain')).toBe(true); + expect(state.load().some((row) => row.address === '*halt*' && row.status === 'uncertain')).toBe( + true, + ); expect(state.isFinished()).toBe(true); - const second = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state, - lock: openLock, - btcUsd: async () => 100_000, - }); + const second = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state, + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(second.exitCode).toBe(4); expect(invoices).toBe(1); @@ -2062,7 +2204,9 @@ describe('runDay', () => { if (String(url).endsWith('/auth')) { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); }); const prior = `${JSON.stringify({ ts: 't', @@ -2072,13 +2216,17 @@ describe('runDay', () => { status: 'uncertain', })}\n`; const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts, - lndhub, - state: memoryState(prior), - lock: openLock, - btcUsd: async () => 100_000, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts, + lndhub, + state: memoryState(prior), + lock: openLock, + btcUsd: async () => 100_000, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(4); expect(invoices).toBe(0); @@ -2122,7 +2270,9 @@ describe('runDay', () => { if (String(url).endsWith('/auth')) { return new Response(JSON.stringify({ access_token: 't' }), { status: 200 }); } - return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ BTC: { AvailableBalance: 1_000_000 } }), { + status: 200, + }); }), state, lock: openLock, @@ -2132,22 +2282,116 @@ describe('runDay', () => { warn.mockRestore(); expect(result.exitCode).toBe(0); expect(invoices).toBe(1); - expect(state.load().some((row) => row.status === 'paid' && row.invoiceId === 'already-paid')).toBe(true); + expect( + state.load().some((row) => row.status === 'paid' && row.invoiceId === 'already-paid'), + ).toBe(true); expect(state.isFinished()).toBe(true); }); it('aborts when Coinbase spot is unreadable', async () => { const state = memoryState(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const result = await runDay(config, { live: true, day: '2026-08-23' }, { - gifts: new GiftsApi('https://api.21.gifts', 'tok'), - lndhub: new LndhubClient(target), - state, - lock: openLock, - btcUsd: async () => null, - }); + const result = await runDay( + config, + { live: true, day: '2026-08-23' }, + { + gifts: new GiftsApi('https://api.21.gifts', 'tok'), + lndhub: new LndhubClient(target), + state, + lock: openLock, + btcUsd: async () => null, + }, + ); warn.mockRestore(); expect(result.exitCode).toBe(3); expect(state.isFinished()).toBe(false); }); + + it('welcome bucket invoices 1 USD with comment Welcome when hasMedia', async () => { + let invoiceBody: unknown; + let eligibleCalls = 0; + const gifts = new GiftsApi( + 'https://api.21.gifts', + 'tok', + giftsFetch(async (url, init) => { + if (String(url).includes('/invoices/eligible')) { + eligibleCalls += 1; + } + invoiceBody = JSON.parse(String(init?.body ?? '{}')); + return new Response( + JSON.stringify({ id: 'id1', pr: 'lnbc1', paymentHash: HASH, amountMsat: 1_000_000 }), + { status: 200 }, + ); + }), + ); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const result = await runDay( + { + ...config, + comment: 'Welcome', + recipients: [{ address: 'a@b.com', amountUsd: 1, comment: 'Welcome' }], + }, + { + live: false, + day: '2026-08-23', + bucket: 'welcome', + checkFundingEligible: true, + messageIdByAddress: { 'a@b.com': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' }, + }, + { + gifts, + lndhub: new LndhubClient(target), + state: memoryState('', 'welcome'), + lock: openLock, + btcUsd: async () => 100_000, + }, + ); + warn.mockRestore(); + expect(result.exitCode).toBe(0); + expect(eligibleCalls).toBe(0); + expect(invoiceBody).toEqual({ + address: 'a@b.com', + amountMsat: 1_000_000, + comment: 'Welcome', + messageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + }); + }); + + it('welcome bucket skips no_media when hasMedia is false', async () => { + const gifts = new GiftsApi('https://api.21.gifts', 'tok', async (url) => { + const href = String(url); + if (href.includes('/invoices/passkey')) { + return new Response(JSON.stringify({ hasPasskey: true }), { status: 200 }); + } + if (href.includes('/invoices/posted')) { + return new Response( + JSON.stringify({ + hasPosted: true, + hasMedia: false, + messageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + postedAt: '2026-08-23T12:00:00.000Z', + }), + { status: 200 }, + ); + } + throw new Error('no invoice'); + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const result = await runDay( + { ...config, recipients: [{ address: 'a@b.com', amountUsd: 1, comment: 'Welcome' }] }, + { live: false, day: '2026-08-23', bucket: 'welcome' }, + { + gifts, + lndhub: new LndhubClient(target), + state: memoryState('', 'welcome'), + lock: openLock, + btcUsd: async () => 100_000, + }, + ); + warn.mockRestore(); + expect(result.exitCode).toBe(0); + expect(result.summary?.skipped).toEqual([ + expect.objectContaining({ address: 'a@b.com', reason: 'no_media' }), + ]); + }); }); diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 9249182..80d6bf7 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -480,7 +480,9 @@ describe('createServer', () => { runDay, fetchImpl: async (url) => { if (String(url).includes('/invoices/eligible')) { - return new Response(JSON.stringify({ eligible: true, status: 'admitted' }), { status: 200 }); + return new Response(JSON.stringify({ eligible: true, status: 'admitted' }), { + status: 200, + }); } throw new Error('no network'); }, @@ -616,7 +618,10 @@ describe('createServer', () => { const res = await app.fetch( pingReq({ headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, - body: JSON.stringify({ address: 'nobody@walletofsatoshi.com', messageId: PING_MESSAGE_ID }), + body: JSON.stringify({ + address: 'nobody@walletofsatoshi.com', + messageId: PING_MESSAGE_ID, + }), }), ); expect(res.status).toBe(200); @@ -859,7 +864,10 @@ describe('createServer', () => { const res = await app.fetch( pingReq({ headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, - body: JSON.stringify({ address: 'alice@walletofsatoshi.com', messageId: PING_MESSAGE_ID }), + body: JSON.stringify({ + address: 'alice@walletofsatoshi.com', + messageId: PING_MESSAGE_ID, + }), }), ); expect(res.status).toBe(200); @@ -909,7 +917,10 @@ describe('createServer', () => { const res = await app.fetch( pingReq({ headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, - body: JSON.stringify({ address: 'alice@walletofsatoshi.com', messageId: PING_MESSAGE_ID }), + body: JSON.stringify({ + address: 'alice@walletofsatoshi.com', + messageId: PING_MESSAGE_ID, + }), }), ); expect(res.status).toBe(200); @@ -1603,6 +1614,118 @@ describe('createServer', () => { warn.mockRestore(); }); + it('POST /ping welcome is 202 and pays 1 USD with comment Welcome', async () => { + const runDay = vi.fn(async () => ({ exitCode: 0 })); + const app = createServer({ + env, + now: () => new Date('2026-08-25T12:00:00.000Z'), + runDay, + fetchImpl: async () => new Response('{}', { status: 200 }), + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const res = await app.fetch( + pingReq({ + headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, + body: JSON.stringify({ + address: 'nobody@walletofsatoshi.com', + messageId: PING_MESSAGE_ID, + kind: 'welcome', + }), + }), + ); + expect(res.status).toBe(202); + expect(await res.json()).toEqual({ status: 'accepted' }); + await vi.waitFor(() => { + expect(runDay).toHaveBeenCalled(); + }); + expect(runDay).toHaveBeenCalledWith( + expect.objectContaining({ + comment: 'Welcome', + recipients: [ + expect.objectContaining({ + address: 'nobody@walletofsatoshi.com', + amountUsd: 1, + comment: 'Welcome', + }), + ], + }), + expect.objectContaining({ + bucket: 'welcome', + onlyAddresses: ['nobody@walletofsatoshi.com'], + messageIdByAddress: { 'nobody@walletofsatoshi.com': PING_MESSAGE_ID }, + checkFundingEligible: false, + }), + ); + warn.mockRestore(); + }); + + it('POST /ping welcome is 200 skipped paid when welcome.jsonl already has paid', async () => { + const dir = mkdtempSync(join(tmpdir(), 'spend-ping-welcome-paid-')); + const seed = join(dir, 'seed.json'); + writeFileSync( + seed, + `${JSON.stringify({ + comment: '21gifts daily', + recipients: [{ address: 'alice@walletofsatoshi.com', amountUsd: 1 }], + })}\n`, + ); + writeFileSync( + join(dir, 'welcome.jsonl'), + `${JSON.stringify({ + ts: 't', + address: 'nobody@walletofsatoshi.com', + invoiceId: '1', + paymentHash: '', + status: 'paid', + })}\n`, + ); + const runDay = vi.fn(async () => ({ exitCode: 0 })); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const app = createServer({ + env: { ...env, STATE_DIR: dir, RECIPIENTS_FILE: seed }, + now: () => new Date('2026-08-25T12:00:00.000Z'), + runDay, + fetchImpl: async () => new Response('{}', { status: 200 }), + }); + const res = await app.fetch( + pingReq({ + headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, + body: JSON.stringify({ + address: 'nobody@walletofsatoshi.com', + messageId: PING_MESSAGE_ID, + kind: 'welcome', + }), + }), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'skipped', reason: 'paid' }); + expect(runDay).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('POST /ping welcome is 400 without messageId', async () => { + const app = createServer({ + env, + fetchImpl: async () => { + throw new Error('no network'); + }, + }); + const res = await app.fetch( + pingReq({ + headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, + body: JSON.stringify({ address: 'alice@walletofsatoshi.com', kind: 'welcome' }), + }), + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Expected a JSON body with address and messageId', + }); + }); + it('POST /ping is 400 for an invalid kind', async () => { const app = createServer({ env, @@ -1646,7 +1769,10 @@ describe('createServer', () => { const daily = await app.fetch( pingReq({ headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, - body: JSON.stringify({ address: 'alice@walletofsatoshi.com', messageId: PING_MESSAGE_ID }), + body: JSON.stringify({ + address: 'alice@walletofsatoshi.com', + messageId: PING_MESSAGE_ID, + }), }), ); expect(daily.status).toBe(200); @@ -1703,7 +1829,10 @@ describe('createServer', () => { const daily = await app.fetch( pingReq({ headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, - body: JSON.stringify({ address: 'alice@walletofsatoshi.com', messageId: PING_MESSAGE_ID }), + body: JSON.stringify({ + address: 'alice@walletofsatoshi.com', + messageId: PING_MESSAGE_ID, + }), }), ); expect(daily.status).toBe(202); @@ -1739,7 +1868,9 @@ describe('createServer', () => { runDay, fetchImpl: async (url) => { if (String(url).includes('/invoices/eligible')) { - return new Response(JSON.stringify({ eligible: false, status: 'none' }), { status: 200 }); + return new Response(JSON.stringify({ eligible: false, status: 'none' }), { + status: 200, + }); } throw new Error('no network'); }, @@ -1747,7 +1878,10 @@ describe('createServer', () => { const daily = await app.fetch( pingReq({ headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, - body: JSON.stringify({ address: 'nobody@walletofsatoshi.com', messageId: PING_MESSAGE_ID }), + body: JSON.stringify({ + address: 'nobody@walletofsatoshi.com', + messageId: PING_MESSAGE_ID, + }), }), ); expect(daily.status).toBe(200); @@ -1802,7 +1936,10 @@ describe('createServer', () => { const res = await app.fetch( pingReq({ headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, - body: JSON.stringify({ address: 'alice@walletofsatoshi.com', messageId: PING_MESSAGE_ID }), + body: JSON.stringify({ + address: 'alice@walletofsatoshi.com', + messageId: PING_MESSAGE_ID, + }), }), ); expect(res.status).toBe(200); @@ -1852,7 +1989,10 @@ describe('createServer', () => { const res = await app.fetch( pingReq({ headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, - body: JSON.stringify({ address: 'alice@walletofsatoshi.com', messageId: PING_MESSAGE_ID }), + body: JSON.stringify({ + address: 'alice@walletofsatoshi.com', + messageId: PING_MESSAGE_ID, + }), }), ); expect(res.status).toBe(200); @@ -1899,7 +2039,10 @@ describe('createServer', () => { const res = await app.fetch( pingReq({ headers: { authorization: 'Bearer tok', 'content-type': 'application/json' }, - body: JSON.stringify({ address: 'alice@walletofsatoshi.com', messageId: PING_MESSAGE_ID }), + body: JSON.stringify({ + address: 'alice@walletofsatoshi.com', + messageId: PING_MESSAGE_ID, + }), }), ); expect(res.status).toBe(200); @@ -2155,7 +2298,10 @@ function readLiveRoster(stateDir: string): LiveRosterFile { return JSON.parse(readFileSync(join(stateDir, 'recipients.json'), 'utf8')) as LiveRosterFile; } -async function login(app: ReturnType, password = 'test-password'): Promise { +async function login( + app: ReturnType, + password = 'test-password', +): Promise { const res = await app.fetch( req('http://127.0.0.1/login', { method: 'POST', @@ -2534,7 +2680,10 @@ describe('recipient editor', () => { it('payout reloads the live list and skips a corrupt file', async () => { const sess = sessionEnv(); const runDay = vi.fn(async (cfg: { recipients: Array<{ address: string }> }) => { - expect(cfg.recipients.map((r) => r.address)).toEqual(['alice@walletofsatoshi.com', 'bob@walletofsatoshi.com']); + expect(cfg.recipients.map((r) => r.address)).toEqual([ + 'alice@walletofsatoshi.com', + 'bob@walletofsatoshi.com', + ]); return { exitCode: 0 }; }); const app = createServer({ @@ -3186,8 +3335,11 @@ describe('recipient editor', () => { }), ); expect( - (JSON.parse(readFileSync(join(sess.STATE_DIR, 'recipients.json'), 'utf8')) as { comment: string }) - .comment, + ( + JSON.parse(readFileSync(join(sess.STATE_DIR, 'recipients.json'), 'utf8')) as { + comment: string; + } + ).comment, ).toBe('hello gifts'); await app.fetch( req('http://127.0.0.1/recipients/update', { @@ -3197,8 +3349,11 @@ describe('recipient editor', () => { }), ); expect( - (JSON.parse(readFileSync(join(sess.STATE_DIR, 'recipients.json'), 'utf8')) as { comment: string }) - .comment, + ( + JSON.parse(readFileSync(join(sess.STATE_DIR, 'recipients.json'), 'utf8')) as { + comment: string; + } + ).comment, ).toBe('hello gifts'); await app.fetch( req('http://127.0.0.1/recipients/delete', { @@ -3548,9 +3703,7 @@ describe('payment switches', () => { expect(live.moderatorPaymentsEnabled).toBe(true); const listed = await app.fetch(req('http://127.0.0.1/', { headers: { cookie } })); const html = await listed.text(); - expect(html).toContain( - 'action="/recipients/payments" aria-label="Daily payments"', - ); + expect(html).toContain('action="/recipients/payments" aria-label="Daily payments"'); expect(html).toContain( '', ); diff --git a/src/__tests__/state.test.ts b/src/__tests__/state.test.ts index b589a49..85857bc 100644 --- a/src/__tests__/state.test.ts +++ b/src/__tests__/state.test.ts @@ -26,7 +26,8 @@ describe('DayState', () => { it('throws CorruptStateError on a JSON object with an invalid status', () => { const state = new DayState('/tmp', '2026-08-23', { exists: () => true, - read: () => `${JSON.stringify({ ts: 't', address: 'a@b.com', invoiceId: '1', paymentHash: 'h', status: 'paif' })}\n`, + read: () => + `${JSON.stringify({ ts: 't', address: 'a@b.com', invoiceId: '1', paymentHash: 'h', status: 'paif' })}\n`, append: () => undefined, mkdir: () => undefined, }); @@ -78,6 +79,31 @@ describe('DayState', () => { expect(appendPath.endsWith('.moderator.jsonl')).toBe(true); }); + it('welcome bucket appends to a welcome.jsonl path', () => { + let appendPath = ''; + const state = new DayState( + '/tmp', + '2026-08-23', + { + exists: () => false, + read: () => '', + append: (path) => { + appendPath = path; + }, + mkdir: () => undefined, + }, + 'welcome', + ); + state.append({ + ts: 't', + address: 'a@b.com', + invoiceId: '1', + paymentHash: 'h', + status: 'paid', + }); + expect(appendPath.endsWith('welcome.jsonl')).toBe(true); + }); + it('markFinished appends once and isFinished becomes true', () => { const writes: string[] = []; const files = new Set(); @@ -104,8 +130,20 @@ describe('latestStatus', () => { expect( latestStatus( [ - { ts: '1', address: 'a@b.com', invoiceId: '1', paymentHash: 'h', status: 'dry-run' }, - { ts: '2', address: 'a@b.com', invoiceId: '1', paymentHash: 'h', status: 'paid' }, + { + ts: '1', + address: 'a@b.com', + invoiceId: '1', + paymentHash: 'h', + status: 'dry-run', + }, + { + ts: '2', + address: 'a@b.com', + invoiceId: '1', + paymentHash: 'h', + status: 'paid', + }, ], 'a@b.com', ), @@ -115,7 +153,15 @@ describe('latestStatus', () => { it('matches an address case-insensitively', () => { expect( latestStatus( - [{ ts: '1', address: 'a@b.com', invoiceId: '1', paymentHash: 'h', status: 'paid' }], + [ + { + ts: '1', + address: 'a@b.com', + invoiceId: '1', + paymentHash: 'h', + status: 'paid', + }, + ], 'A@b.com', ), ).toBe('paid'); @@ -127,8 +173,20 @@ describe('dayBlock', () => { expect( dayBlock( [ - { ts: '1', address: 'a@b.com', invoiceId: '1', paymentHash: 'h', status: 'paid' }, - { ts: '2', address: 'a@b.com', invoiceId: '1', paymentHash: 'h', status: 'dry-run' }, + { + ts: '1', + address: 'a@b.com', + invoiceId: '1', + paymentHash: 'h', + status: 'paid', + }, + { + ts: '2', + address: 'a@b.com', + invoiceId: '1', + paymentHash: 'h', + status: 'dry-run', + }, ], 'a@b.com', ), @@ -138,7 +196,15 @@ describe('dayBlock', () => { it('matches an address case-insensitively', () => { expect( dayBlock( - [{ ts: '1', address: 'a@b.com', invoiceId: '1', paymentHash: 'h', status: 'uncertain' }], + [ + { + ts: '1', + address: 'a@b.com', + invoiceId: '1', + paymentHash: 'h', + status: 'uncertain', + }, + ], 'A@b.com', ), ).toBe('uncertain'); diff --git a/src/run.ts b/src/run.ts index 75291ad..e5c9518 100644 --- a/src/run.ts +++ b/src/run.ts @@ -13,7 +13,7 @@ const HALT_ADDRESS = '*halt*'; export interface RunOptions { live: boolean; day: string; - /** When set, only these addresses are attempted (case-insensitive). Daily: they must be on the live roster. Moderator: the pinged address as listed on the moderator roster (the server gates the ping; the daily roster does not apply). */ + /** When set, only these addresses are attempted (case-insensitive). Daily: they must be on the live roster. Moderator: the pinged address as listed on the moderator roster (the server gates the ping; the daily roster does not apply). Welcome: the pinged address as a synthetic one-address list (the daily roster does not apply). */ onlyAddresses?: string[]; /** * Optional map: lowercase lightning address → forum post UUID that triggered the gift. @@ -27,9 +27,9 @@ export interface RunOptions { * included `groupMessageId`. Used only when {@link RunOptions.bucket} is `'moderator'`. */ groupMessageIdByAddress?: Record; - /** Default `'daily'`. Daily requires `hasPosted` and `hasMedia`. `'moderator'` uses the moderator JSONL, requires living-room `hasPosted` with `postedAt` UTC day === `options.day`, does not inspect `hasMedia`, and does not send `messageId` on `createInvoice`. */ - bucket?: 'daily' | 'moderator'; - /** Default true. False on HTTP ping: API already gated eligibleToday. */ + /** Default `'daily'`. Daily requires `hasPosted` and `hasMedia`. `'moderator'` uses the moderator JSONL, requires living-room `hasPosted` with `postedAt` UTC day === `options.day`, does not inspect `hasMedia`, and does not send `messageId` on `createInvoice`. `'welcome'` uses dateless `welcome.jsonl`, requires `hasPosted` and `hasMedia` like daily (no `postedAt` UTC-day match), never calls `isFundingEligible`, and forwards `messageId` on `createInvoice`. */ + bucket?: 'daily' | 'moderator' | 'welcome'; + /** Default true. False on HTTP ping: API already gated eligibleToday. Welcome treats this as false even when omitted or true. */ checkFundingEligible?: boolean; } @@ -87,14 +87,17 @@ function selectTargets( /** * Run one UTC day's gifts (or a subset when {@link RunOptions.onlyAddresses} is set). * - * Daily `markFinished` still requires every live-roster recipient to be settled; moderator `markFinished` is against the synthetic stipend recipient list for that run, not the living-room roster. + * Daily `markFinished` still requires every live-roster recipient to be settled; moderator `markFinished` is against the synthetic stipend recipient list for that run, not the living-room roster. Welcome `markFinished` writes `welcome.finished` (never the daily `${day}.finished`). * When {@link RunOptions.messageIdByAddress} is set, those post ids are sent on * `createInvoice`; otherwise the id from `hasPosted` is used when the api returns one. * Daily requires `hasPosted` and `hasMedia`; missing or non-true `hasMedia` skips as * `no_media` (no JSONL). When {@link RunOptions.bucket} is `'moderator'`, uses the * moderator JSONL, requires a living-room `hasPosted` whose `postedAt` UTC day matches * {@link RunOptions.day}, does not inspect `hasMedia`, and never sends `messageId` on - * `createInvoice`. When + * `createInvoice`. When {@link RunOptions.bucket} is `'welcome'`, uses dateless + * `welcome.jsonl`, requires `hasPosted` and `hasMedia` like daily (no `postedAt` + * UTC-day match), never calls `isFundingEligible`, and forwards `messageId` like daily. + * When * {@link RunOptions.groupMessageIdByAddress} has an entry for the recipient, that id * is sent as `groupMessageId` on `createInvoice` (moderator only). * @@ -119,16 +122,23 @@ export async function runDay( const target = parseLndhubUri(config.lndhubUri); if (target === null) { log('spend.done', { ok: false, reason: 'bad_lndhub_uri' }); - return { exitCode: 2, summary: makeSummary(options, 2, { reason: 'bad_lndhub_uri' }) }; + return { + exitCode: 2, + summary: makeSummary(options, 2, { reason: 'bad_lndhub_uri' }), + }; } const lndhub = deps?.lndhub ?? new LndhubClient(target); - const state = deps?.state ?? new DayState(config.stateDir, options.day, undefined, options.bucket ?? 'daily'); + const state = + deps?.state ?? new DayState(config.stateDir, options.day, undefined, options.bucket ?? 'daily'); const now = deps?.now ?? (() => new Date()); const lock = deps?.lock ?? fileDayLock(config.stateDir, options.day); if (!lock.tryAcquire()) { log('spend.done', { ok: false, reason: 'locked' }); - return { exitCode: 3, summary: makeSummary(options, 3, { reason: 'locked' }) }; + return { + exitCode: 3, + summary: makeSummary(options, 3, { reason: 'locked' }), + }; } try { @@ -153,10 +163,18 @@ async function runDayLocked( const uncertain: PayoutLine[] = []; const dryRun: PayoutLine[] = []; let btcUsd: number | undefined; + const isolatedBucket = options.bucket === 'moderator' || options.bucket === 'welcome'; + const checkFundingEligible = + options.bucket === 'welcome' ? false : options.checkFundingEligible !== false; const finish = ( exitCode: number, - extra: Partial> = {}, + extra: Partial< + Omit< + RunSummary, + 'day' | 'live' | 'ok' | 'exitCode' | 'paid' | 'skipped' | 'failed' | 'uncertain' | 'dryRun' + > + > = {}, ): RunResult => ({ exitCode, summary: makeSummary(options, exitCode, { @@ -180,7 +198,7 @@ async function runDayLocked( } throw err; } - if (options.live && options.bucket !== 'moderator') { + if (options.live && !isolatedBucket) { const recipientUncertain = config.recipients.some( (recipient) => dayBlock(rows, recipient.address) === 'uncertain', ); @@ -211,7 +229,11 @@ async function runDayLocked( amountUsd: recipient.amountUsd, btcUsd: rate, }); - failed.push({ address: recipient.address, amountUsd: recipient.amountUsd, reason: 'usd_to_sats' }); + failed.push({ + address: recipient.address, + amountUsd: recipient.amountUsd, + reason: 'usd_to_sats', + }); return finish(3, { reason: 'usd_to_sats' }); } satsByAddress.set(recipient.address, sats); @@ -263,7 +285,7 @@ async function runDayLocked( log('spend.done', { ok: false, reason: 'posted_unreachable' }); return finish(3, { reason: 'posted_unreachable' }); } - if (options.checkFundingEligible !== false) { + if (checkFundingEligible) { try { const eligible = await gifts.isFundingEligible(recipient.address); if (!eligible) { @@ -312,11 +334,20 @@ async function runDayLocked( available = bal; } catch (err) { const message = err instanceof Error ? err.message : 'lndhub'; - log('spend.done', { ok: false, reason: 'lndhub_preflight', error: message }); + log('spend.done', { + ok: false, + reason: 'lndhub_preflight', + error: message, + }); return finish(3, { reason: 'lndhub_preflight' }); } if (needed > 0 && needed + feeMargin(available) > available) { - log('spend.done', { ok: false, reason: 'insufficient_balance', needed, available }); + log('spend.done', { + ok: false, + reason: 'insufficient_balance', + needed, + available, + }); return finish(3, { reason: 'insufficient_balance', needed, available }); } } @@ -325,7 +356,7 @@ async function runDayLocked( let stopLive = false; const haltDay = (): void => { - if (options.bucket === 'moderator') { + if (isolatedBucket) { return; } if (!options.live || dayBlock(rows, HALT_ADDRESS) === 'uncertain') { @@ -347,7 +378,11 @@ async function runDayLocked( if (amountSats === undefined) { throw new Error('satsByAddress incomplete'); } - const lineBase = { address: recipient.address, amountSats, amountUsd: recipient.amountUsd }; + const lineBase = { + address: recipient.address, + amountSats, + amountUsd: recipient.amountUsd, + }; const prior = dayBlock(rows, recipient.address); if (prior !== undefined) { @@ -414,10 +449,18 @@ async function runDayLocked( undefined, groupMessageId, ) - : await gifts.createInvoice(recipient.address, amountSats * 1000, comment, invoiceMessageId); + : await gifts.createInvoice( + recipient.address, + amountSats * 1000, + comment, + invoiceMessageId, + ); } catch (err) { if (err instanceof GiftsApiError && err.status === 409) { - log('spend.skip', { address: recipient.address, reason: 'already_paid' }); + log('spend.skip', { + address: recipient.address, + reason: 'already_paid', + }); skipped.push({ ...lineBase, reason: 'already_paid' }); const claimed: StateRow = { ts: now().toISOString(), @@ -454,13 +497,16 @@ async function runDayLocked( message === 'malformed invoice response' || message === 'malformed paymentHash'; const unreachable = !parseFail && (status === 0 || status >= 500); if (unreachable) { - log('spend.skip', { address: recipient.address, reason: 'invoice_unreachable' }); + log('spend.skip', { + address: recipient.address, + reason: 'invoice_unreachable', + }); skipped.push({ ...lineBase, reason: 'invoice_unreachable' }); continue; } if (parseFail) { sawProblem = true; - if (options.bucket !== 'moderator') { + if (!isolatedBucket) { stopLive = true; } haltDay(); @@ -485,7 +531,11 @@ async function runDayLocked( continue; } sawProblem = true; - log('spend.failed', { address: recipient.address, amountSats, error: message }); + log('spend.failed', { + address: recipient.address, + amountSats, + error: message, + }); failed.push(lineBase); if (!options.live) { continue; @@ -505,7 +555,7 @@ async function runDayLocked( const expectedMsat = amountSats * 1000; if (invoice.amountMsat !== expectedMsat) { sawProblem = true; - if (options.bucket !== 'moderator') { + if (!isolatedBucket) { stopLive = true; } haltDay(); @@ -570,7 +620,7 @@ async function runDayLocked( preimage = paidInvoice.preimage; } catch (err) { sawProblem = true; - if (options.bucket !== 'moderator') { + if (!isolatedBucket) { stopLive = true; } haltDay(); @@ -588,7 +638,7 @@ async function runDayLocked( const digest = preimage === null ? null : hashPreimage(preimage); if (preimage === null || digest === null || digest !== invoice.paymentHash) { sawProblem = true; - if (options.bucket !== 'moderator') { + if (!isolatedBucket) { stopLive = true; } haltDay(); @@ -626,7 +676,7 @@ async function runDayLocked( await gifts.submitProof(invoice.id, preimage); } catch (err) { sawProblem = true; - if (options.bucket !== 'moderator') { + if (!isolatedBucket) { stopLive = true; } haltDay(); diff --git a/src/server.ts b/src/server.ts index 70f687e..45450e6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,11 +14,7 @@ import { saveLiveRecipients, type LiveRecipients, } from './recipients-store'; -import { - renderDashboardHtml, - renderUnconfiguredHtml, - type SpendPanel, -} from './recipients-html'; +import { renderDashboardHtml, renderUnconfiguredHtml, type SpendPanel } from './recipients-html'; import { runDay, type RunOptions } from './run'; import { SESSION_TTL_SEC, @@ -42,6 +38,8 @@ const SERVICE_NAME = 'spend'; const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** New-member handbook cap (USD) for an unlisted daily ping with grant admitted or trial. */ const NEW_MEMBER_DAILY_USD = 1; +/** Once-per-Lightning-Address lifetime welcome gift (USD). */ +const WELCOME_USD = 1; /** * Parse `BIND_ADDR` (`host:port`). @@ -49,7 +47,10 @@ const NEW_MEMBER_DAILY_USD = 1; * @param raw - Env value. * @returns Hostname and port. */ -export function parseBindAddr(raw: string | undefined): { hostname: string; port: number } { +export function parseBindAddr(raw: string | undefined): { + hostname: string; + port: number; +} { const value = raw === undefined || raw.trim() === '' ? '0.0.0.0:3000' : raw.trim(); const idx = value.lastIndexOf(':'); if (idx <= 0 || idx === value.length - 1) { @@ -223,7 +224,14 @@ function editorPanel( error?: string, ): SpendPanel { return error === undefined - ? { kind: 'editor', comment, recipients, moderators, paymentsEnabled, moderatorPaymentsEnabled } + ? { + kind: 'editor', + comment, + recipients, + moderators, + paymentsEnabled, + moderatorPaymentsEnabled, + } : { kind: 'editor', comment, @@ -249,10 +257,7 @@ export function createServer(opts: { fetchImpl?: typeof fetch; now?: () => Date; retryCatchupMs?: number; - runDay?: ( - config: SpendConfig, - options: RunOptions, - ) => Promise<{ exitCode: number }>; + runDay?: (config: SpendConfig, options: RunOptions) => Promise<{ exitCode: number }>; }): { fetch: (req: Request) => Promise; startScheduler: () => { stop: () => void }; @@ -333,9 +338,7 @@ export function createServer(opts: { return htmlResponse(renderUnconfiguredHtml(data), 503); }; - const loadOrError = (): - | { ok: true } & LiveRecipients - | { ok: false; response: Response } => { + const loadOrError = (): ({ ok: true } & LiveRecipients) | { ok: false; response: Response } => { try { const liveList = loadLiveRecipients(config.stateDir); return { @@ -350,7 +353,9 @@ export function createServer(opts: { if (err instanceof CorruptRecipientsError) { return { ok: false, - response: new Response('Recipient list is unreadable', { status: 500 }), + response: new Response('Recipient list is unreadable', { + status: 500, + }), }; } throw err; @@ -360,7 +365,11 @@ export function createServer(opts: { const fetchHandler = async (req: Request): Promise => { const url = new URL(req.url); if ((req.method === 'GET' || req.method === 'HEAD') && url.pathname === '/healthz') { - const body = JSON.stringify({ status: 'ok', service: SERVICE_NAME, version }); + const body = JSON.stringify({ + status: 'ok', + service: SERVICE_NAME, + version, + }); return new Response(req.method === 'HEAD' ? null : body, { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' }, @@ -407,7 +416,9 @@ export function createServer(opts: { status, headers: { 'content-type': 'application/json; charset=utf-8' }, }); - if (!bearerMatchesToken(config.giftsApiToken, req.headers.get('authorization') ?? undefined)) { + if ( + !bearerMatchesToken(config.giftsApiToken, req.headers.get('authorization') ?? undefined) + ) { return json(401, { error: 'Unauthorized' }); } let body: unknown; @@ -432,28 +443,47 @@ export function createServer(opts: { groupMessageId?: unknown; }; const kindRaw = pingBody.kind; - if (kindRaw !== undefined && kindRaw !== 'daily' && kindRaw !== 'moderator') { - return json(400, { error: 'Expected a JSON body with address and kind' }); + if ( + kindRaw !== undefined && + kindRaw !== 'daily' && + kindRaw !== 'moderator' && + kindRaw !== 'welcome' + ) { + return json(400, { + error: 'Expected a JSON body with address and kind', + }); } - const kind: 'daily' | 'moderator' = kindRaw === 'moderator' ? 'moderator' : 'daily'; + const kind: 'daily' | 'moderator' | 'welcome' = + kindRaw === 'moderator' ? 'moderator' : kindRaw === 'welcome' ? 'welcome' : 'daily'; let groupMessageId: string | undefined; if (kind === 'moderator') { if ('messageId' in pingBody) { - return json(400, { error: 'Expected a JSON body with address and kind' }); + return json(400, { + error: 'Expected a JSON body with address and kind', + }); } const rawGroupMessageId = pingBody.groupMessageId; if (rawGroupMessageId !== undefined) { if (typeof rawGroupMessageId !== 'string' || !MESSAGE_ID_RE.test(rawGroupMessageId)) { - return json(400, { error: 'Expected a JSON body with address and kind' }); + return json(400, { + error: 'Expected a JSON body with address and kind', + }); } groupMessageId = rawGroupMessageId; } - } else if (typeof pingBody.messageId !== 'string' || !MESSAGE_ID_RE.test(pingBody.messageId)) { - return json(400, { error: 'Expected a JSON body with address and messageId' }); + } else if ( + kind === 'daily' && + (typeof pingBody.messageId !== 'string' || !MESSAGE_ID_RE.test(pingBody.messageId)) + ) { + return json(400, { + error: 'Expected a JSON body with address and messageId', + }); } const parsed = parseLightningAddress(pingBody.address); if (parsed === null) { - return json(400, { error: 'Not a valid Lightning Address (expected name@domain)' }); + return json(400, { + error: 'Not a valid Lightning Address (expected name@domain)', + }); } const logPing = (status: string, reason?: string): void => { console.warn( @@ -463,12 +493,87 @@ export function createServer(opts: { address: parsed, status, ...(reason !== undefined ? { reason } : {}), - ...(kind === 'moderator' ? { kind: 'moderator' } : {}), + ...(kind === 'daily' ? {} : { kind }), }), ); }; const clock = opts.now ?? (() => new Date()); const day = clock().toISOString().slice(0, 10); + if (kind === 'welcome') { + if (typeof pingBody.messageId !== 'string' || !MESSAGE_ID_RE.test(pingBody.messageId)) { + return json(400, { + error: 'Expected a JSON body with address and messageId', + }); + } + const messageId = pingBody.messageId; + let liveList: LiveRecipients; + try { + liveList = loadLiveRecipients(config.stateDir); + } catch (err) { + if (err instanceof CorruptRecipientsError) { + return json(500, { error: 'Recipient list is unreadable' }); + } + throw err; + } + let storedAddress = parsed; + let rows; + try { + rows = new DayState(config.stateDir, day, undefined, 'welcome').load(); + } catch (err) { + if (!(err instanceof CorruptStateError)) { + throw err; + } + rows = undefined; + } + if (rows !== undefined) { + const persisted = rows.find((row) => row.address.toLowerCase() === parsed.toLowerCase()); + if (persisted !== undefined) { + storedAddress = persisted.address; + } + const block = dayBlock(rows, storedAddress); + if (block === 'paid') { + logPing('skipped', 'paid'); + return json(200, { status: 'skipped', reason: 'paid' }); + } + if (block === 'uncertain') { + logPing('skipped', 'uncertain'); + return json(200, { status: 'skipped', reason: 'uncertain' }); + } + if (latestStatus(rows, storedAddress) === 'failed') { + logPing('skipped', 'failed'); + return json(200, { status: 'skipped', reason: 'failed' }); + } + } + if (liveList.paymentsEnabled === false) { + logPing('skipped', 'payments_disabled'); + return json(200, { status: 'skipped', reason: 'payments_disabled' }); + } + logPing('accepted'); + void payout(day, 'ping', [storedAddress], messageId, { + bucket: 'welcome', + recipients: [ + { + address: storedAddress, + amountUsd: WELCOME_USD, + comment: 'Welcome', + }, + ], + comment: 'Welcome', + }).catch((err: unknown) => { + const error = err instanceof Error ? err.message : 'ping'; + console.warn( + JSON.stringify({ + ts: new Date().toISOString(), + event: 'spend.ping', + address: storedAddress, + status: 'error', + error, + kind: 'welcome', + }), + ); + }); + return json(202, { status: 'accepted' }); + } if (kind === 'moderator') { let liveList: LiveRecipients; try { @@ -497,9 +602,7 @@ export function createServer(opts: { rows = undefined; } if (rows !== undefined) { - const persisted = rows.find( - (row) => row.address.toLowerCase() === parsed.toLowerCase(), - ); + const persisted = rows.find((row) => row.address.toLowerCase() === parsed.toLowerCase()); if (persisted !== undefined) { storedAddress = persisted.address; } @@ -573,7 +676,10 @@ export function createServer(opts: { ).fundingGrantStatus(parsed); } catch { logPing('skipped', 'eligible_unreachable'); - return json(200, { status: 'skipped', reason: 'eligible_unreachable' }); + return json(200, { + status: 'skipped', + reason: 'eligible_unreachable', + }); } if (grant !== 'admitted' && grant !== 'trial') { logPing('skipped', 'not_listed'); @@ -620,7 +726,9 @@ export function createServer(opts: { const queued = extraRecipients === undefined ? payout(day, 'ping', [storedAddress], messageId) - : payout(day, 'ping', [storedAddress], messageId, { recipients: extraRecipients }); + : payout(day, 'ping', [storedAddress], messageId, { + recipients: extraRecipients, + }); void queued.catch((err: unknown) => { const error = err instanceof Error ? err.message : 'ping'; console.warn( @@ -708,9 +816,7 @@ export function createServer(opts: { const paymentsMatch = PAYMENTS_SWITCH.exec(url.pathname); if ( req.method === 'POST' && - (rosterMatch !== null || - paymentsMatch !== null || - url.pathname === '/recipients/comment') + (rosterMatch !== null || paymentsMatch !== null || url.pathname === '/recipients/comment') ) { if (config.dashboardPassword === null) { return unconfiguredPage(); @@ -824,7 +930,7 @@ export function createServer(opts: { onlyAddresses?: string[], messageId?: string, extras?: { - bucket?: 'moderator'; + bucket?: 'moderator' | 'welcome'; recipients?: Recipient[]; comment?: string; groupMessageId?: string; @@ -832,11 +938,12 @@ export function createServer(opts: { ): Promise<{ exitCode: number }> => gate.run(async () => { const moderator = extras?.bucket === 'moderator'; + const welcome = extras?.bucket === 'welcome'; const groupMessageId = extras?.groupMessageId; let liveList: { comment: string; recipients: Recipient[] }; - if (moderator) { + if (moderator || welcome) { liveList = { - comment: extras?.comment ?? '21gifts moderator', + comment: extras?.comment ?? (welcome ? 'Welcome' : '21gifts moderator'), recipients: extras?.recipients ?? [], }; } else { @@ -852,7 +959,10 @@ export function createServer(opts: { reason: 'corrupt_recipients', }), ); - const summary = { ...minimalRunSummary(day, live, 4), reason: 'corrupt_recipients' }; + const summary = { + ...minimalRunSummary(day, live, 4), + reason: 'corrupt_recipients', + }; if (telegramTarget !== null && notifyLog.allow(source, summary)) { const sent = await notifyPayout({ target: telegramTarget, @@ -900,15 +1010,19 @@ export function createServer(opts: { messageIdByAddress: Object.fromEntries( onlyAddresses.map((address) => [address.toLowerCase(), messageId]), ), + ...(welcome ? { bucket: 'welcome' as const } : {}), }; if (source === 'ping') runOptions.checkFundingEligible = false; const result = await (opts.runDay ?? runDay)( - { ...config, recipients: liveList.recipients, comment: liveList.comment }, + { + ...config, + recipients: liveList.recipients, + comment: liveList.comment, + }, runOptions, ); const withSummary = result as { exitCode: number; summary?: RunSummary }; - const summary = - withSummary.summary ?? minimalRunSummary(day, live, withSummary.exitCode); + const summary = withSummary.summary ?? minimalRunSummary(day, live, withSummary.exitCode); if (telegramTarget !== null && notifyLog.allow(source, summary)) { const sent = await notifyPayout({ target: telegramTarget, @@ -933,7 +1047,9 @@ export function createServer(opts: { * * @returns Handle whose `stop` does nothing. */ - const startRetryCatchup = (): { stop: () => void } => ({ stop: () => undefined }); + const startRetryCatchup = (): { stop: () => void } => ({ + stop: () => undefined, + }); return { fetch: fetchHandler, @@ -963,7 +1079,12 @@ if (meta.main === true) { } ).Bun; if (bun === undefined) { - console.error(JSON.stringify({ event: 'spend.config', error: 'Bun.serve is required' })); + console.error( + JSON.stringify({ + event: 'spend.config', + error: 'Bun.serve is required', + }), + ); process.exit(2); } bun.serve({ diff --git a/src/state.ts b/src/state.ts index 991561e..2c6d5b5 100644 --- a/src/state.ts +++ b/src/state.ts @@ -28,10 +28,12 @@ export interface StateRow { } /** - * File-backed per-day payout log. + * File-backed payout log. * * `bucket` `'daily'` (default) uses `${day}.jsonl` / `${day}.finished`. * `'moderator'` uses `${day}.moderator.jsonl` / `${day}.moderator.finished`. + * `'welcome'` uses dateless `welcome.jsonl` / `welcome.finished` (the `day` + * argument is ignored for the filename). */ export class DayState { constructor( @@ -56,7 +58,7 @@ export class DayState { }, mkdir: (path) => mkdirSync(path, { recursive: true }), }, - private readonly bucket: 'daily' | 'moderator' = 'daily', + private readonly bucket: 'daily' | 'moderator' | 'welcome' = 'daily', ) {} /** @@ -116,6 +118,9 @@ export class DayState { } private path(): string { + if (this.bucket === 'welcome') { + return join(this.dir, 'welcome.jsonl'); + } if (this.bucket === 'moderator') { return join(this.dir, `${this.day}.moderator.jsonl`); } @@ -123,6 +128,9 @@ export class DayState { } private finishedPath(): string { + if (this.bucket === 'welcome') { + return join(this.dir, 'welcome.finished'); + } if (this.bucket === 'moderator') { return join(this.dir, `${this.day}.moderator.finished`); } @@ -158,7 +166,13 @@ function parseStateRow(line: string): StateRow { ) { throw new CorruptStateError(); } - return { ts, address, invoiceId, paymentHash, status: status as StateRow['status'] }; + return { + ts, + address, + invoiceId, + paymentHash, + status: status as StateRow['status'], + }; } /** @@ -188,7 +202,10 @@ export function latestStatus(rows: StateRow[], address: string): StateRow['statu export function dayBlock(rows: StateRow[], address: string): 'paid' | 'uncertain' | undefined { let found: 'paid' | 'uncertain' | undefined; for (const row of rows) { - if (row.address.toLowerCase() === address.toLowerCase() && (row.status === 'paid' || row.status === 'uncertain')) { + if ( + row.address.toLowerCase() === address.toLowerCase() && + (row.status === 'paid' || row.status === 'uncertain') + ) { found = row.status; } }