import { Transaction } from 'bitcoinjs-lib'; import { ContractModel } from './ContractManager'; import { hash_to_hex, hasOwn, Outpoint, TXIDAndWTXIDMap } from '../util'; import { clamp } from 'lodash'; import { DiagramModel } from '@projectstorm/react-diagrams-core'; import { selectNodePollFreq } from '../Settings/SettingsSlice'; import { store } from '../Store/store'; import { load_status } from './DataSlice'; import { TransactionState } from '../UX/Diagram/DiagramComponents/TransactionNode/TransactionNodeModel'; type TXID = string; export type Status = { txid: TXID; confirmations: number | null }; export function call(method: string, args: any) { return fetch(method, { method: 'post', body: JSON.stringify(args), headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }).then((res) => res.json()); } interface IProps { model: DiagramModel; current_contract: ContractModel; } export function update_broadcastable( current_contract: ContractModel, limbo_tx: TXID ) { const tm = TXIDAndWTXIDMap.get_by_txid_s( current_contract.txid_map, limbo_tx ); if (!tm) { throw new Error( `Invariant Error: ${limbo_tx} must exist in contract model` ); } } function swap(arr: any[], i: number, j: number) { if (i === j) return; const x = arr[i]; arr[i] = arr[j]; arr[j] = x; } function compute_impossible( state: Record<TXID, TransactionState>, cm: ContractModel ): Record<TXID, TransactionState> { const unspendable: Record<TXID, Record<number, null>> = {}; for (const [txid, status] of Object.entries(state)) { if (status !== 'Confirmed' && status !== 'Impossible') continue; const tmi = TXIDAndWTXIDMap.get_by_txid_s(cm.txid_map, txid)!; if (!tmi) throw new Error('All txns Are Expected to be Present'); // If confirmed, all inputs are now unspendable if (status === 'Confirmed') { for (const inp of tmi.getOptions().txn.ins) { const hash = hash_to_hex(inp.hash); if (hasOwn(unspendable, hash)) unspendable[hash]![inp.index] = null; else { unspendable[hash] = { [inp.index]: null }; } } } // If impossible, all outputs are now unspendable if (status === 'Impossible') { const length = tmi.getOptions().txn.outs.length; const obj: Record<number, null> = Object.fromEntries( Array.from({ length }, () => 0).map((v, a) => [a, null]) ); console.log('OBJ', obj); unspendable[txid] = obj; } } let changed; const mutable_state: Record<TXID, { length: number; inps: Outpoint[] }> = Object.fromEntries( Object.entries(state) .filter(([_, f]) => !(f === 'Confirmed' || f === 'Impossible')) .map(([k, _]) => { const tmi = TXIDAndWTXIDMap.get_by_txid_s(cm.txid_map, k)!; if (!tmi) throw new Error('All txns Are Expected to be Present'); return [ k, { length: tmi.getOptions().txn.outs.length, inps: tmi.getOptions().txn.ins.map((inp) => { return { hash: hash_to_hex(inp.hash), nIn: inp.index, }; }), }, ]; }) ); do { changed = false; for (const [txid, { length, inps }] of Object.entries(mutable_state)) { console.log(txid); for (const { hash, nIn } of inps) { if (hasOwn(unspendable[hash] ?? {}, nIn)) { const obj: Record<number, null> = Object.fromEntries( Array.from({ length }, () => 0).map((v, a) => [a, null]) ); unspendable[txid] = obj; changed = true; state[txid] = 'Impossible'; delete mutable_state[txid]; break; } } } } while (changed); return state; } function derive_state( state: Record<TXID, Status | null>, cm: ContractModel ): Record<TXID, TransactionState> { const memo: Record<TXID, TransactionState> = {}; const unknown: Array<Status> = []; for (const [txid, status] of Object.entries(state)) { if (!status) throw new Error('All Statuses Are Expected to be Present'); let conf: TransactionState; if (!status) conf = 'Unknown'; else if (status.confirmations === null) conf = 'Unknown'; else if (status.confirmations > 0) conf = 'Confirmed'; else if (status.confirmations === 0) conf = 'InMempool'; else if (status.confirmations < 0) conf = 'Impossible'; else conf = 'Unknown'; if (conf === 'Unknown') unknown.push(status); else memo[txid] = conf; } /// this should usually be good! unknown.reverse(); let any_changed; do { any_changed = false; const n = unknown.length; for (let l = 0; l < n; ++l) { const status = unknown.pop()!; const tmi = TXIDAndWTXIDMap.get_by_txid_s( cm.txid_map, status.txid )!; let should_next_tx = false; for (const inp of tmi.tx.ins) { switch (memo[hash_to_hex(inp.hash)]) { case undefined: { unknown.push(status); swap(unknown, 0, unknown.length - 1); should_next_tx = true; break; } case 'Confirmed': case 'InMempool': break; case 'Impossible': { memo[status.txid] = 'Impossible'; any_changed = true; should_next_tx = true; break; } // in these cases, something else must be broadcast first. // note: no parent at this phase could go from Unknown -> Confirmed // since that was handled earlier. case 'Broadcastable': case 'NotBroadcastable': memo[status.txid] = 'NotBroadcastable'; any_changed = true; break; case 'Unknown': throw new Error('Logically, cannot be Unknown'); } if (should_next_tx) break; } // Absent any other value, this should now be broadcastable if (!should_next_tx) { memo[status.txid] = memo[status.txid] ?? 'Broadcastable'; any_changed = true; } } } while (any_changed); for (const u of unknown) { memo[u.txid] = 'Unknown'; } return memo; } interface ICommand { method: string; parameters: any[]; } /* Currently non-functional, needs a server to be running somewhere. Should be upgraded to a socket managed driver that does not use polling. */ export class BitcoinNodeManager { props: IProps; mounted: boolean; next_periodic_check: NodeJS.Timeout; constructor(props: IProps) { this.props = props; this.mounted = true; this.next_periodic_check = setTimeout( this.periodic_check.bind(this), 1000 ); } destroy() { this.mounted = false; if (this.next_periodic_check != null) clearTimeout(this.next_periodic_check); } /** * Execute a Bitcoin rpc call * @param command { method: string, parameters: any[] } * @returns result of the command or throws an error */ async execute(command: ICommand) { const [result] = await window.electron.bitcoin_command([command]); if (result === undefined) { throw new Error('Unexpected result returned'); } if (result?.name === 'RpcError') { throw result; } return result; } /** * Execute a batched Bitcoin rpc call * @param commands [{ method: string, parameters: any[] },...] * @returns returns an array of results, results can include error objects */ async executeBatch(commands: ICommand[]) { return window.electron.bitcoin_command(commands); } async periodic_check() { const contract = this.props.current_contract; console.info('PERIODIC CONTRACT CHECK'); if (!contract.should_update()) { // poll here faster this.next_periodic_check = setTimeout( this.periodic_check.bind(this), 1000 ); return; } const status = await this.get_transaction_status(contract); const state = compute_impossible( derive_state(status, this.props.current_contract), this.props.current_contract ); store.dispatch(load_status({ status, state })); if (this.mounted) { const freq = selectNodePollFreq(store.getState()); const period = clamp(freq ?? 0, 5, 60 * 5); console.info('NEXT PERIODIC CONTRACT CHECK ', period, ' SECONDS'); this.next_periodic_check = setTimeout( this.periodic_check.bind(this), 1000 * period ); } } // TODO: make this not static so we can use `execute` directly instead of `bitcoin_command` static async fund_out(tx: Transaction): Promise<Transaction> { const result = await window.electron.bitcoin_command([ { method: 'fundrawtransaction', parameters: [tx.toHex()] }, ]); if (result[0] && result[0].name === 'RpcError') { throw result[0]; } const hex: string = result[0].hex; return Transaction.fromHex(hex); } // TODO: make this not static so we can use `execute` directly instead of `bitcoin_command` static async fetch_utxo(t: TXID, n: number): Promise<QueriedUTXO | null> { const txout = ( await window.electron.bitcoin_command([ { method: 'getrawtransaction', parameters: [t, true] }, ]) )[0]; if (!txout || txout.name === 'RpcError') { return null; } return { blockhash: txout.blockhash, confirmations: txout.confirmations, scriptPubKey: txout.vout[n].scriptPubKey, value: txout.vout[n].value, }; } async check_balance(): Promise<number> { return this.execute({ method: 'getbalance', parameters: [] }); } async blockchaininfo(): Promise<any> { return this.execute({ method: 'getblockchaininfo', parameters: [] }); } async get_new_address(): Promise<string> { return this.execute({ method: 'getnewaddress', parameters: [] }); } async send_to_address(amount: number, address: string): Promise<void> { return this.execute({ method: 'sendtoaddress', parameters: [address, amount], }); } async list_transactions(count: number): Promise<any> { return this.execute({ method: 'listtransactions', parameters: ['*', count], }); } async generate_blocks(n: number): Promise<void> { const addr = await this.get_new_address(); return this.execute({ method: 'generatetoaddress', parameters: [n, addr], }); } // get info about transactions async get_transaction_status( current_contract: ContractModel ): Promise<Record<TXID, Status>> { // TODO: SHould query by WTXID const txids = current_contract.txn_models // .filter((tm) => tm.is_broadcastable()) .map((tm) => tm.get_txid()); const req = txids.map((txid) => { return { method: 'getrawtransaction', parameters: [txid, true], }; }); if (txids.length > 0) { const results: ( | Status | { message: string; code: number; name: string } )[] = await this.executeBatch(req); console.log(results); // TODO: Configure Threshold return Object.fromEntries( results.map((txdata, idx: number) => { const s: [TXID | undefined, Status | undefined] = [ undefined, undefined, ]; if ('code' in txdata) { s[1] = { txid: txids[idx]!, confirmations: null, }; } else { s[1] = { txid: txdata.txid, confirmations: txdata.confirmations ?? 0, }; } s[0] = s[1].txid; return s; }) ); } return {}; } async get_output_status( current_contract: ContractModel, txns: Record<TXID, Status | null> ): Promise<Record<OutpointS, string>> { return {}; } render() { return null; } } export interface QueriedUTXO { blockhash: string; confirmations: number; scriptPubKey: { asm: string; hex: string; address: string; type: string }; value: number; } type OutpointS = string;