Files
AtreidesAssetsFree/tests/repo/proposals.test.mjs
T

576 lines
27 KiB
JavaScript
Raw Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:eb712800d03552fa4079bd778c098f3fc2fb75b87a3722d0d27803f76118c2b9
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
deleteProposal, ignoreAllPendingProposals, ignoreProposal, listPendingProposals,
resolveProposalAsInterest, resolveProposalAsInternal, resolveProposalAsTrade, resolveProposalAsTransfer,
revertProposal, syncTxProposals
} = await import('../../server/data/proposals.js');
const { linkTxEvent } = await import('../../server/data/tx-links.js');
const { eventsNetByAccount } = await import('../../server/data/events.js');
let ctx;
beforeEach(() => {
harness.recreate();
ctx = {
btc: asset('BTC'),
eigen: account('Hardware wallet', { ownCustody: true }),
beurs: account('Exchange', { ownCustody: false }),
};
});
function asset(symbol) {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`, [symbol, symbol, toSearchName(symbol), 'crypto', symbol, null, 8, 'unit']);
return harness.exports.lastId();
}
function account(naam, { ownCustody = true } = {}) {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, [naam, toSearchName(naam), ownCustody ? 'wallet' : 'exchange', ownCustody ? 1 : 0, 1]);
return harness.exports.lastId();
}
function beweging({ account: acc = null, direction = 'in', valueSats = 250_000, txid = 'tx-1',
blockTime = '2024-06-01T10:00:00', feeSats = null } = {}) {
harness.exports.run(
`INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, feeSats, address, blockHeight, blockTime, createdAt)
VALUES (?,'onchain',?,?,?,?,?,?,?,?)`,
[acc ?? ctx.eigen, txid, direction, valueSats, feeSats, null,
blockTime ? 840_000 : null, blockTime, '2024-06-02T00:00:00']);
return harness.exports.lastId();
}
const voorstellen = () => harness.exports.all(
`SELECT id, walletTransactionId, amount, direction, suggestedType, status, resolvedEventId
FROM ReconcileProposal ORDER BY id`);
const events = () => harness.exports.all(`SELECT * FROM Event ORDER BY id`);
describe('syncTxProposals', () => {
it('maakt één voorstel per beweging zonder koppeling', async () => {
beweging({ txid: 'a' });
beweging({ txid: 'b' });
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 2);
assert.equal(voorstellen().length, 2);
assert.ok(voorstellen().every(p => p.status === 'pending'));
});
describe('lightning', () => {
let nodes = 0;
function bron(acc, kind) {
let connectionId = null;
if (kind === 'lnd') {
harness.exports.run(`INSERT INTO Connection (kind, name, searchName, url, isActive, isSystem, createdAt, updatedAt)
VALUES ('lnd', ?, ?, 'https://node.test', 1, 0, '2024-01-01', '2024-01-01')`, [`Node ${++nodes}`, `node ${nodes}`]);
connectionId = harness.exports.lastId();
}
harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, name, createdAt)
VALUES (?, ?, ?, ?, ?, '2026-01-01')`, [acc, connectionId, kind, `${kind}-${acc}`, kind === 'nwc' ? 'Zaps' : null]);
return harness.exports.lastId();
}
function betaling(acc, sourceId, txid = 'hash-zap', feeSats = 1) {
harness.exports.run(`INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, direction, valueSats, feeSats, blockTime, createdAt)
VALUES (?, ?, 'lightning', ?, 'out', 21, ?, '2026-10-19T10:00:00Z', '2026-10-19')`, [acc, sourceId, txid, feeSats]);
return harness.exports.lastId();
}
it('één voorstel voor een betaling op LND en NWC: vanaf de node', async () => {
const lnd = betaling(ctx.eigen, bron(ctx.eigen, 'lnd'));
betaling(ctx.eigen, bron(ctx.eigen, 'nwc'));
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1);
assert.deepEqual(voorstellen().map(p => p.walletTransactionId), [lnd]);
});
it('een NWC-wallet op een account zonder LND-bron krijgt wel een voorstel', async () => {
const nwc = betaling(ctx.eigen, bron(ctx.eigen, 'nwc'));
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1);
assert.deepEqual(voorstellen().map(p => p.walletTransactionId), [nwc]);
});
it('een LND-node op een ander account telt niet voor deze NWC-wallet', async () => {
bron(ctx.beurs, 'lnd');
const nwc = betaling(ctx.eigen, bron(ctx.eigen, 'nwc'));
await syncTxProposals(ctx.eigen, ctx.btc);
assert.deepEqual(voorstellen().map(p => p.walletTransactionId), [nwc]);
});
it('de lijst zegt via welke NWC-wallet de betaling liep', async () => {
betaling(ctx.eigen, bron(ctx.eigen, 'lnd'));
betaling(ctx.eigen, bron(ctx.eigen, 'nwc'));
betaling(ctx.eigen, bron(ctx.eigen, 'lnd'), 'alleen-node');
await syncTxProposals(ctx.eigen, ctx.btc);
const rijen = listPendingProposals().map(p => [p.transactionHash, p.tx_layer, p.via_wallet]);
assert.deepEqual(rijen.sort(), [['alleen-node', 'lightning', null], ['hash-zap', 'lightning', 'Zaps']]);
});
it('neemt de memo van de NWC-tweeling mee, en zet de zap in de notitie van de boeking', async () => {
betaling(ctx.eigen, bron(ctx.eigen, 'lnd'));
const nwc = betaling(ctx.eigen, bron(ctx.eigen, 'nwc'));
const zap = JSON.stringify({ kind: 9734, pubkey: 'c'.repeat(64), content: 'Dank!',
tags: [['p', '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d']] });
harness.exports.run(`UPDATE WalletTransaction SET memo = ? WHERE id = ?`, [zap, nwc]);
await syncTxProposals(ctx.eigen, ctx.btc);
assert.equal(listPendingProposals()[0].tx_memo, zap);
const eventId = await resolveProposalAsTrade(voorstellen()[0].id, { fiatPrice: 60_000 });
assert.equal(events().find(x => x.id === eventId).note, 'zap to npub180cv…h6w6: "Dank!" · reconcile tx hash-zap');
});
it('als verkoop zet hij de fee op de gebeurtenis', async () => {
betaling(ctx.eigen, bron(ctx.eigen, 'lnd'), 'hash-zap', 3);
await syncTxProposals(ctx.eigen, ctx.btc);
const eventId = await resolveProposalAsTrade(voorstellen()[0].id, { fiatPrice: 60_000 });
const e = events().find(x => x.id === eventId);
assert.equal(e.type, 'sell');
assert.equal(e.amount, 21);
assert.equal(e.unitFee, 3);
});
});
it('slaat bewegingen over die al gekoppeld zijn', async () => {
const tx = beweging();
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?)`, [ctx.btc, 'transfer', '2024-06-01T10:00:00', 250_000, ctx.eigen, 'manual', '2024-06-01T10:00:00']);
const ev = harness.exports.lastId();
await linkTxEvent(tx, ev, 'manual');
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 0,
'een beweging met een link is geen gat meer');
});
it('stelt transfer voor bij eigen custody en trade bij een beurs', async () => {
beweging({ account: ctx.eigen, txid: 'eigen' });
await syncTxProposals(ctx.eigen, ctx.btc);
assert.equal(voorstellen()[0].suggestedType, 'transfer');
beweging({ account: ctx.beurs, txid: 'beurs' });
await syncTxProposals(ctx.beurs, ctx.btc);
const bijBeurs = voorstellen().find(p => p.suggestedType === 'trade');
assert.ok(bijBeurs, 'zonder eigen custody hoort een trade voorgesteld te worden');
});
it('is idempotent: een herscan levert geen dubbele voorstellen', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
await syncTxProposals(ctx.eigen, ctx.btc);
assert.equal(voorstellen().length, 1, 'pending wordt eerst opgeruimd, dan geregenereerd');
});
it('raakt de voorstellen van een ánder account niet aan', async () => {
beweging({ account: ctx.eigen, txid: 'a' });
beweging({ account: ctx.beurs, txid: 'b' });
await syncTxProposals(ctx.eigen, ctx.btc);
await syncTxProposals(ctx.beurs, ctx.btc);
assert.equal(voorstellen().length, 2);
});
describe('suppressie van genegeerde voorstellen', () => {
it('laat een genegeerd voorstel niet terugkomen (match op walletTransactionId)', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(voorstellen()[0].id);
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 0);
assert.deepEqual(voorstellen().map(p => p.status), ['ignored'],
'het genegeerde voorstel blijft, er komt niets nieuws bij');
});
it('onderdrukt op (account, asset, richting, bedrag), ook zonder tx-verwijzing', async () => {
harness.exports.run(
`INSERT INTO ReconcileProposal
(accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, detectedAt)
VALUES (?,?, NULL, NULL, ?, ?, 'transfer', 'ignored', ?)`,
[ctx.eigen, ctx.btc, 777_000, 'in', '2024-05-01T00:00:00']);
beweging({ txid: 'nieuw-na-herimport', valueSats: 777_000, direction: 'in' });
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 0,
'zelfde account/asset/richting/bedrag → nog steeds onderdrukt');
});
it('een genegeerde beweging onderdrukt geen andere beweging met hetzelfde bedrag', async () => {
beweging({ txid: 'a', valueSats: 21, direction: 'out' });
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(voorstellen()[0].id);
const tweede = beweging({ txid: 'b', valueSats: 21, direction: 'out' });
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1);
assert.deepEqual(voorstellen().filter(p => p.status === 'pending').map(p => p.walletTransactionId), [tweede]);
});
it('onderdrukt niet bij een ander bedrag', async () => {
beweging({ txid: 'a', valueSats: 100 });
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(voorstellen()[0].id);
beweging({ txid: 'b', valueSats: 200 });
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1,
'een ander bedrag is een ander gat');
});
it('onderdrukt na hergebruik van een tx-id GEEN onverwante beweging meer', async () => {
beweging({ txid: 'oud', valueSats: 100 });
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(voorstellen()[0].id);
assert.equal(voorstellen().length, 1);
harness.exports.run(`DELETE FROM WalletTransaction`);
assert.deepEqual(voorstellen(), [], 'CASCADE: het voorstel gaat mee met zijn beweging');
beweging({ txid: 'volstrekt-andere-tx', valueSats: 999_999 });
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1,
'een onverwante beweging krijgt weer een gewoon voorstel');
});
});
});
describe('listPendingProposals', () => {
it('toont alleen pending, verrijkt met account- en assetnaam', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
const lijst = listPendingProposals();
assert.equal(lijst.length, 1);
assert.equal(lijst[0].account_name, 'Hardware wallet');
assert.equal(lijst[0].asset_symbol, 'BTC');
assert.equal(lijst[0].asset_decimals, 8);
});
it('verbergt een voorstel zodra de beweging gekoppeld raakt', async () => {
const tx = beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
assert.equal(listPendingProposals().length, 1);
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?)`, [ctx.btc, 'transfer', '2024-06-01T10:00:00', 250_000, ctx.eigen, 'manual', '2024-06-01T10:00:00']);
await linkTxEvent(tx, harness.exports.lastId(), 'manual');
assert.deepEqual(listPendingProposals(), [],
'een gekoppelde beweging is geen openstaand gat meer');
});
it('verbergt afgehandelde voorstellen', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(listPendingProposals()[0].id);
assert.deepEqual(listPendingProposals(), []);
});
it('draagt het moment van de TRANSACTIE mee en niet dat van het voorstel', async () => {
beweging({ blockTime: '2021-03-04T12:00:00' });
await syncTxProposals(ctx.eigen, ctx.btc);
const p = listPendingProposals()[0];
assert.equal(p.tx_block_time, '2021-03-04T12:00:00');
assert.notEqual(p.tx_block_time, p.detectedAt, 'anders bewijst deze test niets');
});
it('houdt een ONBEVESTIGDE beweging in de lijst, zonder moment', async () => {
beweging({ blockTime: null });
await syncTxProposals(ctx.eigen, ctx.btc);
const lijst = listPendingProposals();
assert.equal(lijst.length, 1, 'een onbevestigde beweging hoort nog steeds afgehandeld te worden');
assert.equal(lijst[0].tx_block_time, null);
assert.ok(lijst[0].detectedAt, 'dan is het moment van het voorstel het enige dat er is');
});
it('houdt een GAT-voorstel in de lijst, dat aan geen transactie hangt', async () => {
harness.exports.run(
`INSERT INTO ReconcileProposal (accountId, assetId, walletTransactionId, amount, direction, suggestedType, status, detectedAt)
VALUES (?,?,NULL,?,?,?,'pending',?)`,
[ctx.eigen, ctx.btc, 100_000, 'in', 'transfer', '2024-06-02T00:00:00']);
const lijst = listPendingProposals();
assert.equal(lijst.length, 1);
assert.equal(lijst[0].walletTransactionId, null);
assert.equal(lijst[0].tx_block_time, null);
});
it('sorteert op het moment van de beweging, nieuwste eerst', async () => {
beweging({ txid: 'oud', blockTime: '2019-01-01T00:00:00' });
beweging({ txid: 'nieuw', blockTime: '2025-01-01T00:00:00' });
beweging({ txid: 'midden', blockTime: '2022-01-01T00:00:00' });
await syncTxProposals(ctx.eigen, ctx.btc);
assert.deepEqual(listPendingProposals().map(p => p.transactionHash), ['nieuw', 'midden', 'oud']);
});
});
describe('resolveProposalAsTrade', () => {
const eersteVoorstel = async (opts) => {
beweging(opts);
await syncTxProposals(opts?.account ?? ctx.eigen, ctx.btc);
return voorstellen()[0].id;
};
it('maakt van een inkomende beweging een buy op het account', async () => {
const id = await eersteVoorstel({ direction: 'in' });
const eventId = await resolveProposalAsTrade(id, { fiatPrice: 50_000, fiatFee: null, occurredAt: null });
const e = events()[0];
assert.equal(e.id, eventId);
assert.equal(e.type, 'buy');
assert.equal(e.toAccountId, ctx.eigen);
assert.equal(e.fromAccountId, null);
assert.equal(e.amount, 250_000);
assert.equal(e.source, 'reconcile');
});
it('maakt van een uitgaande beweging een sell vanaf het account', async () => {
const id = await eersteVoorstel({ direction: 'out' });
await resolveProposalAsTrade(id, { fiatPrice: 50_000, fiatFee: null, occurredAt: null });
const e = events()[0];
assert.equal(e.type, 'sell');
assert.equal(e.fromAccountId, ctx.eigen);
assert.equal(e.toAccountId, null);
});
it('eist een fiatPrice', async () => {
const id = await eersteVoorstel();
await assert.rejects(() => resolveProposalAsTrade(id, { fiatPrice: null }), /fiatPrice/);
assert.deepEqual(events(), [], 'zonder prijs mag er geen event ontstaan');
assert.equal(voorstellen()[0].status, 'pending', 'en het voorstel blijft open');
});
it('zet het voorstel op accepted_trade met een verwijzing naar het event', async () => {
const id = await eersteVoorstel();
const eventId = await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
const p = voorstellen()[0];
assert.equal(p.status, 'accepted_trade');
assert.equal(p.resolvedEventId, eventId);
});
it('legt een soft-koppeling met de beweging', async () => {
const id = await eersteVoorstel();
const eventId = await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
const l = harness.exports.all(`SELECT method, eventId FROM WalletTransactionEventLink`);
assert.deepEqual(l, [{ method: 'soft', eventId: eventId }]);
});
it('weigert een tweede afhandeling van hetzelfde voorstel', async () => {
const id = await eersteVoorstel();
await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
await assert.rejects(() => resolveProposalAsTrade(id, { fiatPrice: 1 }), (e) => e.key === 'home.recHandled_accepted_trade');
assert.equal(events().length, 1, 'geen tweede event');
});
it('weigert een onbekend voorstel', async () => {
await assert.rejects(() => resolveProposalAsTrade(9999, { fiatPrice: 1 }), (e) => e.key === 'home.recUnknownProposal');
});
describe('welke datum wordt gekozen', () => {
it('gebruikt een expliciete occurredAt als die er is', async () => {
const id = await eersteVoorstel({ blockTime: '2024-06-01T10:00:00' });
await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: '2024-01-01T00:00:00' });
assert.equal(events()[0].occurredAt, '2024-01-01T00:00:00');
});
it('valt terug op de blockTime van de beweging', async () => {
const id = await eersteVoorstel({ blockTime: '2024-06-01T10:00:00' });
await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
assert.equal(events()[0].occurredAt, '2024-06-01T10:00:00');
});
it('valt daarna terug op het detectiemoment van het voorstel', async () => {
const id = await eersteVoorstel({ blockTime: null });
await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
const detected = harness.exports.one(`SELECT detectedAt FROM ReconcileProposal`).detectedAt;
assert.equal(events()[0].occurredAt, detected);
});
});
});
describe('resolveProposalAsTransfer', () => {
const eersteVoorstel = async (opts) => {
beweging(opts);
await syncTxProposals(ctx.eigen, ctx.btc);
return voorstellen()[0].id;
};
it('zet bij uitgaand het eigen account als bron en de tegenrekening als bestemming', async () => {
const id = await eersteVoorstel({ direction: 'out' });
await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null });
const e = events()[0];
assert.equal(e.type, 'transfer');
assert.equal(e.fromAccountId, ctx.eigen);
assert.equal(e.toAccountId, ctx.beurs);
});
it('draait de richting om bij inkomend', async () => {
const id = await eersteVoorstel({ direction: 'in' });
await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null });
const e = events()[0];
assert.equal(e.fromAccountId, ctx.beurs);
assert.equal(e.toAccountId, ctx.eigen);
});
it('eist een tegenrekening', async () => {
const id = await eersteVoorstel();
await assert.rejects(() => resolveProposalAsTransfer(id, { other_account_id: null }), (e) => e.key === 'home.recNoCounter');
assert.deepEqual(events(), []);
});
it('neemt de netwerkfee over bij een uitgaande beweging', async () => {
const id = await eersteVoorstel({ direction: 'out', feeSats: 1_200 });
await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null });
assert.equal(events()[0].unitFee, 1_200);
});
it('neemt de fee NIET over bij een inkomende beweging', async () => {
const id = await eersteVoorstel({ direction: 'in', feeSats: 1_200 });
await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null });
assert.equal(events()[0].unitFee, null);
});
it('laat een expliciete unitFee voorgaan op de netwerkfee', async () => {
const id = await eersteVoorstel({ direction: 'out', feeSats: 1_200 });
await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: 99, occurredAt: null });
assert.equal(events()[0].unitFee, 99);
});
it('zet het voorstel op accepted_transfer', async () => {
const id = await eersteVoorstel();
const eventId = await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null });
const p = voorstellen()[0];
assert.equal(p.status, 'accepted_transfer');
assert.equal(p.resolvedEventId, eventId);
});
});
describe('resolveProposalAsInternal', () => {
const voorstelVoor = async (wtx, kind) => {
harness.exports.run(`UPDATE WalletTransaction SET kind = ? WHERE id = ?`, [kind, wtx]);
await syncTxProposals(ctx.eigen, ctx.btc);
return voorstellen().find(p => p.walletTransactionId === wtx).id;
};
it('boekt een kanaalopening als verplaatsing naar hetzelfde account: netto alleen de fee', async () => {
const wtx = beweging({ direction: 'out', valueSats: 1_000_000, feeSats: 450 });
const eventId = await resolveProposalAsInternal(await voorstelVoor(wtx, 'channel'));
const e = events().find(x => x.id === eventId);
assert.equal(e.type, 'transfer');
assert.equal(e.fromAccountId, ctx.eigen);
assert.equal(e.toAccountId, ctx.eigen);
assert.equal(e.amount, 1_000_000);
assert.equal(e.unitFee, 450);
assert.equal(eventsNetByAccount(ctx.eigen, ctx.btc), -450);
assert.equal(voorstellen()[0].status, 'accepted_transfer');
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM WalletTransactionEventLink WHERE walletTransactionId = ?`, [wtx]).n, 1);
});
it('een kanaalsluiting komt binnen zonder fee: netto nul', async () => {
const wtx = beweging({ direction: 'in', valueSats: 990_000, feeSats: 300 });
await resolveProposalAsInternal(await voorstelVoor(wtx, 'channel'));
assert.equal(events()[0].unitFee, null);
assert.equal(eventsNetByAccount(ctx.eigen, ctx.btc), 0);
});
it('een kanaal van een gekoppelde wallet op een eigen account gaat naar het account van de node', async () => {
harness.exports.run(`INSERT INTO Connection (kind, name, searchName, url, isActive, isSystem, createdAt, updatedAt)
VALUES ('lnd', 'Node', 'node', 'https://node.test', 1, 0, '2024-01-01', '2024-01-01')`);
const conn = harness.exports.lastId();
const node = account('Lightning');
harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, createdAt) VALUES (?, ?, 'lnd', '', '2026-01-01')`, [node, conn]);
harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, scriptType, createdAt) VALUES (?, ?, 'xpub', 'zpub', 'p2wpkh', '2026-01-01')`, [ctx.eigen, conn]);
const wtx = beweging({ direction: 'out', valueSats: 500_000, feeSats: 200 });
const id = await voorstelVoor(wtx, 'channel');
assert.equal(listPendingProposals()[0].tx_node_account, 'Lightning');
const eventId = await resolveProposalAsInternal(id);
const e = events().find(x => x.id === eventId);
assert.deepEqual([e.type, e.fromAccountId, e.toAccountId, e.amount, e.unitFee], ['transfer', ctx.eigen, node, 500_000, 200]);
assert.equal(eventsNetByAccount(ctx.eigen, ctx.btc), -500_200);
assert.equal(eventsNetByAccount(node, ctx.btc), 500_000);
});
it('weigert een gewone beweging, die heeft een tegenpartij', async () => {
beweging({ direction: 'out' });
await syncTxProposals(ctx.eigen, ctx.btc);
await assert.rejects(() => resolveProposalAsInternal(voorstellen()[0].id), (e) => e.key === 'home.recNotInternal');
assert.deepEqual(events(), []);
assert.equal(voorstellen()[0].status, 'pending');
});
it('de lijst draagt de soort en de fee mee', async () => {
const wtx = beweging({ direction: 'out', feeSats: 12 });
await voorstelVoor(wtx, 'channel');
const p = listPendingProposals()[0];
assert.equal(p.tx_kind, 'channel');
assert.equal(p.tx_fee_sats, 12);
});
});
describe('resolveProposalAsInterest', () => {
function forward(txid = 'fwd:1:2:3') {
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt) VALUES (?, 'nwc', 'x', '2026-01-01')`, [ctx.eigen]);
harness.exports.run(`INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, direction, valueSats, blockTime, createdAt)
VALUES (?, ?, 'lightning', ?, 'in', 7, '2026-10-19T10:00:00Z', '2026-10-19')`, [ctx.eigen, harness.exports.lastId(), txid]);
return harness.exports.lastId();
}
it('boekt een forward als rente op het account, en terugdraaien haalt hem weg', async () => {
const wtx = forward();
await syncTxProposals(ctx.eigen, ctx.btc);
const id = voorstellen().find(p => p.walletTransactionId === wtx).id;
const eventId = await resolveProposalAsInterest(id);
const e = events().find(x => x.id === eventId);
assert.deepEqual([e.type, e.amount, e.toAccountId, e.fromAccountId, e.fiatPrice], ['interest', 7, ctx.eigen, null, null]);
assert.equal(e.occurredAt, '2026-10-19T10:00:00Z');
assert.equal(voorstellen()[0].status, 'accepted_interest');
await revertProposal(id);
assert.deepEqual(events(), []);
assert.equal(voorstellen()[0].status, 'pending');
});
it('weigert een gewone inkomende betaling', async () => {
const wtx = forward('hash-gewoon');
await syncTxProposals(ctx.eigen, ctx.btc);
const id = voorstellen().find(p => p.walletTransactionId === wtx).id;
await assert.rejects(() => resolveProposalAsInterest(id), (e) => e.key === 'home.recNotForward');
assert.deepEqual(events(), []);
});
});
describe('negeren en verwijderen', () => {
it('ignoreProposal zet één voorstel op ignored', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(voorstellen()[0].id);
assert.equal(voorstellen()[0].status, 'ignored');
});
it('ignoreProposal weigert een al afgehandeld voorstel', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
const id = voorstellen()[0].id;
await ignoreProposal(id);
await assert.rejects(() => ignoreProposal(id), (e) => e.key === 'home.recHandled_ignored');
});
it('ignoreAllPendingProposals negeert er meerdere en meldt het aantal', async () => {
beweging({ txid: 'a' });
beweging({ txid: 'b' });
beweging({ txid: 'c' });
await syncTxProposals(ctx.eigen, ctx.btc);
assert.equal(await ignoreAllPendingProposals(), 3);
assert.ok(voorstellen().every(p => p.status === 'ignored'));
});
it('ignoreAllPendingProposals schrijft niet als er niets openstaat', async () => {
const voor = harness.calls.persist;
assert.equal(await ignoreAllPendingProposals(), 0);
assert.equal(harness.calls.persist, voor, 'geen wijziging, geen schrijfactie');
});
it('deleteProposal laat het aangemaakte event en de koppeling staan', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
const id = voorstellen()[0].id;
const eventId = await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
await deleteProposal(id);
assert.deepEqual(voorstellen(), []);
assert.equal(events().length, 1);
assert.equal(events()[0].id, eventId);
assert.equal(harness.exports.all(`SELECT * FROM WalletTransactionEventLink`).length, 1);
});
});