mirror of
https://github.com/cryptoadvance/specter-desktop.git
synced 2026-08-13 12:33:29 +02:00
* 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:
parent
a2350a770b
commit
149f5d653d
37 changed files with 3871 additions and 1155 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
2089
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
9
src/cryptoadvance/specter/static/img/mixed.svg
Normal file
9
src/cryptoadvance/specter/static/img/mixed.svg
Normal 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 |
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"> ({{ (pending_psbt['amount'] | sum) | altunit }})</span>{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="optional">
|
||||
{{ pending_psbt['time'] | datetime }}
|
||||
|
|
|
|||
|
|
@ -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"> ({{ 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"> ({{ 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"> ({{ 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"> ({{ 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>
|
||||
<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>
|
||||
|
|
@ -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',[]) %} (✔) {% 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>
|
||||
|
|
|
|||
|
|
@ -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>": {
|
||||
|
|
|
|||
451
src/cryptoadvance/specter/util/psbt.py
Normal file
451
src/cryptoadvance/specter/util/psbt.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1 +1 @@
|
|||
elements-0.21.0_rc1
|
||||
elements-0.21.0_rc2
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue