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:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-04 23:54:16 -04:00
1 parent 69a8ecaa3e
commit 5a7481e30e
3 files changed
+117 -2

No files matched your search

+4 -1
View File
@@ -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.
+22 -1
View File
@@ -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",
);
});