Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Amount is the listed `amountUsd` with comment `21gifts moderator`. Same in-proce

### 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.
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 required. Media is `welcomeHasMedia` when the api sends that boolean (an About-me photo counts and `hasPosted` may be false); when the field is absent, living-room `hasPosted` and `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.

Expand Down Expand Up @@ -118,7 +118,7 @@ Deploy workflows require GitHub Actions secrets `DOCKER_USERNAME`, `DOCKER_PASSW
- 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 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`). 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). The same-day `insufficient_balance` retry also keeps `checkFundingEligible` false and does not call `GET /invoices/eligible` again. 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`
- Daily payouts require a passkey and a live non-profile forum post, plus a photo or video on that post (`GET /invoices/posted` `hasMedia: true`). Welcome requires a passkey and the welcome media gate: `welcomeHasMedia` when the api sends a boolean (an About-me photo counts and `hasPosted` may be false); when the field is absent, `hasPosted` and `hasMedia`. 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). The same-day `insufficient_balance` retry also keeps `checkFundingEligible` false and does not call `GET /invoices/eligible` again. 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; daily and moderator addresses with a passkey but no live non-profile 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`). Welcome does not use `no_post`. Welcome `no_media` follows `welcomeHasMedia` when that field is a boolean, otherwise `hasPosted && hasMedia`; addresses the api reports as not eligible today are skipped as `not_eligible` the same way. Invoice-create `403` `Forum post required` is `no_post` for daily and moderator, and `no_media` for welcome (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/welcome.jsonl`, `STATE_DIR/welcome.finished`, `STATE_DIR/YYYY-MM-DD.retry.jsonl` (owed-address queue for same-UTC-day `insufficient_balance` retries; not a payout log), `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
Expand Down
69 changes: 69 additions & 0 deletions src/__tests__/gifts-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ describe('GiftsApi', () => {
hasMedia: false,
messageId: null,
postedAt: null,
welcomeHasMedia: null,
welcomeMessageId: null,
});
expect(seenUrl).toBe('https://api.21.gifts/invoices/posted?address=a%40b.com');
expect(auth).toBe('Bearer tok');
Expand All @@ -194,6 +196,8 @@ describe('GiftsApi', () => {
hasMedia: false,
messageId: null,
postedAt: null,
welcomeHasMedia: null,
welcomeMessageId: null,
});
});

Expand All @@ -206,6 +210,8 @@ describe('GiftsApi', () => {
hasMedia: true,
messageId: null,
postedAt: null,
welcomeHasMedia: null,
welcomeMessageId: null,
});
});

Expand All @@ -218,6 +224,8 @@ describe('GiftsApi', () => {
hasMedia: false,
messageId: null,
postedAt: null,
welcomeHasMedia: null,
welcomeMessageId: null,
});
});

Expand All @@ -238,6 +246,8 @@ describe('GiftsApi', () => {
hasMedia: false,
messageId: null,
postedAt: null,
welcomeHasMedia: null,
welcomeMessageId: null,
});
}
});
Expand All @@ -254,6 +264,8 @@ describe('GiftsApi', () => {
hasMedia: false,
messageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
postedAt: null,
welcomeHasMedia: null,
welcomeMessageId: null,
});
});

Expand All @@ -275,6 +287,8 @@ describe('GiftsApi', () => {
hasMedia: false,
messageId: null,
postedAt: null,
welcomeHasMedia: null,
welcomeMessageId: null,
});
}
});
Expand All @@ -295,6 +309,61 @@ describe('GiftsApi', () => {
hasMedia: false,
messageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
postedAt: '2026-08-23T12:00:00.000Z',
welcomeHasMedia: null,
welcomeMessageId: null,
});
});

it('hasPosted returns welcome media when the api sends it', async () => {
const api = new GiftsApi('https://api.21.gifts', 'tok', async () =>
new Response(
JSON.stringify({
hasPosted: false,
hasMedia: false,
welcomeHasMedia: true,
welcomeMessageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
}),
{ status: 200 },
),
);
await expect(api.hasPosted('a@b.com')).resolves.toEqual({
hasPosted: false,
hasMedia: false,
messageId: null,
postedAt: null,
welcomeHasMedia: true,
welcomeMessageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
});
const absent = new GiftsApi('https://api.21.gifts', 'tok', async () =>
new Response(
JSON.stringify({
hasPosted: true,
welcomeHasMedia: false,
welcomeMessageId: 'nope',
}),
{ status: 200 },
),
);
await expect(absent.hasPosted('a@b.com')).resolves.toEqual({
hasPosted: true,
hasMedia: false,
messageId: null,
postedAt: null,
welcomeHasMedia: false,
welcomeMessageId: null,
});
const weird = new GiftsApi('https://api.21.gifts', 'tok', async () =>
new Response(JSON.stringify({ hasPosted: true, welcomeHasMedia: 1, welcomeMessageId: 2 }), {
status: 200,
}),
);
await expect(weird.hasPosted('a@b.com')).resolves.toEqual({
hasPosted: true,
hasMedia: false,
messageId: null,
postedAt: null,
welcomeHasMedia: null,
welcomeMessageId: null,
});
});

Expand Down
116 changes: 116 additions & 0 deletions src/__tests__/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2437,4 +2437,120 @@ describe('runDay', () => {
expect.objectContaining({ address: 'a@b.com', reason: 'no_media' }),
]);
});

it('welcome bucket pays an About-me photo when welcomeHasMedia is true', async () => {
let invoiceBody: unknown;
const gifts = new GiftsApi('https://api.21.gifts', 'tok', async (url, init) => {
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: false,
hasMedia: false,
messageId: null,
postedAt: null,
welcomeHasMedia: true,
welcomeMessageId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
}),
{ status: 200 },
);
}
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, 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(invoiceBody).toEqual({
address: 'a@b.com',
amountMsat: 1_000_000,
amountUsd: '1.00',
comment: 'Welcome',
messageId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
});
});

it('welcome bucket skips no_media when welcomeHasMedia 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: true,
messageId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
postedAt: '2026-08-23T12:00:00.000Z',
welcomeHasMedia: false,
}),
{ 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' }),
]);
});

it('welcome bucket skips no_media when invoice create says Forum post required', async () => {
const gifts = new GiftsApi(
'https://api.21.gifts',
'tok',
giftsFetch(async () => {
return new Response(JSON.stringify({ error: 'Forum post required' }), { status: 403 });
}),
);
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' }),
]);
expect(result.summary?.failed).toEqual([]);
});
});
25 changes: 19 additions & 6 deletions src/gifts-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,21 @@ export class GiftsApi {
* Live forum-post flag, media flag, post UUID, and post timestamp for this Lightning Address.
*
* @param address - LUD-16 address.
* @returns `{ hasPosted, hasMedia, messageId, postedAt }` — `hasMedia` is boolean
* (missing or non-true JSON → `false`); `messageId` is a UUID or `null`;
* @returns `{ hasPosted, hasMedia, messageId, postedAt, welcomeHasMedia, welcomeMessageId }`.
* `hasMedia` is boolean (missing or non-true JSON → `false`). `messageId` is a UUID or `null`.
* `postedAt` is an ISO-8601 instant or `null` when missing or unparseable.
* `welcomeHasMedia` is the boolean from the api, or `null` when the field is missing or
* not a boolean (older api). `welcomeMessageId` is a UUID or `null`.
*/
async hasPosted(
address: string,
): Promise<{ hasPosted: boolean; hasMedia: boolean; messageId: string | null; postedAt: string | null }> {
async hasPosted(address: string): Promise<{
hasPosted: boolean;
hasMedia: boolean;
messageId: string | null;
postedAt: string | null;
/** `null` when the api omitted the field (older api). */
welcomeHasMedia: boolean | null;
welcomeMessageId: string | null;
}> {
const path = `/invoices/posted?address=${encodeURIComponent(address)}`;
const json = await this.getJson(path);
const has = json['hasPosted'];
Expand All @@ -74,7 +82,12 @@ export class GiftsApi {
if (typeof rawAt === 'string' && !Number.isNaN(Date.parse(rawAt))) {
postedAt = new Date(rawAt).toISOString();
}
return { hasPosted: has, hasMedia, messageId, postedAt };
const rawWelcome = json['welcomeHasMedia'];
const welcomeHasMedia = typeof rawWelcome === 'boolean' ? rawWelcome : null;
const rawWelcomeId = json['welcomeMessageId'];
const welcomeMessageId =
typeof rawWelcomeId === 'string' && MESSAGE_ID_RE.test(rawWelcomeId) ? rawWelcomeId : null;
return { hasPosted: has, hasMedia, messageId, postedAt, welcomeHasMedia, welcomeMessageId };
}

/**
Expand Down
Loading
Loading