Skip to content

Commit 8c3e8c2

Browse files
committed
Fix payout submit wallet visibility checks
1 parent 33f5da5 commit 8c3e8c2

2 files changed

Lines changed: 139 additions & 18 deletions

File tree

lib/payments/processor.js

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -306,21 +306,26 @@ module.exports = function createPaymentsProcessor(ctx, state, deps) {
306306
return txKey ? { status: "ok", txKey } : { status: "missing", message: "wallet returned no tx_key for " + txHash };
307307
}
308308

309-
async function loadWalletTransfers() {
310-
const heightReply = await callWallet("get_height", {}, true);
311-
const walletHeight = heightReply && heightReply.result ? normalizeInteger(heightReply.result.height) : null;
312-
if (heightReply instanceof Error || typeof heightReply === "string" || !heightReply || typeof heightReply !== "object" || walletHeight === null) {
313-
return { status: "wallet_unavailable", message: "wallet height lookup failed: " + describeWalletReply(heightReply) };
314-
}
315-
316-
const reply = await callWallet("get_transfers", {
309+
async function loadWalletTransfers(options) {
310+
const params = {
317311
out: true,
318312
pending: true,
319-
pool: true,
320-
filter_by_height: true,
321-
min_height: Math.max(0, walletHeight - RECENT_TRANSFER_LOOKBACK_BLOCKS),
322-
max_height: walletHeight
323-
}, true, { connectionClose: false });
313+
pool: true
314+
};
315+
if (!options || options.filterByHeight !== false) {
316+
const heightReply = await callWallet("get_height", {}, true);
317+
const walletHeight = heightReply && heightReply.result ? normalizeInteger(heightReply.result.height) : null;
318+
if (heightReply instanceof Error || typeof heightReply === "string" || !heightReply || typeof heightReply !== "object" || walletHeight === null) {
319+
return { status: "wallet_unavailable", message: "wallet height lookup failed: " + describeWalletReply(heightReply) };
320+
}
321+
Object.assign(params, {
322+
filter_by_height: true,
323+
min_height: Math.max(0, walletHeight - RECENT_TRANSFER_LOOKBACK_BLOCKS),
324+
max_height: walletHeight
325+
});
326+
}
327+
328+
const reply = await callWallet("get_transfers", params, true, { connectionClose: false });
324329
if (reply instanceof Error || typeof reply === "string" || !reply || typeof reply !== "object" || !reply.result) {
325330
return { status: "wallet_unavailable", message: describeWalletReply(reply) };
326331
}
@@ -343,6 +348,9 @@ module.exports = function createPaymentsProcessor(ctx, state, deps) {
343348
if (reply && reply.error) {
344349
const message = describeWalletReply(reply);
345350
if (/not found/i.test(message)) return { status: "tx_not_found" };
351+
if (normalizeInteger(reply.error.code) === 0 && reply.error.message === "") {
352+
return { status: "tx_lookup_unavailable", message };
353+
}
346354
return { status: "wallet_unavailable", message };
347355
}
348356
if (!reply || typeof reply !== "object" || !reply.result) {
@@ -357,6 +365,21 @@ module.exports = function createPaymentsProcessor(ctx, state, deps) {
357365
return transfers.length ? { status: "ok", transfers } : { status: "tx_not_found" };
358366
}
359367

368+
async function loadSubmittedTransfersByTxHash(txHash) {
369+
const byTxHash = await loadWalletTransferByTxHash(txHash);
370+
if (byTxHash.status !== "tx_lookup_unavailable") return byTxHash;
371+
372+
const fallback = await loadWalletTransfers({ filterByHeight: false });
373+
if (fallback.status === "ok") {
374+
const transfers = fallback.transfers.filter(function hasTxHash(transfer) {
375+
return normalizeHash(transfer && transfer.txid) === txHash;
376+
});
377+
return transfers.length ? { status: "ok", transfers } : { status: "tx_not_found" };
378+
}
379+
380+
return fallback;
381+
}
382+
360383
async function loadBatchById(batchId) {
361384
const rows = await mysqlPool.query("SELECT * FROM payment_batches WHERE id = ? LIMIT 1", [batchId]);
362385
return Array.isArray(rows) && rows.length ? rows[0] : null;
@@ -436,16 +459,16 @@ module.exports = function createPaymentsProcessor(ctx, state, deps) {
436459
function isReconcilableSubmittedTransfer(transfer) {
437460
if (!transfer || typeof transfer !== "object") return false;
438461
const transferType = typeof transfer.type === "string" ? transfer.type.toLowerCase() : "";
439-
// Submitted batches may only finalize on durable outgoing entries.
440-
// Reject failed, incoming, and still-locked history rows.
441-
if (transferType !== "out") return false;
442-
if (transfer.locked === true) return false;
462+
// Submitted batches may finalize on exact outgoing wallet visibility,
463+
// including the normal pre-confirmation states immediately after send.
464+
if (transferType !== "out" && transferType !== "pending" && transferType !== "pool") return false;
465+
if (transfer.double_spend_seen === true) return false;
443466
return true;
444467
}
445468

446469
async function findSubmittedTransferVisibility(batch, items) {
447470
const txHash = normalizeHash(batch.tx_hash);
448-
const loadedTransfers = await loadWalletTransferByTxHash(txHash);
471+
const loadedTransfers = await loadSubmittedTransfersByTxHash(txHash);
449472
if (loadedTransfers.status !== "ok") return loadedTransfers;
450473
// Once we already know the tx hash, visibility requires both the same
451474
// hash and the same destination set. That keeps a reused hash-shaped

tests/payments/submit_cycle.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,104 @@ test.describe("submit cycle", { concurrency: false }, function submitCycleSuite(
156156
assert.equal(harness.mysql.state.store.transactions[0].transaction_hash, txHash);
157157
});
158158

159+
test("accepted wallet transfer finalizes when the submitted tx is still pending", async () => {
160+
const transferFee = 300000000;
161+
const txHash = "3".repeat(64);
162+
const txKey = "4".repeat(64);
163+
const harness = createHarness({
164+
balances: [
165+
{ id: 1, payment_address: STANDARD_A, payment_id: null, pool_type: "pplns", amount: Math.round(0.2 * COIN) }
166+
],
167+
walletScript: {
168+
transfer: [{
169+
result: {
170+
fee: transferFee,
171+
tx_hash: txHash,
172+
tx_key: txKey
173+
}
174+
}],
175+
get_transfer_by_txid: [function replyPendingTransfer() {
176+
const transfer = txTransferRecord(harness.clock, [{ address: STANDARD_A, amount: transferItemAmount }], {
177+
fee: transferFee,
178+
locked: true,
179+
txid: txHash,
180+
type: "pending"
181+
});
182+
return {
183+
result: {
184+
transfer,
185+
transfers: [transfer]
186+
}
187+
};
188+
}]
189+
}
190+
});
191+
const plannedBatches = await harness.runtime.planBatches();
192+
const transferItemAmount = plannedBatches[0].items[0].netAmount;
193+
194+
await harness.runtime.runCycle();
195+
196+
assert.equal(harness.mysql.state.store.paymentBatches[0].status, "finalized");
197+
assert.equal(harness.mysql.state.store.paymentBatches[0].tx_hash, txHash);
198+
assert.equal(harness.mysql.state.store.paymentBatches[0].tx_key, txKey);
199+
assert.equal(harness.mysql.state.store.transactions.length, 1);
200+
assert.equal(harness.mysql.state.store.payments.length, 1);
201+
assert.equal(harness.sentEmails.some(function hasFyi(entry) { return entry.subject === "FYI: Payment batch 1 awaiting wallet confirmation"; }), false);
202+
assert.equal(harness.wallet.calls.filter(function isTransfers(call) { return call.method === "get_transfer_by_txid"; }).length, 1);
203+
assert.equal(harness.wallet.calls.filter(function isTransfers(call) { return call.method === "get_transfers"; }).length, 0);
204+
});
205+
206+
test("submitted tx visibility falls back to recent transfers after a blank txid lookup error", async () => {
207+
const transferFee = 300000000;
208+
const txHash = "5".repeat(64);
209+
const txKey = "6".repeat(64);
210+
const harness = createHarness({
211+
balances: [
212+
{ id: 1, payment_address: STANDARD_A, payment_id: null, pool_type: "pplns", amount: Math.round(0.2 * COIN) }
213+
],
214+
walletScript: {
215+
transfer: [{
216+
result: {
217+
fee: transferFee,
218+
tx_hash: txHash,
219+
tx_key: txKey
220+
}
221+
}],
222+
get_transfer_by_txid: [{ error: { code: 0, message: "" }, id: "0", jsonrpc: "2.0" }],
223+
get_transfers: [function replyTransfers() {
224+
return {
225+
result: {
226+
out: [],
227+
pending: [txTransferRecord(harness.clock, [{ address: STANDARD_A, amount: transferItemAmount }], {
228+
fee: transferFee,
229+
locked: true,
230+
txid: txHash,
231+
type: "pending"
232+
})],
233+
pool: []
234+
}
235+
};
236+
}]
237+
}
238+
});
239+
const plannedBatches = await harness.runtime.planBatches();
240+
const transferItemAmount = plannedBatches[0].items[0].netAmount;
241+
242+
await harness.runtime.runCycle();
243+
244+
assert.equal(harness.mysql.state.store.paymentBatches[0].status, "finalized");
245+
assert.equal(harness.mysql.state.store.paymentBatches[0].tx_hash, txHash);
246+
assert.equal(harness.mysql.state.store.paymentBatches[0].tx_key, txKey);
247+
assert.equal(harness.mysql.state.store.transactions.length, 1);
248+
assert.equal(harness.mysql.state.store.payments.length, 1);
249+
assert.equal(harness.sentEmails.some(function hasFyi(entry) { return entry.subject === "FYI: Payment batch 1 awaiting wallet confirmation"; }), false);
250+
assert.equal(harness.wallet.calls.filter(function isTransfers(call) { return call.method === "get_transfer_by_txid"; }).length, 1);
251+
assert.equal(harness.wallet.calls.filter(function isHeights(call) { return call.method === "get_height"; }).length, 0);
252+
const historyCalls = harness.wallet.calls.filter(function isTransfers(call) { return call.method === "get_transfers"; });
253+
assert.equal(historyCalls.length, 1);
254+
assert.deepEqual(historyCalls[0].params, { out: true, pending: true, pool: true });
255+
});
256+
159257
test("reserve transaction failure rolls back cleanly and does not update lastPaymentCycle", async () => {
160258
const harness = createHarness({
161259
balances: [

0 commit comments

Comments
 (0)