From 5a7481e30e71d2b5023bdcc417d4b9114017a0ed Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:54:16 -0400 Subject: [PATCH] zaps: reliable coinos platform address + fix NWC double-pay on timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things: 1. Platform fee address → a4a6b5849bc917b3befd5c81@coinos.io (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 --- config/instance-config.js | 5 +- js/payments/nwcClient.js | 23 ++++- .../nwc-pay-timeout-no-double-spend.test.mjs | 91 +++++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/nwc-pay-timeout-no-double-spend.test.mjs diff --git a/config/instance-config.js b/config/instance-config.js index 61cb1b96..1959fd2a 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -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 = "adammalin@strike.me"; +// coinos: Nostr-native, publishes NIP-57 zap receipts, reliable routing, +// 1-sat minimum. Replaced adammalin@strike.me, whose LNURL was flaky and never +// published receipts, causing platform-share pay timeouts + missing zap tallies. +export const PLATFORM_LUD16_OVERRIDE = "a4a6b5849bc917b3befd5c81@coinos.io"; /** * Optional list of relays to seed new sessions with instead of the defaults. diff --git a/js/payments/nwcClient.js b/js/payments/nwcClient.js index 8933b7b4..0f0020ca 100644 --- a/js/payments/nwcClient.js +++ b/js/payments/nwcClient.js @@ -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, }); diff --git a/tests/nwc-pay-timeout-no-double-spend.test.mjs b/tests/nwc-pay-timeout-no-double-spend.test.mjs new file mode 100644 index 00000000..7b9179a4 --- /dev/null +++ b/tests/nwc-pay-timeout-no-double-spend.test.mjs @@ -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", + ); +});