UIUX: Easier adding and deleting of recipients (#1782)

* Using a new web component for recipients

Co-authored-by: moneymanolis <moneymanolis@protonmail.com>
This commit is contained in:
relativisticelectron 2022-08-02 09:15:32 +02:00 committed by GitHub
parent f739d9bb7a
commit a82eddfe7f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1048 additions and 445 deletions

View file

@ -70,7 +70,7 @@ test_task:
cypress_test_task:
container:
image: registry.gitlab.com/cryptoadvance/specter-desktop/cypress-python:v9.5.4
image: registry.gitlab.com/cryptoadvance/specter-desktop/cypress-python:v9.7.0
cpu: 6
memory: 6G
pre_prep_script:

1
.gitignore vendored
View file

@ -39,3 +39,4 @@ tests/elements
signing_dir
site
docs/README.md
cypresstest-output.xml

View file

@ -25,5 +25,4 @@
"mochaFile": "cypresstest-output.xml",
"toConsole": true
}
}

View file

@ -37,11 +37,6 @@ describe('Operating with an elements singlesig wallet', () => {
expect(newBalance).to.be.lte(oldBalance - 1.5)
})
})
// Workaround: Transaction does not disappear
cy.get('#btn_send').click()
// The "delete" button in the first psbt
cy.get('.row > :nth-child(2) > .btn').click()
})
it('send unconfidential transaction from segwit', () => {
@ -83,11 +78,6 @@ describe('Operating with an elements singlesig wallet', () => {
expect(newBalance).to.be.lte(oldBalance - 1.5)
})
})
// Workaround: Transaction does not disappear
cy.get('#btn_send').click()
// The "delete" button in the first psbt
cy.get('.row > :nth-child(2) > .btn').click()
})
it('send unconfidential transaction from nested segwit', () => {

View file

@ -3,6 +3,6 @@ describe('Ghost machine', () => {
cy.viewport(1200,660)
cy.visit('/')
cy.addDevice('DIY ghost', 'Specter-DIY', 'ghost_machine')
cy.addWallet('Ghost wallet', null, 'segwit', 'funded', 'btc')
cy.addWallet('Ghost wallet', 'segwit', 'funded', 'btc', 'singlesig', 'DIY ghost')
})
})

View file

@ -13,7 +13,7 @@ describe('Test the UI related to a blockchain rescan', () => {
it('Go to the rescan section from a fresh wallet', () => {
// Create a completely fresh wallet which is not receiving funds from the continous mining
cy.addDevice('Trezor hold', 'Trezor', 'hold_accident')
cy.addWallet('Fresh wallet', 'Trezor hold', 'segwit', false)
cy.addWallet('Fresh wallet', 'segwit', false, 'btc', 'singlesig', 'Trezor hold')
cy.get('#btn_transactions').click()
cy.get('#go-to-rescan-btn').click()
cy.get('#blockchain-rescan').should('be.visible')

View file

@ -11,33 +11,13 @@ describe('Test sending transactions', () => {
})
it('Send a standard transaction', () => {
// empty so far
cy.addHotDevice("Hot Device 1","bitcoin")
cy.get('body').then(($body) => {
if ($body.text().includes('Test Hot Wallet 1')) {
cy.get('#wallets_list > .item > svg').click()
cy.get(':nth-child(6) > .right').click()
cy.get('#advanced_settings_tab_btn').click()
cy.get('.card > :nth-child(9) > .btn').click()
}
})
cy.get('#btn_new_wallet').click()
cy.get('[href="./simple/"]').click()
cy.get('#hot_device_1').click()
cy.get('#wallet_name').type("Test Hot Wallet 1")
cy.get('#keysform > .centered').click()
cy.get('body').contains("New wallet was created successfully!")
// Download PDF
// unfortunately this results in weird effects in cypress run
//cy.get('#pdf-wallet-download > img').click()
cy.get('#btn_continue').click()
//get some funds
cy.mine2wallet("btc")
cy.addWallet('Test Hot Wallet 1', 'segwit', 'funded', 'btc', 'singlesig', 'Hot Device 1')
cy.selectWallet("Test Hot Wallet 1")
cy.get('#btn_send').click()
cy.get('#address_0').type("bcrt1qsj30deg0fgzckvlrn5757yk55yajqv6dqx0x7u")
cy.get('#label_0').type("Burn address")
cy.get('#send_max_0').click()
cy.get('#recipient_0').find('#address').type("bcrt1qsj30deg0fgzckvlrn5757yk55yajqv6dqx0x7u", { force: true })
cy.get('#recipient_0').find('#label').type("Burn address", { force: true })
cy.get('#recipient_0').get('#send_max').click()
cy.get('#create_psbt_btn').click()
cy.get('body').contains("Paste signed transaction")
cy.get('#hot_device_1_tx_sign_btn').click()
@ -50,48 +30,129 @@ describe('Test sending transactions', () => {
expect(n).to.be.equals(0)
})
})
it('Create a transaction with multiple recipients', () => {
it('Adding and deleting recipients', () => {
// We need new sats but mine2wallet only works if a wallet is selected
cy.selectWallet("Test Hot Wallet 1")
cy.mine2wallet("btc")
cy.get('#btn_send').click()
/// The addresses are the first three from DIY ghost
cy.get('#address_0').type("bcrt1qvtdx75y4554ngrq6aff3xdqnvjhmct5wck95qs")
cy.get('#label_0').type("Recipient 1")
cy.get('#amount_0').type(10)
// The addresses are the first 5 from DIY ghost
cy.get('#recipient_0').find('#address').invoke('val', "bcrt1qvtdx75y4554ngrq6aff3xdqnvjhmct5wck95qs") // will be deleted, so address doesnt matter
cy.get('#recipient_0').find('#label').type("Recipient 1 to be deleted", { force: true })
cy.get('#recipient_0').find('#amount').type(1, { force: true })
cy.get('main').scrollTo('bottom')
// Adding 4 more recipients
cy.get('#add-recipient').click()
cy.get('#recipient_1').find('#address').invoke('val', "bcrt1qgzmq6e3tn67kveryf2je6nd3nv4txef4sl8wre") // pasting the address is faster than typing
cy.get('#recipient_1').find('#label').type("Recipient 2", { force: true })
cy.get('#recipient_1').find('#amount').type(2, { force: true })
cy.get('main').scrollTo('bottom')
cy.get('#add-recipient').click()
cy.get('#recipient_2').find('#address').invoke('val', "bcrt1q9mkrhmxcn7rslzfv6lke8859m7ntwudfjqmcx7") // will be deleted, so address doesnt matter
cy.get('#recipient_2').find('#label').type("Recipient 3 to be deleted", { force: true })
cy.get('#recipient_2').find('#amount').type(3, { force: true })
cy.get('#add-recipient').click()
cy.get('#recipient_3').find('#address').invoke('val', "bcrt1q4gs9fsf8fh4s4s8w39hxtupafm2q047fytmnxp") // pasting the address is faster than typing
cy.get('#recipient_3').find('#label').type("Recipient 4", { force: true })
cy.get('#recipient_3').find('#amount').type(4, { force: true })
cy.get('#add-recipient').click()
cy.get('#recipient_4').find('#address').invoke('val', "bcrt1q4e8p7x6n7uhtthtelhv3mle52vsc4pqre7ddwm") // pasting the address is faster than typing
cy.get('#recipient_4').find('#label').type("Recipient 5", { force: true })
cy.get('#recipient_4').find('#amount').type(5, { force: true })
cy.get('main').scrollTo('bottom')
// Check the fee selection
cy.get('#toggle_advanced').click()
cy.get('main').scrollTo('bottom')
cy.get('#add-recipient').click()
cy.get('#address_1').type("bcrt1qgzmq6e3tn67kveryf2je6nd3nv4txef4sl8wre")
cy.get('#label_1').type("Recipient 2")
cy.get('#amount_1').type(5)
cy.get('main').scrollTo('bottom')
cy.get('#add-recipient').click()
cy.get('#address_2').type("bcrt1q9mkrhmxcn7rslzfv6lke8859m7ntwudfjqmcx7")
cy.get('#label_2').type("Recipient 3")
cy.get('#send_max_2').click()
cy.get('main').scrollTo('bottom')
// Shadow DOM
// Check whether the subtract fees box is ticked
cy.get('#fee-selection-component').find('.fee_container').find('input#subtract').click()
cy.get('#fee-selection-component').find('.fee_container').find('input#subtract').invoke('prop', 'checked').should('eq', true)
// Check whether the recipient number input field is visible (shadow DOM)
// Check whether the recipient number select field is visible
cy.get('#fee-selection-component').find('.fee_container').find('span#subtract_from').should('be.visible')
// Light DOM
// Check the values of the hidden inputs in the light DOM which are used for the form
// Note: Despite identical ids the hidden inputs seem to be fetched first since they are higher up in the DOM
cy.get('#fee-selection-component').find('#subtract').invoke('attr', 'value').should('eq', 'true')
// Send max was applied to the third recipient, so the value should be 3
cy.get('#fee-selection-component').find('#subtract_from_input').invoke('attr', 'value').should('eq', '3')
// Change recipient number to 2
// Note: No easy way to increment / decrement by clicking, see: https://stackoverflow.com/questions/47180137/incrementing-and-decrementing-the-value-of-an-input-type-number-in-cypress
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_input').clear()
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_input').click().type('2{enter}')
cy.get('#fee-selection-component').find('#subtract_from_input').invoke('attr', 'value').should('eq', '2')
// Remove two recipients
cy.get('#recipient_0').find('#remove').click({ force: true })
cy.get('#recipient_2').find('#remove').click({ force: true })
// Change it back to recipient 3
cy.get('#send_max_2').click()
// Select different recipients to subtract the fees from
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_recipient_id_select').select('Recipient 4') // html select with cypress: https://www.cypress.io/blog/2020/03/20/working-with-select-elements-and-select2-widgets-in-cypress/
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_recipient_id_select').select('Recipient 5')
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_recipient_id_select').select('Recipient 2')
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_recipient_id_select').should('have.value', '1');
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_recipient_id_select').find(':selected').should('have.text', 'Recipient 2');
cy.get('#create_psbt_btn').click()
var amount = 0
// The fee should be subtracted from recipient 2, so the amount should be less than 2
cy.get('div.tx_info > :nth-child(1) > :nth-child(1)').then(($div) => { // nth-child is indexed from 1 https://css-tricks.com/almanac/selectors/n/nth-child/
amount = parseFloat($div.text())
expect(amount).to.be.lt(2)
expect(amount).to.be.gt(1)
})
cy.get('div.tx_info > :nth-child(2) > :nth-child(1)').then(($div) => {
amount = parseFloat($div.text())
expect(amount).to.be.equal(4)
})
cy.get('div.tx_info > :nth-child(3) > :nth-child(1)').then(($div) => {
amount = parseFloat($div.text())
expect(amount).to.be.equal(5)
})
// Delete the PSBT so the utxos can be used in the next test again
cy.get('#deletepsbt_btn').click()
})
it('Create a transaction with multiple recipients and use send max', () => {
cy.selectWallet("Test Hot Wallet 1")
cy.get('#btn_send').click()
/// The addresses are the first three from DIY ghost
cy.get('#recipient_0').find('#address').type("bcrt1qvtdx75y4554ngrq6aff3xdqnvjhmct5wck95qs", { force: true })
cy.get('#recipient_0').find('#label').type("Recipient 1", { force: true })
cy.get('#recipient_0').find('#amount').type(10, { force: true })
cy.get('main').scrollTo('bottom')
cy.get('#add-recipient').click()
cy.get('#recipient_1').find('#address').type("bcrt1qgzmq6e3tn67kveryf2je6nd3nv4txef4sl8wre", { force: true })
cy.get('#recipient_1').find('#label').type("Recipient 2", { force: true })
cy.get('#recipient_1').find('#amount').type(5, { force: true })
cy.get('main').scrollTo('bottom')
cy.get('#add-recipient').click()
cy.get('#recipient_2').find('#address').type("bcrt1q9mkrhmxcn7rslzfv6lke8859m7ntwudfjqmcx7", { force: true })
cy.get('#recipient_2').find('#label').type("Recipient 3", { force: true })
// Using send max
cy.get('#recipient_2').find('#send_max').click()
cy.get('main').scrollTo('bottom')
// Check whether the subtract fees box is ticked (we used send max)
cy.get('#toggle_advanced').click()
cy.get('#fee-selection-component').find('.fee_container').find('input#subtract').invoke('prop', 'checked').should('eq', true)
// Check whether the recipient number input field is visible
cy.get('#fee-selection-component').find('.fee_container').find('span#subtract_from').should('be.visible')
// Check the values of the hidden inputs in the light DOM which are used for the form
// Note: Despite identical ids the hidden inputs seem to be fetched first since they are higher up in the DOM
cy.get('#fee-selection-component').find('#subtract').invoke('attr', 'value').should('eq', 'true')
// Check whether send max set subtract_from to Recipient 3
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_recipient_id_select').should('have.value', '2');
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_recipient_id_select').find(':selected').should('have.text', 'Recipient 3');
// Select Recipient 2 to subract the fee from
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_recipient_id_select').select('Recipient 2')
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_recipient_id_select').should('have.value', '1');
cy.get('#fee-selection-component').find('.fee_container').find('#subtract_from_recipient_id_select').find(':selected').should('have.text', 'Recipient 2');
// Change it back to Recipient 3
cy.get('#recipient_2').find('#send_max').click()
// The fee should be subtracted from the third recipient
cy.get('#create_psbt_btn').click()
@ -104,39 +165,46 @@ describe('Test sending transactions', () => {
cy.deleteWallet("Test Hot Wallet 1")
})
it('Send a transaction from a multisig wallet', () => {
cy.get('body').then(($body) => {
if ($body.text().includes('Test Multisig Wallet 1')) {
cy.get('#wallets_list > .item > svg').click()
cy.get(':nth-child(6) > .right').click()
cy.get('#advanced_settings_tab_btn').click()
cy.get('.card > :nth-child(9) > .btn').click()
}
})
cy.get('#btn_new_wallet').click()
cy.get('[href="./multisig/"]').click()
cy.get('#hot_device_1').click()
cy.get('#diy_ghost').click()
cy.get('#submit-device').click()
cy.get('#wallet_name').type("Test Multisig Wallet 1")
cy.get('#keysform > .centered').click()
cy.get('body').contains("New wallet was created successfully!")
cy.get('#page_overlay_popup_cancel_button').click()
// Send transaction
//get some funds
cy.mine2wallet("btc")
it('No remove button if there is only one recipient', () => {
cy.selectWallet("Ghost wallet")
cy.get('#btn_send').click()
cy.get('#address_0').type("bcrt1qsj30deg0fgzckvlrn5757yk55yajqv6dqx0x7u")
cy.get('#label_0').type("Burn address")
cy.get('#send_max_0').click()
// No remove button when the send dialog is started with only one recipient
cy.get('#recipient_0').find('#remove').should('not.be.visible')
cy.get('#add-recipient').click()
// Now both remove buttons should be visible
cy.get('#recipient_0').find('#remove').should('be.visible')
cy.get('#recipient_1').find('#remove').should('be.visible')
// Remove button should disappear again if only one recipient (here: Recipient 3) remains
cy.get('#add-recipient').click()
cy.get('#recipient_0').find('#remove').click({ force: true })
cy.get('#recipient_1').find('#remove').click({ force: true })
cy.get('#recipient_2').find('#remove').should('not.be.visible')
})
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 })
// 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)')
})
it('Send a transaction from a multisig wallet', () => {
// We need a second hot wallet
cy.addHotDevice("Hot Device 2","bitcoin")
cy.addWallet('Test Multisig Wallet', 'segwit', 'funded', 'btc', 'multisig', 'Hot Device 1', 'Hot Device 2', 'DIY ghost')
cy.get('#btn_send').click()
cy.get('#recipient_0').find('#address').type("bcrt1qsj30deg0fgzckvlrn5757yk55yajqv6dqx0x7u", { force: true })
cy.get('#recipient_0').find('#label').type("Burn address", { force: true })
cy.get('#recipient_0').get('#send_max').click()
cy.get('#create_psbt_btn').click()
cy.get('body').contains("Paste signed transaction")
cy.get('#hot_device_1_tx_sign_btn').click()
cy.get('#hot_device_1_hot_sign_btn').click()
cy.get('#hot_enter_passphrase__submit').click()
cy.get('#hot_device_2_tx_sign_btn').click()
cy.get('#hot_device_2_hot_sign_btn').click()
cy.get('#hot_enter_passphrase__submit').click()
cy.get('#broadcast_local_btn').click()
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") })
.should(($div) => {
@ -144,7 +212,7 @@ describe('Test sending transactions', () => {
expect(n).to.be.equals(0)
})
// Clean up
cy.deleteWallet("Test Multisig Wallet 1")
cy.deleteWallet("Test Multisig Wallet")
cy.deleteDevice("Hot Device 1")
})
})

View file

@ -98,8 +98,8 @@ describe('Test the actions in UTXO list', () => {
it('Managing unsigned transactions', () => {
// Make an unsigned tx
cy.get('#address_0').type("bcrt1qvtdx75y4554ngrq6aff3xdqnvjhmct5wck95qs") // address from "Ghost wallet"
cy.get('#send_max_0').click()
cy.get('#recipient_0').find('#address').type("bcrt1qvtdx75y4554ngrq6aff3xdqnvjhmct5wck95qs") // address from "Ghost wallet"
cy.get('#recipient_0').get('#send_max').click()
cy.get('#create_psbt_btn').click()
// Check the labeling of the unsigned UTXO
cy.log("Check the labeling of the unsigned UTXO")

View file

@ -61,12 +61,8 @@ Cypress.Commands.add("addDevice", (name, device_type, mnemonic) => {
Cypress.Commands.add("addHotDevice", (name, node_type) => {
// node_type is either elements or bitcoin
cy.get('body').then(($body) => {
cy.task("delete:elements-hotwallet")
if ($body.text().includes(name)) {
cy.get('#toggle_devices_list').click()
var refName = "#device_list_item_"+name.toLowerCase().replace(/ /g,"_")
cy.get(refName).click( {force: true} )
cy.get('#forget_device').click()
cy.deleteDevice(name)
// We might get an error here, if the device is used in a wallet
// We assume therefore that this is ok (see below)
}
@ -149,40 +145,60 @@ Cypress.Commands.add("addHotWallet", (wallet_name, device_name, node_type, walle
})
})
Cypress.Commands.add("addWallet", (wallet_name, device_name, wallet_type, funded, node_type) => {
if (wallet_type == null) {
wallet_type = "segwit"
Cypress.Commands.add("addWallet", (walletName, walletType, funded, nodeType, keyType, deviceNameOne, deviceNameTwo, deviceNameThree) => {
if (walletType == null) {
walletType = "segwit"
}
if (device_name == null) {
device_name = "DIY ghost"
if (deviceNameOne == null) {
deviceNameOne = "DIY ghost"
}
cy.get('body').then(($body) => {
if ($body.text().includes(wallet_name)) {
cy.contains(wallet_name).click()
if ($body.text().includes(walletName)) {
cy.contains(walletName).click()
cy.get('#btn_settings' ).click( {force: true})
cy.get('#advanced_settings_tab_btn').click()
cy.get('#delete_wallet').click()
}
cy.get('#side-content').click()
cy.get('#btn_new_wallet').click()
cy.get('[href="./simple/"]').click()
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('#type_nested_segwit_btn').click()
if (keyType == 'singlesig') {
cy.get('[href="./simple/"]').click()
var device_button = "#"+deviceNameOne.toLowerCase().replace(/ /g,"_")
cy.get(device_button).click()
cy.get('#wallet_name').type(walletName)
if (walletType == "nested_segwit") {
cy.get('#type_nested_segwit_btn').click()
}
if (walletType == "taproot") {
cy.get('#type_taproot_btn').click()
}
}
if (wallet_type == "taproot") {
cy.get('#type_taproot_btn').click()
// Makes a 2 out 3 multisig
else if (keyType == "multisig") {
cy.get('[href="./multisig/"]').click()
var deviceButtonOne = "#"+deviceNameOne.toLowerCase().replace(/ /g,"_")
cy.get(deviceButtonOne).click()
var deviceButtonTwo = "#"+deviceNameTwo.toLowerCase().replace(/ /g,"_")
cy.get(deviceButtonTwo).click()
var deviceButtonThree = "#"+deviceNameThree.toLowerCase().replace(/ /g,"_")
cy.get(deviceButtonThree).click()
cy.get('#submit-device').click()
cy.get('#wallet_name').type(walletName)
if (walletType == "nested_segwit") {
cy.get('#type_nested_segwit_btn').click()
}
cy.get(':nth-child(9) > .inline').clear()
cy.get(':nth-child(9) > .inline').type(2)
}
cy.get('#keysform > .centered').click()
cy.get('body').contains("New wallet was created successfully!")
cy.get('#btn_continue').click()
cy.get('#page_overlay_popup_cancel_button').click()
if (funded) {
cy.mine2wallet(node_type)
cy.mine2wallet(nodeType)
}
})
})
Cypress.Commands.add("deleteWallet", (name) => {
cy.get('body').then(($body) => {
if ($body.text().includes(name)) {
@ -234,9 +250,10 @@ Cypress.Commands.add("mine2wallet", (chain) => {
// Quick and easy way to fill out the send form and create a psbt
Cypress.Commands.add("createPsbt", (address, label="a_label", amount=0.01) => {
cy.get('#btn_send').click()
cy.get('#address_0').type(address)
cy.get('#label_0').type(label)
// 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('#send_max_0').click()
cy.get('#amount_0').type(amount)
cy.get('#recipient_0').find('#amount', { includeShadowDom: true }).type(amount)
cy.get('#create_psbt_btn').click()
})

View file

@ -10,5 +10,5 @@ RUN DEBIAN_FRONTEND="noninteractive" apt-get install --no-install-recommends -y
WORKDIR /test
RUN rm -rf node_modules package-lock.json ~/.cache/Cypress
RUN npm install --save-dev cypress@9.5.4
RUN npm install --save-dev cypress@9.7.0
RUN $(npm bin)/cypress verify

View file

@ -1,7 +1,10 @@
An image, ready to be used with cypress but also provides all the dependencies we need for testing specter-desktop.
Use versions of cypress as the version part of the tag. So e.g.:
```
docker build . -t registry.gitlab.com/cryptoadvance/specter-desktop/cypress-python
docker push registry.gitlab.com/cryptoadvance/specter-desktop/cypress-python
```
docker build . -t registry.gitlab.com/cryptoadvance/specter-desktop/cypress-python:v9.7.0
docker push registry.gitlab.com/cryptoadvance/specter-desktop/cypress-python:v9.7.0
```
Search for `cypress-python` on where this is used in the project.

14
package-lock.json generated
View file

@ -13,7 +13,7 @@
"wait-on": "^5.3.0"
},
"devDependencies": {
"cypress": "^9.5.4",
"cypress": "^9.7.0",
"cypress-wait-until": "^1.7.1"
}
},
@ -535,9 +535,9 @@
}
},
"node_modules/cypress": {
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-9.5.4.tgz",
"integrity": "sha512-6AyJAD8phe7IMvOL4oBsI9puRNOWxZjl8z1lgixJMcgJ85JJmyKeP6uqNA0dI1z14lmJ7Qklf2MOgP/xdAqJ/Q==",
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-9.7.0.tgz",
"integrity": "sha512-+1EE1nuuuwIt/N1KXRR2iWHU+OiIt7H28jJDyyI4tiUftId/DrXYEwoDa5+kH2pki1zxnA0r6HrUGHV5eLbF5Q==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@ -2261,9 +2261,9 @@
}
},
"cypress": {
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-9.5.4.tgz",
"integrity": "sha512-6AyJAD8phe7IMvOL4oBsI9puRNOWxZjl8z1lgixJMcgJ85JJmyKeP6uqNA0dI1z14lmJ7Qklf2MOgP/xdAqJ/Q==",
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-9.7.0.tgz",
"integrity": "sha512-+1EE1nuuuwIt/N1KXRR2iWHU+OiIt7H28jJDyyI4tiUftId/DrXYEwoDa5+kH2pki1zxnA0r6HrUGHV5eLbF5Q==",
"dev": true,
"requires": {
"@cypress/request": "^2.88.10",

View file

@ -12,7 +12,7 @@
"wait-on": "^5.3.0"
},
"devDependencies": {
"cypress": "^9.5.4",
"cypress": "^9.7.0",
"cypress-wait-until": "^1.7.1"
},
"scripts": {

View file

@ -28,8 +28,7 @@ class PsbtCreator:
):
"""
* depending of ui_option = (ui|text) Fill the payment-details in either of these:
* request_form: expects the payment-details in a dict request_form:
{ "address_1":"bc1...","btc_amount_1":"0.2", "amount_unit_1":"btc", "label_1":"someLabel","address_2": ...}
* request_form: For details on the structure of the data for each recipient (amounts, addresses, etc.) see below at paymentinfo_from_ui
* recipients_txt: expects the payment-details in textblock "recipients" and recipients_amount_unit for all
amounts in recipients_txt either "sats" or "btc"
* in both cases, the request_form also contains:
@ -135,32 +134,39 @@ class PsbtCreator:
@classmethod
def paymentinfo_from_ui(cls, specter, wallet, request_form):
"""calculates the correct format needed by wallet.createpsbt() out of a request-form
returns something like (addresses, labels, amounts, amount_units) (all arrays)
"""Calculates the correct format needed by wallet.createpsbt() out of a request form.
The recipient_dicts part in the form is a list of dicts and looks like this:
[{'unit': 'btc', 'amount': 1, 'btc_amount': 1, 'recipient_id': 0, 'label': '', 'address': 'bcrt1q ... 58qwn'},
{'unit': 'btc', 'amount': 2, 'btc_amount': 2, 'recipient_id': 1, 'label': '', 'address': 'bcrt1q ... vaa3p'},
{'unit': 'btc', 'amount': 3, 'btc_amount': 3, 'recipient_id': 2, 'label': '', 'address': 'bcrt1q ... n0a85'}]
Returns (addresses, labels, amounts, amount_units) (all arrays)
"""
i = 0
addresses = []
labels = []
amounts = []
amount_units = []
while "address_{}".format(i) in request_form:
addresses.append(request_form["address_{}".format(i)])
recipient_dicts = json.loads(request_form["recipient_dicts"])
print(recipient_dicts)
for recipient_dict in recipient_dicts:
addresses.append(recipient_dict["address"])
amount = 0.0
try:
amount = float(request_form["btc_amount_{}".format(i)])
amount = float(recipient_dict["btc_amount"])
except ValueError:
pass
if isnan(amount):
amount = 0.0
amounts.append(amount)
unit = request_form["amount_unit_{}".format(i)]
unit = recipient_dict["unit"]
if specter.is_liquid and unit in ["sat", "btc"]:
unit = specter.default_asset
amount_units.append(unit)
labels.append(request_form["label_{}".format(i)])
if request_form["label_{}".format(i)] != "":
wallet.setlabel(addresses[i], labels[i])
i += 1
labels.append(recipient_dict["label"])
if recipient_dict["label"] != "":
wallet.setlabel(addresses[-1], labels[-1])
return addresses, labels, amounts, amount_units
@classmethod
@ -170,7 +176,6 @@ class PsbtCreator:
"""calculates the correct format needed by wallet.createpsbt() out of a request-form
out of a textbox holding addresses and amounts.
"""
i = 0
addresses = []
labels = []
amounts = []
@ -264,7 +269,7 @@ class PsbtCreator:
"""calculates the needed kwargs fow wallet.createpsbt() out of a request_form"""
# Who pays the fees?
subtract = str2bool(request_form.get("subtract", False))
subtract_from = int(request_form.get("subtract_from", 1))
subtract_from = int(request_form.get("subtract_from", 0))
fee_option = request_form.get("fee_option")
fee_rate = None
if fee_option:
@ -296,7 +301,7 @@ class PsbtCreator:
rbf_tx_id = request_form.get("rbf_tx_id", "")
kwargs = {
"subtract": subtract,
"subtract_from": subtract_from - 1,
"subtract_from": subtract_from,
"fee_rate": fee_rate,
"rbf": rbf,
"selected_coins": selected_coins,
@ -319,14 +324,14 @@ class PsbtCreator:
except JSONDecodeError as e:
raise SpecterError(f"Error parsing json: {e}")
subtract = bool(json_data.get("subtract", False))
subtract_from = int(json_data.get("subtract_from", 1))
subtract_from = int(json_data.get("subtract_from", 0))
fee_rate = float(json_data.get("fee_rate", None))
rbf = bool(json_data.get("rbf", False))
rbf_tx_id = json_data.get("rbf_tx_id", "")
kwargs = {
"subtract": subtract,
"subtract_from": subtract_from - 1,
"subtract_from": subtract_from,
"fee_rate": fee_rate,
"rbf": rbf,
"selected_coins": [],

View file

@ -59,3 +59,6 @@ class LAddressList(AddressList):
return self[addr]
except KeyError:
return default
except TypeError:
logger.warning(f"{addr} seems to be invalid")
return default

View file

@ -462,7 +462,7 @@ def send_new(wallet_alias):
recipients_txt = ""
fillform = False
subtract = False
subtract_from = 1
subtract_from = 0
fee_options = "dynamic"
rbf = not app.specter.is_liquid
rbf_utxo = []

View file

@ -669,6 +669,24 @@ def utxo_csv(wallet_alias):
return _("Failed to export wallet utxo. Error: {}").format(e), 500
@wallets_endpoint_api.route(
"/wallet/<wallet_alias>/is_address_mine/<address>", methods=["GET"]
)
@login_required
def is_address_mine(wallet_alias, address):
wallet = app.specter.wallet_manager.get_by_alias(wallet_alias)
# filter out invalid input
if (not address) or not isinstance(address, str):
return jsonify(False)
# Segwit addresses are always between 14 and 74 characters long.
if len(address) < 14:
return jsonify(False)
return jsonify(wallet.is_address_mine(address))
@wallets_endpoint_api.route("/wallet/<wallet_alias>/send/estimatefee", methods=["POST"])
@login_required
def estimate_fee(wallet_alias):

View file

@ -131,3 +131,25 @@ function numberWithCommas(x) {
}
return x.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
async function send_request(url, method_str, csrf_token, formData) {
if (!formData) {
formData = new FormData();
}
formData.append("csrf_token", csrf_token)
d = {
method: method_str,
}
if (method_str == 'POST') {
d['body'] = formData;
}
const response = await fetch(url, d);
if(response.status != 200){
showError(await response.text());
console.log(`Error while calling ${url} with ${method_str} ${formData}`)
return
}
return await response.json();
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><path d="M13,8c0-2.21-1.79-4-4-4S5,5.79,5,8s1.79,4,4,4S13,10.21,13,8z M11,8c0,1.1-0.9,2-2,2S7,9.1,7,8s0.9-2,2-2S11,6.9,11,8z M1,18v2h16v-2c0-2.66-5.33-4-8-4S1,15.34,1,18z M3,18c0.2-0.71,3.3-2,6-2c2.69,0,5.78,1.28,6,2H3z M20,15v-3h3v-2h-3V7h-2v3h-3v2 h3v3H20z"/></g></svg>

After

Width:  |  Height:  |  Size: 459 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>

After

Width:  |  Height:  |  Size: 258 B

View file

@ -14,7 +14,9 @@ html, body{
--cmap-bg-lighter: #263044;
--cmap-bg-lightest: #313E50;
--cmap-border: #506072;
--cmap-border-highlight:#4B8CD8;
--cmap-border-darker: #405062;
--cmap-bg-address-is-mine:#306d30;
margin: 0;
padding: 0;
@ -1244,3 +1246,43 @@ input:checked + .slider:before {
-ms-transform: translateX(26px);
transform: translateX(26px);
}
.recipient_wrapper{
width: 100%;
border-radius: 3px;
box-shadow: 0px 10px 15px rgba(0,0,0,0.1);
}
.recipient_wrapper_item{
border-radius: 5px;
border: 1.5px solid var(--default-color);
padding: 5px;
margin-bottom: 5px;
}
.recipient_wrapper_inner_box{
padding: 5px;
margin-top: 5px;
}
.recipient_button{
background: var(--cmap-border);
padding-left: 2px;
padding-right: 2px;
margin-left: 2px;
margin-right: 2px;
border-radius: 3px;
height: 22px;
border: none;
}
.recipient_button:hover {
background: var(--cmap-border-darker);
}
.recipient_dragger {
cursor: grab;
}
.recipient_add {
margin-right: 3px;
height: 25px;
}
.recipient_move {
cursor: default;
padding-left: 6px;
padding-right: 6px;
}

View file

@ -12,8 +12,9 @@
</tool-tip>
</div>
<span id="subtract_from" style="display: none">
<br>{{ _("Subtract from recipient number:") }}
<input id="subtract_from_input" name="subtract_from_stale" type="number" min="1" value="1" step="1" style="width: 80px; min-width: 80px;"><br>
<br>{{ _("Subtract from ") }}
<select id="subtract_from_recipient_id_select" name="subtract_from_stale" style="width: 140px; min-width: 140px;"></select>
<br>
</span>
<br>
<div>
@ -53,7 +54,7 @@
* The API for this component works in a way that it manages 5 hidden inputs which will expose the
* choice of the user.
* * <input type="checkbox" class="rbf-checkbox inline hidden" name="rbf" id="rbf">
* * <input id="subtract_from_input" name="subtract_from" type="number" class="hidden">
* * <input id="subtract_from_recipient_id_select" name="subtract_from" type="number" class="hidden">
* * <input type="hidden" value="dynamic" name="fee_option">
* * <input type="number" class="fee_rate hidden" name="fee_rate" id="fee_rate" value="0.1">
* * <input type="hidden" id="fee_rate_dynamic" name="fee_rate_dynamic" value="0.1" class="hidden">
@ -106,7 +107,7 @@
// Subtract
this.subtract = clone.querySelector("#subtract")
this.subtractFrom = clone.querySelector("#subtract_from")
this.subtractFromInput = clone.querySelector("#subtract_from_input")
this.subtractFromRecipientIdSelect = clone.querySelector("#subtract_from_recipient_id_select")
// Presets
this.feeOptionPreset = this.getAttribute('fee-option-preset') == null ? "dynamic" : this.getAttribute('fee-option-preset')
@ -169,16 +170,11 @@
this.ld.subtract.value = this.subtract.value;
this.appendChild(this.ld.subtract);
// subtract_from (the actual input field)
this.ld.subtractFromInput = this.subtractFromInput.cloneNode(true);
this.ld.subtractFromInput.type = "hidden";
this.ld.subtractFromInput.name = "subtract_from";
this.appendChild(this.ld.subtractFromInput);
// This is just for exposure to the jinja template for displaying, not needed for the form POST
this.ld.subtractFrom = this.subtractFrom.cloneNode(true);
this.ld.subtractFrom.type = "hidden";
this.appendChild(this.ld.subtractFrom);
// subtract_from (for the form)
this.ld.subtractFromRecipientIdSelect = document.createElement("input");
this.ld.subtractFromRecipientIdSelect.type = "hidden";
this.ld.subtractFromRecipientIdSelect.name = "subtract_from";
this.appendChild(this.ld.subtractFromRecipientIdSelect);
}
/**
@ -206,7 +202,7 @@
this.rbfUpdated();
})
this.subtractFromInput.addEventListener("change", (event) => {
this.subtractFromRecipientIdSelect.addEventListener("change", (event) => {
this.subtractUpdated();
})
@ -330,9 +326,9 @@
} catch(e) {console.log(e)}
}
setSubtractFrom(recipient) {
this.subtractFromInput.value = recipient
setSubtractFrom(recipientId) {
this.subtractFromRecipientIdSelect.value = recipientId
this.ld.subtractFromRecipientIdSelect.value = recipientId
}
// Updates Light DOM values for the form
@ -340,7 +336,7 @@
if (this.subtract.checked) {
this.ld.subtract.value = "true"
this.ld.subtract.checked = true
this.ld.subtractFromInput.value = this.subtractFromInput.value
this.ld.subtractFromRecipientIdSelect.value = this.subtractFromRecipientIdSelect.value
}
else {
this.ld.subtract.value = "false"

View file

@ -0,0 +1,484 @@
<!--
A recipient form. The .value property returns a dictionary of address, label, amount and more
{#- Usage -
<recipient-box id=`recipient_0` recipientId=0 title=`Recipient 1`></recipient-box>
or via the class:
let newRecipient = new RecipientBox();
newRecipient.id = `recipient_${recipientId}`;
newRecipient.recipientId = recipientId;
newRecipient.title = `Recipient ${recipientId+1}`;
newRecipient.addEventListener('address-input', (event) => {
validateForm()
})
It exposes the events:
- 'remove' # is called when the remove button is clicked
- 'address-input'
- 'unit-change'
- 'send-max'
It has the attributes: ["value", "name", "id", "title", "address", "label",
"recipientId", "max", "step", "amount", "hiddenRemoveButton"]
These attributes will be assigned to the appropriate html elements
It has the properties:
- 'value' returns a dict with important attributes
#}
-->
<template id="recipient-box">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles.css') }}">
<style>
.max-btn:hover {
text-decoration: underline;
cursor: pointer;
}
.address {
padding: 7px;
}
.address-label {
padding: 7px;
margin-top: 5px;
margin-bottom: 5px;
}
</style>
<div id="recipient" class="recipient_wrapper_item" >
<span id='title'><!-- the title comes in here--></span>
<img src="{{ url_for('static', filename='img/close.svg') }}" class="recipient_button recipient_remove" title="Remove recipient" id="remove" style="float:right">
<div class="recipient_wrapper_inner_box" >
<div class="row" style="gap: 5px">
<input type="text" id="address" name="address" class="address" value="" placeholder='{{ _("Recipient address") }}'>
<qr-scanner id="address-scan" style="margin-top: 3px;">
<a slot="button" class="btn" style="height: 35px;">
<img src="{{ url_for('static', filename='img/qr-code.svg') }}" style="width: 26px; margin: 0px;" class="svg-white"> {{ _("Scan") }}</a>
</qr-scanner>
</div>
<input type="text" id="label" class="address-label" name="label" value="" placeholder='{{ _("Address label") }}'>
<br>
{{ _("Amount:") }}<br>
<input style="width: 200px" type="number" name="amount" id="amount" min=0 step='1e-8' autocomplete="off" placeholder="0"> <!-- the value comes in here-->
<div class="mobile-only" style="margin-top: 10px;"></div>
<span id='asset_selector'><!-- the assetSelector comes in here--></span>
<span class="note max-btn" style="margin-left: 5px;" id="send_max">({{ _("send max") }})</span>
<div>
<span class="note" id="converted_unit_amount">-</span> <span class="note" id="converted_unit_label"><!-- the convertedUnit comes in here--></span> <span class="note" id="converted_unit_alt"></span>
</div>
</div>
<div>
</template>
<template id="liquid-asset-selector">
<select id="amount_unit" name="amount_unit" style="width: 100px;" >
<option value="btc">LBTC</option>
<option value="sat">L-sat</option>
{% for asset in wallet.balance.get("assets",{}).keys() | sort %}
<option value="{{asset}}">{{asset | assetlabel}}</option>
{% endfor %}
</select>
</template>
<template id="bitcoin-asset-selector">
<label><input id="amount_unit_sat" type="radio" class="inline" style="margin: 0 5px;" name="amount_unit" value="sat">sat</label>
<label><input id="amount_unit_btc" type="radio" class="inline" style="margin: 0 5px;" name="amount_unit" value="btc">BTC</label>
</template>
<script type="text/javascript">
class RecipientBox extends HTMLElement {
constructor() {
super();
this.internals = this.attachInternals();
this.recipientId = null; // MUST be set for a functioning instance
this.buildHTML(); // created the html
this.attachListeners(); // attaches the listeners to some html objects
}
// creates the entire html code (without event listeners) from the template
buildHTML(){
// Create a shadow root
this.attachShadow({
mode: 'open'
});
var template_content = document.getElementById('recipient-box').content;
let clone = template_content.cloneNode(true);
this.amountElement = clone.getElementById('amount');
this.addressElement = clone.getElementById('address');
this.labelElement = clone.getElementById('label');
this.titleElement = clone.getElementById('title');
this.removeElement = clone.getElementById('remove');
this.shadowRoot.appendChild(clone);
this.addAssetSelectorToShadowRoot()
}
// add the html code for the assetSelector
addAssetSelectorToShadowRoot() {
// add different asset selector template for bitcoin and liquid
{% if specter.is_liquid and wallet.balance.get("assets", {}) %}
var template_content = document.getElementById('liquid-asset-selector').content;
var clone = template_content.cloneNode(true);
this.amountUnitElement = clone.getElementById('amount_unit');
// create custom getter and setter functions
this.setUnit = (newValue) => {
this.amountUnitElement.value = newValue;
this.updateUnitLabelAndStep();
}
this.getUnit = () => {
return this.amountUnitElement.value;
}
{% else %}
var template_content = document.getElementById('bitcoin-asset-selector').content;
var clone = template_content.cloneNode(true);
this.amountUnitSatElement = clone.getElementById('amount_unit_sat');
this.amountUnitBtcElement = clone.getElementById('amount_unit_btc');
// create custom getter and setter functions
this.setUnit = (newValue) => {
this.amountUnitBtcElement.checked = newValue == 'btc';
this.amountUnitSatElement.checked = newValue == 'sat';
this.updateUnitLabelAndStep();
}
this.getUnit = () => {
return this.amountUnitBtcElement.checked ? this.amountUnitBtcElement.value : this.amountUnitSatElement.value ;
}
{% endif %}
this.shadowRoot.getElementById('asset_selector').appendChild(clone);
}
// adds EventListeners
attachListeners(){
// I cannot use getElementById , but have to use querySelectorAll, because there are multiple unit buttons,
// that need to trigger the same event
this.shadowRoot.querySelectorAll('[name="amount_unit"]').forEach(input => {
input.addEventListener('change',() => {
this.updateUnitLabelAndStep();
this.dispatchEvent(new CustomEvent('unit-change'));
});
});
this.addressElement.addEventListener('input', event=>{
this.dispatchAddressOnInput();
});
this.shadowRoot.getElementById('address-scan').addEventListener('scan', event=>{
let addr = event.detail.result;
if(addr == null){
return;
}
// remove bitcoin: stuff
if(addr.indexOf("bitcoin:") >= 0){
addr = addr.substr(addr.indexOf("bitcoin:")+8);
}
let arr = addr.split("?");
addr = arr[0];
this.addressElement.value = addr;
let evt = new Event('input');
this.addressElement.dispatchEvent(evt);
// parse metadata like amount and message
if(arr.length > 1){
arr = arr[1].split("&");
arr.forEach((e)=>{
if(e.startsWith("amount=")){
let val = parseFloat(e.substr(7));
if(this.unit == 'sat'){
val = Math.round(val*1e8);
}
this.amountElement.value = val;
let evt = new Event('input');
this.amountElement.dispatchEvent(evt);
}
if(e.startsWith("message=") || e.startsWith("label=")){
this.labelElement.value = e.split("=")[1];
}
});
}
});
this.shadowRoot.getElementById('send_max').addEventListener('click', event=>{
this.dispatchSendMax()
});
this.removeElement.addEventListener('click', event=>{
this.dispatchRemove()
});
this.amountElement.addEventListener('input', event=>{
this.calculateConvertedUnit()
});
}
markAdddressGreenIfMine(){
// mark own addresses green
this.isAddressMyOwn().then((isMine) => {
this.markRecipient(isMine);
});
}
dispatchAddressOnInput(result) {
this.markAdddressGreenIfMine();
this.dispatchEvent(new CustomEvent('address-input'));
}
dispatchRemove(result) {
this.dispatchEvent(new CustomEvent('remove'));
}
dispatchSendMax(result) {
this.dispatchEvent(new CustomEvent('send-max'));
}
// this function together with attributeChangedCallback ensures that setting attributes directly in html
// like <recipient-box title='title'></div> works.
static get observedAttributes() {
// define the attributes that can be defined via <recipient-box name="recipient-box" id='4'></recipient-box>
var attributes = ["value", "name", "id", "title", "address", "label", "unit",
"recipientId", "max", "step", "amount", "hiddenRemoveButton"];
return attributes;
}
attributeChangedCallback(attribute, oldValue, newValue) {
if (oldValue == newValue){return}
if (attribute=="value"){
this.value = newValue;
} else if (attribute=="name"){
this.name = newValue;
} else if (attribute=="address"){
this.address = newValue;
} else if (attribute=="title"){
this.title = newValue;
} else if (attribute=="recipientId"){
this.recipientId = newValue;
} else if (attribute=="label"){
this.label = newValue;
} else if (attribute=="unit"){
this.unit = newValue;
} else if (attribute=="step"){
this.step = newValue;
} else if (attribute=="max"){
this.max = newValue;
} else if (attribute=="amount"){
this.amount = newValue;
} else if (attribute=="id"){
this.id = newValue;
} else if (attribute=="hiddenRemoveButton"){
this.hiddenRemoveButton = newValue;
}
}
// title
set title(newValue){
this.titleElement.innerText = newValue;
}
get title(){
return this.titleElement.innerText;
}
// address
set address(newValue){
this.addressElement.value = newValue;
this.markAdddressGreenIfMine();
}
get address(){
return this.addressElement.value;
}
// label
set label(newValue){
this.labelElement.value = newValue;
}
get label(){
return this.labelElement.value;
}
// unit
set unit(newValue){
// this function is different for bitcoin and liquid
this.setUnit(newValue);
}
get unit(){
// this function is different for bitcoin and liquid
return this.getUnit();
}
// step
set step(newValue){
this.amountElement.step = newValue;
}
get step(){
return parseFloat(this.amountElement.step );
}
// amount
set amount(newValue){
this.amountElement.value = newValue;
this.calculateConvertedUnit()
}
get amount(){
var value = parseFloat(this.amountElement.value );
return value;
}
// max
set max(newValue){
this.amountElement.max = newValue;
}
get max(){
return parseFloat(this.amountElement.max );
}
// value
set value(newValue) {
// loop through all the keys and set the attributes
for (var key in newValue) {
this.attributeChangedCallback(key, null, newValue[key]);
}
}
// Returns a dict with important attributes
// the keys are in python style, because they will be later sent to the python server
get value() {
return {
"unit":this.unit,
"amount":this.amount,
"btc_amount":this.btcAmount,
"recipient_id":this.recipientId,
"label":this.label,
"address":this.address,
}
}
static get properties() {
return {
value: {
type: String
}
};
}
// btcAmount
get btcAmount(){
return (this.unit == 'sat' ? this.amount / 1e8 : this.amount);
}
// hiddenRemoveButton
set hiddenRemoveButton(newValue){
if (!this.shadowRoot) {return }
this.removeElement.hidden = newValue;
}
get hiddenRemoveButton(){
return this.removeElement.hidden;
}
updateUnitLabelAndStep() {
let unitLabelEl = this.shadowRoot.getElementById('converted_unit_label');
if(this.unit == 'sat'){
unitLabelEl.innerHTML = 'BTC';
}else if(this.unit == 'btc'){
unitLabelEl.innerHTML = 'sat';
}else{
unitLabelEl.innerHTML = '';
}
this.step = this.unit == 'sat' ? 1 : 1e-8;
this.calculateConvertedUnit();
}
calculateConvertedUnit() {
let convertedAmount = parseFloat(Number.parseFloat(this.amount / (this.unit == 'sat' ? 1e8 : 1e-8)).toFixed((this.unit == 'sat' ? 8 : 0)));
if (convertedAmount == 'NaN') {
convertedAmount = '-'
}
if(this.unit == 'sat' || this.unit == 'btc'){
this.shadowRoot.getElementById('converted_unit_amount').innerHTML = (isNaN(convertedAmount) ? '-' : convertedAmount.toFixed(8).replace(/(\.0+|0+)$/, ''));
{% if specter.price_check %}
let altRate = parseFloat('{{ specter.alt_rate }}');
let altSymbol = '{{ specter.alt_symbol }}';
let altAmount = parseFloat((altRate * this.btcAmount).toFixed(2))
if (!isNaN(altAmount) && (altSymbol && altRate)) {
this.shadowRoot.getElementById('converted_unit_alt').innerHTML = '&nbsp;(' + altAmount + altSymbol + ')';
} else {
this.shadowRoot.getElementById('converted_unit_alt').innerHTML = '';
}
{% endif %}
}else{
this.shadowRoot.getElementById('converted_unit_amount').innerHTML = '&nbsp;';
this.shadowRoot.getElementById('converted_unit_alt').innerHTML = '&nbsp;';
}
}
async isAddressMyOwn() {
let address = this.addressElement.value;
if (!address){return;}
let url="{{ url_for('wallets_endpoint_api.is_address_mine', wallet_alias=wallet.alias, address='this_address') }}"
url = url.replace('this_address', address)
let is_address_mine = send_request(url, 'GET', "{{ csrf_token() }}");
return is_address_mine;
}
markRecipient(is_address_mine){
let html_address = this.addressElement;
if (is_address_mine) {
html_address.style.backgroundColor= 'var(--cmap-bg-address-is-mine)';
} else {
html_address.style.backgroundColor= "rgba(1, 1, 1, 0)";
}
}
}
customElements.define('recipient-box', RecipientBox);
</script>

View file

@ -2,11 +2,7 @@
{% set tab = 'send' %}
{% block content %}
<style>
.max-btn:hover {
text-decoration: underline;
cursor: pointer;
}
<style>
#calculated_tx_fee_label:hover {
text-decoration: underline;
cursor: pointer;
@ -14,6 +10,7 @@
</style>
{% include "includes/qr-scanner.html" %}
{% include "includes/recipient-box.html" %}
{% from 'wallet/send/components/send_nav.jinja' import send_nav %}
{{ send_nav('send_new', wallet_alias) }}
@ -26,6 +23,7 @@
<form action="{{ url_for('wallets_endpoint.send_new',wallet_alias=wallet_alias) }}" id="send-form" method="POST" style="width: 100%;">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="rbf_tx_id" value="{{ rbf_tx_id }}"/>
<input type="hidden" name="recipient_dicts" id="recipient_dicts" value=""/>
<h1 class="padded">{{ _("Create Transaction") }}</h1>
<div style="display: flex; justify-content: center; margin-bottom: 15px">
<div>{{ _("Available funds:") }} {{wallet.amount_available | btcunitamount}}
@ -62,12 +60,13 @@
</div>
{% endif %}
</div>
<div class="card" style="margin: auto;">
<div id="recipients" {% if ui_option != 'ui' %}class="hidden"{% endif %}></div>
<div class="card" style="margin: auto;">
<div id="recipients" {% if ui_option != 'ui' %}class="hidden"{% else %} class="recipient_wrapper" {% endif %}></div>
<img src="{{ url_for('static', filename='img/add-person.svg') }}" id="add-recipient" title="Add a recipient" class="recipient_button recipient_add" style="float:right" onclick="addRecipient('', '', 'btc', '')">
<div id="recipients-txt-container" {% if ui_option == 'ui' %}class="hidden"{% endif %}>
{{ _("Unit:") }}
<label><input type="radio" class="inline" style="margin: 0 5px;" name="amount_unit_text" value="sat" onchange="toggleUnit(this, 'text')" {% if specter.unit == 'sat' %}checked{% endif %}>sat</label>
<label><input type="radio" class="inline" style="margin: 0 5px;" name="amount_unit_text" value="btc" onchange="toggleUnit(this, 'text')" {% if specter.unit != 'sat' %}checked{% endif %}>BTC</label><br>
<label><input type="radio" class="inline" style="margin: 0 5px;" name="amount_unit_text" value="sat" onchange="toggleTextUnit(this)" {% if specter.unit == 'sat' %}checked{% endif %}>sat</label>
<label><input type="radio" class="inline" style="margin: 0 5px;" name="amount_unit_text" value="btc" onchange="toggleTextUnit(this)" {% if specter.unit != 'sat' %}checked{% endif %}>BTC</label><br>
<textarea id="recipients-txt" name="recipients" placeholder="{{ _('Enter recipient address, amount') }}" style="margin-top: 5px; font-size: 0.95em;" onblur="validateForm()">{{ recipients_txt }}</textarea>
<p class="note" style="line-height: 2; background-color: #16202d; padding: 10px; border-radius: 7px;">
@ -77,10 +76,9 @@
&lt;{{ _("ADDRESS") }}3&gt;, &lt;{{ _("AMOUNT") }}3&gt;<br>
</p>
</div>
<button id="remove-recipient" style="width: 200px; height: 38px;" type="button" class="btn hidden" onclick="removeRecipient()"><b>&#8722;</b>&nbsp; &nbsp;{{ _("Remove recipient") }}</button>
<p><span id="calculated_tx_fee_label" class="note" style="margin-top: 17px;" onclick="calculateEstimatedFee()">{{ _("Calculate estimated fee") }}</span><span class="note" id="calculated_tx_fee"></span><p>
<span id="toggle_advanced" style="cursor: pointer;">{{ _("Advanced") }} {% if show_advanced_settings %}&#9660;{% else %}&#9654;{% endif %}</span>
<br><br>
<br>
<div id="advanced_settings" style="margin: auto; max-width: 90%; display: {% if show_advanced_settings %}block{% else %}none{% endif %};">
<div>{{ _("Transaction editor:") }}
<label><input type="radio" class="inline" style="margin: 0 10px 0 20px;" name="ui_option" value="ui" onclick="toggleSendUIType(this)" {% if ui_option == 'ui' %}checked{% endif %} id="ui-radio-btn">UI</label>
@ -91,9 +89,7 @@
<br>
<br>
<br>
<div class="row break-row-mobile" id="coin-selection-row" style="margin-top: 30px;">
<button id="add-recipient" style="width: 200px; height: 38px;" type="button" class="btn" onclick="addRecipient('', 0, 'btc', '')"><svg width="20" height="20" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg> {{ _("Add recipient") }} </button>
</div><br>
<div class="row break-row-mobile" id="vertical-space-for-subtract-fees-from-amount-checkbox" style="margin-top: 0px;"></div>
</div>
{% include "includes/tx-row.html" %}
{% include "includes/tx-data.html" %}
@ -112,10 +108,8 @@
</form>
{% endblock %}
{% block scripts %}
<script>
{% if specter.is_liquid %}
const MIN_FEE_RATE = 0.1;
const NETWORK = "Liquid"
@ -124,9 +118,52 @@
const NETWORK = "Bitcoin"
{% endif %}
function getRecipientIds() {
var ids = [];
var children = document.getElementById(`recipients`).children;
for (var i = 0; i < children.length; i++) {
ids.push(children[i].recipientId)
}
return ids
}
// combines all html recipients to a list
function getRecipients() {
var data = [];
var recipientIds = getRecipientIds();
for (var i in recipientIds) {
var recipientId = recipientIds[i];
let recipient = document.getElementById(`recipient_${recipientId}`);
data.push(recipient);
}
return data;
}
// combines all recipient.value's to a list
function getRecipientDicts() {
var data = [];
var recipients = getRecipients();
for (var i in recipients) {
data.push(recipients[i].value);
}
return data;
}
function setVisibilityRemoveRecipientButton(){
var recipientList = getRecipients();
var hidden = recipientList.length == 1
for (var i in recipientList) {
recipientList[i].hiddenRemoveButton = hidden;
}
}
// Amounts and units
var units = [];
var amounts = [];
var textUnit = '{{ specter.unit }}';
// TODO: use total balance including unconfirmed
var assetBalances = {
@ -176,7 +213,7 @@
// Main part of displaying subtractFrom, the rest is with addRecipient, deleteRecipient and toggleSendUIType
FeeSelectionComponent.addEventListener("subtractClick", (event) => {
if ((amounts.length > 1 || !document.getElementById('ui-radio-btn').checked) && document.getElementById('subtract').checked) {
if ((getRecipientIds().length > 1 || !document.getElementById('ui-radio-btn').checked) && document.getElementById('subtract').checked) {
FeeSelectionComponent.showSubtractFrom(true)
if (!document.getElementById('ui-radio-btn').checked) {
FeeSelectionComponent.addLineBreaks(2)
@ -190,181 +227,107 @@
}
})
function addRecipient(addr, amount, amount_unit, label) {
let i = amounts.length;
amounts.push(amount);
units.push(amount_unit);
let valueOrPlaceholder = amount == 0 ? 'placeholder="0"' : `value=${amount}`
if (amount_unit == 'sat') {
amount = parseFloat(Number.parseFloat(amount * 1e8).toFixed(0));
if (amount == 'NaN') {
amount = '-'
}
}
let step = 1;
if (amount_unit == 'btc') {
step = 1e-8;
}
let satChecked = amount_unit == 'btc' ? '' : 'checked';
let btcChecked = amount_unit == 'btc' ? 'checked' : '';
let convertedUnit = amount_unit == 'btc' ? 'sat' : 'BTC';
{% if specter.is_liquid and wallet.balance.get("assets", {}) %}
let assetSelector = `
<select name="amount_unit_${i}" style="width: 100px;" onchange="toggleUnit(this, ${i})">
<option value="btc">LBTC</option>
<option value="sat">L-sat (10⁻⁸ LBTC)</option>
{% for asset in wallet.balance.get("assets",{}).keys() | sort %}
<option value="{{asset}}">{{asset | assetlabel}}</option>
{% endfor %}
</select>
`;
{% else %}
let assetSelector = `
<label><input type="radio" class="inline" style="margin: 0 5px;" name="amount_unit_${i}" value="sat" onchange="toggleUnit(this, ${i})" ${satChecked}>sat</label>
<label><input type="radio" class="inline" style="margin: 0 5px;" name="amount_unit_${i}" value="btc" onchange="toggleUnit(this, ${i})" ${btcChecked}>BTC</label>
`;
{% endif %}
let recipientForm = `
<div id="recipient_${i}" style="border-bottom: 1px solid var(--cmap-border); padding-bottom: 10px; margin-bottom: 10px;">
{{ _("Recipient address:") }}<br>
<div class="row">
<input type="text" id="address_${i}" name="address_${i}" oninput="validateForm()" value="${addr}"> &nbsp;
<qr-scanner id="address-scan-${i}" style="margin-top: 3px;">
<a slot="button" class="btn" style="height: 35px;">
<img src="{{ url_for('static', filename='img/qr-code.svg') }}" style="width: 26px; margin: 0px;" class="svg-white"> {{ _("Scan") }}</a>
</qr-scanner>
</div>
<br>
{{ _("Address label (optional):") }}<br>
<input type="text" id="label_${i}" name="label_${i}" value="${label}">
<br><br>
{{ _("Amount:") }}<br>
<input style="width: 200px" type="number" name="amount_${i}" oninput="calculateConvertedUnit(${i})" id="amount_${i}" min=0 step="${step}" autocomplete="off" ${valueOrPlaceholder}>
<input type="hidden" name="btc_amount_${i}" id="btc_amount_${i}">
<div class="mobile-only" style="margin-top: 10px;"></div>
${assetSelector}
<span class="note max-btn" style="margin-left: 5px;" id="send_max_${i}" onclick="setMaxAmount(${i})">({{ _("send max") }})</span>
<div>
<span class="note" id="converted_unit_amount_${i}">-</span> <span class="note" id="converted_unit_label_${i}">${convertedUnit}</span> <span class="note" id="converted_unit_alt_${i}"></span>
</div>
<div>
`
let recipients = document.getElementById('recipients');
let newRecipient = document.createElement('div');
newRecipient.innerHTML = recipientForm;
function addRecipient(address, amount, amount_unit, label) {
let recipientIds = getRecipientIds();
recipientId = 0;
if (recipientIds.length > 0) {
recipientId = Math.max(...recipientIds)+1
};
// This adds the recipient
let recipients = document.getElementById('recipients');
let newRecipient = new RecipientBox();
newRecipient.id = `recipient_${recipientId}`;
newRecipient.recipientId = recipientId;
newRecipient.title = `Recipient ${recipientId+1}`;
newRecipient.address = address;
newRecipient.amount = amount;
newRecipient.unit = amount_unit;
newRecipient.label = label;
newRecipient.addEventListener('remove', (event) => {
removeRecipient(newRecipient.recipientId);
})
newRecipient.addEventListener('address-input', (event) => {
validateForm();
})
newRecipient.addEventListener('unit-change', (event) => {
validateForm();
})
newRecipient.addEventListener('send-max', (event) => {
setMaxAmount(newRecipient.recipientId);
})
recipients.appendChild(newRecipient);
document.getElementById('address-scan-' + i).addEventListener('scan', e=>{
let addr = e.detail.result;
if(addr == null){
return;
}
// remove bitcoin: stuff
if(addr.indexOf("bitcoin:") >= 0){
addr = addr.substr(addr.indexOf("bitcoin:")+8);
}
let arr = addr.split("?");
addr = arr[0];
document.getElementById("address_" + i).value = addr;
let evt = new Event('input');
document.getElementById("address_" + i).dispatchEvent(evt);
// parse metadata like amount and message
if(arr.length > 1){
arr = arr[1].split("&");
arr.forEach((e)=>{
if(e.startsWith("amount=")){
let val = parseFloat(e.substr(7));
if(units[i] == 'sat'){
val = Math.round(val*1e8);
}
document.getElementById("amount_" + i).value = val;
let evt = new Event('input');
document.getElementById("amount_" + i).dispatchEvent(evt);
}
if(e.startsWith("message=") || e.startsWith("label=")){
document.getElementById("label_" + i).value = e.split("=")[1];
}
});
}
});
if (amounts.length == 1) {
document.getElementById('remove-recipient').style.display = 'none';
document.getElementById('coin-selection-row').style['margin-top'] = '30px';
// This adds the id's to the subtract_from_recipient_id_select
var feeSelectionComponent = document.getElementById('fee-selection-component');
var subtractFromRecipientIdSelect = feeSelectionComponent.subtractFrom.children.subtract_from_recipient_id_select;
var opt = document.createElement('option');
opt.value = recipientId;
opt.innerHTML = newRecipient.title;
subtractFromRecipientIdSelect.appendChild(opt);
// Make the remove button visible again for all recipient components
setVisibilityRemoveRecipientButton();
// control visibility and vertical spaces
if (getRecipientIds().length == 1) {
FeeSelectionComponent.showSubtractFrom(false);
document.getElementById('vertical-space-for-subtract-fees-from-amount-checkbox').style['margin-top'] = '0px';
} else {
document.getElementById('remove-recipient').style.display = 'block';
document.getElementById('coin-selection-row').style['margin-top'] = '90px';
document.getElementById('vertical-space-for-subtract-fees-from-amount-checkbox').style['margin-top'] = '60px';
if (document.getElementById('subtract').checked) {
FeeSelectionComponent.showSubtractFrom(true)
}
}
calculateConvertedUnit(i);
}
function removeRecipient() {
let i = amounts.length - 1;
let recipient = document.getElementById('recipient_' + i);
recipient.parentNode.removeChild(recipient);
amounts.splice(i, 1);
units.splice(i, 1);
if (amounts.length == 1) {
document.getElementById('remove-recipient').style.display = 'none';
function removeRecipient(recipientId=-1) {
if (recipientId==-1) {
recipientId = getRecipientIds().length - 1
};
let recipients = document.getElementById('recipients');
// This removes the recipient div
for (var i = recipients.children.length -1; i >= 0; i--) {
let child = recipients.children[i];
if (child.recipientId == recipientId) {
recipients.removeChild(child)
}
}
// This removes the recipientId from the subtract_from_recipient_id_select
var subtractFromRecipientIdSelect = FeeSelectionComponent.subtractFrom.children.subtract_from_recipient_id_select;
for (var i = subtractFromRecipientIdSelect.children.length -1; i >= 0; i--) {
let child = subtractFromRecipientIdSelect.children[i];
if (child.value == recipientId) {
subtractFromRecipientIdSelect.removeChild(child)
}
}
// Not strictly necessary but better to have it to avoid any divergence
FeeSelectionComponent.subtractUpdated();
// Hide the remove button if we only have one recipient
setVisibilityRemoveRecipientButton();
// control visibility and vertical spaces
if (getRecipientIds().length == 1) {
FeeSelectionComponent.showSubtractFrom(false);
document.getElementById('coin-selection-row').style['margin-top'] = '30px';
document.getElementById('vertical-space-for-subtract-fees-from-amount-checkbox').style['margin-top'] = '0px';
} else {
document.getElementById('remove-recipient').style.display = 'block';
if (document.getElementById('subtract').checked) {
document.getElementById('subtract_from').style.display = 'block';
}
document.getElementById('coin-selection-row').style['margin-top'] = '90px';
document.getElementById('vertical-space-for-subtract-fees-from-amount-checkbox').style['margin-top'] = '60px';
}
}
function toggleUnit(unitSelected, i) {
if (i == 'text') {
textUnit = unitSelected.value
} else {
units[i] = unitSelected.value;
let unitLabelEl = document.getElementById('converted_unit_label_' + i);
if(units[i] == 'sat'){
unitLabelEl.innerHTML = 'BTC';
}else if(units[i] == 'btc'){
unitLabelEl.innerHTML = 'sat';
}else{
unitLabelEl.innerHTML = '';
}
document.getElementById('amount_' + i).setAttribute('step', units[i] == 'sat' ? '1' : '1e-8');
calculateConvertedUnit(i);
}
}
function calculateConvertedUnit(i) {
let amountInput = document.getElementById('amount_' + i);
amounts[i] = parseFloat(amountInput.value);
let convertedAmount = parseFloat(Number.parseFloat(amounts[i] / (units[i] == 'sat' ? 1e8 : 1e-8)).toFixed((units[i] == 'sat' ? 8 : 0)));
if (convertedAmount == 'NaN') {
convertedAmount = '-'
}
document.getElementById('btc_amount_' + i).value = (units[i] == 'sat' ? amounts[i] / 1e8 : amounts[i]);
if(units[i] == 'sat' || units[i] == 'btc'){
document.getElementById('converted_unit_amount_' + i).innerHTML = (isNaN(convertedAmount) ? '-' : convertedAmount.toFixed(8).replace(/(\.0+|0+)$/, ''));
{% if specter.price_check %}
let altRate = parseFloat('{{ specter.alt_rate }}');
let altSymbol = '{{ specter.alt_symbol }}';
let altAmount = parseFloat((altRate * parseFloat(document.getElementById('btc_amount_' + i).value)).toFixed(2))
if (!isNaN(altAmount) && (altSymbol && altRate)) {
document.getElementById('converted_unit_alt_' + i).innerHTML = '&nbsp;(' + altAmount + altSymbol + ')';
} else {
document.getElementById('converted_unit_alt_' + i).innerHTML = '';
}
{% endif %}
}else{
document.getElementById('converted_unit_amount_' + i).innerHTML = '&nbsp;';
document.getElementById('converted_unit_alt_' + i).innerHTML = '&nbsp;';
}
validateForm();
function toggleTextUnit(unitSelected) {
textUnit = unitSelected.value
}
function isAboveWalletBalance(unit, amount) {
@ -386,22 +349,25 @@
return (unit == 'sat' ? amount / 1e8 : amount) < 1e-8;
}
async function setMaxAmount(i) {
async function setMaxAmount(recipientId) {
const recipients = getRecipients();
const recipient = document.getElementById(`recipient_${recipientId}`);
FeeSelectionComponent.showSubtractFrom(true)
FeeSelectionComponent.setSubtractFrom(i + 1)
let amountInput = document.getElementById('amount_' + i);
if (!validAddress(i)) {
FeeSelectionComponent.setSubtractFrom(recipientId)
if (!validAddress(recipientId)) {
return;
}
FeeSelectionComponent.setSubtract(units[i] == 'sat' || units[i] == 'btc' || units[i] == 'lbtc');
FeeSelectionComponent.setSubtract(recipient.unit == 'sat' || recipient.unit == 'btc' || recipient.unit == 'lbtc');
let othersAmount = 0;
for(let j in amounts) {
if (j != i) {
let unit = units[j];
let amount = amounts[j];
for(let j in recipients) {
other_recipient = recipients[j] ;
if (other_recipient.recipientId != recipientId) {
let unit = other_recipient.unit;
let amount = other_recipient.amount;
// assets
if(units[i] != 'sat' || units[i] != 'btc'){
if(unit == units[i]){
if(other_recipient.unit != 'sat' || other_recipient.unit != 'btc'){
if(unit == recipient.unit){
othersAmount += amount;
}
// btc
@ -412,8 +378,9 @@
}
}
}
let maxAmount = (coinselectionWebcomponent.getSpendableAmount(units[i]) - (units[i] == 'sat' ? othersAmount * 1e8 : othersAmount));
if (units[i] == 'sat') {
let maxAmount = (coinselectionWebcomponent.getSpendableAmount(recipient.unit) - (recipient.unit == 'sat' ? othersAmount * 1e8 : othersAmount));
if (recipient.unit == 'sat') {
maxAmount = Math.round(maxAmount);
} else {
maxAmount = parseFloat(maxAmount.toFixed(8));
@ -421,23 +388,20 @@
if (maxAmount < 0) {
maxAmount = 0;
}
amountInput.value = maxAmount;
calculateConvertedUnit(i);
document.getElementById(`recipient_${recipientId}`).amount = maxAmount;
}
// Form validation
function validateAmount(unit, amount, i, allowZero=false) {
function validateAmount(unit, amount, recipientId, allowZero=false) {
if (isNaN(amount)) {
showError(`{{ _("Amount entered is invalid!") }}`, 5000);
return false;
}
if (i) {
let amountInput = document.getElementById('amount_' + i);
if (recipientId) {
if (coinselectionWebcomponent != null) {
amountInput.max = coinselectionWebcomponent.getSpendableAmount(unit);
document.getElementById(`recipient_${recipientId}`).max = coinselectionWebcomponent.getSpendableAmount(unit);
} else {
amountInput.max = spendableAmount
document.getElementById(`recipient_${recipientId}`).max = spendableAmount
}
}
if (!amount && !allowZero) {
@ -463,19 +427,19 @@
}
// Returns true if the address is valid
function validAddress(i) {
function validAddress(recipientId) {
let reWhite = /\s/;
let addressInput = document.getElementById('address_' + i);
if (!addressInput.value) {
let address = document.getElementById(`recipient_${recipientId}`).address;
if (!address) {
showError(`{{ _("You provided no address.") }}`, 5000);
return false
}
// Segwit addresses are always between 14 and 74 characters long.
else if (addressInput.value.length < 14) {
else if (address.length < 14) {
showError(`{{ _("Please provide a valid address!") }}`, 5000);
return false
}
else if (reWhite.test(addressInput.value)) {
else if (reWhite.test(address)) {
showError(`{{ _("Looks like there are whitespaces in the address field.") }}`, 5000);
return false
}
@ -501,6 +465,9 @@
}
function validateForm(submitted=false) {
const recipients = getRecipients();
// this assures that the 'recipient_dicts' input has the newest recipientDicts
document.getElementById('recipient_dicts').value = JSON.stringify(getRecipientDicts());
console.log("validateForm is called")
let createPSBTButton = document.getElementById('create_psbt_btn');
// Disables submit but button still clickable before every validation
@ -508,28 +475,26 @@
let totalAmount = 0.0;
let assetAmounts = {};
if (document.getElementById('ui-radio-btn').checked) {
for(let i in amounts) {
let unit = units[i]
let amount = amounts[i]
if(unit == 'btc' || unit == 'sat'){
totalAmount += (unit == 'sat' ? amount / 1e8 : amount);
for(let i in recipients) {
let recipient = recipients[i];
if(recipient.unit == 'btc' || recipient.unit == 'sat'){
totalAmount += (recipient.unit == 'sat' ? recipient.amount / 1e8 : recipient.amount);
}else{
if(unit in assetAmounts){
assetAmounts[unit] += amount;
if(recipient.unit in assetAmounts){
assetAmounts[recipient.unit] += recipient.amount;
}else{
assetAmounts[unit] = amount;
assetAmounts[recipient.unit] = recipient.amount;
}
}
if (document.getElementById("amount_" + i).value == '') {
amount = 0;
}
if (submitted) {
// Check address
if (!validAddress(i)) {
if (!validAddress(recipient.recipientId)) {
return;
}
// Check amount
if (!validateAmount(unit, amount, i)) {
if (!validateAmount(recipient.unit, recipient.amount, recipient.recipientId)) {
return;
}
}
@ -558,7 +523,10 @@
return;
}
}
// Necessary since recipients can be removed which leades to
// mismatches between the index in the get_recipient_list and the selected recipientId
indexSubtractFromRecipient = getRecipientIds().indexOf(parseInt(FeeSelectionComponent.subtractFromRecipientIdSelect.value, 10));
FeeSelectionComponent.setSubtractFrom(indexSubtractFromRecipient)
createPSBTButton.setAttribute('type', 'submit');
return true;
}
@ -587,16 +555,12 @@
setVisibility('add-recipient', 'flex');
setVisibility('recipients-txt-container', 'none');
setVisibility('subtract', 'block');
if (amounts.length > 1) {
setVisibility('remove-recipient', 'block');
}
else {
if (getRecipientIds().length <= 1) {
FeeSelectionComponent.showSubtractFrom(false)
}
} else {
setVisibility('recipients', 'none');
setVisibility('add-recipient', 'none');
setVisibility('remove-recipient', 'none');
setVisibility('recipients-txt-container', 'block');
// Assumes that text is always used for multiple recipients
if (document.getElementById('subtract').checked) {
@ -612,23 +576,12 @@
if (await validateForm(true) !== true) {
return;
}
try {
try {
var formData = new FormData(document.getElementById('send-form'));
let url="{{ url_for('wallets_endpoint_api.estimate_fee', wallet_alias=wallet.alias) }}"
formData.append("estimate_fee", true)
const response = await fetch(
url,
{
method: 'POST',
body: formData
}
);
if(response.status != 200){
showError(await response.text());
console.log("Error while fetching fees")
return
}
let result = await response.json();
let result = await send_request(url, 'POST', "{{ csrf_token() }}", formData);
console.log(result);
if (result.success) {
let psbt = result.psbt;
@ -653,18 +606,20 @@
return -1;
}
document.addEventListener("DOMContentLoaded", function(){
{% if fillform %}
{% for addr, amount, amount_unit, label in recipients %}
addRecipient("{{ addr }}", {{ amount }}, "{{ amount_unit }}", "{{ label }}");
{% endfor %}
{% else %}
addRecipient("", 0, "btc", "");
{% endif %}
adjustForLiquid()
document.getElementById('toggle_advanced').addEventListener('click', (event) => {
toggleAdvanced();
});
document.addEventListener("DOMContentLoaded", function() {
{% if fillform %}
{% for addr, amount, amount_unit, label in recipients %}
addRecipient("{{ addr }}", {{ amount }}, "{{ amount_unit }}", "{{ label }}");
{% endfor %}
{% else %}
addRecipient("", "", "btc", "");
{% endif %}
adjustForLiquid()
document.getElementById('toggle_advanced').addEventListener('click', (event) => {
toggleAdvanced();
});
// Hide the remove button if the page is loaded with only once recipient
setVisibilityRemoveRecipientButton();
});
</script>
{% endblock %}

View file

@ -28,10 +28,9 @@ It's designed to be used in a form and will "drop" what the user has chosen in f
<input type="hidden" id="fee_rate_dynamic" name="fee_rate_dynamic" value="4" class="hidden">
<input type="hidden" class="rbf-checkbox inline" name="rbf" id="rbf" value="true">
<input type="hidden" class="inline" name="subtract" id="subtract" value="false">
<input id="subtract_from_input" name="subtract_from" type="hidden" min="1" value="1" step="1" style="width: 80px; min-width: 80px;">
<span id="subtract_from" style="display: none">
<br>Subtract from recipient number:
<input id="subtract_from_input" name="subtract_from" type="number" min="1" value="1" step="1" style="width: 80px; min-width: 80px;"><br>
<br>{{ _("Subtract from ") }}
<select id="subtract_from_recipient_id_select" name="subtract_from_stale" style="width: 140px; min-width: 140px;" ></select><br>
</span>
</textarea>

View file

@ -17,23 +17,16 @@ def test_PsbtCreator_ui(caplog):
# Let's mock the request.form which behaves like a dict but also needs getlist()
request_form_data = {
"rbf_tx_id": "",
"address_0": "BCRT1qgc6h85z43g3ss2dl5zdrzrp3ef6av4neqcqhh8", # will need normalisation
"label_0": "someLabel",
"amount_0": "0.1",
"btc_amount_0": "0.1",
"amount_unit_0": "btc",
"address_1": "bcrt1q3kfetuxpxvujasww6xas94nawklvpz0e52uw8a",
"label_1": "someOtherLabel",
"amount_1": "111211",
"btc_amount_1": "0.00111211",
"amount_unit_1": "sat",
"amount_unit_text": "btc",
"subtract_from": "1",
"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":"BCRT1qgc6h85z43g3ss2dl5zdrzrp3ef6av4neqcqhh8","label":"someLabel","btc_amount":"0.1"},'
'{"unit":"sat","amount":111211,"recipient_id":1,"address":"bcrt1q3kfetuxpxvujasww6xas94nawklvpz0e52uw8a","label":"someOtherLabel","btc_amount":"0.00111211"},'
'{"unit":"btc","amount":0.003,"recipient_id":2,"address":"bcrt1qfvkcy2keql72s8ev87ek93uxuq3xxsx9l0n03r","label":"<script>console.log(\'I escaped\')</script>","btc_amount":"0.003"}]',
}
psbt_creator: PsbtCreator = PsbtCreator(
@ -43,10 +36,15 @@ def test_PsbtCreator_ui(caplog):
assert psbt_creator.addresses == [
"bcrt1qgc6h85z43g3ss2dl5zdrzrp3ef6av4neqcqhh8",
"bcrt1q3kfetuxpxvujasww6xas94nawklvpz0e52uw8a",
"bcrt1qfvkcy2keql72s8ev87ek93uxuq3xxsx9l0n03r",
]
assert psbt_creator.amounts == [0.1, 0.00111211]
assert psbt_creator.labels == ["someLabel", "someOtherLabel"]
assert psbt_creator.amount_units == ["btc", "sat"]
assert psbt_creator.amounts == [0.1, 0.00111211, 0.003]
assert psbt_creator.labels == [
"someLabel",
"someOtherLabel",
"<script>console.log('I escaped')</script>",
]
assert psbt_creator.amount_units == ["btc", "sat", "btc"]
assert psbt_creator.kwargs == {
"fee_rate": 64.0,
"rbf": True,
@ -72,7 +70,7 @@ def test_PsbtCreator_text(caplog):
# Let's mock the request.form which behaves like a dict but also needs getlist()
request_form_data = {
"rbf_tx_id": "",
"subtract_from": "1",
"subtract_from": "0",
"fee_option": "dynamic",
"fee_rate": "",
"fee_rate_dynamic": "64",
@ -142,7 +140,7 @@ def test_PsbtCreator_json(caplog):
}
],
"rbf_tx_id": "",
"subtract_from": "1",
"subtract_from": "0",
"fee_rate": "64",
"rbf": true
}

View file

@ -309,6 +309,7 @@ wallets_endpoint_api.fees
wallets_endpoint_api.generatemnemonic
wallets_endpoint_api.get_label
wallets_endpoint_api.get_scantxoutset_status
wallets_endpoint_api.is_address_mine
wallets_endpoint_api.pending_psbt_list
wallets_endpoint_api.rescan_progress
wallets_endpoint_api.set_label

View file

@ -128,7 +128,7 @@ def test_rr_psbt_post(specter_regtest_configured, bitcoin_regtest, client, caplo
}
],
"rbf_tx_id": "",
"subtract_from": "1",
"subtract_from": "0",
"fee_rate": "64",
"rbf": true
}