fix(zap): collapse split when creator == platform; close #3 audit

Finishes the remaining #3 zap audit items.

- zapSplit: when the platform fee would pay the SAME Lightning address as the
  creator (e.g. the creator is the platform operator), collapse to a single
  full-amount payment instead of sending two invoices to one wallet — avoids an
  extra routing fee and a confusing duplicate receipt for the same total.
- Audited split correctness: floor(amount*fee/100) with the creator taking the
  remainder loses no sats; a sub-1-sat fee rounds to 0 and the whole zap goes to
  the creator (no zero-amount platform send); fee is clamped 0–100 and a junk
  override falls back to the default. The earlier "fee landed in my own wallet"
  report is expected behavior (sender is the platform operator, or a self-zap),
  not a bug.
- Cheat-resistant tests: testCreatorEqualsPlatformCollapsesToOnePayment and
  testSplitRoundingPreservesEverySat in tests/zap-split.test.mjs.
- Receipt validation (9735) and NWC budget/retry UX confirmed already solid;
  audit plan + TODO #3 closed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-25 08:56:00 -04:00
1 parent b52a1b3fea
commit df99bdb65f
4 files changed
+147 -7

No files matched your search

+20 -1
View File
@@ -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:
+14 -2
View File
@@ -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 = [];
+86
View File
@@ -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: "[email protected]", // identical to the creator below
});
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]", // 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: "[email protected]",
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: "[email protected]",
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();
+27 -4
View File
@@ -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).