Bugfix: Refactor wallet class fixes #1394 #1367 #1241 #1101 (#1411)

* use combined embit descriptor in wallets

* use embit descriptors instead of hwi descriptors

* fix wallet importer to work with Liquid

* refactor pending psbts

* psbt class integration

* pin to elements rc1

* refactor SpecterPSBT classes

* black, psbt table assets

* fix hot wallet signing flow

* cleanup for new elements

* fix tests, pset weight

* use sighash rangeproof as default in elements

* refactor rbf

* fix tests

* continue refactoring

* set locktime from blockheight, delete psbt only after confirmation

* refactor createpsbt

* use tag

* refactor liquid createpsbt

* raise on RBF attempt on liquid

* remove decodepsbt from liquidrpc

* check for dynafed activation

* add addr.is_change

* fix dict changed during iteration

* add typing

* add support for dummy outputs

* improve tx handling

* remove dynafed check

* bump embit

* remove fee and dummy when parsing liquid transaction

* update pinned elements tag

* render confidential as confidential

* txlist: continue if dir creation failed

* fixed utxo testing-issue via force

Co-authored-by: Kim Neunert <k9ert@gmx.de>
This commit is contained in:
Stepan Snigirev 2021-10-08 17:56:49 +02:00 committed by GitHub
parent a2350a770b
commit 149f5d653d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 3871 additions and 1155 deletions

View file

@ -12,10 +12,10 @@ describe('Operating with an elements singlesig wallet', () => {
cy.addHotDevice("Hot Elements Device 1","elements")
// Segwit Wallet
cy.addHotWallet("Elm Single Segwit Hot Wallet","elements", "segwit")
cy.addHotWallet("Elm Single Segwit Hot Wallet","Hot Elements Device 1", "elements", "segwit")
// Nested Segwit Wallet
cy.addHotWallet("Elm Single Nested Hot Wallet","elements", "nested_segwit")
cy.addHotWallet("Elm Single Nested Hot Wallet","Hot Elements Device 1", "elements", "nested_segwit")
})
it('send confidential transaction from segwit', () => {

View file

@ -1,26 +1,41 @@
describe('Send transactions from wallets', () => {
it('Freeze and unfreeze UTXO', () => {
const name = "UTXO Hot Bitcoin3"
const wallet_name = name+" wallet"
var wallet_name_ref = wallet_name.toLowerCase().replace(/ /g,"_")
cy.viewport(1200,660)
cy.task("btc:mine")
cy.wait(10000)
cy.task("btc:mine")
cy.wait(10000)
cy.task("btc:mine")
cy.wait(10000)
cy.visit('/')
cy.addHotDevice(name+" device","bitcoin")
cy.addHotWallet(wallet_name,name+" device", "bitcoin", "segwit")
cy.get('#fullbalance_amount').then(($div) => {
const balance = parseFloat($div.text())
if ( balance <= 20) {
cy.log("balance " + balance + " too low. Mining!")
cy.mine2wallet("btc")
cy.mine2wallet("btc")
cy.mine2wallet("btc")
}
})
cy.contains(wallet_name).click()
cy.visit('/wallets/wallet/test_hot_wallet_1/history')
cy.wait(1000)
cy.get('tx-table').shadow().find('.utxo-view-btn').click()
// The table as component is only available through the shadow tree
// That's why we have this stupid .shadow() ...
cy.get('tx-table').shadow().find('.utxo-view-btn').click({ force: true })
cy.log("Check that nothis is frozen")
cy.get('tx-table').shadow().find('tx-row').each(($el, index, $list) => {
cy.wrap($el).shadow().find('.tx-row').should('not.have.class', 'frozen')
cy.wrap($el).shadow().find('.frozen-img').should('have.class', 'hidden')
})
// Freeze the UTXO
cy.log("First select it, then freeze it")
cy.get('tx-table').shadow().find('tx-row').eq(0).shadow().find('.select-tx-img').click()
cy.wait(100)
// then click the freeze-button
cy.get('tx-table').shadow().find('.freeze-tx-btn').click()
cy.wait(100)
cy.get('tx-table').shadow().find('tx-row').each(($el, index, $list) => {
if (index == 0) {
@ -33,6 +48,7 @@ describe('Send transactions from wallets', () => {
})
// Test freeze UTXO can't be spend, and unfreeze works for coin selection option
cy.log("Select 3 UTXOs and freeze them")
cy.get('tx-table').shadow().find('tx-row').eq(0).shadow().find('.select-tx-img').click()
cy.get('tx-table').shadow().find('tx-row').eq(1).shadow().find('.select-tx-img').click()
cy.get('tx-table').shadow().find('tx-row').eq(3).shadow().find('.select-tx-img').click()

View file

@ -24,6 +24,8 @@
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
import 'cypress-wait-until';
Cypress.Commands.add("addDevice", (name) => {
cy.get('body').then(($body) => {
if ($body.text().includes(name)) {
@ -61,6 +63,8 @@ Cypress.Commands.add("addHotDevice", (name, node_type) => {
var refName = "#device_list_item_"+name.toLowerCase().replace(/ /g,"_")
cy.get(refName).click()
cy.get('#forget_device').click()
// We might get an error here, if the device is used in a wallet
// We assume therefore that this is ok (see below)
}
cy.get('#side-content').click()
cy.get('#btn_new_device').click()
@ -69,17 +73,22 @@ Cypress.Commands.add("addHotDevice", (name, node_type) => {
cy.get('#submit-mnemonic').click()
cy.get('#device_name').type(name)
cy.get('#submit-keys').click()
// It's a bit hackish as if the device already exists, we'll get an error
// but continue flaslessly nevertheless
cy.get('#devices_list > .item > div', { timeout: 8000 }).contains(name)
})
})
Cypress.Commands.add("addHotWallet", (name, node_type, wallet_type, single_multi) => {
Cypress.Commands.add("addHotWallet", (wallet_name, device_name, node_type, wallet_type, single_multi) => {
if (wallet_type == null) {
wallet_type = "segwit"
}
if (device_name == null) {
device_name = "Hot Elements Device 1"
}
cy.get('body').then(($body) => {
if ($body.text().includes(name)) {
cy.contains(name).click()
if ($body.text().includes(wallet_name)) {
cy.contains(wallet_name).click()
cy.get('#btn_settings' ).click( {force: true})
cy.get('#advanced_settings_tab_btn').click()
cy.get('#delete_wallet').click()
@ -89,8 +98,9 @@ Cypress.Commands.add("addHotWallet", (name, node_type, wallet_type, single_multi
cy.get('#btn_new_wallet').click()
cy.get('[href="./simple/"]').click()
cy.get('#hot_elements_device_1').click()
cy.get('#wallet_name').type(name)
var device_button = "#"+device_name.toLowerCase().replace(/ /g,"_")
cy.get(device_button).click()
cy.get('#wallet_name').type(wallet_name)
if (wallet_type == "nested_segwit") {
cy.get(':nth-child(1) > #type_nested_segwit_btn').click()
}
@ -103,7 +113,7 @@ Cypress.Commands.add("addHotWallet", (name, node_type, wallet_type, single_multi
cy.get('#btn_continue').click()
//Get some funds
cy.mine2wallet("elm")
cy.mine2wallet(node_type)
})
})
@ -127,21 +137,23 @@ Cypress.Commands.add("mine2wallet", (chain) => {
cy.get('#btn_transactions').click()
cy.get('#fullbalance_amount').then(($div) => {
const oldBalance = parseFloat($div.text())
if (chain=="elm") {
if (chain=="elm" || chain=="elements") {
cy.task("elm:mine")
} else if (chain=="btc") {
} else if (chain=="btc" || chain=="bitcoin") {
cy.task("btc:mine")
} else {
throw new Error("Unknown chain: " + chain)
}
cy.wait(15000)
cy.reload()
cy.get('#fullbalance_amount') // Wait 5 secs + 15 secs timeout
.should(($div) => {
cy.waitUntil( () => cy.reload().get('#fullbalance_amount', { timeout: 3000 })
.then(($div) => {
const n = parseFloat($div.text())
expect(n).to.be.gt(oldBalance)
}
)
return n > oldBalance
})
, {
errorMsg: 'Waited for the funds arriving in the wallet from chain mining but it never did (timeout 30s) ',
timeout: 30000,
interval: 2000
})
})
})

2089
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,9 @@
"rimraf": "^3.0.2",
"wait-on": "^5.3.0"
},
"devDependencies": {},
"devDependencies": {
"cypress-wait-until": "^1.7.1"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},

View file

@ -16,7 +16,7 @@ requests==2.25.0
pysocks==1.7.1
six==1.12.0
stem==1.8.0
embit==0.4.9
embit==0.4.10
psutil==5.7.3
pyopenssl==20.0.1
flask_wtf==0.14.3

View file

@ -108,8 +108,8 @@ ecdsa==0.17.0 \
--hash=sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676 \
--hash=sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa \
# via bitbox02, hwi
embit==0.4.9 \
--hash=sha256:992332bd89af6e2d027e26fe437eb14aa33997db08c882c49064d49c3e6f4ab9 \
embit==0.4.10 \
--hash=sha256:f6484bc495b45da27f3eb7fbe21a24c00cd72c0ab83c6e195660cf17db5cb5e2 \
# via -r requirements.in
flask-babel==2.0.0 \
--hash=sha256:e6820a052a8d344e178cdd36dd4bb8aea09b4bda3d5f9fa9f008df2c7f2f5468 \

View file

@ -53,11 +53,17 @@ class Address(dict):
def is_external(self):
return self.index is None
@property
def is_mine(self):
return not self.is_external
@property
def is_receiving(self):
# change can be True, False or None
# None means it's external
return not self.is_external and not self.change
return self.is_mine and not self.change
@property
def is_change(self):
return self.is_mine and self.change
@property
def index(self):
@ -206,7 +212,11 @@ class AddressList(dict):
return max(
0,
0,
*[addr.index for addr in self.values() if addr.change == change],
*[
addr.index or 0
for addr in self.values()
if addr.is_mine and addr.change == change
],
)
def max_used_index(self, change=False):
@ -214,9 +224,9 @@ class AddressList(dict):
-1,
-1,
*[
addr.index
addr.index or -1
for addr in self.values()
if addr.used and addr.change == change
if addr.is_mine and addr.used and addr.change == change
],
)

View file

@ -25,8 +25,8 @@ class ResourcePsbt(SecureResource):
wallet: Wallet = app.specter.user_manager.get_user(
user
).wallet_manager.get_by_alias(wallet_alias)
pending_psbts = wallet.pending_psbts
return {"result": pending_psbts or []}
pending_psbts = wallet.pending_psbts_dict()
return {"result": pending_psbts or {}}
def post(self, wallet_alias):
user = auth.current_user()
@ -37,5 +37,5 @@ class ResourcePsbt(SecureResource):
psbt_creator = PsbtCreator(
app.specter, wallet, "json", request_json=request.json
)
psbt_creator.create_psbt(wallet)
return {"result": psbt_creator.psbt}
psbt = psbt_creator.create_psbt(wallet)
return {"result": psbt}

View file

@ -24,6 +24,9 @@ class BitcoinCore(Device):
hot_wallet = True
taproot_support = True
# default sighash to use
SIGHASH = "ALL"
def __init__(self, *args, **kwargs):
self._use_descriptors = None
super().__init__(*args, **kwargs)
@ -212,7 +215,7 @@ class BitcoinCore(Device):
)
if file_password:
rpc.walletpassphrase(file_password, 60)
signed_psbt = rpc.walletprocesspsbt(base64_psbt)
signed_psbt = rpc.walletprocesspsbt(base64_psbt, True, self.SIGHASH)
if base64_psbt == signed_psbt["psbt"]:
raise Exception(
"Make sure you have entered the wallet file password correctly. (If your wallet is not encrypted submit empty password)"

View file

@ -16,6 +16,9 @@ class ElementsCore(BitcoinCore):
bitcoin_core_support = False
liquid_support = True
# default sighash to use
SIGHASH = "ALL|RANGEPROOF"
def add_hot_wallet_keys(
self,
mnemonic,

View file

@ -1,6 +1,5 @@
from . import DeviceTypes
from ..device import Device
from ..liquid.util.pset import to_canonical_pset
class GenericDevice(Device):
@ -13,7 +12,6 @@ class GenericDevice(Device):
taproot_support = True
def create_psbts(self, base64_psbt, wallet):
base64_psbt = to_canonical_pset(base64_psbt)
# in QR codes keep only xpubs
qr_psbt = wallet.fill_psbt(base64_psbt, non_witness=False, xpubs=True)
# in SD card put as much as possible

View file

@ -32,10 +32,7 @@ EMOJIS = "😀😃😄😁😆😅😂🤣😊😇🙂🙃😉😌😍😘😗
def get_asset_label(asset, known_assets={}):
# TODO: lookup in the registry
if (
asset is None
or asset == "0000000000000000000000000000000000000000000000000000000000000000"
):
if asset in [None, "00" * 32, "ff" * 32]:
return "???"
if asset == "bitcoin":
return "LBTC"
@ -345,4 +342,4 @@ def get_address_from_dict(data_dict):
addr = data_dict.get("address")
if addr and addr != "Fee":
return addr
raise RuntimeError("Missing address info in object")
raise RuntimeError(f"Missing address info in object {data_dict}")

View file

@ -2,7 +2,7 @@ from collections import OrderedDict
from binascii import hexlify
from embit import base58
from .util.xpub import get_xpub_fingerprint
from embit.descriptor import Key as DescriptorKey
purposes = OrderedDict(
{

View file

@ -26,7 +26,7 @@ class LAddressList(AddressList):
self._update_scripts()
def _update_scripts(self):
for addr in self:
for addr in list(self.keys()):
sc, _ = addr_decode(addr)
if sc and sc not in self._scripts:
self._scripts[sc] = super().__getitem__(addr)
@ -39,9 +39,12 @@ class LAddressList(AddressList):
def __contains__(self, addr):
"""finds address by confidential or unconfidential address by converting to scriptpubkey"""
sc, _ = addr_decode(addr)
if sc and self._scripts.__contains__(sc):
return True
try: # can fail if addr is "Fee", "Dummy" or hex-scriptpubkey
sc, _ = addr_decode(addr)
if sc and self._scripts.__contains__(sc):
return True
except:
pass
return super().__contains__(addr)
def __getitem__(self, addr):

View file

@ -13,7 +13,6 @@ from embit.liquid import slip77
from embit.psbt import read_string
import copy
from io import BytesIO
from .util.pset import to_canonical_pset
import logging
@ -179,6 +178,23 @@ class LiquidRPC(BitcoinRPC):
inputs, outputs, locktime, options, *args, **kwargs
)
psbt = res.get("psbt", None)
# remove zero-output (bug in Elements)
# TODO: remove after release
if psbt:
try:
tx = PSET.from_string(psbt)
# check if there are zero outputs
has_zero = len([out for out in tx.outputs if out.value == 0]) > 0
has_blinded = any([out.blinding_pubkey for out in tx.outputs])
logger.error(has_zer, has_blinded)
if has_blinded and has_zero:
tx.outputs = [out for out in tx.outputs if out.value > 0]
psbt = str(tx)
res["psbt"] = psbt
except:
pass
# replace change addresses from the transactions if we can
if change_addresses and psbt:
try:
@ -230,6 +246,9 @@ class LiquidRPC(BitcoinRPC):
# check that change is also blinded - fixes a bug in pset branch
tx = PSET.from_string(psbt)
changepos = res.get("changepos", None)
# no change output
if changepos < 0:
changepos = None
# generate all blinding stuff ourselves in deterministic way
tx.unblind(
@ -300,9 +319,7 @@ class LiquidRPC(BitcoinRPC):
tx.xpubs.update(t2.xpubs)
tx.unknown.update(t2.unknown)
for i in range(len(tx.inputs)):
inp1 = tx.inputs[i]
inp2 = t2.inputs[i]
for inp1, inp2 in zip(tx.inputs, t2.inputs):
inp1.value = inp1.value or inp2.value
inp1.value_blinding_factor = (
inp1.value_blinding_factor or inp2.value_blinding_factor
@ -327,9 +344,7 @@ class LiquidRPC(BitcoinRPC):
inp1.unknown.update(inp2.unknown)
inp1.range_proof = inp1.range_proof or inp2.range_proof
for i in range(len(tx.outputs)):
out1 = tx.outputs[i]
out2 = t2.outputs[i]
for out1, out2 in zip(tx.outputs, t2.outputs):
out1.value_commitment = out1.value_commitment or out2.value_commitment
out1.value_blinding_factor = (
out1.value_blinding_factor or out2.value_blinding_factor
@ -353,66 +368,6 @@ class LiquidRPC(BitcoinRPC):
out1.unknown.update(out2.unknown)
return str(tx)
def decodepsbt(self, b64psbt, *args, **kwargs):
tx = PSET.from_string(b64psbt)
# pre-processing of the transaction
# so Elements Core doesn't complain
inputs = [
(inp.value or inp.utxo.value, inp.asset or inp.utxo.asset)
for inp in tx.inputs
]
for inp in tx.inputs:
inp.value = None
inp.asset = None
inp.value_blinding_factor = None
inp.asset_blinding_factor = None
for out in tx.outputs:
if out.asset and out.value:
# out.asset = None
out.asset_blinding_factor = None
# out.value = None
out.value_blinding_factor = None
out.asset_commitment = None
out.value_commitment = None
out.range_proof = None
out.surjection_proof = None
out.ecdh_pubkey = None
b64psbt = str(tx)
decoded = super().__getattr__("decodepsbt")(b64psbt, *args, **kwargs)
# pset branch - no fee and global tx fields...
if "fees" in decoded and "bitcoin" in decoded["fees"]:
decoded["fee"] = decoded["fees"]["bitcoin"]
if "tx" not in decoded or "fee" not in decoded:
pset = PSET.from_string(b64psbt)
if "tx" not in decoded:
decoded["tx"] = self.decoderawtransaction(str(pset.tx))
if "fee" not in decoded:
decoded["fee"] = pset.fee() * 1e-8
for out in decoded["outputs"]:
if "value" not in out:
out["value"] = -1
for out in decoded["tx"]["vout"]:
if "value" not in out:
out["value"] = -1
for i, (v, a) in enumerate(inputs):
inp = decoded["tx"]["vin"][i] # old psbt
inp2 = decoded["inputs"][i] # new psbt
if "utxo_rangeproof" in inp2:
inp2.pop("utxo_rangeproof")
a = bytes(reversed(a[-32:])).hex()
v = round(v * 1e-8, 8)
if "value" not in inp:
inp["value"] = v
if "asset" not in inp:
inp["asset"] = a
if "value" not in inp2:
inp2["value"] = v
if "asset" not in inp2:
inp2["asset"] = a
return decoded
def decoderawtransaction(self, tx):
blinded = super().__getattr__("decoderawtransaction")(tx)
try:

View file

@ -1,5 +1,12 @@
from ..txlist import *
from embit.liquid.transaction import LTransaction
from embit.liquid.transaction import LTransaction, TxOutWitness, unblind
from embit.liquid.pset import PSET
from embit.liquid import slip77
from embit.hashes import tagged_hash
from embit.ec import PrivateKey
from .util.pset import SpecterLTx, get_value, get_asset, SpecterPSET
from io import BytesIO
from embit.psbt import read_string
class LTxItem(TxItem):
@ -33,6 +40,112 @@ class LTxItem(TxItem):
bool,
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# unblind what's blinded and remove
# will fill automatically
self.vsize
self._unblind()
def _unblind(self):
if not self.descriptor.is_blinded:
return
b = self.tx
mbpk = self.descriptor.blinding_key.key
net = self.network
values = [0 for out in b.vout]
assets = [b"\xFF" * 32 for out in b.vout]
datas = []
# search for datas encoded in rangeproofs
for i, out in enumerate(b.vout):
# unblinded
if isinstance(out.value, int):
values[i] = out.value
assets[i] = out.asset
continue
pk = slip77.blinding_key(mbpk, out.script_pubkey)
try:
res = out.unblind(pk.secret, message_length=1000)
value, asset, vbf, abf, extra, *_ = res
if len(extra.rstrip(b"\x00")) > 0:
datas.append(extra)
values[i] = value
assets[i] = asset
except Exception as e:
logger.warn(e) # TODO: remove, it's ok
pass
# to calculate blinding seed
tx = PSET(b)
seed = tagged_hash("liquid/blinding_seed", mbpk.secret)
txseed = tx.txseed(seed)
pubkeys = {}
for extra in datas:
s = BytesIO(extra)
while True:
k = read_string(s)
if len(k) == 0:
break
v = read_string(s)
if k[0] == 1 and len(k) == 5:
idx = int.from_bytes(k[1:], "little")
pubkeys[idx] = v
elif k == b"\x01\x00":
txseed = v
for i, out in enumerate(b.vout):
if out.witness.range_proof.is_empty:
continue
if i in pubkeys and len(pubkeys[i]) in [33, 65]:
nonce = tagged_hash(
"liquid/range_proof", txseed + i.to_bytes(4, "little")
)
if out.ecdh_pubkey == PrivateKey(nonce).sec():
try:
res = unblind(
pubkeys[i],
nonce,
out.witness.range_proof.data,
out.value,
out.asset,
out.script_pubkey,
)
value, asset, vbf, abf, extra, min_value, max_value = res
assets[i] = asset
values[i] = value
except Exception as e:
logger.warn(f"Failed at unblinding output {i}: {e}")
else:
logger.warn(f"Failed at unblinding: {e}")
for i, out in enumerate(b.vout):
out.asset = assets[i]
out.value = values[i]
out.witness = TxOutWitness()
@property
def vsize(self):
if self.get("vsize"):
return self["vsize"]
tx = self.tx
txsize = len(tx.serialize())
# tx size - flag - marker - witness
non_witness_size = (
txsize
- 2
- sum([len(inp.witness.serialize()) for inp in tx.vin])
- sum([len(out.witness.serialize()) for out in tx.vout])
)
witness_size = txsize - non_witness_size
weight = non_witness_size * 4 + witness_size
vsize = math.ceil(weight / 4)
return vsize
def __dict__(self):
return {
"txid": self["txid"],
@ -52,102 +165,32 @@ class LTxItem(TxItem):
class LTxList(TxList):
ItemCls = LTxItem
PSBTCls = SpecterPSET
counter = 0
def fill_missing(self, tx):
raw_tx = self.decoderawtransaction(tx.hex)
tx["vsize"] = raw_tx["vsize"]
category = ""
addresses = []
amounts = {}
assets = {}
inputs_mine_count = 0
for vin in raw_tx["vin"]:
# coinbase tx
if (
vin["txid"]
== "0000000000000000000000000000000000000000000000000000000000000000"
):
category = "generate"
break
if vin["txid"] in self:
try:
address = get_address_from_dict(
self.decoderawtransaction(self[vin["txid"]].hex)["vout"][
vin["vout"]
]
)
address_info = self._addresses.get(address, None)
if address_info and not address_info.is_external:
inputs_mine_count += 1
except Exception as e:
logger.error(e)
continue
outputs_mine_count = 0
for out in raw_tx["vout"]:
try:
address = get_address_from_dict(out)
except Exception as e:
# couldn't get address...
logger.error(e)
continue
address_info = self._addresses.get(address)
if address_info and not address_info.is_external:
outputs_mine_count += 1
addresses.append(address)
amounts[address] = out.get("value", 0)
assets[address] = out.get("asset", "Unknown")
if inputs_mine_count:
if outputs_mine_count == len(raw_tx["vout"]):
category = "selftransfer"
# remove change addresses from the dest list
addresses2 = [
address
for address in addresses
if self._addresses.get(address, None)
and not self._addresses[address].change
]
# use new list only if it's not empty
if addresses2:
addresses = addresses2
else:
category = "send"
addresses = [
address
for address in addresses
if not self._addresses.get(address, None)
or self._addresses[address].is_external
]
else:
if not category:
category = "receive"
addresses = [
address
for address in addresses
if self._addresses.get(address, None)
and not self._addresses[address].is_external
]
amounts = [amounts[address] for address in addresses]
assets = [assets[address] for address in addresses]
def _get_psbt(self, raw_tx):
psbt = self.PSBTCls.from_transaction(raw_tx, self.descriptor, self.network)
psbt.psbt.version = 2
# fill derivation paths etc
updated = self.rpc.walletprocesspsbt(str(psbt), False).get("psbt", None)
if updated:
psbt.update(updated)
return psbt
def _update_destinations(self, tx, outs):
# remove dummy and fee
outs = [out for out in outs if out.get("address") not in ["Fee", "DUMMY"]]
# process the rest
addresses = [out.get("address", "Unknown") for out in outs]
amounts = [out.get("float_amount", 0) for out in outs]
assets = [out.get("asset", "ff" * 32) for out in outs]
if len(addresses) == 1:
addresses = addresses[0]
amounts = amounts[0]
assets = assets[0]
tx["category"] = category
tx["address"] = addresses
tx["amount"] = amounts
tx["asset"] = assets
if not addresses:
tx["ismine"] = False
else:
tx["ismine"] = True
def decoderawtransaction(self, txhex):
# TODO: using rpc for now, can be moved to utils
return self.rpc.decoderawtransaction(txhex)
def decoderawtransaction(self, tx: Union[LTransaction, str, bytes]):
return SpecterLTx(self, tx).to_dict()

View file

@ -1,7 +1,15 @@
from embit.liquid.pset import PSET
from embit.liquid.pset import PSET, LInputScope, LOutputScope
from embit.liquid.transaction import LTransaction, LTransactionOutput
from embit.liquid.networks import get_network
from embit.liquid.addresses import address as liquid_address
from embit.liquid import slip77
from embit import bip32, ec, script
from math import ceil
import time
from cryptoadvance.specter.util.psbt import *
def to_canonical_pset(pset):
def to_canonical_pset(pset: str) -> str:
"""
Removes unblinded information from the transaction
so Elements Core can decode it
@ -24,3 +32,169 @@ def to_canonical_pset(pset):
out.value = None
out.value_blinding_factor = None
return str(tx)
def get_address(script_pubkey: script.Script, network: dict) -> str:
if not script_pubkey.data:
return "Fee"
if script_pubkey.data.startswith(b"\x6a"):
if len(script_pubkey.data) == 1:
return "DUMMY" # dummy output to blind
else:
return "OP_RETURN " + script_pubkey.data[1:].hex()
try:
return script_pubkey.address(network)
except:
return script_pubkey.data.hex()
def get_value(value) -> int:
if isinstance(value, int):
return value
return 0 # confidential
def get_asset(asset) -> bytes:
if len(asset) != 32:
return (b"\xFF" * 32).hex() # confidential
return asset[::-1].hex()
class SpecterLTx(SpecterTx):
TxCls = LTransaction
def vout_to_dict(self, vout: LTransactionOutput) -> dict:
i = self.tx.vout.index(vout)
return {
"value": round(1e-8 * get_value(vout.value), 8),
"sats": get_value(vout.value),
"n": i,
"asset": get_asset(vout.asset),
"scriptPubKey": {
"hex": vout.script_pubkey.data.hex(),
"addresses": [get_address(vout.script_pubkey, self.network)],
},
}
class SpecterLInputScope(SpecterInputScope):
TxCls = SpecterLTx
@property
def assetid(self) -> str:
if self.scope.asset is None:
return "???"
return self.scope.asset[::-1].hex()
@property
def address(self) -> str:
# TODO: blinding key?
try:
return liquid_address(self.scope.script_pubkey, network=self.network)
except:
return None
@property
def sat_amount(self) -> int:
return self.scope.value or 0
def to_dict(self) -> dict:
obj = super().to_dict()
obj.update({"asset": self.assetid})
return obj
class SpecterLOutputScope(SpecterOutputScope):
@property
def assetid(self) -> str:
if self.scope.asset is None:
return "???"
return self.scope.asset[::-1].hex()
@property
def address(self) -> str:
if not self.scope.script_pubkey.data:
return "Fee"
if self.scope.script_pubkey.data.startswith(b"\x6a"):
if len(self.scope.script_pubkey.data) == 1:
return "DUMMY" # dummy output to blind
else:
return "OP_RETURN " + self.scope.script_pubkey.data[1:].hex()
try:
# try making a liquid address
return liquid_address(
self.scope.script_pubkey, self.blinding_key, network=self.network
)
except:
# if failed - return hex of the scriptpubkey
return self.scope.script_pubkey.data.hex()
def extra_weight(self) -> int:
wit = 0
if self.scope.is_blinded:
wit += 33 * 4 # nonce
wit += (33 - 9) * 4 # value
if self.scope.range_proof and self.scope.surjection_proof:
# serialized witness length
wit += (
len(self.scope.surjection_proof) + len(self.scope.range_proof) + 3
)
else:
# we don't have proofs yet but we can estimate their size
wit += 4245
return wit
@property
def blinding_key(self) -> ec.PublicKey:
if self.scope.blinding_pubkey:
return ec.PublicKey.parse(self.scope.blinding_pubkey)
@property
def sat_amount(self) -> int:
return self.scope.value or 0
def to_dict(self) -> dict:
obj = super().to_dict()
obj.update({"asset": self.assetid})
return obj
class SpecterPSET(SpecterPSBT):
"""Specter's PSBT class with some handy functions"""
PSBTCls = PSET
InputCls = SpecterLInputScope
OutputCls = SpecterLOutputScope
TxCls = SpecterLTx
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.psbt.verify(ignore_missing=True)
@property
def full_size(self) -> int:
size = len(self.psbt.tx.serialize()) * 4
# witness and redeem script
size += len(self.inputs) * self.extra_input_weight
for out in self.outputs:
size += out.extra_weight()
return ceil(size / 4)
def should_display(self, out: SpecterLOutputScope) -> bool:
"""Checks if this output should be displayed"""
if not out.scope.script_pubkey.data:
# Fee
return False
if out.scope.value == 0 and out.scope.script_pubkey.data == b"\x6a":
# Dummy output
return False
return super().should_display(out)
@property
def assets(self) -> List[str]:
return [out.assetid for out in self.outputs if self.should_display(out)]
def to_dict(self) -> dict:
obj = super().to_dict()
obj.update({"asset": self.assets})
return obj

View file

@ -3,10 +3,13 @@ from ..addresslist import Address
from embit import ec
from embit.liquid.pset import PSET
from embit.liquid.transaction import LTransaction
from embit.liquid.descriptor import LDescriptor
from embit.descriptor.checksum import add_checksum
from .txlist import LTxList
from .addresslist import LAddressList
from embit.liquid.addresses import to_unconfidential
from ..specter_error import SpecterError
from .util.pset import SpecterPSET
class LWallet(Wallet):
@ -14,51 +17,24 @@ class LWallet(Wallet):
AddressListCls = LAddressList
TxListCls = LTxList
TxCls = LTransaction
PSBTCls = PSET
PSBTCls = SpecterPSET
DescriptorCls = LDescriptor
@classmethod
def create(
cls,
rpc,
rpc_path,
working_folder,
device_manager,
wallet_manager,
name,
alias,
sigs_required,
key_type,
keys,
devices,
core_version=None,
def construct_descriptor(
cls, sigs_required, key_type, keys, devices, blinding_key=None
):
"""Creates a wallet. If core_version is not specified - get it from rpc"""
# get xpubs in a form [fgp/der]xpub from all keys
xpubs = [key.metadata["combined"] for key in keys]
recv_keys = ["%s/0/*" % xpub for xpub in xpubs]
change_keys = ["%s/1/*" % xpub for xpub in xpubs]
is_multisig = len(keys) > 1
# we start by constructing an argument for descriptor wrappers
if is_multisig:
recv_descriptor = "sortedmulti({},{})".format(
sigs_required, ",".join(recv_keys)
)
change_descriptor = "sortedmulti({},{})".format(
sigs_required, ",".join(change_keys)
)
else:
recv_descriptor = recv_keys[0]
change_descriptor = change_keys[0]
# now we iterate over script-type in reverse order
# to get sh(wpkh(xpub)) from sh-wpkh and xpub
arr = key_type.split("-")
for el in arr[::-1]:
recv_descriptor = "%s(%s)" % (el, recv_descriptor)
change_descriptor = "%s(%s)" % (el, change_descriptor)
"""
Creates a wallet descriptor from arguments.
We need to pass `devices` for Liquid wallet, here it's not used.
"""
# construct normal bitcoin descriptor first
btcdescriptor = Wallet.construct_descriptor(
sigs_required, key_type, keys, devices
)
# get blinding key for the wallet
blinding_key = None
if len(devices) == 1:
# get blinding key for the wallet if it's not provided
if len(devices) == 1 and blinding_key is None:
blinding_key = devices[0].blinding_key
if not blinding_key:
raise SpecterError(
@ -68,14 +44,13 @@ class LWallet(Wallet):
# if we don't have slip77 key for a device or it is multisig
# we use chaincodes to generate slip77 key.
if not blinding_key:
desc = LDescriptor.from_string(recv_descriptor)
# For now we use sha256(b"blinding_key", xor(chaincodes)) as a blinding key
# where chaincodes are corresponding to xpub of the first receiving address.
# It's not a standard but we use that until musig(blinding_xpubs) is implemented.
# Chaincodes of the first address are not used anywhere else so they can be used
# as a source for the blinding keys. They are also independent of the xpub's origin.
xor = bytearray(32)
desc_keys = desc.derive(0).keys
desc_keys = btcdescriptor.derive(0, branch_index=0).keys
for k in desc_keys:
if k.is_extended:
chaincode = k.key.chain_code
@ -83,81 +58,21 @@ class LWallet(Wallet):
xor[i] = xor[i] ^ chaincode[i]
secret = hashlib.sha256(b"blinding_key" + bytes(xor)).digest()
blinding_key = ec.PrivateKey(secret).wif()
if blinding_key:
recv_descriptor = f"blinded(slip77({blinding_key}),{recv_descriptor})"
change_descriptor = f"blinded(slip77({blinding_key}),{change_descriptor})"
recv_descriptor = AddChecksum(recv_descriptor)
change_descriptor = AddChecksum(change_descriptor)
assert recv_descriptor != change_descriptor
# get Core version if we don't know it
if core_version is None:
core_version = rpc.getnetworkinfo().get("version", 0)
use_descriptors = core_version >= 209900
# v20.99 is pre-v21 Elements Core for descriptors
if use_descriptors:
# Use descriptor wallet
rpc.createwallet(os.path.join(rpc_path, alias), True, True, "", False, True)
else:
rpc.createwallet(os.path.join(rpc_path, alias), True)
wallet_rpc = rpc.wallet(os.path.join(rpc_path, alias))
# import descriptors
args = [
{
"desc": desc,
"internal": change,
"timestamp": "now",
"watchonly": True,
}
for (change, desc) in [(False, recv_descriptor), (True, change_descriptor)]
]
for arg in args:
if use_descriptors:
arg["active"] = True
else:
arg["keypool"] = True
arg["range"] = [0, cls.GAP_LIMIT]
assert args[0] != args[1]
# Descriptor wallets were introduced in v0.21.0, but upgraded nodes may
# still have legacy wallets. Use getwalletinfo to check the wallet type.
# The "keypool" for descriptor wallets is automatically refilled
if use_descriptors:
res = wallet_rpc.importdescriptors(args)
else:
res = wallet_rpc.importmulti(args, {"rescan": False})
assert all([r["success"] for r in res])
return cls(
name,
alias,
"{} of {} {}".format(sigs_required, len(keys), purposes[key_type])
if len(keys) > 1
else purposes[key_type],
addrtypes[key_type],
"",
-1,
"",
-1,
0,
0,
recv_descriptor,
change_descriptor,
keys,
devices,
sigs_required,
{},
[],
os.path.join(working_folder, "%s.json" % alias),
device_manager,
wallet_manager,
return cls.DescriptorCls.from_string(
f"blinded(slip77({blinding_key}),{str(btcdescriptor)})"
)
def derive_descriptor(self, index: int, change: bool, keep_xpubs=False):
"""
For derived descriptor for individual address we remove blinding key
as it is used in HWI calls that doesn't support blinding descriptors yet.
TODO: handle blinding keys in HWI
"""
desc = super().derive_descriptor(index, change, keep_xpubs)
desc.blinding_key = None
return desc
def getdata(self):
self.fetch_transactions()
self.check_utxo()
@ -246,8 +161,7 @@ class LWallet(Wallet):
fee_rate: float = 1.0,
selected_coins=[],
readonly=False,
rbf=True,
existing_psbt=None,
rbf=False,
rbf_edit_mode=False,
assets=None,
):
@ -257,6 +171,9 @@ class LWallet(Wallet):
if fee_rate > 0 and fee_rate < self.MIN_FEE_RATE:
fee_rate = self.MIN_FEE_RATE
if not assets:
raise SpecterError("Missing assets information")
options = {"includeWatching": True, "replaceable": rbf}
extra_inputs = []
# get change addresses for all assets + for LBTC
@ -265,163 +182,92 @@ class LWallet(Wallet):
for i in range(len(assets) + 1)
]
if not existing_psbt:
# if not rbf_edit_mode:
# if self.full_available_balance < sum(amounts):
# raise SpecterError(
# "The wallet does not have sufficient funds to make the transaction."
# )
if selected_coins:
extra_inputs = selected_coins
# if selected_coins != []:
# still_needed = sum(amounts)
# for coin in selected_coins:
# coin_txid = coin.split(",")[0]
# coin_vout = int(coin.split(",")[1])
# coin_amount = self.gettransaction(coin_txid, decode=True)["vout"][
# coin_vout
# ]["value"]
# extra_inputs.append({"txid": coin_txid, "vout": coin_vout})
# still_needed -= coin_amount
# if still_needed < 0:
# break
# if still_needed > 0:
# raise SpecterError(
# "Selected coins does not cover Full amount! Please select more coins!"
# )
# elif self.available_balance["trusted"] <= sum(amounts):
# txlist = self.rpc.listunspent(0, 0)
# b = sum(amounts) - self.available_balance["trusted"]
# for tx in txlist:
# extra_inputs.append({"txid": tx["txid"], "vout": tx["vout"]})
# b -= tx["amount"]
# if b < 0:
# break
# subtract fee from amount of this output:
# currently only one address is supported, so either
# empty array (subtract from change) or [0]
subtract_arr = [subtract_from] if subtract else []
# subtract fee from amount of this output:
# currently only one address is supported, so either
# empty array (subtract from change) or [0]
subtract_arr = [subtract_from] if subtract else []
options = {
"includeWatching": True,
# FIXME: get back change addresses
# "changeAddress": self.change_address,
"subtractFeeFromOutputs": subtract_arr,
"replaceable": rbf,
"changeAddresses": change_addresses, # not supported by Elements - custom field for out LiquidRPC
}
# 209900 is pre-v21 for Elements Core
if self.manager.bitcoin_core_version_raw >= 209900:
options["add_inputs"] = selected_coins == []
if fee_rate > 0:
# bitcoin core needs us to convert sat/B to BTC/kB
options["feeRate"] = round((fee_rate * 1000) / 1e8, 8)
# looks like change_type is required in nested segwit wallets
# but not in native segwit
if "changeAddress" not in options and self.address_type:
options["change_type"] = self.address_type
r = self.rpc.walletcreatefundedpsbt(
extra_inputs, # inputs
[
{addresses[i]: amounts[i], "asset": assets[i]}
for i in range(len(addresses))
], # output
0, # locktime
options, # options
True, # bip32-der
)
b64psbt = r["psbt"]
psbt = self.rpc.decodepsbt(b64psbt)
else:
psbt = existing_psbt
# vins from psbt v0 or v2
if "tx" in psbt:
extra_inputs = [
{"txid": tx["txid"], "vout": tx["vout"]} for tx in psbt["tx"]["vin"]
]
else:
extra_inputs = [
{"txid": inp["previous_txid"], "vout": inp["previous_vout"]}
for inp in psbt["inputs"]
]
options = {
"includeWatching": True,
# FIXME: get back change addresses
# if "changeAddress" in psbt:
# options["changeAddress"] = psbt["changeAddress"]
# if "change_type" in options:
# options.pop("change_type")
if "base64" in psbt:
b64psbt = psbt["base64"]
# "changeAddress": self.change_address,
"subtractFeeFromOutputs": subtract_arr,
"replaceable": rbf,
"changeAddresses": change_addresses, # not supported by Elements - custom field for our LiquidRPC
}
options["add_inputs"] = not selected_coins
if fee_rate > 0:
# bitcoin core needs us to convert sat/B to BTC/kB
options["feeRate"] = round((fee_rate * 1000) / 1e8, 8)
# looks like change_type is required in nested segwit wallets
# but not in native segwit
if "changeAddress" not in options and self.address_type:
options["change_type"] = self.address_type
if fee_rate > 0.0:
if not existing_psbt:
adjusted_fee_rate = self.adjust_fee(psbt, fee_rate)
options["feeRate"] = "%.8f" % round((adjusted_fee_rate * 1000) / 1e8, 8)
else:
options["feeRate"] = "%.8f" % round((fee_rate * 1000) / 1e8, 8)
try:
locktime = min([tip["height"] for tip in self.rpc.getchaintips()])
except:
locktime = 0
r = self.rpc.walletcreatefundedpsbt(
extra_inputs, # inputs
[
{addresses[i]: amounts[i], "asset": assets[i]}
for i in range(len(addresses))
], # outputs
locktime,
options, # options
True, # bip32-der
)
b64psbt = r["psbt"]
psbt = self.PSBTCls(
b64psbt,
self.descriptor,
self.network,
devices=list(zip(self.keys, self._devices)),
)
if fee_rate > 0:
# scale by which Core misses the fee rate
scale = fee_rate / psbt.fee_rate
adjusted_fee_rate = fee_rate * scale
options["feeRate"] = round((adjusted_fee_rate * 1000) / 1e8, 8)
r = self.rpc.walletcreatefundedpsbt(
extra_inputs, # inputs
[
{addresses[i]: amounts[i], "asset": assets[i]}
for i in range(len(addresses))
], # output
0, # locktime
locktime,
options, # options
True, # bip32-der
)
b64psbt = r["psbt"]
psbt = self.rpc.decodepsbt(b64psbt)
psbt["fee_rate"] = options["feeRate"]
# estimate full size
tx_full_size = ceil(
psbt["tx"]["vsize"] + len(psbt["inputs"]) * self.weight_per_input / 4
)
psbt["tx_full_size"] = tx_full_size
psbt = self.PSBTCls(
b64psbt,
self.descriptor,
self.network,
devices=list(zip(self.keys, self._devices)),
)
psbt["base64"] = b64psbt
psbt["amount"] = amounts
psbt["address"] = addresses
if assets:
psbt["asset"] = assets
psbt["time"] = time.time()
psbt["sigs_count"] = 0
if not readonly:
self.save_pending_psbt(psbt)
return psbt.to_dict()
return psbt
def canceltx(self, *args, **kwargs):
raise SpecterError("RBF is not implemented on Liquid")
def adjust_fee(self, psbt, fee_rate):
psbt_fees_sats = int(psbt.get("fees", {}).get("bitcoin", 0) * 1e8)
# TODO: handle non-blind outputs differently
num_blinded_outs = len(psbt["outputs"]) - 1
# estimate final size: add weight of inputs and outputs
# out witness weight is 4245 (from some random tx)
# commitments in blinded tx: 33 for nonce and 33 for value
commitment_size = 33 + 33
tx_full_size = ceil(
psbt["tx"]["vsize"]
+ len(psbt["inputs"]) * self.weight_per_input / 4
+ num_blinded_outs * 4245 / 4
# probably elements doesn't count commitments
+ len(psbt["inputs"]) * commitment_size
+ num_blinded_outs * commitment_size
)
return (
fee_rate
* (fee_rate / (psbt_fees_sats / psbt["tx"]["vsize"]))
* (tx_full_size / psbt["tx"]["vsize"])
)
def bumpfee(self, *args, **kwargs):
raise SpecterError("RBF is not implemented on Liquid")
def addresses_info(self, is_change):
"""Create a list of (receive or change) addresses from cache and retrieve the
@ -432,7 +278,7 @@ class LWallet(Wallet):
addresses_info = []
addresses_cache = [
v for _, v in self._addresses.items() if v.change == is_change
v for _, v in self._addresses.items() if v.change == is_change and v.is_mine
]
for addr in addresses_cache:

View file

@ -161,16 +161,11 @@ class WalletManager:
for psbt in loaded_wallet.pending_psbts:
logger.info(
"lock %s " % wallet_alias,
loaded_wallet.pending_psbts[psbt]["tx"]["vin"],
loaded_wallet.pending_psbts[psbt].utxo_dict(),
)
loaded_wallet.rpc.lockunspent(
False,
[
utxo
for utxo in loaded_wallet.pending_psbts[
psbt
]["tx"]["vin"]
],
loaded_wallet.pending_psbts[psbt].utxo_dict(),
)
if len(loaded_wallet.frozen_utxo) > 0:
loaded_wallet.rpc.lockunspent(
@ -285,7 +280,7 @@ class WalletManager:
logger.debug(f"Updating WalletManager rpc {self._rpc} with None")
self._rpc = value
def create_wallet(self, name, sigs_required, key_type, keys, devices):
def create_wallet(self, name, sigs_required, key_type, keys, devices, **kwargs):
try:
walletsindir = [
wallet["name"] for wallet in self.rpc.listwalletdir()["wallets"]
@ -314,6 +309,7 @@ class WalletManager:
keys,
devices,
self.bitcoin_core_version_raw,
**kwargs,
)
# save wallet file to disk
if w and self.working_folder is not None:

View file

@ -3,7 +3,6 @@ from flask import current_app as app
from flask import Blueprint
from jinja2 import contextfilter
from ..helpers import to_ascii20
from ..liquid.util.pset import to_canonical_pset
filters_bp = Blueprint("filters", __name__)
@ -16,6 +15,12 @@ def ascii20(context, name):
return to_ascii20(name)
@contextfilter
@filters_bp.app_template_filter("unique_len")
def unique_len(context, arr):
return len(set(arr))
@contextfilter
@filters_bp.app_template_filter("datetime")
def timedatetime(context, s):
@ -31,12 +36,6 @@ def btcamount(context, value):
return "{:,.8f}".format(value).rstrip("0").rstrip(".")
@contextfilter
@filters_bp.app_template_filter("to_canonical")
def to_canonical(context, psbt):
return to_canonical_pset(psbt)
@contextfilter
@filters_bp.app_template_filter("btc2sat")
def btc2sat(context, value):

View file

@ -29,7 +29,6 @@ from ..helpers import (
get_devices_with_keys_by_type,
get_txid,
)
from ..liquid.util.pset import to_canonical_pset
from ..key import Key
from ..persistence import delete_file
from ..rpc import RpcError
@ -186,8 +185,8 @@ def new_wallet(wallet_type):
cosigners=wallet_importer.cosigners,
unknown_cosigners=wallet_importer.unknown_cosigners,
unknown_cosigners_types=wallet_importer.unknown_cosigners_types,
sigs_required=wallet_importer.descriptor.multisig_M,
sigs_total=wallet_importer.descriptor.multisig_N,
sigs_required=wallet_importer.sigs_required,
sigs_total=wallet_importer.sigs_total,
specter=app.specter,
rand=rand,
)
@ -528,11 +527,24 @@ def send_new(wallet_alias):
rand=rand,
)
elif action == "rbf":
elif action in ["rbf", "rbf_cancel"]:
try:
rbf_tx_id = request.form["rbf_tx_id"]
rbf_fee_rate = float(request.form["rbf_fee_rate"])
psbt = wallet.bumpfee(rbf_tx_id, rbf_fee_rate)
if action == "rbf":
psbt = wallet.bumpfee(rbf_tx_id, rbf_fee_rate)
elif action == "rbf_cancel":
psbt = wallet.canceltx(rbf_tx_id, rbf_fee_rate)
else:
raise SpecterError("Invalid action")
if psbt["fee_rate"] - rbf_fee_rate > wallet.MIN_FEE_RATE / 10:
flash(
_(
"We had to increase the fee rate from {} to {} sat/vbyte"
).format(rbf_fee_rate, psbt["fee_rate"])
)
return render_template(
"wallet/send/sign/wallet_send_sign_psbt.jinja",
psbt=psbt,
@ -544,24 +556,8 @@ def send_new(wallet_alias):
)
except Exception as e:
flash(_("Failed to perform RBF. Error: {}").format(e), "error")
elif action == "rbf_cancel":
try:
rbf_tx_id = request.form["rbf_tx_id"]
rbf_fee_rate = float(request.form["rbf_fee_rate"])
psbt = wallet.canceltx(rbf_tx_id, rbf_fee_rate)
return render_template(
"wallet/send/sign/wallet_send_sign_psbt.jinja",
psbt=psbt,
labels=[],
wallet_alias=wallet_alias,
wallet=wallet,
specter=app.specter,
rand=rand,
)
except Exception as e:
flash(
_("Failed to cancel transaction with RBF. Error: {}").format(e),
"error",
return redirect(
url_for("wallets_endpoint.history", wallet_alias=wallet_alias)
)
elif action == "rbf_edit":
try:
@ -589,7 +585,8 @@ def send_new(wallet_alias):
elif action == "signhotwallet":
passphrase = request.form["passphrase"]
psbt = json.loads(request.form["psbt"])
b64psbt = wallet.pending_psbts[psbt["tx"]["txid"]]["base64"]
current_psbt = wallet.pending_psbts[psbt["tx"]["txid"]]
b64psbt = str(current_psbt)
device = request.form["device"]
if "devices_signed" not in psbt or device not in psbt["devices_signed"]:
try:
@ -597,15 +594,12 @@ def send_new(wallet_alias):
signed_psbt = app.specter.device_manager.get_by_alias(
device
).sign_psbt(b64psbt, wallet, passphrase)
raw = None
if signed_psbt["complete"]:
if "devices_signed" not in psbt:
psbt["devices_signed"] = []
psbt["devices_signed"].append(device)
psbt["sigs_count"] = len(psbt["devices_signed"])
raw = wallet.rpc.finalizepsbt(b64psbt)
if "hex" in raw:
psbt["raw"] = raw["hex"]
current_psbt.update(signed_psbt["psbt"], raw)
signed_psbt = signed_psbt["psbt"]
psbt = current_psbt.to_dict()
except Exception as e:
signed_psbt = None
flash(_("Failed to sign PSBT: {}").format(e), "error")
@ -692,13 +686,7 @@ def send_pending(wallet_alias):
specter=app.specter,
rand=rand,
)
pending_psbts = wallet.pending_psbts
######## Migration to multiple recipients format ###############
for psbt in pending_psbts:
if not isinstance(pending_psbts[psbt]["address"], list):
pending_psbts[psbt]["address"] = [pending_psbts[psbt]["address"]]
pending_psbts[psbt]["amount"] = [pending_psbts[psbt]["amount"]]
###############################################################
pending_psbts = wallet.pending_psbts_dict()
return render_template(
"wallet/send/pending/wallet_sendpending.jinja",
pending_psbts=pending_psbts,
@ -910,9 +898,6 @@ def combine(wallet_alias):
return e.error_msg, e.status_code
except Exception as e:
return _("Unknown error: {}").format(e), 500
if "psbt" in raw:
# returned psbt should be valid for Bitcoin or Elements Core decoding
raw["psbt"] = to_canonical_pset(raw["psbt"])
return json.dumps(raw)
@ -1672,7 +1657,12 @@ def process_txlist(txlist, idx=0, limit=100, search=None, sortby=None, sortdir="
if app.specter.is_liquid:
for tx in txlist:
if "asset" in tx:
tx["assetlabel"] = app.specter.asset_label(tx["asset"])
if isinstance(tx["asset"], list):
tx["assetlabel"] = [
app.specter.asset_label(asset) for asset in tx["asset"]
]
else:
tx["assetlabel"] = app.specter.asset_label(tx["asset"])
return {"txlist": json.dumps(txlist), "pageCount": page_count}

View file

@ -0,0 +1,9 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="receive" transform="translate(7.000000, 7.000000)">
<path d="M0,12.2436 L0,15.6872 C0,16.1098 0.3426,16.4524 0.76523,16.4524 L15.3045,16.4524 C15.7272,16.4524 16.0698,16.1098 16.0698,15.6872 L16.0698,12.2436" id="Path" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M5.8872,11.2435 C6.1801,11.5364 6.6549,11.5364 6.9478,11.2435 C7.09335115,11.0704319 7.16612673,10.8927177 7.16612673,10.7103574 C7.16612673,10.5279972 6.91658449,10.175378 6.4175,9.6525 L2.1749,5.4099 C1.882,5.117 1.4071,5.117 1.1142,5.4099 C0.8213,5.7028 0.8213,6.1776 1.1142,6.4705 L5.8872,11.2435 Z M5.6675,0 L5.6675,10.7132 L7.1675,10.7132 L7.1675,0 L5.6675,0 Z" id="Shape" fill="#000000" fill-rule="nonzero"></path>
<path d="M13.8872,11.2435 C14.1801,11.5364 14.6549,11.5364 14.9478,11.2435 C15.0933512,11.0704319 15.1661267,10.8927177 15.1661267,10.7103574 C15.1661267,10.5279972 14.9165845,10.175378 14.4175,9.6525 L10.1749,5.4099 C9.882,5.117 9.4071,5.117 9.1142,5.4099 C8.8213,5.7028 8.8213,6.1776 9.1142,6.4705 L13.8872,11.2435 Z M13.6675,0 L13.6675,10.7132 L15.1675,10.7132 L15.1675,0 L13.6675,0 Z" id="Shape" fill="#000000" fill-rule="nonzero" transform="translate(12.031013, 5.731587) scale(-1, -1) translate(-12.031013, -5.731587) "></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -70,6 +70,16 @@
async connectedCallback() {
this.asset = this.getAttribute('data-asset');
this.labelValue = this.getAttribute('data-label');
// edit-mode can be 'hover', 'disabled', 'enabled'
this.mode = this.getAttribute('edit-mode');
// we don't know the asset
if( (this.asset == null) ||
(this.asset == "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") ||
(this.asset == "0000000000000000000000000000000000000000000000000000000000000000")){
this.asset = "Confidential";
this.labelValue = "Confidential";
this.mode = "disabled";
}
// Set the label - fetch if not specified
if (this.labelValue) {
@ -78,8 +88,6 @@
await this.fetchAssetLabel();
}
// edit-mode can be 'hover', 'disabled', 'enabled'
this.mode = this.getAttribute('edit-mode');
if(!this.mode){
this.mode = 'hover';
}

View file

@ -9,7 +9,7 @@
.svg-receive, .svg-immature, .svg-generate {
filter: invert(61%) sepia(48%) saturate(3609%) hue-rotate(189deg) brightness(91%) contrast(94%);
}
.svg-selftransfer {
.svg-selftransfer, .svg-mixed {
filter: invert(77%) sepia(68%) saturate(3384%) hue-rotate(44deg) brightness(112%) contrast(74%);
}
.svg-cancelled {
@ -145,12 +145,25 @@
this.amount = parseInt(this.amount * 1e8);
}
if (this.amount == 1e-8) {
if (this.amount == 1e-8 || this.amount == 0) {
this.amountText.innerText = '{{ _("Confidential") }}';
} else {
this.amountText.innerHTML = this.hideSensitiveInfo ? '#########' : `${numberWithCommas(this.amount.toString())}`;
if("assetlabel" in this.tx){
this.amountText.innerHTML += ` <asset-label data-asset="${this.tx.asset}" data-label="${this.tx.assetlabel}"></asset-label>`;
if(this.hideSensitiveInfo){
this.amountText.innerHTML = '#########';
}else{
this.amountText.innerHTML = `${numberWithCommas(this.amount.toString())}`;
if("assetlabel" in this.tx){ // liquid
if (Array.isArray(this.tx.assetlabel)) { // multiple assets
let unique_assets = this.tx.assetlabel.filter((v, i, a) => { return a.indexOf(v) === i });
if (unique_assets.length == 1){
this.amountText.innerHTML += ` <asset-label data-asset="${this.tx.asset[0]}" data-label="${this.tx.assetlabel[0]}"></asset-label>`;
}else{
this.amountText.innerHTML = `${unique_assets.length} assets`;
}
}else{
this.amountText.innerHTML += ` <asset-label data-asset="${this.tx.asset}" data-label="${this.tx.assetlabel}"></asset-label>`;
}
}
}
}
@ -230,11 +243,8 @@
if (newFee <= 1.02) {
newFee = 1;
}
if (rbfType == 'cancel') {
newFee *= 1.5; // Tx likely to have lower size so will need higher fee
} else {
newFee += 2;
}
// TODO: get fee diff from specter
newFee += 2;
txDataPopup.innerHTML = `
<form action="${url}" method="POST">
<h1>${rbfType == 'cancel' ? '{{ _("Cancel") }}' : '{{ _("Speed up") }}'} {{ _("the Transaction") }}</h1>
@ -277,11 +287,12 @@
return `{{ url_for('static', filename='img') }}/send.svg`;
case "receive":
case "immature":
return `{{ url_for('static', filename='img') }}/receive.svg`;
case "generate":
return `{{ url_for('static', filename='img') }}/generate_icon.svg`;
return `{{ url_for('static', filename='img') }}/receive.svg`;
case "selftransfer":
return `{{ url_for('static', filename='img') }}/transfer.svg`;
case "mixed":
return `{{ url_for('static', filename='img') }}/mixed.svg`;
}
}
}

View file

@ -18,7 +18,15 @@
{% endif %}
</td>
<td>
{% if specter.is_liquid %}
{% if pending_psbt['asset'] | unique_len == 1 %}
{{ (pending_psbt['amount'] | sum) | btcunitamount }} <asset-label data-asset="{{pending_psbt['asset'][0]}}" data-label="{{pending_psbt['asset'][0] | assetlabel}}"></asset-label>
{% else %}
{{ pending_psbt['asset'] | unique_len }} assets
{% endif %}
{% else %}
{{ (pending_psbt['amount'] | sum) | btcunitamount }}{% if specter.price_check %}<span class="note">&nbsp;({{ (pending_psbt['amount'] | sum) | altunit }})</span>{% endif %}
{% endif %}
</td>
<td class="optional">
{{ pending_psbt['time'] | datetime }}

View file

@ -127,48 +127,15 @@
<b>{{ _("Inputs count:") }}</b> {{ psbt['tx']['vin'] | length }}<br>
<b>{{ _("Outputs count:") }}</b> {{ psbt['tx']['vout'] | length }}
</p>
<h2 class="tx_details_header">{{ _("Inputs") }} ({{psbt['tx']['vin'] | length}})</h2>
{% for input in psbt['tx']['vin'] %}
{% set tx = wallet.gettransaction(input['txid'], decode=true) %}
<h2 class="tx_details_header">{{ _("Inputs") }} ({{psbt['inputs'] | length}})</h2>
{% for input in psbt['inputs'] %}
{% set bg_color = '#131a24' %}
{% if tx and tx.vout and tx.vout|length > input['vout'] and 'addresses' in tx.vout[input['vout']] and tx.vout[input['vout']].addresses|length > 0 %}
{% set address = tx.vout[input['vout']].addresses[0] %}
{% set bg_color = '#925d07' if wallet.is_address_mine(address) else bg_color %}
{% endif %}
{% set address=input['address'] %}
{% set bg_color = '#925d07' if wallet.is_address_mine(address) else bg_color %}
<p class="tx_info" style="text-align: left; background-color: {{ bg_color }};">
<b style="margin: auto;">Input #{{loop.index0}}</b><br><br>
<b>{{ _("Transaction id:") }}</b><br>
{{ explorer_link('tx', input['txid'], input['txid'], specter.explorer) }}
({{ _("Output") }} #{{ input['vout'] }})<br>
{% if tx %}
{% if address %}
{% set addr_label = wallet.getlabel(address) %}
<b>{{ _("Address:") }}</b>
{{ address }}
<br>
{% if addr_label != address %}
<b>{{ _("Label:") }}</b>
<address-label data-address="{{ address }}" data-label="{{ addr_label }}" data-wallet="{{ wallet_alias }}"></address-label>
<br>
{% endif %}
{% endif %}
{% if specter.is_liquid %}
{% set amount = psbt['inputs'][loop.index0].get('value', -1) %}
{% set asset = psbt['inputs'][loop.index0].get('asset', "") %}
<b>{{ _("Amount:") }}</b> {{ amount|btcamount }} <asset-label data-asset="{{asset}}" data-label="{{asset | assetlabel}}"></asset-label>
{% else %}
{% set amount = psbt['inputs'][loop.index0].get('witness_utxo',{}).get('amount', -1) %}
<b>{{ _("Amount:") }}</b> {{ amount|btcamount }} BTC {% if specter.price_check %}<span class="note">&nbsp;({{ amount | altunit }})</span>{% endif %}
{% endif %}
{% endif %}
</p>
{% endfor %}
<h2 class="tx_details_header">{{ _("Outputs") }} ({{psbt['tx']['vout']|length}})</h2>
{% for output in psbt['tx']['vout'] if output['scriptPubKey']['type'] != 'fee' %}
{% set address = output['scriptPubKey']['addresses'][0] if "addresses" in output["scriptPubKey"] else output["scriptPubKey"]["address"] %}
{% set bg_color = '#154984' if wallet.is_address_mine(address) else '#131a24' %}
<p class="tx_info" style="text-align: left; background-color: {{ bg_color }};">
<b>{{ _("Output") }} #{{loop.index0}}</b> {% if address not in psbt['address'] %}(Change){% endif %}<br><br>
{{ explorer_link('tx', input['txid'], input['txid'], specter.explorer) }} : {{ input['vout'] }}<br>
{% set addr_label = wallet.getlabel(address) %}
<b>{{ _("Address:") }}</b>
{{ address }}
@ -178,18 +145,44 @@
<address-label data-address="{{ address }}" data-label="{{ addr_label }}" data-wallet="{{ wallet_alias }}"></address-label>
<br>
{% endif %}
<b>{{ _("Amount:") }}</b> {{ output['value']|btcamount }} BTC {% if specter.price_check %}<span class="note">&nbsp;({{ output['value'] | altunit }})</span>{% endif %}
{% if specter.is_liquid %}
<b>{{ _("Amount:") }}</b> {{ input['float_amount']|btcamount }} <asset-label data-asset="{{input['asset']}}" data-label="{{input['asset'] | assetlabel}}"></asset-label>
{% else %}
<b>{{ _("Amount:") }}</b> {{ input['float_amount']|btcamount }} BTC {% if specter.price_check %}<span class="note">&nbsp;({{ input['float_amount'] | altunit }})</span>{% endif %}
{% endif %}
</p>
{% endfor %}
{{ _("Raw PSBT:") }}<textarea id="raw-psbt" disabled style="background-color: #131a24;">{{ psbt['base64'] | to_canonical }}</textarea>
<h2 class="tx_details_header">{{ _("Outputs") }} ({{psbt['outputs']|length}})</h2>
{% for output in psbt['outputs'] %}
{% set address = output['address'] %}
{% set bg_color = '#154984' if output['is_mine'] else '#131a24' %}
<p class="tx_info" style="text-align: left; background-color: {{ bg_color }};">
<b>{{ _("Output") }} #{{loop.index0}}</b> {% if output['is_change'] %}(Change){% endif %}<br><br>
{% set addr_label = wallet.getlabel(address) %}
<b>{{ _("Address:") }}</b>
{{ address }}
<br>
{% if addr_label != address %}
<b>{{ _("Label:") }}</b>
<address-label data-address="{{ address }}" data-label="{{ addr_label }}" data-wallet="{{ wallet_alias }}"></address-label>
<br>
{% endif %}
{% if specter.is_liquid %}
<b>{{ _("Amount:") }}</b> {{ output['float_amount']|btcamount }} <asset-label data-asset="{{output['asset']}}" data-label="{{output['asset'] | assetlabel}}"></asset-label>
{% else %}
<b>{{ _("Amount:") }}</b> {{ output['float_amount']|btcamount }} BTC {% if specter.price_check %}<span class="note">&nbsp;({{ output['float_amount'] | altunit }})</span>{% endif %}
{% endif %}
</p>
{% endfor %}
{{ _("Raw PSBT:") }}<textarea id="raw-psbt" disabled style="background-color: #131a24;">{{ psbt['base64'] }}</textarea>
<div class="row break-row-mobile" style="margin:auto">
<a id="download-psbt-btn" class="btn"
download="binary_{{ psbt['tx']['hash'] }}.psbt" href="data:application/octet-stream;base64;content-disposition=attachment,{{ psbt['base64'] | to_canonical }}">
download="binary_{{ psbt['tx']['hash'] }}.psbt" href="data:application/octet-stream;base64;content-disposition=attachment,{{ psbt['base64'] }}">
<img src="{{ url_for('static', filename='img/file.svg') }}" style="width: 26px; margin: 0px;" class="svg-white">
{{ _("Save binary") }}
</a>&nbsp;
<a id="download-psbt-btn" class="btn"
download="base64_{{ psbt['tx']['hash'] }}.psbt" href="data:text/plain;content-disposition=attachment,{{ psbt['base64'] | to_canonical }}">
download="base64_{{ psbt['tx']['hash'] }}.psbt" href="data:text/plain;content-disposition=attachment,{{ psbt['base64'] }}">
<img src="{{ url_for('static', filename='img/file.svg') }}" style="width: 26px; margin: 0px;" class="svg-white">
{{ _("Save base64") }}
</a>&nbsp;
@ -208,7 +201,8 @@
<div id="signing_container" class="signing_container flex-column {% if psbt['raw'] %}hidden{% endif %}" style="text-align: center;">
<p style="margin-bottom: 15px; margin-top: 0px;">{{ _("Sign transaction with your:") }}</p>
{% for device in wallet.devices %}
<button type="button" class="btn signing-column-btn" id="{{ device.alias }}_tx_sign_btn" {{ 'disabled style=background-color:#303c49;' if device.alias in psbt.get("devices_signed",[]) else '' }}>
{% set device_signed = (device.alias in psbt.get("devices_signed")) %}
<button type="button" class="btn signing-column-btn" id="{{ device.alias }}_tx_sign_btn" {% if device_signed %} disabled style="background-color:#303c49;" {% endif %}>
{{ device.name }} {% if device.alias in psbt.get('devices_signed',[]) %} (&#10004;) {% endif %}
</button>
{% endfor %}
@ -244,7 +238,7 @@
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="row" style="min-height: 400px;">
<span style="margin: auto;" id="raw-psbt-qr-holder">
<qr-code id="raw-psbt-qr" class='center' value="{{ psbt['base64'] | to_canonical }}" width="400" scalable></qr-code>
<qr-code id="raw-psbt-qr" class='center' value="{{ psbt['base64'] }}" width="400" scalable></qr-code>
</span>
</div>
</form>

View file

@ -1,14 +1,18 @@
"""
Manages the list of transactions for the wallet
"""
from typing import Union
import os
from .persistence import write_csv, read_csv
from .helpers import get_address_from_dict
from embit.transaction import Transaction
from embit.liquid.networks import get_network
from embit import bip32
import json
import math
import logging
from .util.tx import decoderawtransaction
from .util.psbt import SpecterTx, AbstractTxContext, SpecterPSBT
logger = logging.getLogger(__name__)
@ -22,7 +26,21 @@ def parse_arr(v):
return v
class TxItem(dict):
class AbstractTxListContext(AbstractTxContext):
@property
def rpc(self):
if hasattr(self, "parent"):
return self.parent.rpc
raise NotImplementedError("Implement this!")
@property
def chain(self) -> str:
if hasattr(self, "parent"):
return self.parent.chain
raise NotImplementedError("Implement this!")
class TxItem(dict, AbstractTxListContext):
TransactionCls = Transaction
columns = [
"txid", # str, txid in hex
@ -51,11 +69,10 @@ class TxItem(dict):
bool,
]
def __init__(self, rpc, addresses, rawdir, chain, **kwargs):
self.rpc = rpc
def __init__(self, parent, addresses, rawdir, **kwargs):
self.parent = parent
self._addresses = addresses
self.rawdir = rawdir
self.chain = chain
# copy
kwargs = dict(**kwargs)
# replace with None or convert
@ -66,8 +83,11 @@ class TxItem(dict):
super().__init__(**kwargs)
self._tx = None
# if we have hex data
if kwargs.get("hex"):
if "hex" in kwargs:
self._tx = self.TransactionCls.from_string(kwargs["hex"])
# conflicts were renamed to walletconflicts
if "walletconflicts" in kwargs:
self["conflicts"] = kwargs["walletconflicts"]
@property
def fname(self):
@ -99,16 +119,44 @@ class TxItem(dict):
logger.error(e)
return self._tx
@property
def vsize(self):
if self.get("vsize"):
return self["vsize"]
tx = self.tx
txsize = len(tx.serialize())
if tx.is_segwit:
# tx size - flag - marker - witness
non_witness_size = (
txsize - 2 - sum([len(inp.witness.serialize()) for inp in tx.vin])
)
witness_size = txsize - non_witness_size
weight = non_witness_size * 4 + witness_size
vsize = math.ceil(weight / 4)
else:
vsize = txsize
weight = txsize * 4
return vsize
def dump(self):
"""Dumps transaction in binary to the folder if it's not there"""
# nothing to do if file exists or we don't have binary tx
if os.path.isfile(self.fname) or not self._tx:
return
# create dir if it doesn't exist
if not os.path.isdir(self.rawdir):
os.mkdir(self.rawdir)
with open(self.fname, "wb") as f:
self.tx.write_to(f)
# Try to create a directory if it's not there
# and write raw tx to file
# Can fail if multiple threads create the same dir
try:
# create dir if it doesn't exist
if not os.path.isdir(self.rawdir):
os.mkdir(self.rawdir)
except Exception as e:
logger.error(e)
try:
with open(self.fname, "wb") as f:
self.tx.write_to(f)
except Exception as e:
logger.error(e)
# clear cached tx as we saved the transaction to file
self._tx = None
@ -142,15 +190,15 @@ class TxItem(dict):
}
class TxList(dict):
class TxList(dict, AbstractTxListContext):
ItemCls = TxItem # for inheritance
PSBTCls = SpecterPSBT
def __init__(self, path, rpc, addresses, chain):
self.chain = chain
def __init__(self, path, parent, addresses):
self.parent = parent
self.path = path
# folder to store transactions in binary form
self.rawdir = path.replace(".csv", "_raw")
self.rpc = rpc
self._addresses = addresses
txs = []
file_exists = False
@ -159,10 +207,9 @@ class TxList(dict):
txs = read_csv(
self.path,
self.ItemCls,
self.rpc,
self,
self._addresses,
self.rawdir,
self.chain,
)
for tx in txs:
self[tx.txid] = tx
@ -181,6 +228,18 @@ class TxList(dict):
write_csv(self.path, list(self.values()), self.ItemCls)
self._file_exists = True
def getfetch(self, txid):
"""
Returns TxItem instance if it is known,
otherwise tries to get it from rpc, adds to self and returns TxItem
"""
if txid not in self:
tx = self.rpc.gettransaction(txid)
if "time" not in tx:
tx["time"] = tx["timereceived"]
self.add({txid: tx})
return self[txid]
def gettransaction(self, txid, blockheight=None, decode=False, full=True):
"""
Will ask Bitcoin Core for a transaction if blockheight is None or txid not known
@ -207,8 +266,8 @@ class TxList(dict):
res.update(self.decoderawtransaction(tx.hex))
return res
def decoderawtransaction(self, txhex):
return decoderawtransaction(txhex, self.chain)
def decoderawtransaction(self, tx: Union[Transaction, str, bytes]):
return SpecterTx(self, tx).to_dict()
def add(self, txs):
"""
@ -247,9 +306,7 @@ class TxList(dict):
"bip125-replaceable": tx.get("bip125-replaceable", "no"),
"hex": tx.get("hex", None),
}
txitem = self.ItemCls(
self.rpc, self._addresses, self.rawdir, self.chain, **obj
)
txitem = self.ItemCls(self, self._addresses, self.rawdir, **obj)
self[txid] = txitem
if txitem.tx:
for vout in txitem.tx.vout:
@ -265,97 +322,68 @@ class TxList(dict):
self.fill_missing(tx)
self.save()
def fill_missing(self, tx):
raw_tx = self.decoderawtransaction(tx.hex)
tx["vsize"] = raw_tx["vsize"]
category = ""
addresses = []
amounts = {}
inputs_mine_count = 0
for vin in raw_tx["vin"]:
# coinbase tx
if (
vin["txid"]
== "0000000000000000000000000000000000000000000000000000000000000000"
):
category = "generate"
break
if vin["txid"] in self:
try:
address = get_address_from_dict(
self.decoderawtransaction(self[vin["txid"]].hex)["vout"][
vin["vout"]
]
)
address_info = self._addresses.get(address, None)
if address_info and not address_info.is_external:
inputs_mine_count += 1
except Exception as e:
logger.error(e)
continue
outputs_mine_count = 0
for out in raw_tx["vout"]:
try:
address = get_address_from_dict(out)
except Exception as e:
# couldn't get address...
logger.error(e)
continue
address_info = self._addresses.get(address)
if address_info and not address_info.is_external:
outputs_mine_count += 1
addresses.append(address)
amounts[address] = out.get("value", 0)
if inputs_mine_count:
if outputs_mine_count == len(raw_tx["vout"]):
category = "selftransfer"
# remove change addresses from the dest list
addresses2 = [
address
for address in addresses
if self._addresses.get(address, None)
and not self._addresses[address].change
]
# use new list only if it's not empty
if addresses2:
addresses = addresses2
else:
category = "send"
addresses = [
address
for address in addresses
if not self._addresses.get(address, None)
or self._addresses[address].is_external
]
else:
if not category:
category = "receive"
addresses = [
address
for address in addresses
if self._addresses.get(address, None)
and not self._addresses[address].is_external
]
amounts = [amounts[address] for address in addresses]
def _update_destinations(self, tx, outs):
addresses = [out.get("address", "Unknown") for out in outs]
amounts = [out["float_amount"] for out in outs]
if len(addresses) == 1:
addresses = addresses[0]
amounts = amounts[0]
tx["category"] = category
tx["address"] = addresses
tx["amount"] = amounts
if not addresses:
tx["ismine"] = False
def _get_psbt(self, raw_tx):
psbt = self.PSBTCls.from_transaction(raw_tx, self.descriptor, self.network)
# fill derivation paths etc
updated = self.rpc.walletprocesspsbt(str(psbt), False).get("psbt", None)
if updated:
psbt.update(updated)
return psbt
def fill_missing(self, tx):
raw_tx = tx.tx
psbt = self._get_psbt(raw_tx)
# detect category
category = "mixed"
# calculate everything once
inputs = [inp.to_dict() for inp in psbt.inputs]
outputs = [out.to_dict() for out in psbt.outputs]
all_inputs_mine = all([inp["is_mine"] for inp in inputs])
all_outputs_mine = all([out["is_mine"] for out in outputs])
all_inputs_external = not any([inp["is_mine"] for inp in inputs])
if b"\x00" * 32 in [vin.txid for vin in raw_tx.vin]:
category = "generate"
elif all_inputs_mine and all_outputs_mine:
category = "selftransfer"
elif all_inputs_external:
category = "receive"
elif all_inputs_mine:
category = "send"
all_outs = [out for out in outputs]
my_outs = [out for out in all_outs if out["is_mine"]]
my_receiving = [out for out in my_outs if not out["change"]]
external = [out for out in all_outs if out not in my_outs]
# decide what addresses to show
if category in ["generate", "receive", "selftransfer"]:
# either receiving only (if not empty), or only mine (if not empty), or all
outs = my_receiving or my_outs or all_outs
elif category in ["send"]:
# keep only external addresses if they are present
outs = external or all_outs
else:
tx["ismine"] = True
# not sure what's the best here
outs = my_receiving or my_outs or external or all_outs
self._update_destinations(tx, outs)
tx["category"] = category
# at least one input or output is ours - tx is ours
tx["ismine"] = any(scope["is_mine"] for scope in (inputs + outputs))
def load(self, arr):
"""
TODO: load transactions from backup
Load transactions to Core with merkle proofs to avoid rescan
arr should be a dict with dicts:
"<txid>": {

View file

@ -0,0 +1,451 @@
"""
The goal of this module is to slowly migrate from json-like representation of PSBT received from Bitcoin RPC
to a normal PSBT class that does not require RPC calls and can do more things.
to_dict and from_dict methods are maintained for backward-compatibility
"""
from cryptoadvance.specter.key import Key
from embit.psbt import PSBT, InputScope, OutputScope, DerivationPath
from embit.transaction import Transaction, TransactionOutput, TransactionInput
from embit.liquid.networks import get_network
from embit import bip32
from embit.descriptor import Descriptor
from math import ceil
import time
from typing import Union, Tuple, List
class AbstractTxContext:
"""Class inherited from this one must have the following properties:
- self.network : dict with network constants (see embit.networks)
- self.descriptor : Descriptor class that can check if it owns a PSBT scope or not
Allows to pass context Wallet -> SpecterPSBT -> SpecterScope -> SpecterTx
"""
@property
def network(self) -> dict:
if hasattr(self, "parent"):
return self.parent.network
raise NotImplementedError("Implement this!")
@property
def descriptor(self) -> Descriptor:
if hasattr(self, "parent"):
return self.parent.descriptor
raise NotImplementedError("Implement this!")
class SpecterTx(AbstractTxContext):
TxCls = Transaction
def __init__(self, parent: AbstractTxContext, tx: Union[Transaction, str, bytes]):
self.parent = parent
if isinstance(tx, str):
tx = self.TxCls.from_string(tx)
elif isinstance(tx, bytes):
tx = self.TxCls.parse(tx)
self.tx = tx
def vin_to_dict(self, vin: TransactionInput) -> dict:
return {
"txid": vin.txid.hex(),
"vout": vin.vout,
"sequence": vin.sequence,
}
def vout_to_dict(self, vout: TransactionOutput) -> dict:
i = self.tx.vout.index(vout)
obj = {
"value": round(1e-8 * vout.value, 8),
"sats": vout.value,
"n": i,
"scriptPubKey": {
"hex": vout.script_pubkey.data.hex(),
},
}
try:
if scope.script_pubkey.data.startswith(b"\x6a"):
obj["scriptPubKey"]["addresses"] = [
"OP_RETURN " + scope.script_pubkey.data.hex()
]
else:
obj["scriptPubKey"]["addresses"] = [
vout.script_pubkey.address(self.network)
]
except:
pass
return obj
def to_dict(self) -> dict:
txid = self.tx.txid().hex()
size = len(self.tx.serialize())
return {
"txid": txid,
"hash": txid, # not sure why it's the same
"version": self.tx.version,
"size": size,
"vsize": size,
"weight": 4 * size,
"locktime": self.tx.locktime,
"vin": [self.vin_to_dict(vin) for vin in self.tx.vin],
"vout": [self.vout_to_dict(vout) for vout in self.tx.vout],
}
class SpecterScope(AbstractTxContext):
def __init__(
self, parent: AbstractTxContext, scope: Union[InputScope, OutputScope]
):
self.parent = parent
self.scope = scope
@property
def is_mine(self) -> bool:
return self.descriptor.owns(self.scope)
@property
def is_change(self) -> bool:
"""Returns True only if the scope belongs to change descriptor (branch 1)"""
return self.descriptor.branch(1).owns(self.scope)
@property
def is_receiving(self) -> bool:
return self.is_mine and not self.is_change
@property
def address(self) -> str:
try:
if self.scope.script_pubkey.data.startswith(b"\x6a"):
return "OP_RETURN " + self.scope.script_pubkey.data.hex()
else:
return self.scope.script_pubkey.address(self.network)
except:
return None
@property
def sat_amount(self) -> int:
"""Implement this!"""
raise NotImplementedError("Not implemented for this scope")
@property
def float_amount(self) -> float:
return round(self.sat_amount * 1e-8, 8)
def to_dict(self) -> dict:
addr = self.address
try:
sats = self.sat_amount
except:
sats = None
obj = {
"change": self.is_change,
"is_mine": self.is_mine,
}
if addr:
obj["address"] = addr
if sats is not None:
obj.update(
{
"float_amount": round(sats * 1e-8, 8),
"sat_amount": sats,
}
)
if self.scope.bip32_derivations:
obj["bip32_derivs"] = [
{
"pubkey": pub.sec().hex(),
"master_fingerprint": der.fingerprint.hex(),
"path": bip32.path_to_str(der.derivation),
}
for pub, der in self.scope.bip32_derivations.items()
]
return obj
class SpecterInputScope(SpecterScope):
TxCls = SpecterTx
@property
def inp(self) -> InputScope:
return self.scope
@property
def sat_amount(self) -> int:
return self.scope.utxo.value
@property
def txid(self) -> bytes:
return self.scope.txid
@property
def vout(self) -> int:
return self.scope.vout
def to_dict(self) -> dict:
obj = super().to_dict()
obj.update(
{
"txid": self.scope.txid.hex(),
"vout": self.scope.vout,
}
)
if self.scope.witness_utxo:
obj["witness_utxo"] = {
"amount": self.float_amount,
"sats": self.sat_amount,
"scriptPubKey": {
"hex": self.scope.script_pubkey.data.hex(),
"addresses": [self.address],
},
}
else:
obj["non_witness_utxo"] = self.TxCls(self, self.scope.non_witness_utxo)
return obj
class SpecterOutputScope(SpecterScope):
@property
def out(self) -> OutputScope:
return self.scope
@property
def sat_amount(self) -> int:
return self.out.value
class SpecterPSBT(AbstractTxContext):
"""Specter's PSBT class with some handy functions"""
PSBTCls = PSBT
InputCls = SpecterInputScope
OutputCls = SpecterOutputScope
TxCls = SpecterTx
def __init__(
self,
psbt: Union[str, PSBT],
descriptor: Descriptor,
network: dict,
raw: Union[None, str] = None,
devices: List[Tuple[Key, str]] = [], # list of tuples: (Key, device_alias)
**kwargs
):
"""
kwargs can contain:
- "time" - creation time of the transaction, time.time() is used if missing,
- other keys in kwargs are dropped
"""
if isinstance(psbt, str):
psbt = self.PSBTCls.from_string(psbt)
self.psbt = psbt
self._descriptor = descriptor
self._network = network
self.devices = devices
self.raw = bytes.fromhex(raw) if raw else None
self.time = kwargs.get("time", time.time())
@property
def network(self) -> dict:
return self._network
@property
def descriptor(self) -> Descriptor:
return self._descriptor
def update(self, b64psbt: str, raw: dict = {}) -> None:
"""
b64psbt - PSBT transaction with some extra data that we should take
raw dict can contain "hex" key with hex string of the finalized transaction. Or not.
"""
if raw and "hex" in raw:
self.raw = bytes.fromhex(raw["hex"])
if not b64psbt:
return
psbt = self.PSBTCls.from_string(b64psbt)
for inp1, inp2 in zip(self.psbt.inputs, psbt.inputs):
inp1.update(inp2)
for out1, out2 in zip(self.psbt.outputs, psbt.outputs):
out1.update(out2)
def utxo_dict(self) -> dict:
return [{"txid": inp.txid.hex(), "vout": inp.vout} for inp in self.psbt.inputs]
@property
def extra_input_weight(self) -> int:
redeem_script = self.descriptor.redeem_script()
witness_script = self.descriptor.witness_script()
weight = 0
if redeem_script:
weight += len(redeem_script.data) * 4
if witness_script:
weight += (
len(witness_script.data) + 2
) # number of items in witness + script length
if self.descriptor.is_basic_multisig:
threshold = self.descriptor.miniscript.args[0].num
num_keys = len(self.descriptor.keys)
weight += num_keys * 34
weight += threshold * 75
else:
# pubkey, signature
weight += 75 + 34
return weight
@property
def full_size(self) -> int:
weight = len(self.psbt.tx.serialize()) * 4 + 4 # marker will be added
weight += len(self.inputs) * self.extra_input_weight
return ceil(weight / 4)
@property
def fee(self) -> int:
return self.psbt.fee()
@property
def fee_rate(self) -> float:
return self.fee / self.full_size
@property
def threshold(self) -> int:
if self.descriptor.is_basic_multisig:
return self.descriptor.miniscript.args[0].num
return 1
@property
def sigs_count(self) -> int:
# everything is signed if final witness is there or
if self.raw or any([inp.final_scriptwitness for inp in self.psbt.inputs]):
return self.threshold
# not quite true but ok for most common cases
return max([len(inp.partial_sigs) for inp in self.psbt.inputs])
def should_display(self, out: SpecterOutputScope) -> bool:
"""Checks if this output should be displayed"""
return not self.descriptor.branch(1).owns(out.scope)
@property
def addresses(self) -> List[str]:
return [out.address for out in self.outputs if self.should_display(out)]
@property
def amounts(self) -> List[float]:
return [out.float_amount for out in self.outputs if self.should_display(out)]
@property
def sats(self) -> List[int]:
return [out.value for out in self.psbt.outputs if not self.descriptor.owns(out)]
@property
def txid(self) -> str:
return self.psbt.tx.txid().hex()
@property
def inputs(self) -> List[SpecterInputScope]:
return [self.InputCls(self, inp) for inp in self.psbt.inputs]
@property
def outputs(self) -> List[SpecterOutputScope]:
return [self.OutputCls(self, out) for out in self.psbt.outputs]
@property
def tx(self) -> Transaction:
return self.TxCls(self, self.psbt.tx)
def get_signed_devices(self) -> List[str]:
if not self.devices:
return []
devices = []
# for each devices check if there is a partial signature for any input
for key, device in self.devices:
device_signed = False
for inp in self.psbt.inputs:
if not inp.partial_sigs:
continue
for pub in inp.partial_sigs:
der = inp.bip32_derivations.get(pub)
if der and der.fingerprint.hex() == key.fingerprint:
device_signed = True
break
if device_signed:
break
if device_signed and device not in devices:
devices.append(device)
return devices
@classmethod
def from_dict(cls, obj: dict, descriptor: Descriptor, network: dict, devices=[]):
psbt = cls.PSBTCls.from_string(obj["base64"])
kwargs = {}
kwargs.update(obj)
kwargs.pop("base64")
return cls(psbt, descriptor, network, devices=devices, **kwargs)
@classmethod
def from_transaction(
cls,
tx: Union[Transaction, str, bytes],
descriptor: Descriptor,
network: dict,
devices=[],
):
if isinstance(tx, str):
tx = cls.TxCls.from_string(tx)
elif isinstance(tx, bytes):
tx = cls.TxCls.parse(tx)
psbt = cls.PSBTCls(tx)
return cls(psbt, descriptor, network, devices=devices)
def to_dict(self) -> dict:
# fee calculation may fail if inputs info is missing
try:
fee = self.fee
except:
fee = 0
full_size = self.full_size
obj = {
"tx": self.tx.to_dict(),
"inputs": [inp.to_dict() for inp in self.inputs],
"outputs": [out.to_dict() for out in self.outputs],
"base64": str(self.psbt),
"fee": round(fee * 1e-8, 8),
"fee_sat": fee,
"address": self.addresses,
"amount": self.amounts,
"sats": self.sats,
"tx_full_size": full_size,
"sigs_count": self.sigs_count,
"time": self.time,
"devices_signed": self.get_signed_devices(),
"fee_rate": round(fee / full_size, 2),
}
if self.raw:
obj.update({"raw": self.raw.hex()})
return obj
@classmethod
def from_string(cls, b64psbt: str) -> PSBT:
"""Returns PSBTCls, not cls"""
return cls.PSBTCls.from_string(b64psbt)
def to_string(self) -> str:
return str(self.psbt)
def __str__(self):
return str(self.psbt)
@classmethod
def fill_output(cls, out: OutputScope, desc: Descriptor) -> bool:
"""
Fills derivations and all other information in PSBT output
from derived descriptor
"""
if desc.script_pubkey() != out.script_pubkey:
return False
out.redeem_script = desc.redeem_script()
out.witness_script = desc.witness_script()
out.bip32_derivations = {
key.get_public_key(): DerivationPath(
key.origin.fingerprint, key.origin.derivation
)
for key in desc.keys
}
return True

View file

@ -257,9 +257,13 @@ class PsbtCreator:
rbf = bool(request_form.get("rbf", False))
# workaround for making the tests work with a dict
if hasattr(request_form, "getlist"):
selected_coins = request_form.getlist("coinselect")
coins = [coin.split(",") for coin in request_form.getlist("coinselect")]
# convert to tuples
selected_coins = [
{"txid": txid.strip(), "vout": int(vout)} for txid, vout in coins
]
else:
selected_coins = None
selected_coins = []
rbf_tx_id = request_form.get("rbf_tx_id", "")
kwargs = {
"subtract": subtract,
@ -267,8 +271,8 @@ class PsbtCreator:
"fee_rate": fee_rate,
"rbf": rbf,
"selected_coins": selected_coins,
"readonly": "estimate_fee"
in request_form, # determines whether the psbt gets persisted
# determines whether the psbt gets persisted
"readonly": "estimate_fee" in request_form,
"rbf_edit_mode": (rbf_tx_id != ""),
}
return kwargs

View file

@ -5,8 +5,10 @@ from cryptoadvance.specter.managers.wallet_manager import WalletManager
from cryptoadvance.specter.specter_error import SpecterError
from ..helpers import is_testnet
from .descriptor import AddChecksum, Descriptor
from embit.descriptor import Descriptor
from embit.descriptor import Key as DescriptorKey
from embit.liquid.descriptor import LDescriptor
from cryptoadvance.specter.key import Key
logger = logging.getLogger(__name__)
@ -27,6 +29,7 @@ class WalletImporter:
* unknown_cosigners
* unknown_cosigners_types
"""
DescriptorCls = LDescriptor if specter.is_liquid else Descriptor
if device_manager is None:
device_manager = specter.device_manager
try:
@ -40,12 +43,8 @@ class WalletImporter:
logger.warning(f"Trying to import: {wallet_json}")
raise SpecterError(f"Unsupported wallet import format:{e}")
try:
self.descriptor = Descriptor.parse(
AddChecksum(self.recv_descriptor.split("#")[0]),
testnet=is_testnet(specter.chain),
)
if self.descriptor is None:
raise SpecterError(f"Invalid wallet descriptor. (returns None)")
self.descriptor = DescriptorCls.from_string(self.recv_descriptor)
self.check_descriptor()
except Exception as e:
raise SpecterError(f"Invalid wallet descriptor: {e}")
if self.wallet_name in specter.wallet_manager.wallets_names:
@ -55,8 +54,57 @@ class WalletImporter:
self.cosigners,
self.unknown_cosigners,
self.unknown_cosigners_types,
) = self.descriptor.parse_signers(device_manager.devices, self.cosigners_types)
self.wallet_type = "multisig" if self.descriptor.multisig_N > 1 else "simple"
) = self.parse_signers(device_manager.devices, self.cosigners_types)
self.wallet_type = "multisig" if self.descriptor.is_basic_multisig else "simple"
def check_descriptor(self):
for key in self.descriptor.keys:
if not key.is_extended:
raise SpecterError("Only HD keys are supported in descriptor")
if key.allowed_derivation is None or key.allowed_derivation.indexes != [
0,
None,
]:
raise SpecterError(
"Descriptor key has wrong derivation, only /0/* derivation is supported."
)
def parse_signers(self, devices, cosigners_types):
keys = []
cosigners = []
unknown_cosigners = []
unknown_cosigners_types = []
for i, descriptor_key in enumerate(self.descriptor.keys):
# remove derivation from the key for comparison
account_key = DescriptorKey.from_string(str(descriptor_key))
account_key.allowed_derivation = None
# Specter Key class
desc_key = Key.parse_xpub(str(account_key))
cosigner_found = False
for cosigner in devices.values():
for key in cosigner.keys:
# check key matches
if key.to_string(slip132=False) == desc_key.to_string(
slip132=False
):
keys.append(key)
cosigners.append(cosigner)
cosigner_found = True
break
if cosigner_found:
break
if not cosigner_found:
if len(cosigners_types) > i:
unknown_cosigners.append((desc_key, cosigners_types[i]["label"]))
else:
unknown_cosigners.append((desc_key, None))
if len(unknown_cosigners) > len(cosigners_types):
unknown_cosigners_types.append("other")
else:
unknown_cosigners_types.append(cosigners_types[i]["type"])
return (keys, cosigners, unknown_cosigners, unknown_cosigners_types)
def create_nonexisting_signers(self, device_manager, request_form):
"""creates non existinging signer via the device_manager
@ -82,17 +130,56 @@ class WalletImporter:
self.keys.append(unknown_cosigner_key)
self.cosigners.append(device)
@property
def address_type(self):
if self.descriptor.is_taproot:
return "tr"
res = ""
if self.descriptor.miniscript:
if self.descriptor.wsh:
res = "wsh"
else:
if self.descriptor.wpkh:
res = "wpkh"
else:
return "pkh"
if self.descriptor.sh:
if res:
return f"sh-{res}"
else:
return "sh"
return res
@property
def sigs_required(self):
sigs_required = 1
if self.descriptor.is_basic_multisig:
sigs_required = self.descriptor.miniscript.args[0].num
return sigs_required
@property
def sigs_total(self):
return len(self.descriptor.keys)
def create_wallet(self, wallet_manager):
"""creates the wallet. Assumes all devices are there (create with create_nonexisting_signers)
will also keypoolrefill and import_labels
"""
try:
kwargs = {}
if (
isinstance(self.descriptor, LDescriptor)
and self.descriptor.blinding_key
):
kwargs["blinding_key"] = self.descriptor.blinding_key.key
self.wallet = wallet_manager.create_wallet(
name=self.wallet_name,
sigs_required=self.descriptor.multisig_M,
key_type=self.descriptor.address_type,
sigs_required=self.sigs_required,
key_type=self.address_type,
keys=self.keys,
devices=self.cosigners,
**kwargs,
)
except Exception as e:
raise SpecterError(f"Failed to create wallet: {e}")
@ -154,7 +241,7 @@ class WalletImporter:
"""
cosigners_types = []
# Specter-DIY format
# Specter-Desktop format
if "recv_descriptor" in wallet_data:
wallet_name = wallet_data.get("name", "Imported Wallet")
recv_descriptor = wallet_data.get("recv_descriptor", None)

File diff suppressed because it is too large Load diff

View file

@ -1 +1 @@
elements-0.21.0_rc1
elements-0.21.0_rc2

View file

@ -2,6 +2,18 @@ import pytest
import json
import base64
import logging
from numbers import Number
def almost_equal(a: Number, b: Number, precision: float = 0.01) -> bool:
"""
Checks if a and b are not very different.
Default precision is 1%
"""
if a == b:
return True
diff = 2 * (a - b) / (a + b)
return (diff < precision) and (diff > -precision)
def test_rr_psbt_get(client, caplog):
@ -55,7 +67,7 @@ def test_rr_psbt_get(client, caplog):
)
assert result.status_code == 200
data = json.loads(result.data)
assert data["result"] == []
assert data["result"] == {}
def test_rr_psbt_post(specter_regtest_configured, client, caplog):
@ -125,7 +137,7 @@ def test_rr_psbt_post(specter_regtest_configured, client, caplog):
assert data["result"]["tx"]
assert data["result"]["inputs"]
assert data["result"]["outputs"]
assert data["result"]["fee_rate"] == "0.00064000"
assert almost_equal(data["result"]["fee_rate"], 64)
assert data["result"]["tx_full_size"]
assert data["result"]["base64"]
assert data["result"]["time"]

View file

@ -52,7 +52,7 @@ def test_PsbtCreator_ui(caplog):
"rbf": True,
"rbf_edit_mode": False,
"readonly": False,
"selected_coins": None,
"selected_coins": [],
"subtract": False,
"subtract_from": 0,
}
@ -107,7 +107,7 @@ bcrt1q3kfetuxpxvujasww6xas94nawklvpz0e52uw8a, 0.5
"rbf": True,
"rbf_edit_mode": False,
"readonly": False,
"selected_coins": None,
"selected_coins": [],
"subtract": False,
"subtract_from": 0,
}

View file

@ -39,7 +39,9 @@ def test_WalletImporter_unit():
{"label": "MyTestTrezor", "type": "trezor"},
]
# The descriptor (very briefly)
assert wallet_importer.descriptor.origin_fingerprint == ["fb7c1f11", "1ef4e492"]
assert [
key.origin.fingerprint.hex() for key in wallet_importer.descriptor.keys
] == ["fb7c1f11", "1ef4e492"]
assert len(wallet_importer.keys) == 0
assert len(wallet_importer.cosigners) == 0
assert len(wallet_importer.unknown_cosigners) == 2

View file

@ -142,9 +142,8 @@ def test_wallet_createpsbt(docker, request, devices_filled_data_folder, device_m
unspents = wallet.rpc.listunspent(0)
# Lets take 3 more or less random txs from the unspents:
selected_coins = [
"{},{}".format(unspents[5]["txid"], unspents[5]["vout"]),
"{},{}".format(unspents[9]["txid"], unspents[9]["vout"]),
"{},{}".format(unspents[12]["txid"], unspents[12]["vout"]),
{"txid": u["txid"], "vout": u["vout"]}
for u in [unspents[5], unspents[9], unspents[12]]
]
selected_coins_amount_sum = (
unspents[5]["amount"] + unspents[9]["amount"] + unspents[12]["amount"]
@ -163,7 +162,7 @@ def test_wallet_createpsbt(docker, request, devices_filled_data_folder, device_m
assert len(psbt["tx"]["vin"]) == 3
psbt_txs = [tx["txid"] for tx in psbt["tx"]["vin"]]
for coin in selected_coins:
assert coin.split(",")[0] in psbt_txs
assert coin["txid"] in psbt_txs
# Now let's spend more coins than we have selected. This should result in an exception:
try: