WatchOnly Extension - add Serial Port communication (#839)
* feat: add `Share PSBT` button with options * feat: add basic communication via the serial port * chore: code format * feat: send data to and from serial port * fix: port disconnect * feat: handle psbt extract * feat: show signed transaction details * fix: handle Connect/Disconnect failure state * feat:small UI improvements * feat: broadcast transaction (partial solution) * feat: integrate psbt response from HWW * feat: login and send commands to HWW * feat: ui improvements * feat: ui/ux improvements * feat: more small UI impreovemsnts * feat: simplify UI * feat: add `help` command * feat: add wipe command * feet: add `seed` command * feat: add `restore` command * feat: always show PSBT input text (for outside PSBTs) * feat: show spinner while signing tx * feat: hide panels after transaction is broadcast * feat: basic use of custom components * refactor: move components one folder up * refactor: extract wallet-config * refactor: extract `wallet-list` component * refactor: clean-up * chore: code format html component files * refactor: extract address-list component * refactor: extract `history` component * refactor: extract `utxo-list` component * feat: UI/UX improvements * feat: partial payment redesign * refactor: rename `fee` to `fee-rate` * refactor: rename component * refactor: extract `send-to` component * refactor: payment: first migration * fix: init `sendToList` * fix: change address * fix: change address and `Select All` coins * feat: show custom fees & two way binding for addresses * fix: scanAddressesWithAmount * fix: max amount * fix: coin selection mode * chore: code clean-up * feat: shuffle the UI * fix: change amount * feat: update tx size in real time * fix: coin selection * fix: show erro messages * fix: psbt generation * refactor: move serial port logic * refactor: payment component * refactor: code clean-up; use `slot` for `serial-signer` * feat: toggle serial port * feat: add Disconnect command * feat: prompt for `Connect` and `Login` before signing * refactor: send psbt to device * feat: extract signed transaction * refactor: code clean-up * feat: show auth green icon * chore: code clean-up * feat: show console * feat: allow `Connect` from dropdown menu * fix: stop if serial port cannot be open * feat: confirm outputs and fee * feat: add cancel command * fix: add `sats-denominated` for confirmations * feat: wait for HWW to authenticate, then open dialog * feat: share PSBT as text * refactor: extract `refreshAddresses` * feat: small UI improvements * feat: add default `Mainnet` network * feat: fix mempool endpint * feat: propagate config update only when explicitly updated * feat: add network for wallet accounts * fix: stop scanning when network changed * chore: code clean-up * chore: code clean-up * feat: show hardware device Xpub option * fix: handle failed to parse psbt * feat: add accounts using the HWW * fix: testnet is in the bip32 derivation path * feat: add spinner while wallet account is created * fix: check network and masterpub for duplicate accounts * feat: integrate transaction broadcast * feat: add password confirmation for `Wipe` and `Restore` * fix: fingerprint is not unique per account (it is the fingerprint of the master) * chore: code clean-up, remove `masterpub_fingerprint` * fix: account name diplay * chore: code format * fix: memppol links * fix: shortcut buttons * fix: note update * chore: code format * chore: clean-up rebase left overs * chore: clean-up * feat: less technical labels for addresses * feat: add serial port config params * fix: address type selection * chore: drop `mempool` table * fix: change & fee value * fix: handle no input signed scenario * fix: sat/btc unit * fix: small UI stuff * doc: update the readme * Update README.md
This commit is contained in:
parent
63849a0894
commit
1f139884fe
4
Makefile
4
Makefile
|
@ -7,7 +7,7 @@ format: prettier isort black
|
|||
check: mypy checkprettier checkisort checkblack
|
||||
|
||||
prettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
|
||||
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||
|
||||
black:
|
||||
poetry run black .
|
||||
|
@ -19,7 +19,7 @@ isort:
|
|||
poetry run isort .
|
||||
|
||||
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
|
||||
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||
|
||||
checkblack:
|
||||
poetry run black --check .
|
||||
|
|
|
@ -8,7 +8,7 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb
|
|||
|
||||
### Wallet Account
|
||||
- a user can add one or more `xPubs` or `descriptors`
|
||||
- the `xPub` fingerprint must be unique per user
|
||||
- the `xPub` must be unique per user
|
||||
- such and entry is called an `Wallet Account`
|
||||
- the addresses in a `Wallet Account` are split into `Receive Addresses` and `Change Address`
|
||||
- the user interacts directly only with the `Receive Addresses` (by sharing them)
|
||||
|
@ -17,6 +17,7 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb
|
|||
- when a `Wallet Account` is created, there are generated `20 Receive Addresses` and `5 Change Address`
|
||||
- the limits can be change from the `Config` page (see `screenshot 1`)
|
||||
- regular wallets only scan up to `20` empty receive addresses. If the user generates addresses beyond this limit a warning is shown (see `screenshot 4`)
|
||||
- an account can be added `From Hardware Device`
|
||||
|
||||
### Scan Blockchain
|
||||
- when the user clicks `Scan Blockchain`, the wallet will loop over the all addresses (for each account)
|
||||
|
@ -48,33 +49,32 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb
|
|||
- shows the UTXOs for all wallets
|
||||
- there can be multiple UTXOs for the same address
|
||||
|
||||
### Make Payment
|
||||
### New Payment
|
||||
- create a new `Partially Signed Bitcoin Transaction`
|
||||
- multiple `Send Addresses` can be added
|
||||
- the `Max` button next to an address is for sending the remaining funds to this address (no change)
|
||||
- the user can select the inputs (UTXOs) manually, or it can use of the basic selection algorithms
|
||||
- amounts have to be provided for the `Send Addresses` beforehand (so the algorithm knows the amount to be selected)
|
||||
- `Show Advanced` allows to (see `screenshot 2`):
|
||||
- select from which account the change address will be selected (defaults to the first one)
|
||||
- select the `Fee Rate`
|
||||
- it defaults to the `Medium` value at the moment the `Make Payment` button was clicked
|
||||
- `Show Change` allows to select from which account the change address will be selected (defaults to the first one)
|
||||
- `Show Custom Fee` allows to manually select the fee
|
||||
- it defaults to the `Medium` value at the moment the `New Payment` button was clicked
|
||||
- it can be refreshed
|
||||
- warnings are shown if the fee is too Low or to High
|
||||
|
||||
### Create PSBT
|
||||
- based on the Inputs & Outputs selected by the user a PSBT will be generated
|
||||
- this wallet is watch-only, therefore does not support signing
|
||||
- it is not mandatory for the `Selected Amount` to be grater than `Payed Amount`
|
||||
- the generated PSBT can be combined with other PSBTs that add more inputs.
|
||||
- the generated PSBT can be imported for signing into different wallets like Electrum
|
||||
- import the PSBT into Electrum and check the In/Outs/Fee (see `screenshot 3`)
|
||||
### Check & Send
|
||||
- creates the PSBT and sends it to the Hardware Wallet
|
||||
- a confirmation will be shown for each Output and for the Fee
|
||||
- after the user confirms the addresses and amounts, the transaction will be signed on the Hardware Device
|
||||
|
||||
### Share PSBT
|
||||
- Show the PSBT without sending it to the Hardware Wallet
|
||||
|
||||
## Screensots
|
||||
- screenshot 1:
|
||||
![image](https://user-images.githubusercontent.com/2951406/177181611-eeeac70c-c245-4b45-b80b-8bbb511f6d1d.png)
|
||||
|
||||
- screenshot 2:
|
||||
![image](https://user-images.githubusercontent.com/2951406/177331468-f9b43626-548a-4608-b0d0-44007f402404.png)
|
||||
![image](https://user-images.githubusercontent.com/2951406/183087898-b91f5243-8ed9-4a14-9e57-7bb4f1fd43ef.png)
|
||||
|
||||
- screenshot 3:
|
||||
![image](https://user-images.githubusercontent.com/2951406/177333755-4a9118fb-3eaf-43d6-bc7e-c3d8c80bc61e.png)
|
||||
|
|
|
@ -4,8 +4,8 @@ from typing import List, Optional
|
|||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .helpers import derive_address, parse_key
|
||||
from .models import Address, Config, Mempool, WalletAccount
|
||||
from .helpers import derive_address
|
||||
from .models import Address, Config, WalletAccount
|
||||
|
||||
##########################WALLETS####################
|
||||
|
||||
|
@ -22,9 +22,10 @@ async def create_watch_wallet(w: WalletAccount) -> WalletAccount:
|
|||
title,
|
||||
type,
|
||||
address_no,
|
||||
balance
|
||||
balance,
|
||||
network
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
|
@ -35,6 +36,7 @@ async def create_watch_wallet(w: WalletAccount) -> WalletAccount:
|
|||
w.type,
|
||||
w.address_no,
|
||||
w.balance,
|
||||
w.network,
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -48,9 +50,10 @@ async def get_watch_wallet(wallet_id: str) -> Optional[WalletAccount]:
|
|||
return WalletAccount.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_watch_wallets(user: str) -> List[WalletAccount]:
|
||||
async def get_watch_wallets(user: str, network: str) -> List[WalletAccount]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,)
|
||||
"""SELECT * FROM watchonly.wallets WHERE "user" = ? AND network = ?""",
|
||||
(user, network),
|
||||
)
|
||||
return [WalletAccount(**row) for row in rows]
|
||||
|
||||
|
|
|
@ -77,7 +77,19 @@ async def m004_create_config_table(db):
|
|||
);"""
|
||||
)
|
||||
|
||||
### TODO: fix statspay dependcy first
|
||||
# await db.execute(
|
||||
# "DROP TABLE watchonly.wallets;"
|
||||
# )
|
||||
|
||||
async def m005_add_network_column_to_wallets(db):
|
||||
"""
|
||||
Add network' column to the 'wallets' table
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
"ALTER TABLE watchonly.wallets ADD COLUMN network TEXT DEFAULT 'Mainnet';"
|
||||
)
|
||||
|
||||
|
||||
async def m006_drop_mempool_table(db):
|
||||
"""
|
||||
Mempool data is now part of `config`
|
||||
"""
|
||||
await db.execute("DROP TABLE watchonly.mempool;")
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from sqlite3 import Row
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
|
@ -8,6 +8,7 @@ from pydantic import BaseModel
|
|||
class CreateWallet(BaseModel):
|
||||
masterpub: str = Query("")
|
||||
title: str = Query("")
|
||||
network: str = "Mainnet"
|
||||
|
||||
|
||||
class WalletAccount(BaseModel):
|
||||
|
@ -19,22 +20,13 @@ class WalletAccount(BaseModel):
|
|||
address_no: int
|
||||
balance: int
|
||||
type: str = ""
|
||||
network: str = "Mainnet"
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "WalletAccount":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
### TODO: fix statspay dependcy and remove
|
||||
class Mempool(BaseModel):
|
||||
user: str
|
||||
endpoint: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Mempool":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class Address(BaseModel):
|
||||
id: str
|
||||
address: str
|
||||
|
@ -57,7 +49,7 @@ class TransactionInput(BaseModel):
|
|||
address: str
|
||||
branch_index: int
|
||||
address_index: int
|
||||
masterpub_fingerprint: str
|
||||
wallet: str
|
||||
tx_hex: str
|
||||
|
||||
|
||||
|
@ -66,10 +58,11 @@ class TransactionOutput(BaseModel):
|
|||
address: str
|
||||
branch_index: int = None
|
||||
address_index: int = None
|
||||
masterpub_fingerprint: str = None
|
||||
wallet: str = None
|
||||
|
||||
|
||||
class MasterPublicKey(BaseModel):
|
||||
id: str
|
||||
public_key: str
|
||||
fingerprint: str
|
||||
|
||||
|
@ -82,8 +75,23 @@ class CreatePsbt(BaseModel):
|
|||
tx_size: int
|
||||
|
||||
|
||||
class ExtractPsbt(BaseModel):
|
||||
psbtBase64 = "" # // todo snake case
|
||||
inputs: List[TransactionInput]
|
||||
|
||||
|
||||
class SignedTransaction(BaseModel):
|
||||
tx_hex: Optional[str]
|
||||
tx_json: Optional[str]
|
||||
|
||||
|
||||
class BroadcastTransaction(BaseModel):
|
||||
tx_hex: str
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
mempool_endpoint = "https://mempool.space"
|
||||
receive_gap_limit = 20
|
||||
change_gap_limit = 5
|
||||
sats_denominated = True
|
||||
network = "Mainnet"
|
||||
|
|
|
@ -0,0 +1,204 @@
|
|||
<div>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col q-pr-lg">
|
||||
<q-select
|
||||
filled
|
||||
clearable
|
||||
dense
|
||||
emit-value
|
||||
v-model="selectedWallet"
|
||||
:options="accounts"
|
||||
label="Wallet Account"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col q-pr-lg">
|
||||
<q-select
|
||||
filled
|
||||
clearable
|
||||
dense
|
||||
emit-value
|
||||
multiple
|
||||
:options="filterOptions"
|
||||
v-model="filterValues"
|
||||
label="Filter"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="addressesTable.filter"
|
||||
placeholder="Search"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
style="height: 400px"
|
||||
flat
|
||||
dense
|
||||
:data="getFilteredAddresses()"
|
||||
row-key="id"
|
||||
virtual-scroll
|
||||
:columns="addressesTable.columns"
|
||||
:pagination.sync="addressesTable.pagination"
|
||||
:filter="addressesTable.filter"
|
||||
>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="accent"
|
||||
round
|
||||
dense
|
||||
@click="props.row.expanded= !props.row.expanded"
|
||||
:icon="props.row.expanded? 'remove' : 'add'"
|
||||
/>
|
||||
</q-td>
|
||||
|
||||
<q-td key="address" :props="props">
|
||||
<div>
|
||||
<a
|
||||
style="color: unset"
|
||||
:href="'https://'+ mempoolEndpoint + '/address/' + props.row.address"
|
||||
target="_blank"
|
||||
>
|
||||
{{props.row.address}}</a
|
||||
>
|
||||
<q-badge
|
||||
v-if="props.row.branch_index === 1"
|
||||
color="orange"
|
||||
class="q-mr-md"
|
||||
outline
|
||||
>
|
||||
change
|
||||
</q-badge>
|
||||
<q-btn
|
||||
v-if="props.row.gapLimitExceeded"
|
||||
color="yellow"
|
||||
icon="warning"
|
||||
title="Gap Limit Exceeded"
|
||||
@click="props.row.expanded= !props.row.expanded"
|
||||
outline
|
||||
class="q-ml-md"
|
||||
size="xs"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-td>
|
||||
|
||||
<q-td
|
||||
key="amount"
|
||||
:props="props"
|
||||
:class="props.row.amount > 0 ? 'text-green-13 text-weight-bold' : ''"
|
||||
>
|
||||
<div>{{satBtc(props.row.amount)}}</div>
|
||||
</q-td>
|
||||
|
||||
<q-td key="note" :props="props" :class="">
|
||||
<div>{{props.row.note}}</div>
|
||||
</q-td>
|
||||
<q-td key="wallet" :props="props" :class="">
|
||||
<div>{{getWalletName(props.row.wallet)}}</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-show="props.row.expanded" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="md"
|
||||
icon="qr_code"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="showAddressDetails(props.row)"
|
||||
>
|
||||
QR Code</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-2 q-pr-lg">
|
||||
<q-btn
|
||||
outline
|
||||
dense
|
||||
size="md"
|
||||
icon="refresh"
|
||||
color="grey"
|
||||
@click="scanAddress(props.row)"
|
||||
>
|
||||
Rescan</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-2 q-pr-lg">
|
||||
<q-btn
|
||||
outline
|
||||
dense
|
||||
size="md"
|
||||
icon="history"
|
||||
color="grey"
|
||||
@click="searchInTab('history', props.row.address)"
|
||||
>History</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-2 q-pr-lg">
|
||||
<q-btn
|
||||
outline
|
||||
dense
|
||||
size="md"
|
||||
color="grey"
|
||||
@click="searchInTab('utxos', props.row.address)"
|
||||
>View Coins</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-pr-lg">Note:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="props.row.note"
|
||||
type="text"
|
||||
label="Note"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-2 q-pr-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="updateNoteForAddress(props.row, props.row.note)"
|
||||
>Update
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.row.error" class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-10 q-pr-lg">
|
||||
<q-badge color="red">{{props.row.error}}</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.row.gapLimitExceeded"
|
||||
class="row items-center no-wrap q-mb-md"
|
||||
>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-10 q-pr-lg">
|
||||
<q-badge color="yellow" text-color="black"
|
||||
>Gap limit of 20 addresses exceeded. Other wallets might not
|
||||
detect funds at this address.</q-badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
|
@ -0,0 +1,121 @@
|
|||
async function addressList(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
Vue.component('address-list', {
|
||||
name: 'address-list',
|
||||
template,
|
||||
|
||||
props: [
|
||||
'addresses',
|
||||
'accounts',
|
||||
'mempool-endpoint',
|
||||
'inkey',
|
||||
'sats-denominated'
|
||||
],
|
||||
data: function () {
|
||||
return {
|
||||
show: false,
|
||||
history: [],
|
||||
selectedWallet: null,
|
||||
note: '',
|
||||
filterOptions: [
|
||||
'Show Change Addresses',
|
||||
'Show Gap Addresses',
|
||||
'Only With Amount'
|
||||
],
|
||||
filterValues: [],
|
||||
|
||||
addressesTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'expand',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'address',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount',
|
||||
field: 'amount',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
align: 'left',
|
||||
label: 'Note',
|
||||
field: 'note',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'wallet',
|
||||
align: 'left',
|
||||
label: 'Account',
|
||||
field: 'wallet',
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 0,
|
||||
sortBy: 'amount',
|
||||
descending: true
|
||||
},
|
||||
filter: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
satBtc(val, showUnit = true) {
|
||||
return satOrBtc(val, showUnit, this.satsDenominated)
|
||||
},
|
||||
getWalletName: function (walletId) {
|
||||
const wallet = (this.accounts || []).find(wl => wl.id === walletId)
|
||||
return wallet ? wallet.title : 'unknown'
|
||||
},
|
||||
getFilteredAddresses: function () {
|
||||
const selectedWalletId = this.selectedWallet?.id
|
||||
const filter = this.filterValues || []
|
||||
const includeChangeAddrs = filter.includes('Show Change Addresses')
|
||||
const includeGapAddrs = filter.includes('Show Gap Addresses')
|
||||
const excludeNoAmount = filter.includes('Only With Amount')
|
||||
|
||||
const walletsLimit = (this.accounts || []).reduce((r, w) => {
|
||||
r[`_${w.id}`] = w.address_no
|
||||
return r
|
||||
}, {})
|
||||
|
||||
const fAddresses = this.addresses.filter(
|
||||
a =>
|
||||
(includeChangeAddrs || !a.isChange) &&
|
||||
(includeGapAddrs ||
|
||||
a.isChange ||
|
||||
a.addressIndex <= walletsLimit[`_${a.wallet}`]) &&
|
||||
!(excludeNoAmount && a.amount === 0) &&
|
||||
(!selectedWalletId || a.wallet === selectedWalletId)
|
||||
)
|
||||
return fAddresses
|
||||
},
|
||||
|
||||
scanAddress: async function (addressData) {
|
||||
this.$emit('scan:address', addressData)
|
||||
},
|
||||
showAddressDetails: function (addressData) {
|
||||
this.$emit('show-address-details', addressData)
|
||||
},
|
||||
searchInTab: function (tab, value) {
|
||||
this.$emit('search:tab', {tab, value})
|
||||
},
|
||||
updateNoteForAddress: async function (addressData, note) {
|
||||
this.$emit('update:note', {addressId: addressData.id, note})
|
||||
}
|
||||
},
|
||||
|
||||
created: async function () {}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
<div>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-pr-lg">Fee Rate:</div>
|
||||
<div class="col-3 q-pr-lg">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="feeRate"
|
||||
:rules="[val => !!val || 'Field is required']"
|
||||
type="number"
|
||||
label="sats/vbyte"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<q-slider
|
||||
v-model="feeRate"
|
||||
color="orange"
|
||||
markers
|
||||
snap
|
||||
label
|
||||
label-always
|
||||
:label-value="getFeeRateLabel(feeRate)"
|
||||
:min="1"
|
||||
:max="recommededFees.fastestFee"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="feeRate < recommededFees.hourFee || feeRate > recommededFees.fastestFee"
|
||||
class="row items-center no-wrap q-mb-md"
|
||||
>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-10 q-pr-lg">
|
||||
<q-badge v-if="feeRate < recommededFees.hourFee" color="pink" size="lg">
|
||||
Warning! The fee is too low. The transaction might take a long time to
|
||||
confirm.
|
||||
</q-badge>
|
||||
<q-badge v-if="feeRate > recommededFees.fastestFee" color="pink">
|
||||
Warning! The fee is too high. You might be overpaying for this
|
||||
transaction.
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-pr-lg">Fee:</div>
|
||||
<div class="col-3 q-pr-lg">{{feeValue}} sats</div>
|
||||
<div class="col-7">
|
||||
<q-btn
|
||||
outline
|
||||
dense
|
||||
size="md"
|
||||
icon="refresh"
|
||||
color="grey"
|
||||
class="float-right"
|
||||
@click="refreshRecommendedFees()"
|
||||
>Refresh Fee Rates</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,64 @@
|
|||
async function feeRate(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
Vue.component('fee-rate', {
|
||||
name: 'fee-rate',
|
||||
template,
|
||||
|
||||
props: ['rate', 'fee-value', 'sats-denominated', 'mempool-endpoint'],
|
||||
|
||||
computed: {
|
||||
feeRate: {
|
||||
get: function () {
|
||||
return this['rate']
|
||||
},
|
||||
set: function (value) {
|
||||
this.$emit('update:rate', +value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
recommededFees: {
|
||||
fastestFee: 1,
|
||||
halfHourFee: 1,
|
||||
hourFee: 1,
|
||||
economyFee: 1,
|
||||
minimumFee: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
satBtc(val, showUnit = true) {
|
||||
return satOrBtc(val, showUnit, this.satsDenominated)
|
||||
},
|
||||
|
||||
refreshRecommendedFees: async function () {
|
||||
const fn = async () => {
|
||||
const {
|
||||
bitcoin: {fees: feesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: this.mempoolEndpoint
|
||||
})
|
||||
return feesAPI.getFeesRecommended()
|
||||
}
|
||||
this.recommededFees = await retryWithDelay(fn)
|
||||
},
|
||||
getFeeRateLabel: function (feeRate) {
|
||||
const fees = this.recommededFees
|
||||
if (feeRate >= fees.fastestFee)
|
||||
return `High Priority (${feeRate} sat/vB)`
|
||||
if (feeRate >= fees.halfHourFee)
|
||||
return `Medium Priority (${feeRate} sat/vB)`
|
||||
if (feeRate >= fees.hourFee) return `Low Priority (${feeRate} sat/vB)`
|
||||
return `No Priority (${feeRate} sat/vB)`
|
||||
}
|
||||
},
|
||||
|
||||
created: async function () {
|
||||
await this.refreshRecommendedFees()
|
||||
this.feeRate = this.recommededFees.halfHourFee
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
<div>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col q-pr-lg"></div>
|
||||
<div class="col q-pr-lg">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
class="float-right"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn outline color="grey" label="...">
|
||||
<q-menu auto-close>
|
||||
<q-list style="min-width: 100px">
|
||||
<q-item clickable>
|
||||
<q-item-section @click="exportHistoryToCSV"
|
||||
>Export to CSV</q-item-section
|
||||
>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
style="height: 400px"
|
||||
flat
|
||||
dense
|
||||
:data="getFilteredAddressesHistory()"
|
||||
row-key="id"
|
||||
virtual-scroll
|
||||
:columns="historyTable.columns"
|
||||
:pagination.sync="historyTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="accent"
|
||||
round
|
||||
dense
|
||||
@click="props.row.expanded = !props.row.expanded"
|
||||
:icon="props.row.expanded ? 'remove' : 'add'"
|
||||
/>
|
||||
</q-td>
|
||||
|
||||
<q-td key="status" :props="props">
|
||||
<q-badge
|
||||
v-if="props.row.sent"
|
||||
@click="props.row.expanded = !props.row.expanded"
|
||||
color="orange"
|
||||
class="q-mr-md cursor-pointer"
|
||||
>
|
||||
{{props.row.confirmed ? 'Sent' : 'Sending...'}}
|
||||
</q-badge>
|
||||
<q-badge
|
||||
v-if="props.row.received"
|
||||
@click="props.row.expanded = !props.row.expanded"
|
||||
color="green"
|
||||
class="q-mr-md cursor-pointer"
|
||||
>
|
||||
{{props.row.confirmed ? 'Received' : 'Receiving...'}}
|
||||
</q-badge>
|
||||
</q-td>
|
||||
<q-td
|
||||
key="amount"
|
||||
:props="props"
|
||||
:class="props.row.amount && props.row.received > 0 ? 'text-green-13 text-weight-bold' : ''"
|
||||
>
|
||||
<div>{{satBtc(props.row.totalAmount || props.row.amount)}}</div>
|
||||
</q-td>
|
||||
<q-td key="address" :props="props">
|
||||
<a
|
||||
v-if="!props.row.sameTxItems"
|
||||
style="color: unset"
|
||||
:href="'https://' + mempoolEndpoint + '/address/' + props.row.address"
|
||||
target="_blank"
|
||||
>
|
||||
{{props.row.address}}</a
|
||||
>
|
||||
<q-badge
|
||||
v-if="props.row.sameTxItems"
|
||||
@click="props.row.expanded = !props.row.expanded"
|
||||
outline
|
||||
color="blue"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
...
|
||||
</q-badge>
|
||||
</q-td>
|
||||
<q-td key="date" :props="props"> {{ props.row.date }} </q-td>
|
||||
</q-tr>
|
||||
<q-tr v-show="props.row.expanded" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-pr-lg">Transaction Id</div>
|
||||
<div class="col-10 q-pr-lg">
|
||||
<a
|
||||
style="color: unset"
|
||||
:href="'https://' +mempoolEndpoint + '/tx/' + props.row.txId"
|
||||
target="_blank"
|
||||
>
|
||||
{{props.row.txId}}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.row.sameTxItems"
|
||||
class="row items-center no-wrap q-mb-md"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">UTXOs</div>
|
||||
<div class="col-4 q-pr-lg">{{satBtc(props.row.amount)}}</div>
|
||||
<div class="col-6 q-pr-lg">{{props.row.address}}</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in props.row.sameTxItems || []"
|
||||
class="row items-center no-wrap q-mb-md"
|
||||
>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-4 q-pr-lg">{{satBtc(s.amount)}}</div>
|
||||
<div class="col-6 q-pr-lg">{{s.address}}</div>
|
||||
</div>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-pr-lg">Fee</div>
|
||||
<div class="col-4 q-pr-lg">{{satBtc(props.row.fee)}}</div>
|
||||
</div>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-pr-lg">Block Height</div>
|
||||
<div class="col-4 q-pr-lg">{{props.row.height}}</div>
|
||||
</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
|
@ -0,0 +1,94 @@
|
|||
async function history(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
Vue.component('history', {
|
||||
name: 'history',
|
||||
template,
|
||||
|
||||
props: ['history', 'mempool-endpoint', 'sats-denominated', 'filter'],
|
||||
data: function () {
|
||||
return {
|
||||
historyTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'expand',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
align: 'left',
|
||||
label: 'Status'
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount',
|
||||
field: 'amount',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'address',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Date',
|
||||
field: 'date',
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
exportColums: [
|
||||
{
|
||||
label: 'Action',
|
||||
field: 'action'
|
||||
},
|
||||
{
|
||||
label: 'Date&Time',
|
||||
field: 'date'
|
||||
},
|
||||
{
|
||||
label: 'Amount',
|
||||
field: 'amount'
|
||||
},
|
||||
{
|
||||
label: 'Fee',
|
||||
field: 'fee'
|
||||
},
|
||||
{
|
||||
label: 'Transaction Id',
|
||||
field: 'txId'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
satBtc(val, showUnit = true) {
|
||||
return satOrBtc(val, showUnit, this.satsDenominated)
|
||||
},
|
||||
getFilteredAddressesHistory: function () {
|
||||
return this.history.filter(a => (!a.isChange || a.sent) && !a.isSubItem)
|
||||
},
|
||||
exportHistoryToCSV: function () {
|
||||
const history = this.getFilteredAddressesHistory().map(a => ({
|
||||
...a,
|
||||
action: a.sent ? 'Sent' : 'Received'
|
||||
}))
|
||||
LNbits.utils.exportCSV(
|
||||
this.historyTable.exportColums,
|
||||
history,
|
||||
'address-history'
|
||||
)
|
||||
}
|
||||
},
|
||||
created: async function () {}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
<div class="checkbox-wrapper" @click="check">
|
||||
<div :class="{ checkbox: true, checked: checked }"></div>
|
||||
<div class="title">{{ title }}</div>
|
||||
<q-btn color="primary">XXX</q-btn>
|
||||
</div>
|
|
@ -0,0 +1,16 @@
|
|||
async function initMyCheckbox(path) {
|
||||
const t = await loadTemplateAsync(path)
|
||||
Vue.component('my-checkbox', {
|
||||
name: 'my-checkbox',
|
||||
template: t,
|
||||
data() {
|
||||
return {checked: false, title: 'Check me'}
|
||||
},
|
||||
methods: {
|
||||
check() {
|
||||
this.checked = !this.checked
|
||||
console.log('### checked', this.checked)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,312 @@
|
|||
<div>
|
||||
<q-form @submit="checkAndSend" ref="paymentFormRef" class="q-gutter-md">
|
||||
<q-card class="q-mt-lg">
|
||||
<q-card-section>
|
||||
<send-to
|
||||
:data.sync="sendToList"
|
||||
:fee-rate="feeRate"
|
||||
:tx-size="txSizeNoChange"
|
||||
:selected-amount="selectedAmount"
|
||||
:sats-denominated="satsDenominated"
|
||||
@update:outputs="handleOutputsChange"
|
||||
></send-to>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-mt-lg">
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<div class="col-4">
|
||||
<q-toggle
|
||||
label="Show Custom Fee"
|
||||
color="secodary"
|
||||
class="float-left"
|
||||
v-model="showCustomFee"
|
||||
></q-toggle>
|
||||
</div>
|
||||
|
||||
<div class="col-8">
|
||||
<div class="float-right">
|
||||
<span>Fee Rate:</span>
|
||||
<span class="text-subtitle2 q-ml-md">
|
||||
{{feeRate}} sats/vbyte</span
|
||||
>
|
||||
<span class="q-ml-lg">Fee:</span>
|
||||
<span class="text-subtitle2 q-ml-md"> {{satBtc(feeValue)}} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showCustomFee" class="row items-center no-wrap q-mt-md">
|
||||
<div class="col-12">
|
||||
<q-separator class="q-mb-md"></q-separator>
|
||||
<fee-rate
|
||||
:fee-value="feeValue"
|
||||
:rate.sync="feeRate"
|
||||
:mempool-endpoint="mempoolEndpoint"
|
||||
:sats-denominated="satsDenominated"
|
||||
></fee-rate>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-mt-lg">
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<div class="col-4">
|
||||
<q-toggle
|
||||
label="Show Coin Select"
|
||||
color="secodary"
|
||||
class="float-left"
|
||||
v-model="showCoinSelect"
|
||||
></q-toggle>
|
||||
</div>
|
||||
|
||||
<div class="col-8">
|
||||
<div class="float-right">
|
||||
<span>Balance:</span>
|
||||
<span class="text-subtitle2 q-ml-md"> {{satBtc(balance)}} </span>
|
||||
<span class="q-ml-lg">Selected:</span>
|
||||
<span class="text-subtitle2 q-ml-md">
|
||||
{{satBtc(selectedAmount)}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showCoinSelect" class="row items-center no-wrap q-mt-md">
|
||||
<div class="col-12">
|
||||
<q-separator class="q-mb-md"></q-separator>
|
||||
<utxo-list
|
||||
ref="utxoList"
|
||||
:utxos="utxos"
|
||||
:selectable="true"
|
||||
:payed-amount="totalPayedAmount"
|
||||
:mempool-endpoint="mempoolEndpoint"
|
||||
:sats-denominated="satsDenominated"
|
||||
:accounts="accounts"
|
||||
></utxo-list>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-mt-lg">
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<div class="col-4">
|
||||
<q-toggle
|
||||
label="Show Change"
|
||||
color="secodary"
|
||||
class="float-left"
|
||||
v-model="showChange"
|
||||
></q-toggle>
|
||||
</div>
|
||||
|
||||
<div class="col-4">
|
||||
<q-badge
|
||||
v-if="changeAmount > 0 && changeAmount < DUST_LIMIT"
|
||||
class="text-subtitle2 float-right"
|
||||
color="yellow"
|
||||
text-color="black"
|
||||
>
|
||||
Below dust limit. Will be used as fee.
|
||||
</q-badge>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="float-right">
|
||||
<span>Change:</span>
|
||||
<span v-if="changeAmount < 0" class="text-subtitle2 q-ml-md">
|
||||
{{satBtc(0)}}
|
||||
</span>
|
||||
<span v-if="changeAmount >= 0" class="text-subtitle2 q-ml-md">
|
||||
{{satBtc(changeAmount)}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showChange" class="row items-center no-wrap q-mt-md">
|
||||
<div class="col-12">
|
||||
<q-separator class="q-mb-md"></q-separator>
|
||||
<div class="row items-center no-wrap">
|
||||
<div class="col-2 q-pr-lg">Change Account:</div>
|
||||
<div class="col-3 q-pr-lg">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="changeWallet"
|
||||
:options="accounts"
|
||||
@input="selectChangeAddress"
|
||||
:rules="[val => !!val || 'Field is required']"
|
||||
label="Wallet Account"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
v-model.trim="changeAddress.address"
|
||||
:rules="[val => !!val || 'Field is required']"
|
||||
type="text"
|
||||
label="Change Address"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<div class="row items-center no-wrap q-mb-md q-pt-lg">
|
||||
<div class="col-3">
|
||||
<q-btn-dropdown
|
||||
split
|
||||
unelevated
|
||||
:disabled="changeAmount < 0 || showChecking"
|
||||
label="Check & Send"
|
||||
color="green"
|
||||
type="submit"
|
||||
class="btn-full"
|
||||
>
|
||||
<q-list>
|
||||
<q-item :disabled="changeAmount < 0" clickable v-close-popup>
|
||||
<q-item-section>
|
||||
<q-item-label>Serial Port</q-item-label>
|
||||
<q-item-label caption>
|
||||
Sign using a Serial Port device</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item @click="showPsbtDialog" clickable v-close-popup>
|
||||
<q-item-section>
|
||||
<q-item-label>Share PSBT</q-item-label>
|
||||
<q-item-label caption
|
||||
>Share the PSBT as text or Animated QR Code</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="col-9">
|
||||
<q-spinner
|
||||
v-if="showChecking"
|
||||
size="2.55em"
|
||||
color="primary"
|
||||
></q-spinner>
|
||||
<q-badge
|
||||
v-if="changeAmount < 0"
|
||||
class="text-subtitle2 float-right"
|
||||
color="yellow"
|
||||
text-color="black"
|
||||
>
|
||||
The payed amount is higher than the selected amount!
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
<q-dialog v-model="showPsbt" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="psbtBase64"
|
||||
type="textarea"
|
||||
rows="25"
|
||||
cols="200"
|
||||
label="PSBT"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showFinalTx" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col-12">
|
||||
<span class="text-subtitle1">Transaction Details</span>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-mb-lg"></q-separator>
|
||||
<div v-if="signedTx" class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-12">
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col-3 q-pr-lg">Version</div>
|
||||
<div class="col-9">{{signedTx.version}}</div>
|
||||
</div>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col-3 q-pr-lg">Locktime</div>
|
||||
<div class="col-9">{{signedTx.locktime}}</div>
|
||||
</div>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col-3 q-pr-lg">Fee</div>
|
||||
<div class="col-9">
|
||||
<q-badge color="orange">{{satBtc(signedTx.fee)}} </q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-mb-lg"></q-separator>
|
||||
<span class="text-subtitle2">Outputs</span>
|
||||
<q-separator class="q-mb-lg"></q-separator>
|
||||
<div
|
||||
v-for="out in signedTx.outputs"
|
||||
class="row items-center no-wrap q-mb-sm"
|
||||
>
|
||||
<div class="col-3 q-pr-lg">
|
||||
<q-badge color="orange">{{satBtc(out.amount)}}</q-badge>
|
||||
</div>
|
||||
|
||||
<div class="col-9">
|
||||
<q-badge outline color="blue">{{out.address}}</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-mb-lg"></q-separator>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="signedTxHex"
|
||||
type="textarea"
|
||||
cols="300"
|
||||
rows="1"
|
||||
label="Signed Tx Hex"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="psbtBase64Signed"
|
||||
ype="textarea"
|
||||
cols="300"
|
||||
rows="1"
|
||||
label="PSBT"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="secondary"
|
||||
class="float-left"
|
||||
@click="broadcastTransaction"
|
||||
>Send</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
336
lnbits/extensions/watchonly/static/components/payment/payment.js
Normal file
336
lnbits/extensions/watchonly/static/components/payment/payment.js
Normal file
|
@ -0,0 +1,336 @@
|
|||
async function payment(path) {
|
||||
const t = await loadTemplateAsync(path)
|
||||
Vue.component('payment', {
|
||||
name: 'payment',
|
||||
template: t,
|
||||
|
||||
props: [
|
||||
'accounts',
|
||||
'addresses',
|
||||
'utxos',
|
||||
'mempool-endpoint',
|
||||
'sats-denominated',
|
||||
'serial-signer-ref',
|
||||
'adminkey'
|
||||
],
|
||||
watch: {
|
||||
immediate: true,
|
||||
accounts() {
|
||||
this.updateChangeAddress()
|
||||
},
|
||||
addresses() {
|
||||
this.updateChangeAddress()
|
||||
}
|
||||
},
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
DUST_LIMIT: 546,
|
||||
tx: null,
|
||||
psbtBase64: null,
|
||||
psbtBase64Signed: null,
|
||||
signedTx: null,
|
||||
signedTxHex: null,
|
||||
sentTxId: null,
|
||||
signedTxId: null,
|
||||
paymentTab: 'destination',
|
||||
sendToList: [{address: '', amount: undefined}],
|
||||
changeWallet: null,
|
||||
changeAddress: {},
|
||||
showCustomFee: false,
|
||||
showCoinSelect: false,
|
||||
showChecking: false,
|
||||
showChange: false,
|
||||
showPsbt: false,
|
||||
showFinalTx: false,
|
||||
feeRate: 1
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
txSize: function () {
|
||||
const tx = this.createTx()
|
||||
return Math.round(txSize(tx))
|
||||
},
|
||||
txSizeNoChange: function () {
|
||||
const tx = this.createTx(true)
|
||||
return Math.round(txSize(tx))
|
||||
},
|
||||
feeValue: function () {
|
||||
return this.feeRate * this.txSize
|
||||
},
|
||||
selectedAmount: function () {
|
||||
return this.utxos
|
||||
.filter(utxo => utxo.selected)
|
||||
.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
},
|
||||
changeAmount: function () {
|
||||
return (
|
||||
this.selectedAmount -
|
||||
this.totalPayedAmount -
|
||||
this.feeRate * this.txSize
|
||||
)
|
||||
},
|
||||
balance: function () {
|
||||
return this.utxos.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
},
|
||||
totalPayedAmount: function () {
|
||||
return this.sendToList.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
satBtc(val, showUnit = true) {
|
||||
return satOrBtc(val, showUnit, this.satsDenominated)
|
||||
},
|
||||
checkAndSend: async function () {
|
||||
this.showChecking = true
|
||||
try {
|
||||
if (!this.serialSignerRef.isConnected()) {
|
||||
const portOpen = await this.serialSignerRef.openSerialPort()
|
||||
if (!portOpen) return
|
||||
}
|
||||
if (!this.serialSignerRef.isAuthenticated()) {
|
||||
await this.serialSignerRef.hwwShowPasswordDialog()
|
||||
const authenticated = await this.serialSignerRef.isAuthenticating()
|
||||
if (!authenticated) return
|
||||
}
|
||||
|
||||
await this.createPsbt()
|
||||
|
||||
if (this.psbtBase64) {
|
||||
const txData = {
|
||||
outputs: this.tx.outputs,
|
||||
feeRate: this.tx.fee_rate,
|
||||
feeValue: this.feeValue
|
||||
}
|
||||
await this.serialSignerRef.hwwSendPsbt(this.psbtBase64, txData)
|
||||
await this.serialSignerRef.isSendingPsbt()
|
||||
}
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Cannot check and sign PSBT!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
} finally {
|
||||
this.showChecking = false
|
||||
this.psbtBase64 = null
|
||||
}
|
||||
},
|
||||
showPsbtDialog: async function () {
|
||||
try {
|
||||
const valid = await this.$refs.paymentFormRef.validate()
|
||||
if (!valid) return
|
||||
|
||||
const data = await this.createPsbt()
|
||||
if (data) {
|
||||
this.showPsbt = true
|
||||
}
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to create PSBT!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
createPsbt: async function () {
|
||||
try {
|
||||
console.log('### this.createPsbt')
|
||||
this.tx = this.createTx()
|
||||
for (const input of this.tx.inputs) {
|
||||
input.tx_hex = await this.fetchTxHex(input.tx_id)
|
||||
}
|
||||
|
||||
const changeOutput = this.tx.outputs.find(o => o.branch_index === 1)
|
||||
if (changeOutput) changeOutput.amount = this.changeAmount
|
||||
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/watchonly/api/v1/psbt',
|
||||
this.adminkey,
|
||||
this.tx
|
||||
)
|
||||
|
||||
this.psbtBase64 = data
|
||||
return data
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
createTx: function (excludeChange = false) {
|
||||
const tx = {
|
||||
fee_rate: this.feeRate,
|
||||
masterpubs: this.accounts.map(w => ({
|
||||
id: w.id,
|
||||
public_key: w.masterpub,
|
||||
fingerprint: w.fingerprint
|
||||
}))
|
||||
}
|
||||
tx.inputs = this.utxos
|
||||
.filter(utxo => utxo.selected)
|
||||
.map(mapUtxoToPsbtInput)
|
||||
.sort((a, b) =>
|
||||
a.tx_id < b.tx_id ? -1 : a.tx_id > b.tx_id ? 1 : a.vout - b.vout
|
||||
)
|
||||
|
||||
tx.outputs = this.sendToList.map(out => ({
|
||||
address: out.address,
|
||||
amount: out.amount
|
||||
}))
|
||||
|
||||
if (!excludeChange) {
|
||||
const change = this.createChangeOutput()
|
||||
const diffAmount = this.selectedAmount - this.totalPayedAmount
|
||||
if (diffAmount >= this.DUST_LIMIT) {
|
||||
tx.outputs.push(change)
|
||||
}
|
||||
}
|
||||
tx.tx_size = Math.round(txSize(tx))
|
||||
tx.inputs = _.shuffle(tx.inputs)
|
||||
tx.outputs = _.shuffle(tx.outputs)
|
||||
|
||||
return tx
|
||||
},
|
||||
createChangeOutput: function () {
|
||||
const change = this.changeAddress
|
||||
const walletAcount =
|
||||
this.accounts.find(w => w.id === change.wallet) || {}
|
||||
|
||||
return {
|
||||
address: change.address,
|
||||
address_index: change.addressIndex,
|
||||
branch_index: change.isChange ? 1 : 0,
|
||||
wallet: walletAcount.id
|
||||
}
|
||||
},
|
||||
selectChangeAddress: function (account) {
|
||||
if (!account) this.changeAddress = ''
|
||||
this.changeAddress =
|
||||
this.addresses.find(
|
||||
a => a.wallet === account.id && a.isChange && !a.hasActivity
|
||||
) || {}
|
||||
},
|
||||
updateChangeAddress: function () {
|
||||
if (this.changeWallet) {
|
||||
const changeAccount = (this.accounts || []).find(
|
||||
w => w.id === this.changeWallet.id
|
||||
)
|
||||
// change account deleted
|
||||
if (!changeAccount) {
|
||||
this.changeWallet = this.accounts[0]
|
||||
}
|
||||
} else {
|
||||
this.changeWallet = this.accounts[0]
|
||||
}
|
||||
this.selectChangeAddress(this.changeWallet)
|
||||
},
|
||||
updateSignedPsbt: async function (psbtBase64) {
|
||||
try {
|
||||
this.showChecking = true
|
||||
this.psbtBase64Signed = psbtBase64
|
||||
|
||||
console.log('### payment updateSignedPsbt psbtBase64', psbtBase64)
|
||||
|
||||
const data = await this.extractTxFromPsbt(psbtBase64)
|
||||
this.showFinalTx = true
|
||||
if (data) {
|
||||
this.signedTx = JSON.parse(data.tx_json)
|
||||
this.signedTxHex = data.tx_hex
|
||||
} else {
|
||||
this.signedTx = null
|
||||
this.signedTxHex = null
|
||||
}
|
||||
} finally {
|
||||
this.showChecking = false
|
||||
}
|
||||
},
|
||||
extractTxFromPsbt: async function (psbtBase64) {
|
||||
console.log('### extractTxFromPsbt psbtBase64', psbtBase64)
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/watchonly/api/v1/psbt/extract',
|
||||
this.adminkey,
|
||||
{
|
||||
psbtBase64,
|
||||
inputs: this.tx.inputs
|
||||
}
|
||||
)
|
||||
console.log('### extractTxFromPsbt data', data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log('### error', error)
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Cannot finalize PSBT!',
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
broadcastTransaction: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/watchonly/api/v1/tx',
|
||||
this.adminkey,
|
||||
{tx_hex: this.signedTxHex}
|
||||
)
|
||||
this.sentTxId = data
|
||||
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Transaction broadcasted!',
|
||||
caption: `${data}`,
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
// todo: event rescan with amount
|
||||
// todo: display tx id
|
||||
} catch (error) {
|
||||
this.sentTxId = null
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to broadcast!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
} finally {
|
||||
this.showFinalTx = false
|
||||
}
|
||||
},
|
||||
fetchTxHex: async function (txId) {
|
||||
const {
|
||||
bitcoin: {transactions: transactionsAPI}
|
||||
} = mempoolJS({
|
||||
hostname: this.mempoolEndpoint
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await transactionsAPI.getTxHex({txid: txId})
|
||||
return response
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: `Failed to fetch transaction details for tx id: '${txId}'`,
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
handleOutputsChange: function () {
|
||||
this.$refs.utxoList.refreshUtxoSelection(this.totalPayedAmount)
|
||||
},
|
||||
getTotalPaymentAmount: function () {
|
||||
return this.sendToList.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
}
|
||||
},
|
||||
|
||||
created: async function () {}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
hide-header
|
||||
:data="data"
|
||||
:columns="paymentTable.columns"
|
||||
:pagination.sync="paymentTable.pagination"
|
||||
>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<div class="row no-wrap">
|
||||
<div class="col-1">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="l"
|
||||
@click="deletePaymentAddress(props.row)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
class="q-mt-sm"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-7 q-pr-lg">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="props.row.address"
|
||||
type="text"
|
||||
label="Address"
|
||||
:rules="[val => !!val || 'Field is required']"
|
||||
@input="handleOutputsChange"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-3 q-pr-lg">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="props.row.amount"
|
||||
type="number"
|
||||
step="1"
|
||||
label="Amount (sats)"
|
||||
:rules="[val => !!val || 'Field is required', val => +val > DUST_LIMIT || 'Amount to small (below dust limit)']"
|
||||
@input="handleOutputsChange"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<q-btn outline color="grey" @click="sendMaxToAddress(props.row)"
|
||||
>Max</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
<div class="row items-center no-wrap">
|
||||
<div class="col-3 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="secondary"
|
||||
@click="addPaymentAddress"
|
||||
class="btn-full"
|
||||
>Add</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<div class="float-right">
|
||||
<span>Payed Amount: </span>
|
||||
<span class="text-subtitle2 q-ml-lg">
|
||||
{{satBtc(getTotalPaymentAmount())}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,81 @@
|
|||
async function sendTo(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
Vue.component('send-to', {
|
||||
name: 'send-to',
|
||||
template,
|
||||
|
||||
props: [
|
||||
'data',
|
||||
'tx-size',
|
||||
'selected-amount',
|
||||
'fee-rate',
|
||||
'sats-denominated'
|
||||
],
|
||||
|
||||
computed: {
|
||||
dataLocal: {
|
||||
get: function () {
|
||||
return this.data
|
||||
},
|
||||
set: function (value) {
|
||||
console.log('### computed update data', value)
|
||||
this.$emit('update:data', value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
DUST_LIMIT: 546,
|
||||
paymentTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'data',
|
||||
align: 'left'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
},
|
||||
filter: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
satBtc(val, showUnit = true) {
|
||||
return satOrBtc(val, showUnit, this.satsDenominated)
|
||||
},
|
||||
addPaymentAddress: function () {
|
||||
this.dataLocal.push({address: '', amount: undefined})
|
||||
this.handleOutputsChange()
|
||||
},
|
||||
deletePaymentAddress: function (v) {
|
||||
const index = this.dataLocal.indexOf(v)
|
||||
if (index !== -1) {
|
||||
this.dataLocal.splice(index, 1)
|
||||
}
|
||||
this.handleOutputsChange()
|
||||
},
|
||||
|
||||
sendMaxToAddress: function (paymentAddress = {}) {
|
||||
const feeValue = this.feeRate * this.txSize
|
||||
const inputAmount = this.selectedAmount
|
||||
const currentAmount = Math.max(0, paymentAddress.amount || 0)
|
||||
const payedAmount = this.getTotalPaymentAmount() - currentAmount
|
||||
paymentAddress.amount = Math.max(
|
||||
0,
|
||||
inputAmount - payedAmount - feeValue
|
||||
)
|
||||
},
|
||||
handleOutputsChange: function () {
|
||||
this.$emit('update:outputs')
|
||||
},
|
||||
getTotalPaymentAmount: function () {
|
||||
return this.dataLocal.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
}
|
||||
},
|
||||
|
||||
created: async function () {}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
<div>
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="config.baudRate"
|
||||
type="number"
|
||||
label="Baud Rate"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="config.bufferSize"
|
||||
type="number"
|
||||
label="Buffer Size"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="config.flowControl"
|
||||
label="Flow Control"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="config.parity"
|
||||
label="Parity"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="config.dataBits"
|
||||
type="number"
|
||||
label="Data Bits"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="config.stopBits"
|
||||
type="number"
|
||||
label="Stop Bits"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,24 @@
|
|||
async function serialPortConfig(path) {
|
||||
const t = await loadTemplateAsync(path)
|
||||
Vue.component('serial-port-config', {
|
||||
name: 'serial-port-config',
|
||||
template: t,
|
||||
data() {
|
||||
return {
|
||||
config: {
|
||||
baudRate: 9600,
|
||||
bufferSize: 255,
|
||||
dataBits: 8,
|
||||
flowControl: 'none',
|
||||
parity: 'none',
|
||||
stopBits: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getConfig: function () {
|
||||
return this.config
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,451 @@
|
|||
<div>
|
||||
<q-btn-dropdown
|
||||
split
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="usb"
|
||||
:text-color="selectedPort ? hww.authenticated ? 'green' : 'orange' : 'white'"
|
||||
@click="openSerialPortDialog"
|
||||
>
|
||||
<q-list>
|
||||
<q-item
|
||||
v-if="selectedPort && !hww.authenticated"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="hwwShowPasswordDialog()"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>Login</q-item-label>
|
||||
<q-item-label caption
|
||||
>Enter password for Hardware Wallet.</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item
|
||||
v-if="hww.authenticated"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="hwwLogout()"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>Logout</q-item-label>
|
||||
<q-item-label caption>Clear password for HWW.</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
v-if="!selectedPort"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="openSerialPortConfig"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>Config & Connect</q-item-label>
|
||||
<q-item-label caption
|
||||
>Set the Serial Port communication parameters.</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
v-if="selectedPort"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="closeSerialPort()"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>Disconnect</q-item-label>
|
||||
<q-item-label caption>Disconnect from Serial Port.</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item
|
||||
v-if="selectedPort"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="hwwShowRestoreDialog()"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>Restore</q-item-label>
|
||||
<q-item-label caption
|
||||
>Restore wallet from existing word list.</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
v-if="hww.authenticated"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="hwwShowSeed()"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>Show Seed</q-item-label>
|
||||
<q-item-label caption
|
||||
>Show seed on the Hardware Wallet display.</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
v-if="selectedPort"
|
||||
@click="hwwShowWipeDialog()"
|
||||
clickable
|
||||
v-close-popup
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>Wipe</q-item-label>
|
||||
<q-item-label caption
|
||||
>Clean-up the wallet. New random seed.</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="selectedPort" @click="hwwHelp()" clickable v-close-popup>
|
||||
<q-item-section>
|
||||
<q-item-label>Help</q-item-label>
|
||||
<q-item-label caption>View available comands.</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
v-if="selectedPort"
|
||||
@click="showConsole = true"
|
||||
clickable
|
||||
v-close-popup
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>Console</q-item-label>
|
||||
<q-item-label caption
|
||||
>Show the serial port communication messages</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
|
||||
<q-dialog v-model="hww.showConfigDialog" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="hwwConfigAndConnect" class="q-gutter-md">
|
||||
<span>Enter Config</span>
|
||||
|
||||
<serial-port-config
|
||||
ref="serialPortConfig"
|
||||
:config="hww.config"
|
||||
></serial-port-config>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" type="submit">Connect</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="hww.showPasswordDialog" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="hwwLogin" class="q-gutter-md">
|
||||
<span>Enter password for Hardware Wallet (8 numbers/letters)</span>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="hww.password"
|
||||
type="password"
|
||||
label="Password"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="!selectedPort"
|
||||
type="submit"
|
||||
>Login</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="hww.showConfirmationDialog" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="hwwSignPsbt" class="q-gutter-md">
|
||||
<div v-if="tx">
|
||||
<div v-if="!hww.confirm.showFee" class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<span class="text-subtitle2"
|
||||
>Output {{hww.confirm.outputIndex}}</span
|
||||
>
|
||||
<q-badge
|
||||
v-if="tx.outputs[hww.confirm.outputIndex].branch_index === 1"
|
||||
color="orange"
|
||||
text-color="black"
|
||||
>
|
||||
<span>change</span>
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hww.confirm.showFee" class="row q-mt-lg">
|
||||
<div class="col-3">
|
||||
<span>Address:</span>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<span>{{tx.outputs[hww.confirm.outputIndex].address}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hww.confirm.showFee" class="row q-mt-lg">
|
||||
<div class="col-3">
|
||||
<span>Amount:</span>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<span
|
||||
>{{satBtc(tx.outputs[hww.confirm.outputIndex].amount)}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hww.confirm.showFee" class="row q-mt-lg">
|
||||
<div class="col-3">
|
||||
<span>Fee: </span>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<span>{{satBtc(tx.feeValue)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hww.confirm.showFee" class="row q-mt-lg">
|
||||
<div class="col-3">
|
||||
<span>Fee Rate:</span>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<span>{{tx.feeRate}} sats/vbyte</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<q-badge class="text-subtitle2" color="yellow" text-color="black">
|
||||
<span>Check data on the display of the hardware device.</span>
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
v-if="hww.confirm.showFee"
|
||||
unelevated
|
||||
color="green"
|
||||
:disable="!selectedPort"
|
||||
type="submit"
|
||||
class="float-left"
|
||||
label="Confirm"
|
||||
>
|
||||
<q-spinner v-if="hww.signingPsbt" color="primary"></q-spinner>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="secondary"
|
||||
label="Next"
|
||||
class="float-left"
|
||||
v-if="!hww.confirm.showFee"
|
||||
@click="hwwConfirmNext"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<q-btn
|
||||
@click="cancelOperation"
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="float-right"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="hww.showWipeDialog" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="hwwWipe" class="q-gutter-md">
|
||||
<q-badge color="pink" text-color="black">
|
||||
This action will remove all data from the Hardware Wallet. Please
|
||||
create a back-up for the seed!
|
||||
</q-badge>
|
||||
<span>Enter new password for Hardware Wallet (8 numbers/letters)</span>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="hww.password"
|
||||
type="password"
|
||||
label="Password"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="hww.confirmedPassword"
|
||||
type="password"
|
||||
label="Confirm Password"
|
||||
></q-input>
|
||||
<q-badge color="pink" text-color="black">
|
||||
This action cannot be reversed!
|
||||
</q-badge>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="!hww.password || hww.password.length < 8 || (hww.password !== hww.confirmedPassword)"
|
||||
type="submit"
|
||||
>Wipe</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showConsole" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
for="serial-port-console"
|
||||
v-model.trim="receivedData"
|
||||
type="textarea"
|
||||
rows="25"
|
||||
cols="200"
|
||||
label="Console"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="hww.showSeedDialog" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<span>Check word at position {{hww.seedWordPosition}} on display</span>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col-4">
|
||||
<q-btn
|
||||
v-if="hww.seedWordPosition!== 1"
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="showPrevSeedWord"
|
||||
>Prev</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<q-btn
|
||||
v-if="hww.seedWordPosition!== 24"
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="showNextSeedWord"
|
||||
>Next</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="hww.showRestoreDialog" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="hwwRestore" class="q-gutter-md">
|
||||
<q-badge
|
||||
color="pink"
|
||||
text-color="black"
|
||||
class="text-subtitle2"
|
||||
multi-line
|
||||
>
|
||||
For test purposes only. Do not enter word list with real funds!!!
|
||||
</q-badge>
|
||||
<br /><br /><br />
|
||||
<span>Enter new word list separated by space</span>
|
||||
<q-input
|
||||
v-model.trim="hww.mnemonic"
|
||||
filled
|
||||
:type="hww.showMnemonic ? 'text' : 'password'"
|
||||
filled
|
||||
dense
|
||||
label="Word List"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="hww.showMnemonic ? 'visibility' : 'visibility_off'"
|
||||
class="cursor-pointer"
|
||||
@click="hww.showMnemonic = !hww.showMnemonic"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
<br />
|
||||
<span>Enter new password (8 numbers/letters)</span>
|
||||
<q-input
|
||||
v-model.trim="hww.password"
|
||||
filled
|
||||
:type="hww.showPassword ? 'text' : 'password'"
|
||||
filled
|
||||
dense
|
||||
label="New Password"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="hww.showPassword ? 'visibility' : 'visibility_off'"
|
||||
class="cursor-pointer"
|
||||
@click="hww.showPassword = !hww.showPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="hww.confirmedPassword"
|
||||
type="password"
|
||||
label="Confirm Password"
|
||||
></q-input>
|
||||
<br /><br />
|
||||
<q-badge
|
||||
color="pink"
|
||||
text-color="black"
|
||||
class="text-subtitle2"
|
||||
multi-line
|
||||
>
|
||||
For test purposes only. Do not enter word list with real funds!!!
|
||||
</q-badge>
|
||||
<q-separator></q-separator>
|
||||
<q-badge
|
||||
color="pink"
|
||||
text-color="black"
|
||||
class="text-subtitle2"
|
||||
multi-line
|
||||
>
|
||||
ALL existing data on the Hardware Device will be lost.
|
||||
</q-badge>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="!hww.mnemonic || !hww.password || hww.password.length < 8 || (hww.password !== hww.confirmedPassword)"
|
||||
type="submit"
|
||||
>Restore</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
|
@ -0,0 +1,601 @@
|
|||
async function serialSigner(path) {
|
||||
const t = await loadTemplateAsync(path)
|
||||
Vue.component('serial-signer', {
|
||||
name: 'serial-signer',
|
||||
template: t,
|
||||
|
||||
props: ['sats-denominated', 'network'],
|
||||
data: function () {
|
||||
return {
|
||||
selectedPort: null,
|
||||
writableStreamClosed: null,
|
||||
writer: null,
|
||||
readableStreamClosed: null,
|
||||
reader: null,
|
||||
receivedData: '',
|
||||
config: {},
|
||||
|
||||
hww: {
|
||||
password: null,
|
||||
showPassword: false,
|
||||
mnemonic: null,
|
||||
showMnemonic: false,
|
||||
authenticated: false,
|
||||
showPasswordDialog: false,
|
||||
showConfigDialog: false,
|
||||
showWipeDialog: false,
|
||||
showRestoreDialog: false,
|
||||
showConfirmationDialog: false,
|
||||
showSignedPsbt: false,
|
||||
sendingPsbt: false,
|
||||
signingPsbt: false,
|
||||
loginResolve: null,
|
||||
psbtSentResolve: null,
|
||||
xpubResolve: null,
|
||||
seedWordPosition: 1,
|
||||
showSeedDialog: false,
|
||||
confirm: {
|
||||
outputIndex: 0,
|
||||
showFee: false
|
||||
}
|
||||
},
|
||||
tx: null, // todo: move to hww
|
||||
|
||||
showConsole: false
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
satBtc(val, showUnit = true) {
|
||||
return satOrBtc(val, showUnit, this.satsDenominated)
|
||||
},
|
||||
openSerialPortDialog: async function () {
|
||||
await this.openSerialPort()
|
||||
},
|
||||
openSerialPort: async function (config = {baudRate: 9600}) {
|
||||
if (!this.checkSerialPortSupported()) return false
|
||||
if (this.selectedPort) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Already connected. Disconnect first!',
|
||||
timeout: 10000
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
navigator.serial.addEventListener('connect', event => {
|
||||
console.log('### navigator.serial event: connected!', event)
|
||||
})
|
||||
|
||||
navigator.serial.addEventListener('disconnect', () => {
|
||||
console.log('### navigator.serial event: disconnected!', event)
|
||||
this.hww.authenticated = false
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Disconnected from Serial Port!',
|
||||
timeout: 10000
|
||||
})
|
||||
})
|
||||
this.selectedPort = await navigator.serial.requestPort()
|
||||
// Wait for the serial port to open.
|
||||
await this.selectedPort.open(config)
|
||||
this.startSerialPortReading()
|
||||
|
||||
const textEncoder = new TextEncoderStream()
|
||||
this.writableStreamClosed = textEncoder.readable.pipeTo(
|
||||
this.selectedPort.writable
|
||||
)
|
||||
|
||||
this.writer = textEncoder.writable.getWriter()
|
||||
return true
|
||||
} catch (error) {
|
||||
this.selectedPort = null
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Cannot open serial port!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
return false
|
||||
}
|
||||
},
|
||||
openSerialPortConfig: async function () {
|
||||
this.hww.showConfigDialog = true
|
||||
},
|
||||
closeSerialPort: async function () {
|
||||
try {
|
||||
if (this.writer) this.writer.close()
|
||||
if (this.writableStreamClosed) await this.writableStreamClosed
|
||||
if (this.reader) this.reader.cancel()
|
||||
if (this.readableStreamClosed)
|
||||
await this.readableStreamClosed.catch(() => {
|
||||
/* Ignore the error */
|
||||
})
|
||||
if (this.selectedPort) await this.selectedPort.close()
|
||||
this.selectedPort = null
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Serial port disconnected!',
|
||||
timeout: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
this.selectedPort = null
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Cannot close serial port!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
} finally {
|
||||
this.hww.authenticated = false
|
||||
}
|
||||
},
|
||||
|
||||
isConnected: function () {
|
||||
return !!this.selectedPort
|
||||
},
|
||||
isAuthenticated: function () {
|
||||
return this.hww.authenticated
|
||||
},
|
||||
isAuthenticating: function () {
|
||||
if (this.isAuthenticated()) return false
|
||||
return new Promise(resolve => {
|
||||
this.loginResolve = resolve
|
||||
})
|
||||
},
|
||||
|
||||
isSendingPsbt: async function () {
|
||||
if (!this.hww.sendingPsbt) return false
|
||||
return new Promise(resolve => {
|
||||
this.psbtSentResolve = resolve
|
||||
})
|
||||
},
|
||||
|
||||
isFetchingXpub: async function () {
|
||||
return new Promise(resolve => {
|
||||
this.xpubResolve = resolve
|
||||
})
|
||||
},
|
||||
|
||||
checkSerialPortSupported: function () {
|
||||
if (!navigator.serial) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Serial port communication not supported!',
|
||||
caption:
|
||||
'Make sure your browser supports Serial Port and that you are using HTTPS.',
|
||||
timeout: 10000
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
startSerialPortReading: async function () {
|
||||
const port = this.selectedPort
|
||||
|
||||
while (port && port.readable) {
|
||||
const textDecoder = new TextDecoderStream()
|
||||
this.readableStreamClosed = port.readable.pipeTo(textDecoder.writable)
|
||||
this.reader = textDecoder.readable.getReader()
|
||||
const readStringUntil = readFromSerialPort(this.reader)
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const {value, done} = await readStringUntil('\n')
|
||||
if (value) {
|
||||
this.handleSerialPortResponse(value)
|
||||
this.updateSerialPortConsole(value)
|
||||
}
|
||||
if (done) return
|
||||
}
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Serial port communication error!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
handleSerialPortResponse: function (value) {
|
||||
const command = value.split(' ')[0]
|
||||
const commandData = value.substring(command.length).trim()
|
||||
|
||||
switch (command) {
|
||||
case COMMAND_SIGN_PSBT:
|
||||
this.handleSignResponse(commandData)
|
||||
break
|
||||
case COMMAND_PASSWORD:
|
||||
this.handleLoginResponse(commandData)
|
||||
break
|
||||
case COMMAND_PASSWORD_CLEAR:
|
||||
this.handleLogoutResponse(commandData)
|
||||
break
|
||||
case COMMAND_SEND_PSBT:
|
||||
this.handleSendPsbtResponse(commandData)
|
||||
break
|
||||
case COMMAND_WIPE:
|
||||
this.handleWipeResponse(commandData)
|
||||
break
|
||||
case COMMAND_XPUB:
|
||||
this.handleXpubResponse(commandData)
|
||||
break
|
||||
default:
|
||||
console.log('### console', value)
|
||||
}
|
||||
},
|
||||
updateSerialPortConsole: function (value) {
|
||||
this.receivedData += value + '\n'
|
||||
const textArea = document.getElementById('serial-port-console')
|
||||
if (textArea) textArea.scrollTop = textArea.scrollHeight
|
||||
},
|
||||
hwwShowPasswordDialog: async function () {
|
||||
try {
|
||||
this.hww.showPasswordDialog = true
|
||||
await this.writer.write(COMMAND_PASSWORD + '\n')
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to connect to Hardware Wallet!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
hwwShowWipeDialog: async function () {
|
||||
try {
|
||||
this.hww.showWipeDialog = true
|
||||
await this.writer.write(COMMAND_WIPE + '\n')
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to connect to Hardware Wallet!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
hwwShowRestoreDialog: async function () {
|
||||
try {
|
||||
this.hww.showRestoreDialog = true
|
||||
await this.writer.write(COMMAND_WIPE + '\n')
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to connect to Hardware Wallet!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
hwwConfirmNext: async function () {
|
||||
this.hww.confirm.outputIndex += 1
|
||||
if (this.hww.confirm.outputIndex >= this.tx.outputs.length) {
|
||||
this.hww.confirm.showFee = true
|
||||
}
|
||||
await this.writer.write(COMMAND_CONFIRM_NEXT + '\n')
|
||||
},
|
||||
cancelOperation: async function () {
|
||||
try {
|
||||
await this.writer.write(COMMAND_CANCEL + '\n')
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to send cancel!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
hwwConfigAndConnect: async function () {
|
||||
this.hww.showConfigDialog = false
|
||||
const config = this.$refs.serialPortConfig.getConfig()
|
||||
await this.openSerialPort(config)
|
||||
return true
|
||||
},
|
||||
hwwLogin: async function () {
|
||||
try {
|
||||
await this.writer.write(
|
||||
COMMAND_PASSWORD + ' ' + this.hww.password + '\n'
|
||||
)
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to send password to Hardware Wallet!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
} finally {
|
||||
this.hww.showPasswordDialog = false
|
||||
this.hww.password = null
|
||||
this.hww.showPassword = false
|
||||
}
|
||||
},
|
||||
handleLoginResponse: function (res = '') {
|
||||
this.hww.authenticated = res.trim() === '1'
|
||||
if (this.loginResolve) {
|
||||
this.loginResolve(this.hww.authenticated)
|
||||
}
|
||||
|
||||
if (this.hww.authenticated) {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Login successfull!',
|
||||
timeout: 10000
|
||||
})
|
||||
} else {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Wrong password, try again!',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
hwwLogout: async function () {
|
||||
try {
|
||||
await this.writer.write(COMMAND_PASSWORD_CLEAR + '\n')
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to logout from Hardware Wallet!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
handleLogoutResponse: function (res = '') {
|
||||
this.hww.authenticated = !(res.trim() === '1')
|
||||
if (this.hww.authenticated) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to logout from Hardware Wallet',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
hwwSendPsbt: async function (psbtBase64, tx) {
|
||||
try {
|
||||
this.tx = tx
|
||||
this.hww.sendingPsbt = true
|
||||
await this.writer.write(
|
||||
COMMAND_SEND_PSBT + ' ' + this.network + ' ' + psbtBase64 + '\n'
|
||||
)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Data sent to serial port device!',
|
||||
timeout: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
this.hww.sendingPsbt = false
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to send data to serial port!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
handleSendPsbtResponse: function (res = '') {
|
||||
try {
|
||||
const psbtOK = res.trim() === '1'
|
||||
if (!psbtOK) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to send PSBT!',
|
||||
caption: `${res}`,
|
||||
timeout: 10000
|
||||
})
|
||||
return
|
||||
}
|
||||
this.hww.confirm.outputIndex = 0
|
||||
this.hww.showConfirmationDialog = true
|
||||
this.hww.confirm = {
|
||||
outputIndex: 0,
|
||||
showFee: false
|
||||
}
|
||||
this.hww.sendingPsbt = false
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to send PSBT!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
} finally {
|
||||
this.psbtSentResolve()
|
||||
}
|
||||
},
|
||||
hwwSignPsbt: async function () {
|
||||
try {
|
||||
this.hww.showConfirmationDialog = false
|
||||
this.hww.signingPsbt = true
|
||||
await this.writer.write(COMMAND_SIGN_PSBT + '\n')
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to sign PSBT!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
handleSignResponse: function (res = '') {
|
||||
this.hww.signingPsbt = false
|
||||
const [count, psbt] = res.trim().split(' ')
|
||||
if (!psbt || !count || count.trim() === '0') {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'No input signed!',
|
||||
caption: 'Are you using the right seed?',
|
||||
timeout: 10000
|
||||
})
|
||||
return
|
||||
}
|
||||
this.updateSignedPsbt(psbt)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Transaction Signed',
|
||||
message: `Inputs signed: ${count}`,
|
||||
timeout: 10000
|
||||
})
|
||||
},
|
||||
hwwHelp: async function () {
|
||||
try {
|
||||
await this.writer.write(COMMAND_HELP + '\n')
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Check display or console for details!',
|
||||
timeout: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to ask for help!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
hwwWipe: async function () {
|
||||
try {
|
||||
this.hww.showWipeDialog = false
|
||||
await this.writer.write(COMMAND_WIPE + ' ' + this.hww.password + '\n')
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to ask for help!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
} finally {
|
||||
this.hww.password = null
|
||||
this.hww.confirmedPassword = null
|
||||
this.hww.showPassword = false
|
||||
}
|
||||
},
|
||||
handleWipeResponse: function (res = '') {
|
||||
const wiped = res.trim() === '1'
|
||||
if (wiped) {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Wallet wiped!',
|
||||
timeout: 10000
|
||||
})
|
||||
} else {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to wipe wallet!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
hwwXpub: async function (path) {
|
||||
try {
|
||||
console.log(
|
||||
'### hwwXpub',
|
||||
COMMAND_XPUB + ' ' + this.network + ' ' + path
|
||||
)
|
||||
await this.writer.write(
|
||||
COMMAND_XPUB + ' ' + this.network + ' ' + path + '\n'
|
||||
)
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to fetch XPub!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
handleXpubResponse: function (res = '') {
|
||||
const args = res.trim().split(' ')
|
||||
if (args.length < 3 || args[0].trim() !== '1') {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to fetch XPub!',
|
||||
caption: `${res}`,
|
||||
timeout: 10000
|
||||
})
|
||||
this.xpubResolve({})
|
||||
return
|
||||
}
|
||||
const xpub = args[1].trim()
|
||||
const fingerprint = args[2].trim()
|
||||
this.xpubResolve({xpub, fingerprint})
|
||||
},
|
||||
hwwShowSeed: async function () {
|
||||
try {
|
||||
this.hww.showSeedDialog = true
|
||||
this.hww.seedWordPosition = 1
|
||||
await this.writer.write(
|
||||
COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n'
|
||||
)
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to show seed!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
showNextSeedWord: async function () {
|
||||
this.hww.seedWordPosition++
|
||||
await this.writer.write(
|
||||
COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n'
|
||||
)
|
||||
},
|
||||
showPrevSeedWord: async function () {
|
||||
this.hww.seedWordPosition = Math.max(1, this.hww.seedWordPosition - 1)
|
||||
console.log('### this.hww.seedWordPosition', this.hww.seedWordPosition)
|
||||
await this.writer.write(
|
||||
COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n'
|
||||
)
|
||||
},
|
||||
handleShowSeedResponse: function (res = '') {
|
||||
const args = res.trim().split(' ')
|
||||
if (args.length < 2 || args[0].trim() !== '1') {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to show seed!',
|
||||
caption: `${res}`,
|
||||
timeout: 10000
|
||||
})
|
||||
return
|
||||
}
|
||||
},
|
||||
hwwRestore: async function () {
|
||||
try {
|
||||
await this.writer.write(
|
||||
COMMAND_RESTORE + ' ' + this.hww.mnemonic + '\n'
|
||||
)
|
||||
await this.writer.write(
|
||||
COMMAND_PASSWORD + ' ' + this.hww.password + '\n'
|
||||
)
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to restore from seed!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
} finally {
|
||||
this.hww.showRestoreDialog = false
|
||||
this.hww.mnemonic = null
|
||||
this.hww.showMnemonic = false
|
||||
this.hww.password = null
|
||||
this.hww.confirmedPassword = null
|
||||
this.hww.showPassword = false
|
||||
}
|
||||
},
|
||||
|
||||
updateSignedPsbt: async function (value) {
|
||||
this.$emit('signed:psbt', value)
|
||||
}
|
||||
},
|
||||
created: async function () {}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div v-if="selectable" class="col-3 q-pr-lg">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="utxoSelectionMode"
|
||||
:options="utxoSelectionModes"
|
||||
label="Selection Mode"
|
||||
@input="updateUtxoSelection"
|
||||
></q-select>
|
||||
</div>
|
||||
<div v-if="selectable" class="col-1 q-pr-lg">
|
||||
<q-btn
|
||||
outline
|
||||
icon="refresh"
|
||||
color="grey"
|
||||
@click="updateUtxoSelection"
|
||||
class="q-ml-sm"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-if="selectable" class="col-5 q-pr-lg"></div>
|
||||
<div v-if="!selectable" class="col-9 q-pr-lg"></div>
|
||||
<div class="col-3 float-right">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:data="utxos"
|
||||
row-key="id"
|
||||
:columns="columns"
|
||||
:pagination.sync="utxosTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="accent"
|
||||
round
|
||||
dense
|
||||
@click="props.row.expanded= !props.row.expanded"
|
||||
:icon="props.row.expanded? 'remove' : 'add'"
|
||||
/>
|
||||
</q-td>
|
||||
|
||||
<q-td v-if="selectable" key="selected" :props="props">
|
||||
<div>
|
||||
<q-checkbox v-model="props.row.selected"></q-checkbox>
|
||||
</div>
|
||||
</q-td>
|
||||
<q-td key="status" :props="props">
|
||||
<div>
|
||||
<q-badge
|
||||
v-if="props.row.confirmed"
|
||||
@click="props.row.expanded = !props.row.expanded"
|
||||
color="green"
|
||||
class="q-mr-md cursor-pointer"
|
||||
>
|
||||
Confirmed
|
||||
</q-badge>
|
||||
<q-badge
|
||||
v-if="!props.row.confirmed"
|
||||
@click="props.row.expanded = !props.row.expanded"
|
||||
color="orange"
|
||||
class="q-mr-md cursor-pointer"
|
||||
>
|
||||
Pending
|
||||
</q-badge>
|
||||
</div>
|
||||
</q-td>
|
||||
<q-td key="address" :props="props">
|
||||
<div>
|
||||
<a
|
||||
style="color: unset"
|
||||
:href="'https://' + mempoolEndpoint + '/address/' + props.row.address"
|
||||
target="_blank"
|
||||
>
|
||||
{{props.row.address}}</a
|
||||
>
|
||||
<q-badge v-if="props.row.isChange" color="orange" class="q-mr-md">
|
||||
change
|
||||
</q-badge>
|
||||
</div>
|
||||
</q-td>
|
||||
|
||||
<q-td
|
||||
key="amount"
|
||||
:props="props"
|
||||
class="text-green-13 text-weight-bold"
|
||||
>
|
||||
<div>{{satBtc(props.row.amount)}}</div>
|
||||
</q-td>
|
||||
|
||||
<q-td key="date" :props="props"> {{ props.row.date }} </q-td>
|
||||
<q-td key="wallet" :props="props" :class="">
|
||||
<div>{{getWalletName(props.row.wallet)}}</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-show="props.row.expanded" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="col-2 q-pr-lg">Transaction Id</div>
|
||||
<div class="col-10 q-pr-lg">
|
||||
<a
|
||||
style="color: unset"
|
||||
:href="'https://' + mempoolEndpoint + '/tx/' + props.row.txId"
|
||||
target="_blank"
|
||||
>
|
||||
{{props.row.txId}}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section></q-card
|
||||
>
|
|
@ -0,0 +1,148 @@
|
|||
async function utxoList(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
Vue.component('utxo-list', {
|
||||
name: 'utxo-list',
|
||||
template,
|
||||
|
||||
props: [
|
||||
'utxos',
|
||||
'accounts',
|
||||
'selectable',
|
||||
'payed-amount',
|
||||
'sats-denominated',
|
||||
'mempool-endpoint',
|
||||
'filter'
|
||||
],
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
utxosTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'expand',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'selected',
|
||||
align: 'left',
|
||||
label: '',
|
||||
selectable: true
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
align: 'center',
|
||||
label: 'Status',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'address',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount',
|
||||
field: 'amount',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Date',
|
||||
field: 'date',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'wallet',
|
||||
align: 'left',
|
||||
label: 'Account',
|
||||
field: 'wallet',
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
utxoSelectionModes: [
|
||||
'Manual',
|
||||
'Random',
|
||||
'Select All',
|
||||
'Smaller Inputs First',
|
||||
'Larger Inputs First'
|
||||
],
|
||||
utxoSelectionMode: 'Random',
|
||||
utxoSelectAmount: 0
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
columns: function () {
|
||||
return this.utxosTable.columns.filter(c =>
|
||||
c.selectable ? this.selectable : true
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
satBtc(val, showUnit = true) {
|
||||
return satOrBtc(val, showUnit, this.satsDenominated)
|
||||
},
|
||||
getWalletName: function (walletId) {
|
||||
const wallet = (this.accounts || []).find(wl => wl.id === walletId)
|
||||
return wallet ? wallet.title : 'unknown'
|
||||
},
|
||||
getTotalSelectedUtxoAmount: function () {
|
||||
const total = (this.utxos || [])
|
||||
.filter(u => u.selected)
|
||||
.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
return total
|
||||
},
|
||||
refreshUtxoSelection: function (totalPayedAmount) {
|
||||
this.utxoSelectAmount = totalPayedAmount
|
||||
this.applyUtxoSelectionMode()
|
||||
},
|
||||
updateUtxoSelection: function () {
|
||||
this.utxoSelectAmount = this.payedAmount
|
||||
this.applyUtxoSelectionMode()
|
||||
},
|
||||
applyUtxoSelectionMode: function () {
|
||||
const mode = this.utxoSelectionMode
|
||||
const isSelectAll = mode === 'Select All'
|
||||
if (isSelectAll) {
|
||||
this.utxos.forEach(u => (u.selected = true))
|
||||
return
|
||||
}
|
||||
|
||||
const isManual = mode === 'Manual'
|
||||
if (isManual || !this.utxoSelectAmount) return
|
||||
|
||||
this.utxos.forEach(u => (u.selected = false))
|
||||
|
||||
const isSmallerFirst = mode === 'Smaller Inputs First'
|
||||
const isLargerFirst = mode === 'Larger Inputs First'
|
||||
let selectedUtxos = this.utxos.slice()
|
||||
if (isSmallerFirst || isLargerFirst) {
|
||||
const sortFn = isSmallerFirst
|
||||
? (a, b) => a.amount - b.amount
|
||||
: (a, b) => b.amount - a.amount
|
||||
selectedUtxos.sort(sortFn)
|
||||
} else {
|
||||
// default to random order
|
||||
selectedUtxos = _.shuffle(selectedUtxos)
|
||||
}
|
||||
selectedUtxos.reduce((total, utxo) => {
|
||||
utxo.selected = total < this.utxoSelectAmount
|
||||
total += utxo.amount
|
||||
return total
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
|
||||
created: async function () {}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
<div>
|
||||
<q-card>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-ml-lg">
|
||||
<q-btn unelevated @click="show = true" color="primary" icon="settings">
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<div class="row justify-center q-gutter-x-md items-center">
|
||||
<div class="text-h3">{{satBtc(total)}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2 float-right">
|
||||
<slot name="serial"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<q-dialog v-model="show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="updateConfig" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="config.mempool_endpoint"
|
||||
type="text"
|
||||
label="Mempool Endpoint"
|
||||
>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="config.receive_gap_limit"
|
||||
type="number"
|
||||
min="0"
|
||||
label="Receive Gap Limit"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="config.change_gap_limit"
|
||||
type="number"
|
||||
min="0"
|
||||
label="Change Gap Limit"
|
||||
></q-input>
|
||||
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="config.network"
|
||||
:options="networOptions"
|
||||
label="Network"
|
||||
></q-select>
|
||||
|
||||
<q-toggle
|
||||
:label="config.sats_denominated ? 'sats denominated' : 'BTC denominated'"
|
||||
color="secodary"
|
||||
v-model="config.sats_denominated"
|
||||
></q-toggle>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
!config.mempool_endpoint "
|
||||
type="submit"
|
||||
>Update</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
|
@ -0,0 +1,67 @@
|
|||
async function walletConfig(path) {
|
||||
const t = await loadTemplateAsync(path)
|
||||
Vue.component('wallet-config', {
|
||||
name: 'wallet-config',
|
||||
template: t,
|
||||
|
||||
props: ['total', 'config-data', 'adminkey'],
|
||||
data: function () {
|
||||
return {
|
||||
networOptions: ['Mainnet', 'Testnet'],
|
||||
internalConfig: {},
|
||||
show: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
config: {
|
||||
get() {
|
||||
return this.internalConfig
|
||||
},
|
||||
set(value) {
|
||||
value.isLoaded = true
|
||||
this.internalConfig = JSON.parse(JSON.stringify(value))
|
||||
this.$emit(
|
||||
'update:config-data',
|
||||
JSON.parse(JSON.stringify(this.internalConfig))
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
satBtc(val, showUnit = true) {
|
||||
return satOrBtc(val, showUnit, this.config.sats_denominated)
|
||||
},
|
||||
updateConfig: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/watchonly/api/v1/config',
|
||||
this.adminkey,
|
||||
this.config
|
||||
)
|
||||
this.show = false
|
||||
this.config = data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getConfig: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/config',
|
||||
this.adminkey
|
||||
)
|
||||
this.config = data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
await this.getConfig()
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,233 @@
|
|||
<div>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<q-btn-dropdown
|
||||
split
|
||||
unelevated
|
||||
label="Add Wallet Account"
|
||||
color="primary"
|
||||
@click="showAddAccountDialog"
|
||||
>
|
||||
<q-list>
|
||||
<q-item @click="showAddAccountDialog" clickable v-close-popup>
|
||||
<q-item-section>
|
||||
<q-item-label>New Account</q-item-label>
|
||||
<q-item-label caption
|
||||
>Enter account Xpub or Descriptor</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item @click="getXpubFromDevice" clickable v-close-popup>
|
||||
<q-item-section>
|
||||
<q-item-label>From Hardware Device</q-item-label>
|
||||
<q-item-label caption>
|
||||
Get Xpub from a Hardware Device</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="col-auto q-pr-lg"></div>
|
||||
<div class="col-auto q-pl-lg">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:data="walletAccounts"
|
||||
row-key="id"
|
||||
:columns="walletsTable.columns"
|
||||
:pagination.sync="walletsTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="accent"
|
||||
round
|
||||
dense
|
||||
@click="props.row.expanded= !props.row.expanded"
|
||||
:icon="props.row.expanded? 'remove' : 'add'"
|
||||
/>
|
||||
</q-td>
|
||||
<q-td key="new">
|
||||
<q-badge
|
||||
size="lg"
|
||||
color="secondary"
|
||||
class="q-mr-md cursor-pointer"
|
||||
@click="openGetFreshAddressDialog(props.row.id)"
|
||||
>
|
||||
New Receive Address
|
||||
</q-badge>
|
||||
</q-td>
|
||||
|
||||
<q-td key="title" :props="props" :class="">
|
||||
<div>{{props.row.title}}</div>
|
||||
</q-td>
|
||||
<q-td key="amount" :props="props" :class="">
|
||||
<div>{{getAmmountForWallet(props.row.id)}}</div>
|
||||
</q-td>
|
||||
<q-td key="type" :props="props" :class="">
|
||||
<div>{{props.row.type}}</div>
|
||||
</q-td>
|
||||
<q-td key="id" :props="props" :class="">
|
||||
<div>{{props.row.id}}</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-show="props.row.expanded" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="secondary"
|
||||
@click="openGetFreshAddressDialog(props.row.id)"
|
||||
>New Receive Address</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{{getAccountDescription(props.row.type)}}
|
||||
</div>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
</div>
|
||||
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-pr-lg">Master Pubkey:</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
v-model="props.row.masterpub"
|
||||
filled
|
||||
readonly
|
||||
type="textarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
</div>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-pr-lg">Last Address Index:</div>
|
||||
<div class="col-8">
|
||||
<span v-if="props.row.address_no >= 0"
|
||||
>{{props.row.address_no}}</span
|
||||
>
|
||||
<span v-if="props.row.address_no < 0">none</span>
|
||||
</div>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
</div>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-pr-lg">Fingerprint:</div>
|
||||
<div class="col-8">{{props.row.fingerprint}}</div>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="pink"
|
||||
icon="cancel"
|
||||
@click="deleteWalletAccount(props.row.id)"
|
||||
>Delete</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-4"></div>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="addWalletAccount" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.title"
|
||||
type="text"
|
||||
label="Title"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="!formDialog.useSerialPort"
|
||||
filled
|
||||
type="textarea"
|
||||
v-model="formDialog.data.masterpub"
|
||||
height="50px"
|
||||
autogrow
|
||||
label="Account Extended Public Key; xpub, ypub, zpub; Bitcoin Descriptor"
|
||||
></q-input>
|
||||
<q-select
|
||||
v-if="formDialog.useSerialPort"
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.addressType"
|
||||
:options="addressTypeOptions"
|
||||
label="Address Type"
|
||||
@input="handleAddressTypeChanged"
|
||||
></q-select>
|
||||
|
||||
<q-input
|
||||
v-if="formDialog.useSerialPort"
|
||||
filled
|
||||
type="text"
|
||||
v-model="accountPath"
|
||||
height="50px"
|
||||
autogrow
|
||||
label="Account Path"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
label="Add Watch-Only Account"
|
||||
:disable="
|
||||
(formDialog.data.masterpub == null && accountPath == null)||
|
||||
formDialog.data.title == null || showCreating"
|
||||
type="submit"
|
||||
>
|
||||
</q-btn>
|
||||
<q-spinner v-if="showCreating" color="primary" size="2em"></q-spinner>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
|
@ -0,0 +1,290 @@
|
|||
async function walletList(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
Vue.component('wallet-list', {
|
||||
name: 'wallet-list',
|
||||
template,
|
||||
|
||||
props: [
|
||||
'adminkey',
|
||||
'inkey',
|
||||
'sats-denominated',
|
||||
'addresses',
|
||||
'network',
|
||||
'serial-signer-ref'
|
||||
],
|
||||
data: function () {
|
||||
return {
|
||||
walletAccounts: [],
|
||||
address: {},
|
||||
formDialog: {
|
||||
show: false,
|
||||
|
||||
addressType: {
|
||||
label: 'Segwit (P2WPKH)',
|
||||
id: 'wpkh',
|
||||
pathMainnet: "m/84'/0'/0'",
|
||||
pathTestnet: "m/84'/1'/0'"
|
||||
},
|
||||
useSerialPort: false,
|
||||
data: {
|
||||
title: '',
|
||||
masterpub: ''
|
||||
}
|
||||
},
|
||||
accountPath: '',
|
||||
filter: '',
|
||||
showCreating: false,
|
||||
addressTypeOptions: [
|
||||
{
|
||||
label: 'Legacy (P2PKH)',
|
||||
id: 'pkh',
|
||||
pathMainnet: "m/44'/0'/0'",
|
||||
pathTestnet: "m/44'/1'/0'"
|
||||
},
|
||||
{
|
||||
label: 'Segwit (P2WPKH)',
|
||||
id: 'wpkh',
|
||||
pathMainnet: "m/84'/0'/0'",
|
||||
pathTestnet: "m/84'/1'/0'"
|
||||
},
|
||||
{
|
||||
label: 'Wrapped Segwit (P2SH-P2WPKH)',
|
||||
id: 'sh',
|
||||
pathMainnet: "m/49'/0'/0'",
|
||||
pathTestnet: "m/49'/1'/0'"
|
||||
},
|
||||
{
|
||||
label: 'Taproot (P2TR)',
|
||||
id: 'tr',
|
||||
pathMainnet: "m/86'/0'/0'",
|
||||
pathTestnet: "m/86'/1'/0'"
|
||||
}
|
||||
],
|
||||
|
||||
walletsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'new',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
align: 'left',
|
||||
label: 'Title',
|
||||
field: 'title'
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount'
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
align: 'left',
|
||||
label: 'Type',
|
||||
field: 'type'
|
||||
},
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
},
|
||||
filter: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
immediate: true,
|
||||
async network(newNet, oldNet) {
|
||||
if (newNet !== oldNet) {
|
||||
await this.refreshWalletAccounts()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
satBtc(val, showUnit = true) {
|
||||
return satOrBtc(val, showUnit, this.satsDenominated)
|
||||
},
|
||||
|
||||
addWalletAccount: async function () {
|
||||
this.showCreating = true
|
||||
const data = _.omit(this.formDialog.data, 'wallet')
|
||||
data.network = this.network
|
||||
await this.createWalletAccount(data)
|
||||
this.showCreating = false
|
||||
},
|
||||
createWalletAccount: async function (data) {
|
||||
try {
|
||||
if (this.formDialog.useSerialPort) {
|
||||
const {xpub, fingerprint} = await this.fetchXpubFromHww()
|
||||
if (!xpub) return
|
||||
const path = this.accountPath.substring(2)
|
||||
const outputType = this.formDialog.addressType.id
|
||||
if (outputType === 'sh') {
|
||||
data.masterpub = `${outputType}(wpkh([${fingerprint}/${path}]${xpub}/{0,1}/*))`
|
||||
} else {
|
||||
data.masterpub = `${outputType}([${fingerprint}/${path}]${xpub}/{0,1}/*)`
|
||||
}
|
||||
}
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.adminkey,
|
||||
data
|
||||
)
|
||||
this.walletAccounts.push(mapWalletAccount(response.data))
|
||||
this.formDialog.show = false
|
||||
|
||||
await this.refreshWalletAccounts()
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
fetchXpubFromHww: async function () {
|
||||
const error = findAccountPathIssues(this.accountPath)
|
||||
if (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Invalid derivation path.',
|
||||
caption: error,
|
||||
timeout: 10000
|
||||
})
|
||||
return
|
||||
}
|
||||
await this.serialSignerRef.hwwXpub(this.accountPath)
|
||||
return await this.serialSignerRef.isFetchingXpub()
|
||||
},
|
||||
deleteWalletAccount: function (walletAccountId) {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to delete this watch only wallet?'
|
||||
)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
'/watchonly/api/v1/wallet/' + walletAccountId,
|
||||
this.adminkey
|
||||
)
|
||||
this.walletAccounts = _.reject(this.walletAccounts, function (
|
||||
obj
|
||||
) {
|
||||
return obj.id === walletAccountId
|
||||
})
|
||||
await this.refreshWalletAccounts()
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message:
|
||||
'Error while deleting wallet account. Please try again.',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
getWatchOnlyWallets: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/watchonly/api/v1/wallet?network=${this.network}`,
|
||||
this.inkey
|
||||
)
|
||||
return data
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to fetch wallets.',
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
return []
|
||||
},
|
||||
refreshWalletAccounts: async function () {
|
||||
this.walletAccounts = []
|
||||
const wallets = await this.getWatchOnlyWallets()
|
||||
this.walletAccounts = wallets.map(w => mapWalletAccount(w))
|
||||
this.$emit('accounts-update', this.walletAccounts)
|
||||
},
|
||||
getAmmountForWallet: function (walletId) {
|
||||
const amount = this.addresses
|
||||
.filter(a => a.wallet === walletId)
|
||||
.reduce((t, a) => t + a.amount || 0, 0)
|
||||
return this.satBtc(amount)
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
getAccountDescription: function (accountType) {
|
||||
return getAccountDescription(accountType)
|
||||
},
|
||||
openGetFreshAddressDialog: async function (walletId) {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/watchonly/api/v1/address/${walletId}`,
|
||||
this.inkey
|
||||
)
|
||||
const addressData = mapAddressesData(data)
|
||||
|
||||
addressData.note = `Shared on ${currentDateTime()}`
|
||||
const lastAcctiveAddress =
|
||||
this.addresses
|
||||
.filter(
|
||||
a =>
|
||||
a.wallet === addressData.wallet && !a.isChange && a.hasActivity
|
||||
)
|
||||
.pop() || {}
|
||||
addressData.gapLimitExceeded =
|
||||
!addressData.isChange &&
|
||||
addressData.addressIndex >
|
||||
lastAcctiveAddress.addressIndex + DEFAULT_RECEIVE_GAP_LIMIT
|
||||
|
||||
const wallet = this.walletAccounts.find(w => w.id === walletId) || {}
|
||||
wallet.address_no = addressData.addressIndex
|
||||
this.$emit('new-receive-address', addressData)
|
||||
},
|
||||
showAddAccountDialog: function () {
|
||||
this.formDialog.show = true
|
||||
this.formDialog.useSerialPort = false
|
||||
},
|
||||
getXpubFromDevice: async function () {
|
||||
try {
|
||||
if (!this.serialSignerRef.isConnected()) {
|
||||
const portOpen = await this.serialSignerRef.openSerialPort()
|
||||
if (!portOpen) return
|
||||
}
|
||||
if (!this.serialSignerRef.isAuthenticated()) {
|
||||
await this.serialSignerRef.hwwShowPasswordDialog()
|
||||
const authenticated = await this.serialSignerRef.isAuthenticating()
|
||||
if (!authenticated) return
|
||||
}
|
||||
this.formDialog.show = true
|
||||
this.formDialog.useSerialPort = true
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Cannot fetch Xpub!',
|
||||
caption: `${error}`,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
},
|
||||
handleAddressTypeChanged: function (value = {}) {
|
||||
const addressType =
|
||||
this.addressTypeOptions.find(t => t.id === value.id) || {}
|
||||
this.accountPath = addressType[`path${this.network}`]
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
if (this.inkey) {
|
||||
await this.refreshWalletAccounts()
|
||||
this.handleAddressTypeChanged(this.addressTypeOptions[1])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1,18 +1,29 @@
|
|||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
const watchOnly = async () => {
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
Vue.filter('reverse', function (value) {
|
||||
await walletConfig('static/components/wallet-config/wallet-config.html')
|
||||
await walletList('static/components/wallet-list/wallet-list.html')
|
||||
await addressList('static/components/address-list/address-list.html')
|
||||
await history('static/components/history/history.html')
|
||||
await utxoList('static/components/utxo-list/utxo-list.html')
|
||||
await feeRate('static/components/fee-rate/fee-rate.html')
|
||||
await sendTo('static/components/send-to/send-to.html')
|
||||
await payment('static/components/payment/payment.html')
|
||||
await serialSigner('static/components/serial-signer/serial-signer.html')
|
||||
await serialPortConfig(
|
||||
'static/components/serial-port-config/serial-port-config.html'
|
||||
)
|
||||
|
||||
Vue.filter('reverse', function (value) {
|
||||
// slice to make a copy of array, then reverse the copy
|
||||
return value.slice().reverse()
|
||||
})
|
||||
})
|
||||
|
||||
new Vue({
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
DUST_LIMIT: 546,
|
||||
filter: '',
|
||||
|
||||
scan: {
|
||||
scanning: false,
|
||||
scanCount: 0,
|
||||
|
@ -23,199 +34,40 @@ new Vue({
|
|||
|
||||
tab: 'addresses',
|
||||
|
||||
config: {
|
||||
data: {
|
||||
mempool_endpoint: 'https://mempool.space',
|
||||
receive_gap_limit: 20,
|
||||
change_gap_limit: 5
|
||||
},
|
||||
DEFAULT_RECEIVE_GAP_LIMIT: 20,
|
||||
show: false
|
||||
},
|
||||
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
config: {sats_denominated: true},
|
||||
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
},
|
||||
...tables,
|
||||
...tableData
|
||||
...tableData,
|
||||
|
||||
walletAccounts: [],
|
||||
addresses: [],
|
||||
history: [],
|
||||
historyFilter: '',
|
||||
|
||||
showAddress: false,
|
||||
addressNote: '',
|
||||
showPayment: false,
|
||||
fetchedUtxos: false,
|
||||
utxosFilter: '',
|
||||
network: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
mempoolHostname: function () {
|
||||
if (!this.config.isLoaded) return
|
||||
let hostname = new URL(this.config.mempool_endpoint).hostname
|
||||
if (this.config.network === 'Testnet') {
|
||||
hostname += '/testnet'
|
||||
}
|
||||
return hostname
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
//################### CONFIG ###################
|
||||
getConfig: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/config',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.config.data = data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
updateConfig: async function () {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/watchonly/api/v1/config',
|
||||
wallet.adminkey,
|
||||
this.config.data
|
||||
)
|
||||
this.config.show = false
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
|
||||
//################### WALLETS ###################
|
||||
getWalletName: function (walletId) {
|
||||
const wallet = this.walletAccounts.find(wl => wl.id === walletId)
|
||||
return wallet ? wallet.title : 'unknown'
|
||||
},
|
||||
addWalletAccount: async function () {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
const data = _.omit(this.formDialog.data, 'wallet')
|
||||
await this.createWalletAccount(wallet, data)
|
||||
},
|
||||
createWalletAccount: async function (wallet, data) {
|
||||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/watchonly/api/v1/wallet',
|
||||
wallet.adminkey,
|
||||
data
|
||||
)
|
||||
this.walletAccounts.push(mapWalletAccount(response.data))
|
||||
this.formDialog.show = false
|
||||
|
||||
await this.refreshWalletAccounts()
|
||||
await this.refreshAddresses()
|
||||
|
||||
if (!this.payment.changeWallett) {
|
||||
this.payment.changeWallet = this.walletAccounts[0]
|
||||
this.selectChangeAddress(this.payment.changeWallet)
|
||||
}
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
deleteWalletAccount: function (walletAccountId) {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to delete this watch only wallet?'
|
||||
)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
'/watchonly/api/v1/wallet/' + walletAccountId,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.walletAccounts = _.reject(this.walletAccounts, function (obj) {
|
||||
return obj.id === walletAccountId
|
||||
})
|
||||
await this.refreshWalletAccounts()
|
||||
await this.refreshAddresses()
|
||||
if (
|
||||
this.payment.changeWallet &&
|
||||
this.payment.changeWallet.id === walletAccountId
|
||||
) {
|
||||
this.payment.changeWallet = this.walletAccounts[0]
|
||||
this.selectChangeAddress(this.payment.changeWallet)
|
||||
}
|
||||
await this.scanAddressWithAmount()
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Error while deleting wallet account. Please try again.',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
getAddressesForWallet: async function (walletId) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/addresses/' + walletId,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
return data.map(mapAddressesData)
|
||||
} catch (err) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: `Failed to fetch addresses for wallet with id ${walletId}.`,
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
return []
|
||||
},
|
||||
getWatchOnlyWallets: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
return data
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to fetch wallets.',
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
return []
|
||||
},
|
||||
refreshWalletAccounts: async function () {
|
||||
const wallets = await this.getWatchOnlyWallets()
|
||||
this.walletAccounts = wallets.map(w => mapWalletAccount(w))
|
||||
},
|
||||
getAmmountForWallet: function (walletId) {
|
||||
const amount = this.addresses.data
|
||||
.filter(a => a.wallet === walletId)
|
||||
.reduce((t, a) => t + a.amount || 0, 0)
|
||||
return this.satBtc(amount)
|
||||
},
|
||||
|
||||
//################### ADDRESSES ###################
|
||||
|
||||
refreshAddresses: async function () {
|
||||
const wallets = await this.getWatchOnlyWallets()
|
||||
this.addresses.data = []
|
||||
for (const {id, type} of wallets) {
|
||||
const newAddresses = await this.getAddressesForWallet(id)
|
||||
const uniqueAddresses = newAddresses.filter(
|
||||
newAddr =>
|
||||
!this.addresses.data.find(a => a.address === newAddr.address)
|
||||
)
|
||||
|
||||
const lastAcctiveAddress =
|
||||
uniqueAddresses.filter(a => !a.isChange && a.hasActivity).pop() || {}
|
||||
|
||||
uniqueAddresses.forEach(a => {
|
||||
a.expanded = false
|
||||
a.accountType = type
|
||||
a.gapLimitExceeded =
|
||||
!a.isChange &&
|
||||
a.addressIndex >
|
||||
lastAcctiveAddress.addressIndex +
|
||||
this.config.DEFAULT_RECEIVE_GAP_LIMIT
|
||||
})
|
||||
this.addresses.data.push(...uniqueAddresses)
|
||||
}
|
||||
},
|
||||
updateAmountForAddress: async function (addressData, amount = 0) {
|
||||
try {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
|
@ -232,6 +84,7 @@ new Vue({
|
|||
}
|
||||
}
|
||||
|
||||
// todo: account deleted
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/watchonly/api/v1/address/${addressData.id}`,
|
||||
|
@ -248,71 +101,22 @@ new Vue({
|
|||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
updateNoteForAddress: async function (addressData, note) {
|
||||
updateNoteForAddress: async function ({addressId, note}) {
|
||||
try {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/watchonly/api/v1/address/${addressData.id}`,
|
||||
`/watchonly/api/v1/address/${addressId}`,
|
||||
wallet.adminkey,
|
||||
{note: addressData.note}
|
||||
{note}
|
||||
)
|
||||
const updatedAddress =
|
||||
this.addresses.data.find(a => a.id === addressData.id) || {}
|
||||
this.addresses.find(a => a.id === addressId) || {}
|
||||
updatedAddress.note = note
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
getFilteredAddresses: function () {
|
||||
const selectedWalletId = this.addresses.selectedWallet?.id
|
||||
const filter = this.addresses.filterValues || []
|
||||
const includeChangeAddrs = filter.includes('Show Change Addresses')
|
||||
const includeGapAddrs = filter.includes('Show Gap Addresses')
|
||||
const excludeNoAmount = filter.includes('Only With Amount')
|
||||
|
||||
const walletsLimit = this.walletAccounts.reduce((r, w) => {
|
||||
r[`_${w.id}`] = w.address_no
|
||||
return r
|
||||
}, {})
|
||||
|
||||
const addresses = this.addresses.data.filter(
|
||||
a =>
|
||||
(includeChangeAddrs || !a.isChange) &&
|
||||
(includeGapAddrs ||
|
||||
a.isChange ||
|
||||
a.addressIndex <= walletsLimit[`_${a.wallet}`]) &&
|
||||
!(excludeNoAmount && a.amount === 0) &&
|
||||
(!selectedWalletId || a.wallet === selectedWalletId)
|
||||
)
|
||||
return addresses
|
||||
},
|
||||
openGetFreshAddressDialog: async function (walletId) {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/watchonly/api/v1/address/${walletId}`,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
const addressData = mapAddressesData(data)
|
||||
|
||||
addressData.note = `Shared on ${currentDateTime()}`
|
||||
const lastAcctiveAddress =
|
||||
this.addresses.data
|
||||
.filter(
|
||||
a => a.wallet === addressData.wallet && !a.isChange && a.hasActivity
|
||||
)
|
||||
.pop() || {}
|
||||
addressData.gapLimitExceeded =
|
||||
!addressData.isChange &&
|
||||
addressData.addressIndex >
|
||||
lastAcctiveAddress.addressIndex +
|
||||
this.config.DEFAULT_RECEIVE_GAP_LIMIT
|
||||
|
||||
this.openQrCodeDialog(addressData)
|
||||
const wallet = this.walletAccounts.find(w => w.id === walletId) || {}
|
||||
wallet.address_no = addressData.addressIndex
|
||||
await this.refreshAddresses()
|
||||
},
|
||||
|
||||
//################### ADDRESS HISTORY ###################
|
||||
addressHistoryFromTxs: function (addressData, txs) {
|
||||
|
@ -331,24 +135,9 @@ new Vue({
|
|||
})
|
||||
return addressHistory
|
||||
},
|
||||
getFilteredAddressesHistory: function () {
|
||||
return this.addresses.history.filter(
|
||||
a => (!a.isChange || a.sent) && !a.isSubItem
|
||||
)
|
||||
},
|
||||
exportHistoryToCSV: function () {
|
||||
const history = this.getFilteredAddressesHistory().map(a => ({
|
||||
...a,
|
||||
action: a.sent ? 'Sent' : 'Received'
|
||||
}))
|
||||
LNbits.utils.exportCSV(
|
||||
this.historyTable.exportColums,
|
||||
history,
|
||||
'address-history'
|
||||
)
|
||||
},
|
||||
|
||||
markSameTxAddressHistory: function () {
|
||||
this.addresses.history
|
||||
this.history
|
||||
.filter(s => s.sent)
|
||||
.forEach((el, i, arr) => {
|
||||
if (el.isSubItem) return
|
||||
|
@ -364,156 +153,43 @@ new Vue({
|
|||
el.sameTxItems = sameTxItems
|
||||
})
|
||||
},
|
||||
showAddressHistoryDetails: function (addressHistory) {
|
||||
addressHistory.expanded = true
|
||||
},
|
||||
|
||||
//################### PAYMENT ###################
|
||||
createTx: function (excludeChange = false) {
|
||||
const tx = {
|
||||
fee_rate: this.payment.feeRate,
|
||||
tx_size: this.payment.txSize,
|
||||
masterpubs: this.walletAccounts.map(w => ({
|
||||
public_key: w.masterpub,
|
||||
fingerprint: w.fingerprint
|
||||
}))
|
||||
}
|
||||
tx.inputs = this.utxos.data
|
||||
.filter(utxo => utxo.selected)
|
||||
.map(mapUtxoToPsbtInput)
|
||||
.sort((a, b) =>
|
||||
a.tx_id < b.tx_id ? -1 : a.tx_id > b.tx_id ? 1 : a.vout - b.vout
|
||||
)
|
||||
|
||||
tx.outputs = this.payment.data.map(out => ({
|
||||
address: out.address,
|
||||
amount: out.amount
|
||||
}))
|
||||
|
||||
if (excludeChange) {
|
||||
this.payment.changeAmount = 0
|
||||
} else {
|
||||
const change = this.createChangeOutput()
|
||||
this.payment.changeAmount = change.amount
|
||||
if (change.amount >= this.DUST_LIMIT) {
|
||||
tx.outputs.push(change)
|
||||
}
|
||||
}
|
||||
// Only sort by amount on UI level (no lib for address decode)
|
||||
// Should sort by scriptPubKey (as byte array) on the backend
|
||||
tx.outputs.sort((a, b) => a.amount - b.amount)
|
||||
|
||||
return tx
|
||||
},
|
||||
createChangeOutput: function () {
|
||||
const change = this.payment.changeAddress
|
||||
const fee = this.payment.feeRate * this.payment.txSize
|
||||
const inputAmount = this.getTotalSelectedUtxoAmount()
|
||||
const payedAmount = this.getTotalPaymentAmount()
|
||||
const walletAcount =
|
||||
this.walletAccounts.find(w => w.id === change.wallet) || {}
|
||||
|
||||
return {
|
||||
address: change.address,
|
||||
amount: inputAmount - payedAmount - fee,
|
||||
addressIndex: change.addressIndex,
|
||||
addressIndex: change.addressIndex,
|
||||
masterpub_fingerprint: walletAcount.fingerprint
|
||||
}
|
||||
},
|
||||
computeFee: function () {
|
||||
const tx = this.createTx()
|
||||
this.payment.txSize = Math.round(txSize(tx))
|
||||
return this.payment.feeRate * this.payment.txSize
|
||||
},
|
||||
createPsbt: async function () {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
try {
|
||||
this.computeFee()
|
||||
const tx = this.createTx()
|
||||
txSize(tx)
|
||||
for (const input of tx.inputs) {
|
||||
input.tx_hex = await this.fetchTxHex(input.tx_id)
|
||||
}
|
||||
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/watchonly/api/v1/psbt',
|
||||
wallet.adminkey,
|
||||
tx
|
||||
)
|
||||
|
||||
this.payment.psbtBase64 = data
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
deletePaymentAddress: function (v) {
|
||||
const index = this.payment.data.indexOf(v)
|
||||
if (index !== -1) {
|
||||
this.payment.data.splice(index, 1)
|
||||
}
|
||||
},
|
||||
initPaymentData: async function () {
|
||||
if (!this.payment.show) return
|
||||
await this.refreshAddresses()
|
||||
},
|
||||
|
||||
this.payment.showAdvanced = false
|
||||
this.payment.changeWallet = this.walletAccounts[0]
|
||||
this.selectChangeAddress(this.payment.changeWallet)
|
||||
|
||||
await this.refreshRecommendedFees()
|
||||
this.payment.feeRate = this.payment.recommededFees.halfHourFee
|
||||
},
|
||||
getFeeRateLabel: function (feeRate) {
|
||||
const fees = this.payment.recommededFees
|
||||
if (feeRate >= fees.fastestFee) return `High Priority (${feeRate} sat/vB)`
|
||||
if (feeRate >= fees.halfHourFee)
|
||||
return `Medium Priority (${feeRate} sat/vB)`
|
||||
if (feeRate >= fees.hourFee) return `Low Priority (${feeRate} sat/vB)`
|
||||
return `No Priority (${feeRate} sat/vB)`
|
||||
},
|
||||
addPaymentAddress: function () {
|
||||
this.payment.data.push({address: '', amount: undefined})
|
||||
},
|
||||
getTotalPaymentAmount: function () {
|
||||
return this.payment.data.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
},
|
||||
selectChangeAddress: function (wallet = {}) {
|
||||
this.payment.changeAddress =
|
||||
this.addresses.data.find(
|
||||
a => a.wallet === wallet.id && a.isChange && !a.hasActivity
|
||||
) || {}
|
||||
},
|
||||
goToPaymentView: async function () {
|
||||
this.payment.show = true
|
||||
this.tab = 'utxos'
|
||||
this.showPayment = true
|
||||
await this.initPaymentData()
|
||||
},
|
||||
sendMaxToAddress: function (paymentAddress = {}) {
|
||||
paymentAddress.amount = 0
|
||||
const tx = this.createTx(true)
|
||||
this.payment.txSize = Math.round(txSize(tx))
|
||||
const fee = this.payment.feeRate * this.payment.txSize
|
||||
const inputAmount = this.getTotalSelectedUtxoAmount()
|
||||
const payedAmount = this.getTotalPaymentAmount()
|
||||
paymentAddress.amount = Math.max(0, inputAmount - payedAmount - fee)
|
||||
|
||||
//################### PSBT ###################
|
||||
|
||||
updateSignedPsbt: async function (psbtBase64) {
|
||||
this.$refs.paymentRef.updateSignedPsbt(psbtBase64)
|
||||
},
|
||||
|
||||
//################### SERIAL PORT ###################
|
||||
|
||||
//################### HARDWARE WALLET ###################
|
||||
|
||||
//################### UTXOs ###################
|
||||
scanAllAddresses: async function () {
|
||||
await this.refreshAddresses()
|
||||
this.addresses.history = []
|
||||
let addresses = this.addresses.data
|
||||
this.history = []
|
||||
let addresses = this.addresses
|
||||
this.utxos.data = []
|
||||
this.utxos.total = 0
|
||||
// Loop while new funds are found on the gap adresses.
|
||||
// Use 1000 limit as a safety check (scan 20 000 addresses max)
|
||||
for (let i = 0; i < 1000 && addresses.length; i++) {
|
||||
await this.updateUtxosForAddresses(addresses)
|
||||
const oldAddresses = this.addresses.data.slice()
|
||||
const oldAddresses = this.addresses.slice()
|
||||
await this.refreshAddresses()
|
||||
const newAddresses = this.addresses.data.slice()
|
||||
const newAddresses = this.addresses.slice()
|
||||
// check if gap addresses have been extended
|
||||
addresses = newAddresses.filter(
|
||||
newAddr => !oldAddresses.find(oldAddr => oldAddr.id === newAddr.id)
|
||||
|
@ -530,8 +206,8 @@ new Vue({
|
|||
scanAddressWithAmount: async function () {
|
||||
this.utxos.data = []
|
||||
this.utxos.total = 0
|
||||
this.addresses.history = []
|
||||
const addresses = this.addresses.data.filter(a => a.hasActivity)
|
||||
this.history = []
|
||||
const addresses = this.addresses.filter(a => a.hasActivity)
|
||||
await this.updateUtxosForAddresses(addresses)
|
||||
},
|
||||
scanAddress: async function (addressData) {
|
||||
|
@ -542,6 +218,49 @@ new Vue({
|
|||
timeout: 10000
|
||||
})
|
||||
},
|
||||
refreshAddresses: async function () {
|
||||
if (!this.walletAccounts) return
|
||||
this.addresses = []
|
||||
for (const {id, type} of this.walletAccounts) {
|
||||
const newAddresses = await this.getAddressesForWallet(id)
|
||||
const uniqueAddresses = newAddresses.filter(
|
||||
newAddr => !this.addresses.find(a => a.address === newAddr.address)
|
||||
)
|
||||
|
||||
const lastAcctiveAddress =
|
||||
uniqueAddresses.filter(a => !a.isChange && a.hasActivity).pop() ||
|
||||
{}
|
||||
|
||||
uniqueAddresses.forEach(a => {
|
||||
a.expanded = false
|
||||
a.accountType = type
|
||||
a.gapLimitExceeded =
|
||||
!a.isChange &&
|
||||
a.addressIndex >
|
||||
lastAcctiveAddress.addressIndex + DEFAULT_RECEIVE_GAP_LIMIT
|
||||
})
|
||||
this.addresses.push(...uniqueAddresses)
|
||||
}
|
||||
this.$emit('update:addresses', this.addresses)
|
||||
},
|
||||
getAddressesForWallet: async function (walletId) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/addresses/' + walletId,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
return data.map(mapAddressesData)
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: `Failed to fetch addresses for wallet with id ${walletId}.`,
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
return []
|
||||
},
|
||||
updateUtxosForAddresses: async function (addresses = []) {
|
||||
this.scan = {scanning: true, scanCount: addresses.length, scanIndex: 0}
|
||||
|
||||
|
@ -549,20 +268,20 @@ new Vue({
|
|||
for (addrData of addresses) {
|
||||
const addressHistory = await this.getAddressTxsDelayed(addrData)
|
||||
// remove old entries
|
||||
this.addresses.history = this.addresses.history.filter(
|
||||
this.history = this.history.filter(
|
||||
h => h.address !== addrData.address
|
||||
)
|
||||
|
||||
// add new entrie
|
||||
this.addresses.history.push(...addressHistory)
|
||||
this.addresses.history.sort((a, b) =>
|
||||
!a.height ? -1 : b.height - a.height
|
||||
)
|
||||
// add new entries
|
||||
this.history.push(...addressHistory)
|
||||
this.history.sort((a, b) => (!a.height ? -1 : b.height - a.height))
|
||||
this.markSameTxAddressHistory()
|
||||
|
||||
if (addressHistory.length) {
|
||||
// search only if it ever had any activity
|
||||
const utxos = await this.getAddressTxsUtxoDelayed(addrData.address)
|
||||
const utxos = await this.getAddressTxsUtxoDelayed(
|
||||
addrData.address
|
||||
)
|
||||
this.updateUtxosForAddress(addrData, utxos)
|
||||
}
|
||||
|
||||
|
@ -605,139 +324,76 @@ new Vue({
|
|||
)
|
||||
this.updateAmountForAddress(addressData, addressTotal)
|
||||
},
|
||||
getTotalSelectedUtxoAmount: function () {
|
||||
const total = this.utxos.data
|
||||
.filter(u => u.selected)
|
||||
.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
return total
|
||||
},
|
||||
applyUtxoSelectionMode: function () {
|
||||
const payedAmount = this.getTotalPaymentAmount()
|
||||
const mode = this.payment.utxoSelectionMode
|
||||
this.utxos.data.forEach(u => (u.selected = false))
|
||||
const isManual = mode === 'Manual'
|
||||
if (isManual || !payedAmount) return
|
||||
|
||||
const isSelectAll = mode === 'Select All'
|
||||
if (isSelectAll || payedAmount >= this.utxos.total) {
|
||||
this.utxos.data.forEach(u => (u.selected = true))
|
||||
return
|
||||
}
|
||||
const isSmallerFirst = mode === 'Smaller Inputs First'
|
||||
const isLargerFirst = mode === 'Larger Inputs First'
|
||||
|
||||
let selectedUtxos = this.utxos.data.slice()
|
||||
if (isSmallerFirst || isLargerFirst) {
|
||||
const sortFn = isSmallerFirst
|
||||
? (a, b) => a.amount - b.amount
|
||||
: (a, b) => b.amount - a.amount
|
||||
selectedUtxos.sort(sortFn)
|
||||
} else {
|
||||
// default to random order
|
||||
selectedUtxos = _.shuffle(selectedUtxos)
|
||||
}
|
||||
selectedUtxos.reduce((total, utxo) => {
|
||||
utxo.selected = total < payedAmount
|
||||
total += utxo.amount
|
||||
return total
|
||||
}, 0)
|
||||
},
|
||||
|
||||
//################### MEMPOOL API ###################
|
||||
getAddressTxsDelayed: async function (addrData) {
|
||||
const accounts = this.walletAccounts
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.config.data.mempool_endpoint).hostname
|
||||
hostname: this.mempoolHostname
|
||||
})
|
||||
|
||||
const fn = async () =>
|
||||
addressesAPI.getAddressTxs({
|
||||
const fn = async () => {
|
||||
if (!accounts.find(w => w.id === addrData.wallet)) return []
|
||||
return addressesAPI.getAddressTxs({
|
||||
address: addrData.address
|
||||
})
|
||||
}
|
||||
const addressTxs = await retryWithDelay(fn)
|
||||
return this.addressHistoryFromTxs(addrData, addressTxs)
|
||||
},
|
||||
|
||||
refreshRecommendedFees: async function () {
|
||||
const {
|
||||
bitcoin: {fees: feesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.config.data.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
const fn = async () => feesAPI.getFeesRecommended()
|
||||
this.payment.recommededFees = await retryWithDelay(fn)
|
||||
},
|
||||
getAddressTxsUtxoDelayed: async function (address) {
|
||||
const endpoint = this.mempoolHostname
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.config.data.mempool_endpoint).hostname
|
||||
hostname: endpoint
|
||||
})
|
||||
|
||||
const fn = async () =>
|
||||
addressesAPI.getAddressTxsUtxo({
|
||||
const fn = async () => {
|
||||
if (endpoint !== this.mempoolHostname) return []
|
||||
return addressesAPI.getAddressTxsUtxo({
|
||||
address
|
||||
})
|
||||
return retryWithDelay(fn)
|
||||
},
|
||||
fetchTxHex: async function (txId) {
|
||||
const {
|
||||
bitcoin: {transactions: transactionsAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.config.data.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await transactionsAPI.getTxHex({txid: txId})
|
||||
return response
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: `Failed to fetch transaction details for tx id: '${txId}'`,
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(error)
|
||||
throw error
|
||||
}
|
||||
return retryWithDelay(fn)
|
||||
},
|
||||
|
||||
//################### OTHER ###################
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
|
||||
openQrCodeDialog: function (addressData) {
|
||||
this.currentAddress = addressData
|
||||
this.addresses.note = addressData.note || ''
|
||||
this.addresses.show = true
|
||||
this.addressNote = addressData.note || ''
|
||||
this.showAddress = true
|
||||
},
|
||||
searchInTab: function (tab, value) {
|
||||
searchInTab: function ({tab, value}) {
|
||||
this.tab = tab
|
||||
this[`${tab}Table`].filter = value
|
||||
this[`${tab}Filter`] = value
|
||||
},
|
||||
|
||||
satBtc(val, showUnit = true) {
|
||||
const value = this.config.data.sats_denominated
|
||||
? LNbits.utils.formatSat(val)
|
||||
: val == 0
|
||||
? 0.0
|
||||
: (val / 100000000).toFixed(8)
|
||||
if (!showUnit) return value
|
||||
return this.config.data.sats_denominated ? value + ' sat' : value + ' BTC'
|
||||
updateAccounts: async function (accounts) {
|
||||
this.walletAccounts = accounts
|
||||
await this.refreshAddresses()
|
||||
await this.scanAddressWithAmount()
|
||||
},
|
||||
getAccountDescription: function (accountType) {
|
||||
return getAccountDescription(accountType)
|
||||
showAddressDetails: function (addressData) {
|
||||
this.openQrCodeDialog(addressData)
|
||||
},
|
||||
initUtxos: function (addresses) {
|
||||
if (!this.fetchedUtxos && addresses.length) {
|
||||
this.fetchedUtxos = true
|
||||
this.addresses = addresses
|
||||
this.scanAddressWithAmount()
|
||||
}
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
await this.getConfig()
|
||||
await this.refreshWalletAccounts()
|
||||
await this.refreshAddresses()
|
||||
await this.scanAddressWithAmount()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
watchOnly()
|
||||
|
|
|
@ -43,7 +43,7 @@ const mapUtxoToPsbtInput = utxo => ({
|
|||
address: utxo.address,
|
||||
branch_index: utxo.isChange ? 1 : 0,
|
||||
address_index: utxo.addressIndex,
|
||||
masterpub_fingerprint: utxo.masterpubFingerprint,
|
||||
wallet: utxo.wallet,
|
||||
accountType: utxo.accountType,
|
||||
txHex: ''
|
||||
})
|
||||
|
@ -66,15 +66,15 @@ const mapAddressDataToUtxo = (wallet, addressData, utxo) => ({
|
|||
selected: false
|
||||
})
|
||||
|
||||
const mapWalletAccount = function (obj) {
|
||||
obj._data = _.clone(obj)
|
||||
obj.date = obj.time
|
||||
const mapWalletAccount = function (o) {
|
||||
return Object.assign({}, o, {
|
||||
date: o.time
|
||||
? Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
new Date(o.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
: ''
|
||||
obj.label = obj.title // for drop-downs
|
||||
obj.expanded = false
|
||||
return obj
|
||||
: '',
|
||||
label: o.title,
|
||||
expanded: false
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,99 +1,4 @@
|
|||
const tables = {
|
||||
walletsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'new',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
align: 'left',
|
||||
label: 'Title',
|
||||
field: 'title'
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount'
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
align: 'left',
|
||||
label: 'Type',
|
||||
field: 'type'
|
||||
},
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
},
|
||||
filter: ''
|
||||
},
|
||||
utxosTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'expand',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'selected',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
align: 'center',
|
||||
label: 'Status',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'address',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount',
|
||||
field: 'amount',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Date',
|
||||
field: 'date',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'wallet',
|
||||
align: 'left',
|
||||
label: 'Account',
|
||||
field: 'wallet',
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
},
|
||||
filter: ''
|
||||
},
|
||||
paymentTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'data',
|
||||
align: 'left'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
},
|
||||
filter: ''
|
||||
},
|
||||
summaryTable: {
|
||||
columns: [
|
||||
{
|
||||
|
@ -117,157 +22,36 @@ const tables = {
|
|||
label: 'Change'
|
||||
}
|
||||
]
|
||||
},
|
||||
addressesTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'expand',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'address',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount',
|
||||
field: 'amount',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
align: 'left',
|
||||
label: 'Note',
|
||||
field: 'note',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'wallet',
|
||||
align: 'left',
|
||||
label: 'Account',
|
||||
field: 'wallet',
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 0,
|
||||
sortBy: 'amount',
|
||||
descending: true
|
||||
},
|
||||
filter: ''
|
||||
},
|
||||
historyTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'expand',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
align: 'left',
|
||||
label: 'Status'
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount',
|
||||
field: 'amount',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'address',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Date',
|
||||
field: 'date',
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
exportColums: [
|
||||
{
|
||||
label: 'Action',
|
||||
field: 'action'
|
||||
},
|
||||
{
|
||||
label: 'Date&Time',
|
||||
field: 'date'
|
||||
},
|
||||
{
|
||||
label: 'Amount',
|
||||
field: 'amount'
|
||||
},
|
||||
{
|
||||
label: 'Fee',
|
||||
field: 'fee'
|
||||
},
|
||||
{
|
||||
label: 'Transaction Id',
|
||||
field: 'txId'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 0
|
||||
},
|
||||
filter: ''
|
||||
}
|
||||
}
|
||||
|
||||
const tableData = {
|
||||
walletAccounts: [],
|
||||
addresses: {
|
||||
show: false,
|
||||
data: [],
|
||||
history: [],
|
||||
selectedWallet: null,
|
||||
note: '',
|
||||
filterOptions: [
|
||||
'Show Change Addresses',
|
||||
'Show Gap Addresses',
|
||||
'Only With Amount'
|
||||
],
|
||||
filterValues: []
|
||||
},
|
||||
utxos: {
|
||||
data: [],
|
||||
total: 0
|
||||
},
|
||||
payment: {
|
||||
data: [{address: '', amount: undefined}],
|
||||
changeWallet: null,
|
||||
changeAddress: {},
|
||||
changeAmount: 0,
|
||||
|
||||
feeRate: 1,
|
||||
recommededFees: {
|
||||
fastestFee: 1,
|
||||
halfHourFee: 1,
|
||||
hourFee: 1,
|
||||
economyFee: 1,
|
||||
minimumFee: 1
|
||||
},
|
||||
fee: 0,
|
||||
txSize: 0,
|
||||
tx: null,
|
||||
psbtBase64: '',
|
||||
utxoSelectionModes: [
|
||||
'Manual',
|
||||
'Random',
|
||||
'Select All',
|
||||
'Smaller Inputs First',
|
||||
'Larger Inputs First'
|
||||
psbtBase64Signed: '',
|
||||
signedTx: null,
|
||||
signedTxHex: null,
|
||||
sentTxId: null,
|
||||
|
||||
signModes: [
|
||||
{
|
||||
label: 'Serial Port Device',
|
||||
value: 'serial-port'
|
||||
},
|
||||
{
|
||||
label: 'Animated QR',
|
||||
value: 'animated-qr',
|
||||
disable: true
|
||||
}
|
||||
],
|
||||
utxoSelectionMode: 'Manual',
|
||||
signMode: '',
|
||||
show: false,
|
||||
showAdvanced: false
|
||||
},
|
||||
|
|
|
@ -1,3 +1,18 @@
|
|||
const PSBT_BASE64_PREFIX = 'cHNidP8'
|
||||
const COMMAND_PASSWORD = '/password'
|
||||
const COMMAND_PASSWORD_CLEAR = '/password-clear'
|
||||
const COMMAND_SEND_PSBT = '/psbt'
|
||||
const COMMAND_SIGN_PSBT = '/sign'
|
||||
const COMMAND_HELP = '/help'
|
||||
const COMMAND_WIPE = '/wipe'
|
||||
const COMMAND_SEED = '/seed'
|
||||
const COMMAND_RESTORE = '/restore'
|
||||
const COMMAND_CONFIRM_NEXT = '/confirm-next'
|
||||
const COMMAND_CANCEL = '/cancel'
|
||||
const COMMAND_XPUB = '/xpub'
|
||||
|
||||
const DEFAULT_RECEIVE_GAP_LIMIT = 20
|
||||
|
||||
const blockTimeToDate = blockTime =>
|
||||
blockTime ? moment(blockTime * 1000).format('LLL') : ''
|
||||
|
||||
|
@ -97,3 +112,72 @@ const ACCOUNT_TYPES = {
|
|||
}
|
||||
|
||||
const getAccountDescription = type => ACCOUNT_TYPES[type] || 'nonstandard'
|
||||
|
||||
const readFromSerialPort = reader => {
|
||||
let partialChunk
|
||||
let fulliness = []
|
||||
|
||||
const readStringUntil = async (separator = '\n') => {
|
||||
if (fulliness.length) return fulliness.shift().trim()
|
||||
const chunks = []
|
||||
if (partialChunk) {
|
||||
// leftovers from previous read
|
||||
chunks.push(partialChunk)
|
||||
partialChunk = undefined
|
||||
}
|
||||
while (true) {
|
||||
const {value, done} = await reader.read()
|
||||
if (value) {
|
||||
const values = value.split(separator)
|
||||
// found one or more separators
|
||||
if (values.length > 1) {
|
||||
chunks.push(values.shift()) // first element
|
||||
partialChunk = values.pop() // last element
|
||||
fulliness = values // full lines
|
||||
return {value: chunks.join('').trim(), done: false}
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
if (done) return {value: chunks.join('').trim(), done: true}
|
||||
}
|
||||
}
|
||||
return readStringUntil
|
||||
}
|
||||
|
||||
function satOrBtc(val, showUnit = true, showSats = false) {
|
||||
const value = showSats
|
||||
? LNbits.utils.formatSat(val)
|
||||
: val == 0
|
||||
? 0.0
|
||||
: (val / 100000000).toFixed(8)
|
||||
if (!showUnit) return value
|
||||
return showSats ? value + ' sat' : value + ' BTC'
|
||||
}
|
||||
|
||||
function loadTemplateAsync(path) {
|
||||
const result = new Promise(resolve => {
|
||||
const xhttp = new XMLHttpRequest()
|
||||
|
||||
xhttp.onreadystatechange = function () {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status == 200) resolve(this.responseText)
|
||||
|
||||
if (this.status == 404) resolve(`<div>Page not found: ${path}</div>`)
|
||||
}
|
||||
}
|
||||
|
||||
xhttp.open('GET', path, true)
|
||||
xhttp.send()
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function findAccountPathIssues(path = '') {
|
||||
const p = path.split('/')
|
||||
if (p[0] !== 'm') return "Path must start with 'm/'"
|
||||
for (let i = 1; i < p.length; i++) {
|
||||
if (p[i].endsWith('')) p[i] = p[i].substring(0, p[i].length - 1)
|
||||
if (isNaN(p[i])) return `${p[i]} is not a valid value`
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,7 +1,8 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
from embit import script
|
||||
from embit.descriptor import Descriptor, Key
|
||||
import httpx
|
||||
from embit import finalizer, script
|
||||
from embit.ec import PublicKey
|
||||
from embit.psbt import PSBT, DerivationPath
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
|
@ -28,18 +29,31 @@ from .crud import (
|
|||
update_watch_wallet,
|
||||
)
|
||||
from .helpers import parse_key
|
||||
from .models import Config, CreatePsbt, CreateWallet, WalletAccount
|
||||
from .models import (
|
||||
BroadcastTransaction,
|
||||
Config,
|
||||
CreatePsbt,
|
||||
CreateWallet,
|
||||
ExtractPsbt,
|
||||
SignedTransaction,
|
||||
WalletAccount,
|
||||
)
|
||||
|
||||
###################WALLETS#############################
|
||||
|
||||
|
||||
@watchonly_ext.get("/api/v1/wallet")
|
||||
async def api_wallets_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
async def api_wallets_retrieve(
|
||||
network: str = Query("Mainnet"), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
|
||||
try:
|
||||
return [wallet.dict() for wallet in await get_watch_wallets(wallet.wallet.user)]
|
||||
return [
|
||||
wallet.dict()
|
||||
for wallet in await get_watch_wallets(wallet.wallet.user, network)
|
||||
]
|
||||
except:
|
||||
return ""
|
||||
return []
|
||||
|
||||
|
||||
@watchonly_ext.get("/api/v1/wallet/{wallet_id}")
|
||||
|
@ -61,7 +75,13 @@ async def api_wallet_create_or_update(
|
|||
data: CreateWallet, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
try:
|
||||
(descriptor, _) = parse_key(data.masterpub)
|
||||
(descriptor, network) = parse_key(data.masterpub)
|
||||
if data.network != network["name"]:
|
||||
raise ValueError(
|
||||
"Account network error. This account is for '{}'".format(
|
||||
network["name"]
|
||||
)
|
||||
)
|
||||
|
||||
new_wallet = WalletAccount(
|
||||
id="none",
|
||||
|
@ -72,11 +92,19 @@ async def api_wallet_create_or_update(
|
|||
title=data.title,
|
||||
address_no=-1, # so fresh address on empty wallet can get address with index 0
|
||||
balance=0,
|
||||
network=network["name"],
|
||||
)
|
||||
|
||||
wallets = await get_watch_wallets(w.wallet.user)
|
||||
wallets = await get_watch_wallets(w.wallet.user, network["name"])
|
||||
existing_wallet = next(
|
||||
(ew for ew in wallets if ew.fingerprint == new_wallet.fingerprint), None
|
||||
(
|
||||
ew
|
||||
for ew in wallets
|
||||
if ew.fingerprint == new_wallet.fingerprint
|
||||
and ew.network == new_wallet.network
|
||||
and ew.masterpub == new_wallet.masterpub
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_wallet:
|
||||
raise ValueError(
|
||||
|
@ -215,12 +243,13 @@ async def api_psbt_create(
|
|||
|
||||
descriptors = {}
|
||||
for _, masterpub in enumerate(data.masterpubs):
|
||||
descriptors[masterpub.fingerprint] = parse_key(masterpub.public_key)
|
||||
descriptors[masterpub.id] = parse_key(masterpub.public_key)
|
||||
|
||||
inputs_extra = []
|
||||
bip32_derivations = {}
|
||||
|
||||
for i, inp in enumerate(data.inputs):
|
||||
descriptor = descriptors[inp.masterpub_fingerprint][0]
|
||||
bip32_derivations = {}
|
||||
descriptor = descriptors[inp.wallet][0]
|
||||
d = descriptor.derive(inp.address_index, inp.branch_index)
|
||||
for k in d.keys:
|
||||
bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath(
|
||||
|
@ -239,12 +268,13 @@ async def api_psbt_create(
|
|||
for i, inp in enumerate(inputs_extra):
|
||||
psbt.inputs[i].bip32_derivations = inp["bip32_derivations"]
|
||||
psbt.inputs[i].non_witness_utxo = inp.get("non_witness_utxo", None)
|
||||
print("### ", inp.get("non_witness_utxo", None))
|
||||
|
||||
outputs_extra = []
|
||||
bip32_derivations = {}
|
||||
for i, out in enumerate(data.outputs):
|
||||
if out.branch_index == 1:
|
||||
descriptor = descriptors[out.masterpub_fingerprint][0]
|
||||
descriptor = descriptors[out.wallet][0]
|
||||
d = descriptor.derive(out.address_index, out.branch_index)
|
||||
for k in d.keys:
|
||||
bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath(
|
||||
|
@ -261,6 +291,66 @@ async def api_psbt_create(
|
|||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@watchonly_ext.put("/api/v1/psbt/extract")
|
||||
async def api_psbt_extract_tx(
|
||||
data: ExtractPsbt, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
res = SignedTransaction()
|
||||
try:
|
||||
psbt = PSBT.from_base64(data.psbtBase64)
|
||||
for i, inp in enumerate(data.inputs):
|
||||
psbt.inputs[i].non_witness_utxo = Transaction.from_string(inp.tx_hex)
|
||||
|
||||
final_psbt = finalizer.finalize_psbt(psbt)
|
||||
if not final_psbt:
|
||||
raise ValueError("PSBT cannot be finalized!")
|
||||
res.tx_hex = final_psbt.to_string()
|
||||
|
||||
transaction = Transaction.from_string(res.tx_hex)
|
||||
tx = {
|
||||
"locktime": transaction.locktime,
|
||||
"version": transaction.version,
|
||||
"outputs": [],
|
||||
"fee": psbt.fee(),
|
||||
}
|
||||
|
||||
for out in transaction.vout:
|
||||
tx["outputs"].append(
|
||||
{"amount": out.value, "address": out.script_pubkey.address()}
|
||||
)
|
||||
res.tx_json = json.dumps(tx)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
return res.dict()
|
||||
|
||||
|
||||
@watchonly_ext.post("/api/v1/tx")
|
||||
async def api_tx_broadcast(
|
||||
data: BroadcastTransaction, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
try:
|
||||
config = await get_config(w.wallet.user)
|
||||
if not config:
|
||||
raise ValueError(
|
||||
"Cannot broadcast transaction. Mempool endpoint not defined!"
|
||||
)
|
||||
|
||||
endpoint = (
|
||||
config.mempool_endpoint
|
||||
if config.network == "Mainnet"
|
||||
else config.mempool_endpoint + "/testnet"
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(endpoint + "/api/tx", data=data.tx_hex)
|
||||
tx_id = r.text
|
||||
print("### broadcast tx_id: ", tx_id)
|
||||
return tx_id
|
||||
# return "0f0f0f0f0f0f0f0f0f0f0f00f0f0f0f0f0f0f0f0f0f00f0f0f0f0f0f0.mock.transaction.id"
|
||||
except Exception as e:
|
||||
print("### broadcast error: ", str(e))
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
#############################CONFIG##########################
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user