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