224 lines
13 KiB
JavaScript
224 lines
13 KiB
JavaScript
// Generated, do not edit. sha256:b635cddd04e7b73f066847eaf0cbf8dfca213ceaa0e4c52ee3c3820f5df5cd3a
|
|||
|
|
import * as db from '../db.js';
|
||
|
|
import { ENUMS } from '../../shared/config.js';
|
||
|
|
import { assertEnum, nowIso } from './_shared.js';
|
||
|
|
import { fail } from '../../shared/app-error.js';
|
||
|
|
import { createEvent, deleteEvent } from './events.js';
|
||
|
|
import { linkProposalTxSoft } from './tx-links.js';
|
||
|
|
import { memoNote } from '../../shared/zap.js';
|
||
|
|
|
||
|
|
const MEMO_OF = `COALESCE(w.memo, (SELECT twin.memo FROM WalletTransaction twin
|
||
|
|
WHERE w.layer = 'lightning' AND twin.layer = 'lightning' AND twin.accountId = w.accountId
|
||
|
|
AND twin.txid = w.txid AND twin.id <> w.id AND twin.memo IS NOT NULL
|
||
|
|
ORDER BY twin.id LIMIT 1))`;
|
||
|
|
|
||
|
|
const NODE_ACCOUNT_OF = `(SELECT node.accountId FROM WalletSource onchain
|
||
|
|
JOIN WalletSource node ON node.connectionId = onchain.connectionId AND node.kind = 'lnd'
|
||
|
|
WHERE onchain.accountId = w.accountId AND onchain.kind IN ('xpub','address')
|
||
|
|
AND node.accountId <> w.accountId
|
||
|
|
ORDER BY node.id LIMIT 1)`;
|
||
|
|
|
||
|
|
function bookingNote(p, prefix = 'reconcile tx', zonderHash = 'reconcile') {
|
||
|
|
const memo = p.walletTransactionId
|
||
|
|
? db.one(`SELECT ${MEMO_OF} AS memo FROM WalletTransaction w WHERE w.id = ?`, [p.walletTransactionId])?.memo : null;
|
||
|
|
const basis = p.transactionHash ? `${prefix} ${p.transactionHash}` : zonderHash;
|
||
|
|
const context = memoNote(memo, p.direction);
|
||
|
|
return context ? `${context} · ${basis}` : basis;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function listPendingProposals() {
|
||
|
|
return db.all(`
|
||
|
|
SELECT p.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name,
|
||
|
|
a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit,
|
||
|
|
w.blockTime AS tx_block_time, w.layer AS tx_layer, w.kind AS tx_kind, w.feeSats AS tx_fee_sats,
|
||
|
|
${MEMO_OF} AS tx_memo,
|
||
|
|
CASE WHEN w.kind = 'channel' THEN (SELECT name FROM Account WHERE id = ${NODE_ACCOUNT_OF}) END AS tx_node_account,
|
||
|
|
-- The NWC wallet the payment went through (user, 19-10-2026): its row with the same hash.
|
||
|
|
(SELECT COALESCE(s.name, '') FROM WalletTransaction twin
|
||
|
|
JOIN WalletSource s ON s.id = twin.sourceId
|
||
|
|
WHERE w.layer = 'lightning' AND twin.layer = 'lightning' AND s.kind = 'nwc'
|
||
|
|
AND twin.accountId = w.accountId AND twin.txid = w.txid AND twin.id <> w.id
|
||
|
|
ORDER BY twin.id LIMIT 1) AS via_wallet
|
||
|
|
FROM ReconcileProposal p
|
||
|
|
JOIN Account ac ON ac.id = p.accountId
|
||
|
|
JOIN Asset a ON a.id = p.assetId
|
||
|
|
LEFT JOIN WalletTransaction w ON w.id = p.walletTransactionId
|
||
|
|
WHERE p.status = 'pending'
|
||
|
|
AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink l WHERE l.walletTransactionId = p.walletTransactionId)
|
||
|
|
ORDER BY COALESCE(w.blockTime, p.detectedAt) DESC, p.id DESC`);
|
||
|
|
}
|
||
|
|
export async function syncTxProposals(accountId, assetId) {
|
||
|
|
db.run(`DELETE FROM ReconcileProposal WHERE status='pending' AND accountId=?1 AND assetId=?2`, [accountId, assetId]);
|
||
|
|
db.run(`INSERT INTO ReconcileProposal
|
||
|
|
(accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt)
|
||
|
|
SELECT w.accountId, ?2, w.id, w.txid, w.valueSats, w.direction,
|
||
|
|
CASE WHEN ac.isOwnCustody THEN 'transfer' ELSE 'trade' END, 'pending', NULL, ?3
|
||
|
|
FROM WalletTransaction w
|
||
|
|
JOIN Account ac ON ac.id = w.accountId
|
||
|
|
WHERE w.accountId = ?1
|
||
|
|
-- LIGHTNING ONLY FROM THE NODE: an NWC wallet is a grouping
|
||
|
|
-- on top of LND, so its payment is booked once, from LND, and the NWC row takes the event
|
||
|
|
-- over by payment hash. On an account WITHOUT a LND source the NWC wallet counts on its own,
|
||
|
|
-- as in its total on Tracking. The same rule stands in hygiene.js.
|
||
|
|
AND (w.layer = 'onchain'
|
||
|
|
OR EXISTS (SELECT 1 FROM WalletSource s
|
||
|
|
WHERE s.id = w.sourceId
|
||
|
|
AND (s.kind = 'lnd'
|
||
|
|
OR (s.kind = 'nwc' AND NOT EXISTS (
|
||
|
|
SELECT 1 FROM WalletSource node
|
||
|
|
WHERE node.accountId = w.accountId AND node.kind = 'lnd')))))
|
||
|
|
AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink l WHERE l.walletTransactionId = w.id)
|
||
|
|
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
|
||
|
|
WHERE p.status = 'ignored'
|
||
|
|
AND ( p.walletTransactionId = w.id
|
||
|
|
-- The amount match only for an ignored proposal WITHOUT a movement
|
||
|
|
-- (a gap proposal). One that hangs on a movement means "this one",
|
||
|
|
-- and matching it on amount suppressed every other movement of the
|
||
|
|
-- same size: one ignored 21-sat zap hid every 21-sat zap.
|
||
|
|
OR (p.walletTransactionId IS NULL
|
||
|
|
AND p.accountId = w.accountId AND p.assetId = ?2
|
||
|
|
AND p.direction = w.direction AND p.amount = w.valueSats) ))`,
|
||
|
|
[accountId, assetId, nowIso()]);
|
||
|
|
const n = db.one(`SELECT changes() AS n`).n;
|
||
|
|
await db.persist();
|
||
|
|
return n;
|
||
|
|
}
|
||
|
|
function getProposal(id) {
|
||
|
|
const p = db.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]);
|
||
|
|
if (!p) fail('home.recUnknownProposal', { id }, `Proposal ${id} no longer exists.`);
|
||
|
|
if (p.status !== 'pending') {
|
||
|
|
fail(`home.recHandled_${p.status}`, { id }, `Proposal ${id} has already been handled (${p.status}).`);
|
||
|
|
}
|
||
|
|
return p;
|
||
|
|
}
|
||
|
|
export async function resolveProposalAsTrade(id, { fiatPrice, fiatFee, occurredAt, priceOrigin }) {
|
||
|
|
const p = getProposal(id);
|
||
|
|
if (fiatPrice === undefined || fiatPrice === null) throw new Error('fiatPrice is required for a trade.');
|
||
|
|
const wtx = p.walletTransactionId ? db.one(`SELECT blockTime, feeSats FROM WalletTransaction WHERE id=?`, [p.walletTransactionId]) : null;
|
||
|
|
const blockTime = wtx && wtx.blockTime ? wtx.blockTime : null;
|
||
|
|
const type = p.direction === 'in' ? 'buy' : 'sell';
|
||
|
|
const txFee = type === 'sell' && wtx && wtx.feeSats ? wtx.feeSats : null;
|
||
|
|
const eventId = await createEvent({
|
||
|
|
assetId: p.assetId, type, occurredAt: occurredAt ?? blockTime ?? p.detectedAt,
|
||
|
|
amount: p.amount, fiatPrice, fiatFee: fiatFee ?? null, unitFee: txFee,
|
||
|
|
priceOrigin: priceOrigin ?? null,
|
||
|
|
fromAccountId: type === 'sell' ? p.accountId : null,
|
||
|
|
toAccountId: type === 'buy' ? p.accountId : null,
|
||
|
|
source: 'reconcile', note: bookingNote(p),
|
||
|
|
});
|
||
|
|
if (p.walletTransactionId) linkProposalTxSoft(p.walletTransactionId, eventId);
|
||
|
|
db.run(`UPDATE ReconcileProposal SET status='accepted_trade', resolvedEventId=? WHERE id=?`, [eventId, id]);
|
||
|
|
await db.persist();
|
||
|
|
return eventId;
|
||
|
|
}
|
||
|
|
export async function resolveProposalAsTransfer(id, { other_account_id, unitFee, occurredAt }) {
|
||
|
|
const p = getProposal(id);
|
||
|
|
if (!other_account_id) {
|
||
|
|
fail('home.recNoCounter', null, 'A counter-account is required for a transfer.');
|
||
|
|
}
|
||
|
|
const wtx = p.walletTransactionId ? db.one(`SELECT blockTime, feeSats FROM WalletTransaction WHERE id=?`, [p.walletTransactionId]) : null;
|
||
|
|
const blockTime = wtx && wtx.blockTime ? wtx.blockTime : null;
|
||
|
|
const txFee = p.direction === 'out' && wtx && wtx.feeSats != null ? wtx.feeSats : null;
|
||
|
|
const fromAccountId = p.direction === 'out' ? p.accountId : other_account_id;
|
||
|
|
const toAccountId = p.direction === 'out' ? other_account_id : p.accountId;
|
||
|
|
const eventId = await createEvent({
|
||
|
|
assetId: p.assetId, type: 'transfer', occurredAt: occurredAt ?? blockTime ?? p.detectedAt,
|
||
|
|
amount: p.amount, fiatPrice: null, fiatFee: null, unitFee: unitFee ?? txFee,
|
||
|
|
fromAccountId, toAccountId, source: 'reconcile',
|
||
|
|
note: bookingNote(p),
|
||
|
|
});
|
||
|
|
if (p.walletTransactionId) linkProposalTxSoft(p.walletTransactionId, eventId);
|
||
|
|
db.run(`UPDATE ReconcileProposal SET status='accepted_transfer', resolvedEventId=? WHERE id=?`, [eventId, id]);
|
||
|
|
await db.persist();
|
||
|
|
return eventId;
|
||
|
|
}
|
||
|
|
export async function resolveProposalAsInternal(id) {
|
||
|
|
const p = getProposal(id);
|
||
|
|
const wtx = p.walletTransactionId
|
||
|
|
? db.one(`SELECT w.blockTime, w.feeSats, w.kind, ${NODE_ACCOUNT_OF} AS nodeAccountId
|
||
|
|
FROM WalletTransaction w WHERE w.id = ?`, [p.walletTransactionId]) : null;
|
||
|
|
if (!wtx || wtx.kind == null) {
|
||
|
|
fail('home.recNotInternal', { id }, `Proposal ${id} is not a movement inside the account.`);
|
||
|
|
}
|
||
|
|
const ander = wtx.kind === 'channel' && wtx.nodeAccountId ? wtx.nodeAccountId : p.accountId;
|
||
|
|
const eventId = await createEvent({
|
||
|
|
assetId: p.assetId, type: 'transfer', occurredAt: wtx.blockTime ?? p.detectedAt,
|
||
|
|
amount: p.amount, fiatPrice: null, fiatFee: null,
|
||
|
|
unitFee: p.direction === 'out' && wtx.feeSats ? wtx.feeSats : null,
|
||
|
|
fromAccountId: p.direction === 'out' ? p.accountId : ander,
|
||
|
|
toAccountId: p.direction === 'out' ? ander : p.accountId, source: 'reconcile',
|
||
|
|
note: bookingNote(p, `reconcile ${wtx.kind}`, `reconcile ${wtx.kind}`),
|
||
|
|
});
|
||
|
|
linkProposalTxSoft(p.walletTransactionId, eventId);
|
||
|
|
db.run(`UPDATE ReconcileProposal SET status='accepted_transfer', resolvedEventId=? WHERE id=?`, [eventId, id]);
|
||
|
|
await db.persist();
|
||
|
|
return eventId;
|
||
|
|
}
|
||
|
|
export async function resolveProposalAsInterest(id) {
|
||
|
|
const p = getProposal(id);
|
||
|
|
const wtx = p.walletTransactionId
|
||
|
|
? db.one(`SELECT blockTime, layer, txid FROM WalletTransaction WHERE id = ?`, [p.walletTransactionId]) : null;
|
||
|
|
if (!wtx || wtx.layer !== 'lightning' || !String(wtx.txid).startsWith('fwd:') || p.direction !== 'in') {
|
||
|
|
fail('home.recNotForward', { id }, `Proposal ${id} is not a lightning forward.`);
|
||
|
|
}
|
||
|
|
const eventId = await createEvent({
|
||
|
|
assetId: p.assetId, type: 'interest', occurredAt: wtx.blockTime ?? p.detectedAt,
|
||
|
|
amount: p.amount, fiatPrice: null, fiatFee: null, unitFee: null,
|
||
|
|
fromAccountId: null, toAccountId: p.accountId, source: 'reconcile', note: bookingNote(p, 'reconcile forward', 'reconcile forward'),
|
||
|
|
});
|
||
|
|
linkProposalTxSoft(p.walletTransactionId, eventId);
|
||
|
|
db.run(`UPDATE ReconcileProposal SET status='accepted_interest', resolvedEventId=? WHERE id=?`, [eventId, id]);
|
||
|
|
await db.persist();
|
||
|
|
return eventId;
|
||
|
|
}
|
||
|
|
export async function ignoreProposal(id) {
|
||
|
|
getProposal(id);
|
||
|
|
db.run(`UPDATE ReconcileProposal SET status='ignored' WHERE id=?`, [id]);
|
||
|
|
await db.persist();
|
||
|
|
}
|
||
|
|
export async function deleteProposal(id) {
|
||
|
|
db.run(`DELETE FROM ReconcileProposal WHERE id=?`, [id]);
|
||
|
|
await db.persist();
|
||
|
|
}
|
||
|
|
export async function ignoreAllPendingProposals() {
|
||
|
|
db.run(`UPDATE ReconcileProposal SET status='ignored' WHERE status='pending'`);
|
||
|
|
const n = db.one(`SELECT changes() AS n`).n;
|
||
|
|
if (n > 0) await db.persist();
|
||
|
|
return n;
|
||
|
|
}
|
||
|
|
export async function deleteAllPendingProposals() {
|
||
|
|
db.run(`DELETE FROM ReconcileProposal WHERE status = 'pending'`);
|
||
|
|
const n = db.one(`SELECT changes() AS n`).n;
|
||
|
|
if (n > 0) await db.persist();
|
||
|
|
return n;
|
||
|
|
}
|
||
|
|
export async function revertProposal(id) {
|
||
|
|
const p = db.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]);
|
||
|
|
if (!p) fail('home.recUnknownProposal', { id }, `Proposal ${id} no longer exists.`);
|
||
|
|
if (p.status === 'accepted_trade' || p.status === 'accepted_transfer' || p.status === 'accepted_interest') {
|
||
|
|
if (p.resolvedEventId) await deleteEvent(p.resolvedEventId);
|
||
|
|
db.run(`UPDATE ReconcileProposal SET status = 'pending', resolvedEventId = NULL WHERE id = ?`, [id]);
|
||
|
|
} else if (p.status === 'ignored') {
|
||
|
|
if (p.walletTransactionId) db.run(`DELETE FROM ReconcileProposal WHERE id = ?`, [id]);
|
||
|
|
else db.run(`UPDATE ReconcileProposal SET status = 'pending' WHERE id = ?`, [id]);
|
||
|
|
} else if (p.status === 'auto_linked') {
|
||
|
|
fail('home.recAutoLinked', { id }, `Proposal ${id} is auto-linked; unlink it through the match popover.`);
|
||
|
|
} else {
|
||
|
|
fail('home.recStillOpen', { id }, `Proposal ${id} (${p.status}) cannot be reverted.`);
|
||
|
|
}
|
||
|
|
await db.persist();
|
||
|
|
}
|
||
|
|
export function listHandledProposals(limit = 50) {
|
||
|
|
return db.all(`
|
||
|
|
SELECT p.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name,
|
||
|
|
a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit,
|
||
|
|
e.type AS event_type, w.blockTime AS tx_block_time
|
||
|
|
FROM ReconcileProposal p
|
||
|
|
JOIN Account ac ON ac.id = p.accountId
|
||
|
|
JOIN Asset a ON a.id = p.assetId
|
||
|
|
LEFT JOIN Event e ON e.id = p.resolvedEventId
|
||
|
|
LEFT JOIN WalletTransaction w ON w.id = p.walletTransactionId
|
||
|
|
WHERE p.status != 'pending'
|
||
|
|
ORDER BY COALESCE(w.blockTime, p.detectedAt) DESC, p.id DESC
|
||
|
|
LIMIT ?`, [limit]);
|
||
|
|
}
|