Bugfix: Clarification of Amounts in tables (fixes #1861) (#2026)

* refactoring categories and amounts + improvements of amounts

* clear cache functionality in wallet settings

* refactoring of wallet settings

* clean up and frontend wallet-refactoring

* some documentation about psbt and embit

* intermediate commit

* moar testing

* proper descriptor

* updated tests

* finalize WalletAwareTxItem Test

* coming closer

* fix tests

* Update src/cryptoadvance/specter/templates/includes/tx-table.html

Co-authored-by: Manolis Mandrapilias <70536101+moneymanolis@users.noreply.github.com>

* Update src/cryptoadvance/specter/templates/includes/tx-table.html

Co-authored-by: Manolis Mandrapilias <70536101+moneymanolis@users.noreply.github.com>

* Update src/cryptoadvance/specter/templates/wallet/settings/wallet_settings.jinja

Co-authored-by: Manolis Mandrapilias <70536101+moneymanolis@users.noreply.github.com>

* Update src/cryptoadvance/specter/templates/wallet/settings/wallet_settings.jinja

Co-authored-by: Manolis Mandrapilias <70536101+moneymanolis@users.noreply.github.com>

* Update src/cryptoadvance/specter/server_endpoints/wallets/wallets.py

Co-authored-by: Manolis Mandrapilias <70536101+moneymanolis@users.noreply.github.com>

* fix cypress

* refactoring  some parts of the psbt-creation-process and wallet.txlist

* fix cypress

* fix broken csv-download

* bugfix confirmations: null

* Bugfix: accidental Speed-up-button on confirmed TXs

* fix test

* fix test (part2)

* Liquid specific fixes

* improve cache usage

* refactoring wallet.check_utxo()

* reimplemented the whole check_utxo method

Co-authored-by: Manolis Mandrapilias <70536101+moneymanolis@users.noreply.github.com>
This commit is contained in:
k9ert 2023-01-20 09:45:40 +01:00 committed by GitHub
parent e92811398f
commit fa9605d6c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1646 additions and 681 deletions

View file

@ -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)
})
})
})
})
}
})

View file

@ -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)
})
})
})
})
}
})

View file

@ -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.
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)

View file

@ -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..."

View file

@ -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"],

View file

@ -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"]

View file

@ -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(

View file

@ -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):

View file

@ -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)

View file

@ -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/<wallet_alias>/")
@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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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 <wallet_alias>.
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/<wallet_alias>/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/<wallet_alias>/settings/importaddresslabels", methods=["GET"]
)
@wallets_endpoint.route(
"/wallet/<wallet_alias>/settings/keypoolrefill", methods=["GET"]
)
@wallets_endpoint.route("/wallet/<wallet_alias>/settings/rescan", methods=["GET"])
@wallets_endpoint.route("/wallet/<wallet_alias>/settings/deletewallet", methods=["GET"])
@wallets_endpoint.route("/wallet/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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",

View file

@ -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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/pending_psbt_list", methods=["GET"])
@login_required
def pending_psbt_list(wallet_alias):
wallet = app.specter.wallet_manager.get_by_alias(wallet_alias)
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/<wallet_alias>/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(

View file

@ -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")

View file

@ -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

View file

@ -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');

View file

@ -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');

View file

@ -188,7 +188,7 @@
<h1 style="font-size: 1.7em;">{{ _("Advanced Options") }}</h1>
<div class="card">
<h2 class="subtitle">{{ _("Import address labels (Electrum or Specter)") }}</h2>
<form action="." method="POST" class="padded" style="max-width: 500px">
<form action="./importaddresslabels" method="POST" class="padded" style="max-width: 500px">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="row aligned">
<dnd-textarea
@ -219,7 +219,7 @@
</form>
<div class="section-separator"></div>
<h2 class="subtitle">{{ _("Keypool control") }}</h2>
<form action="." method="POST" class="padded" style="max-width: 500px">
<form action="./keypoolrefill" method="POST" class="padded" style="max-width: 500px">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<div>{{ _("Currently watching") }} {{ wallet.keypool }} {{ _("receiving and") }} {{ wallet.change_keypool }} {{ _("change addresses.") }}</div>
<div class="row aligned">
@ -229,7 +229,7 @@
</form>
<div class="section-separator"></div>
<h2 class="subtitle" id="blockchain-rescan">{{ _("Blockchain rescan") }}</h2>
<form action="." method="POST" class="padded" style="max-width: 500px">
<form action="./rescan" method="POST" class="padded" style="max-width: 500px">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
{% if wallet.rescan_progress %}
<div class="row aligned">
@ -261,7 +261,7 @@
{% endif %}
{% endif %}
</form>
<form action="." method="POST" class="padded" style="max-width: 500px">
<form action="./rescan" method="POST" class="padded" style="max-width: 500px">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
{% if specter.info["utxorescan"] %}
<div class="row aligned padded">
@ -322,8 +322,26 @@
{% endif %}
</form>
<div class="section-separator"></div>
<h2 class="subtitle">{{ _("Clear Cache") }}</h2>
<div class="row center">
<form action="./clearcache" method="POST" class="padded" style="max-width: 500px">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" name="action" value="clearcache" class="btn" style="max-width: 130px; margin-left: 20px">{{ _("Clear Cache") }}
<tool-tip width="200px">
<span slot="paragraph">
{{ _('Each wallet maintains a cache which stores the details of transactions') }}<br>
{{ _('in order to speed up loading of the tx-list. ') }}<br>
{{ _('Clearing that cache is not deleting any crucial data and possible any time.') }}<br>
{{ _('However, expect a longer loading time after clearing the cache') }}<br><br>
</span>
</tool-tip>
</button>
</form>
</div>
<div class="section-separator"></div>
<h2 class="subtitle">{{ _("Delete wallet") }}</h2>
<form action="." method="POST" class="padded" style="max-width: 500px">
<form action="./deletewallet" method="POST" class="padded" style="max-width: 500px">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" id="delete_wallet" name="action" value="deletewallet" class="btn danger centered" style="max-width: 160px;">{{ _("Delete Wallet") }}</button>
</form>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 (

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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():

View file

@ -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