mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
b52a1b3fea
commit
df99bdb65f
4 files changed
+147
-7
No files matched your search
+20
-1
@@ -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
@@ -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 = [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in new issue
Block a user