mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
fix(payments): preserve unknown receipt evidence
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user