From 2e6f1a6335e06c376cb8ea83fdeabf68c96d0bb4 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 31 May 2026 10:02:19 -0400 Subject: [PATCH] fix: admin refund actually sends funds (was calling a nonexistent method) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin "send funds" refund path called walletRPCService.sendPayment(), which does not exist — guarded by a typeof check, so it silently never sent while still reporting the refund as processed. Now uses the real processPayout() (validates the address and transfers, returning { success, txHash }), and refunds the amount the user actually paid (received_amount, recorded at confirmation) rather than the expected amount, falling back to expected for older records. --- docs/REMEDIATION_PLAN.md | 2 +- src/index.js | 28 ++++++++++++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/REMEDIATION_PLAN.md b/docs/REMEDIATION_PLAN.md index 75c4704..ccc1c27 100644 --- a/docs/REMEDIATION_PLAN.md +++ b/docs/REMEDIATION_PLAN.md @@ -29,7 +29,7 @@ Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker - [x] **1.2 S — Snapshot payout config at game start.** ✅ Migration 016 adds `payout_escape_amount`/`payout_treasure_amount` (BIGINT) + multipliers to `games`. Both start paths stamp the resolved payout amounts (via `_computePayoutSnapshot`); `completeGame` pays from the snapshot (falls back to live calc only for pre-snapshot games). New `payoutSnapshot.test.js` proves a mid-game config change can't alter an in-flight payout. - [x] **1.3 S — Close payout uniqueness holes.** ✅ `processing` covered in the unique index (migration 015) and in the in-code existing-payout guard. Migration 017 adds idempotent `NOT VALID` CHECK constraints on `payments.status`, `payments.payment_type`, and `payouts.status` so a typo'd status can't silently orphan a money row (enforces new writes, won't choke on legacy rows). _(games.status/game_mode CHECKs deferred — transient states, lower money-impact.)_ - [x] **1.4 S — Fix retry stat guard + non-atomic tx_hash write.** ✅ The "already on-chain" path used a `NOT EXISTS` guard against the row it had just marked completed (always skipped → never counted); replaced with a conditional `UPDATE … WHERE status <> 'completed' RETURNING` so stats count exactly once. Successful retries now store `tx_hash` + mark completed + count stats in ONE transaction (no stranded-tx_hash window). New `payoutRetry.test.js` covers count-once, no-double-count, and atomic completion. -- [ ] **1.5 S — Remove dead refund send-path** (`src/index.js:795`). +- [x] **1.5 S — Fix dead refund send-path.** ✅ The admin refund called a nonexistent `walletRPCService.sendPayment()` (guarded by a `typeof` check), so refunds were silently never sent while still reporting success. Now uses the real `processPayout()` (validates address + transfers, returns `{success, txHash}`) and refunds the **actually received** amount (`received_amount`, falling back to expected). - [ ] **1.6 S — `credits` → BIGINT; make migration 010 idempotent** (`src/migrations/010_security_constraints.sql`); per-migration transactions in runner (`src/db/databaseManager.js:146`). ## Phase 2 — Identity, abuse & concurrency (🟠) diff --git a/src/index.js b/src/index.js index 2d3faa1..c92e7de 100644 --- a/src/index.js +++ b/src/index.js @@ -811,19 +811,27 @@ app.post('/api/admin/refund/payment', adminAuth, asyncHandler(async (req, res) = await client.query('COMMIT'); - // Optionally send funds back via wallet RPC + // Optionally send funds back via wallet RPC. Refund what the user ACTUALLY paid + // (received_amount, recorded at confirmation) when available, falling back to the + // expected amount for older records. let fundsSent = false; let txHash = null; - if (sendFunds && payment.payout_address && payment.expected_amount > 0) { + const refundAmount = (payment.received_amount != null && Number(payment.received_amount) > 0) + ? payment.received_amount + : payment.expected_amount; + if (sendFunds && payment.payout_address && Number(refundAmount) > 0) { try { - if (walletRPCService && typeof walletRPCService.sendPayment === 'function') { - const result = await walletRPCService.sendPayment( - payment.payout_address, - payment.expected_amount - ); - fundsSent = true; - txHash = result?.tx_hash || null; - } + // processPayout validates the address and performs the transfer; returns + // { success, txHash, fee }. (The previous code called a nonexistent sendPayment(), + // so refunds were silently never sent while still reporting success.) + const result = await walletRPCService.processPayout({ + address: payment.payout_address, + amount: refundAmount, + userId: payment.user_id, + description: `Refund for payment ${paymentId}` + }); + fundsSent = !!(result && result.success); + txHash = (result && result.txHash) || null; } catch (err) { console.error('Failed to send refund funds:', err.message); // Don't fail the request - refund is recorded, funds can be sent manually