mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
zaps: reliable coinos platform address + fix NWC double-pay on timeout
Two things: 1. Platform fee address → [email protected] (verified live: allowsNostr + nostrPubkey, 1-sat min, working callback). coinos is Nostr-native and PUBLISHES zap receipts, unlike strike.me whose flaky LNURL + never-published receipts caused the platform-share pay timeouts and the missing zap tallies. The 30% platform fee is unchanged — only the rail. 2. Double-pay guard (bug found while diagnosing): on a `pay_invoice` timeout, nwcClient's nip44->nip04 encryption-fallback resent the request — but a timeout is ambiguous (the wallet may already be paying), so the resend could DOUBLE-SPEND. shouldRetryWithFallback now refuses to auto-resend any spending method (pay_invoice/pay_keysend/multi_pay_*) on timeout; read-only methods and explicit unsupported-encryption rejections (wallet never paid) stay retryable. The manual "Send again" retry is the safe path for a real payment timeout. Tests: tests/nwc-pay-timeout-no-double-spend.test.mjs (4). Lint + build green. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
69a8ecaa3e
commit
5a7481e30e
3 files changed
+117
-2
No files matched your search
@@ -191,7 +191,10 @@ export const PLATFORM_FEE_PERCENT = 30;
|
||||
* Super Admin profile publishing a `lud16` so bitvid still knows where to route
|
||||
* fees when they are enabled.
|
||||
*/
|
||||
export const PLATFORM_LUD16_OVERRIDE = "[email protected]";
|
||||
// coinos: Nostr-native, publishes NIP-57 zap receipts, reliable routing,
|
||||
// 1-sat minimum. Replaced [email protected], whose LNURL was flaky and never
|
||||
// published receipts, causing platform-share pay timeouts + missing zap tallies.
|
||||
export const PLATFORM_LUD16_OVERRIDE = "[email protected]";
|
||||
|
||||
/**
|
||||
* Optional list of relays to seed new sessions with instead of the defaults.
|
||||
|
||||
@@ -926,7 +926,7 @@ async function ensureEncryption(context) {
|
||||
throw new Error("No compatible wallet encryption scheme available.");
|
||||
}
|
||||
|
||||
function shouldRetryWithFallback(context, error, encryption, { hasRetried }) {
|
||||
function shouldRetryWithFallback(context, error, encryption, { hasRetried, method } = {}) {
|
||||
if (!context || !encryption || hasRetried) {
|
||||
return false;
|
||||
}
|
||||
@@ -934,6 +934,8 @@ function shouldRetryWithFallback(context, error, encryption, { hasRetried }) {
|
||||
const code = typeof error?.code === "string" ? error.code : "";
|
||||
const message = typeof error?.message === "string" ? error.message : "";
|
||||
|
||||
// An explicit "unsupported encryption" rejection means the wallet never
|
||||
// processed the request, so re-sending under nip04 is safe for any method.
|
||||
if (code === "UNSUPPORTED_ENCRYPTION" || code === "unsupported_encryption") {
|
||||
return true;
|
||||
}
|
||||
@@ -947,6 +949,13 @@ function shouldRetryWithFallback(context, error, encryption, { hasRetried }) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A TIMEOUT is ambiguous: the wallet may have received the request and be
|
||||
// paying it. NEVER auto-resend a spending method on timeout — the encryption
|
||||
// fallback would double-pay. Read-only methods stay retryable.
|
||||
if (NWC_SPENDING_METHODS.has(method)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = ensureEncryptionState(context);
|
||||
if (state.unsupported.has("nip04")) {
|
||||
return false;
|
||||
@@ -1358,6 +1367,15 @@ async function dispatchWalletRequest(context, payload, { timeoutMs } = {}) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
// NWC methods that MOVE money. A timeout on these is ambiguous — the payment
|
||||
// may already be in flight — so we must NOT auto-resend them (double-spend).
|
||||
const NWC_SPENDING_METHODS = new Set([
|
||||
"pay_invoice",
|
||||
"pay_keysend",
|
||||
"multi_pay_invoice",
|
||||
"multi_pay_keysend",
|
||||
]);
|
||||
|
||||
async function sendWalletRequest(context, payload, { timeoutMs, __internalRetry = false } = {}) {
|
||||
try {
|
||||
return await dispatchWalletRequest(context, payload, { timeoutMs });
|
||||
@@ -1366,6 +1384,7 @@ async function sendWalletRequest(context, payload, { timeoutMs, __internalRetry
|
||||
if (
|
||||
shouldRetryWithFallback(context, error, encryption, {
|
||||
hasRetried: __internalRetry === true,
|
||||
method: payload?.method,
|
||||
})
|
||||
) {
|
||||
rememberUnsupportedEncryption(context, encryption.scheme);
|
||||
@@ -1647,4 +1666,6 @@ export const __TESTING__ = Object.freeze({
|
||||
buildPayInvoiceParams,
|
||||
getActiveState: () => activeState,
|
||||
ensureEncryptionForContext: ensureEncryption,
|
||||
shouldRetryWithFallback,
|
||||
NWC_SPENDING_METHODS,
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// Safety: a `pay_invoice` (or any spending method) that TIMES OUT must NOT be
|
||||
// auto-resent. The wallet may have received it and be paying — the nip44→nip04
|
||||
// encryption-fallback retry would double-spend. Only read-only methods (and an
|
||||
// explicit unsupported-encryption rejection, which means the wallet never
|
||||
// processed the request) stay retryable.
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-nwc-no-double-spend
|
||||
// given: "a nip44 wallet context and various errors/methods"
|
||||
// when: "shouldRetryWithFallback decides whether to resend"
|
||||
// then: "spending method + timeout → no retry; read-only + timeout → retry; unsupported-encryption → retry for any method; already-retried → no retry"
|
||||
// observable_outcomes:
|
||||
// - "boolean retry decision per (method, error) pair"
|
||||
// determinism_controls:
|
||||
// - "pure decision function; no socket/network"
|
||||
// anti_cheat_rationale:
|
||||
// prevents: ["hard-coded return value", "over-mocking internal logic"]
|
||||
// relaxation:
|
||||
// did_relax_any_assertion: false
|
||||
|
||||
import test from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { __TESTING__ } from "../js/payments/nwcClient.js";
|
||||
|
||||
const { shouldRetryWithFallback, NWC_SPENDING_METHODS } = __TESTING__;
|
||||
|
||||
const nip44Ctx = () => ({
|
||||
encryption: { scheme: "nip44_v2" },
|
||||
encryptionState: undefined,
|
||||
});
|
||||
const timeout = new Error("Wallet request timed out.");
|
||||
const unsupported = Object.assign(new Error("nope"), { code: "UNSUPPORTED_ENCRYPTION" });
|
||||
|
||||
test("timeout on a spending method never resends (no double-spend)", () => {
|
||||
const encryption = { scheme: "nip44_v2" };
|
||||
for (const method of NWC_SPENDING_METHODS) {
|
||||
assert.equal(
|
||||
shouldRetryWithFallback(nip44Ctx(), timeout, encryption, { hasRetried: false, method }),
|
||||
false,
|
||||
`${method} must not auto-retry on timeout`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("timeout on a read-only method may still fall back to nip04", () => {
|
||||
const ctx = nip44Ctx();
|
||||
const encryption = { scheme: "nip44_v2" };
|
||||
const decision = shouldRetryWithFallback(ctx, timeout, encryption, {
|
||||
hasRetried: false,
|
||||
method: "get_info",
|
||||
});
|
||||
// Read-only methods remain eligible for the encryption fallback (the guard
|
||||
// only blocks spending methods). The exact result depends on the context's
|
||||
// encryption state, but it must NOT be short-circuited to false by the guard.
|
||||
assert.equal(typeof decision, "boolean");
|
||||
// With a fresh context (nip04 not marked unsupported), it retries.
|
||||
assert.equal(decision, true, "read-only timeout retries the encryption fallback");
|
||||
});
|
||||
|
||||
test("explicit unsupported-encryption rejection retries for any method", () => {
|
||||
const encryption = { scheme: "nip44_v2" };
|
||||
assert.equal(
|
||||
shouldRetryWithFallback(nip44Ctx(), unsupported, encryption, {
|
||||
hasRetried: false,
|
||||
method: "pay_invoice",
|
||||
}),
|
||||
true,
|
||||
"a rejection means the wallet never paid → safe to resend under nip04",
|
||||
);
|
||||
});
|
||||
|
||||
test("already-retried or non-nip44 never retries", () => {
|
||||
const encryption = { scheme: "nip44_v2" };
|
||||
assert.equal(
|
||||
shouldRetryWithFallback(nip44Ctx(), timeout, encryption, {
|
||||
hasRetried: true,
|
||||
method: "get_info",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRetryWithFallback(nip44Ctx(), timeout, { scheme: "nip04" }, {
|
||||
hasRetried: false,
|
||||
method: "get_info",
|
||||
}),
|
||||
false,
|
||||
"already on nip04 → no fallback",
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user