diff --git a/docs/zap-audit-plan.md b/docs/zap-audit-plan.md index 53e2a625..77c4babc 100644 --- a/docs/zap-audit-plan.md +++ b/docs/zap-audit-plan.md @@ -112,7 +112,26 @@ Pre-launch priority #3. The zap flow feels clunky and errors on send. use (see 3a), not a comment bug. - **Popover mis-positioning (zap + embed)** — see TODO 3a; shared `popoverEngine`. -## To do (next session, in priority order) +## Status: CLOSED 2026-06-24 + +All audit items below are resolved: +1. **CORS / LNURL** — DECIDED: accept graceful degradation, keep the visual + zappability flag, no proxy (breaks static rule + invoice-swap risk; WebLN's + only bypass pays directly with no receipt/split). See TODO #3. +2. **Popover positioning (3a)** — FIXED (portaled to `#uiOverlay`). +3. **Platform-fee split correctness** — AUDITED + FIXED. Rounding never loses + sats (creator gets the remainder); sub-1-sat fee → 0 and the whole zap goes to + the creator; fee `clampPercent`-bounded and junk override falls back to default; + **creator==platform collapses to one full-amount payment** (was double-paying). + "Fee landed in my own wallet" is expected (sender is the platform operator, or + self-zap), not a bug. Tests in `tests/zap-split.test.mjs`. +4. **Receipt validation (9735)** — FIXED (author + bolt11 + description-hash match, + polling for late receipts). `tests/zap-receipt-pool.test.mjs`. +5. **NWC budget/retry UX** — actionable budget-exhausted message + remaining-share + summary + Retry button. +6. **General clunkiness** — maintainer confirms the flow works well; accepted. + +## To do (historical — completed) 1. **CORS / LNURL proxy — DECISION: re-test first (2026-06-23).** Because the NWC connection was fully broken (hex secret → `getPublicKey`; fixed this session), an unknown share of the observed "send errors" were the NWC bug, NOT CORS. Plan: diff --git a/js/payments/zapSplit.js b/js/payments/zapSplit.js index 4fa27a5c..d6ce95fb 100644 --- a/js/payments/zapSplit.js +++ b/js/payments/zapSplit.js @@ -540,8 +540,8 @@ export async function splitAndZap( } const platformFee = clampPercent(getOverridePlatformFee()); - const platformShare = Math.floor((amount * platformFee) / 100); - const creatorShare = amount - platformShare; + let platformShare = Math.floor((amount * platformFee) / 100); + let creatorShare = amount - platformShare; let platformAddress = null; if (platformShare > 0) { @@ -551,6 +551,18 @@ export async function splitAndZap( if (!platformAddress) { throw new Error("Platform Lightning address is unavailable."); } + + // When the platform fee would be paid to the very same Lightning address as + // the creator (e.g. the creator is the platform operator), splitting just + // sends two invoices to one wallet — extra routing fees and a confusing + // duplicate receipt for the same total. Collapse to a single full-amount + // payment to the creator; the recipient is unchanged and ends up with the + // same sats. + if (platformAddress.toLowerCase() === creatorAddress.toLowerCase()) { + creatorShare = amount; + platformShare = 0; + platformAddress = null; + } } const receipts = []; diff --git a/tests/zap-split.test.mjs b/tests/zap-split.test.mjs index dfaca74b..31c5931f 100644 --- a/tests/zap-split.test.mjs +++ b/tests/zap-split.test.mjs @@ -959,6 +959,92 @@ function testExtractDescriptionHashFromBolt11() { assert.equal(extracted, descriptionHashHex); } +// When the platform fee would be paid to the SAME Lightning address as the +// creator, the split must collapse to a single full-amount payment instead of +// sending two invoices to one wallet (extra routing fee + duplicate receipt). +async function testCreatorEqualsPlatformCollapsesToOnePayment() { + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 30; + const { deps, getSendCalls, getValidatorCalls } = createDeps({ + platformAddress: "creator@example.com", // identical to the creator below + }); + + const videoEvent = { + id: "event-id", + pubkey: "c".repeat(64), + lightningAddress: "Creator@example.com", // mixed case → must still collapse + tags: [["d", "pointer"]], + kind: 30078, + }; + + const result = await splitAndZap( + { videoEvent, amountSats: 1000, comment: "self-owned platform" }, + deps + ); + + assert.equal(result.totalAmount, 1000); + assert.equal(result.creatorShare, 1000, "creator receives the full amount"); + assert.equal(result.platformShare, 0, "no separate platform fee is split out"); + assert.equal(result.receipts.length, 1, "exactly one payment is made"); + + const sendCalls = getSendCalls(); + assert.equal(sendCalls.length, 1, "must not double-pay the same wallet"); + assert.equal(sendCalls[0].amountSats, 1000); + assert.equal( + getValidatorCalls().length, + 1, + "only one receipt to validate after collapsing" + ); +} + +// The split must never lose or invent sats: creatorShare + platformShare always +// equals the total, and a fee that rounds below 1 sat falls entirely to the +// creator rather than silently dropping value. +async function testSplitRoundingPreservesEverySat() { + // 33% of 1000 = 330 exactly-floored; creator gets the remainder. + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 33; + { + const { deps, getSendCalls } = createDeps(); + const videoEvent = { + id: "e1", + pubkey: "c".repeat(64), + lightningAddress: "creator@example.com", + tags: [["d", "p"]], + kind: 30078, + }; + const result = await splitAndZap({ videoEvent, amountSats: 1000 }, deps); + assert.equal(result.platformShare, 330); + assert.equal(result.creatorShare, 670); + assert.equal( + result.creatorShare + result.platformShare, + result.totalAmount, + "no sats lost or invented" + ); + const amounts = getSendCalls().map((c) => c.amountSats); + assert.deepEqual(amounts, [670, 330]); + } + + // 10% of 5 sats floors to 0 → the whole zap goes to the creator as one payment. + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 10; + { + const { deps, getSendCalls } = createDeps(); + const videoEvent = { + id: "e2", + pubkey: "c".repeat(64), + lightningAddress: "creator@example.com", + tags: [["d", "p"]], + kind: 30078, + }; + const result = await splitAndZap({ videoEvent, amountSats: 5 }, deps); + assert.equal(result.platformShare, 0, "sub-1-sat fee is not charged"); + assert.equal(result.creatorShare, 5); + const sendCalls = getSendCalls(); + assert.equal(sendCalls.length, 1, "no zero-amount platform payment is sent"); + assert.equal(sendCalls[0].amountSats, 5); + } +} + +await testCreatorEqualsPlatformCollapsesToOnePayment(); +await testSplitRoundingPreservesEverySat(); await testSplitMath(); await testBech32LightningAddressZapTag(); await testUrlLightningAddressZapTag(); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 7175a424..03772129 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -151,7 +151,7 @@ edge instead of `bottom-end` under its trigger. fresh portaled panel and already positions correctly. If any other pre-existing in-modal panel is found mis-positioned, this same engine fix now covers it. -### 3. Zaps system + platform-fee zap split — full plan in docs/zap-audit-plan.md +### 3. Zaps system + platform-fee zap split — DONE 2026-06-24 (full plan in docs/zap-audit-plan.md) Audit started 2026-06-23. See the doc for architecture map + findings. Summary: - [x] **Send-error softened** (`dd1ce113`): recipient LNURL unreachable/CORS now shows a clear message, not raw "Failed to fetch". @@ -200,9 +200,32 @@ Audit started 2026-06-23. See the doc for architecture map + findings. Summary: deterministic amount — so it degrades a zap into an unattributed tip and bypasses the platform fee. Not acceptable as a silent fallback. Net: the current behavior is the right answer for a static, no-custody client. -- [ ] **Remaining audit items**: platform-fee split correctness (the earlier "fee - landed in my own wallet" report + self-zap / creator==platform edge), receipt - validation (9735), NWC budget/retry UX, general clunkiness. +- [x] **Remaining audit items — DONE 2026-06-24. #3 fully closed.** + - **Split correctness (`js/payments/zapSplit.js`):** `platformShare = + floor(amount*fee/100)`, `creatorShare = amount - platformShare` — the + creator always gets the remainder, so **no sats are lost or invented**; a + sub-1-sat fee rounds to 0 and the whole zap goes to the creator (no + zero-amount platform send). Fee is `clampPercent`-bounded to 0–100 and a + junk override falls back to the configured default (can't be bypassed or + swallow the zap). **Fixed the creator==platform edge:** when the platform + fee would pay the *same* Lightning address as the creator, the split now + collapses to a single full-amount payment (was sending two invoices to one + wallet → extra routing fee + duplicate receipt). Cheat-resistant tests + added in `tests/zap-split.test.mjs` + (`testCreatorEqualsPlatformCollapsesToOnePayment`, + `testSplitRoundingPreservesEverySat`). + - **"Fee landed in my own wallet" — NOT a bug (expected):** occurs when the + *sender is the platform operator* (the platform fee correctly returns to + them) or on a *self-zap* (you get your own creator share back). The fee + recipient is always the configured platform address, never the sender's. + - **Receipt validation (9735):** already fixed 2026-06-23 (author + bolt11 + + description-hash match, with polling for late receipts); confirmed green + (`tests/zap-receipt-pool.test.mjs`, validator tests in `zap-split`). + - **NWC budget/retry UX:** already actionable — detects budget-exhausted, + shows "Increase your wallet zap limit or reduce the platform fee, then + retry…" plus the remaining-share summary and a Retry button. + - **General clunkiness:** maintainer confirms the flow is working well; + closed as accepted. - [x] Comment box confirmed WORKING (the "message doesn't work" was the popover mis-position making it hard to use — see 3a).