Files
bitvid/tests/nwc-pay-timeout-no-double-spend.test.mjs
T
thePR0M3TH3ANandClaude Fable 5 5a7481e30e 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]>
2026-07-04 23:54:16 -04:00

92 lines
3.4 KiB
JavaScript

// 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",
);
});