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:
Vlad Stan 2022-08-09 05:00:09 -04:00 committed by GitHub
parent 63849a0894
commit 1f139884fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 4358 additions and 2077 deletions

View File

@ -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 .

View File

@ -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)

View File

@ -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]

View File

@ -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;")

View File

@ -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"

View File

@ -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>

View File

@ -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 () {}
})
}

View File

@ -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>

View File

@ -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
}
})
}

View File

@ -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>

View File

@ -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 () {}
})
}

View File

@ -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>

View File

@ -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)
}
}
})
}

View File

@ -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>

View 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 () {}
})
}

View File

@ -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>

View File

@ -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 () {}
})
}

View File

@ -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>

View File

@ -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
}
}
})
}

View File

@ -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>

View File

@ -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 () {}
})
}

View File

@ -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
>

View File

@ -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 () {}
})
}

View File

@ -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>

View File

@ -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()
}
})
}

View File

@ -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>

View File

@ -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])
}
}
})
}

View File

@ -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()

View File

@ -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
})
}

View File

@ -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
},

View File

@ -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

View File

@ -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##########################