diff --git a/cypress/integration/spec_elm_multi_segwit_wallet.js b/cypress/integration/spec_elm_multi_segwit_wallet.js index 28223aca8..ddbcbb28c 100644 --- a/cypress/integration/spec_elm_multi_segwit_wallet.js +++ b/cypress/integration/spec_elm_multi_segwit_wallet.js @@ -1,188 +1,191 @@ describe('Operating with an Elements multisig wallet', () => { - - it('Creates two (segwit/nested) Elements multisig (2/3) hot wallets', () => { - cy.viewport(1200,660) - cy.visit('/') - cy.get('#node-switch-icon').click() - cy.contains('Elements Node').click() - - // Delete wallets if existing - cy.deleteWallet("Elm Multi Segwit Wallet") - cy.deleteWallet("Elm Multi Nested Wallet") + if (Cypress.env("CI")) { - // 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") + it('Creates two (segwit/nested) Elements multisig (2/3) hot wallets', () => { - // Create Segwit multisig wallet - cy.get('#btn_new_wallet').click() - cy.get('[href="./multisig/"]').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() - // 2 of 2 - cy.get(':nth-child(9) > .inline').type("2") - // submit - cy.get('#keysform > .centered').click() - // Cancel-button (no pdf download) - cy.get('#page_overlay_popup_cancel_button').click() - //Get some funds - cy.mine2wallet("elm") + cy.viewport(1200,660) + cy.visit('/') + cy.get('#node-switch-icon').click() + cy.contains('Elements Node').click() + + // Delete wallets if existing + cy.deleteWallet("Elm Multi Segwit Wallet") + cy.deleteWallet("Elm Multi Nested Wallet") - // Create Nested multisig wallet - cy.get('#btn_new_wallet').click() - cy.get('[href="./multisig/"]').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() - // 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() - cy.get(':nth-child(9) > .inline').type("2") - // submit - cy.get('#keysform > .centered').click() - // Click cancel (no pdf download) - cy.get('#page_overlay_popup_cancel_button').click() - // Get some funds - cy.mine2wallet("elm") - }) - - 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") + // 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('#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() + // 2 of 2 + cy.get(':nth-child(9) > .inline').type("2") + // submit + cy.get('#keysform > .centered').click() + // Cancel-button (no pdf download) + cy.get('#page_overlay_popup_cancel_button').click() + //Get some funds + cy.mine2wallet("elm") + + // Create Nested multisig wallet + cy.get('#btn_new_wallet').click() + cy.get('[href="./multisig/"]').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() + // 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() + cy.get(':nth-child(9) > .inline').type("2") + // submit + cy.get('#keysform > .centered').click() + // Click cancel (no pdf download) + cy.get('#page_overlay_popup_cancel_button').click() + // Get some funds + cy.mine2wallet("elm") + }) - // 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() + it('Spending to a confidential address from segwit', () => { + cy.viewport(1200,660) + cy.visit('/') + cy.contains("Elm Multi Segwit Wallet").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) + 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('/') - cy.contains("Elm Multi Segwit Wallet").click() + it('Spending to an unnconfidential 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("ert1q38la37ulxgc0uwt334he46eua7h8qagqnlm5phcqk7ntgv3x73cqjtr2fa", "unconf Burn address","1.5") + 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() + // 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) + // 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() + 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() - - // 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() + 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) + // 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 unconfidential address from nested', () => { - cy.viewport(1200,660) - cy.visit('/') - cy.contains("Elm Multi Nested Wallet").click() + it('Spending to a unconfidential 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("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 the tx - cy.get('#broadcast_local_btn').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("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 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) + // 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) + }) }) }) - }) + } }) \ No newline at end of file diff --git a/cypress/integration/spec_elm_single_segwit_wallet.js b/cypress/integration/spec_elm_single_segwit_wallet.js index 8281c2ae9..a4269af72 100644 --- a/cypress/integration/spec_elm_single_segwit_wallet.js +++ b/cypress/integration/spec_elm_single_segwit_wallet.js @@ -1,104 +1,106 @@ describe('Operating with an elements singlesig wallet', () => { - it('Creates a single sig elements hot wallet', () => { - cy.viewport(1300,660) - cy.visit('/') - cy.get('#node-switch-icon').click() - cy.contains('Elements Node').click() + if (Cypress.env("CI")) { + + it('Creates a single sig elements hot wallet', () => { + cy.viewport(1300,660) + cy.visit('/') + cy.get('#node-switch-icon').click() + cy.contains('Elements Node').click() - // Delete Wallet if existing - cy.deleteWallet("Elm Single Segwit Hot Wallet") - cy.deleteWallet("Elm Single Nested Hot Wallet") + // Delete Wallet if existing + cy.deleteWallet("Elm Single Segwit Hot Wallet") + cy.deleteWallet("Elm Single Nested Hot Wallet") - cy.addHotDevice("Hot Elements Device 1","elements") - - // Segwit Wallet - cy.addHotWallet("Elm Single Segwit Hot Wallet","Hot Elements Device 1", "elements", "segwit") + cy.addHotDevice("Hot Elements Device 1","elements") - // Nested Segwit Wallet - cy.addHotWallet("Elm Single Nested Hot Wallet","Hot Elements Device 1", "elements", "nested_segwit") - }) + // Segwit Wallet + cy.addHotWallet("Elm Single Segwit Hot Wallet","Hot Elements Device 1", "elements", "segwit") + + // Nested Segwit Wallet + cy.addHotWallet("Elm Single Nested Hot Wallet","Hot Elements Device 1", "elements", "nested_segwit") + }) - it('send confidential transaction from segwit', () => { - cy.viewport(1300,660) - cy.visit('/') - cy.contains("Elm Single Segwit Hot Wallet").click() + it('send confidential transaction from segwit', () => { + cy.viewport(1300,660) + cy.visit('/') + cy.contains("Elm Single Segwit Hot 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_1_tx_sign_btn').click() - cy.get('#hot_elements_device_1_hot_sign_btn').click() - cy.get('#hot_enter_passphrase__submit').click() - cy.get('#broadcast_local_btn').click() - cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }) - .should(($div) => { - const newBalance = parseFloat($div.text()) - expect(newBalance).to.be.lte(oldBalance - 1.5) + 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_1_tx_sign_btn').click() + cy.get('#hot_elements_device_1_hot_sign_btn').click() + cy.get('#hot_enter_passphrase__submit').click() + cy.get('#broadcast_local_btn').click() + cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }) + .should(($div) => { + const newBalance = parseFloat($div.text()) + expect(newBalance).to.be.lte(oldBalance - 1.5) + }) }) }) - }) - it('send unconfidential transaction from segwit', () => { - cy.viewport(1300,660) - cy.visit('/') - cy.contains("Elm Single Segwit Hot Wallet").click() - cy.get('#fullbalance_amount').then(($div) => { - const oldBalance = parseFloat($div.text()) - expect(oldBalance).to.be.gte(1.5) - cy.createPsbt("ert1q38la37ulxgc0uwt334he46eua7h8qagqnlm5phcqk7ntgv3x73cqjtr2fa", "Burn address","1.5") - cy.get('#hot_elements_device_1_tx_sign_btn').click() - cy.get('#hot_elements_device_1_hot_sign_btn').click() - cy.get('#hot_enter_passphrase__submit').click() - cy.get('#broadcast_local_btn').click() - cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }) - .should(($div) => { - const newBalance = parseFloat($div.text()) - expect(newBalance).to.be.lte(oldBalance - 1.5) + it('send unconfidential transaction from segwit', () => { + cy.viewport(1300,660) + cy.visit('/') + cy.contains("Elm Single Segwit Hot Wallet").click() + cy.get('#fullbalance_amount').then(($div) => { + const oldBalance = parseFloat($div.text()) + expect(oldBalance).to.be.gte(1.5) + cy.createPsbt("ert1q38la37ulxgc0uwt334he46eua7h8qagqnlm5phcqk7ntgv3x73cqjtr2fa", "Burn address","1.5") + cy.get('#hot_elements_device_1_tx_sign_btn').click() + cy.get('#hot_elements_device_1_hot_sign_btn').click() + cy.get('#hot_enter_passphrase__submit').click() + cy.get('#broadcast_local_btn').click() + cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }) + .should(($div) => { + const newBalance = parseFloat($div.text()) + expect(newBalance).to.be.lte(oldBalance - 1.5) + }) }) }) - }) - it('send confidential transaction from nested segwit', () => { - cy.viewport(1300,660) - cy.visit('/') - cy.contains("Elm Single Nested Hot Wallet").click() + it('send confidential transaction from nested segwit', () => { + cy.viewport(1300,660) + cy.visit('/') + cy.contains("Elm Single Nested Hot 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_1_tx_sign_btn').click() - cy.get('#hot_elements_device_1_hot_sign_btn').click() - cy.get('#hot_enter_passphrase__submit').click() - cy.get('#broadcast_local_btn').click() - cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }) - .should(($div) => { - const newBalance = parseFloat($div.text()) - expect(newBalance).to.be.lte(oldBalance - 1.5) + 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_1_tx_sign_btn').click() + cy.get('#hot_elements_device_1_hot_sign_btn').click() + cy.get('#hot_enter_passphrase__submit').click() + cy.get('#broadcast_local_btn').click() + cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }) + .should(($div) => { + const newBalance = parseFloat($div.text()) + expect(newBalance).to.be.lte(oldBalance - 1.5) + }) }) }) - }) - it('send unconfidential transaction from nested segwit', () => { - cy.viewport(1300,660) - cy.visit('/') - cy.contains("Elm Single Nested Hot Wallet").click() - cy.get('#fullbalance_amount').then(($div) => { - const oldBalance = parseFloat($div.text()) - expect(oldBalance).to.be.gte(1.5) - cy.createPsbt("ert1q38la37ulxgc0uwt334he46eua7h8qagqnlm5phcqk7ntgv3x73cqjtr2fa", "Burn address","1.5") - cy.get('#hot_elements_device_1_tx_sign_btn').click() - cy.get('#hot_elements_device_1_hot_sign_btn').click() - cy.get('#hot_enter_passphrase__submit').click() - cy.get('#broadcast_local_btn').click() - cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }) - .should(($div) => { - const newBalance = parseFloat($div.text()) - expect(newBalance).to.be.lte(oldBalance - 1.5) + it('send unconfidential transaction from nested segwit', () => { + cy.viewport(1300,660) + cy.visit('/') + cy.contains("Elm Single Nested Hot Wallet").click() + cy.get('#fullbalance_amount').then(($div) => { + const oldBalance = parseFloat($div.text()) + expect(oldBalance).to.be.gte(1.5) + cy.createPsbt("ert1q38la37ulxgc0uwt334he46eua7h8qagqnlm5phcqk7ntgv3x73cqjtr2fa", "Burn address","1.5") + cy.get('#hot_elements_device_1_tx_sign_btn').click() + cy.get('#hot_elements_device_1_hot_sign_btn').click() + cy.get('#hot_enter_passphrase__submit').click() + cy.get('#broadcast_local_btn').click() + cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }) + .should(($div) => { + const newBalance = parseFloat($div.text()) + expect(newBalance).to.be.lte(oldBalance - 1.5) + }) }) }) - }) - + } }) \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 1b4536546..5c554f6a4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,4 +28,17 @@ This is also a pattern used for plugins. The more specific a functionality became, the more awkward it felt to integrate it in the core architecture. When we started to make exchange specific functionality, we wanted to protect the core architecture. Therefore, we created a plugin concept which allows to have the above concepts replicated in their own self-contained standalone units. -We now try to implement bigger chunks of functionality in plugins. Maybe, even core functionality might be implemented in "core plugins" in the future. Internal plugins are placed in `src/specterext`. But, plugins can also live in there own repos, have their own release lifecycle and be used by Specter like any other dependency. For more information about plugins see [Third Party Service Integrations](./extensions.md) where we also discuss the nuances between plugins and extensions. \ No newline at end of file +We now try to implement bigger chunks of functionality in plugins. Maybe, even core functionality might be implemented in "core plugins" in the future. Internal plugins are placed in `src/specterext`. But, plugins can also live in there own repos, have their own release lifecycle and be used by Specter like any other dependency. For more information about plugins see [Third Party Service Integrations](./extensions.md) where we also discuss the nuances between plugins and extensions. + +# The Crypto Engine +The Crypto Engine consists of a bunch of classes which partially are based on Embit. +The documentation here is incomplete but will better over time. + +## psbt classes + +[![](https://mermaid.ink/img/pako:eNqVU11PgzAU_StNnzTO_QDiizoffNKEPRkS0rWXrRF6SXurLHP_3QKOsjHNhIRczj0997M7LlEBT7gshXMLLdZWVJlh4blfObJC0rJ5REPQELv7ur1laQ2SwC6bS1ipxBp64hg54jyb2tMlxBdPx8y_Y7-mD8vM9G_L7iqcntn1XsZulJbEDNAn2vcBXICTVteElqlo9u79WHjoSxT80CYnzFvdq-uIoqcz8AlyTrsrPcqvEEumXV5pAxNQboRZT2ELEnTIaz14QjuYUMqCcwOmTcGcoFxU6A0NcFGiINZ9T1wXJB_HHCuIGNOmHoWnc-FXWwLHqNGKHVHbhv4jkdEaxUxGIBvJTVL5XbZdt6g3n88H29dKEIxGXVisTsffYWEzjQvLqdEMFfAZr8BWQqtwS7sAGacNVJDxJJgKCuFLynhmWmof60npsKU8KUTpYMaFJ0y3RvKErIcD6eeyD6xamDfEw__-G-B6VVQ?type=png)](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNqVU11PgzAU_StNnzTO_QDiizoffNKEPRkS0rWXrRF6SXurLHP_3QKOsjHNhIRczj0997M7LlEBT7gshXMLLdZWVJlh4blfObJC0rJ5REPQELv7ur1laQ2SwC6bS1ipxBp64hg54jyb2tMlxBdPx8y_Y7-mD8vM9G_L7iqcntn1XsZulJbEDNAn2vcBXICTVteElqlo9u79WHjoSxT80CYnzFvdq-uIoqcz8AlyTrsrPcqvEEumXV5pAxNQboRZT2ELEnTIaz14QjuYUMqCcwOmTcGcoFxU6A0NcFGiINZ9T1wXJB_HHCuIGNOmHoWnc-FXWwLHqNGKHVHbhv4jkdEaxUxGIBvJTVL5XbZdt6g3n88H29dKEIxGXVisTsffYWEzjQvLqdEMFfAZr8BWQqtwS7sAGacNVJDxJJgKCuFLynhmWmof60npsKU8KUTpYMaFJ0y3RvKErIcD6eeyD6xamDfEw__-G-B6VVQ) + +## embit classes +Here is a diagram of all the classes from the embit library. No properties/attributes or methods are in there, yet. + +[![](https://mermaid.ink/img/pako:eNqFk19rgzAQwL-K5Ln9ArKnYtnGNiqzbDDycsZrDcREkstGcX73xT9tLejqSzx_Py935NIwYQpkMRMKnEskHC1UXBfSoiBpdPT6znUUnm2VS9qAw-jhd70ewhc8TWCIBvaU3ILrX5k8aiBvcZ6KUhtrgzSXNfW5kmJhy9TKbyBc2jfBnod-UqByVkmzzT4Tph5Lu4QDfda1p2W883TLZ5IvtGxlTbPoU5JG52bZ3oJ20B_QPd5Xfk8a6udjsn4UokdlclAT6UPiT3N22qnatbdMsxIs_oMypCllK1ahrUAWYSqb7jNnVGKFnMXhtcADeEWccd0G1ddFOPdtIclYFh9AOVwx8GSykxYsJuvxLI3DfbFq0F_GXGPsk7yN16Fb2j-RrgZR?type=png)](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNqFk19rgzAQwL-K5Ln9ArKnYtnGNiqzbDDycsZrDcREkstGcX73xT9tLejqSzx_Py935NIwYQpkMRMKnEskHC1UXBfSoiBpdPT6znUUnm2VS9qAw-jhd70ewhc8TWCIBvaU3ILrX5k8aiBvcZ6KUhtrgzSXNfW5kmJhy9TKbyBc2jfBnod-UqByVkmzzT4Tph5Lu4QDfda1p2W883TLZ5IvtGxlTbPoU5JG52bZ3oJ20B_QPd5Xfk8a6udjsn4UokdlclAT6UPiT3N22qnatbdMsxIs_oMypCllK1ahrUAWYSqb7jNnVGKFnMXhtcADeEWccd0G1ddFOPdtIclYFh9AOVwx8GSykxYsJuvxLI3DfbFq0F_GXGPsk7yN16Fb2j-RrgZR) \ No newline at end of file diff --git a/src/cryptoadvance/specter/commands/psbt_creator.py b/src/cryptoadvance/specter/commands/psbt_creator.py index 569fb8e11..d79032eaa 100644 --- a/src/cryptoadvance/specter/commands/psbt_creator.py +++ b/src/cryptoadvance/specter/commands/psbt_creator.py @@ -4,6 +4,8 @@ import logging from math import isnan import requests +from cryptoadvance.specter.util.psbt import SpecterPSBT +from cryptoadvance.specter.wallet import Wallet from cryptoadvance.specter.specter_error import SpecterError from cryptoadvance.specter.util.common import str2bool @@ -14,7 +16,10 @@ logger = logging.getLogger(__name__) class PsbtCreator: - """A class to create PSBTs easily out of stuff coming from the frontend""" + """A class to create PSBTs easily out of stuff coming from the frontend + For an overview of the overall workflow, checkout e.g. + https://github.com/bitcoin/bitcoin/blob/master/doc/psbt.md + """ def __init__( self, @@ -108,11 +113,14 @@ class PsbtCreator: additional_data, ) - def create_psbt(self, wallet): + def create_psbt(self, wallet: Wallet) -> dict: """creates the PSBT via the wallet and modifies it for if substract is true If there was a "estimate_fee" in the request_form, the PSBT will not get persisted """ - self.psbt = wallet.createpsbt(self.addresses, self.amounts, **self.kwargs) + self.psbt_as_object: SpecterPSBT = wallet.createpsbt( + self.addresses, self.amounts, **self.kwargs + ) + self.psbt = self.psbt_as_object.to_dict() if self.psbt is None: raise SpecterError( "Probably you don't have enough funds, or something else..." diff --git a/src/cryptoadvance/specter/liquid/txlist.py b/src/cryptoadvance/specter/liquid/txlist.py index f7c7b6866..f7efecb61 100644 --- a/src/cryptoadvance/specter/liquid/txlist.py +++ b/src/cryptoadvance/specter/liquid/txlist.py @@ -9,7 +9,7 @@ from io import BytesIO from embit.psbt import read_string -class LTxItem(TxItem): +class LTxItem(WalletAwareTxItem): TransactionCls = LTransaction columns = [ "txid", # str, txid in hex @@ -148,6 +148,27 @@ class LTxItem(TxItem): vsize = math.ceil(weight / 4) return vsize + # Three properties which are defined in WalletAwareTxItem which we can't calculate here but which need to + # return something as the getter is called in WalletAwareTxItem`#s constructor + # This is definitely not a good way to fix this. + # ToDo: Do it properly if we have more time for Liquid + + @property + def category(self): + return None + + @property + def address(self): + return None + + @property + def flow_amount(self): + return None + + @property + def ismine(self): + return None + def __dict__(self): return { "txid": self["txid"], diff --git a/src/cryptoadvance/specter/liquid/wallet.py b/src/cryptoadvance/specter/liquid/wallet.py index f7df1bb20..c4e505113 100644 --- a/src/cryptoadvance/specter/liquid/wallet.py +++ b/src/cryptoadvance/specter/liquid/wallet.py @@ -248,7 +248,7 @@ class LWallet(Wallet): if not readonly: self.save_pending_psbt(psbt) - return psbt.to_dict() + return psbt def canceltx(self, *args, **kwargs): raise SpecterError("RBF is not implemented on Liquid") @@ -279,7 +279,7 @@ class LWallet(Wallet): for utxo in self.full_utxo if to_unconfidential(utxo["address"]) == to_unconfidential(addr.address) ]: - addr_amount = addr_amount + utxo["amount"] + addr_amount = addr_amount + utxo.utxo_amount addr_utxo = addr_utxo + 1 addr_assets[utxo.get("asset")] = ( addr_assets.get(utxo.get("asset"), 0) + utxo["amount"] diff --git a/src/cryptoadvance/specter/managers/wallet_manager.py b/src/cryptoadvance/specter/managers/wallet_manager.py index 2f45ed250..4617fde77 100644 --- a/src/cryptoadvance/specter/managers/wallet_manager.py +++ b/src/cryptoadvance/specter/managers/wallet_manager.py @@ -388,7 +388,6 @@ class WalletManager: wallet.delete_files() # Remove the wallet instance del self.wallets[wallet.name] - self.update() specter_wallet_deleted = True except KeyError: raise SpecterError( diff --git a/src/cryptoadvance/specter/process_controller/node_controller.py b/src/cryptoadvance/specter/process_controller/node_controller.py index 609d2b862..66f3daa33 100644 --- a/src/cryptoadvance/specter/process_controller/node_controller.py +++ b/src/cryptoadvance/specter/process_controller/node_controller.py @@ -263,10 +263,11 @@ class NodeController: rpc.generatetoaddress(102, default_address) btc_balance = default_rpc.getbalance() - default_rpc.sendtoaddress(address, amount) + result = default_rpc.sendtoaddress(address, amount) if confirm_payment: # confirm it rpc.generatetoaddress(1, default_address) + return result @staticmethod def check_node(rpcconn, raise_exception=False): diff --git a/src/cryptoadvance/specter/server_endpoints/controller.py b/src/cryptoadvance/specter/server_endpoints/controller.py index a1990860e..f1e9fd9cb 100644 --- a/src/cryptoadvance/specter/server_endpoints/controller.py +++ b/src/cryptoadvance/specter/server_endpoints/controller.py @@ -181,7 +181,12 @@ def server_error_405(e): app.logger.error(trace) if request.headers.get("Accept") == "application/json": return {"error": error_msg} - flash(_("Session expired. Please refresh and try again."), "error") + flash( + _( + "Method not allowed. Probably the session expired. Please refresh and try again." + ), + "error", + ) return redirect(request.url) diff --git a/src/cryptoadvance/specter/server_endpoints/wallets/wallets.py b/src/cryptoadvance/specter/server_endpoints/wallets/wallets.py index 4276db115..622754475 100644 --- a/src/cryptoadvance/specter/server_endpoints/wallets/wallets.py +++ b/src/cryptoadvance/specter/server_endpoints/wallets/wallets.py @@ -45,7 +45,7 @@ def check_wallet(func): """checks the wallet for healthiness A wrapper function""" if kwargs["wallet_alias"]: wallet_alias = kwargs["wallet_alias"] - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) wallet.get_info() return func(*args, **kwargs) @@ -98,6 +98,7 @@ def wallets_overview(): ) return redirect(wallets_overview_vm.wallets_overview_redirect) + wallet: Wallet for wallet in list(app.specter.wallet_manager.wallets.values()): wallet.update_balance() wallet.check_utxo() @@ -325,7 +326,7 @@ def new_wallet(wallet_type): # create a wallet here try: - wallet = app.specter.wallet_manager.create_wallet( + wallet: Wallet = app.specter.wallet_manager.create_wallet( wallet_name, sigs_required, address_type, keys, cosigners ) except Exception as e: @@ -408,7 +409,7 @@ def new_wallet(wallet_type): @wallets_endpoint.route("/wallet//") @login_required def wallet(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) if wallet.amount_total > 0: return redirect(url_for("wallets_endpoint.history", wallet_alias=wallet_alias)) else: @@ -419,7 +420,7 @@ def wallet(wallet_alias): @wallets_endpoint.route("/wallet//history/", methods=["GET", "POST"]) @login_required def history(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) tx_list_type = "txlist" if request.method == "POST": @@ -483,7 +484,7 @@ def receive(wallet_alias): @wallets_endpoint.route("/wallet//send") @login_required def send(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) if len(wallet.pending_psbts) > 0: return redirect( url_for("wallets_endpoint.send_pending", wallet_alias=wallet_alias) @@ -495,7 +496,7 @@ def send(wallet_alias): @wallets_endpoint.route("/wallet//send/new", methods=["GET", "POST"]) @login_required def send_new(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) # update balances in the wallet wallet.update_balance() # update utxo list for coin selection @@ -695,7 +696,7 @@ def send_new(wallet_alias): @wallets_endpoint.route("/wallet//send/pending/", methods=["GET", "POST"]) @login_required def send_pending(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) if request.method == "POST": action = request.form["action"] if action == "deletepsbt": @@ -729,7 +730,7 @@ def send_pending(wallet_alias): @wallets_endpoint.route("/wallet//send/import", methods=["GET", "POST"]) @login_required def import_psbt(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) if request.method == "POST": action = request.form["action"] if action == "importpsbt": @@ -774,7 +775,7 @@ def addresses(wallet_alias): """Show informations about cached addresses (wallet._addresses) of the . It updates balances in the wallet before renderization in order to show updated UTXO and balance of each address.""" - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) # update balances in the wallet app.specter.check_blockheight() @@ -795,94 +796,25 @@ def addresses(wallet_alias): @wallets_endpoint.route("/wallet//settings/", methods=["GET", "POST"]) +# In case of exceptions in the "subactions" POST method handlers, the error-handler +# will redirect to the same endpoint but GET-method. Specifying them here: +@wallets_endpoint.route( + "/wallet//settings/importaddresslabels", methods=["GET"] +) +@wallets_endpoint.route( + "/wallet//settings/keypoolrefill", methods=["GET"] +) +@wallets_endpoint.route("/wallet//settings/rescan", methods=["GET"]) +@wallets_endpoint.route("/wallet//settings/deletewallet", methods=["GET"]) +@wallets_endpoint.route("/wallet//settings/clearcache", methods=["GET"]) @login_required def settings(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - error = None + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) if request.method == "POST": action = request.form["action"] - if action == "rescanblockchain": - startblock = int(request.form["startblock"]) - try: - delete_file(wallet._transactions.path) - wallet.fetch_transactions() - - # This rpc call does not seem to return a result; use no_wait to ignore timeout errors - wallet.rpc.rescanblockchain(startblock, no_wait=True) - except Exception as e: - handle_exception(e) - error = "%r" % e - wallet.getdata() - elif action == "abortrescan": - res = wallet.rpc.abortrescan() - if not res: - error = _("Failed to abort rescan. Maybe already complete?") - wallet.getdata() - elif action == "rescanutxo": - explorer = None - if "use_explorer" in request.form: - if request.form["explorer"] == "CUSTOM": - explorer = request.form["custom_explorer"] - else: - explorer = app.config["EXPLORERS_LIST"][request.form["explorer"]][ - "url" - ] - - wallet.rescanutxo( - explorer, - app.specter.requests_session(explorer and explorer.endswith(".onion")), - app.specter.only_tor, - ) - app.specter.info["utxorescan"] = 1 - app.specter.utxorescanwallet = wallet.alias - flash( - "Rescan started. Check the status bar on the left for progress and/or the logs for potential issues." - ) - elif action == "abortrescanutxo": - app.specter.node.abortrescanutxo() - app.specter.info["utxorescan"] = None - app.specter.utxorescanwallet = None - flash(_("Successfully aborted the UTXO rescan")) - elif action == "import_address_labels": - address_labels = request.form["address_labels_data"] - imported_addresses_len = wallet.import_address_labels(address_labels) - if imported_addresses_len > 1: - flash(f"Successfully imported {imported_addresses_len} address labels.") - elif imported_addresses_len == 1: - flash(f"Successfully imported {imported_addresses_len} address label.") - else: - flash("No address labels were imported.") - elif action == "keypoolrefill": - delta = int(request.form["keypooladd"]) - wallet.keypoolrefill(wallet.keypool, wallet.keypool + delta) - wallet.keypoolrefill( - wallet.change_keypool, wallet.change_keypool + delta, change=True - ) - wallet.getdata() - elif action == "deletewallet": - deleted = app.specter.wallet_manager.delete_wallet(wallet, app.specter.node) - # deleted is a tuple: (specter_wallet_deleted, core_wallet_file_deleted) - if deleted == (True, True): - flash( - _("Wallet in Specter and wallet file on node deleted successfully.") - ) - elif deleted == (True, False): - flash( - _( - "Wallet in Specter deleted successfully but wallet file on node could not be removed automatically." - ) - ) - elif deleted == (False, True): - flash( - _( - "Deletion of wallet in Specter failed, but wallet on node was removed." - ), - "error", - ) - else: - flash(_("Deletion of wallet failed."), "error") - return redirect(url_for("index")) - elif action == "rename": + # Would like to refactor this to another endpoint as well + # but that's not so easy as the ui part is also used elsewhere + if action == "rename": wallet_name = request.form["newtitle"] if not wallet_name: flash(_("Wallet name cannot be empty"), "error") @@ -892,7 +824,164 @@ def settings(wallet_alias): flash(_("Wallet already exists"), "error") else: app.specter.wallet_manager.rename_wallet(wallet, wallet_name) + flash("Wallet successfully renamed!") + else: + flash(f"Unknown action: {action}") + return render_template( + "wallet/settings/wallet_settings.jinja", + purposes=purposes, + wallet_alias=wallet_alias, + wallet=wallet, + specter=app.specter, + rand=rand, + scroll_to_rescan_blockchain=request.args.get("rescan_blockchain"), + ) + +@wallets_endpoint.route( + "/wallet//settings/importaddresslabels", methods=["POST"] +) +@login_required +def settings_importaddresslabels(wallet_alias): + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + action = request.form["action"] + address_labels = request.form["address_labels_data"] + imported_addresses_len = wallet.import_address_labels(address_labels) + if imported_addresses_len > 1: + flash(f"Successfully imported {imported_addresses_len} address labels.") + elif imported_addresses_len == 1: + flash(f"Successfully imported {imported_addresses_len} address label.") + else: + flash("No address labels were imported.") + return redirect(url_for("wallets_endpoint.settings")) + + +@wallets_endpoint.route( + "/wallet//settings/keypoolrefill", methods=["POST"] +) +@login_required +def settings_keypoolrefill(wallet_alias): + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + delta = int(request.form["keypooladd"]) + wallet.keypoolrefill(wallet.keypool, wallet.keypool + delta) + wallet.keypoolrefill( + wallet.change_keypool, wallet.change_keypool + delta, change=True + ) + wallet.getdata() + return render_template( + "wallet/settings/wallet_settings.jinja", + purposes=purposes, + wallet_alias=wallet_alias, + wallet=wallet, + specter=app.specter, + rand=rand, + scroll_to_rescan_blockchain=request.args.get("rescan_blockchain"), + ) + + +@wallets_endpoint.route("/wallet//settings/rescan", methods=["POST"]) +@login_required +def settings_rescan(wallet_alias): + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + error = None + action = request.form["action"] + if action == "rescanblockchain": + startblock = int(request.form["startblock"]) + try: + delete_file(wallet._transactions.path) + wallet.fetch_transactions() + + # This rpc call does not seem to return a result; use no_wait to ignore timeout errors + wallet.rpc.rescanblockchain(startblock, no_wait=True) + except Exception as e: + handle_exception(e) + error = "%r" % e + wallet.getdata() + elif action == "abortrescan": + res = wallet.rpc.abortrescan() + if not res: + error = _("Failed to abort rescan. Maybe already complete?") + wallet.getdata() + elif action == "rescanutxo": + explorer = None + if "use_explorer" in request.form: + if request.form["explorer"] == "CUSTOM": + explorer = request.form["custom_explorer"] + else: + explorer = app.config["EXPLORERS_LIST"][request.form["explorer"]]["url"] + wallet.rescanutxo( + explorer, + app.specter.requests_session(explorer and explorer.endswith(".onion")), + app.specter.only_tor, + ) + app.specter.info["utxorescan"] = 1 + app.specter.utxorescanwallet = wallet.alias + flash( + "Rescan started. Check the status bar on the left for progress and/or the logs for potential issues." + ) + elif action == "abortrescanutxo": + app.specter.node.abortrescanutxo() + app.specter.info["utxorescan"] = None + app.specter.utxorescanwallet = None + flash(_("Successfully aborted the UTXO rescan")) + scroll_to_rescan_blockchain = request.args.get("rescan_blockchain") + return render_template( + "wallet/settings/wallet_settings.jinja", + purposes=purposes, + wallet_alias=wallet_alias, + wallet=wallet, + specter=app.specter, + rand=rand, + error=error, + scroll_to_rescan_blockchain=scroll_to_rescan_blockchain, + ) + + +@wallets_endpoint.route( + "/wallet//settings/deletewallet", methods=["POST"] +) +@login_required +def settings_deletewallet(wallet_alias): + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + error = None + deleted = app.specter.wallet_manager.delete_wallet(wallet, app.specter.node) + # deleted is a tuple: (specter_wallet_deleted, core_wallet_file_deleted) + if deleted == (True, True): + flash(_("Wallet in Specter and wallet file on node deleted successfully.")) + elif deleted == (True, False): + flash( + _( + "Wallet in Specter deleted successfully but wallet file on node could not be removed automatically." + ) + ) + elif deleted == (False, True): + flash( + _("Deletion of wallet in Specter failed, but wallet on node was removed."), + "error", + ) + else: + flash(_("Deletion of wallet failed."), "error") + return redirect(url_for("index")) + scroll_to_rescan_blockchain = request.args.get("rescan_blockchain") + return render_template( + "wallet/settings/wallet_settings.jinja", + purposes=purposes, + wallet_alias=wallet_alias, + wallet=wallet, + specter=app.specter, + rand=rand, + error=error, + scroll_to_rescan_blockchain=scroll_to_rescan_blockchain, + ) + + +@wallets_endpoint.route("/wallet//settings/clearcache", methods=["POST"]) +@login_required +def settings_clearcache(wallet_alias): + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + error = None + wallet.clear_cache() + flash("Cache with transactions cleared successfully!") scroll_to_rescan_blockchain = request.args.get("rescan_blockchain") return render_template( "wallet/settings/wallet_settings.jinja", diff --git a/src/cryptoadvance/specter/server_endpoints/wallets/wallets_api.py b/src/cryptoadvance/specter/server_endpoints/wallets/wallets_api.py index 7af2d65aa..aecc9ab94 100644 --- a/src/cryptoadvance/specter/server_endpoints/wallets/wallets_api.py +++ b/src/cryptoadvance/specter/server_endpoints/wallets/wallets_api.py @@ -9,6 +9,7 @@ from datetime import datetime from io import StringIO from math import isnan from numbers import Number +from typing import List import requests from embit.descriptor.checksum import add_checksum @@ -20,12 +21,14 @@ from flask_babel import lazy_gettext as _ from flask_login import current_user, login_required from werkzeug.wrappers import Response +from cryptoadvance.specter.txlist import WalletAwareTxItem + from ...commands.psbt_creator import PsbtCreator from ...helpers import bcur2base64 from ...rpc import RpcError from ...server_endpoints import flash from ...server_endpoints.filters import assetlabel -from ...specter_error import SpecterError, handle_exception +from ...specter_error import SpecterError, SpecterInternalException, handle_exception from ...util.base43 import b43_decode from ...util.descriptor import Descriptor from ...util.fee_estimation import FeeEstimationResultEncoder, get_fees @@ -114,7 +117,7 @@ def fees_old(blocks): @wallets_endpoint_api.route("/wallet//combine/", methods=["POST"]) @login_required def combine(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) # only post requests # FIXME: ugly... txid = request.form.get("txid") @@ -172,7 +175,7 @@ def combine(wallet_alias): @wallets_endpoint_api.route("/wallet//broadcast/", methods=["POST"]) @login_required def broadcast(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) tx = request.form.get("tx") res = wallet.rpc.testmempoolaccept([tx])[0] if res["allowed"]: @@ -193,7 +196,7 @@ def broadcast(wallet_alias): ) @login_required def broadcast_blockexplorer(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) tx = request.form.get("tx") explorer = request.form.get("explorer") use_tor = request.form.get("use_tor", "true") == "true" @@ -342,7 +345,7 @@ def decoderawtx(wallet_alias): @app.csrf.exempt def rescan_progress(wallet_alias): try: - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) wallet.get_info() return jsonify( active=wallet.rescan_progress is not None, @@ -356,7 +359,7 @@ def rescan_progress(wallet_alias): @login_required def get_label(wallet_alias): try: - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) address = request.form.get("address", "") label = wallet.getlabel(address) return jsonify( @@ -375,7 +378,7 @@ def get_label(wallet_alias): @login_required def set_label(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) address = request.form["address"] label = request.form["label"].rstrip() wallet.setlabel(address, label) @@ -386,7 +389,7 @@ def set_label(wallet_alias): @login_required @app.csrf.exempt def txlist(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) idx = int(request.form.get("idx", 0)) limit = int(request.form.get("limit", 100)) search = request.form.get("search", None) @@ -400,16 +403,17 @@ def txlist(wallet_alias): current_blockheight=app.specter.info["blocks"], service_id=service_id, ) - return process_txlist( + txlist, page_count = process_txlist( txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir ) + return {"txlist": json.dumps(txlist), "pageCount": page_count} @wallets_endpoint_api.route("/wallet//utxo_list", methods=["POST"]) @login_required @app.csrf.exempt def utxo_list(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) idx = int(request.form.get("idx", 0)) limit = int(request.form.get("limit", 100)) search = request.form.get("search", None) @@ -419,9 +423,10 @@ def utxo_list(wallet_alias): for tx in txlist: if not tx.get("label", None): tx["label"] = wallet.getlabel(tx["address"]) - return process_txlist( + txlist, page_count = process_txlist( txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir ) + return {"txlist": json.dumps(txlist), "pageCount": page_count} @wallets_endpoint_api.route("/wallets_overview/txlist", methods=["POST"]) @@ -441,10 +446,10 @@ def wallets_overview_txlist(): current_blockheight=app.specter.info.get("blocks"), service_id=service_id, ) - - return process_txlist( + txlist, page_count = process_txlist( txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir ) + return {"txlist": json.dumps(txlist), "pageCount": page_count} @wallets_endpoint_api.route("/wallets_overview/utxo_list", methods=["POST"]) @@ -458,15 +463,16 @@ def wallets_overview_utxo_list(): sortdir = request.form.get("sortdir", "asc") fetch_transactions = request.form.get("fetch_transactions", False) txlist = app.specter.wallet_manager.full_utxo() - return process_txlist( + txlist, page_count = process_txlist( txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir ) + return {"txlist": json.dumps(txlist), "pageCount": page_count} @wallets_endpoint_api.route("/wallet//pending_psbt_list", methods=["GET"]) @login_required def pending_psbt_list(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) pending_psbts = wallet.pending_psbts_dict() return jsonify(pending_psbts=pending_psbts) @@ -510,7 +516,7 @@ def addresses_list(wallet_alias): @login_required @app.csrf.exempt def addressinfo(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) address = request.form.get("address", "") if not address: return jsonify(success=False) @@ -583,7 +589,7 @@ def addresses_list_csv(wallet_alias): type (address_type param)""" try: - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) sortby = request.args.get("sortby", "index") sortdir = request.args.get("sortdir", "asc") @@ -634,16 +640,14 @@ def addresses_list_csv(wallet_alias): @wallets_endpoint_api.route("/wallet//transactions.csv") @login_required def tx_history_csv(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) validate_merkle_proofs = app.specter.config.get("validate_merkle_proofs", False) txlist = wallet.txlist(validate_merkle_proofs=validate_merkle_proofs) search = request.args.get("search", None) sortby = request.args.get("sortby", "time") sortdir = request.args.get("sortdir", "desc") - txlist = json.loads( - process_txlist( - txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir - )["txlist"] + txlist, _ = process_txlist( + txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir ) includePricesHistory = request.args.get("exportPrices", "false") == "true" @@ -664,29 +668,23 @@ def tx_history_csv(wallet_alias): @login_required def utxo_csv(wallet_alias): try: - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) includePricesHistory = request.args.get("exportPrices", "false") == "true" search = request.args.get("search", None) sortby = request.args.get("sortby", "time") sortdir = request.args.get("sortdir", "desc") - txlist = json.loads( - process_txlist( - wallet.full_utxo, - idx=0, - limit=0, - search=search, - sortby=sortby, - sortdir=sortdir, - )["txlist"] + txlist, _ = process_txlist( + wallet.full_utxo, + idx=0, + limit=0, + search=search, + sortby=sortby, + sortdir=sortdir, ) # stream the response as the data is generated response = Response( stream_with_context( - txlist_to_csv( - wallet, - txlist, - includePricesHistory, - ) + txlist_to_csv(wallet, txlist, includePricesHistory, amount_logic="utxo") ), mimetype="text/csv", ) @@ -703,7 +701,7 @@ def utxo_csv(wallet_alias): ) @login_required def is_address_mine(wallet_alias, address): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) # filter out invalid input # and Segwit addresses are always between 14 and 74 characters long. @@ -727,7 +725,7 @@ def is_address_mine(wallet_alias, address): @login_required def estimate_fee(wallet_alias): """Returns a json-representation of a psbt which did not get persisted. Kind of a draft-run.""" - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) # update balances in the wallet wallet.update_balance() # update utxo list for coin selection @@ -748,7 +746,7 @@ def estimate_fee(wallet_alias): ) try: # Won't get persisted - psbt = psbt_creator.create_psbt(wallet) + psbt = psbt_creator.create_psbt(wallet).to_dict() return jsonify(success=True, psbt=psbt) except SpecterError as se: app.logger.error(se) @@ -759,7 +757,7 @@ def estimate_fee(wallet_alias): @login_required def asset_balances(wallet_alias): try: - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) if app.specter.is_testnet: label = "tBTC" elif app.specter.is_liquid: @@ -804,10 +802,8 @@ def wallet_overview_txs_csv(): search = request.args.get("search", None) sortby = request.args.get("sortby", "time") sortdir = request.args.get("sortdir", "desc") - txlist = json.loads( - process_txlist( - txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir - )["txlist"] + txlist, _ = process_txlist( + txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir ) includePricesHistory = request.args.get("exportPrices", "false") == "true" # stream the response as the data is generated @@ -834,15 +830,15 @@ def wallet_overview_utxo_csv(): search = request.args.get("search", None) sortby = request.args.get("sortby", "time") sortdir = request.args.get("sortdir", "desc") - txlist = json.loads( - process_txlist( - txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir - )["txlist"] + txlist, _ = process_txlist( + txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir ) includePricesHistory = request.args.get("exportPrices", "false") == "true" # stream the response as the data is generated response = Response( - stream_with_context(txlist_to_csv(None, txlist, includePricesHistory)), + stream_with_context( + txlist_to_csv(None, txlist, includePricesHistory, amount_logic="utxo") + ), mimetype="text/csv", ) # add a filename @@ -858,19 +854,22 @@ def wallet_overview_utxo_csv(): ################## Helpers ####################### # Transactions list to user-friendly CSV format -def txlist_to_csv(wallet: Wallet, _txlist, includePricesHistory=False): +def txlist_to_csv( + wallet: Wallet, _txlist, includePricesHistory=False, amount_logic="flow" +): """transforms a txlist into a csv-stream. This function is not returning but yielding. As such it needs to be called via wrapping it in stream_with_context see https://flask.palletsprojects.com/en/1.1.x/patterns/streaming/#streaming-with-context for details """ - txlist = [] + txlist: List[WalletAwareTxItem] = [] for tx in _txlist: + if isinstance(tx["address"], list): tx_copy = tx.copy() # No idea how this could be?! for i in range(0, len(tx["address"])): tx_copy["address"] = tx["address"][i] - tx_copy["amount"] = tx["amount"][i] + tx_copy["amount"] = tx["flow_amount"][i] txlist.append(tx_copy.copy()) else: txlist.append(tx.copy()) @@ -898,14 +897,14 @@ def txlist_to_csv(wallet: Wallet, _txlist, includePricesHistory=False): lazy_gettext("Timestamp"), ) if not wallet: - row = (_("Wallet"),) + row + row = ("Wallet",) + row w.writerow(row) yield data.getvalue() data.seek(0) data.truncate(0) # write each log item - _wallet = wallet + _wallet: Wallet = wallet for tx in txlist: if not wallet: wallet_alias = tx.get("wallet_alias", None) @@ -923,9 +922,13 @@ def txlist_to_csv(wallet: Wallet, _txlist, includePricesHistory=False): tx["blockheight"] = tx_raw["blockheight"] else: tx["blockheight"] = "Unconfirmed" - if app.specter.unit == "sat": - value = float(tx["amount"]) - tx["amount"] = round(value * 1e8) + # For txs, the relevant amount is flow_amount + if amount_logic == "flow": + tx["amount"] = tx.flow_amount + elif amount_logic == "utxo": + tx["amount"] = tx.utxo_amount + else: + raise SpecterInternalException(f"Unknown amount_logic: {amount_logic}") amount_price = "not supported" rate = "not supported" if tx.get("blocktime"): @@ -939,7 +942,7 @@ def txlist_to_csv(wallet: Wallet, _txlist, includePricesHistory=False): rate = float(rate) if app.specter.unit == "sat": rate = rate / 1e8 - amount_price = float(tx["amount"]) * rate + amount_price = float(tx["flow_amount"]) * rate amount_price = round(amount_price * 100) / 100 if app.specter.unit == "sat": rate = round(1 / rate) @@ -952,7 +955,7 @@ def txlist_to_csv(wallet: Wallet, _txlist, includePricesHistory=False): time.strftime("%Y-%m-%d", time.localtime(timestamp)), label, tx["category"], - round(tx["amount"], (0 if app.specter.unit == "sat" else 8)), + round(tx.get("amount", 9999), (0 if app.specter.unit == "sat" else 8)), amount_price, rate, tx["txid"], @@ -1010,7 +1013,7 @@ def addresses_list_to_csv(wallet: Wallet): if address_info.used: for tx in wallet.full_utxo: if tx.get("address", "") == address: - balance_on_address += tx.get("amount", 0) + balance_on_address += tx.flow_amount row += (balance_on_address,) w.writerow(row) @@ -1063,7 +1066,9 @@ def wallet_addresses_list_to_csv(addresses_list): def process_txlist(txlist, idx=0, limit=100, search=None, sortby=None, sortdir="asc"): - """Prepares the txlist for the ui filtering it with the search-criterias and sorting it""" + """Prepares the txlist for the ui filtering it with the search-criterias and sorting it + returns a tuple of the txlist and the pagecount + """ if search: search_lower = search.lower() txlist = [ @@ -1133,7 +1138,7 @@ def process_txlist(txlist, idx=0, limit=100, search=None, sortby=None, sortdir=" ] else: tx["assetlabel"] = app.specter.asset_label(tx["asset"]) - return {"txlist": json.dumps(txlist), "pageCount": page_count} + return txlist, page_count def process_addresses_list( diff --git a/src/cryptoadvance/specter/services/extension_gen.py b/src/cryptoadvance/specter/services/extension_gen.py index 13ed8b441..b228221a1 100644 --- a/src/cryptoadvance/specter/services/extension_gen.py +++ b/src/cryptoadvance/specter/services/extension_gen.py @@ -113,6 +113,7 @@ class ExtGen: self.render(f"{package_path}/templates/dummy/index.jinja") if self.devicename: self.render(f"{package_path}/devices/devicename.py") + self.render(f"{package_path}/devices/__init__.py") self.create_binary_file(f"{package_path}/static/dummy/img/device_icon.svg") if not self.isolated_client: self.render(f"{package_path}/static/dummy/css/styles.css") diff --git a/src/cryptoadvance/specter/specter.py b/src/cryptoadvance/specter/specter.py index 6398bf7ee..0b9840516 100644 --- a/src/cryptoadvance/specter/specter.py +++ b/src/cryptoadvance/specter/specter.py @@ -29,6 +29,7 @@ from .helpers import clean_psbt, deep_update, get_asset_label, is_liquid from .internal_node import InternalNode from .liquid.rpc import LiquidRPC from .managers.config_manager import ConfigManager +from .managers.device_manager import DeviceManager from .managers.node_manager import NodeManager from .managers.otp_manager import OtpManager from .managers.service_manager import ServiceManager @@ -651,31 +652,31 @@ class Specter: return self.user_config.get("alt_symbol", "BTC") @property - def admin(self): + def admin(self) -> User: for u in self.user_manager.users: if u.is_admin: return u @property - def user(self): + def user(self) -> User: return self.user_manager.user @property - def config_manager(self): + def config_manager(self) -> ConfigManager: if not hasattr(self, "_config_manager"): self._config_manager = ConfigManager(self.data_folder) return self._config_manager @property - def device_manager(self): + def device_manager(self) -> DeviceManager: return self.user.device_manager @property - def wallet_manager(self): + def wallet_manager(self) -> WalletManager: return self.user.wallet_manager @property - def otp_manager(self): + def otp_manager(self) -> OtpManager: if not hasattr(self, "_otp_manager"): self._otp_manager = OtpManager(self.data_folder) return self._otp_manager diff --git a/src/cryptoadvance/specter/templates/includes/tx-row.html b/src/cryptoadvance/specter/templates/includes/tx-row.html index 6389bfb92..117ffee09 100644 --- a/src/cryptoadvance/specter/templates/includes/tx-row.html +++ b/src/cryptoadvance/specter/templates/includes/tx-row.html @@ -164,7 +164,7 @@ this.hideSensitiveInfo = this.getAttribute('data-hide-sensitive-info') == 'true'; // 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.amount = `${this.tx['flow_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? @@ -233,10 +233,14 @@ } // Set amount - if (Array.isArray(this.tx.amount)) { - this.amount = parseFloat(this.tx.amount.reduce((a, b) => a + b, 0).toFixed(8)) + if (this.mode == 'utxo') { + this.amount = parseFloat(this.tx.amount.toFixed(8)) } else { - this.amount = parseFloat(this.tx.amount.toFixed(8)); + if (this.tx.flow_amount) { + this.amount = parseFloat(this.tx.flow_amount.toFixed(8)) + } else { + this.amount = parseFloat(this.tx.amount.toFixed(8)) + } } if (!this.price || !this.symbol || this.amount == 1e-8 || this.hideSensitiveInfo) { @@ -292,7 +296,7 @@ this.category.classList.add('svg-cancelled'); } - if ((this.tx.category == "send" || this.tx.category == "selftransfer") && this.tx["bip125-replaceable"] == "yes") { + if ((this.tx.category == "send" || this.tx.category == "selftransfer") && this.tx["bip125-replaceable"] == "yes" && this.tx["confirmations"] == 0) { this.rbf.classList.remove('hidden'); if (this.tx.category == "selftransfer") { this.rbfCancel.classList.add('hidden'); diff --git a/src/cryptoadvance/specter/templates/includes/tx-table.html b/src/cryptoadvance/specter/templates/includes/tx-table.html index 2de1fcf45..1f0aba011 100644 --- a/src/cryptoadvance/specter/templates/includes/tx-table.html +++ b/src/cryptoadvance/specter/templates/includes/tx-table.html @@ -776,6 +776,11 @@ */ attributeChangedCallback(attrName, oldValue, newValue) { if (attrName != "selected-coins") { + if (this.getAttribute('type') == "txlist") { + this.amountHeader.innerText="Amount \u0394" + } else { + this.amountHeader.innerText = "Amount" + } if (this.getAttribute('blockhash') == 'true' && this.getAttribute('type') == "txlist" && this.getAttribute('wallet')) { this.blockhashHeader.classList.remove('hidden'); this.blockhashSummary.classList.remove('hidden'); diff --git a/src/cryptoadvance/specter/templates/wallet/settings/wallet_settings.jinja b/src/cryptoadvance/specter/templates/wallet/settings/wallet_settings.jinja index a6d41e4a3..7ac740db4 100644 --- a/src/cryptoadvance/specter/templates/wallet/settings/wallet_settings.jinja +++ b/src/cryptoadvance/specter/templates/wallet/settings/wallet_settings.jinja @@ -188,7 +188,7 @@

{{ _("Advanced Options") }}

{{ _("Import address labels (Electrum or Specter)") }}

-
+

{{ _("Keypool control") }}

- +
{{ _("Currently watching") }} {{ wallet.keypool }} {{ _("receiving and") }} {{ wallet.change_keypool }} {{ _("change addresses.") }}
@@ -229,7 +229,7 @@

{{ _("Blockchain rescan") }}

-
+ {% if wallet.rescan_progress %}
@@ -261,7 +261,7 @@ {% endif %} {% endif %} -
+ {% if specter.info["utxorescan"] %}
@@ -322,8 +322,26 @@ {% endif %}
+ +

{{ _("Clear Cache") }}

+
+
+ + +
+
+

{{ _("Delete wallet") }}

-
+
diff --git a/src/cryptoadvance/specter/txlist.py b/src/cryptoadvance/specter/txlist.py index 9776d82cb..4d8f42b11 100644 --- a/src/cryptoadvance/specter/txlist.py +++ b/src/cryptoadvance/specter/txlist.py @@ -1,19 +1,29 @@ """ Manages the list of transactions for the wallet """ -from typing import Union -import os -from .specter_error import SpecterError -from .persistence import delete_file, write_csv, read_csv -from .helpers import get_address_from_dict -from embit.transaction import Transaction -from embit.liquid.networks import get_network -from embit import bip32 import json -import math import logging +import math +import os +from typing import Dict, List, Union + +from embit import bip32 +from embit.liquid.networks import get_network +from embit.transaction import Transaction + +from .helpers import get_address_from_dict +from .persistence import delete_file, read_csv, write_csv +from .specter_error import SpecterError, SpecterInternalException +from embit.descriptor import Descriptor +from embit.liquid.descriptor import LDescriptor +from .util.psbt import ( + AbstractTxContext, + SpecterInputScope, + SpecterOutputScope, + SpecterPSBT, + SpecterTx, +) from .util.tx import decoderawtransaction -from .util.psbt import SpecterTx, AbstractTxContext, SpecterPSBT logger = logging.getLogger(__name__) @@ -28,6 +38,14 @@ def parse_arr(v): class AbstractTxListContext(AbstractTxContext): + """An Abstract useful data-structure which solves 4 things: + * Passing the rpc-reference around is not necessary as long as we're navigating in some hierarchical structure where + we can ask the "parent" for an rpc-reference as the rpc reference is the same + * Same idea but with the chain. We don't want to tell each small data-structure what its chain is. Just pass a parent + in the constructor. + * It's derived from AbstractTxContext so it's also providing the attributes "descriptor" and "network" + """ + @property def rpc(self): if hasattr(self, "parent"): @@ -42,9 +60,21 @@ class AbstractTxListContext(AbstractTxContext): class TxItem(dict, AbstractTxListContext): + """A TxItem tries to be a clever dict which can easily be cached, holding all sorts of values which belongs to a Tx + and might be valuable for client-code. + The hex-represeantation of a Tx is cached in the "rawdir". If the the txid is existing as file in the rawdir, the hex + representation will be loaded from there. + The keys for the values which are returned if you call dict(obj) need to be specified in: + * columns + * type_converter (basically the type of the key) + * __dict__ method + """ + TransactionCls = Transaction + # columns will be used by _write_csv in order to derive the columns columns = [ "txid", # str, txid in hex + "fee", # int "blockhash", # str, blockhash, None if not confirmed "blockheight", # int, blockheight, None if not confirmed "time", # int (timestamp in seconds), time received @@ -52,13 +82,11 @@ class TxItem(dict, AbstractTxListContext): "bip125-replaceable", # str ("yes" / "no"), whatever RBF is enabled for the transaction "conflicts", # rbf conflicts, list of txids "vsize", - "category", "address", - "amount", - "ismine", ] type_converter = [ str, + int, str, int, int, @@ -66,10 +94,7 @@ class TxItem(dict, AbstractTxListContext): str, parse_arr, int, - str, parse_arr, - parse_arr, - bool, ] def __init__(self, parent, addresses, rawdir, **kwargs): @@ -84,13 +109,24 @@ class TxItem(dict, AbstractTxListContext): kwargs[k] = None if v in ["", None] else self.type_converter[i](v) super().__init__(**kwargs) - self._tx = None + self._tx: Transaction = None # if we have hex data - if "hex" in kwargs: + if "hex" in kwargs and kwargs["hex"] is not None: self._tx = self.TransactionCls.from_string(kwargs["hex"]) # conflicts were renamed to walletconflicts if "walletconflicts" in kwargs: self["conflicts"] = kwargs["walletconflicts"] + self.confirmations # trigger calculation + + def copy(self): + """Creates a copy of this TxItem""" + mycopy = self.__class__(self.parent, self._addresses, self.rawdir, **self) + return mycopy + + def clear_cache(self): + """removes the binary cache for this tx""" + if os.path.isfile(self.fname): + delete_file(self.fname) @property def fname(self): @@ -115,32 +151,13 @@ class TxItem(dict, AbstractTxListContext): # get transaction from rpc try: res = self.rpc.gettransaction(self.txid) - tx = self.TransactionCls.from_string(res["hex"]) + tx: Transaction = self.TransactionCls.from_string(res["hex"]) self._tx = tx return tx except Exception as e: logger.exception(e) return self._tx - @property - def vsize(self): - if self.get("vsize"): - return self["vsize"] - tx = self.tx - txsize = len(tx.serialize()) - if tx.is_segwit: - # tx size - flag - marker - witness - non_witness_size = ( - txsize - 2 - sum([len(inp.witness.serialize()) for inp in tx.vin]) - ) - witness_size = txsize - non_witness_size - weight = non_witness_size * 4 + witness_size - vsize = math.ceil(weight / 4) - else: - vsize = txsize - weight = txsize * 4 - return vsize - def dump(self): """Dumps transaction in binary to the folder if it's not there""" # nothing to do if file exists or we don't have binary tx @@ -163,39 +180,256 @@ class TxItem(dict, AbstractTxListContext): # clear cached tx as we saved the transaction to file self._tx = None + @property + def current_blockheight(self): + """Just for the completeness, this property is not suppose to be used. The current_blockheight is just there to compute self.confirmations""" + return self.get("_current_blockheight") + + @current_blockheight.setter + def set_current_blockheight(self, current_blockheight): + self["_current_blockheight"] = current_blockheight + + @property + def confirmations(self): + if not self.blockheight: + self["confirmations"] = 0 # still in the mempool + return self["confirmations"] + if self.current_blockheight: + self["confirmations"] = self.current_blockheight - self.blockheight + 1 + return self["confirmations"] + else: + # hmmm difficult to decide what to do here, we simply don't know + # Being actice and using parent.rpc.getblockcount() ? + # Might be a perf nightmare + # Raising an exception is not an option but + # returning a phantasy 0 or a magic number (-1 ?!) is also not cool + self["confirmations"] = None + return None + @property def txid(self): - return self["txid"] + if self.get("txid"): + return self["txid"] + if self._tx: + return self._tx.txid().hex() + return "undefined" + + @property + def blockheight(self): + if self.get("blockheight"): + return self["blockheight"] + return None + + @property + def time(self): + if self.get("time"): + return self["time"] + return None + + @property + def bip125_replaceable(self): + if self.get("bip125-replaceable"): + return self["bip125-replaceable"] + return "no" + + @property + def conflicts(self): + if self.get("conflicts"): + return self["conflicts"] + return None + + @property + def vsize(self): + if self.get("vsize"): + return self["vsize"] + tx = self.tx + txsize = len(tx.serialize()) + if tx.is_segwit: + # tx size - flag - marker - witness + non_witness_size = ( + txsize - 2 - sum([len(inp.witness.serialize()) for inp in tx.vin]) + ) + witness_size = txsize - non_witness_size + weight = non_witness_size * 4 + witness_size + vsize = math.ceil(weight / 4) + else: + vsize = txsize + weight = txsize * 4 + self["vsize"] = vsize + return self["vsize"] @property def hex(self): return str(self.tx) def __str__(self): + """Good implementation ? I'm not sure""" return self.txid def __repr__(self): - return f"TxItem({str(self)})" + return ( + f"{self.__class__.__name__}({str(self)}{ ' with hex' if self._tx else ''})" + ) def __dict__(self): - return { - "txid": self["txid"], - "blockhash": self["blockhash"], - "blockheight": self["blockheight"], - "time": self["time"], - "blocktime": self["blocktime"], - "conflicts": self["conflicts"], - "bip125-replaceable": self["bip125-replaceable"], - "vsize": self["vsize"], - "category": self["category"], - "address": self["address"], - "amount": self["amount"], - "ismine": self["ismine"], - } + # I don't get why we return a dict here as we are already a dict. So let's instead return a copy + return self.copy() + # { + # "txid": self["txid"], + # "blockhash": self["blockhash"], + # "blockheight": self["blockheight"], + # "time": self["time"], + # "blocktime": self["blocktime"], + # "conflicts": self["conflicts"], + # "bip125-replaceable": self["bip125-replaceable"], + # "vsize": self["vsize"], + # "address": self["address"], + # } + + +class WalletAwareTxItem(TxItem): + PSBTCls = SpecterPSBT + columns = TxItem.columns.copy() + columns.extend( + ["category", "flow_amount", "utxo_amount", "ismine"], + ) + + type_converter = TxItem.type_converter.copy() + type_converter.extend([str, float, float, bool]) + + def __init__(self, parent, addresses, rawdir, **kwargs): + super().__init__(parent, addresses, rawdir, **kwargs) + if type(self.parent.descriptor) not in [Descriptor, LDescriptor]: + raise SpecterInternalException( + f"Cannot instantiate WalletAwareTxItem without proper Descriptor, got: {type(self.parent.descriptor)}" + ) + # ToDo: Make that more lazy. We're triggering those properties to fill the dict with the corresponding keys + self.category + self.address + self.flow_amount + self.ismine + + @property + def psbt(self) -> SpecterPSBT: + """This tx but as a psbt. Need rpc-calls""" + if hasattr(self, "_psbt"): + return self._psbt + self._psbt: SpecterPSBT = self.PSBTCls.from_transaction( + self.tx, self.descriptor, self.network + ) + # fill derivation paths etc + updated = self.rpc.walletprocesspsbt(str(self._psbt), False).get("psbt", None) + if updated: + self._psbt.update(updated) + return self._psbt + + @property + def category(self): + """One of mixed (default), generate, selftransfer, receive or send""" + if self.get("_category"): + return self["_category"] + # detect category + category = "mixed" + + # calculate everything once + inputs = self.psbt.inputs + outputs = self.psbt.outputs + all_inputs_mine = all([inp.is_mine for inp in inputs]) + all_outputs_mine = all([out.is_mine for out in outputs]) + all_inputs_external = not any([inp.is_mine for inp in inputs]) + + if b"\x00" * 32 in [vin.txid for vin in self.tx.vin]: + category = "generate" + elif all_inputs_mine and all_outputs_mine: + category = "selftransfer" + elif all_inputs_external: + category = "receive" + elif all_inputs_mine: + category = "send" + self["category"] = category + return self["category"] + + @property + def utxo_amount(self) -> float: + """In the UTXO-view, you want to know how much the UTXOs from that TX are worth. + So you return the sum of the wallet-specific outputs + """ + if self.get("utxo_amount"): + return self["utxo_amount"] + outputs = [out.to_dict() for out in self.psbt.outputs] + self["utxo_amount"] = sum( + [output["float_amount"] for output in outputs if output["is_mine"]] + ) + return self["utxo_amount"] + + @property + def flow_amount(self) -> float: + """In the history-view, you want to know how many sats your wallet gained or lost. + that's the flow amount of a tx: All wallet_specifc outputs minus wallet-specific inputs + """ + if self.get("flow_amount"): + return self["flow_amount"] + inputs: List[SpecterInputScope] = self.psbt.inputs + outputs: List[SpecterOutputScope] = self.psbt.outputs + all_my_inputs_sum = sum( + [input.float_amount for input in inputs if input.is_mine] + ) + all_my_ouputs_sum = sum( + [output.float_amount for output in outputs if output.is_mine] + ) + # This includes fees! + self["flow_amount"] = all_my_ouputs_sum - all_my_inputs_sum + return self["flow_amount"] + + @property + def ismine(self) -> bool: + if self.get("ismine"): + return self["ismine"] + inputs = self.psbt.inputs + outputs = self.psbt.outputs + any_inputs_mine = any([inp.is_mine for inp in inputs]) + any_outputs_mine = any([out.is_mine for out in outputs]) + self["ismine"] = any_inputs_mine or any_outputs_mine + return self["ismine"] + + @property + def address(self): + """Does it make sense to show an address for a transaction? Maybe yes in certain + situations. For those, you can use this one: + """ + if self.get("address"): + return self["address"] + all_outs = [out.to_dict() for out in self.psbt.outputs] + my_outs = [out for out in all_outs if out["is_mine"]] + my_receiving = [out for out in my_outs if not out["change"]] + external = [out for out in all_outs if out not in my_outs] + # decide what addresses to show + if self.category in ["generate", "receive", "selftransfer"]: + # either receiving only (if not empty), or only mine (if not empty), or all + outs = my_receiving or my_outs or all_outs + elif self.category in ["send"]: + # keep only external addresses if they are present + outs = external or all_outs + else: + # not sure what's the best here + outs = my_receiving or my_outs or external or all_outs + addresses = [out.get("address", "Unknown") for out in outs] + self["address"] = addresses[0] + return self["address"] + + def __dict__(self): + super_dict = dict(self) + super_dict["category"] = self.category + super_dict["flow_amount"] = self.flow_amount + super_dict["utxo_amount"] = self.utxo_amount + super_dict["ismine"] = (self["ismine"] or self.ismine,) + return super_dict class TxList(dict, AbstractTxListContext): - ItemCls = TxItem # for inheritance + """A TxList is a dict with txids as keys and TxItems as values.""" + + ItemCls = WalletAwareTxItem # for inheritance PSBTCls = SpecterPSBT def __init__(self, path, parent, addresses): @@ -232,8 +466,15 @@ class TxList(dict, AbstractTxListContext): write_csv(self.path, list(self.values()), self.ItemCls) self._file_exists = True else: - delete_file(self.path) - self._file_exists = False + self.clear_cache() + + def clear_cache(self): + """Asks all Txs to clear its cache and removes the csv-file""" + for tx in self.values(): + tx.clear_cache() + delete_file(self.path) + self._file_exists = False + logger.info(f"Cleared the Cache for {self.path} (and rawdir)") def getfetch(self, txid): """ @@ -247,7 +488,51 @@ class TxList(dict, AbstractTxListContext): self.add({txid: tx}) return self[txid] - def gettransaction(self, txid, blockheight=None, decode=False, full=True): + def get_transactions(self, current_blockheight=None) -> WalletAwareTxItem: + """A great method to get massaged Txs. Those are all copies so mess with it as you see fit. + This is what's added: + 1. sorted by time + 2. conflict free (only the oldest if conflicts) + 3. have a confirmation key with the number of confirmations + So what's missing? + 1. No labels (not cached in TxList) + """ + if not current_blockheight: + current_blockheight = self.rpc.getblockcount() + + tx: WalletAwareTxItem + + # Make a copy of all txs if the tx.ismine (which should be all of them) + # As TxItem is derived from Dict, the __Dict__ will return a TxItem + transactions: List(TxItem) = [tx.copy() for tx in self.values() if tx.ismine] + # 1. sorted + transactions = sorted(transactions, key=lambda tx: tx["time"], reverse=True) + + # 2. conflict free + # conflict-filter: only tx which don't have conflicts or, if it has conflicts, only the one + # with the highest time-stamp + transactions = [ + tx + for tx in transactions + if ( + not tx.conflicts + or max( + [ + self.gettransaction(conflicting_tx, 0, full=False)["time"] + for conflicting_tx in tx["conflicts"] + ] + ) + < tx["time"] + ) + ] + for tx in transactions: + # 3. with a confirmation-key + tx.set_current_blockheight = current_blockheight + tx.confirmations # trigger calculation + + return transactions + + def gettransaction(self, txid, blockheight=None, decode=False, full=True) -> Dict: """ Will ask Bitcoin Core for a transaction if blockheight is None or txid not known Provide blockheight or 0 if you don't care about confirmations number @@ -301,6 +586,7 @@ class TxList(dict, AbstractTxListContext): "conflicts", - list of txids spending the same inputs (rbf) "bip125-replaceable", - str ("yes" or "no") - is rbf enabled for this tx } + (format of listtransactions) """ # here we store all addresses in transactions # to set them used later @@ -317,6 +603,7 @@ class TxList(dict, AbstractTxListContext): ) obj = { "txid": txid, + "fee": tx.get("fee", None), "blockheight": tx.get("blockheight", None), "blockhash": tx.get("blockhash", None), "time": time, @@ -336,75 +623,8 @@ class TxList(dict, AbstractTxListContext): except: pass # maybe not an address, but a raw script? self._addresses.set_used(addresses) - # detect category, amounts and addresses - for tx in [self[txid] for txid in self if txid in txs]: - self._fill_missing(tx) self._save() - def _update_destinations(self, tx, outs): - addresses = [out.get("address", "Unknown") for out in outs] - amounts = [out["float_amount"] for out in outs] - if len(addresses) == 1: - addresses = addresses[0] - amounts = amounts[0] - tx["address"] = addresses - tx["amount"] = amounts - - def _get_psbt(self, raw_tx): - psbt = self.PSBTCls.from_transaction(raw_tx, self.descriptor, self.network) - # fill derivation paths etc - updated = self.rpc.walletprocesspsbt(str(psbt), False).get("psbt", None) - if updated: - psbt.update(updated) - return psbt - - def _fill_missing(self, tx): - """This seem to calculate the category of the tx which is one of: - mixed (default), generate, selftransfer, receive or send - - Also the tx gets a key with a boolean to figure out whether its "mine" - """ - raw_tx = tx.tx - psbt = self._get_psbt(raw_tx) - # detect category - category = "mixed" - - # calculate everything once - inputs = [inp.to_dict() for inp in psbt.inputs] - outputs = [out.to_dict() for out in psbt.outputs] - all_inputs_mine = all([inp["is_mine"] for inp in inputs]) - all_outputs_mine = all([out["is_mine"] for out in outputs]) - all_inputs_external = not any([inp["is_mine"] for inp in inputs]) - - if b"\x00" * 32 in [vin.txid for vin in raw_tx.vin]: - category = "generate" - elif all_inputs_mine and all_outputs_mine: - category = "selftransfer" - elif all_inputs_external: - category = "receive" - elif all_inputs_mine: - category = "send" - - all_outs = [out for out in outputs] - my_outs = [out for out in all_outs if out["is_mine"]] - my_receiving = [out for out in my_outs if not out["change"]] - external = [out for out in all_outs if out not in my_outs] - # decide what addresses to show - if category in ["generate", "receive", "selftransfer"]: - # either receiving only (if not empty), or only mine (if not empty), or all - outs = my_receiving or my_outs or all_outs - elif category in ["send"]: - # keep only external addresses if they are present - outs = external or all_outs - else: - # not sure what's the best here - outs = my_receiving or my_outs or external or all_outs - - self._update_destinations(tx, outs) - tx["category"] = category - # at least one input or output is ours - tx is ours - tx["ismine"] = any(scope["is_mine"] for scope in (inputs + outputs)) - def load(self, arr): """ TODO: load transactions from backup diff --git a/src/cryptoadvance/specter/user.py b/src/cryptoadvance/specter/user.py index 1f408da03..82c0775c6 100644 --- a/src/cryptoadvance/specter/user.py +++ b/src/cryptoadvance/specter/user.py @@ -264,14 +264,14 @@ class User(UserMixin): self.check_wallet_manager() @property - def wallet_manager(self): + def wallet_manager(self) -> WalletManager: if self._wallet_manager is None: self.check_wallet_manager() assert self._wallet_manager is not None return self._wallet_manager @property - def device_manager(self): + def device_manager(self) -> DeviceManager: if self._device_manager is None: self.check_device_manager() assert self._device_manager is not None diff --git a/src/cryptoadvance/specter/util/psbt.py b/src/cryptoadvance/specter/util/psbt.py index 8cfbc8901..00235619b 100644 --- a/src/cryptoadvance/specter/util/psbt.py +++ b/src/cryptoadvance/specter/util/psbt.py @@ -3,6 +3,7 @@ The goal of this module is to slowly migrate from json-like representation of PS to a normal PSBT class that does not require RPC calls and can do more things. to_dict and from_dict methods are maintained for backward-compatibility """ +import logging from cryptoadvance.specter.key import Key from embit.psbt import PSBT, InputScope, OutputScope, DerivationPath from embit.transaction import Transaction, TransactionOutput, TransactionInput @@ -13,6 +14,8 @@ from math import ceil import time from typing import Union, Tuple, List +logger = logging.getLogger(__name__) + class AbstractTxContext: """Class inherited from this one must have the following properties: @@ -120,7 +123,12 @@ class SpecterScope(AbstractTxContext): return "OP_RETURN " + self.scope.script_pubkey.data.hex() else: return self.scope.script_pubkey.address(self.network) - except: + except AttributeError as e: # 'NoneType' object has no attribute 'data' + if type(self.scope) != InputScope: # known issue in this case + logger.exception() + return None + except TypeError: + # remove this and you get in trouble with tests/test_util_psbt.py return None @property @@ -132,6 +140,15 @@ class SpecterScope(AbstractTxContext): def float_amount(self) -> float: return round(self.sat_amount * 1e-8, 8) + def __repr__(self) -> str: + if self.is_receiving: + addr_type = "r" + elif self.is_change: + addr_type = "c" + else: + addr_type = "u" # unknown + return f"{self.__class__.__name__}(float_amount={self.float_amount} mine={self.is_mine} adr={self.address} r/c={addr_type})" + def to_dict(self) -> dict: addr = self.address try: @@ -184,6 +201,11 @@ class SpecterInputScope(SpecterScope): @property def sat_amount(self) -> int: + if self.scope.utxo is None: + # If utxo is not there, then the wallet-api from Core was not able to provide + # the underlying utxo (as it's not part of the wallet). Therefore not mine + # Therefore float_amount is 0 + return 0 return self.scope.utxo.value @property @@ -243,7 +265,7 @@ class SpecterPSBT(AbstractTxContext): network: dict, raw: Union[None, str] = None, devices: List[Tuple[Key, str]] = [], # list of tuples: (Key, device_alias) - **kwargs + **kwargs, ): """ kwargs can contain: @@ -252,7 +274,7 @@ class SpecterPSBT(AbstractTxContext): """ if isinstance(psbt, str): psbt = self.PSBTCls.from_string(psbt) - self.psbt = psbt + self.psbt: PSBT = psbt self._descriptor = descriptor self._network = network self.devices = devices diff --git a/src/cryptoadvance/specter/wallet.py b/src/cryptoadvance/specter/wallet.py index fba8f08b1..1e094d7ba 100644 --- a/src/cryptoadvance/specter/wallet.py +++ b/src/cryptoadvance/specter/wallet.py @@ -13,7 +13,7 @@ from embit.liquid.networks import get_network from embit.psbt import DerivationPath from embit.transaction import Transaction from io import StringIO -from typing import List +from typing import Dict, List from cryptoadvance.specter.commands.utxo_scanner import UtxoScanner from cryptoadvance.specter.rpc import RpcError @@ -25,7 +25,7 @@ from .util.merkleblock import is_valid_merkle_proof from .helpers import get_address_from_dict from .persistence import write_json_file, delete_file, delete_folder from .specter_error import SpecterError, handle_exception -from .txlist import TxList +from .txlist import TxItem, TxList, WalletAwareTxItem from .util.psbt import SpecterPSBT from .util.tx import decoderawtransaction from .util.xpub import get_xpub_fingerprint @@ -185,7 +185,7 @@ class Wallet: return add_checksum(str(self.descriptor.branch(1))) @property - def devices(self): + def devices(self) -> List[Device]: return [ ( device @@ -206,7 +206,7 @@ class Wallet: return get_network(self.chain) @classmethod - def construct_descriptor(cls, sigs_required, key_type, keys, devices): + def construct_descriptor(cls, sigs_required, key_type, keys, devices) -> Descriptor: """ Creates a wallet descriptor from arguments. We need to pass `devices` for Liquid wallet, here it's not used. @@ -376,7 +376,14 @@ class Wallet: self._addresses.add(recv + change, check_rpc=True) def fetch_transactions(self): - """Load transactions from Bitcoin Core""" + """Loads new transactions from Bitcoin Core. A quite confusing method which mainly tries to figure out which transactions are new + and need to be added to the local TxList self._transactions and adding them. + So the method doesn't return anything but has these side_effects: + 1. Adding the new interesting transactions to self._transactions + 2. for self.use_descriptors create new addresses and add them to self._addresses + 3. calls self.delete_spent_pending_psbts + Most of that code could probably encapsulated in the TxList class. + """ arr = [] idx = 0 # unconfirmed_selftransfers needed since Bitcoin Core does not properly list `selftransfer` txs in `listtransactions` command @@ -388,7 +395,7 @@ class Wallet: unconfirmed_selftransfers = [ txid for txid in self._transactions - if self._transactions[txid].get("category", "") == "selftransfer" + if self._transactions[txid].category == "selftransfer" and not self._transactions[txid].get("blockhash", None) ] unconfirmed_selftransfers_txs = [] @@ -805,13 +812,77 @@ class Wallet: return self.info def check_utxo(self): + """fetches the utxo-set from core and stores the result in self.__full_utxo which is + a List[WalletAwareTxItem] enriched with utxo specific data: + * item["locked"] if the item is locked in Core + * item["vout"] to enable its use in coinselection + * item["amount"] is the utxo_amount + """ + _full_utxo = [] try: # listunspent only lists not locked utxos # so we need to unlock, then list, then lock back locked_utxo = self.rpc.listlockunspent() + # e.g. [ + # {'txid': '1211aaba2b261e1bf06a3d46d5ac8837cee4669980ddfa7e1e73b2a7cd593f23', 'vout': 0}, + # {'txid': '7cdc6c668b665c47de5823caca41c194f2d70815420c564aa4fa5786d4c0693f', 'vout': 0} + # ] + locked_utxo_list = [tx["txid"] for tx in locked_utxo] + utxo = self.rpc.listunspent(0) + utxo.extend(locked_utxo) + + txlist = self._transactions.get_transactions() + txlist_dict = {_tx["txid"]: _tx for _tx in txlist} + # iterating over the utxos/vouts and creating a list of WalletAwareTxItems + for utxo_txid, utxo_vout in { + _utxo["txid"]: _utxo["vout"] for _utxo in utxo + }.items(): + # maybe the txlist is outdated and it's a new utxo?! + if utxo_txid not in txlist_dict.keys(): + self.fetch_transactions() # ToDo: make this much slimmer! + txlist = self._transactions.get_transactions() + txlist_dict = {_tx["txid"]: _tx for _tx in txlist} + tx: WalletAwareTxItem = txlist_dict[utxo_txid] + # Adding vout, locked and amount + tx["vout"] = utxo_vout + if tx.txid in locked_utxo_list: + tx["locked"] = True + else: + tx["locked"] = False + tx["amount"] = tx.utxo_amount + _full_utxo.append(tx) + + # Finally sorting: + self._full_utxo = sorted( + _full_utxo, + key=lambda _full_utxo: _full_utxo["time"], + reverse=True, + ) + except Exception as e: + logger.exception(e) + self._full_utxo = [] + raise SpecterError(f"Failed to load utxos, {e}") + + def check_utxo_orig(self): + """fetches the utxo-set from core and stores the result in self.__full_utxo which is + a List[WalletAwareTxItem] enriched with utxo specific data: + * item["locked"] if the item is locked in Core + * item["vout"] to enable its use in coinselection + * item["amount"] is the utxo_amount + """ + result_utxos = [] + try: + # listunspent only lists not locked utxos + # so we need to unlock, then list, then lock back + locked_utxo = self.rpc.listlockunspent() + # e.g. [ + # {'txid': '1211aaba2b261e1bf06a3d46d5ac8837cee4669980ddfa7e1e73b2a7cd593f23', 'vout': 0}, + # {'txid': '7cdc6c668b665c47de5823caca41c194f2d70815420c564aa4fa5786d4c0693f', 'vout': 0} + # ] if locked_utxo: self.rpc.lockunspent(True, locked_utxo) utxo = self.rpc.listunspent(0) + utxo_dict = {item["txid"]: item for item in utxo} if locked_utxo: self.rpc.lockunspent(False, locked_utxo) for tx in utxo: @@ -823,13 +894,31 @@ class Wallet: tx["locked"] = True # list only the ones we know (have descriptor for it) utxo = [tx for tx in utxo if tx.get("desc", "")] - for tx in utxo: - tx_data = self.gettransaction(tx["txid"], 0, full=False) - tx["time"] = tx_data["time"] - 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) + # We need a list in order to check later whether an txid is in that list + utxo = [tx["txid"] for tx in utxo] + # same for locked_utxo_list + locked_utxo_list = [tx["txid"] for tx in locked_utxo] + + # This is the full txlist: + txlist = self._transactions.get_transactions() + tx: WalletAwareTxItem + for tx in txlist: + + if tx.txid in utxo: + result_utxos.append(tx) + # ToDo: What if a tx contains more than one spendable output? + tx["vout"] = utxo_dict[tx.txid]["vout"] + if tx.txid in locked_utxo_list: + tx["locked"] = True + else: + tx["locked"] = False + tx["amount"] = tx.utxo_amount + + self._full_utxo = sorted( + result_utxos, + key=lambda result_utxos: result_utxos["time"], + reverse=True, + ) except Exception as e: logger.exception(e) self._full_utxo = [] @@ -862,7 +951,11 @@ class Wallet: self.save_to_file() @property - def full_utxo(self): + def full_utxo(self) -> List[WalletAwareTxItem]: + """Lazy getter for the current full_utxo-set. Full means locked and not locked utxo. The result + us a List of WalletAwareTxItem. Check check_utxo for more details + Call check_utxo() to update it with recent data from core + """ if hasattr(self, "_full_utxo"): return self._full_utxo else: @@ -870,11 +963,13 @@ class Wallet: return self._full_utxo @property - def utxo(self): + def utxo(self) -> List[WalletAwareTxItem]: + """utxos which are not locked.""" return [utxo for utxo in self._full_utxo if not utxo["locked"]] @property - def locked_utxo(self): + def locked_utxo(self) -> List[WalletAwareTxItem]: + """utxos which are locked""" return [utxo for utxo in self._full_utxo if utxo["locked"]] @property @@ -930,6 +1025,9 @@ class Wallet: except: pass + def clear_cache(self): + self._transactions.clear_cache() + @property def use_descriptors(self): if not self.info: @@ -1057,6 +1155,7 @@ class Wallet: # validate_merkle_proofs (bool): Return transactions with validated_blockhash # current_blockheight (int): Current blockheight for calculating confirmations number (None will fetch the block count from the RPC) """ + # Consider to update self._transactions via self.fetch_transactions() if fetch_transactions or ( self.use_descriptors and len( @@ -1071,47 +1170,9 @@ class Wallet: ): self.fetch_transactions() - _transactions = [ - tx.__dict__().copy() for tx in self._transactions.values() if tx["ismine"] - ] - transactions = sorted(_transactions, key=lambda tx: tx["time"], reverse=True) - transactions = [ - tx - for tx in transactions - if ( - not tx["conflicts"] - or max( - [ - self.gettransaction(conflicting_tx, 0, full=False)["time"] - for conflicting_tx in tx["conflicts"] - ] - ) - < tx["time"] - ) - ] - if not current_blockheight: - current_blockheight = self.rpc.getblockcount() + transactions = self._transactions.get_transactions() result = [] - blocks = {} for tx in transactions: - if not tx.get("blockheight", 0): - tx["confirmations"] = 0 - else: - tx["confirmations"] = current_blockheight - tx["blockheight"] + 1 - - # coinbase tx - if tx["category"] == "generate": - if tx["confirmations"] <= 100: - category = "immature" - - if ( - tx.get("confirmations") == 0 - and tx.get("bip125-replaceable", "no") == "yes" - ): - rpc_tx = self.rpc.gettransaction(tx["txid"]) - tx["fee"] = rpc_tx.get("fee", 1) - tx["confirmations"] = rpc_tx.get("confirmations", 0) - tx["vsize"] = decoderawtransaction(rpc_tx["hex"]).get("vsize") if isinstance(tx["address"], str): tx["label"] = self.getlabel(tx["address"]) @@ -1155,7 +1216,7 @@ class Wallet: result.append(tx) return result - def gettransaction(self, txid, blockheight=None, decode=False, full=True): + def gettransaction(self, txid, blockheight=None, decode=False, full=True) -> Dict: """Gets transaction from cache If full=True it will also contain "hex" key with full hex transaction. If decode=True it will decode the transaction similar to Core decoderawtransaction call @@ -1577,7 +1638,7 @@ class Wallet: 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"] + amount += utxo.utxo_amount return amount @property @@ -1640,7 +1701,7 @@ class Wallet: readonly=False, # fee estimation rbf=True, rbf_edit_mode=False, - ) -> dict: + ) -> SpecterPSBT: """ Returns psbt as dictionary. fee_rate: in sat/B or BTC/kB. If set to 0 Bitcoin Core sets feeRate automatically. @@ -1746,7 +1807,7 @@ class Wallet: # TODO: Re-evaluate if this is necessary if user is running Bitcoin Core w/BIP-371 support b64psbt = self.fill_psbt(r["psbt"]) - psbt = self.PSBTCls( + psbt: SpecterPSBT = self.PSBTCls( b64psbt, self.descriptor, self.network, @@ -1754,7 +1815,7 @@ class Wallet: ) if not readonly: self.save_pending_psbt(psbt) - return psbt.to_dict() + return psbt def get_rbf_utxo(self, rbf_tx_id): decoded_tx = self.decode_tx(rbf_tx_id) @@ -2004,7 +2065,7 @@ class Wallet: for utxo in [ utxo for utxo in self._full_utxo if utxo["address"] == addr.address ]: - addr_amount = addr_amount + utxo["amount"] + addr_amount = addr_amount + utxo.utxo_amount addr_utxo = addr_utxo + 1 if service_id and ( diff --git a/tests/conftest_visibility.py b/tests/conftest_visibility.py index a51e76708..e5850d7a9 100644 --- a/tests/conftest_visibility.py +++ b/tests/conftest_visibility.py @@ -26,11 +26,13 @@ def should_intercept(call): This needs to be more and more restricted overtime as we hopefully have less and less flaky tests in the future and the normal output is enough. """ - return not ( - isinstance(call.excinfo.value, RpcError) - or isinstance(call.excinfo.value, SpecterError) - or isinstance(call.excinfo.value, AssertionError) - ) + # Modify that manually! + return False + + # isinstance(call.excinfo.value, RpcError) + # or isinstance(call.excinfo.value, SpecterError) + # or isinstance(call.excinfo.value, AssertionError) + # or isinstance(call.excinfo.value, AttributeError) @pytest.hookimpl(hookwrapper=True) diff --git a/tests/fix_devices_and_wallets.py b/tests/fix_devices_and_wallets.py index 78ec2b1d9..310187e2a 100644 --- a/tests/fix_devices_and_wallets.py +++ b/tests/fix_devices_and_wallets.py @@ -66,7 +66,7 @@ def hot_ghost_machine_device( def create_hot_segwit_wallet( specter_regtest_configured: Specter, device: Device, wallet_id ) -> Wallet: - wallet_manager = specter_regtest_configured.wallet_manager + wallet_manager: WalletManager = specter_regtest_configured.wallet_manager assert device.taproot_available(specter_regtest_configured.rpc) # create the wallet diff --git a/tests/test_ep_wallet_api.py b/tests/test_ep_wallet_api.py index 204cba074..decc391d8 100644 --- a/tests/test_ep_wallet_api.py +++ b/tests/test_ep_wallet_api.py @@ -31,7 +31,7 @@ def test_txlist_to_csv( caplog, app, specter_regtest_configured, - funded_hot_wallet_1, + funded_hot_wallet_1: Wallet, ): caplog.set_level(logging.DEBUG) diff --git a/tests/test_txlist.py b/tests/test_txlist.py index 3460295ba..884b963c3 100644 --- a/tests/test_txlist.py +++ b/tests/test_txlist.py @@ -1,18 +1,31 @@ import json import os +import shutil import time from binascii import hexlify from datetime import datetime from pathlib import Path +from tokenize import Floatnumber +from typing import List +import pytest from cryptoadvance.specter.process_controller.bitcoind_controller import ( BitcoindPlainController, ) -from cryptoadvance.specter.txlist import TxItem, TxList +from cryptoadvance.specter.rpc import BitcoinRPC +from cryptoadvance.specter.txlist import TxItem, TxList, WalletAwareTxItem +from cryptoadvance.specter.util.psbt import ( + SpecterInputScope, + SpecterOutputScope, + SpecterPSBT, + SpecterScope, +) +from embit.descriptor import Descriptor from embit.descriptor.arguments import Key -from embit.descriptor.descriptor import Descriptor -from embit.transaction import Transaction, TransactionInput -from mock import MagicMock +from embit.networks import NETWORKS +from embit.psbt import PSBT, InputScope, OutputScope +from embit.transaction import Transaction, TransactionInput, TransactionOutput +from mock import MagicMock, PropertyMock descriptor = "pkh([78738c82/84h/1h/0h]vpub5YN2RvKrA9vGAoAdpsruQGfQMWZzaGt3M5SGMMhW8i2W4SyNSHMoLtyyLLS6EjSzLfrQcbtWdQcwNS6AkCWne1Y7U8bt9JgVYxfeH9mCVPH/1/*)" # The example transaction from a regtest @@ -32,23 +45,120 @@ with open("tests/xtestdata_txlist/tx2_confirmed2.json") as f: tx2_confirmed2 = json.load(f) +def calc_descriptor(wrpc) -> Descriptor: + """calculates one descriptor via a wallet_rpc , most importantly: + replace("/0/", "/{0,1}/") + """ + i = 0 + for desc in wrpc.listdescriptors()["descriptors"]: + i += 1 + # print(f"{i} {desc}") + if desc["desc"].startswith("wpkh([") and not desc["internal"]: + descriptor = desc + + # We need One descriptor for both, receiving and change-addresses. However, core + # delivers two of them, one for each. + # So we take the receiving one, and create that special form out of it: + descriptor = descriptor["desc"].replace("/0/", "/{0,1}/") + descriptor = Descriptor.from_string(descriptor) + return descriptor + + +def calc_parent_mock(wrpc, parent_mock): + + # The wallet is not directly passed but via the parent which + # holds the wallet_rpc and the descriptor describing the wallet + + # mock the property rpc to be wallet rpc + type(parent_mock).rpc = PropertyMock(return_value=wrpc) + # so here is the matching descriptor: + + print("\nDESCRIPTOR\n==========") + descriptor = calc_descriptor(wrpc) + print("Our descriptor:") + print(descriptor) + print("\n") + type(parent_mock).descriptor = PropertyMock(return_value=descriptor) + return parent_mock + + +@pytest.fixture +def parent_mock(bitcoin_regtest): + """A Mock implementing AbstractTxListContext and AbstractTxContext""" + parent_mock = MagicMock() + # AbstractTxListContext: + type(parent_mock).rpc = PropertyMock(return_value=bitcoin_regtest.get_rpc()) + assert parent_mock.rpc.getblockchaininfo()["chain"] == "regtest" + type(parent_mock).chain = PropertyMock(return_value="regtest") + assert parent_mock.chain == "regtest" + # AbstractTxContext + type(parent_mock).network = PropertyMock(return_value=NETWORKS["regtest"]) + # omit descriptor! + return parent_mock + + def test_understandTransaction(): - mytx = Transaction.from_string(tx1_confirmed["hex"]) + mytx: Transaction = Transaction.from_string(tx1_confirmed["hex"]) assert mytx.version == 2 assert mytx.locktime == 415 + assert type(mytx.vin[0]) == TransactionInput assert ( hexlify(mytx.vin[0].txid) == b"c7c9dd852fa9cbe72b2f6e3b2eeba1a2b47dc4422b3719a55381be8010d7993f" ) - assert mytx.vout[0].value == 1999999890 + + assert type(mytx.vout[0]) == TransactionOutput + assert mytx.vout[0].value == 1999999890 # sats assert ( hexlify(mytx.txid()) == b"42f5c9e826e52cde883cde7a6c7b768db302e0b8b32fc52db75ad3c5711b4a9e" ) + # The computed txid is the same than the input + assert mytx.txid().hex() == tx1_confirmed["txid"] + + +def test_TxItem_load(empty_data_folder): + fname = "c518428b318612e60ba8a90ef767a0c6ea0ccf989ed69c3b10b1df537fab850e.bin" + shutil.copyfile( + f"tests/xtestdata_txlist/{fname}", os.path.join(empty_data_folder, fname) + ) + mytxitem = TxItem( + None, + [], + empty_data_folder, + arbitrary_key=1642182445, # arbitrary stuff can get passed + txid="c518428b318612e60ba8a90ef767a0c6ea0ccf989ed69c3b10b1df537fab850e", + ) + mytxitem_copy = mytxitem.copy() + # The property is loading the tx from disk + assert type(mytxitem.tx) == Transaction + assert type(mytxitem_copy.tx) == Transaction + assert ( + mytxitem.txid + == "c518428b318612e60ba8a90ef767a0c6ea0ccf989ed69c3b10b1df537fab850e" + ) + assert ( + mytxitem_copy.txid + == "c518428b318612e60ba8a90ef767a0c6ea0ccf989ed69c3b10b1df537fab850e" + ) + assert ( + mytxitem.tx.txid().hex() + == "c518428b318612e60ba8a90ef767a0c6ea0ccf989ed69c3b10b1df537fab850e" + ) + assert ( + mytxitem_copy.tx.txid().hex() + == "c518428b318612e60ba8a90ef767a0c6ea0ccf989ed69c3b10b1df537fab850e" + ) + assert mytxitem["arbitrary_key"] == 1642182445 + assert mytxitem_copy["arbitrary_key"] == 1642182445 + def test_TxItem(empty_data_folder): + # those two arrays could have been implemented as dict and need + # therefore same size + assert len(TxItem.type_converter) == len(TxItem.columns) mytxitem = TxItem( None, [], @@ -56,52 +166,299 @@ def test_TxItem(empty_data_folder): hex=tx1_confirmed["hex"], blocktime=1642182445, # arbitrary stuff can get passed ) - # a TxItem pretty much works like a hash with some extrafunctionality + assert type(mytxitem.tx) == Transaction # the parsed Tx from the hex + assert ( + mytxitem.txid + == "42f5c9e826e52cde883cde7a6c7b768db302e0b8b32fc52db75ad3c5711b4a9e" + ) + assert ( + str(mytxitem) + == "42f5c9e826e52cde883cde7a6c7b768db302e0b8b32fc52db75ad3c5711b4a9e" + ) + assert ( + mytxitem.__repr__() + == "TxItem(42f5c9e826e52cde883cde7a6c7b768db302e0b8b32fc52db75ad3c5711b4a9e with hex)" + ) + # a TxItem pretty much works like a dict with some extrafunctionality assert mytxitem["blocktime"] == 1642182445 # We can also add data after the fact + mytxitem["some_data"] = 123 + assert mytxitem["some_data"] == 123 + assert mytxitem.copy()["some_data"] == 123 + # Don't do that with reserved keys which have meaning: mytxitem["confirmations"] = 123 - assert mytxitem.tx.vout - assert mytxitem["confirmations"] == 123 + assert ( + mytxitem["confirmations"] == 123 + ) # might work for the instance itself but ... + assert not mytxitem.copy()["confirmations"] == 123 # not for the copy + + assert type(mytxitem.tx.vout) == list + # It's not saved yet: + assert not os.listdir(empty_data_folder) + # let's save: + mytxitem.dump() + assert os.listdir(empty_data_folder) + mydict = dict(mytxitem) -def test_txlist(empty_data_folder, bitcoin_regtest): - parent_mock = MagicMock() - bitcoin_regtest.get_rpc().createwallet("txlist1") - wrpc = bitcoin_regtest.get_rpc().wallet("txlist1") - parent_mock.rpc = wrpc +def test_WalletAwareTxItem_fromTxItem(bitcoin_regtest, parent_mock, empty_data_folder): + result = bitcoin_regtest.get_rpc().createwallet( + "test_WalletAwareTxItem_fromTxItem", False, False, "", False, True + ) + wrpc = bitcoin_regtest.get_rpc().wallet("test_WalletAwareTxItem_fromTxItem") + parent_mock = calc_parent_mock(wrpc, parent_mock) + # Let's fund the wallet + txid_funding_addr = wrpc.getnewaddress() + print(f"address: {txid_funding_addr}") + txid_funding = bitcoin_regtest.testcoin_faucet(txid_funding_addr, amount=1) + print(f"balance: {wrpc.getbalances()['mine']['trusted']}") + assert wrpc.getbalances()["mine"]["trusted"] == 1 + mywalletawaretxitem = WalletAwareTxItem( + parent_mock, [], empty_data_folder, txid=txid_funding + ) + mywalletawaretxitem_copy = mywalletawaretxitem.copy() + assert mywalletawaretxitem.flow_amount == mywalletawaretxitem_copy.flow_amount + assert mywalletawaretxitem.txid == mywalletawaretxitem_copy.txid + + +def test_WalletAwareTxItem(bitcoin_regtest, parent_mock, empty_data_folder): + # those two arrays could have been implemented as dict and need + # therefore same size + assert len(WalletAwareTxItem.type_converter) == len(WalletAwareTxItem.columns) + + # No testing of a WalletAwareTxItem if you don't have a wallet + result = bitcoin_regtest.get_rpc().createwallet( + "test_WalletAwareTxItem", False, False, "", False, True + ) + wrpc = bitcoin_regtest.get_rpc().wallet("test_WalletAwareTxItem") + parent_mock = calc_parent_mock(wrpc, parent_mock) + + # Let's fund the wallet + print("=========================================") + print("\nFUNDING TX (1btc)") + print("=========================================") + txid_funding_addr = wrpc.getnewaddress() + print(f"address: {txid_funding_addr}") + txid_funding = bitcoin_regtest.testcoin_faucet(txid_funding_addr, amount=1) + print(f"balance: {wrpc.getbalances()['mine']['trusted']}") + assert wrpc.getbalances()["mine"]["trusted"] == 1 + + mywalletawaretxitem = WalletAwareTxItem( + parent_mock, [], empty_data_folder, txid=txid_funding + ) + + print("INPUTS\n------") + for inp in mywalletawaretxitem.psbt.inputs: + print(str(inp)) + print("\nOUTPUTS\n------") + for out in mywalletawaretxitem.psbt.outputs: + print(str(out)) + assert mywalletawaretxitem.flow_amount == 1 + assert mywalletawaretxitem.category == "receive" + + # Let's do a selftransfer + print("=========================================") + print("\n\nSELFTRANSFER TX (0.1btc)") + print("=========================================") + txid_selftransfer_addr = wrpc.getnewaddress() + print(f"address = {txid_selftransfer_addr}") + txid_selftransfer = wrpc.sendtoaddress(txid_selftransfer_addr, 0.1) + print(f"balance: {wrpc.getbalances()['mine']['trusted']}") + assert wrpc.getbalances()["mine"]["trusted"] < 1 + assert wrpc.getbalances()["mine"]["trusted"] > 0.99 + + mywalletawaretxitem = WalletAwareTxItem( + parent_mock, [], empty_data_folder, txid=txid_selftransfer + ) + + print("INPUTS\n------") + for inp in mywalletawaretxitem.psbt.inputs: + print(str(inp)) + print("\nOUTPUTS\n-------") + print(str(mywalletawaretxitem.psbt.outputs[0])) + print(str(mywalletawaretxitem.psbt.outputs[1])) + + print("\n\nOUTPUT[0] INVESTIGATION\n====================") + assert mywalletawaretxitem.tx.is_segwit + assert type(mywalletawaretxitem.tx.vout[0]) == TransactionOutput + + address = mywalletawaretxitem.tx.vout[0].script_pubkey.address(NETWORKS["regtest"]) + # Core thinks that the address belongs to the wallet + assert wrpc.getaddressinfo(address)["ismine"] + + print( + f"address (via my.tx.vout[0].script_pubkey.address): {mywalletawaretxitem.tx.vout[0].script_pubkey.address(NETWORKS['regtest'])}" + ) + + specter_psbt = mywalletawaretxitem.psbt + embit_psbt = specter_psbt.psbt + + # Some Type Checks + assert type(specter_psbt) == SpecterPSBT + assert type(specter_psbt.psbt) == PSBT + assert type(specter_psbt.psbt.outputs[0]) == OutputScope + assert type(specter_psbt.outputs[0]) == SpecterOutputScope + assert type(specter_psbt.outputs[0].scope) == OutputScope + assert type(specter_psbt.outputs[0].scope.vout) == TransactionOutput + + # The ways to get the addresses ... + address = specter_psbt.outputs[0].address + print(f"address (via specter_psbt.SpecterOutputScope.address): {address}") + assert address == specter_psbt.outputs[0].scope.script_pubkey.address( + NETWORKS["regtest"] + ) + assert address == specter_psbt.outputs[0].scope.vout.script_pubkey.address( + NETWORKS["regtest"] + ) + assert address == embit_psbt.outputs[0].script_pubkey.address(NETWORKS["regtest"]) + + # Let's check the two outputs + # Order is not reliable, so make fixed indexes + if specter_psbt.outputs[0].address == txid_selftransfer_addr: + rcv_idx = 0 + cha_idx = 1 + else: + rcv_idx = 1 + cha_idx = 0 + # The receiving one: + assert ( + specter_psbt.outputs[rcv_idx].scope.vout.value == 10000000 + ) # 0.1 btc == 10 mil sats + assert specter_psbt.outputs[rcv_idx].is_mine + assert specter_psbt.outputs[ + rcv_idx + ].is_receiving # the 0 output is the receiving one (0.1) + assert not specter_psbt.outputs[rcv_idx].is_change + + # The change one: + assert specter_psbt.outputs[cha_idx].scope.vout.value < 90000000 + assert specter_psbt.outputs[cha_idx].is_mine + assert specter_psbt.outputs[cha_idx].is_change + assert not specter_psbt.outputs[cha_idx].is_receiving + + # amounts + assert specter_psbt.inputs[0].float_amount == 1 + assert specter_psbt.outputs[rcv_idx].float_amount == 0.1 + assert specter_psbt.outputs[cha_idx].float_amount >= 0.89 + assert mywalletawaretxitem.flow_amount >= -0.0001 # the wallet lost some fees + assert mywalletawaretxitem.category == "selftransfer" + + print("=========================================") + print("\n\nOutgoing-Transaction (0.2 btc)") + print("=========================================") + + txid_outgoing_addr = "n4MN27Lk7Yh3pwfjCiAbRXtRVjs4Uk67fG" + print(f"address = {txid_outgoing_addr}") + txid_outgoing = wrpc.sendtoaddress(txid_outgoing_addr, 0.2) + print(f"balance: {wrpc.getbalances()['mine']['trusted']}") + assert wrpc.getbalances()["mine"]["trusted"] < 0.8 + + mywalletawaretxitem = WalletAwareTxItem( + parent_mock, [], empty_data_folder, txid=txid_outgoing + ) + + print("INPUTS\n-------") + for inp in mywalletawaretxitem.psbt.inputs: + print(str(inp)) + print("\nOUTPUTS\n------") + for out in mywalletawaretxitem.psbt.outputs: + print(str(out)) + + # Let's check the two outputs + # Order is not reliable, so make fixed indexes + specter_psbt = mywalletawaretxitem.psbt + if specter_psbt.outputs[0].address == txid_outgoing_addr: + snd_idx = 0 + cha_idx = 1 + else: + snd_idx = 1 + cha_idx = 0 + # The sending one: + assert not specter_psbt.outputs[snd_idx].is_mine + assert not specter_psbt.outputs[ + snd_idx + ].is_receiving # the 0 output is the receiving one (0.1) + assert not specter_psbt.outputs[snd_idx].is_change + + # The change one: + assert specter_psbt.outputs[cha_idx].is_mine + assert specter_psbt.outputs[cha_idx].is_change + assert not specter_psbt.outputs[cha_idx].is_receiving + + # amounts + assert specter_psbt.inputs[0].float_amount >= 0.8 + assert specter_psbt.outputs[snd_idx].float_amount == 0.2 + assert specter_psbt.outputs[cha_idx].float_amount >= 0.6 + assert mywalletawaretxitem.flow_amount <= -0.2 # 0.2 wallet lost plus some fees + + +def test_txlist(empty_data_folder, parent_mock, bitcoin_regtest): + # assert funded_hot_wallet_1.rpc() + # Non Empty hotwallet using descriptors + result = bitcoin_regtest.get_rpc().createwallet( + "mywallet_for_test_txlist", False, False, "", False, True + ) + wrpc = bitcoin_regtest.get_rpc().wallet("mywallet_for_test_txlist") + parent_mock = calc_parent_mock(wrpc, parent_mock) + + # so here is the matching descriptor: + descriptor: Descriptor = calc_descriptor(wrpc) + print(f"Descriptor: {descriptor}") + + # mock the property rpc to be wallet rpc + type(parent_mock).rpc = PropertyMock(return_value=wrpc) + + for i in range(0, 10): + bitcoin_regtest.testcoin_faucet(wrpc.getnewaddress(), amount=0.1) - parent_mock.descriptor = Descriptor.from_string(descriptor) - assert type(parent_mock.descriptor.key) == Key - assert parent_mock.descriptor.key.allowed_derivation != None - assert parent_mock.descriptor.to_string() == descriptor filename = os.path.join(empty_data_folder, "my_filename.csv") mytxlist = TxList(filename, parent_mock, MagicMock()) - # mytxlist.descriptor = descriptor - mytxlist.add({tx1_confirmed["txid"]: tx1_confirmed}) - # .add will save implicitely. - # mytxlist._save() - with open(filename, "r+") as file: - # Reading form a file - assert file.readline().startswith( - "txid,blockhash,blockheight,time,blocktime,bip125-replaceable,conflicts,vsize,category,address,amount,ismine" - ) - assert file.readline().startswith( - "42f5c9e826e52cde883cde7a6c7b768db302e0b8b32fc52db75ad3c5711b4a9e,72523c637e0b93505806564495b1acf915a88bacc45f50e35e8a536becd2f914,,1642494258,1642494258,no,[],,receive,Unknown,19.9999989,False" - ) - assert len(mytxlist) == 1 - mytxlist.invalidate(tx1_confirmed["txid"]) - assert len(mytxlist) == 0 - assert not Path(filename).is_file() + print("How do the Txs look like?\n===================") - # Mock rpc-calls - mock_rpc = MagicMock() - mock_rpc.gettransaction.return_value = tx2_confirmed - mock_parent = MagicMock() - mock_parent.rpc = mock_rpc - mytxlist.parent = mock_parent - # mytxlist.getfetch("42f5c9e826e52cde883cde7a6c7b768db302e0b8b32fc52db75ad3c5711b4a9e") + tx_from_listtransactions = wrpc.listtransactions()[-1] + tx = tx_from_listtransactions + print("a transaction as it looks like from listtransaction:") + # print(tx) + print(f" those keys: {sorted(tx.keys())}") + print() + tx_from_gettransaction = wrpc.gettransaction(tx["txid"]) + tx = tx_from_gettransaction + print("A tx from gettransaction") + # print(tx) + print(f" those keys: {sorted(tx.keys())}") + print() + print("A tx from decoderawtransaction") + hex = tx["hex"] + tx_from_decoderawtransaction = wrpc.decoderawtransaction(hex) + tx = tx_from_decoderawtransaction + # print(tx) + print(f" those keys: {sorted(tx.keys())}") + print(tx["vout"]) + # print("So now we create a PSBT out of that: ") + # psbt = SpecterPSBT.from_transaction(hex, parent_mock.descriptor, parent_mock.network) + # print(psbt) + + mytxlist.add({tx["txid"]: tx_from_gettransaction}) + assert type(mytxlist[tx["txid"]]) == WalletAwareTxItem + assert type(mytxlist[tx["txid"]].psbt) == SpecterPSBT + + assert type(mytxlist[tx["txid"]].psbt.outputs[0]) == SpecterOutputScope + assert type(mytxlist[tx["txid"]].psbt.outputs[0].float_amount) == float + assert len(mytxlist[tx["txid"]].psbt.outputs) == 2 + assert ( + mytxlist[tx["txid"]].psbt.outputs[0].is_mine + or mytxlist[tx["txid"]].psbt.outputs[1].is_mine + ) + + assert type(mytxlist[tx["txid"]].psbt.inputs[0]) == SpecterInputScope + print(mytxlist[tx["txid"]].psbt.inputs[0].scope) + assert type(mytxlist[tx["txid"]].psbt.inputs[0].scope) == InputScope + assert type(mytxlist[tx["txid"]].psbt.inputs[0].float_amount) == float + + for tx in mytxlist.values(): + assert tx.flow_amount > 0 # assert False diff --git a/tests/test_util_descriptor.py b/tests/test_util_descriptor.py index 1c6caf945..e6b630351 100644 --- a/tests/test_util_descriptor.py +++ b/tests/test_util_descriptor.py @@ -9,7 +9,7 @@ import pytest def test_parse_descriptor_with_origin(): - desc = Descriptor.parse( + desc: Descriptor = Descriptor.parse( "wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", True, ) @@ -26,6 +26,8 @@ def test_parse_descriptor_with_origin(): assert desc.testnet == True assert desc.m_path_base == "m/84'/1'/0'" assert desc.m_path == "m/84'/1'/0'/0/0" + assert desc.address_type == "wpkh" + assert type(desc.derive(1)) == Descriptor def test_parse_multisig_descriptor_with_origin(): diff --git a/tests/test_wallet.py b/tests/test_wallet.py index faca61533..926a22f93 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -1,4 +1,12 @@ +from asyncio.streams import FlowControlMixin +import random +import time +from typing import List import pytest, logging +from cryptoadvance.specter.util.psbt import SpecterPSBT +from cryptoadvance.specter.commands.psbt_creator import PsbtCreator +from cryptoadvance.specter.txlist import WalletAwareTxItem +from cryptoadvance.specter.device import Device from cryptoadvance.specter.specter import Specter from cryptoadvance.specter.wallet import Wallet @@ -6,10 +14,122 @@ from cryptoadvance.specter.process_controller.bitcoind_controller import ( BitcoindPlainController, ) from cryptoadvance.specter.specter_error import SpecterError +from fix_devices_and_wallets import create_hot_wallet_device, create_hot_segwit_wallet logger = logging.getLogger(__name__) +def send_helper(specter_regtest_configured: Specter, wallet: Wallet, device: Device): + """A small heper method to send some fund with a hot-wallet. Maybe this should be in wallet.py ? + To understand der PSBT-workflow, i like this article: + https://github.com/bitcoin/bitcoin/blob/master/doc/psbt.md + """ + # sending some funds is quite complicated: + request_json = """ + { + "recipients" : [ + { + "address": "bcrt1q3kfetuxpxvujasww6xas94nawklvpz0e52uw8a", + "amount": 0.2, + "unit": "btc", + "label": "someLabel" + } + ], + "rbf_tx_id": "", + "subtract_from": "0", + "fee_rate": "64", + "rbf": true + } + """ + + psbt_creator: PsbtCreator = PsbtCreator( + specter_regtest_configured, wallet, "json", request_json=request_json + ) + + psbt_dict = psbt_creator.create_psbt(wallet) + psbt = psbt_creator.psbt_as_object + + b64psbt = str(psbt_dict["base64"]) + signed_psbt = specter_regtest_configured.device_manager.get_by_alias( + device.alias + ).sign_psbt(b64psbt, wallet, "") + print() + print(f"signed_psbt: {signed_psbt}") + + if signed_psbt["complete"]: + raw = wallet.rpc.finalizepsbt(signed_psbt["psbt"]) + psbt.update(signed_psbt["psbt"], raw) + specter_regtest_configured.broadcast(raw["hex"]) + + +def test_txlist( + bitcoin_regtest: BitcoindPlainController, + specter_regtest_configured: Specter, + hot_wallet_device_1: Device, + hot_ghost_machine_device, + caplog, +): + """this is very similiar of what you can find in fix_devices_and_wallets.py but here you get a failure and nor an error + (Always search for failures first before checking errors) + so this should speed up the fixing process although it's a duplication. + """ + caplog.set_level(logging.ERROR) + logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO) + wallet = create_hot_segwit_wallet( + specter_regtest_configured, + hot_wallet_device_1, + f"a_hotwallet_{random.randint(0, 999999)}", + ) + assert len(wallet.txlist()) == 0 + for i in range(0, 10): + bitcoin_regtest.testcoin_faucet(wallet.getnewaddress(), amount=1) + + # Send some funds somewhere + send_helper(specter_regtest_configured, wallet, hot_wallet_device_1) + + wallet.update() + bitcoin_regtest.get_rpc().generatetoaddress(1, wallet.getnewaddress()) + for i in range(0, 2): + bitcoin_regtest.testcoin_faucet( + wallet.getnewaddress(), + amount=2.5, + confirm_payment=False, + ) + time.sleep(5) # needed for tx to propagate + wallet.update() + + wallet.fetch_transactions() + txlist: List(WalletAwareTxItem) = wallet.txlist() + assert txlist[0].__class__ == WalletAwareTxItem + + tx: WalletAwareTxItem = txlist[0] + psbt = tx.psbt + print(psbt) + + print( + "mine \t category \t flow_amount \t blockh \t time \t conflicts \t #conf \t vsize \t fee" + ) + print("-" * 100) + tx: WalletAwareTxItem + for tx in txlist: + print( + f"{tx.ismine} \t {tx.category:<8} \t {tx.flow_amount: {2}.{3}} \t\t {tx.blockheight} \ +\t {tx.time} \t {tx.conflicts} \t\t {tx.confirmations} \t {tx.vsize} \t {tx['fee']}" + ) + assert tx.ismine + assert tx.category in ["receive", "generate", "send"] + assert tx.flow_amount > 0.9 or tx.flow_amount < 0.2 + assert tx.blockheight is None or tx.blockheight > 200 + assert tx.time > 1673512597 + assert tx.conflicts == None + assert tx.confirmations >= 0 + + # 12 txs + assert len(wallet.txlist()) == 14 + # two of them are unconfirmed + assert len([tx for tx in wallet.txlist() if tx["confirmations"] == 0]) == 2 + + @pytest.mark.slow def test_createpsbt( bitcoin_regtest: BitcoindPlainController, @@ -45,19 +165,20 @@ def test_createpsbt( 1, selected_coins=selected_coin, # Selecting only one UTXO since input ordering seems to also be random in Core. ) - assert len(psbt["tx"]["vin"]) == 1 - assert len(psbt["inputs"]) == 1 + psbt_dict = psbt.to_dict() + assert len(psbt_dict["tx"]["vin"]) == 1 + assert len(psbt_dict["inputs"]) == 1 # Input fields assert ( - psbt["inputs"][0]["bip32_derivs"][0]["pubkey"] + psbt_dict["inputs"][0]["bip32_derivs"][0]["pubkey"] == "0330955ab511845fb48fc5739da551875ed54fa1f2fdd4cf77f3473ce2cffb4c75" ) - assert psbt["inputs"][0]["bip32_derivs"][0]["path"] == "m/84h/1h/0h/0/1" - assert psbt["inputs"][0]["bip32_derivs"][0]["master_fingerprint"] == "8c24a510" + assert psbt_dict["inputs"][0]["bip32_derivs"][0]["path"] == "m/84h/1h/0h/0/1" + assert psbt_dict["inputs"][0]["bip32_derivs"][0]["master_fingerprint"] == "8c24a510" # Output fields - for output in psbt["outputs"]: # The ordering of the outputs is random + for output in psbt_dict["outputs"]: # The ordering of the outputs is random if output["change"] == False: assert output["address"] == "bcrt1q7mlxxdna2e2ufzgalgp5zhtnndl7qddlxjy5eg" else: @@ -82,20 +203,25 @@ def test_createpsbt( 0, 1, ) + psbt_dict = psbt.to_dict() # Input fields - assert psbt["inputs"][0]["taproot_bip32_derivs"][0]["path"] == "m/86h/1h/0h/0/1" assert ( - psbt["inputs"][0]["taproot_bip32_derivs"][0]["master_fingerprint"] == "8c24a510" + psbt_dict["inputs"][0]["taproot_bip32_derivs"][0]["path"] == "m/86h/1h/0h/0/1" ) - assert psbt["inputs"][0]["taproot_bip32_derivs"][0]["leaf_hashes"] == [] + assert ( + psbt_dict["inputs"][0]["taproot_bip32_derivs"][0]["master_fingerprint"] + == "8c24a510" + ) + assert psbt_dict["inputs"][0]["taproot_bip32_derivs"][0]["leaf_hashes"] == [] complete_pubkey = ( "0274fea50d7f2a69489c2d2a146e317e02f47ad032e81b35fe6059e066670a100e" ) assert ( - psbt["inputs"][0]["taproot_bip32_derivs"][0]["pubkey"] == complete_pubkey[2:] + psbt_dict["inputs"][0]["taproot_bip32_derivs"][0]["pubkey"] + == complete_pubkey[2:] ) # The pubkey is "xonly", for details: https://embit.rocks/#/api/ec/public_key?id=xonly # Output fields - for output in psbt["outputs"]: + for output in psbt_dict["outputs"]: if output["change"] == False: assert output["address"] == "bcrt1q7mlxxdna2e2ufzgalgp5zhtnndl7qddlxjy5eg" else: @@ -176,5 +302,5 @@ def test_check_utxo_and_amounts(funded_hot_wallet_1: Wallet): selected_coins=selected_coins, ) assert wallet.amount_locked_unsigned == selected_coins_amount_sum - wallet.delete_pending_psbt(psbt["tx"]["txid"]) + wallet.delete_pending_psbt(psbt.to_dict()["tx"]["txid"]) assert wallet.amount_locked_unsigned == 0 diff --git a/tests/xtestdata_txlist/c518428b318612e60ba8a90ef767a0c6ea0ccf989ed69c3b10b1df537fab850e.bin b/tests/xtestdata_txlist/c518428b318612e60ba8a90ef767a0c6ea0ccf989ed69c3b10b1df537fab850e.bin new file mode 100644 index 000000000..af79b50eb Binary files /dev/null and b/tests/xtestdata_txlist/c518428b318612e60ba8a90ef767a0c6ea0ccf989ed69c3b10b1df537fab850e.bin differ