diff --git a/cypress/integration/spec_elm_multi_segwit_wallet.js b/cypress/integration/spec_elm_multi_segwit_wallet.js index 126978197..5deba9f23 100644 --- a/cypress/integration/spec_elm_multi_segwit_wallet.js +++ b/cypress/integration/spec_elm_multi_segwit_wallet.js @@ -5,6 +5,7 @@ describe('Operating with an Elements multisig wallet', () => { cy.visit('/') cy.get('#node-switch-icon').click() cy.get('#elements_node-select-node-form > .item > div').click() + cy.get('[value="save"]').click() // Delete wallets if existing cy.deleteWallet("Elm Multi Segwit Wallet") diff --git a/cypress/integration/spec_wallet_send.js b/cypress/integration/spec_wallet_send.js index 26ff1e0b8..716a83c49 100644 --- a/cypress/integration/spec_wallet_send.js +++ b/cypress/integration/spec_wallet_send.js @@ -184,7 +184,10 @@ describe('Test sending transactions', () => { it('Use an address belonging to the wallet', () => { cy.selectWallet("Ghost wallet") cy.get('#btn_send').click() - cy.get('#recipients').find('#recipient_0').find('#address').type("bcrt1qvtdx75y4554ngrq6aff3xdqnvjhmct5wck95qs", { force: true }) + // Simulating pasting the address, this also reduces the amount of fetch API calls to just one + cy.get('#recipient_0').find('#address').invoke('val', "bcrt1qvtdx75y4554ngrq6aff3xdqnvjhmct5wck95qs").trigger('input') + cy.get('#recipient_0').find('#label').type('To my own wallet', { force: true }) + cy.get('#recipient_0').find('#amount').type(10, { force: true }) // Checking that the background colour of the address is green as it belongs to the wallet cy.get('#recipients').find('#recipient_0').find('#address').should('have.css', 'background-color','rgb(48, 109, 48)') }) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 5775f0721..bd9f1e997 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -87,7 +87,7 @@ Cypress.Commands.add("deleteDevice", (name) => { cy.get('body').then(($body) => { if ($body.text().includes(name)) { cy.get('#toggle_devices_list').click() - cy.contains(name).click() + cy.contains(name).click( {force: true} ) cy.get('#forget_device').click() cy.reload() // For hot wallets only @@ -251,9 +251,9 @@ Cypress.Commands.add("mine2wallet", (chain) => { Cypress.Commands.add("createPsbt", (address, label="a_label", amount=0.01) => { cy.get('#btn_send').click() // it is not clear why .shadow(), or { includeShadowDom: true } is needed here to find the elements in the ShadowDOM, but not in the other cypresss tests - cy.get('#recipient_0').find('#address', { includeShadowDom: true }).type(address) - cy.get('#recipient_0').find('#label', { includeShadowDom: true }).type(label) + cy.get('#recipient_0').find('#address', { includeShadowDom: true }).type(address, { force: true }) + cy.get('#recipient_0').find('#label', { includeShadowDom: true }).type(label, { force: true }) //cy.get('#send_max_0').click() - cy.get('#recipient_0').find('#amount', { includeShadowDom: true }).type(amount) + cy.get('#recipient_0').find('#amount', { includeShadowDom: true }).type(amount, { force: true }) cy.get('#create_psbt_btn').click() }) diff --git a/src/cryptoadvance/specter/commands/psbt_creator.py b/src/cryptoadvance/specter/commands/psbt_creator.py index 55a1e7870..1cde916a7 100644 --- a/src/cryptoadvance/specter/commands/psbt_creator.py +++ b/src/cryptoadvance/specter/commands/psbt_creator.py @@ -7,7 +7,7 @@ import requests from cryptoadvance.specter.specter_error import SpecterError from cryptoadvance.specter.util.common import str2bool -from ..helpers import is_testnet +from ..helpers import is_testnet, normalize_address from ..util.descriptor import AddChecksum, Descriptor logger = logging.getLogger(__name__) @@ -86,12 +86,7 @@ class PsbtCreator: f"Unknown ui_option: {ui_option}. Valid ones are ui|text|json" ) # normalizing - self.addresses = [ - address.lower() - if address.startswith(("BC1", "TB1", "BCRT1", "EL1", "ERT1", "EX1", "LQ1")) - else address - for address in self.addresses - ] + self.addresses = [normalize_address(addr) for addr in self.addresses] # get kwargs if ui_option == "ui" or ui_option == "text": self.kwargs = PsbtCreator.kwargs_from_request_form(request_form) @@ -186,7 +181,7 @@ class PsbtCreator: continue addresses.append(output.split(",")[0].strip()) if recipients_amount_unit == "sat": - amounts.append(float(output.split(",")[1].strip()) / 1e8) + amounts.append(round(float(output.split(",")[1].strip()) / 1e8, 8)) elif recipients_amount_unit == "btc": amounts.append(float(output.split(",")[1].strip())) else: @@ -243,18 +238,28 @@ class PsbtCreator: try: amount = float(recipient["amount"]) if recipient["unit"] == "sat": - amounts.append(float(amount / 1e8)) + amounts.append(round(amount / 1e8, 8)) elif recipient["unit"] == "btc": amounts.append(amount) else: - raise SpecterError( - f"Non-compliant json: Unknown unit {recipient['unit']}" - ) + if specter.is_liquid: + if len(recipient["unit"]) == 64: + amounts.append(amount) + else: + raise SpecterError( + f"Non-compliant json: Unknown unit {recipient['unit']}. Accepted: sat, btc or 64-char hex asset id." + ) + else: + raise SpecterError( + f"Non-compliant json: Unknown unit {recipient['unit']}" + ) except ValueError as e: raise SpecterError( f"Could not parse amount {recipient.get('amount')} because {e}" ) unit = recipient["unit"] + if specter.is_liquid and unit in ["sat", "btc"]: + unit = specter.default_asset amount_units.append(unit) label = recipient.get("label", "") diff --git a/src/cryptoadvance/specter/helpers.py b/src/cryptoadvance/specter/helpers.py index 43e2edc3a..9e2c5d53d 100644 --- a/src/cryptoadvance/specter/helpers.py +++ b/src/cryptoadvance/specter/helpers.py @@ -16,6 +16,7 @@ from embit.psbt import PSBT from embit.transaction import Transaction from embit.liquid.pset import PSET from embit.liquid.transaction import LTransaction +from embit.liquid.networks import NETWORKS from .persistence import read_json_file, write_json_file from .util.bcur import bcur_decode import threading @@ -67,6 +68,23 @@ def is_liquid(chain): return chain not in ["main", "regtest", "test", "signet", "None", "none", None, ""] +def normalize_address(addr: str) -> str: + """ + Normalized address to canonical form. + Upper bech32 -> lower bech32, but doesn't touch base58 addresses. + """ + # bitcoin hrps + hrps = {net["bech32"] + "1" for net in NETWORKS.values()} + # liquid hrps + # Blech32 addresses are intended for confidential assets + hrps = hrps.union( + {net["blech32"] + "1" for net in NETWORKS.values() if "blech32" in net} + ) + if addr.lower().startswith(tuple(hrps)): + return addr.lower() + return addr + + def locked(customlock=defaultlock): """ @locked(lock) decorator. diff --git a/src/cryptoadvance/specter/templates/includes/recipient-box.html b/src/cryptoadvance/specter/templates/includes/recipient-box.html index a1621cba8..513582130 100644 --- a/src/cryptoadvance/specter/templates/includes/recipient-box.html +++ b/src/cryptoadvance/specter/templates/includes/recipient-box.html @@ -240,7 +240,7 @@ - markAdddressGreenIfMine(){ + markAddressGreenIfMine(){ // mark own addresses green this.isAddressMyOwn().then((isMine) => { this.markRecipient(isMine); @@ -248,8 +248,13 @@ } dispatchAddressOnInput(result) { - this.markAdddressGreenIfMine(); - this.dispatchEvent(new CustomEvent('address-input')); + let address = this.addressElement.value; + // To reduce the fetch API calls if the address is typed + // TODO: If we have a better JS address validation, this can be useful here, too + if (address.length >= 14) { + this.markAddressGreenIfMine(); + this.dispatchEvent(new CustomEvent('address-input')); + } } dispatchRemove(result) { @@ -314,7 +319,7 @@ // address set address(newValue){ this.addressElement.value = newValue; - this.markAdddressGreenIfMine(); + this.markAddressGreenIfMine(); } get address(){ return this.addressElement.value; diff --git a/tests/test_commands_psbt_creator.py b/tests/test_commands_psbt_creator.py index 2de628947..e78acb156 100644 --- a/tests/test_commands_psbt_creator.py +++ b/tests/test_commands_psbt_creator.py @@ -168,3 +168,134 @@ def test_PsbtCreator_json(caplog): } psbt_creator.create_psbt(wallet_mock) + + +def test_PsbtCreator_ui_liquid(caplog): + caplog.set_level(logging.DEBUG) + specter_mock = MagicMock() + # non liquid and default asset btc (for unit-calculation) + specter_mock.is_liquid = True + specter_mock.default_asset = "1" * 64 + + wallet_mock = MagicMock() + specter_mock.chain = "liquid-regtest" + # Let's mock the request.form which behaves like a dict but also needs getlist() + request_form_data = { + "rbf_tx_id": "", + "amount_unit_text": "btc", + "subtract_from": "0", + "fee_option": "dynamic", + "fee_rate": "", + "fee_rate_dynamic": "64", + "rbf": "on", + "action": "createpsbt", + "recipient_dicts": '[{"unit":"btc","amount":0.1,"recipient_id":0,"address":"el1qqgxqu7f05rm7e245nvdje28wa6fl3s0cv5lga3yahfqddgr4u8zg75nxwjmrrswvue8x2glk0rhvngz30du8gs5m3lgtfvjrx","label":"someLabel","btc_amount":"0.1"},' + '{"unit":"sat","amount":111211,"recipient_id":1,"address":"EL1QQWHNYT8NSPG0PFXDZJ98ER2K66VE07D297VQ50TRGXZ2P28RJJSS69W3ZY3AUYLF60SKRL0G72V8ZTCCXZYR5WM2AQNAMNQMZ","label":"someOtherLabel","btc_amount":"0.00111211"},' + '{"unit":"0b411a802e536a4217f46a47644c9e207db975f81e697753db2a1915d17dda79","amount":0.003,"recipient_id":2,"address":"AzpxZsPXCk3ct5wx9QfynivkEjHwzKELtHqWtwZ7RZKPncPtYBYn6tktDxCeMtNghMUBMw9MtD7kDcjC","label":"asset test","btc_amount":"0.003"}]', + } + + psbt_creator: PsbtCreator = PsbtCreator( + specter_mock, wallet_mock, "ui", request_form=request_form_data + ) + + assert psbt_creator.addresses == [ + "el1qqgxqu7f05rm7e245nvdje28wa6fl3s0cv5lga3yahfqddgr4u8zg75nxwjmrrswvue8x2glk0rhvngz30du8gs5m3lgtfvjrx", + "el1qqwhnyt8nspg0pfxdzj98er2k66ve07d297vq50trgxz2p28rjjss69w3zy3auylf60skrl0g72v8ztccxzyr5wm2aqnamnqmz", + "AzpxZsPXCk3ct5wx9QfynivkEjHwzKELtHqWtwZ7RZKPncPtYBYn6tktDxCeMtNghMUBMw9MtD7kDcjC", + ] + assert psbt_creator.amounts == [0.1, 0.00111211, 0.003] + assert psbt_creator.labels == ["someLabel", "someOtherLabel", "asset test"] + assert psbt_creator.amount_units == [ + specter_mock.default_asset, + specter_mock.default_asset, + "0b411a802e536a4217f46a47644c9e207db975f81e697753db2a1915d17dda79", + ] + assert psbt_creator.kwargs == { + "fee_rate": 64.0, + "rbf": True, + "rbf_edit_mode": False, + "readonly": False, + "selected_coins": [], + "subtract": False, + "subtract_from": 0, + "assets": [ + specter_mock.default_asset, + specter_mock.default_asset, + "0b411a802e536a4217f46a47644c9e207db975f81e697753db2a1915d17dda79", + ], + } + + psbt_creator.create_psbt(wallet_mock) + + +def test_PsbtCreator_json(caplog): + caplog.set_level(logging.DEBUG) + specter_mock = MagicMock() + # set to liquid and provide default 64-char hex asset (111....) + specter_mock.is_liquid = True + specter_mock.default_asset = "1" * 64 + + wallet_mock = MagicMock() + specter_mock.chain = "liquid-regtest" + # Let's mock the request.form which behaves like a dict but also needs getlist() + request_json = """ + { + "recipients" : [ + { + "address": "el1qqgxqu7f05rm7e245nvdje28wa6fl3s0cv5lga3yahfqddgr4u8zg75nxwjmrrswvue8x2glk0rhvngz30du8gs5m3lgtfvjrx", + "amount": 0.1, + "unit": "1111111111111111111111111111111111111111111111111111111111111111", + "label": "someLabel" + }, + { + "address": "EL1QQWHNYT8NSPG0PFXDZJ98ER2K66VE07D297VQ50TRGXZ2P28RJJSS69W3ZY3AUYLF60SKRL0G72V8ZTCCXZYR5WM2AQNAMNQMZ", + "amount": 111211, + "unit": "sat", + "label": "someOtherLabel" + }, + { + "address": "AzpxZsPXCk3ct5wx9QfynivkEjHwzKELtHqWtwZ7RZKPncPtYBYn6tktDxCeMtNghMUBMw9MtD7kDcjC", + "amount": 0.1, + "unit": "0b411a802e536a4217f46a47644c9e207db975f81e697753db2a1915d17dda79", + "label": "asset test" + } + ], + "rbf_tx_id": "", + "subtract_from": "0", + "fee_rate": "64", + "rbf": true + } + """ + + psbt_creator: PsbtCreator = PsbtCreator( + specter_mock, wallet_mock, "json", request_json=request_json + ) + + assert psbt_creator.addresses == [ + "el1qqgxqu7f05rm7e245nvdje28wa6fl3s0cv5lga3yahfqddgr4u8zg75nxwjmrrswvue8x2glk0rhvngz30du8gs5m3lgtfvjrx", + "el1qqwhnyt8nspg0pfxdzj98er2k66ve07d297vq50trgxz2p28rjjss69w3zy3auylf60skrl0g72v8ztccxzyr5wm2aqnamnqmz", + "AzpxZsPXCk3ct5wx9QfynivkEjHwzKELtHqWtwZ7RZKPncPtYBYn6tktDxCeMtNghMUBMw9MtD7kDcjC", + ] + assert psbt_creator.amounts == [0.1, 0.00111211, 0.1] + assert psbt_creator.labels == ["someLabel", "someOtherLabel", "asset test"] + assert psbt_creator.amount_units == [ + specter_mock.default_asset, + specter_mock.default_asset, + "0b411a802e536a4217f46a47644c9e207db975f81e697753db2a1915d17dda79", + ] + assert psbt_creator.kwargs == { + "fee_rate": 64.0, + "rbf": True, + "rbf_edit_mode": False, + "readonly": False, + "selected_coins": [], + "subtract": False, + "subtract_from": 0, + "assets": [ + specter_mock.default_asset, + specter_mock.default_asset, + "0b411a802e536a4217f46a47644c9e207db975f81e697753db2a1915d17dda79", + ], + } + + psbt_creator.create_psbt(wallet_mock)