Commit 73a99ba7 authored by zeroleak's avatar zeroleak
Browse files

add tx0 for multiple utxos

parent aac5f20c
......@@ -94,6 +94,26 @@ td.utxoMessage {
visibility: visible;
}
.table-utxos .utxo-controls {
visibility: hidden;
}
.table-utxos tbody tr:hover .utxo-controls {
visibility: visible;
}
.table-utxos tbody input[type='checkbox'] {
visibility: hidden;
}
.table-utxos tbody tr:hover input[type='checkbox'], .table-utxos tbody input:checked {
visibility: visible;
}
.table-generic .select-actions {
padding: 0 1em;
}
.modal-body canvas.qr {
margin: 0.6em 0 0.2em 0;
}
......
// @flow
import React, { useEffect, useState } from 'react';
import { Alert, Button, Modal } from 'react-bootstrap';
export default function GenericModal(props) {
const {dialogClassName, modalUtils, title, buttons, onClose} = props
return (
<Modal show={true} onHide={onClose} dialogClassName={dialogClassName}>
<Modal.Header>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
{props.children}
</Modal.Body>
<Modal.Footer>
{modalUtils.isLoading() && <div className="modal-status">
<div className="spinner-border spinner-border-sm" role="status"/> {modalUtils.loading}
</div>}
{!modalUtils.isLoading() && modalUtils.isError() && <div className="modal-status">
<Alert variant='danger'>{modalUtils.error}</Alert>
</div>}
<Button variant="secondary" onClick={onClose}>Close</Button>
{!modalUtils.isLoading() && !modalUtils.isError() && buttons}
</Modal.Footer>
</Modal>
);
}
// @flow
import React from 'react';
import { Button } from 'react-bootstrap';
import React, { useEffect, useState } from 'react';
import { Alert, Button } from 'react-bootstrap';
import * as Icon from 'react-feather';
import utils from '../../services/utils';
import mixService from '../../services/mixService';
import AbstractModal from './AbstractModal';
import poolsService from '../../services/poolsService';
import { TX0_FEE_TARGET } from '../../const';
import utils from '../../services/utils';
import poolsService from '../../services/poolsService';
import backendService from '../../services/backendService';
import GenericModal from './GenericModal';
import ModalUtils from '../../services/modalUtils';
export default class Tx0Modal extends AbstractModal {
constructor(props) {
const initialState = {
pools: undefined,
feeTarget: TX0_FEE_TARGET.BLOCKS_2.value,
poolId: props.utxo.poolId,
}
super(props, 'modal-tx0', initialState)
console.log('Tx0Modal', initialState)
export default function Tx0Modal(props) {
const {utxos, onClose} = props
this.handleChangeFeeTarget = this.handleChangeFeeTarget.bind(this);
this.handleChangePoolTx0 = this.handleChangePoolTx0.bind(this);
this.handleSubmitTx0 = this.handleSubmitTx0.bind(this)
this.fetchPoolsForTx0FeeTarget = this.fetchPoolsForTx0FeeTarget.bind(this)
this.setStateWithTx0Preview = this.setStateWithTx0Preview.bind(this)
this.fetchPoolsForTx0FeeTarget(initialState.feeTarget)
// use first available poolId
const computeInitialPoolId = utxos => {
for (const utxo of utxos) {
if (utxo.poolId) {
return utxo.poolId
}
}
}
fetchPoolsForTx0FeeTarget(tx0FeeTarget) {
const [spendValue, setSpendValue] = useState(utils.sumUtxos(utxos))
const [poolId, setPoolId] = useState(computeInitialPoolId(utxos))
const [feeTarget, setFeeTarget] = useState(TX0_FEE_TARGET.BLOCKS_2.value)
const [pools, setPools] = useState([])
const [tx0Preview, setTx0Preview] = useState(undefined)
const modalUtils = new ModalUtils(useState, useEffect)
// compute spendValue
useEffect(() => {
const newSpendValue = utils.sumUtxos(utxos)
setSpendValue(newSpendValue)
}, [utxos])
// compute available pools
useEffect(() => {
// fetch pools for tx0 feeTarget
this.loading("Fetching pools for tx0...", poolsService.fetchPoolsForTx0(this.props.utxo.value, tx0FeeTarget).then(pools => {
if (pools.length == 0) {
this.setError("No pool for this utxo and miner fee.")
modalUtils.load("Fetching pools for tx0...", poolsService.fetchPoolsForTx0(spendValue, feeTarget).then(newPools => {
console.log('????newPools',newPools,spendValue)
if (newPools.length == 0) {
modalUtils.setError("No pool for this utxo and miner fee.")
}
setPools(newPools)
}))
}, [feeTarget, spendValue])
const defaultPoolId = pools.length > 0 ? pools[0].poolId : undefined
// preserve active poolId if still available
const poolId = (this.state.poolId && pools.filter(p => p.poolId === this.state.poolId).length > 0) ? this.state.poolId : defaultPoolId
// compute selected poolId
useEffect(() => {
const defaultPoolId = pools.length > 0 ? pools[0].poolId : undefined
// preserve selected poolId if still available
const newPoolId = (poolId && pools.filter(p => p.poolId === poolId).length > 0) ? poolId : defaultPoolId
setPoolId(newPoolId)
}, [pools])
return this.setStateWithTx0Preview({
poolId: poolId,
pools: pools
})
}))
}
const isTx0Possible = (feeTarget, poolId, utxos) => feeTarget && poolId && utxos && utxos.length > 0
setStateWithTx0Preview(newState) {
const fullState = Object.assign(this.state, newState)
if (!fullState.feeTarget || !fullState.poolId) {
// tx0 preview
useEffect(() => {
if (!isTx0Possible(feeTarget, poolId, utxos)) {
// cannot preview yet
newState.tx0Preview = undefined
this.setState(
newState
)
return Promise.resolve()
setTx0Preview(undefined)
} else {
// preview
modalUtils.load("Fetching tx0 data...", backendService.tx0.tx0Preview(utxos, feeTarget, poolId).then(newTx0Preview => {
setTx0Preview(newTx0Preview)
}))
}
return this.loading("Fetching tx0 data...", backendService.utxo.tx0Preview(this.props.utxo.hash, this.props.utxo.index, fullState.feeTarget, fullState.poolId).then(tx0Preview => {
newState.tx0Preview = tx0Preview
this.setState(
newState
)
}))
}
}, [feeTarget, poolId, utxos])
handleChangeFeeTarget(e) {
const feeTarget = e.target.value
this.setStateWithTx0Preview({
feeTarget: feeTarget
})
this.fetchPoolsForTx0FeeTarget(feeTarget)
}
handleChangePoolTx0(e) {
const poolId = e.target.value
this.setStateWithTx0Preview({
poolId: poolId
})
}
handleSubmitTx0() {
mixService.tx0(this.props.utxo, this.state.feeTarget, this.state.poolId)
this.props.onClose();
}
renderTitle() {
return <div>
Send to Premix
</div>
const submitTx0 = () => {
mixService.tx0(utxos, feeTarget, poolId)
onClose();
}
renderButtons() {
return <Button onClick={this.handleSubmitTx0}>Premix <Icon.ChevronsRight size={12}/></Button>
}
renderBody() {
return <div>
This will send <strong>{utils.toBtc(this.props.utxo.value)}btc</strong> to Premix and prepare for mixing.<br/>
Spending <strong>{this.props.utxo.hash}:{this.props.utxo.index}</strong><br/>
return <GenericModal dialogClassName='modal-tx0'
modalUtils={modalUtils}
title='Send to Premix'
buttons={isTx0Possible(feeTarget, poolId, utxos) && <Button onClick={submitTx0}>Premix <Icon.ChevronsRight size={12}/></Button>}
onClose={onClose}>
This will send <strong>{utils.toBtc(spendValue)}btc</strong> to Premix and prepare for mixing.<br/>
{utxos.length==1 && <div>Spending <strong>{utxos[0].hash}:{utxos[0].index}</strong></div>}
{utxos.length>1 && <Alert variant='warning'>You are spending <strong>{utxos.length} utxos</strong> at once, this may degrade your privacy. We recommend premixing utxos individually when possible.</Alert>}
<br/>
{!modalUtils.isLoading() && <div>
{pools && pools.length>0 && <div>
Pool fee: {tx0Preview && <span><strong>{utils.toBtc(tx0Preview.feeValue)} btc</strong></span>}
<select className="form-control" onChange={e => setPoolId(e.target.value)} defaultValue={poolId}>
{pools.map(pool => <option key={pool.poolId} value={pool.poolId}>{pool.poolId} · denomination: {utils.toBtc(pool.denomination)} btc · fee: {utils.toBtc(pool.feeValue)} btc</option>)}
</select>
</div>}
<br/>
{!this.isLoading() && <div>
{this.state.pools && this.state.pools.length>0 && <div>
Pool fee: {this.state.tx0Preview && <span><strong>{utils.toBtc(this.state.tx0Preview.feeValue)} btc</strong></span>}
<select className="form-control" onChange={this.handleChangePoolTx0} defaultValue={this.state.poolId}>
{this.state.pools.map(pool => <option key={pool.poolId} value={pool.poolId}>{pool.poolId} · denomination: {utils.toBtc(pool.denomination)} btc · fee: {utils.toBtc(pool.feeValue)} btc</option>)}
</select>
</div>}
<br/>
Miner fee: {this.state.tx0Preview && <strong>{utils.toBtc(this.state.tx0Preview.minerFee)} btc</strong>}
<select className="form-control" onChange={this.handleChangeFeeTarget} defaultValue={this.state.feeTarget}>
{Object.keys(TX0_FEE_TARGET).map(feeTargetKey => {
const feeTargetItem = TX0_FEE_TARGET[feeTargetKey]
return <option key={feeTargetItem.value} value={feeTargetItem.value}>{feeTargetItem.label}</option>
})}
</select><br/>
{!this.isError() && <div>
{this.state.tx0Preview && <div>
This will generate <strong>{this.state.tx0Preview.nbPremix} premixs</strong> of <strong>{utils.toBtc(this.state.tx0Preview.premixValue)} btc</strong> + <strong>{utils.toBtc(this.state.tx0Preview.changeValue)} btc</strong> change
</div>}
Miner fee: {tx0Preview && <strong>{utils.toBtc(tx0Preview.minerFee)} btc</strong>}
<select className="form-control" onChange={e => setFeeTarget(e.target.value)} defaultValue={feeTarget}>
{Object.keys(TX0_FEE_TARGET).map(feeTargetKey => {
const feeTargetItem = TX0_FEE_TARGET[feeTargetKey]
return <option key={feeTargetItem.value} value={feeTargetItem.value}>{feeTargetItem.label}</option>
})}
</select><br/>
{!modalUtils.isError() && <div>
{tx0Preview && <div>
This will generate <strong>{tx0Preview.nbPremix} premixs</strong> of <strong>{utils.toBtc(tx0Preview.premixValue)} btc</strong> + <strong>{utils.toBtc(tx0Preview.changeValue)} btc</strong> change
</div>}
</div>}
</div>
}
</div>}
</GenericModal>
}
import React from 'react';
import BTable from 'react-bootstrap/Table';
import { usePagination, useSortBy, useTable } from 'react-table';
import { useRowSelect, useSortBy, useTable } from 'react-table';
import * as Icon from 'react-feather';
export default function TableGeneric({ columns, data, size='sm', /*onFetchData, pageIndex, pageSize, filters, */sortBy, getRowStyle=()=>{}, getRowClassName=()=>{} }) {
const IndeterminateCheckbox = React.forwardRef(
({ indeterminate, ...rest }, ref) => {
const defaultRef = React.useRef()
const resolvedRef = ref || defaultRef
React.useEffect(() => {
resolvedRef.current.indeterminate = indeterminate
}, [resolvedRef, indeterminate])
return (
<>
<input type="checkbox" ref={resolvedRef} {...rest} />
</>
)
}
)
export default function TableGeneric({ columns, data, size='sm', /*onFetchData, pageIndex, pageSize, filters, */sortBy, getRowStyle=()=>{}, getRowClassName=()=>{}, onSelect=undefined }) {
if (!data) {
return
}
......@@ -12,13 +30,37 @@ export default function TableGeneric({ columns, data, size='sm', /*onFetchData,
initialState.sortBy = sortBy;
}
// Use the state and functions returned from useTable to build your UI
const { getTableProps, headerGroups, rows, prepareRow } = useTable(
const { getTableProps, headerGroups, rows, prepareRow, selectedFlatRows, state: { selectedRowIds } } = useTable(
{
columns,
data,
initialState,
},
useSortBy,
useRowSelect,
hooks => {
hooks.visibleColumns.push(columns => [
// Let's make a column for selection
{
id: 'selection',
// The header can use the table's getToggleAllRowsSelectedProps method
// to render a checkbox
Header: ({ getToggleAllRowsSelectedProps }) => (
<div>
<IndeterminateCheckbox {...getToggleAllRowsSelectedProps()} />
</div>
),
// The cell can use the individual row's getToggleRowSelectedProps method
// to the render a checkbox
Cell: ({ row }) => (
<div>
<IndeterminateCheckbox {...row.getToggleRowSelectedProps()} />
</div>
),
},
...columns,
])
}
);
// When these table states change, fetch new data!
......@@ -26,38 +68,48 @@ export default function TableGeneric({ columns, data, size='sm', /*onFetchData,
onFetchData({ pageIndex, pageSize, sortBy, filters })
}, [onFetchData, pageIndex, pageSize, sortBy, filters])*/
const selectedItems = selectedFlatRows.length>0 ? selectedFlatRows.map(
d => d.original
) : undefined
// Render the UI for your table
return (
<BTable hover size={size} {...getTableProps()}>
<thead>
{headerGroups.map(headerGroup => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map(column => (
<th {...column.getHeaderProps(column.getSortByToggleProps())}>
{column.render('Header')}
<span>
{column.isSorted ? (column.isSortedDesc ? '' : '') : ''}
</span>
</th>
))}
</tr>
))}
</thead>
<tbody>
{rows.map((row, i) => {
prepareRow(row);
return (
<tr {...row.getRowProps({
style: getRowStyle(row),
className:getRowClassName(row)
})}>
{row.cells.map(cell => (
<td {...cell.getCellProps()}>{cell.render('Cell')}</td>
<div className='table-generic'>
<BTable hover size={size} {...getTableProps()}>
<thead>
{headerGroups.map(headerGroup => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map(column => (
<th {...column.getHeaderProps(column.getSortByToggleProps())}>
{column.render('Header')}
<span>
{column.isSorted ? (column.isSortedDesc ? '' : '') : ''}
</span>
</th>
))}
</tr>
);
})}
</tbody>
</BTable>
))}
</thead>
<tbody>
{rows.map((row, i) => {
prepareRow(row);
return (
<tr {...row.getRowProps({
style: getRowStyle(row),
className:getRowClassName(row)
})}>
{row.cells.map(cell => (
<td {...cell.getCellProps()}>{cell.render('Cell')}</td>
))}
</tr>
);
})}
</tbody>
</BTable>
{onSelect && selectedItems && <div className='select-actions text-muted'>
{Object.keys(selectedRowIds).length} {onSelect.label} selected <Icon.ArrowRight size={12}/>{' '}
{onSelect.actions(selectedItems).map((action,i) => <span key={i}>{action}</span>)}
</div>}
</div>
);
}
......@@ -18,8 +18,8 @@ import TableGeneric from '../TableGeneric/TableGeneric';
const UtxoControls = React.memo(({ utxo }) => {
return (
<div>
{utxo.account === WHIRLPOOL_ACCOUNTS.DEPOSIT && mixService.isTx0Possible(utxo) && <button className='btn btn-sm btn-primary' title='Send to Premix' onClick={() => modalService.openTx0(utxo)}>Premix <Icon.ArrowRight size={12}/></button>}
<div className='utxo-controls'>
{utxo.account === WHIRLPOOL_ACCOUNTS.DEPOSIT && mixService.isTx0Possible(utxo) && <button className='btn btn-sm btn-primary' title='Send to Premix' onClick={() => modalService.openTx0([utxo])}>Premix <Icon.ArrowRight size={12}/></button>}
{mixService.isStartMixPossible(utxo) && utxo.mixableStatus === MIXABLE_STATUS.MIXABLE && <button className='btn btn-sm btn-primary' title='Start mixing' onClick={() => mixService.startMixUtxo(utxo)}>Mix <Icon.Play size={12} /></button>}
{mixService.isStartMixPossible(utxo) && utxo.mixableStatus !== MIXABLE_STATUS.MIXABLE && <button className='btn btn-sm btn-border' title='Add to queue' onClick={() => mixService.startMixUtxo(utxo)}><Icon.Plus size={12} />queue</button>}
{mixService.isStopMixPossible(utxo) && utxo.status === UTXO_STATUS.MIX_QUEUE && <button className='btn btn-sm btn-border' title='Remove from queue' onClick={() => mixService.stopMixUtxo(utxo)}><Icon.Minus size={12} />queue</button>}
......@@ -158,6 +158,12 @@ const UtxosTable = ({ controls, account, utxos }) => {
data={visibleUtxos}
sortBy={[{ id: 'lastActivityElapsed', desc: true }]}
getRowClassName={row => isReadOnly(row.original) ? 'utxo-disabled' : ''}
onSelect={{
label: 'utxos',
actions: utxos => [
<button className='btn btn-sm btn-primary' title='Send to Premix' onClick={() => modalService.openTx0(utxos)}>Premix</button>
]
}}
/>
{visibleUtxos.length == 0 && <div className='text-center text-muted'><small>No utxo yet</small></div>}
</div>
......
......@@ -260,7 +260,7 @@ class App extends React.Component<Props> {
{guiUpdate && <div><br/><Alert variant='warning'>GUI update {guiUpdate} available!</Alert></div>}
{this.routes()}
{this.state.modalTx0 && <Tx0Modal utxo={this.state.modalTx0} onClose={modalService.close.bind(modalService)}/>}
{this.state.modalTx0 && <Tx0Modal utxos={this.state.modalTx0} onClose={modalService.close.bind(modalService)}/>}
{this.state.modalDeposit && <DepositModal onClose={modalService.close.bind(modalService)}/>}
{this.state.modalZpub && <ZpubModal zpub={this.state.modalZpub.zpub} account={this.state.modalZpub.account} onClose={modalService.close.bind(modalService)}/>}
......
......@@ -473,8 +473,7 @@ class InitPage extends Component<Props> {
step3() {
return <div>
<p>Success. <b>whirlpool-gui</b> is now configured.</p>
<p>Reconnecting to CLI...</p>
Success. Restarting...
</div>
}
}
......
......@@ -151,23 +151,30 @@ class BackendService {
}
};
utxo = {
tx0Preview: (hash, index, feeTarget, poolId) => {
tx0 = {
tx0Preview: (utxos, feeTarget, poolId) => {
const inputsRef = utils.utxoRefs(utxos)
return this.withStatus('Utxo', 'Preview tx0', () =>
this.fetchBackendAsJson('/rest/utxos/'+hash+':'+index+'/tx0Preview', 'POST', {
this.fetchBackendAsJson('/rest/tx0/preview', 'POST', {
feeTarget: feeTarget,
poolId: poolId
poolId: poolId,
inputs: inputsRef
})
)
},
tx0: (hash, index, feeTarget, poolId) => {
tx0: (utxos, feeTarget, poolId) => {
const inputsRef = utils.utxoRefs(utxos)
return this.withStatus('Utxo', 'New tx0', () =>
this.fetchBackendAsJson('/rest/utxos/'+hash+':'+index+'/tx0', 'POST', {
this.fetchBackendAsJson('/rest/tx0', 'POST', {
feeTarget: feeTarget,
poolId: poolId
poolId: poolId,
inputs: inputsRef
})
)
},
}
};
utxo = {
configure: (hash, index, poolId) => {
return this.withStatus('Utxo', 'Configure utxo', () =>
this.fetchBackendAsJson('/rest/utxos/'+hash+':'+index, 'POST', {
......
......@@ -412,7 +412,7 @@ class CliService {
}
if (cliService.getCliUrlError()) {
// error
const status = 'CLI is disconnected'
const status = 'Connecting to CLI'
return format(<FontAwesomeIcon icon={Icons.faWifi} color='red' title={status} />, status)
}
// connected & initialization required
......@@ -424,10 +424,7 @@ class CliService {
if (cliService.isConnected()) {
// ???
let cliMessage = cliService.getCliMessage()
if (!cliMessage) {
cliMessage = 'not ready.'
}
const status = 'Waiting for CLI: '+cliMessage
const status = 'Waiting for CLI... '+(cliMessage ? cliMessage:'')
return format(<FontAwesomeIcon icon={Icons.faWifi} color='lightgreen' title={status}/>, status)
}
// not connected
......
......@@ -79,8 +79,8 @@ class MixService {
return backendService.utxo.configure(utxo.hash, utxo.index, utxo.poolId).then(() => walletService.fetchState())
}
tx0(utxo, feeTarget, poolId) {
return backendService.utxo.tx0(utxo.hash, utxo.index, feeTarget, poolId).then(() => walletService.fetchState())
tx0(utxos, feeTarget, poolId) {
return backendService.tx0.tx0(utxos, feeTarget, poolId).then(() => walletService.fetchState())
}
startMixUtxo(utxo) {
......
export default class ModalUtils {
constructor(useState, useEffect) {
const [loading, setLoading] = useState(false)
this.loading = loading
this.setLoading = setLoading
const [error, setError] = useState(false)
this.error = error
this.setError = setError
useEffect(() => {
if (error) {
setLoading(false)
}
}, [error])
useEffect(() => {
console.log('modalUtils: loading='+loading+', error='+error)
}, [loading,error])
}
isLoading() {
return this.loading
}
isError() {
return this.error
}