fix(payments): preserve unknown receipt evidence

This commit is contained in:
2026-10-21 18:05:18 -04:00
parent bc4cd2d810
commit 6d7bd43043
8 changed files with 600 additions and 77 deletions
+133
View File
@@ -52,6 +52,129 @@ describe('late incoming payment reconciliation', () => {
expect(walletService.stopPaymentMonitoring).toHaveBeenCalledWith('late-address');
});
test.each([
['missing status', undefined],
['missing receipts', { complete: false }],
['non-array receipts', { receipts: null }],
['malformed receipts', { receipts: {} }],
['nonfinal positive without receipts', { in_mempool: true, confirmed: false, complete: false, receipts: [] }],
['confirmed without receipts', { confirmed: true, complete: true, receipts: [] }],
['complete without confirmation', { confirmed: false, complete: true, receipts: [] }],
['receipts without confirmation', { confirmed: false, complete: false, receipts: [{}] }]
])('does not mark the invoice scanned on %s', async (_case, status) => {
const row = {
id: 9, user_id: 3, status: 'expired', subaddress: 'late-address',
address_index: 11, expected_amount: '100', provider_id: 'native-monero'
};
const walletService = {
accountIndex: 0, addressToUser: new Map(), addressToSocket: new Map(),
checkPaymentStatus: jest.fn().mockResolvedValue(status),
stopPaymentMonitoring: jest.fn()
};
const gameModeManager = {
reconcileLatePaymentForReview: jest.fn(), paymentProviders: { get: jest.fn() }
};
const service = new LatePaymentReconciler({
db: { query: jest.fn().mockResolvedValue({ rows: [row] }) },
gameModeManager, walletService,
logger: { warn: jest.fn(), error: jest.fn() }
});
expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 });
expect(gameModeManager.reconcileLatePaymentForReview).not.toHaveBeenCalled();
expect(walletService.stopPaymentMonitoring).not.toHaveBeenCalled();
expect(walletService.addressToUser.has(row.subaddress)).toBe(false);
});
test('gateway status alone cannot establish an empty historical receipt scan', async () => {
const provider = { getWalletStatus: jest.fn().mockResolvedValue({
_terminal: true, confirmed: false, complete: false, receipts: []
}) };
const gameModeManager = {
paymentProviders: { get: jest.fn(() => provider) },
reconcileLatePaymentForReview: jest.fn()
};
const service = new LatePaymentReconciler({
db: { query: jest.fn().mockResolvedValue({ rows: [{
id: 13, user_id: 3, subaddress: 'gateway-address',
address_index: 0, expected_amount: '100', provider_id: 'btcpay',
provider_invoice_id: 'gateway-invoice'
}] }) },
gameModeManager,
logger: { warn: jest.fn(), error: jest.fn() }
});
expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 });
expect(gameModeManager.reconcileLatePaymentForReview).not.toHaveBeenCalled();
});
test('does not log upstream response text from a failed gateway receipt read', async () => {
const privateMarker = 'untrusted-provider-response-canary';
const provider = { getWalletStatus: jest.fn().mockRejectedValue(new Error(privateMarker)) };
const logger = { warn: jest.fn(), error: jest.fn() };
const service = new LatePaymentReconciler({
db: { query: jest.fn().mockResolvedValue({ rows: [{
id: 14, user_id: 3, subaddress: 'gateway-address',
address_index: 0, expected_amount: '100', provider_id: 'btcpay',
provider_invoice_id: 'gateway-invoice'
}] }) },
gameModeManager: {
paymentProviders: { get: jest.fn(() => provider) },
reconcileLatePaymentForReview: jest.fn()
},
logger
});
expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 });
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error.mock.calls[0][0].includes(privateMarker)).toBe(false);
});
test('does not advance the marker when the wallet read rejects', async () => {
const walletService = {
accountIndex: 0, addressToUser: new Map(), addressToSocket: new Map(),
checkPaymentStatus: jest.fn().mockRejectedValue(new Error('wallet unavailable')),
stopPaymentMonitoring: jest.fn()
};
const gameModeManager = { reconcileLatePaymentForReview: jest.fn() };
const service = new LatePaymentReconciler({
db: { query: jest.fn().mockResolvedValue({ rows: [{
id: 10, user_id: 3, subaddress: 'late-address',
address_index: 11, expected_amount: '100', provider_id: 'native-monero'
}] }) },
gameModeManager, walletService,
logger: { warn: jest.fn(), error: jest.fn() }
});
expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 });
expect(gameModeManager.reconcileLatePaymentForReview).not.toHaveBeenCalled();
expect(walletService.stopPaymentMonitoring).not.toHaveBeenCalled();
});
test('a successful empty receipt list advances the scan without a review', async () => {
const walletService = {
accountIndex: 0, addressToUser: new Map(), addressToSocket: new Map(),
checkPaymentStatus: jest.fn().mockResolvedValue({
complete: false, confirmed: false, receipts: []
}),
stopPaymentMonitoring: jest.fn()
};
const gameModeManager = {
reconcileLatePaymentForReview: jest.fn().mockResolvedValue({ checked: true, needsReview: false })
};
const service = new LatePaymentReconciler({
db: { query: jest.fn().mockResolvedValue({ rows: [{
id: 11, user_id: 3, subaddress: 'late-address',
address_index: 11, expected_amount: '100', provider_id: 'native-monero'
}] }) },
gameModeManager, walletService,
logger: { warn: jest.fn(), error: jest.fn() }
});
expect(await service.runOnce()).toEqual({ skipped: false, checked: 1, reviews: 0, errors: 0 });
expect(gameModeManager.reconcileLatePaymentForReview).toHaveBeenCalledWith(11, []);
});
test('durable late receipt stays expired and creates review without any entitlement write', async () => {
const calls = [];
const client = {
@@ -66,6 +189,9 @@ describe('late incoming payment reconciliation', () => {
if (/COALESCE\(SUM\(amount\)/i.test(sql)) {
return { rows: [{ total: '100', primary_tx_hash: 'c'.repeat(64), receipt_count: '1' }] };
}
if (/INSERT INTO payment_late_reviews/i.test(sql)) {
return { rows: [{ status: 'needs_review' }], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
})
};
@@ -91,4 +217,11 @@ describe('late incoming payment reconciliation', () => {
expect(calls.some(call => /SET status = CASE WHEN status = 'pending' THEN 'expired'/i.test(call.sql))).toBe(true);
expect(calls.some(call => /UPDATE users|payment_entitlement_grants|credit_transactions/i.test(call.sql))).toBe(false);
});
test('the manager refuses missing evidence before starting a transaction', async () => {
const manager = Object.create(GameModeManager.prototype);
manager.db = { withTransaction: jest.fn() };
await expect(manager.reconcileLatePaymentForReview(12)).rejects.toThrow();
expect(manager.db.withTransaction).not.toHaveBeenCalled();
});
});
+133
View File
@@ -0,0 +1,133 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { createRequire } = require('module');
const { spawnSync } = require('child_process');
const GameModeManager = require('../src/game/gameModeManager');
const { Client } = createRequire(path.join(__dirname, '../src/package.json'))('pg');
function commandAvailable(name) {
return spawnSync(name, ['--version'], { stdio: 'ignore' }).status === 0;
}
const postgresAvailable = process.getuid?.() !== 0
&& commandAvailable('initdb') && commandAvailable('pg_ctl');
function run(name, args, environment) {
const result = spawnSync(name, args, { encoding: 'utf8', env: environment, timeout: 15000 });
if (result.status !== 0) {
throw new Error(`${name} failed for disposable local PostgreSQL fixture`);
}
}
const sql = `
CREATE TABLE payments (
id integer PRIMARY KEY, status text NOT NULL, expected_amount bigint NOT NULL,
provider_id text NOT NULL, address_index integer NOT NULL,
late_receipt_checked_at timestamptz, received_amount bigint
);
CREATE TABLE payment_receipts (
payment_id integer NOT NULL, provider_id text NOT NULL,
evidence_type text NOT NULL, evidence_id text NOT NULL,
tx_hash text, output_id text, address_index integer, amount bigint NOT NULL,
confirmed boolean NOT NULL, UNIQUE (provider_id, evidence_id)
);
CREATE TABLE payment_late_reviews (
payment_id integer UNIQUE NOT NULL, provider_id text NOT NULL,
status text NOT NULL DEFAULT 'needs_review'
CHECK (status IN ('needs_review', 'resolved', 'ignored')),
observed_amount bigint NOT NULL,
expected_amount bigint NOT NULL, receipt_count integer NOT NULL,
metadata jsonb NOT NULL, last_observed_at timestamptz DEFAULT now(),
resolved_at timestamptz
);
`;
(postgresAvailable ? test : test.skip)(
'real PostgreSQL keeps decided late reviews closed while repeated evidence updates open reviews',
async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wow-late-review-'));
const data = path.join(root, 'data');
const socket = path.join(root, 'socket');
fs.mkdirSync(socket, { mode: 0o700 });
const environment = { PATH: process.env.PATH, HOME: root, LANG: 'C', LC_ALL: 'C' };
let startAttempted = false;
let client;
try {
run('initdb', ['-D', data, '-A', 'trust', '-U', 'wow_fixture', '-N'], environment);
startAttempted = true;
run('pg_ctl', ['-D', data, '-l', path.join(root, 'postgres.log'),
'-o', `-c listen_addresses= -c unix_socket_directories=${socket} -p 5432`,
'-w', 'start'], environment);
client = new Client({ host: socket, port: 5432, user: 'wow_fixture',
database: 'postgres', connectionTimeoutMillis: 3000 });
await client.connect();
await client.query(sql);
const manager = Object.create(GameModeManager.prototype);
manager.db = { withTransaction: async callback => {
await client.query('BEGIN');
try {
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
} };
for (const [id, status] of [[1, 'resolved'], [2, 'ignored'], [3, 'needs_review']]) {
await client.query(`INSERT INTO payments
(id, status, expected_amount, provider_id, address_index)
VALUES ($1, 'expired', 100, 'native-monero', $1)`, [id]);
await client.query(`INSERT INTO payment_late_reviews
(payment_id, provider_id, status, observed_amount, expected_amount,
receipt_count, metadata, resolved_at)
VALUES ($1, 'native-monero', $2, $3, 100, 1,
'{"operatorDecision":"retained"}'::jsonb,
CASE WHEN $2 = 'needs_review' THEN NULL ELSE now() END)`,
[id, status, status === 'needs_review' ? 50 : 100]);
const txHash = String(id).repeat(64);
const outputId = String(id + 3).repeat(64);
const result = await manager.reconcileLatePaymentForReview(id, [{
evidenceType: 'chain_output', providerId: 'native-monero',
txHash, outputId, evidenceId: `${txHash}:${outputId}`,
addressIndex: id, amount: '100', confirmed: true
}]);
const observed = (await client.query(`SELECT r.status, r.metadata, r.resolved_at,
r.observed_amount, p.late_receipt_checked_at,
COUNT(pr.payment_id)::integer AS receipts
FROM payment_late_reviews r
JOIN payments p ON p.id = r.payment_id
LEFT JOIN payment_receipts pr ON pr.payment_id = p.id
WHERE p.id = $1
GROUP BY r.status, r.metadata, r.resolved_at, r.observed_amount,
p.late_receipt_checked_at`, [id])).rows[0];
expect(result.checked).toBe(true);
expect(result.needsReview).toBe(status === 'needs_review');
expect(observed.status).toBe(status);
expect(observed.receipts).toBeGreaterThan(0);
expect(observed.observed_amount).toBe('100');
expect(observed.late_receipt_checked_at).not.toBeNull();
if (status !== 'needs_review') {
expect(observed.resolved_at).not.toBeNull();
expect(observed.metadata.operatorDecision).toBe('retained');
}
}
} finally {
if (client) await client.end().catch(() => {});
if (startAttempted) {
const stopped = spawnSync('pg_ctl', ['-D', data, '-m', 'immediate', '-w', 'stop'],
{ env: environment, stdio: 'ignore', timeout: 15000 });
if (stopped.status !== 0) {
throw new Error(`Disposable PostgreSQL fixture stop failed; retained ${root} for inspection`);
}
}
fs.rmSync(root, { recursive: true, force: true });
}
},
30000
);
+35
View File
@@ -96,6 +96,13 @@ describe('BTCPayProvider (Greenfield)', () => {
expect(st.amount).toBe(150000000000); // 1.5 * 1e11
});
test('accepts harmless gateway trailing zeros beyond chain precision', async () => {
const st = await mk('Settled', [{
cryptoCode: 'WOW', totalPaid: '1.500000000000', amount: '1.500000000000'
}], ['WOW']).getWalletStatus('inv1');
expect(st.amount).toBe(150000000000);
});
test('gateway atomic amounts above Number.MAX_SAFE_INTEGER remain exact strings', async () => {
const st = await mk('Settled', [{
cryptoCode: 'XMR',
@@ -114,6 +121,34 @@ describe('BTCPayProvider (Greenfield)', () => {
expect(st.amount).toBe(500000000000);
});
test.each([null, {}, [], [{ cryptoCode: 'WOW', amount: '1' }],
[{ cryptoCode: 'WOW', totalPaid: '0', amount: '1' }],
[{ cryptoCode: 'WOW', totalPaid: '1.2.3', amount: '1' }],
[{ cryptoCode: 'WOW', totalPaid: '1-', amount: '1' }],
[{ cryptoCode: 'WOW', totalPaid: '1.000000000001', amount: '1' }],
[{ cryptoCode: 'UNKNOWN', totalPaid: '1', amount: '1' }],
[{ totalPaid: '1', amount: '1' }]])(
'refuses settled invoices with incomplete payment-method evidence', async (methods) => {
await expect(mk('Settled', methods, ['WOW']).getWalletStatus('inv1')).rejects.toThrow();
}
);
test('refuses a failed payment-method read for a settled invoice', async () => {
const provider = new BTCPayProvider({
baseUrl: 'x', storeId: 's', apiKey: 'k', chains: ['WOW'],
fetchImpl: recordingFetch([
{ method: 'GET', match: /\/invoices\/inv1$/, body: { id: 'inv1', status: 'Settled' } },
{ method: 'GET', match: /\/invoices\/inv1\/payment-methods$/, ok: false, status: 503,
body: { message: 'payment evidence unavailable' } }
])
});
await expect(provider.getWalletStatus('inv1')).rejects.toThrow();
});
test('refuses an unrecognized gateway status instead of reporting an empty scan', async () => {
await expect(mk('Unrecognized', [], ['WOW']).getWalletStatus('inv1')).rejects.toThrow();
});
test('New -> pending, no amount', async () => {
const st = await mk('New', [], ['XMR']).getWalletStatus('inv1');
expect(st.confirmed).toBe(false);
+149 -21
View File
@@ -398,6 +398,22 @@ describe('WalletRPCService Payment Monitoring', () => {
svc.stopPaymentMonitoring(address);
});
test('a failed poll does not call the payer or stop the monitor', async () => {
const callback = jest.fn();
svc.checkPaymentStatus = jest.fn()
.mockRejectedValueOnce(new Error('wallet unavailable'))
.mockResolvedValueOnce({ confirmed: true, complete: true });
svc.startPaymentMonitoring('wow1retry', callback, 100);
await jest.advanceTimersByTimeAsync(100);
expect(callback).not.toHaveBeenCalled();
expect(svc.paymentWatchers.has('wow1retry')).toBe(true);
await jest.advanceTimersByTimeAsync(100);
expect(callback).toHaveBeenCalledTimes(1);
expect(svc.paymentWatchers.has('wow1retry')).toBe(false);
});
test('cleanupUserPayments removes all monitors for user', () => {
const userId = 'user123';
const addr1 = 'wow1addr1';
@@ -429,14 +445,58 @@ describe('WalletRPCService.checkPaymentStatus', () => {
svc = new WalletRPCService(debugManagerMock);
});
test('returns default status for unknown address', async () => {
const status = await svc.checkPaymentStatus('unknown_address');
expect(status.complete).toBe(false);
expect(status.in_mempool).toBe(false);
expect(status.confirmed).toBe(false);
expect(status.amount).toBe(0);
expect(status.confirmations).toBe(0);
test('does not classify an unregistered address as a successful empty wallet read', async () => {
await expect(svc.checkPaymentStatus('unknown_address')).rejects.toMatchObject({
code: 'WALLET_PAYMENT_ADDRESS_UNAVAILABLE'
});
});
test.each([
['explicit empty lists', { in: [], pool: [] }, { transfers: [] }],
['omitted empty lists', {}, {}]
])('distinguishes %s from unknown evidence', async (_case, transfers, outputs) => {
svc.addressToUser.set('wow1empty', {
userId: 'user1', socketId: 'sock1', amount: 100,
addressIndex: 5, accountIndex: 0
});
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({ result: transfers })
.mockResolvedValueOnce({ result: outputs });
const status = await svc.checkPaymentStatus('wow1empty');
expect(status).toEqual(expect.objectContaining({
complete: false, confirmed: false, amount: 0, receipts: []
}));
});
test.each([
['first wallet RPC failure', () => jest.fn().mockRejectedValue(new Error('transport failed'))],
['second wallet RPC failure', () => jest.fn()
.mockResolvedValueOnce({ result: { in: [], pool: [] } })
.mockRejectedValueOnce(new Error('auth failed'))],
['missing transaction result', () => jest.fn()
.mockResolvedValueOnce({ result: null })
.mockResolvedValueOnce({ result: { transfers: [] } })],
['non-array output list', () => jest.fn()
.mockResolvedValueOnce({ result: { in: [], pool: [] } })
.mockResolvedValueOnce({ result: { transfers: null } })],
['malformed transfer list', () => jest.fn()
.mockResolvedValueOnce({ result: { in: {}, pool: [] } })
.mockResolvedValueOnce({ result: { transfers: [] } })]
])('rejects %s instead of reporting zero receipts', async (_case, rpc) => {
svc.addressToUser.set('wow1unknown', {
userId: 'user1', socketId: 'sock1', amount: 100,
addressIndex: 5, accountIndex: 0
});
svc.rpcCall = rpc();
const log = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
await expect(svc.checkPaymentStatus('wow1unknown')).rejects.toMatchObject({
statusCode: expect.any(Number), code: expect.any(String)
});
} finally {
log.mockRestore();
}
});
test('checks mempool and confirmed transfers', async () => {
@@ -524,7 +584,7 @@ describe('WalletRPCService.checkPaymentStatus', () => {
addressIndex: 8, accountIndex: 0, detected: false, confirmed: false
});
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({ result: { pool: [], in: [
.mockResolvedValueOnce({ result: { in: [
confirmedTransfer({ txid: 'b'.repeat(64), amount: 400, height: 301,
confirmations: 3, minor: 8 }),
confirmedTransfer({ txid: 'a'.repeat(64), amount: 600, height: 302,
@@ -577,7 +637,7 @@ describe('WalletRPCService.checkPaymentStatus', () => {
expect(status.receipts.map(receipt => receipt.outputId)).toEqual([firstPubkey, secondPubkey]);
});
test('fails closed when a confirmed transfer has no stable output identifier', async () => {
test('reports unknown evidence when a confirmed transfer lacks its output identifier', async () => {
svc.addressToUser.set('wow1missingoutputid', {
userId: 'user1', socketId: 'sock1', amount: 100,
addressIndex: 14, accountIndex: 0, detected: false, confirmed: false
@@ -590,12 +650,63 @@ describe('WalletRPCService.checkPaymentStatus', () => {
txHash: '8'.repeat(64), amount: 100, blockHeight: 500, minor: 14
})] } });
const status = await svc.checkPaymentStatus('wow1missingoutputid');
const log = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
await expect(svc.checkPaymentStatus('wow1missingoutputid')).rejects.toMatchObject({
code: 'WALLET_RECEIPT_EVIDENCE_INCOMPLETE'
});
} finally {
log.mockRestore();
}
});
expect(status.complete).toBe(false);
expect(status.confirmed).toBe(false);
expect(status.amount).toBe(0);
expect(status.receipts).toEqual([]);
test('reports unknown evidence when the output RPC omits a safe confirmed transfer', async () => {
svc.addressToUser.set('wow1missingoutput', {
userId: 'user1', socketId: 'sock1', amount: 100,
addressIndex: 14, accountIndex: 0
});
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({ result: { pool: [], in: [confirmedTransfer({
txid: '8'.repeat(64), amount: 100, height: 500, confirmations: 1, minor: 14
})] } })
.mockResolvedValueOnce({ result: { transfers: [] } });
const log = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
await expect(svc.checkPaymentStatus('wow1missingoutput')).rejects.toMatchObject({
code: 'WALLET_RECEIPT_EVIDENCE_INCOMPLETE'
});
} finally {
log.mockRestore();
}
});
test.each([
['mismatched block height', { amount: 100, blockHeight: 501 }, true],
['output amount exceeding the transaction', { amount: 101, blockHeight: 500 }, true],
['output without transaction state', { amount: 100, blockHeight: 500 }, false]
])('rejects %s across wallet receipt views', async (_case, outputShape, includeTx) => {
svc.addressToUser.set('wow1contradiction', {
userId: 'user1', socketId: 'sock1', amount: 100,
addressIndex: 14, accountIndex: 0
});
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({ result: includeTx ? { in: [confirmedTransfer({
txid: '8'.repeat(64), amount: 100, height: 500, confirmations: 1, minor: 14
})] } : {} })
.mockResolvedValueOnce({ result: { transfers: [incomingOutput({
txHash: '8'.repeat(64), pubkey: 'a'.repeat(64), globalIndex: 500,
minor: 14, ...outputShape
})] } });
const log = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
await expect(svc.checkPaymentStatus('wow1contradiction')).rejects.toMatchObject({
code: 'WALLET_RECEIPT_EVIDENCE_INCOMPLETE'
});
} finally {
log.mockRestore();
}
});
test('ignores double-spend-marked and explicitly timelocked incoming transfers', async () => {
@@ -608,17 +719,13 @@ describe('WalletRPCService.checkPaymentStatus', () => {
confirmedTransfer({ txid: '7'.repeat(64), amount: 100, height: 600,
confirmations: 2, minor: 15, doubleSpend: true }),
confirmedTransfer({ txid: '6'.repeat(64), amount: 100, height: 601,
confirmations: 2, minor: 15, unlockTime: 42 }),
confirmedTransfer({ txid: '5'.repeat(64), amount: 100, height: 602,
confirmations: 2, minor: 15, unlockTime: Number.MAX_SAFE_INTEGER + 1 })
confirmations: 2, minor: 15, unlockTime: 42 })
] } })
.mockResolvedValueOnce({ result: { transfers: [
incomingOutput({ txHash: '7'.repeat(64), pubkey: 'a'.repeat(64), globalIndex: 600,
amount: 100, blockHeight: 600, minor: 15 }),
incomingOutput({ txHash: '6'.repeat(64), pubkey: 'b'.repeat(64), globalIndex: 601,
amount: 100, blockHeight: 601, minor: 15 }),
incomingOutput({ txHash: '5'.repeat(64), pubkey: 'c'.repeat(64), globalIndex: 602,
amount: 100, blockHeight: 602, minor: 15 })
amount: 100, blockHeight: 601, minor: 15 })
] } });
const status = await svc.checkPaymentStatus('wow1unsafeoutputs');
@@ -629,6 +736,27 @@ describe('WalletRPCService.checkPaymentStatus', () => {
expect(status.receipts).toEqual([]);
});
test('rejects an incoming transfer with an unsafe numeric unlock time', async () => {
svc.addressToUser.set('wow1invalidunlock', {
userId: 'user1', socketId: 'sock1', amount: 100,
addressIndex: 15, accountIndex: 0
});
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({ result: { in: [confirmedTransfer({
txid: '5'.repeat(64), amount: 100, height: 602,
confirmations: 2, minor: 15, unlockTime: Number.MAX_SAFE_INTEGER + 1
})] } })
.mockResolvedValueOnce({ result: { transfers: [] } });
const log = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
await expect(svc.checkPaymentStatus('wow1invalidunlock')).rejects.toMatchObject({
code: 'WALLET_RECEIPT_EVIDENCE_INCOMPLETE'
});
} finally {
log.mockRestore();
}
});
test('requires the configured minimum confirmations and rejects frozen output evidence', async () => {
svc = new WalletRPCService(debugManagerMock, { minConfirmations: 2 });
svc.addressToUser.set('wow1depth', {