mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
92 lines
3.4 KiB
JavaScript
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",
|
|
);
|
|
});
|