UIUX: Overhaul of UTXO list, handling of locked UTXOs and scrollbar added to tx-table web component (#1580)

* Added tests/bitcoin to gitignore

* Ensure locked UTXO can't be used for transactions, fix rounding error in coin selection, fix styling/text in general settings.

* overhaul of checkbox logic in utxolist including mouseover event for checkboxes, manage psbt button action added, icon info popups added,design changes in utxo- and txlist to make the display quieter

* change of balance display, scrollbars for main and tx table, bugfixes (pagination, coin selection, unsigned labeling) & some styling finetuning

* deleted locked utxo balance calculations, added doc strings regarding balances

* jinja comment added in tx table, making scrollbar less vivid, tx table settings changed to no compact view toggle and scrollbar on by default

* more granular amount properties, little test fix.

* applying new amount properties to the frontend and other parts of the code & improving balance display design

* refactored a_simple_wallet to be only used in test_rest once

* tests for amounts and test performance optimisations.

* fix spec_node_configured

* fix spec_wallet_send.js

* fix spec_wallet_utxo.js

* keep action teaser div hidden on wallets overview.

* minor bug fix in tx-table, added includeShadowDom to cypress.json, adapted spec_wallet_utxo.js to new utxo list logic and added tests of new functionality to it.

* fix spec_plugins.js and switch back to includeShadowDom false
The problem with true is, that you are no longer able to spot
when you need to apply the workaround described in
https://docs.cypress.io/api/commands/shadow#Known-Issue
as you only need to apply in a shadow-DOM.
Feel free to switch it back in any test you see fit.
Cypress.config('includeShadowDom',true)

* better organisation of wallet utxo spec file

* some wait-tweaks to cypress tests to make them run smoother on cirrus, note: cypress tests green in local dev.

* longer wait in spec_elm_multi_segwit_wallet.js

* fixing spec_elm_multi_segwit_wallet.js

* more checks whether checkboxes get selected and additional waits for cirrus in spec_wallet_utxo.js.

Co-authored-by: Kim Neunert <k9ert@gmx.de>
This commit is contained in:
Manolis 2022-04-08 11:07:01 +02:00 committed by GitHub
parent 06f81c5ebc
commit b6bd82a90c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1391 additions and 998 deletions

View file

@ -14,5 +14,6 @@
"baseUrl": "http://localhost:25444",
"env": {
"broadcast_timeout":"8000"
}
},
"includeShadowDom": false
}

View file

@ -1,27 +1,26 @@
describe('Operating with an elements multisig wallet', () => {
describe('Operating with an Elements multisig wallet', () => {
it('Creates an two elements multisig hot wallets (segwit/nested) both 2/3', () => {
it('Creates two (segwit/nested) Elements multisig (2/3) hot wallets', () => {
cy.viewport(1200,660)
cy.visit('/')
cy.get('#node-switch-icon').click()
cy.get('#elements_node-select-node-form > .item > div').click()
// Delete Wallet if existing
// Delete wallets if existing
cy.deleteWallet("Elm Multi Segwit Wallet")
cy.deleteWallet("Elm Multi Nested Wallet")
// Create the Hot Element Wallets
cy.addHotDevice("Hot Elements Device 2","elements")
cy.addHotDevice("Hot Elements Device 3","elements")
cy.addHotDevice("Hot Elements Device 4","elements")
// Add devices for multisig
cy.addHotDevice("Elm Multisig Device 1","elements")
cy.addHotDevice("Elm Multisig Device 2","elements")
cy.addHotDevice("Elm Multisig Device 3","elements")
// Create Segwit multisig wallet
cy.get('#btn_new_wallet').click()
cy.get('[href="./multisig/"]').click()
cy.get('#hot_elements_device_2').click()
cy.get('#hot_elements_device_3').click()
cy.get('#hot_elements_device_4').click()
cy.get('#elm_multisig_device_1').click()
cy.get('#elm_multisig_device_2').click()
cy.get('#elm_multisig_device_3').click()
cy.get('#submit-device').click()
cy.get('#wallet_name').type("Elm Multi Segwit Wallet")
cy.get(':nth-child(9) > .inline').clear()
@ -34,193 +33,156 @@ describe('Operating with an elements multisig wallet', () => {
//Get some funds
cy.mine2wallet("elm")
// Create Nested multisig wallet
cy.get('#btn_new_wallet').click()
cy.get('[href="./multisig/"]').click()
cy.get('#hot_elements_device_2').click()
cy.get('#hot_elements_device_3').click()
cy.get('#hot_elements_device_4').click()
cy.get('#elm_multisig_device_1').click()
cy.get('#elm_multisig_device_2').click()
cy.get('#elm_multisig_device_3').click()
cy.get('#submit-device').click()
cy.get(':nth-child(1) > #type_nested_segwit_btn') // Nested!!
// Switch to Nested Segwit
cy.get('#type_nested_segwit_btn').click()
cy.get('#wallet_name').type("Elm Multi Nested Wallet")
// 2 of 3
cy.get(':nth-child(9) > .inline').clear()
// 2 of 2
cy.get(':nth-child(9) > .inline').type("2")
// submit
cy.get('#keysform > .centered').click()
// Cancel-button (no pdf download)
// Click cancel (no pdf download)
cy.get('#page_overlay_popup_cancel_button').click()
//Get some funds
// Get some funds
cy.mine2wallet("elm")
})
it('Spending to a Confidential address from segwit', () => {
it('Spending to a confidential address from segwit', () => {
cy.viewport(1200,660)
cy.visit('/')
cy.contains("Elm Multi Segwit Wallet").click()
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }).then(($div) => {
// Create PSBT
const oldBalance = parseFloat($div.text())
expect(oldBalance).to.be.gte(1.5)
cy.createPsbt("el1qqdsywea5scrn7t9q83fd540pw447h0uae30pdp82rzgkl7yzvjz6gra9ls8qu6sslw4s0ck48we06zhqd6kwjy2quh69zwxwn", "Burn address","1.5")
// First signature
cy.get('#elm_multisig_device_1_tx_sign_btn').click()
cy.get('#elm_multisig_device_1_hot_sign_btn').click()
cy.contains('Sign transaction').click()
// Second signature
cy.get('#elm_multisig_device_2_tx_sign_btn').click()
cy.get('#elm_multisig_device_2_hot_sign_btn').click()
cy.contains('Sign transaction').click()
// Send the tx
cy.get('#broadcast_local_btn').click()
// Redirect to "transactions", check balance there
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") })
.should(($div) => {
const newBalance = parseFloat($div.text())
expect(newBalance).to.be.lte(oldBalance - 1.5)
})
})
})
it('Spending to an unnconfidential address from segwit', () => {
cy.viewport(1200,660)
cy.visit('/')
// spend the money again
cy.contains("Elm Multi Segwit Wallet").click()
cy.get('#fullbalance_amount').then(($div) => {
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }).then(($div) => {
// Create PSBT
const oldBalance = parseFloat($div.text())
expect(oldBalance).to.be.gte(1.5)
cy.createPsbt("ert1q38la37ulxgc0uwt334he46eua7h8qagqnlm5phcqk7ntgv3x73cqjtr2fa", "unconf Burn address","1.5")
// First signature
cy.get('#elm_multisig_device_1_tx_sign_btn').click()
cy.get('#elm_multisig_device_1_hot_sign_btn').click()
cy.contains('Sign transaction').click()
// Second signature
cy.get('#elm_multisig_device_2_tx_sign_btn').click()
cy.get('#elm_multisig_device_2_hot_sign_btn').click()
cy.contains('Sign transaction').click()
// Send tx
cy.get('#broadcast_local_btn').click()
// Redirect to "transactions", check balance there
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") })
.should(($div) => {
const newBalance = parseFloat($div.text())
expect(newBalance).to.be.lte(oldBalance - 1.5)
})
})
})
it('Spending to a confidential address from nested', () => {
cy.viewport(1200,660)
cy.visit('/')
cy.contains("Elm Multi Nested Wallet").click()
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }).then(($div) => {
// Create PSBT
const oldBalance = parseFloat($div.text())
expect(oldBalance).to.be.gte(1.5)
cy.createPsbt("el1qqdsywea5scrn7t9q83fd540pw447h0uae30pdp82rzgkl7yzvjz6gra9ls8qu6sslw4s0ck48we06zhqd6kwjy2quh69zwxwn", "Burn address","1.5")
// First signature
cy.get('#elm_multisig_device_1_tx_sign_btn').click()
cy.get('#elm_multisig_device_1_hot_sign_btn').click()
cy.contains('Sign transaction').click()
cy.get('#hot_elements_device_2_tx_sign_btn').click()
cy.get('#hot_elements_device_2_hot_sign_btn').click()
cy.get('#hot_enter_passphrase__submit').click()
cy.get('#hot_elements_device_3_tx_sign_btn').click()
cy.get('#hot_elements_device_3_hot_sign_btn').click()
cy.get('#hot_enter_passphrase__submit').click()
// cy.get('#broadcast_local_btn').click()
// There is a bug as the first device doesn't seem to have signed and so we can't send the tx just right from here
// 2 more clicks until to get to the send button
cy.get('#btn_send').click()
cy.get('.row > :nth-child(1) > .btn').click()
cy.get('#send_tx_btn').click()
// Second signature
cy.get('#elm_multisig_device_2_tx_sign_btn').click()
cy.get('#elm_multisig_device_2_hot_sign_btn').click()
cy.contains('Sign transaction').click()
// Send the tx
cy.get('#broadcast_local_btn').click()
// gets redirected to "transactions"
// Redirect to "transactions", check balance there
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") })
.should(($div) => {
const newBalance = parseFloat($div.text())
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('Spending to a Unconfidential address from segwit', () => {
it('Spending to a unconfidential address from nested', () => {
cy.viewport(1200,660)
cy.visit('/')
// spend the money again
cy.contains("Elm Multi Segwit Wallet").click()
cy.contains("Elm Multi Nested Wallet").click()
cy.get('#fullbalance_amount').then(($div) => {
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }).then(($div) => {
// Create PSBT
const oldBalance = parseFloat($div.text())
expect(oldBalance).to.be.gte(1.5)
cy.createPsbt("ert1q38la37ulxgc0uwt334he46eua7h8qagqnlm5phcqk7ntgv3x73cqjtr2fa", "unconf Burn address","1.5")
// First signature
cy.get('#elm_multisig_device_1_tx_sign_btn').click()
cy.get('#elm_multisig_device_1_hot_sign_btn').click()
cy.contains('Sign transaction').click()
cy.get('#hot_elements_device_2_tx_sign_btn').click()
cy.get('#hot_elements_device_2_hot_sign_btn').click()
cy.get('#hot_enter_passphrase__submit').click()
cy.get('#hot_elements_device_3_tx_sign_btn').click()
cy.get('#hot_elements_device_3_hot_sign_btn').click()
cy.get('#hot_enter_passphrase__submit').click()
// cy.get('#broadcast_local_btn').click()
// There is a bug as the first device doesn't seem to have signed and so we can't send the tx just right from here
// 2 more clicks until to get to the send button
cy.get('#btn_send').click()
cy.get('.row > :nth-child(1) > .btn').click()
cy.get('#send_tx_btn').click()
// Second signature
cy.get('#elm_multisig_device_2_tx_sign_btn').click()
cy.get('#elm_multisig_device_2_hot_sign_btn').click()
cy.contains('Sign transaction').click()
// Send the tx
cy.get('#broadcast_local_btn').click()
// gets redirected to "transactions"
// Redirect to "transactions", check balance there
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") })
.should(($div) => {
const newBalance = parseFloat($div.text())
expect(newBalance).to.be.lte(oldBalance - 1.5)
})
})
// Workaround: Transaction does not disappear
// Strange bug, not needed here
// cy.get('#btn_send').click()
// The "delete" button in the first psbt
// cy.get('.row > :nth-child(2) > .btn').click()
})
it('Spending to a Confidential address from nested', () => {
cy.viewport(1200,660)
cy.visit('/')
// spend the money again
cy.contains("Elm Multi Nested Wallet").click()
cy.get('#fullbalance_amount').then(($div) => {
const oldBalance = parseFloat($div.text())
expect(oldBalance).to.be.gte(1.5)
cy.createPsbt("el1qqdsywea5scrn7t9q83fd540pw447h0uae30pdp82rzgkl7yzvjz6gra9ls8qu6sslw4s0ck48we06zhqd6kwjy2quh69zwxwn", "Burn address","1.5")
cy.get('#hot_elements_device_2_tx_sign_btn').click()
cy.get('#hot_elements_device_2_hot_sign_btn').click()
cy.get('#hot_enter_passphrase__submit').click()
cy.get('#hot_elements_device_3_tx_sign_btn').click()
cy.get('#hot_elements_device_3_hot_sign_btn').click()
cy.get('#hot_enter_passphrase__submit').click()
// cy.get('#broadcast_local_btn').click()
// There is a bug as the first device doesn't seem to have signed and so we can't send the tx just right from here
// 2 more clicks until to get to the send button
cy.get('#btn_send').click()
cy.get('.row > :nth-child(1) > .btn').click()
cy.get('#send_tx_btn').click()
cy.get('#broadcast_local_btn').click()
// gets redirected to "transactions"
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") })
.should(($div) => {
const newBalance = parseFloat($div.text())
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('Spending to a Unconfidential address from nested', () => {
cy.viewport(1200,660)
cy.visit('/')
// spend the money again
cy.contains("Elm Multi Nested Wallet").click()
cy.get('#fullbalance_amount').then(($div) => {
const oldBalance = parseFloat($div.text())
expect(oldBalance).to.be.gte(1.5)
cy.createPsbt("ert1q38la37ulxgc0uwt334he46eua7h8qagqnlm5phcqk7ntgv3x73cqjtr2fa", "unconf Burn address","1.5")
cy.get('#hot_elements_device_2_tx_sign_btn').click()
cy.get('#hot_elements_device_2_hot_sign_btn').click()
cy.get('#hot_enter_passphrase__submit').click()
cy.get('#hot_elements_device_3_tx_sign_btn').click()
cy.get('#hot_elements_device_3_hot_sign_btn').click()
cy.get('#hot_enter_passphrase__submit').click()
// cy.get('#broadcast_local_btn').click()
// There is a bug as the first device doesn't seem to have signed and so we can't send the tx just right from here
// 2 more clicks until to get to the send button
cy.get('#btn_send').click()
cy.get('.row > :nth-child(1) > .btn').click()
cy.get('#send_tx_btn').click()
cy.get('#broadcast_local_btn').click()
// gets redirected to "transactions"
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") })
.should(($div) => {
const newBalance = parseFloat($div.text())
expect(newBalance).to.be.lte(oldBalance - 1.5)
})
})
// Workaround: Transaction does not disappear
// Strange bug, not needed here
// cy.get('#btn_send').click()
// The "delete" button in the first psbt
// cy.get('.row > :nth-child(2) > .btn').click()
})
})

View file

@ -5,10 +5,10 @@ describe('Node Configured', () => {
cy.addDevice("Testdevice Ghost")
cy.get('body').then(($body) => {
if ($body.text().includes('Testwallet Ghost')) {
cy.get('#wallets_list > .item > svg').click()
cy.get(':nth-child(6) > .right').click()
cy.contains('Testwallet Ghost').click()
cy.get('#btn_settings').click({"force": true})
cy.get('#advanced_settings_tab_btn').click()
cy.get('.card > :nth-child(9) > .btn').click()
cy.get('#delete_wallet').click()
}
})
cy.get('#btn_new_wallet').click()

View file

@ -7,7 +7,7 @@ describe('plugins are working', () => {
// choose address
cy.get('#test_hot_wallet_1-sidebar-list-item').click()
cy.get('[href="/wallets/wallet/test_hot_wallet_1/addresses/"]').click()
cy.get('addresses-table').shadow().find('address-row').eq(0).shadow().find('.explorer-link').click()
cy.get('addresses-table').shadow().find('address-row').eq(0).shadow().find('.explorer-link').eq(0).click({position: 'top'})
cy.get('address-data').shadow().find('#associate-btn').click()
cy.contains("Service integration requires an authentication method that includes a password")
@ -20,7 +20,7 @@ describe('plugins are working', () => {
// choose address again
cy.get('#test_hot_wallet_1-sidebar-list-item').click()
cy.get('[href="/wallets/wallet/test_hot_wallet_1/addresses/"]').click()
cy.get('addresses-table').shadow().find('address-row').eq(0).shadow().find('.explorer-link').click()
cy.get('addresses-table').shadow().find('address-row').eq(0).shadow().find('.explorer-link').eq(0).click()
cy.get('address-data').shadow().find('#associate-btn').click()
cy.contains("Associating an address with a service will")

View file

@ -1,82 +1,150 @@
describe('Send transactions from wallets', () => {
it('Freeze and unfreeze UTXO', () => {
const name = "UTXO Hot Bitcoin3"
const wallet_name = name+" wallet"
describe('Test the actions in UTXO list', () => {
before(() => {
Cypress.config('includeShadowDom', true)
const device_name = "UTXO device"
const wallet_name = "UTXO wallet"
var wallet_name_ref = wallet_name.toLowerCase().replace(/ /g,"_")
cy.viewport(1200,660)
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.get('#toggle_devices_list').click()
cy.addHotDevice(device_name, "bitcoin")
cy.addHotWallet(wallet_name, device_name, "bitcoin", "segwit")
cy.get('#fullbalance_amount').then(($span) => {
const balance = parseFloat($span.text())
if (balance <= 20) {
cy.log("balance " + balance + " too low. Mining!")
cy.mine2wallet("btc")
cy.mine2wallet("btc")
cy.mine2wallet("btc")
cy.mine2wallet("btc")
// We are having 5 UTXO in the end
}
})
cy.contains(wallet_name).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 })
// Keeps the session cookie alive, Cypress by default clears all cookies before each test
beforeEach(() => {
Cypress.Cookies.preserveOnce('session')
})
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')
})
cy.log("First select it, then freeze it")
cy.get('tx-table').shadow().find('tx-row').eq(0).shadow().find('.select-tx-img').click()
it('Freezing', () => {
cy.contains("UTXO wallet").click()
cy.get('tx-table').find('.utxo-view-btn').click({ force: true })
cy.wait(500)
// Freeze 3 UTXOs
cy.log("Freeze 3 UTXO")
cy.get('tx-table').find('tx-row').eq(0).find('.select-tx-img').click( {position: 'top'} )
cy.get('tx-table').find('tx-row').eq(0).find('.select-tx-value').invoke('attr', 'value').should('eq', 'true') // Check whether click worked
cy.wait(100)
// then click the freeze-button
cy.get('tx-table').shadow().find('.freeze-tx-btn').click()
cy.get('tx-table').find('tx-row').eq(1).find('.select-tx-img').click( {position: 'top'} )
cy.get('tx-table').find('tx-row').eq(1).find('.select-tx-value').invoke('attr', 'value').should('eq', 'true')
cy.wait(100)
cy.get('tx-table').shadow().find('tx-row').each(($el, index, $list) => {
if (index == 0) {
cy.wrap($el).shadow().find('.tx-row').should('have.class', 'frozen')
cy.wrap($el).shadow().find('.frozen-img').should('not.have.class', 'hidden')
cy.get('tx-table').find('tx-row').eq(3).find('.select-tx-img').click( {position: 'top'} )
cy.get('tx-table').find('tx-row').eq(3).find('.select-tx-value').invoke('attr', 'value').should('eq', 'true')
cy.wait(100)
cy.get('tx-table').find('.freeze-tx-btn').click()
cy.wait(500)
// We should have 3 frozen outputs now, let's check that
cy.log("Check whether we have 3 frozen outputs")
cy.get('tx-table').find('tx-row').each(($el, index, $list) => {
if (index == 0 || index == 1 || index == 3) {
cy.wrap($el).find('.tx-row').should('have.class', 'frozen')
cy.wrap($el).find('.frozen-img').should('not.have.class', 'hidden')
} else {
cy.wrap($el).shadow().find('.tx-row').should('not.have.class', 'frozen')
cy.wrap($el).shadow().find('.frozen-img').should('have.class', 'hidden')
cy.wrap($el).find('.tx-row').should('not.have.class', 'frozen')
cy.wrap($el).find('.frozen-img').should('have.class', 'hidden')
}
})
// Check that we can't create a transaction when a frozen UTXO is selected ...
cy.log("Check that we can't create a transaction when frozen UTXO is selected")
cy.get('tx-table').find('tx-row').eq(2).find('.select-tx-img').click( {position: 'top'} )
cy.get('tx-table').find('tx-row').eq(0).find('.select-tx-img').click( {position: 'top'} )
cy.get('tx-table').find('.compose-tx-btn').should('be.hidden')
// ... and that the checkboxes disappear for non-frozen UTXO and get unticked
cy.log("... and that the checkboxes disappear for non-frozen UTXO and get unticked")
cy.get('tx-table').find('tx-row').eq(2).find('.select-tx-img').should('be.hidden')
cy.get('tx-table').find('tx-row').eq(2).find('.select-tx-img').should('have.attr', 'src').and('contain', 'untick')
cy.get('tx-table').find('tx-row').eq(4).find('.select-tx-img').should('be.hidden')
cy.get('tx-table').find('tx-row').eq(4).find('.select-tx-img').should('have.attr', 'src').and('contain', 'untick')
// Unselect the first UTXO again and check that all checkboxes are visible and none are selected
cy.log("Unselect the first UTXO again and check that all checkboxes are visible and none are selected")
cy.get('tx-table').find('tx-row').eq(0).find('.select-tx-img').click( {position: 'top'} )
cy.wait(100)
cy.get('tx-table').find('tx-row').each(($el, index, $list) => {
cy.wrap($el).find('.tx-row').find('.select-tx-img').should('not.be.hidden').should('have.attr', 'src').and('contain', 'untick')
})
})
// 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()
cy.get('tx-table').shadow().find('.compose-tx-btn').click()
it('Creating a transaction', () => {
// Make a transaction with the third and forth UTXO
cy.log("Make a transaction with the third and forth UTXO")
// Unfreeze necessary for the forth UTXO
cy.get('tx-table').find('tx-row').eq(3).find('.select-tx-img').click( {position: 'top'} )
cy.wait(100)
cy.get('tx-table').find('.freeze-tx-btn').click()
cy.wait(200)
cy.get('tx-table').find('tx-row').eq(2).find('.select-tx-img').click( {position: 'top'} );
cy.get('tx-table').find('tx-row').eq(2).find('.select-tx-value').invoke('attr', 'value').should('eq', 'true') // Check that the click flow is (still) in order
cy.get('tx-table').find('tx-row').eq(3).find('.select-tx-img').click( {position: 'top'} )
cy.get('tx-table').find('tx-row').eq(3).find('.select-tx-value').invoke('attr', 'value').should('eq', 'true')
cy.wait(100)
cy.get('tx-table').find('.compose-tx-btn').should('not.be.hidden')
cy.get('tx-table').find('.compose-tx-btn').click()
// Switch to coin selection, check that the right amount of coins are preselected in the coin selection
cy.log("Switch to coin selection, check that the right amount of coins are preselected in the coin selection")
cy.get('.coinselect-hidden').should('have.length', 2);
})
// If you select a coin from the utxo-set and cklick on "create transaction", the coins need to be preselected
cy.get('.coinselect-hidden').should('have.length', 3);
// Unfreeze the UTXO
it('Managing unsigned transactions', () => {
// Make an unsigned tx
cy.get('#address_0').type("bcrt1q74tj43hrg550vdtkhsmkrfjqspkku8zes0vq34") // some address from testwallet ghost
cy.get('#send_max_0').click()
cy.get('#create_psbt_btn').click()
// Check the labeling of the unsigned UTXO
cy.log("Check the labeling of the unsigned UTXO")
cy.get('#btn_transactions').click()
cy.wait(1000)
cy.get('tx-table').shadow().find('.utxo-view-btn').click()
cy.get('tx-table').shadow().find('tx-row').eq(0).shadow().find('.select-tx-img').click()
cy.get('tx-table').shadow().find('.freeze-tx-btn').click()
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')
cy.get('tx-table').find('.utxo-view-btn').click()
cy.get('tx-table').find('tx-row').eq(2).find('#column-category').should('contain', 'Unsigned')
cy.get('tx-table').find('tx-row').eq(3).find('#column-category').should('contain', 'Unsigned')
// Check that only the two checkboxes of the unsigned UTXO are visible
cy.log("Check that only the two checkboxes of the unsigned UTXO are visible")
cy.get('tx-table').find('tx-row').eq(2).find('.select-tx-img').click( {position: 'top'} )
cy.wait(200)
cy.get('tx-table').find('tx-row').each(($el, index, $list) => {
if (index == 0 || index == 1) {
cy.wrap($el).find('.tx-row').find('.select-tx-img').should('be.hidden').should('have.attr', 'src').and('contain', 'untick')
}
})
// Ensure selection of unfreeze now works
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()
cy.get('tx-table').shadow().find('.compose-tx-btn').click()
cy.get('.coinselect-hidden').should('have.length', 3);
// Unselect the third UTXO again and check that all checkboxes are visible and none are selected
cy.log("Unselect the third UTXO again and check that all checkboxes are visible and none are selected")
cy.get('tx-table').find('tx-row').eq(2).find('.select-tx-img').click( {position: 'top'} )
cy.get('tx-table').find('tx-row').eq(2).find('.select-tx-value').invoke('attr', 'value').should('eq', '')
cy.wait(100)
cy.get('tx-table').find('tx-row').each(($el, index, $list) => {
cy.wrap($el).find('.tx-row').find('.select-tx-img').should('not.be.hidden')
})
// Select a normal UTXO, select an unsigned UTXO and then unselect the unsigned again
cy.log("Select a normal UTXO, select an unsigned UTXO and then unselect the unsigned again")
cy.get('tx-table').find('tx-row').eq(4).find('.select-tx-img').click( {position: 'top'} )
cy.get('tx-table').find('tx-row').eq(4).find('.select-tx-value').invoke('attr', 'value').should('eq', 'true')
cy.wait(100)
cy.get('tx-table').find('tx-row').eq(3).find('.select-tx-img').click( {position: 'top'} )
cy.get('tx-table').find('tx-row').eq(3).find('.select-tx-value').invoke('attr', 'value').should('eq', 'true')
cy.wait(100)
cy.get('tx-table').find('tx-row').eq(3).find('.select-tx-img').click( {position: 'top'} )
cy.get('tx-table').find('tx-row').eq(3).find('.select-tx-value').invoke('attr', 'value').should('eq', '')
cy.wait(100)
// Check that all checkboxes are visible again and none are selected
cy.get('tx-table').find('tx-row').each(($el, index, $list) => {
cy.wrap($el).find('.tx-row').find('.select-tx-img').should('not.be.hidden').should('have.attr', 'src').and('contain', 'untick')
})
// Check that the manage PSBT button is working
cy.get('tx-table').find('tx-row').eq(2).find('.select-tx-img').click( {position: 'top'} )
cy.wait(100)
cy.get('tx-table').find('#manage-psbt-btn').should('not.be.hidden').click()
cy.wait(200)
cy.contains("Here you can manage PSBTs")
})
})

View file

@ -48,6 +48,18 @@ module.exports = (on, config) => {
}
})
// For future use, not used yet
on('task', {
'delete:bitcoin-hotwallet': (name) => {
console.log('connection details: %s', btc_conn)
const bitcoin_data_dir=btc_conn["bitcoin_data_dir"];
var rimraf = require("rimraf");
console.log('Removing all wallets in %s', bitcoin_data_dir+"/regtest/wallets/specter123456_hotstorage")
rimraf.sync(bitcoin_data_dir+"/regtest/wallets/specter123456_hotstorage");
return null
}
})
on('task', {
'btc:mine': () => {
// sending the bitcoind-process a signal SIGUSR1 (10) will cause mining towards all specter-wallets

View file

@ -63,10 +63,11 @@ Cypress.Commands.add("addDevice", (name) => {
Cypress.Commands.add("addHotDevice", (name, node_type) => {
// node_type is either elements or bitcoin
cy.get('body').then(($body) => {
//cy.task("delete:elements-hotwallet")
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()
cy.get(refName).click( {force: true} )
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)
@ -75,7 +76,7 @@ Cypress.Commands.add("addHotDevice", (name, node_type) => {
if (!cy.get('#btn_new_device').isVisible) {
cy.get('#toggle_devices_list').click()
}
cy.get('#btn_new_device').click()
cy.get('#btn_new_device').click( {force: true} )
cy.contains('Select Your Device Type')
cy.get(`#${node_type}core_device_card`).click()
cy.get('#submit-mnemonic').click()
@ -102,9 +103,7 @@ Cypress.Commands.add("addHotWallet", (wallet_name, device_name, node_type, walle
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,"_")
@ -131,7 +130,7 @@ Cypress.Commands.add("deleteWallet", (name) => {
cy.get('body').then(($body) => {
if ($body.text().includes(name)) {
cy.contains(name).click()
cy.get('#btn_settings').click()
cy.get('#btn_settings').click( {force: true} )
cy.get('#advanced_settings_tab_btn').click()
cy.get('#delete_wallet').click()
// That does not seem to delete the wallet-file in elements, though
@ -144,8 +143,8 @@ Cypress.Commands.add("deleteWallet", (name) => {
Cypress.Commands.add("mine2wallet", (chain) => {
// Fund it and check the balance
cy.get('#btn_transactions').click()
cy.get('#fullbalance_amount').then(($div) => {
const oldBalance = parseFloat($div.text())
cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }).then(($span) => {
const oldBalance = parseFloat($span.text())
if (chain=="elm" || chain=="elements") {
cy.task("elm:mine")
} else if (chain=="btc" || chain=="bitcoin") {
@ -154,8 +153,8 @@ Cypress.Commands.add("mine2wallet", (chain) => {
throw new Error("Unknown chain: " + chain)
}
cy.waitUntil( () => cy.reload().get('#fullbalance_amount', { timeout: 3000 })
.then(($div) => {
const n = parseFloat($div.text())
.then(($span) => {
const n = parseFloat($span.text())
return n > oldBalance
})
, {

View file

@ -32,7 +32,7 @@ def timedatetime(context, s):
def btcamount(context, value):
if value is None:
return "Unknown"
if value < 0:
if value < 0 and app.specter.is_liquid:
return "Confidential"
value = round(float(value), 8)
return "{:,.8f}".format(value).rstrip("0").rstrip(".")
@ -63,7 +63,7 @@ def btcunitamount(context, value):
return "#########"
if value is None:
return "Unknown"
if value < 0:
if value < 0 and app.specter.is_liquid:
return "Confidential"
if app.specter.unit != "sat":
return btcamount(context, value)

View file

@ -361,7 +361,7 @@ def new_wallet(wallet_type):
@login_required
def wallet(wallet_alias):
wallet = app.specter.wallet_manager.get_by_alias(wallet_alias)
if wallet.fullbalance > 0:
if wallet.amount_total > 0:
return redirect(url_for("wallets_endpoint.history", wallet_alias=wallet_alias))
else:
return redirect(url_for("wallets_endpoint.receive", wallet_alias=wallet_alias))
@ -468,6 +468,15 @@ def send_new(wallet_alias):
rbf_utxo = []
rbf_tx_id = ""
selected_coins = request.form.getlist("coinselect")
# Additional server side check not to use frozen UTXO as a precaution
frozen_utxo = wallet.frozen_utxo
for utxo in selected_coins:
if utxo in frozen_utxo:
selected_coins.remove(utxo)
flash(f"You've selected a frozen UTXO for a transaction.", "error")
return redirect(
url_for("wallets_endpoint.history", wallet_alias=wallet_alias)
)
if request.method == "POST":
action = request.form.get("action")
@ -601,11 +610,7 @@ def send_new(wallet_alias):
flash(_("Failed to get RBF coins. Error: {}").format(e), "error")
show_advanced_settings = (
ui_option != "ui"
or subtract
or fee_options != "dynamic"
or not rbf
or selected_coins
ui_option != "ui" or subtract or fee_options != "dynamic" or not rbf
)
wallet_utxo = wallet.utxo
if app.specter.is_liquid:

View file

@ -439,6 +439,14 @@ def wallets_overview_utxo_list():
)
@wallets_endpoint_api.route("/wallet/<wallet_alias>/pending_psbt_list", methods=["GET"])
@login_required
def pending_psbt_list(wallet_alias):
wallet = app.specter.wallet_manager.get_by_alias(wallet_alias)
pending_psbts = wallet.pending_psbts_dict()
return jsonify(pending_psbts=pending_psbts)
@wallets_endpoint_api.route("/wallet/<wallet_alias>/addresses_list/", methods=["POST"])
@login_required
@app.csrf.exempt
@ -676,11 +684,11 @@ def asset_balances(wallet_alias):
textUnit = app.specter.unit
asset_balances = {
"btc": {
"balance": wallet.full_available_balance,
"balance": wallet.amount_available,
"label": label,
},
"sat": {
"balance": int(wallet.full_available_balance * 1e8),
"balance": int(wallet.amount_available * 1e8),
"label": "sat",
},
}

View file

@ -141,16 +141,6 @@ ul, li{
/*max-width: 100%;*/
justify-content: center;
}
tr.unconfirmed{
background: var(--cmap-bg);
/*font-style: italic;*/
}
.unconfirmed td img{
opacity: 0.7;
}
.unconfirmed:hover td img{
opacity: 1;
}
.row{
display: flex;
flex-direction: row;
@ -215,7 +205,6 @@ main{
/*justify-content: center;*/
padding: 0;
/*padding: 30px 0 20px 0;*/
overflow-y: scroll;
}
nav.side > .item, nav.side > div > .item{
padding: 10px 0;
@ -520,6 +509,24 @@ input:hover{
.flex-item{
margin: 0.5em;
}
.smaller-card{
min-width: 220px;
padding: 15px;
background: var(--cmap-bg-lighter);
border: 2px solid var(--cmap-border);
border-radius: 4px;
color: #fff;
font-size: 0.9em;
text-align: center;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
margin: 5px;
}
.smaller-card:hover{
border: 2px solid hsl(217, 15%, 55%);
}
.small-card{
min-width: 220px;
/*height: 250px;*/
@ -707,8 +714,6 @@ table{
.table-holder{
padding: 0 30px;
width: 100%;
overflow-y: scroll;
/*overflow-x: scroll;*/
}
.full-width{
width: 100%;
@ -741,29 +746,74 @@ tbody tr:hover{
.tx{
max-width: 200px;
}
.scroll{
/* Scroll functionality along x axis
(mainly used for xpub keys)
*/
.scroll {
padding: 0 10px;
}
.scroll > *{
.scroll > * {
/* ugly fix for firefox - scrolling child element */
display: block;
overflow-x: scroll;
width: 100%;
padding: 20px 0;
}
.scroll > *::-webkit-scrollbar,
#side-content::-webkit-scrollbar,
main::-webkit-scrollbar,
pre::-webkit-scrollbar{
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
td.scroll > *,
#side-content,
main,pre{
/* Hide scrollbar thumb and track for IE, Edge and Firefox */
td.scroll > *, pre {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
scrollbar-height: none; /* Firefox */
}
/* Hide scrollbar thumb and track for Chrome */
td.scroll > *::-webkit-scrollbar,
pre::-webkit-scrollbar {
display: none;
}
/* Standard scrollbar along y axis
scrollbar-color and scrollbar-width only supported by Firefox */
main {
overflow-y: scroll;
scrollbar-color: hsl(217, 10%, 80%) var(--cmap-border);
scrollbar-width: thin;
}
/* Not supported by Firefox & Safari on iOS */
main::-webkit-scrollbar {
width: 0.5em;
}
main::-webkit-scrollbar:horizontal {
display: none;
}
main::-webkit-scrollbar-track {
background: var(--cmap-bg);
border-radius: 100vw;
margin-top: 3.5em;
margin-bottom: 1em;
}
main::-webkit-scrollbar-track:active {
background: hsl(217, 15%, 55%);
border-radius: 100vw;
margin-top: 3.5em;
margin-bottom: 1em;
}
main::-webkit-scrollbar-thumb {
background: hsl(217, 10%, 80%);
border-radius: 100vw;
}
main::-webkit-scrollbar-thumb:hover {
background: white;
}
/* No scrollbar for side-content */
#side-content::-webkit-scrollbar {
display: none;
}
#side-content {
scrollbar-width: none;
}
table a, .address-link{

View file

@ -65,7 +65,7 @@
{% endblock %}
<main><div><br><br></div>
{% if current_user.is_authenticated and not hwi_bridge %}
<div class="row" id="status-bar" style="{% if session['is_language_rtl'] %}border-radius: 0 0 15px 0; left{% else %}border-radius: 0 0 0 15px; right{% endif %}: 0; position: absolute; color: #ddd; background-color: #323e50;">
<div class="row" id="status-bar" style="{% if session['is_language_rtl'] %}border-radius: 0 0 15px 0; left{% else %}border-radius: 0 0 15px 15px; right{% endif %}: 1%; position: absolute; color: #ddd; background-color: #323e50;">
{% include "components/price_bar.jinja" %}
<a class="settings-bar-btn" onclick="toggleHideSensitiveInfo()" style="padding: 0px 12px; cursor: pointer;" title="{{ 'Hide sensitive info' if not specter.hide_sensitive_info else 'Show sensitive info' }}">
<img src="{{ url_for('static', filename='img/hidden.svg' if specter.hide_sensitive_info else 'img/visible.svg') }}" style="width: 30px;" class="svg-white"/>

View file

@ -10,7 +10,11 @@
btc-unit="{{ specter.unit }}"
hide-sensitive-info="{{ specter.hide_sensitive_info | lower }}"
type="utxo" hide-columns="category time txid confirmations action"
blockhash="true" hide-switcher="true">
blockhash="true" hide-switcher="true"
wallet="{{ wallet.alias }}"
scrollbar-height="400px"
scrollbar-default="on"
compact-switch="false">
</tx-table>
</div>
</template>
@ -26,11 +30,11 @@
<coin-selection
id="coinselection-webcomponent"
wallet-alias="{{wallet.alias}}"
spendable-amount='{{ wallet.full_available_balance | btcamount }}'
spendable-amount='{{ wallet.amount_available | btcamount }}'
is-liquid="{{specter.is_liquid}}"
selected-coins="{{ selected_coins }}">
</coin-selection>
Internally, a tx-table is used which is customizable in order to be usefull here.
Internally, a tx-table is used which is customizable in order to be useful here.
We're using the asset_balance endpoint to get Liquid assets.
*/
class CoinSelection extends HTMLElement {
@ -71,16 +75,12 @@
if (this.getAttribute("is-liquid") == "True") {
this.classList.add("hidden")
}
this.txtable.setAttribute("wallet", this.walletAlias)
let selectedCoins = JSON.parse(this.getAttribute('selected-coins').replace(/'/g, '"'));
if (selectedCoins.length > 0) {
this.txtable.setAttribute("selected-coins", JSON.stringify(selectedCoins))
// unfold if we have preselected coins
this.toggleCoinselection()
}
}
/**
@ -117,7 +117,7 @@
let spendableAmount;
if (!this.isCoinSelectionActive()) {
if(unit == 'btc' || unit == 'sat'){
spendableAmount = '{{ wallet.full_available_balance }}';
spendableAmount = '{{ wallet.amount_available }}';
} else {
if (unit in assetBalances){
return assetBalances[unit].balance;
@ -155,6 +155,7 @@
this.txtable.getSelectedTxs().forEach( (tx) => {
sum = sum + tx.amount
})
sum = Number.parseFloat(sum.toFixed(8))
return sum
}

View file

@ -6,6 +6,7 @@
.page-limit-fieldset {
float: left;
border: none;
margin-top: 4px; /*to better vertically align on tx history / utxo list overview*/
display: inline;
}
.page-limit-fieldset > select {

View file

@ -1,8 +1,5 @@
<template id="tx-row">
<style>
.frozen {
background: #546773;
}
.svg-send {
filter: invert(63%) sepia(67%) saturate(851%) hue-rotate(346deg) brightness(107%) contrast(92%);
}
@ -15,10 +12,21 @@
.svg-cancelled {
filter: invert(20%) sepia(32%) saturate(4838%) hue-rotate(332deg) brightness(81%) contrast(97%);
}
.tx-row:hover {
background-color: var(--cmap-bg-lightest);
vertical-align: middle;
}
.tx-row {
vertical-align: middle;
}
/* .frozen {
background: #48535d
#546773;
}
.frozen:hover {
background: #48535d
#546773;
} */
.address {
overflow: hidden;
text-overflow: ellipsis;
@ -29,14 +37,64 @@
height:24px;
vertical-align: middle;
}
.coin_select_checkbox {
vertical-align: middle;
}
.icon-info {
display: inline-block;
position: relative;
margin-left: 0.5em;
}
.icon-info .icon-info-text {
display: none;
background: #262626;
border: 1px solid grey;
border-radius: 5px;
padding: 0.5em;
position: absolute;
left: 30px;
top: -20px;
width: 100px;
z-index: 2;
}
.icon-info .icon-info-text:before, .icon-info .icon-info-text:after {
content: "";
position: absolute;
left: -10px;
top: 7px;
border-style: solid;
border-width: 10px 10px 10px 0;
border-color: transparent grey;
}
.icon-info .icon-info-text:after {
left: -8px;
border-right-color: #262626;
}
.icon-info .icon-info-text .info-2 {
display: block;
}
.icon-info-text {
text-transform: capitalize;
text-align: center;
}
.icon-info:hover .icon-info-text, .icon-info:focus .icon-info-text {
display: inline-block;
}
</style>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles.css') }}">
<tr class="tx-row">
<td id="column-callbackaction" class="callbackaction">
<input class="checkbox coin_select_checkbox" type="checkbox" name="coinselect" value="" >
<input id="coin-select-checkbox" class="checkbox coin_select_checkbox" type="checkbox" name="coinselect" value="">
</td>
<td id="column-category">
<img class="category" style="vertical-align: middle; margin-right: 10px;"/>
<div class="icon-info">
<img class="category" style="vertical-align: middle; margin-right: 10px;"/>
<p id="icon-info-text" class="icon-info-text">
</p>
</div>
<img class="frozen-img hidden svg-white" style="vertical-align: middle; height: 28px;" src="{{ url_for('static', filename='img/snowflake.svg') }}"/>
</td>
<td id="column-txid" class="tx scroll optional txid">
@ -54,7 +112,10 @@
<button class="rbf-cancel danger btn optional hidden" style="width: 130px; float: right; margin-right: 10px;" type="button">{{ _("Cancel transaction") }}</button>
</td>
<td class="hidden optional blockhash"></td>
<td id="column-action"><input class="select-tx-value" type="hidden" value=""><img style="vertical-align: middle;" class="select-tx-img" src="{{ url_for('static', filename='img/checkbox-untick.svg') }}" width="25px"></tool-tip></td>
<td id="column-action" class="highlight-box">
<input class="select-tx-value" type="hidden" value="">
<img style="vertical-align: middle;" class="select-tx-img" src="{{ url_for('static', filename='img/checkbox-untick.svg') }}" width="25px">
</td>
</tr>
</template>
@ -69,7 +130,10 @@
this.el = clone.querySelector(".tx-row");
this.callbackaction = clone.querySelector(".callbackaction");
this.coinSelectCheckbox = clone.getElementById("coin-select-checkbox");
this.categoryCell = clone.getElementById("column-category");
this.category = clone.querySelector(".category");
this.iconInfoText = clone.getElementById("icon-info-text");
this.txid = clone.querySelector(".txid .explorer-link");
this.address = clone.querySelector(".address");
this.serviceIcon = clone.querySelector(".service-icon");
@ -97,15 +161,15 @@
this.symbol = this.getAttribute('data-symbol');
this.mode = this.getAttribute('data-mode');
this.hideSensitiveInfo = this.getAttribute('data-hide-sensitive-info') == 'true';
// Set data for customaction
this.callbackaction.childNodes[1].value=`${this.tx['txid']},${this.tx['vout']}`
this.callbackaction.childNodes[1].amount = `${this.tx['amount']}` // a bit hackish, doing that to have access via this in next line
this.callbackaction.childNodes[1].addEventListener("change", (e) => {
let event = new CustomEvent('txRowCustomSelected', { detail: {
txid: this.tx.txid,
// Listening for changes of coin selection checkboxes
this.coinSelectCheckbox.value=`${this.tx['txid']},${this.tx['vout']}` // Necessary?
this.coinSelectCheckbox.amount = `${this.tx['amount']}` // a bit hackish, doing that to have access via this in next line - necessary?
this.coinSelectCheckbox.addEventListener("change", (e) => {
let event = new CustomEvent(('coinSelectRowSelected'), { detail: {
txid: this.tx.txid, // Detail currently not used, remove?
vout: this.tx.vout,
amount: this.amount
amount: this.amount,
locked: this.tx.locked,
} }
);
this.dispatchEvent(event);
@ -257,8 +321,6 @@
}
}
// Show blockhash
if (this.showBlockhash) {
this.blockhash.classList.remove('hidden');
@ -285,7 +347,8 @@
selected: this.selectTxValue.value == "true",
txid: this.tx.txid,
vout: this.tx.vout,
amount: this.amount
amount: this.amount,
locked: this.tx.locked,
} });
this.dispatchEvent(event);
}
@ -315,15 +378,15 @@
}
isSelected() {
return this.callbackaction.childNodes[1].checked
return this.coinSelectCheckbox.checked
}
unselect() {
this.callbackaction.childNodes[1].checked = false
this.coinSelectCheckbox.checked = false
}
select() {
this.callbackaction.childNodes[1].checked = true
this.coinSelectCheckbox.checked = true
}
rbfPopup(rbfType) {
@ -376,9 +439,10 @@
case "send":
return `{{ url_for('static', filename='img') }}/send.svg`;
case "receive":
return `{{ url_for('static', filename='img') }}/receive.svg`;
case "immature":
case "generate":
return `{{ url_for('static', filename='img') }}/receive.svg`;
return `{{ url_for('static', filename='img') }}/gear.svg`;
case "selftransfer":
return `{{ url_for('static', filename='img') }}/transfer.svg`;
case "mixed":

View file

@ -1,9 +1,49 @@
<!--
Custom element for showing a table of transactions.
Can be configured to show:
- The transaction history of a wallet (set attribute `type` to "txlist" and `wallet` to the wallet's alias)
- The UTXO list of a wallet (set attribute `type` to "utxo" and `wallet` to the wallet's alias)
- The transaction history of all the user's wallets (set attribute `type` to "txlist" and `wallet` to null)
- The UTXO list of all the user's wallets (set attribute `type` to "utxo" and `wallet` to null)
The uses Specter's API to fetch transactions every time the user changes its parameters.
These parameters are configured using components included in this table element:
- Tabs for switching between History (`type="txlist"`) and UTXO (`type="utxo"`) list types
- Sorting transactions by any column (ascending or descending)
- Search bar to filter transactions using free text search
- Configuring limit for transactions displayed per page
- Pagination, jumping between pages and showing the total pages count
- Export - export the full (unfiltered) list from which the table is drawing its data as CSV (optionally with price history)
The table also supports:
- Showing amounts in either BTC or sats (set attribute `btc-unit` to either "btc" or "sat")
- Showing prices next to the amounts (set attribute `price` to the BTC price and symbol to the symbol of the currency you're pricing at)
- Showing validated blockhash column (set attribute `blockhash` to either "true" or "false")
- Limiting txs/utxos to just those associated with the specified `data-service-id`.
- Hide columns by hide-columns="category time"
- Enable scrollbar
- Toggle for scrollbar
{#- Usage -
<tx-table
btc-unit="{{ specter.unit }}"
hide-sensitive-info="{{ specter.hide_sensitive_info | lower }}"
type="utxo"
hide-columns="category time txid confirmations action"
blockhash="true"
hide-switcher="true"
wallet="{{ wallet.alias }}"
scrollbar-height="333px"
scrollbar-default="off"
compact-switch="true">
</tx-table>
#}
-->
<template id="tx-table">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles.css') }}">
<style>
.tx-table {
display: table;
}
tx-row {
display: contents;
}
@ -19,12 +59,10 @@
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type=number] {
-moz-appearance: textfield;
}
.pagination-idx {
min-width: 50px;
max-width: 50px;
@ -33,17 +71,16 @@
text-align: center;
height: 35px;
}
.search {
/* float: right; */
margin: auto;
width: 100%;
max-width: 350px;
}
.search-container {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 0.5em;
}
.search {
margin: auto;
}
.up-arrow {
width: 0;
height: 0;
@ -56,7 +93,6 @@
margin-right: 50px;
margin-top: 5px;
}
.down-arrow {
width: 0;
height: 0;
@ -70,7 +106,6 @@
margin-right: 50px;
margin-top: 5px;
}
.export-container {
max-width: 50%;
margin: 20px auto;
@ -91,7 +126,85 @@
margin: 5px;
height: 38px;
}
.action-teaser {
max-width: 20em;
border: 1px solid var(--cmap-border);
background-color: var(--cmap-bg-lighter);
padding: 0.8em;
text-align: center;
font-size: 15px;
line-height: 12px;
font-weight: 50;
color: grey;
}
.freeze-tx-btn {
border: 1px solid var(--cmap-border);
border-radius: 0.5em;
background-color: var(--cmap-bg-lighter);
padding: 0.8em;
text-align: center;
font-weight: 250;
}
.freeze-tx-btn:hover {
background-color: var(--cmap-bg-lightest);
border: 1px solid var(--main-color);
}
.compose-tx-btn {
border: 1px solid var(--cmap-border);
border-radius: 0.5em;
background-color: var(--cmap-bg-lighter);
padding: 0.8em;
text-align: center;
font-weight: 250;
}
.compose-tx-btn:hover {
background-color: var(--cmap-bg-lightest);
border: 1px solid var(--main-color);
}
.manage-psbt-btn {
border: 1px solid var(--cmap-border);
border-radius: 0.5em;
background-color: var(--cmap-bg-lighter);
padding: 0.8em;
text-align: center;
font-weight: 250;
}
.manage-psbt-btn:hover {
background-color: var(--cmap-bg-lightest);
border: 1px solid var(--main-color);
}
.table-container {
width: 100%;
}
.scrollbar {
min-height: 350px;
overflow-y: scroll;
scrollbar-color: hsl(217, 10%, 80%) var(--cmap-border);
scrollbar-width: thin;
}
.table-container::-webkit-scrollbar {
width: 0.4em;
}
.table-container::-webkit-scrollbar-track {
background: var(--cmap-bg);
border-radius: 100vw;
margin-top: 4em;
}
.table-container::-webkit-scrollbar-thumb {
background: hsl(217, 10%, 80%);
border-radius: 100vw;
}
.table-container::-webkit-scrollbar-thumb:hover {
background: white;
}
.scrollbar-checkbox-div {
margin-left: 15px;
}
input:checked + .slider {
background-color: var(--cmap-border);
}
</style>
<nav class="switcher row">
<button
type="button"
@ -112,34 +225,48 @@
</div>
<div class="search-container">
<!--Left part of the toolbar-->
<div style="flex: 1;">
<div style="flex: 0 0 200px">
{% include "includes/page-limit-select.html" %}
</div>
<!--Middle part of the toolbar-->
<input class="search" type="text" placeholder="Search..." />
<tool-tip title="Search options">
{{ _("anything in the address, tx, label, confirmations or amount") }}<br>
{{ _('search like') }} <code>%d.%m.%Y %H:%M</code> {{ _('if you want to search in the time field') }}<br>
{{ _("<code> 1</code> for anything more than 1 BTC and likewise <code>< 1</code> (don't forget the space)") }}<br>
</tool-tip>
<!--Placeholder for right part of the toolbar-->
<div style="flex: 1;"></div>
</div>
<div class="selected-rows-action-box row hidden">
<form class="freeze-tx-form" method="POST">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="button" class="btn freeze-tx-btn">
<!--Middle part of the toolbar-->
<div style="flex-grow: 0.5">
<input class="search" type="text" placeholder="Search..."></input>
</div>
<div style="flex: 0 0 50px">
<tool-tip title="Search options">
{{ _("anything in the address, tx, label, confirmations or amount") }}<br>
{{ _('search like') }} <code>%d.%m.%Y %H:%M</code> {{ _('if you want to search in the time field') }}<br>
{{ _("<code> 1</code> for anything more than 1 BTC and likewise <code>< 1</code> (don't forget the space)") }}<br>
</tool-tip>
</div>
<!--Right part of the toolbar-->
<div id="action-teaser" class="action-teaser hidden" style="flex-grow: 1">
<span id="action-teaser-text" class="action-teaser-text">Select checkboxes for actions</span>
</div>
<div class="selected-rows-action-box row hidden">
<form class="freeze-tx-form" method="POST">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="button" class="btn freeze-tx-btn">
<tool-tip title="Freezing UTXOs">
{{ _("Freezing a UTXO will make Specter disallow spending it.") }}<br><br>
{{ _("This is useful especially for dealing with a dust attack, where you don't want to accidentally spend the dust received.") }}"
{{ _("This is useful especially for dealing with a dust attack, where you don't want to accidentally spend the dust received.") }}
</tool-tip>&nbsp;&nbsp;
Un/Freeze UTXOs
</button>
</form>
<form class="compose-tx-form" method="POST">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="button" class="btn compose-tx-btn">{{ _("Create new transaction") }}</button>
</form>
</form>
<form class="compose-tx-form" method="POST">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="button" class="btn compose-tx-btn">{{ _("Create new transaction") }}</button>
</form>
<a id="manage-psbt-btn" type="button" class="btn manage-psbt-btn hidden" href="">{{ _("Manage unsigned PSBT") }}</a>
</div>
<div id="scrollbar-checkbox-div" class="scrollbar-checkbox-div row hidden">
{{ _("Compact table") }} &nbsp;&nbsp;
<label class="switch">
<input type="checkbox" id="scrollbar-checkbox" class="scrollbar-checkbox" name="scrollbar-checkbox">
<span class="slider"></span>
</label>
</div>
</div>
<div class="export-container hidden">
<h1>{{ _("Export transactions to CSV") }}</h1>
@ -156,78 +283,57 @@
</div>
<a class="export btn" download>{{ _("Export") }}</a>
</div>
<table class="tx-table">
<thead>
<tr>
<th value="callbackaction" class="column-callbackaction customaction-header"></th>
<th value="category" class="column-category category-header"><div class="category-arrow"></div></th>
<th value="txid" class="column-txid optional txid-header">TxID<div class="txid-arrow"></div></th>
<th value="label" class="column-label optional label-header">{{ _("Label") }}<div class="label-arrow"></div></th>
<th value="amount" class="column-amount amount-header">{{ _("Amount") }}<div class="amount-arrow"></div></th>
<th value="time" class="column-time time-header">{{ _("Time") }}<div class="time-arrow up-arrow"></div></th>
<th value="confirmations" class="column-confirmations confirmations-header">{{ _("Confirmations") }}<div class="confirmations-arrow"></div></th>
<th value="blockhash" class="column-blockhash optional hidden blockhash-header">
{{ _("Block Hash") }}
{% include "includes/merkletooltip.html" %}
</th>
<th class="column-action" style="width: 50px; padding: 10px;">
<button type="button" class="export-btn btn" style="width: 50px;">
{{ _("Export") }}
</button>
</th>
</tr>
</thead>
<tr class="empty">
<td class="column-callbackaction"></td>
<td class="column-category"></td>
<td class="column-txid">{{ _("Fetching transactions...") }}</td>
<td class="column-label optional"></td>
<td class="column-amount optional"></td>
<td class="column-time" ></td>
<td class="column-confirmations"></td>
<td class="column-blockhash optional hidden blockhash-empty"></td>
<td class="column-action"></td>
</tr>
<tr class="summary-row hidden">
<td class="column-callbackaction"></td>
<td class="column-category" ></td>
<td class="column-txid summary-tx-count"></td>
<td class="column-label optional"></td>
<td class="column-amount summary-amount"></td>
<td class="column-time optional"></td>
<td class="column-confirmations"> </td>
<td class="column-blockhash optional hidden blockhash-summary"></td>
<td class="column-action"></td>
</tr>
<tbody class="tx-tbody">
</tbody>
</table>
<div id="table-container" class="table-container">
<table class="tx-table">
<thead>
<tr>
<th value="callbackaction" class="column-callbackaction customaction-header"></th>
<th value="category" class="column-category category-header"><div class="category-arrow"></div></th>
<th value="txid" class="column-txid optional txid-header">TxID<div class="txid-arrow"></div></th>
<th value="label" class="column-label optional label-header">{{ _("Label") }}<div class="label-arrow"></div></th>
<th value="amount" class="column-amount amount-header">{{ _("Amount") }}<div class="amount-arrow"></div></th>
<th value="time" class="column-time time-header">{{ _("Time") }}<div class="time-arrow up-arrow"></div></th>
<th value="confirmations" class="column-confirmations confirmations-header">{{ _("Confirmations") }}<div class="confirmations-arrow"></div></th>
<th value="blockhash" class="column-blockhash optional hidden blockhash-header">
{{ _("Block Hash") }}
{% include "includes/merkletooltip.html" %}
</th>
<th class="column-action" style="width: 50px; padding: 10px;">
<button type="button" class="export-btn btn" style="width: 50px;">
{{ _("Export") }}
</button>
</th>
</tr>
</thead>
<tr class="empty">
<td class="column-callbackaction"></td>
<td class="column-category"></td>
<td class="column-txid">{{ _("Fetching transactions...") }}</td>
<td class="column-label optional"></td>
<td class="column-amount optional"></td>
<td class="column-time" ></td>
<td class="column-confirmations"></td>
<td class="column-blockhash optional hidden blockhash-empty"></td>
<td class="column-action"></td>
</tr>
<tr class="summary-row hidden">
<td class="column-callbackaction"></td>
<td class="column-category" ></td>
<td class="column-txid summary-tx-count"></td>
<td class="column-label optional"></td>
<td class="column-amount summary-amount"></td>
<td class="column-time optional"></td>
<td class="column-confirmations"> </td>
<td class="column-blockhash optional hidden blockhash-summary"></td>
<td class="column-action"></td>
</tr>
<tbody class="tx-tbody">
</tbody>
</table>
</div>
</template>
<script type="text/javascript">
/**
* Custom element for showing a table of transactions.
* Can be configured to show:
* - The transaction history of a wallet (set attribute `type` to "txlist" and `wallet` to the wallet's alias)
* - The UTXO list of a wallet (set attribute `type` to "utxo" and `wallet` to the wallet's alias)
* - The transaction history of all the user's wallets (set attribute `type` to "txlist" and `wallet` to null)
* - The UTXO list of all the user's wallets (set attribute `type` to "utxo" and `wallet` to null)
* The uses Specter's API to fetch transactions every time the user changes its parameters.
* These parameters are configured using components included in this table element:
* - Tabs for switching between History (`type="txlist"`) and UTXO (`type="utxo"`) list types
* - Sorting transactions by any column (ascending or descending)
* - Search bar to filter transactions using free text search
* - Configuring limit for transactions displayed per page
* - Pagination, jumping between pages and showing the total pages count
* - Export - export the full (unfiltered) list from which the table is drawing its data as CSV (optionally with price history)
*
* The table also supports:
* - Showing amounts in either BTC or sats (set attribute `btc-unit` to either "btc" or "sat")
* - Showing prices next to the amounts (set attribute `price` to the BTC price and symbol to the symbol of the currency you're pricing at)
* - Showing validated blockhash column (set attribute `blockhash` to either "true" or "false")
* - Limiting txs/utxos to just those associated with the specified `data-service-id`.
* - Hide columns by hide-columns="category time"
*/
class TxTableElement extends HTMLElement {
constructor() {
super();
@ -237,6 +343,7 @@
var clone = style.cloneNode(true);
this.el = clone.querySelector(".tx-table");
this.tbody = clone.querySelector(".tx-tbody");
this.tableContainer = clone.getElementById("table-container")
// Empty row element
this.emptyRow = clone.querySelector(".empty");
@ -292,31 +399,46 @@
this.composeTxBtn = clone.querySelector(".compose-tx-btn");
this.freezeTxForm = clone.querySelector(".freeze-tx-form");
this.freezeTxBtn = clone.querySelector(".freeze-tx-btn");
this.managePSBTBtn = clone.getElementById("manage-psbt-btn")
this.selectedRows = [];
// Action teaser
this.actionTeaserDiv = clone.getElementById("action-teaser")
this.actionTeaserText = clone.getElementById("action-teaser-text")
// Scrollbar checkbox
this.scrollbarCheckboxDiv = clone.getElementById("scrollbar-checkbox-div")
this.scrollbarCheckbox = clone.getElementById("scrollbar-checkbox")
// CustomAction Preselection default
this.selectedCoins = []
this.txlistViewBtn.onclick = () => {
if (!this.txlistViewBtn.classList.contains("checked")) {
this.idx = 0
this.utxoViewBtn.classList.remove("checked");
this.txlistViewBtn.classList.add("checked");
this.setAttribute("type","txlist");
this.actionTeaserDiv.classList.add("hidden");
}
}
this.utxoViewBtn.onclick = () => {
if (!this.utxoViewBtn.classList.contains("checked")) {
this.idx = 0
this.txlistViewBtn.classList.remove("checked");
this.utxoViewBtn.classList.add("checked");
this.setAttribute("type","utxo");
// The action teaser should not be visible on wallets overview
if (this.wallet != null) {
this.actionTeaserDiv.classList.remove("hidden");
}
}
}
// Init call id to avoid fetch returning after another one triggered
this.callId = 0
// Initialize default sort, search, limit and page parameters
this.idx = 0;
this.limit = 50;
@ -329,7 +451,7 @@
this.paginationNext.onclick = () => {
this.idx++;
console.log("paginationNext fetch");
this.update();
}
// Previous page action
@ -421,6 +543,7 @@
this.export.href = this.export.href.split('?')[0] + `?exportPrices=${this.exportPricesSwitch.checked}&search=${this.search}&sortby=${this.sortby}&sortdir=${this.sortdir}`;
}
// Create transaction with selected UTXO
this.composeTxBtn.onclick = () => {
for (let tx of this.selectedRows) {
this.composeTxForm.innerHTML += `
@ -431,6 +554,7 @@
this.composeTxForm.submit();
}
// Freeze selected UTXO
this.freezeTxBtn.onclick = () => {
for (let tx of this.selectedRows) {
this.freezeTxForm.innerHTML += `
@ -440,6 +564,7 @@
this.freezeTxForm.innerHTML += `<input type="hidden" name="action" value="freezeutxo"/>`
this.freezeTxForm.action = `{{ url_for('wallets_endpoint.history', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet);
this.freezeTxForm.submit();
}
// Attach the created element to the shadow dom
@ -447,11 +572,23 @@
}
static get observedAttributes() {
return ['blockhash', 'btc-unit', 'price', 'symbol', 'type', 'hide-sensitive-info', 'wallet', 'service-id', 'selected-coins'];
return ['blockhash', 'btc-unit', 'price', 'symbol', 'type', 'hide-sensitive-info', 'wallet', 'service-id', 'selected-coins', 'scrollbar-height', 'scrollbar-default', 'compact-switch'];
}
connectedCallback() {
// Hide columns
// Manage PSBT
this.managePSBTBtn.href = `{{ url_for('wallets_endpoint.send_pending', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet);
// Listerning for changes in compact table
this.scrollbarCheckbox.addEventListener("click", () => {
if (this.scrollbarCheckbox.checked) {
this.tableContainer.classList.add("scrollbar")
}
else {
this.tableContainer.classList.remove("scrollbar")
}
})
this.hideColumns = this.getAttribute("hide-columns") ?? "callbackaction"
if (this.hideColumns && this.hideColumns != "null") {
this.hideColumns.split(" ").forEach((column_name) => {
@ -486,6 +623,7 @@
})
}
// TODO: Remove? Not used anywhere and looks incomplete
select_coins(selected_coins) {
this.shadowRoot.querySelectorAll(".txrowitem").forEach((item) => {
selected_coins.forEach((selected_coin) => {
@ -507,8 +645,18 @@
* - type: Transactions list type to load. Either "txlist" or "utxo"
* - hide-sensitive-info: Mask user sensitive info. Either "true" or "false"
* - wallet: The wallet alias (null to get all wallets combined)
* - scrollbar-height: Height of the table container with the scrollbar
* - scrollbar-default: Whether scrollbar is enabled by default
* - compact-switch: Offer a switch to change between long and compact list
*/
attributeChangedCallback(attrName, oldValue, newValue) {
this.tableContainer.style.height = this.getAttribute("scrollbar-height")
if (this.getAttribute("scrollbar-default") == "on") {
this.tableContainer.classList.add("scrollbar")
}
if (this.getAttribute("compact-switch") == "true") {
this.scrollbarCheckboxDiv.classList.remove("hidden")
}
if (attrName != "selected-coins") {
if (this.getAttribute('blockhash') == 'true' && this.getAttribute('type') == "txlist" && this.getAttribute('wallet')) {
this.blockhashHeader.classList.remove('hidden');
@ -558,8 +706,8 @@
}
/**
* searches this.selectedCoin for a transactionId and removes that if found and returns True in that case.
* Explanation: An initial pre-ticking the checkboxes is not that easy as the state of the component is
* searches this.selectedCoins for a transactionId and removes that if found and returns True in that case.
* Explanation: An initial pre ticking of the checkboxes is not that easy as the state of the component is
* created dynamically. This also involves the preselected checkboxes which get set by setAttribute("selected-coins",...)
* In order to solve this, this.selectedCoins is an array of txids which nedd to be checked and they get "consumed" when the
* the check is positive.
@ -572,7 +720,7 @@
if (filtered.length == this.selectedCoins.length) {
return false
} else {
console.log("Found txid"+txid)
console.log(`Found txid: ${txid}`)
this.selectedCoins = filtered
return true
}
@ -641,6 +789,7 @@
formData.append('service_id', this.serviceId);
}
formData.append('csrf_token', '{{ csrf_token() }}');
console.log(url)
try {
let callId = this.callId;
const response = await fetch(
@ -659,7 +808,6 @@
}
const jsonResponse = await response.json();
let showBlockhash = !this.blockhashHeader.classList.contains('hidden');
if ("txlist" in jsonResponse) {
this.render(jsonResponse, showBlockhash)
return
@ -671,7 +819,7 @@
}
}
render(jsonResponse, showBlockhash) {
async render(jsonResponse, showBlockhash) {
// jsonresponse is something like:
// {
// "pageCount": 1,
@ -776,31 +924,154 @@
// if preselectedCoins are there, we need to dispatch an event
let dispatchLater = false
for (let txRow of this.shadowRoot.querySelectorAll('tx-row')) {
// Get txids of pending PSBTs
if (this.wallet) {
var pending_psbts = await this.fetchWalletData();
var pending_psbts_txids = []
const keys = Object.keys(pending_psbts)
keys.forEach((key, index) => {
for (let input of pending_psbts[key]["inputs"]) {
let txid = input["txid"]
pending_psbts_txids.push(txid)
}
});
} else {
pending_psbts = []
}
for (let txRow of this.shadowRoot.querySelectorAll('tx-row')) {
// EventListener when hovering over checkboxes
txRow.selectTxImg.addEventListener("mouseover", () => {
this.actionTeaserDiv.style.color = 'orange';
})
txRow.selectTxImg.addEventListener("mouseout", () => {
this.actionTeaserDiv.style.color = 'grey';
})
// EventListener when hovering over categories
txRow.category.addEventListener("mouseover", () => {
if (txRow.tx.confirmations == 0) {
txRow.iconInfoText.innerText = `${txRow.tx.category} (unconfirmed)`
}
else {
txRow.iconInfoText.innerText = txRow.tx.category
}
})
// Adding additional text to special UTXO in coin selection and UTXO list
if (txRow.tx.locked && pending_psbts_txids.includes(txRow.tx.txid) == false) {
// Replaces checkbox for locked UTXO in coin selection by snowflake image
txRow.callbackaction.innerHTML = ""
// Need to clone otherwise we're ripping the node from the UTXO overview
let frozenImgClone = txRow.frozenImg.cloneNode()
txRow.callbackaction.append(frozenImgClone)
// Adding "Frozen" in coin selection and UTXO overview
txRow.callbackaction.append("Frozen")
txRow.categoryCell.append("Frozen")
}
if (txRow.tx.locked && pending_psbts_txids.includes(txRow.tx.txid) == true) {
txRow.callbackaction.innerHTML = ""
let frozenImgClone = txRow.frozenImg.cloneNode()
txRow.callbackaction.append(frozenImgClone)
// Adding "Unsigned" in coin selection and UTXO overview
txRow.callbackaction.append("Unsigned")
txRow.categoryCell.append("Unsigned")
}
if (txRow.tx.confirmations == 0) {
var el = txRow.callbackaction.childNodes
var nodeArray = Array.from(el)
function checkNodes(node) {
return node.textContent == "Frozen" || node.textContent == "Unsigned"
}
if (nodeArray.some(checkNodes)) {
txRow.callbackaction.append(" & unconfirmed");
}
else {
txRow.callbackaction.append("Unconfirmed");
}
}
// Preselect if necessary
if (this.checkForSelectedCoin(txRow.tx.txid)) {
txRow.select()
dispatchLater = true
}
txRow.addEventListener('txRowCustomSelected', (e) => {
// Listening for (un-) selecting coin selection checkboxes
txRow.addEventListener('coinSelectRowSelected', (e) => {
this.fireCustomSelectedEvent()
})
txRow.addEventListener('txRowSelected', e=>{
let selectedRow = e.target;
// Listening for (un-) selecting standard checkboxes
txRow.addEventListener('txRowSelected', (e) => {
let selectedRow = e.target;
if (e.detail.selected) {
this.selectedRows.push({
txid: e.detail.txid,
vout: e.detail.vout,
amount: e.detail.amount.toFixed(8)
amount: e.detail.amount.toFixed(8),
locked: e.detail.locked,
});
} else {
// Remove unselected rows from array
this.selectedRows = this.selectedRows.filter(row => !(row.txid === e.detail.txid && row.vout === e.detail.vout))
}
if (this.selectedRows.length == 0) {
this.selectedRowsActionBox.classList.add('hidden');
} else {
// Returns true if a selected UTXO is locked
function checkLocked(row) {
return row.locked == true
}
// Returns true if a selected UTXO is unsigned-locked
function checkUnsignedLocked(row) {
if (row.locked == true && pending_psbts_txids.includes(row.txid) == true) {
return true
}
}
// Locked UTXO is selected
if (this.selectedRows.some(checkLocked) && !this.selectedRows.some(checkUnsignedLocked)) {
this.composeTxForm.classList.add('hidden');
this.actionTeaserDiv.classList.add("hidden");
this.selectedRowsActionBox.classList.remove('hidden');
this.freezeTxForm.classList.remove('hidden');
for (let txRow of this.shadowRoot.querySelectorAll('tx-row')) {
if ((!txRow.tx.locked || pending_psbts_txids.includes(txRow.tx.txid) == true)) {
txRow.selectTxImg.classList.add("hidden")
txRow.selectTxImg.src = "{{ url_for('static', filename='img/checkbox-untick.svg') }}"
txRow.selectTxValue.value = ""
// Remove rows that were selected previously
this.selectedRows = this.selectedRows.filter(row => (row.locked))
}
}
}
// Unsigned-locked UTXO is selected
else if (this.selectedRows.some(checkUnsignedLocked)) {
this.composeTxForm.classList.add('hidden');
this.freezeTxForm.classList.add('hidden');
this.selectedRowsActionBox.classList.remove('hidden');
this.managePSBTBtn.classList.remove('hidden');
this.actionTeaserDiv.classList.add("hidden");
for (let txRow of this.shadowRoot.querySelectorAll('tx-row')) {
if ((!pending_psbts_txids.includes(txRow.tx.txid) == true)) {
txRow.selectTxImg.classList.add("hidden")
txRow.selectTxImg.src = "{{ url_for('static', filename='img/checkbox-untick.svg') }}"
txRow.selectTxValue.value = ""
// Remove rows that were selected previously
this.selectedRows = this.selectedRows.filter(row => (checkUnsignedLocked(row)))
}
}
}
else {
this.selectedRowsActionBox.classList.remove('hidden');
this.composeTxForm.classList.remove('hidden');
this.freezeTxForm.classList.remove('hidden');
this.managePSBTBtn.classList.add('hidden');
this.actionTeaserDiv.classList.add("hidden");
for (let txRow of this.shadowRoot.querySelectorAll('tx-row')) {
txRow.selectTxImg.classList.remove("hidden")
}
}
if (this.selectedRows.length == 0) {
this.composeTxForm.classList.add('hidden');
this.freezeTxForm.classList.add('hidden');
this.managePSBTBtn.classList.add('hidden');
this.actionTeaserDiv.classList.remove("hidden");
}
});
}
@ -809,21 +1080,38 @@
}
}
}
/**
* If this event is fired, then some row got ticked/unticked in the custom-selection-column
*/
// If this event is fired, then some row got ticked/unticked in the custom-selection-column
// This is picked up by coin selection
fireCustomSelectedEvent() {
let event = new CustomEvent('CustomSelected', { detail: {
txs: this.getSelectedTxs()
} }
);
let event = new CustomEvent('CustomSelected')
this.dispatchEvent(event);
}
async fetchWalletData() {
// Currently only fetching the list of pending PSBT
if (this.wallet) {
var url = `{{ url_for('wallets_endpoint_api.pending_psbt_list', wallet_alias='WALLET_ALIAS') }}`.replace('WALLET_ALIAS', this.wallet);
}
try {
const response = await fetch(url, {method: 'GET',});
if (response.status != 200) {
showError(`{{ _("Error while fetching wallet data:") }} ${response.statusText}`)
}
else {
let pending_psbts_json = await response.json()
var pending_psbts = pending_psbts_json["pending_psbts"]
// This returns a promise
return pending_psbts
}
}
catch(e) {
console.log("Caught error: ", e);
showError(`{{ _("Failed to fetch wallet data! <br> Use this to ask about this error:") }} ${e}`);
}
}
}
/**
* Shows a <tx-data> element popup for specified txid.
* @param btcUnit - Bitcoin unit to display amounts with. Either "btc" or "sat"

View file

@ -52,13 +52,12 @@
</div>
{{ _("Specter Data Backup") }}:
<div class="note">
{{ _("Warning: This backup does not include private seed of the hot wallets or, obviously, the private seed of your Hardwarewallets.") }}
{{ _("Warning: This backup does not include the private keys of any hot wallet or the BIP 39 seed of your hardware wallets.") }}
</div>
<div class="row">
<a href="{{ url_for('settings_endpoint.backup_file') }}" class="btn" style="width: 100%; margin-top: -5px;">{{ _("Download Specter backup files") }}</a>
<a href="{{ url_for('settings_endpoint.backup_file') }}" class="btn" style="max-width: 500px">{{ _("Download Specter backup files") }}</a>
</div>
<br>
<span style="margin: auto 5px auto 0px;">Load Specter backup:</span>
<div class="tool-tip" style="float: right; margin-bottom: 5px;">
<i class="tool-tip__icon">i</i>
<p class="tool-tip__info">
@ -70,12 +69,12 @@
</span>
</p>
</div>
<span style="margin: auto 5px auto 0px;">{{ _("Load Specter backup") }}:</span><br><br>
<span style="margin: auto 5px auto 0px;">{{ _("Load Specter backup") }}:</span><br>
<div class="row">
<input type="file" id="file" class="inputfile" webkitdirectory mozdirectory/>
<input type="hidden" id="restorewallets" name="restorewallets"/>
<input type="hidden" id="restoredevices" name="restoredevices"/>
<label for="file" class="btn" style="margin-top: -5px;">
<label for="file" class="btn" style="min-width: 500px; margin-top: 5px;">
{{ _("Choose backup folder") }}
</label>
</div>

View file

@ -6,27 +6,78 @@
- specter: Specter object
#}
{% macro total_wallet_balances(wallet, specter) -%}
<h1>
<small style="line-height:30px">Total balance:</small><br>
<span style="color: #fff">
<span id="fullbalance_amount">{{ wallet.fullbalance | btcunitamount }}</span>
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}
</span><br>
<span class="note">{{ wallet.fullbalance | altunit }}</span>
{% if wallet.balance.get("untrusted_pending", 0) or wallet.balance.get("immature", 0) %}<br>
<small>( {{ wallet.balance.get("trusted", 0) | btcunitamount }} confirmed,
{% if wallet.balance.get("untrusted_pending", 0) %}
{{ wallet.balance.get("untrusted_pending", 0) | btcunitamount }} pending
{% endif %}
{% if wallet.balance.get("immature", 0) %}
{{ wallet.balance.get("immature", 0) | btcunitamount }} immature
{% endif %}
)</small>
{% endif %}
<div style="display: flex; flex-direction: column; min-width: 300px; align-items: center">
<span style="line-height:30px; font-size: 1.1em;">Balance:</span>
<div class="smaller-card">
<div>
Total amount: <span id="fullbalance_amount">{{ wallet.amount_total | btcunitamount }}</span>
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}
{% if specter.price_check %}
<span class="note">({{ wallet.amount_total | altunit }})</span>
{% endif %}
</div>
{% if wallet.amount_unconfirmed > 0 or wallet.amount_immature > 0 %}
<div>
Confirmed: {{ wallet.amount_confirmed | btcunitamount }}
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}
{% if specter.price_check %}
<span class="note">({{ wallet.amount_confirmed | altunit }})</span>
{% endif %}
</div>
{% if wallet.amount_unconfirmed > 0 %}
<div>
Unconfirmed: {{ wallet.amount_unconfirmed | btcunitamount }}
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}
{% if specter.price_check %}
<span class="note">({{ wallet.amount_unconfirmed | altunit }})</span>
{% endif %}
</div>
{% endif %}
{% if wallet.amount_immature > 0 and specter.chain == "regtest" %}
<div style="color: grey">
Immature: {{ wallet.amount_immature | btcunitamount }}
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}
{% if specter.price_check %}
<span class="note" style="color: grey">({{ wallet.amount_immature | altunit }})</span>
{% endif %}
<tool-tip style="color: #fff" title='{{ _("What are immature outputs?") }}'>
{{ _('The UTXO of coinbase transactions (mining rewards) have the special condition that they cannot be spent for at least 100 blocks. Their amount is not included in the total balance here. You see these outputs because you are using regtest and have mined blocks.') }}
</tool-tip>
</div>
{% endif %}
{% elif wallet.amount_immature > 0 and specter.chain == "main" %}
<div style="color: #ccc">
Immature: {{ wallet.amount_immature | btcunitamount }}
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}
{% if specter.price_check %}
<span class="note" style="color: grey">({{ wallet.amount_immature | altunit }})</span>
{% endif %}
<tool-tip style="color: #fff" title='{{ _("What are immature outputs?") }}'>
{{ _('The UTXO of coinbase transactions (mining rewards) have the special condition that they cannot be spent for at least 100 blocks. Their amount is not included in the total balance here.') }}
</tool-tip>
</div>
{% endif %}
</div>
{% if specter.is_liquid %}
{% if wallet.balance.get("assets", {}) %}
<div style="margin-bottom: 15px;">
@ -47,7 +98,7 @@
</small>
<br>
{% endif %}
</h1>
</div>
{% if wallet.rescan_progress or specter.utxorescanwallet == wallet.alias %}
<span id="wallet_rescan_data" style="display: contents;">
<h2>Rescanning blockchain: <span id="{{ wallet.alias }}_balances_wallet_rescan_percents">{{ "%.2f"|format(specter.info["utxorescan"] if specter.utxorescanwallet == wallet.alias else wallet.rescan_progress * 100) }}</span>%</h2>

View file

@ -18,7 +18,10 @@
blockhash="{{ specter.config.validate_merkle_proofs | lower }}"
type="{{ tx_list_type }}"
hide-sensitive-info="{{ specter.hide_sensitive_info | lower }}"
wallet="{{ wallet.alias }}">
wallet="{{ wallet.alias }}"
scrollbar-height="500px"
scrollbar-default="on"
compact-switch="false">
</tx-table>
</div>
{% endblock %}

View file

@ -27,44 +27,38 @@
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="rbf_tx_id" value="{{ rbf_tx_id }}"/>
<h1 class="padded">{{ _("Create Transaction") }}</h1>
<p class="center">{{ _("Available funds:") }} {{wallet.full_available_balance | btcunitamount}}
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}<br>
{% if specter.price_check %}
<span class="note">{{ wallet.full_available_balance | altunit }}</span>
{% endif %}
{% if specter.is_liquid and wallet.balance.get("assets", {}) %}
<span>
<br>{{ _("Assets:") }}<br>
{% for asset in wallet.balance.get("assets",{}).keys() | sort %}
{% set balance = wallet.balance.get("assets",{}).get(asset, {}) %}
<span style="margin: 0 10px;">
{{ (balance.get("trusted", 0) + balance.get("untrusted_pending", 0) + balance.get("immature", 0)) | btcamount }}
<asset-label data-asset="{{asset}}" data-label="{{asset | assetlabel}}"></asset-label>
</span>
{% endfor %}
</span>
{% endif %}
</p>
{% if wallet.locked_amount > 0 %}
<div style="margin: auto; text-align: center; max-width: 90%;">
<div class="tool-tip" style="text-align: center;">
<i class="tool-tip__icon">i</i>
<p class="tool-tip__info">
<span class="info">
<span class="info__title">{{ _("Funds waiting in unsigned transactions") }}<br><br></span>
{{ _('To make funds available to spend in new transactions, just click on the "Unsigned" tab and delete unsigned transactions you have no need for anymore.') }}
</span>
</p>
<div style="display: flex; justify-content: center; margin-bottom: 15px">
<div>{{ _("Available funds:") }} {{wallet.amount_available | btcunitamount}}
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}<br>
{% if specter.price_check %}
<span class="note">{{ wallet.amount_available | altunit }}</span>
{% endif %}
{% if specter.is_liquid and wallet.balance.get("assets", {}) %}
<span>
<br>{{ _("Assets:") }}<br>
{% for asset in wallet.balance.get("assets",{}).keys() | sort %}
{% set balance = wallet.balance.get("assets",{}).get(asset, {}) %}
<span style="margin: 0 10px;">
{{ (balance.get("trusted", 0) + balance.get("untrusted_pending", 0) + balance.get("immature", 0)) | btcamount }}
<asset-label data-asset="{{asset}}" data-label="{{asset | assetlabel}}"></asset-label>
</span>
{% endfor %}
</span>
{% endif %}
</div>
<p style="display: inline; margin: auto; text-align: center;">&nbsp;{{ _("You have") }} {{wallet.locked_amount | btcunitamount}} {% if specter.unit == 'sat' %}sats{% else %}{% if specter.is_testnet %}t{%endif%}BTC{% endif %} {{ _("waiting in unsigned transactions.") }}</p>
<br>
<br>
</div>
{% endif %}
{% if wallet.amount_locked_unsigned > 0 or wallet.amount_frozen > 0 %}
<div>
<tool-tip title='{{ _("Why is this less than mal total balance?") }}'>
{{ _(' Frozen outputs and outputs locked in unsigned transactions cannot be used here.
To make more funds available, click on the "Unsigned" tab and delete not required transactions or unfreeze UTXO under "Transactions".') }}
</tool-tip>
</div>
{% endif %}
</div>
<div class="card" style="margin: auto;">
<div id="recipients" {% if ui_option != 'ui' %}class="hidden"{% endif %}></div>
<div id="recipients-txt-container" {% if ui_option == 'ui' %}class="hidden"{% endif %}>

View file

@ -8,5 +8,5 @@
{% from 'wallet/send/pending/components/pending_psbt_table.jinja' import pending_psbt_table %}
{{ pending_psbt_table(pending_psbts, wallet_alias, wallet, specter) }}
</div><br>
<span class="center note padded">{{ _("Here you can manage PSBTs (Partially Signed Bitcoin Transactions) you have created but did not finish signing or broadcasted to the network.") }}</span>
<span class="center note padded" style="margin-top: 12px;">{{ _("Here you can manage PSBTs (Partially Signed Bitcoin Transactions) you have created but did not finish signing or did not broadcast to the network.") }}</span>
{% endblock %}

View file

@ -811,9 +811,9 @@ class Wallet:
tx["category"] = tx_data.get("category") or "send"
if "locked" not in tx:
tx["locked"] = False
self.full_utxo = sorted(utxo, key=lambda utxo: utxo["time"], reverse=True)
self._full_utxo = sorted(utxo, key=lambda utxo: utxo["time"], reverse=True)
except Exception as e:
self.full_utxo = []
self._full_utxo = []
raise SpecterError(f"Failed to load utxos, {e}")
def getdata(self):
@ -841,9 +841,21 @@ class Wallet:
self.change_address = self.get_address(self.change_index, change=True)
self.save_to_file()
@property
def full_utxo(self):
if hasattr(self, "_full_utxo"):
return self._full_utxo
else:
self.check_utxo()
return self._full_utxo
@property
def utxo(self):
return [utxo for utxo in self.full_utxo if not utxo["locked"]]
return [utxo for utxo in self._full_utxo if not utxo["locked"]]
@property
def locked_utxo(self):
return [utxo for utxo in self._full_utxo if utxo["locked"]]
@property
def json(self):
@ -914,10 +926,8 @@ class Wallet:
@property
def locked_amount(self):
amount = 0
for psbt in self.pending_psbts.values():
amount += sum([inp.float_amount for inp in psbt.inputs])
return amount
"""Deprecated, please use amount_locked_unsigned"""
return self.amount_locked_unsigned
def delete_spent_pending_psbts(self, txs: list):
"""
@ -1543,22 +1553,6 @@ class Wallet:
if self.use_descriptors
else self.rpc.getbalances()["watchonly"]
)
# calculate available balance
locked_utxo = self.rpc.listlockunspent()
available = {}
available.update(balance)
for tx in locked_utxo:
tx_data = self.gettransaction(tx["txid"])
raw_tx = decoderawtransaction(tx_data["hex"], self.manager.chain)
delta = raw_tx["vout"][tx["vout"]]["value"]
if "confirmations" not in tx_data or tx_data["confirmations"] == 0:
available["untrusted_pending"] -= delta
else:
available["trusted"] -= delta
available["trusted"] = round(available["trusted"], 8)
available["untrusted_pending"] = round(available["untrusted_pending"], 8)
balance["trusted"] = available["trusted"]
balance["untrusted_pending"] = available["untrusted_pending"]
except Exception as e:
raise SpecterError(f"was not able to get wallet_balance because {e}")
self.balance = balance
@ -1664,13 +1658,58 @@ class Wallet:
# TODO: remove
return self.getlabel(address)
@property
def amount_confirmed(self):
"""Confirmed outputs (and outputs created by the wallet for Bitcoin Core Hot Wallets)"""
return round(self.balance["trusted"], 8)
@property
def amount_unconfirmed(self):
"""Unconfirmed outputs"""
return round(self.balance["untrusted_pending"], 8)
@property
def amount_frozen(self):
"""Only frozen outputs, no outputs locked in unsigned PSBTS"""
amount = 0
frozen_txid = [utxo.split(":")[0] for utxo in self.frozen_utxo]
for utxo in self.locked_utxo:
if utxo["txid"] in frozen_txid:
amount += utxo["amount"]
return amount
@property
def amount_locked_unsigned(self):
"""Outputs locked in unsigned PSBTs"""
amount = 0
for psbt in self.pending_psbts.values():
amount += sum([inp.float_amount for inp in psbt.inputs])
return amount
@property
def amount_immature(self):
"""Immature coinbase outputs"""
return round(self.balance["immature"], 8)
@property
def amount_total(self):
"""All outputs, including unconfirmed outputs, except for immature outputs"""
return self.amount_confirmed + self.amount_unconfirmed
@property
def amount_available(self):
"""All outputs minus UTXO locked in unsigned transactions and frozen outputs"""
return self.amount_total - self.amount_locked_unsigned - self.amount_frozen
@property
def fullbalance(self):
"""Deprecated, please use amount_total"""
balance = self.balance
return round(balance["trusted"], 8)
@property
def full_available_balance(self):
"""Deprecated, please use amount_available"""
return round(self.balance["trusted"] + self.balance["untrusted_pending"], 8)
@property
@ -1712,7 +1751,7 @@ class Wallet:
total_sats = round(sum(amounts) * 1e8)
# if creating new tx - check we have enough balance
if not rbf_edit_mode and self.full_available_balance < total_btc:
if not rbf_edit_mode and self.amount_available < total_btc:
raise SpecterError(
f"Wallet {self.name} does not have sufficient funds to make the transaction."
)
@ -2043,7 +2082,7 @@ class Wallet:
addr_amount = 0
for utxo in [
utxo for utxo in self.full_utxo if utxo["address"] == addr.address
utxo for utxo in self._full_utxo if utxo["address"] == addr.address
]:
addr_amount = addr_amount + utxo["amount"]
addr_utxo = addr_utxo + 1

View file

@ -470,36 +470,6 @@ def specter_regtest_configured(bitcoin_regtest, devices_filled_data_folder):
specter.user_manager.save()
specter.check()
assert not someuser.wallet_manager.working_folder is None
# Create a Wallet
wallet_json = '{"label": "a_simple_wallet", "blockheight": 0, "descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/0/*)#xp8lv5nr", "devices": [{"type": "trezor", "label": "trezor"}]} '
wallet_importer = WalletImporter(
wallet_json, specter, device_manager=someuser.device_manager
)
wallet_importer.create_nonexisting_signers(
someuser.device_manager,
{"unknown_cosigner_0_name": "trezor", "unknown_cosigner_0_type": "trezor"},
)
dm: DeviceManager = someuser.device_manager
wallet = wallet_importer.create_wallet(someuser.wallet_manager)
try:
# fund it with some coins
bitcoin_regtest.testcoin_faucet(address=wallet.getnewaddress())
# make sure it's confirmed
bitcoin_regtest.mine()
# Realize that the wallet has funds:
wallet.update()
except SpecterError as se:
if str(se).startswith("Timeout"):
pytest.fail(
"We got a Bitcoin-RPC timeout while setting up the test, minting some coins. Test Error! Check cpu/mem utilastion and btc/elem logs!"
)
return
else:
raise se
assert wallet.fullbalance >= 20
assert not specter.wallet_manager.working_folder is None
try:
yield specter

View file

@ -1,5 +1,6 @@
import pytest
import random
import time
from cryptoadvance.specter.util.mnemonic import generate_mnemonic
from cryptoadvance.specter.process_controller.node_controller import NodeController
@ -15,16 +16,6 @@ mnemonic_zoo = (
)
@pytest.fixture
def hot_wallet_device_1(specter_regtest_configured):
return create_hot_wallet_device(specter_regtest_configured)
@pytest.fixture
def hot_wallet_device_2(specter_regtest_configured):
return create_hot_wallet_device(specter_regtest_configured)
def create_hot_wallet_device(
specter_regtest_configured, name=None, mnemonic=None
) -> Device:
@ -58,6 +49,29 @@ def create_hot_wallet_device(
return device
@pytest.fixture
def hot_wallet_device_1(specter_regtest_configured):
return create_hot_wallet_device(specter_regtest_configured)
@pytest.fixture
def hot_wallet_device_2(specter_regtest_configured):
return create_hot_wallet_device(specter_regtest_configured)
def create_hot_wallet_with_ID(
specter_regtest_configured: Specter, hot_wallet_device_1, wallet_id
) -> Wallet:
device = hot_wallet_device_1
wallet_manager = specter_regtest_configured.wallet_manager
assert device.taproot_available(specter_regtest_configured.rpc)
# create the wallet
keys = [key for key in device.keys if key.key_type == "wpkh"]
source_wallet = wallet_manager.create_wallet(wallet_id, 1, "wpkh", keys, [device])
return source_wallet
@pytest.fixture
def unfunded_hot_wallet_1(specter_regtest_configured, hot_wallet_device_1) -> Wallet:
return create_hot_wallet_with_ID(
@ -76,19 +90,6 @@ def unfunded_hot_wallet_2(specter_regtest_configured, hot_wallet_device_1) -> Wa
)
def create_hot_wallet_with_ID(
specter_regtest_configured: Specter, hot_wallet_device_1, wallet_id
) -> Wallet:
device = hot_wallet_device_1
wallet_manager = specter_regtest_configured.wallet_manager
assert device.taproot_available(specter_regtest_configured.rpc)
# create the wallet
keys = [key for key in device.keys if key.key_type == "wpkh"]
source_wallet = wallet_manager.create_wallet(wallet_id, 1, "wpkh", keys, [device])
return source_wallet
@pytest.fixture
def funded_hot_wallet_1(
bitcoin_regtest: NodeController, unfunded_hot_wallet_1: Wallet
@ -96,16 +97,15 @@ def funded_hot_wallet_1(
funded_hot_wallet_1 = unfunded_hot_wallet_1
assert len(funded_hot_wallet_1.txlist()) == 0
for i in range(0, 10):
bitcoin_regtest.testcoin_faucet(
funded_hot_wallet_1.getnewaddress(), amount=random.randint(1, 4)
)
bitcoin_regtest.testcoin_faucet(funded_hot_wallet_1.getnewaddress(), amount=1)
funded_hot_wallet_1.update()
for i in range(0, 2):
bitcoin_regtest.testcoin_faucet(
funded_hot_wallet_1.getnewaddress(),
amount=random.randint(1, 4),
amount=2.5,
confirm_payment=False,
)
time.sleep(0.5) # needed for tx to propagate
funded_hot_wallet_1.update()
# 12 txs
assert len(funded_hot_wallet_1.txlist()) == 12

View file

@ -1,146 +1,67 @@
import json, logging, pytest, time, os
from cryptoadvance.specter.specter import Specter
from cryptoadvance.specter.wallet import Wallet
from cryptoadvance.specter.managers.wallet_manager import WalletManager
from conftest import instantiate_bitcoind_controller
logger = logging.getLogger(__name__)
@pytest.mark.slow
def test_import_address_labels(
caplog, docker, request, devices_filled_data_folder, device_manager
caplog, specter_regtest_configured: Specter, funded_hot_wallet_1: Wallet
):
caplog.set_level(logging.DEBUG)
wallet = funded_hot_wallet_1
# ==== Specter-specific: do custom setup ====
# Instantiate a new bitcoind w/limited mempool. Use a different port to not interfere
# with existing instance for other tests.
bitcoind_controller = instantiate_bitcoind_controller(
docker,
request,
rpcport=18968,
extra_args=["-acceptnonstdtxn=1", "-maxmempool=5", "-spendzeroconfchange=0"],
# the utxo is only available after the 100 mined blocks
utxos = wallet.rpc.listunspent()
logger.debug(f"these are the utxos: {utxos}.")
# txid of the funding of test_address
txid = utxos[0]["txid"]
logger.debug(f"this is the txid: {txid}.")
test_address = utxos[0]["address"]
logger.debug(f"these are the addresses: {wallet.addresses}.")
logger.debug(f"these are the _addresses: {wallet._addresses}.")
assert wallet._addresses[test_address]["label"] is None
number_of_addresses = len(wallet._addresses)
# Electrum
# Test it with a txid label that does not belong to the wallet -> should be ignored
wallet.import_address_labels(
json.dumps(
{
"8d0958cb8701fac7421eb077e44b36809b90c7ad4a35e0c607c2cd591c522668": "txid label"
}
)
)
try:
assert bitcoind_controller.get_rpc().test_connection()
rpcconn = bitcoind_controller.rpcconn
rpc = rpcconn.get_rpc()
assert rpc is not None
assert rpc.ipaddress != None
assert wallet._addresses[test_address]["label"] is None
assert len(wallet._addresses) == number_of_addresses
# Note: Our utxo creation is simpler than mempool_limit.py's approach since we're
# running in regtest and can just use generatetoaddress().
# Test it with an address label that does not belong to the wallet -> should be ignored
wallet.import_address_labels(
json.dumps({"12dRugNcdxK39288NjcDV4GX7rMsKCGn6B": "address label"})
)
assert wallet._addresses[test_address]["label"] is None
assert len(wallet._addresses) == number_of_addresses
# Instantiate a new Specter instance to talk to this bitcoind
config = {
"rpc": {
"autodetect": False,
"datadir": "",
"user": rpcconn.rpcuser,
"password": rpcconn.rpcpassword,
"port": rpcconn.rpcport,
"host": rpcconn.ipaddress,
"protocol": "http",
},
"auth": {
"method": "rpcpasswordaspin",
},
}
specter = Specter(data_folder=devices_filled_data_folder, config=config)
specter.check()
# Test it with a txid label
wallet.import_address_labels(json.dumps({txid: "txid label"}))
assert wallet._addresses[test_address]["label"] == "txid label"
# Largely copy-and-paste from test_wallet_manager.test_wallet_createpsbt.
# TODO: Make a test fixture in conftest.py that sets up already funded wallets
# for a bitcoin core hot wallet.
wallet_manager = WalletManager(
200100,
devices_filled_data_folder,
rpc,
"regtest",
device_manager,
allow_threading=False,
)
# The txid label should now be replaced by the address label
wallet.import_address_labels(json.dumps({test_address: "address label"}))
assert wallet._addresses[test_address]["label"] == "address label"
# Create a new device that can sign psbts (Bitcoin Core hot wallet)
device = device_manager.add_device(
name="bitcoin_core_hot_wallet", device_type="bitcoincore", keys=[]
)
device.setup_device(file_password=None, wallet_manager=wallet_manager)
device.add_hot_wallet_keys(
mnemonic="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
passphrase="",
paths=["m/49h/0h/0h"],
file_password=None,
wallet_manager=wallet_manager,
testnet=True,
keys_range=[0, 1000],
keys_purposes=[],
)
# Specter JSON
wallet._addresses[test_address].set_label("some_fancy_label_json")
specter_json = json.dumps(wallet.to_json(for_export=True))
wallet._addresses[test_address].set_label("label_got_lost")
wallet.import_address_labels(specter_json)
assert wallet._addresses[test_address]["label"] == "some_fancy_label_json"
wallet = wallet_manager.create_wallet(
"bitcoincore_test_wallet", 1, "sh-wpkh", [device.keys[0]], [device]
)
# Fund the wallet. Going to need a LOT of utxos to play with.
logger.info("Generating utxos to wallet")
test_address = wallet.getnewaddress() # 2NCSZrX49HHyzUy6oj8ggm9WD19hFvjzzou
wallet.rpc.generatetoaddress(1, test_address)[0]
# newly minted coins need 100 blocks to get spendable
# let's mine another 100 blocks to get these coins spendable
trash_address = wallet.getnewaddress()
wallet.rpc.generatetoaddress(100, trash_address)
# the utxo is only available after the 100 mined blocks
utxos = wallet.rpc.listunspent()
# txid of the funding of test_address
txid = utxos[0]["txid"]
assert wallet._addresses[test_address]["label"] is None
number_of_addresses = len(wallet._addresses)
# Electrum
# Test it with a txid label that does not belong to the wallet -> should be ignored
wallet.import_address_labels(
json.dumps(
{
"8d0958cb8701fac7421eb077e44b36809b90c7ad4a35e0c607c2cd591c522668": "txid label"
}
)
)
assert wallet._addresses[test_address]["label"] is None
assert len(wallet._addresses) == number_of_addresses
# Test it with an address label that does not belong to the wallet -> should be ignored
wallet.import_address_labels(
json.dumps({"12dRugNcdxK39288NjcDV4GX7rMsKCGn6B": "address label"})
)
assert wallet._addresses[test_address]["label"] is None
assert len(wallet._addresses) == number_of_addresses
# Test it with a txid label
wallet.import_address_labels(json.dumps({txid: "txid label"}))
assert wallet._addresses[test_address]["label"] == "txid label"
# The txid label should now be replaced by the address label
wallet.import_address_labels(json.dumps({test_address: "address label"}))
assert wallet._addresses[test_address]["label"] == "address label"
# Specter JSON
wallet._addresses[test_address].set_label("some_fancy_label_json")
specter_json = json.dumps(wallet.to_json(for_export=True))
wallet._addresses[test_address].set_label("label_got_lost")
wallet.import_address_labels(specter_json)
assert wallet._addresses[test_address]["label"] == "some_fancy_label_json"
# Specter CSV
csv_string = """Index,Address,Type,Label,Used,UTXO,Amount (BTC)
0,2NCSZrX49HHyzUy6oj8ggm9WD19hFvjzzou,receive,some_fancy_label_csv,Yes,0,0"""
wallet._addresses[test_address].set_label("label_got_lost")
wallet.import_address_labels(csv_string)
assert wallet._addresses[test_address]["label"] == "some_fancy_label_csv"
finally:
# Clean up
bitcoind_controller.stop_bitcoind()
# Specter CSV
csv_string = f"""Index,Address,Type,Label,Used,UTXO,Amount (BTC)
0,{test_address},receive,some_fancy_label_csv,Yes,0,0"""
wallet._addresses[test_address].set_label("label_got_lost")
wallet.import_address_labels(csv_string)
assert wallet._addresses[test_address]["label"] == "some_fancy_label_csv"

View file

@ -16,178 +16,154 @@ from conftest import instantiate_bitcoind_controller
@pytest.mark.slow
def test_WalletManager(docker, request, devices_filled_data_folder, device_manager):
# Instantiate a fresh bitcoind instance to isolate this test.
bitcoind_controller = instantiate_bitcoind_controller(
docker, request, rpcport=18998
def test_WalletManager(
docker, request, devices_filled_data_folder, device_manager, bitcoin_regtest
):
wm = WalletManager(
200100,
devices_filled_data_folder,
bitcoin_regtest.get_rpc(),
"regtest",
device_manager,
allow_threading=False,
)
try:
wm = WalletManager(
200100,
devices_filled_data_folder,
bitcoind_controller.get_rpc(),
"regtest",
device_manager,
allow_threading=False,
)
# A wallet-creation needs a device
device = device_manager.get_by_alias("trezor")
assert device != None
# Lets's create a wallet with the WalletManager
wm.create_wallet("a_test_wallet", 1, "wpkh", [device.keys[5]], [device])
# The wallet-name gets its filename and therefore its alias
wallet = wm.wallets["a_test_wallet"]
assert wallet != None
assert wallet.balance["trusted"] == 0
assert wallet.balance["untrusted_pending"] == 0
# this is a sum of both
assert wallet.fullbalance == 0
address = wallet.getnewaddress()
# newly minted coins need 100 blocks to get spendable
wallet.rpc.generatetoaddress(1, address)
# let's mine another 100 blocks to get these coins spendable
random_address = "mruae2834buqxk77oaVpephnA5ZAxNNJ1r"
wallet.rpc.generatetoaddress(100, random_address)
# update the balance
wallet.update_balance()
assert wallet.fullbalance >= 25
# A wallet-creation needs a device
device = device_manager.get_by_alias("trezor")
assert device != None
# Lets's create a wallet with the WalletManager
wm.create_wallet("a_test_wallet", 1, "wpkh", [device.keys[5]], [device])
# The wallet-name gets its filename and therefore its alias
wallet = wm.wallets["a_test_wallet"]
assert wallet != None
assert wallet.balance["trusted"] == 0
assert wallet.balance["untrusted_pending"] == 0
# this is a sum of both
assert wallet.amount_total == 0
address = wallet.getnewaddress()
bitcoin_regtest.testcoin_faucet(address, amount=3)
# update the balance
wallet.update_balance()
assert wallet.amount_total == 3
assert wallet.amount_immature == 0
# You can create a multisig wallet with the wallet manager like this
second_device = device_manager.get_by_alias("specter")
multisig_wallet = wm.create_wallet(
"a_multisig_test_wallet",
1,
"wsh",
[device.keys[7], second_device.keys[0]],
[device, second_device],
)
# You can create a multisig wallet with the wallet manager like this
second_device = device_manager.get_by_alias("specter")
multisig_wallet = wm.create_wallet(
"a_multisig_test_wallet",
1,
"wsh",
[device.keys[7], second_device.keys[0]],
[device, second_device],
)
assert len(wm.wallets) == 2
assert multisig_wallet != None
assert multisig_wallet.fullbalance == 0
multisig_address = multisig_wallet.getnewaddress()
multisig_wallet.rpc.generatetoaddress(1, multisig_address)
multisig_wallet.rpc.generatetoaddress(100, random_address)
# update balance
multisig_wallet.update_balance()
assert multisig_wallet.fullbalance >= 12.5
# The WalletManager also has a `wallets_names` property, returning a sorted list of the names of all wallets
assert wm.wallets_names == ["a_multisig_test_wallet", "a_test_wallet"]
assert len(wm.wallets) == 2
assert multisig_wallet != None
assert multisig_wallet.amount_total == 0
multisig_address = multisig_wallet.getnewaddress()
bitcoin_regtest.testcoin_faucet(multisig_address, amount=4)
# update balance
multisig_wallet.update_balance()
assert multisig_wallet.amount_total == 4
# The WalletManager also has a `wallets_names` property, returning a sorted list of the names of all wallets
assert wm.wallets_names == ["a_multisig_test_wallet", "a_test_wallet"]
# You can rename a wallet using the wallet manager using `rename_wallet`, passing the wallet object and the new name to assign to it
wm.rename_wallet(multisig_wallet, "new_name_test_wallet")
assert multisig_wallet.name == "new_name_test_wallet"
assert wm.wallets_names == ["a_test_wallet", "new_name_test_wallet"]
# You can rename a wallet using the wallet manager using `rename_wallet`, passing the wallet object and the new name to assign to it
wm.rename_wallet(multisig_wallet, "new_name_test_wallet")
assert multisig_wallet.name == "new_name_test_wallet"
assert wm.wallets_names == ["a_test_wallet", "new_name_test_wallet"]
# you can also delete a wallet by passing it to the wallet manager's `delete_wallet` method
# it will delete the json and attempt to remove it from Bitcoin Core
wallet_fullpath = multisig_wallet.fullpath
assert os.path.exists(wallet_fullpath)
wm.delete_wallet(multisig_wallet)
assert not os.path.exists(wallet_fullpath)
assert len(wm.wallets) == 1
finally:
# cleanup
bitcoind_controller.stop_bitcoind()
# you can also delete a wallet by passing it to the wallet manager's `delete_wallet` method
# it will delete the json and attempt to remove it from Bitcoin Core
wallet_fullpath = multisig_wallet.fullpath
assert os.path.exists(wallet_fullpath)
wm.delete_wallet(multisig_wallet)
assert not os.path.exists(wallet_fullpath)
assert len(wm.wallets) == 1
@pytest.mark.slow
def test_wallet_createpsbt(docker, request, devices_filled_data_folder, device_manager):
# Instantiate a fresh bitcoind instance to isolate this test.
bitcoind_controller = instantiate_bitcoind_controller(
docker, request, rpcport=18978
def test_wallet_createpsbt(
docker, request, devices_filled_data_folder, device_manager, bitcoin_regtest
):
wm = WalletManager(
200100,
devices_filled_data_folder,
bitcoin_regtest.get_rpc(),
"regtest",
device_manager,
allow_threading=False,
)
# A wallet-creation needs a device
device = device_manager.get_by_alias("specter")
key = Key.from_json(
{
"derivation": "m/48h/1h/0h/2h",
"original": "Vpub5n9kKePTPPGtw3RddeJWJe29epEyBBcoHbbPi5HhpoG2kTVsSCUzsad33RJUt3LktEUUPPofcZczuudnwR7ZgkAkT6N2K2Z7wdyjYrVAkXM",
"fingerprint": "08686ac6",
"type": "wsh",
"xpub": "tpubDFHpKypXq4kwUrqLotPs6fCic5bFqTRGMBaTi9s5YwwGymE8FLGwB2kDXALxqvNwFxB1dLWYBmmeFVjmUSdt2AsaQuPmkyPLBKRZW8BGCiL",
}
)
wallet = wm.create_wallet("a_second_test_wallet", 1, "wpkh", [key], [device])
# Let's fund the wallet
address = wallet.getnewaddress()
assert address == "bcrt1qtnrv2jpygx2ef3zqfjhqplnycxak2m6ljnhq6z"
bitcoin_regtest.testcoin_faucet(address, amount=5)
address = wallet.getnewaddress()
bitcoin_regtest.testcoin_faucet(address, amount=10)
address = wallet.getnewaddress()
bitcoin_regtest.testcoin_faucet(address, amount=15)
# update the wallet data
wallet.update_balance()
assert wallet.amount_total == 30
unspents = wallet.rpc.listunspent(0)
# Let's take 3 UTXO:
selected_coins = [
{"txid": u["txid"], "vout": u["vout"]}
for u in [unspents[0], unspents[1], unspents[2]]
]
selected_coins_amount_sum = (
unspents[0]["amount"] + unspents[1]["amount"] + unspents[2]["amount"]
)
number_of_coins_to_spend = (
selected_coins_amount_sum - 0.1
) # Let's spend almost all of them
random_address = "bcrt1qwxhn49vkppudjcgrd4mylxczgjtuwayg5ckp5s"
psbt = wallet.createpsbt(
[random_address],
[number_of_coins_to_spend],
True,
0,
10,
selected_coins=selected_coins,
)
assert len(psbt["tx"]["vin"]) == 3
psbt_txs = [tx["txid"] for tx in psbt["tx"]["vin"]]
for coin in selected_coins:
assert coin["txid"] in psbt_txs
# Now let's spend more coins than we have selected. This should result in an exception:
try:
wm = WalletManager(
200100,
devices_filled_data_folder,
bitcoind_controller.rpcconn.get_rpc(),
"regtest",
device_manager,
allow_threading=False,
)
# A wallet-creation needs a device
device = device_manager.get_by_alias("specter")
key = Key.from_json(
{
"derivation": "m/48h/1h/0h/2h",
"original": "Vpub5n9kKePTPPGtw3RddeJWJe29epEyBBcoHbbPi5HhpoG2kTVsSCUzsad33RJUt3LktEUUPPofcZczuudnwR7ZgkAkT6N2K2Z7wdyjYrVAkXM",
"fingerprint": "08686ac6",
"type": "wsh",
"xpub": "tpubDFHpKypXq4kwUrqLotPs6fCic5bFqTRGMBaTi9s5YwwGymE8FLGwB2kDXALxqvNwFxB1dLWYBmmeFVjmUSdt2AsaQuPmkyPLBKRZW8BGCiL",
}
)
wallet = wm.create_wallet("a_second_test_wallet", 1, "wpkh", [key], [device])
# Let's fund the wallet with ... let's say 40 blocks a 50 coins each --> 200 coins
address = wallet.getnewaddress()
assert address == "bcrt1qtnrv2jpygx2ef3zqfjhqplnycxak2m6ljnhq6z"
wallet.rpc.generatetoaddress(20, address)
# in two addresses
address = wallet.getnewaddress()
wallet.rpc.generatetoaddress(20, address)
# newly minted coins need 100 blocks to get spendable
# let's mine another 100 blocks to get these coins spendable
random_address = "mruae2834buqxk77oaVpephnA5ZAxNNJ1r"
wallet.rpc.generatetoaddress(110, random_address)
# update the wallet data
wallet.update_balance()
# Now we have loads of potential inputs
# Let's spend 500 coins
assert wallet.fullbalance >= 250
# From this print-statement, let's grab some txids which we'll use for coinselect
unspents = wallet.rpc.listunspent(0)
# Lets take 3 more or less random txs from the unspents:
selected_coins = [
{"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"]
)
number_of_coins_to_spend = (
selected_coins_amount_sum - 0.1
) # Let's spend almost all of them
psbt = wallet.createpsbt(
[random_address],
[number_of_coins_to_spend],
[number_of_coins_to_spend + 1],
True,
0,
10,
selected_coins=selected_coins,
)
assert len(psbt["tx"]["vin"]) == 3
psbt_txs = [tx["txid"] for tx in psbt["tx"]["vin"]]
for coin in selected_coins:
assert coin["txid"] in psbt_txs
assert False, "should throw an exception!"
except SpecterError as e:
pass
# Now let's spend more coins than we have selected. This should result in an exception:
try:
psbt = wallet.createpsbt(
[random_address],
[number_of_coins_to_spend + 1],
True,
0,
10,
selected_coins=selected_coins,
)
assert False, "should throw an exception!"
except SpecterError as e:
pass
assert wallet.amount_locked_unsigned == selected_coins_amount_sum
assert len(wallet.rpc.listlockunspent()) == 3
assert wallet.amount_available == wallet.amount_total - selected_coins_amount_sum
assert wallet.locked_amount == selected_coins_amount_sum
assert len(wallet.rpc.listlockunspent()) == 3
assert (
wallet.full_available_balance
== wallet.fullbalance - selected_coins_amount_sum
)
wallet.delete_pending_psbt(psbt["tx"]["txid"])
assert wallet.locked_amount == 0
assert len(wallet.rpc.listlockunspent()) == 0
assert wallet.full_available_balance == wallet.fullbalance
finally:
# cleanup
bitcoind_controller.stop_bitcoind()
wallet.delete_pending_psbt(psbt["tx"]["txid"])
assert wallet.amount_locked_unsigned == 0
assert len(wallet.rpc.listlockunspent()) == 0
assert wallet.amount_total == wallet.amount_available
def test_WalletManager_check_duplicate_keys(empty_data_folder):
@ -335,26 +311,33 @@ def test_wallet_labeling(bitcoin_regtest, devices_filled_data_folder, device_man
)
wallet = wm.create_wallet("a_second_test_wallet", 1, "wpkh", [key], [device])
address = wallet.address
assert wallet.getlabel(address) == "Address #0"
wallet.setlabel(address, "Random label")
assert wallet.getlabel(address) == "Random label"
first_address = wallet.address
assert wallet.getlabel(first_address) == "Address #0"
wallet.setlabel(first_address, "Random label")
assert wallet.getlabel(first_address) == "Random label"
wallet.rpc.generatetoaddress(20, address)
second_address = wallet.getnewaddress()
wallet.setlabel(second_address, "")
bitcoin_regtest.testcoin_faucet(second_address, amount=0.6)
random_address = "mruae2834buqxk77oaVpephnA5ZAxNNJ1r"
wallet.rpc.generatetoaddress(100, random_address)
third_address = wallet.getnewaddress()
wallet.setlabel(third_address, "")
bitcoin_regtest.testcoin_faucet(third_address, amount=0.4)
assert sorted(wallet.addresses) == sorted(
[first_address, second_address, third_address]
)
# Make 20 UTXO
for i in range(0, 20):
_address = wallet.getnewaddress()
bitcoin_regtest.testcoin_faucet(_address, amount=0.1)
# update utxo
wallet.getdata()
# update balance
wallet.update_balance()
assert len(wallet.full_utxo) == 22
address_balance = wallet.fullbalance
assert len(wallet.full_utxo) == 20
print(wallet.full_utxo[4])
# Something like:
# An entry in full_utxo looks something like this:
# { 'txid': 'fab823558781745179916b4bfdfd65b382bfc0e70e85188f1b9538604202f537',
# 'vout': 0, 'address': 'bcrt1qmlrraffw0evkjy2yrxmt263ksgfgv2gqhcddrt',
# 'label': 'Random label', 'scriptPubKey': '0014dfc63ea52e7e5969114419b6b56a368212862900',
@ -363,24 +346,6 @@ def test_wallet_labeling(bitcoin_regtest, devices_filled_data_folder, device_man
# 'safe': True, 'time': 1637091575, 'category': 'generate', 'locked': False
# }
new_address = wallet.getnewaddress()
wallet.setlabel(new_address, "")
wallet.rpc.generatetoaddress(20, new_address)
random_address = "mruae2834buqxk77oaVpephnA5ZAxNNJ1r"
wallet.rpc.generatetoaddress(100, random_address)
wallet.getdata()
wallet.update_balance()
assert len(wallet.full_utxo) == 40
wallet.setlabel(new_address, "")
third_address = wallet.getnewaddress()
wallet.getdata()
assert sorted(wallet.addresses) == sorted([address, new_address, third_address])
def test_wallet_change_addresses(
bitcoin_regtest, devices_filled_data_folder, device_manager
@ -410,11 +375,9 @@ def test_wallet_change_addresses(
change_address = wallet.change_address
assert wallet.addresses == [address]
assert wallet.change_addresses == [change_address]
wallet.rpc.generatetoaddress(20, change_address)
random_address = "mruae2834buqxk77oaVpephnA5ZAxNNJ1r"
wallet.rpc.generatetoaddress(110, random_address)
wallet.getdata()
bitcoin_regtest.testcoin_faucet(change_address, amount=0.1)
wallet.update_balance()
assert wallet.amount_total == 0.1
# new change address should be genrated automatically after receiving
# assert wallet.change_addresses == [change_address, wallet.change_address]
@ -442,20 +405,15 @@ def test_singlesig_wallet_backup_and_restore(caplog, specter_regtest_configured)
# create a wallet
wallet = wallet_manager.create_wallet(
name="my_test_wallet",
name="my_singlesig_test_wallet",
sigs_required=1,
key_type=key.key_type,
keys=[key],
devices=[device],
)
# Fund the wallet
address = wallet.getnewaddress()
wallet.rpc.generatetoaddress(101, address)
# update the wallet data
balance = wallet.update_balance()
assert balance["trusted"] > 0.0
# Wallet was prefunded in specter_regtest_configured fixture, not sure, though, why the amount is 23 and not 20 ...
amount = wallet.amount_total
# Save the json backup
wallet_backup = json.loads(wallet.account_map)
@ -512,7 +470,8 @@ def test_singlesig_wallet_backup_and_restore(caplog, specter_regtest_configured)
wallet.rpc.rescanblockchain(0)
# We restored the wallet's utxos
assert wallet.update_balance()["trusted"] > 0.0
wallet.update_balance()
assert wallet.amount_total == amount
# Now do it again, but without the newer "devices" attr
del wallet_backup["devices"]
@ -567,10 +526,13 @@ def test_singlesig_wallet_backup_and_restore(caplog, specter_regtest_configured)
wallet.rpc.rescanblockchain(0)
# We restored the wallet's utxos
assert wallet.update_balance()["trusted"] > 0.0
wallet.update_balance()
assert wallet.amount_total == amount
def test_multisig_wallet_backup_and_restore(caplog, specter_regtest_configured):
def test_multisig_wallet_backup_and_restore(
bitcoin_regtest, caplog, specter_regtest_configured
):
"""
Multisig wallets should be able to be backed up and re-imported
with or without the "devices" attr in the json backup.
@ -620,20 +582,18 @@ def test_multisig_wallet_backup_and_restore(caplog, specter_regtest_configured):
# create the multisig wallet
wallet = wallet_manager.create_wallet(
name="my_test_wallet",
name="my_multisig_test_wallet",
sigs_required=2,
key_type=key.key_type,
keys=[key, hot_wallet_1_device.keys[0], hot_wallet_2_device.keys[0]],
devices=[device, hot_wallet_1_device, hot_wallet_2_device],
)
# Fund the wallet
# Wallet is unfunded
address = wallet.getnewaddress()
wallet.rpc.generatetoaddress(101, address)
# update the wallet data
balance = wallet.update_balance()
assert balance["trusted"] > 0.0
bitcoin_regtest.testcoin_faucet(address, amount=3.3)
wallet.update_balance()
assert wallet.amount_total == 3.3
# Save the json backup
wallet_backup = json.loads(wallet.account_map.replace("\\\\", "").replace("'", "h"))
@ -694,7 +654,7 @@ def test_multisig_wallet_backup_and_restore(caplog, specter_regtest_configured):
wallet.rpc.rescanblockchain(0)
# We restored the wallet's utxos
assert wallet.update_balance()["trusted"] > 0.0
assert wallet.amount_total == 3.3
# Now do it again, but without the newer "devices" attr
del wallet_backup["devices"]
@ -754,11 +714,11 @@ def test_multisig_wallet_backup_and_restore(caplog, specter_regtest_configured):
wallet.rpc.rescanblockchain(0)
# We restored the wallet's utxos
assert wallet.update_balance()["trusted"] > 0.0
assert wallet.amount_total == 3.3
@pytest.mark.slow
def test_taproot_wallet(caplog, specter_regtest_configured):
def test_taproot_wallet(bitcoin_regtest, caplog, specter_regtest_configured):
"""
Taproot m/86h/ derivation path xpubs should be able to create wallets and
receive funds
@ -784,7 +744,7 @@ def test_taproot_wallet(caplog, specter_regtest_configured):
)
taproot_wallet = wallet_manager.create_wallet(
name="my_test_wallet",
name="my_taproot_test_wallet",
sigs_required=1,
key_type=taproot_device.keys[0].key_type,
keys=[taproot_device.keys[0]],
@ -793,12 +753,9 @@ def test_taproot_wallet(caplog, specter_regtest_configured):
# Fund the wallet
address = taproot_wallet.getnewaddress()
bitcoin_regtest.testcoin_faucet(address, amount=2.2)
taproot_wallet.update_balance()
assert taproot_wallet.amount_total == 2.2
# Taproot test addrs are bcrt1p vs native segwit bcrt1q
assert address.startswith("bcrt1p")
taproot_wallet.rpc.generatetoaddress(101, address)
# update the wallet data
balance = taproot_wallet.update_balance()
assert balance["trusted"] > 0.0

View file

@ -1,9 +1,16 @@
import pytest
import json
import base64
import json
import logging
from numbers import Number
import pytest
from cryptoadvance.specter.managers.device_manager import DeviceManager
from cryptoadvance.specter.specter import Specter
from cryptoadvance.specter.specter_error import SpecterError
from cryptoadvance.specter.util.wallet_importer import WalletImporter
from devices_and_wallets import create_hot_wallet_with_ID
def almost_equal(a: Number, b: Number, precision: float = 0.01) -> bool:
"""
@ -16,7 +23,8 @@ def almost_equal(a: Number, b: Number, precision: float = 0.01) -> bool:
return (diff < precision) and (diff > -precision)
def test_rr_psbt_get(client, caplog):
def test_rr_psbt_get(client, specter_regtest_configured, bitcoin_regtest, caplog):
create_a_simple_wallet(specter_regtest_configured, bitcoin_regtest)
caplog.set_level(logging.DEBUG)
""" testing the registration """
# Unauthorized
@ -70,7 +78,8 @@ def test_rr_psbt_get(client, caplog):
assert data["result"] == {}
def test_rr_psbt_post(specter_regtest_configured, client, caplog):
def test_rr_psbt_post(specter_regtest_configured, bitcoin_regtest, client, caplog):
create_a_simple_wallet(specter_regtest_configured, bitcoin_regtest)
caplog.set_level(logging.DEBUG)
""" testing the registration """
@ -142,3 +151,37 @@ def test_rr_psbt_post(specter_regtest_configured, client, caplog):
assert data["result"]["base64"]
assert data["result"]["time"]
assert data["result"]["sigs_count"] == 0
def create_a_simple_wallet(specter: Specter, bitcoin_regtest):
"""ToDo: Could potentially do this with a a fixture but this is only relevant for this file only"""
someuser = specter.user_manager.get_user_by_username("someuser")
assert not someuser.wallet_manager.working_folder is None
# Create a Wallet
wallet_json = '{"label": "a_simple_wallet", "blockheight": 0, "descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/0/*)#xp8lv5nr", "devices": [{"type": "trezor", "label": "trezor"}]} '
wallet_importer = WalletImporter(
wallet_json, specter, device_manager=someuser.device_manager
)
wallet_importer.create_nonexisting_signers(
someuser.device_manager,
{"unknown_cosigner_0_name": "trezor", "unknown_cosigner_0_type": "trezor"},
)
dm: DeviceManager = someuser.device_manager
wallet = wallet_importer.create_wallet(someuser.wallet_manager)
try:
# fund it with some coins
bitcoin_regtest.testcoin_faucet(address=wallet.getnewaddress())
# make sure it's confirmed
bitcoin_regtest.mine()
# Realize that the wallet has funds:
wallet.update()
except SpecterError as se:
if str(se).startswith("Timeout"):
pytest.fail(
"We got a Bitcoin-RPC timeout while setting up the test, minting some coins. Test Error! Check cpu/mem utilastion and btc/elem logs!"
)
return
else:
raise se
assert wallet.fullbalance >= 20

View file

@ -7,92 +7,49 @@ from conftest import instantiate_bitcoind_controller
logger = logging.getLogger(__name__)
def test_import_address_labels(caplog, specter_regtest_configured):
caplog.set_level(logging.INFO)
specter = specter_regtest_configured
# Create a new device that can sign psbts (Bitcoin Core hot wallet)
device = specter.device_manager.add_device(
name="bitcoin_core_hot_wallet", device_type="bitcoincore", keys=[]
)
device.setup_device(file_password=None, wallet_manager=specter.wallet_manager)
device.add_hot_wallet_keys(
mnemonic="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
passphrase="",
paths=["m/49h/0h/0h"],
file_password=None,
wallet_manager=specter.wallet_manager,
testnet=True,
keys_range=[0, 1000],
keys_purposes=[],
)
wallet: Wallet = specter.wallet_manager.create_wallet(
"bitcoincore_test_wallet", 1, "sh-wpkh", [device.keys[0]], [device]
)
# Fund the wallet. Going to need a LOT of utxos to play with.
logger.info("Generating utxos to wallet")
test_address = wallet.getnewaddress() # 2NCSZrX49HHyzUy6oj8ggm9WD19hFvjzzou
wallet.rpc.generatetoaddress(1, test_address)[0]
# newly minted coins need 100 blocks to get spendable
# let's mine another 100 blocks to get these coins spendable
trash_address = wallet.getnewaddress()
wallet.rpc.generatetoaddress(100, trash_address)
# the utxo is only available after the 100 mined blocks
utxos = wallet.rpc.listunspent()
# txid of the funding of test_address
txid = utxos[0]["txid"]
assert wallet._addresses[test_address]["label"] is None
number_of_addresses = len(wallet._addresses)
assert wallet.txlist()[0]["blockheight"] != None
assert wallet.txlist()[0]["blocktime"] != None
def test_check_utxo(specter_regtest_configured: Specter, funded_hot_wallet_1: Wallet):
def test_check_utxo_and_amounts(
specter_regtest_configured: Specter, funded_hot_wallet_1: Wallet
):
wl = funded_hot_wallet_1
# Let's first prepare some locked txids
unspent_list_orig = wl.rpc.listunspent() # to be able to ref in stable way
wl.check_utxo()
# 10 transactions + 2 unconfirmed
assert len(wl.full_utxo) == 12
for tx in wl.full_utxo:
print(f"txid: {tx['txid']}")
# non are locked
# none are locked
assert len([tx for tx in wl.full_utxo if tx["locked"]]) == 0
unspent_list = wl.rpc.listunspent()
print(f"unspent_list: {len(unspent_list)}")
print([tx["txid"] for tx in unspent_list])
print(f"\n\nLocking this one: {unspent_list_orig[0]['txid']}")
print(wl.rpc.gettransaction(unspent_list_orig[0]["txid"]))
wl.rpc.lockunspent(
False,
[{"txid": unspent_list_orig[0]["txid"], "vout": unspent_list_orig[0]["vout"]}],
# Freeze 2 UTXO
# ["txid:vout", "txid:vout"]
wl.toggle_freeze_utxo(
[
f"{unspent_list_orig[0]['txid']}:{unspent_list_orig[0]['vout']}",
f"{unspent_list_orig[1]['txid']}:{unspent_list_orig[1]['vout']}",
]
)
print(f"\n\nLocking this one: {unspent_list_orig[1]['txid']}")
print(wl.rpc.gettransaction(unspent_list_orig[1]["txid"]))
wl.rpc.lockunspent(
False,
[{"txid": unspent_list_orig[1]["txid"], "vout": unspent_list_orig[1]["vout"]}],
)
unspent_list = wl.rpc.listunspent()
print(f"\n\nunspent_list: {len(unspent_list)}")
print([tx["txid"] for tx in unspent_list])
# still 12 utxo with 2 unconfirmed
wl.check_utxo()
# still 2 transactions + 2 unconfirmed
for tx in wl.full_utxo:
print(f"txid: {tx['txid']}")
assert len(wl.full_utxo) == 12
# 2 are locked
assert len([tx for tx in wl.full_utxo if tx["locked"]]) == 2
# Check total amount
assert wl.amount_total == 15
# Check confirmed amount
assert wl.amount_confirmed == 10
# Check unconfirmed amount
assert wl.amount_unconfirmed == 5
# Check frozen amount
assert wl.amount_frozen == 2
# Check available amount
assert wl.amount_available == 13 # total - frozen
# Check immature amount
address = wl.getnewaddress()
wl.rpc.generatetoaddress(1, address)
wl.update_balance()
assert (
round(wl.amount_immature, 1) == 50
or round(wl.amount_immature, 1) == 25
or round(wl.amount_immature, 1) == 12.5
or round(wl.amount_immature, 2) == 6.25
)
# amount_locked_unsigned is tested in test_wallet_createpsbt (in test_managers_wallet.py)