diff --git a/cypress/integration/spec_elm_single_segwit_wallet.js b/cypress/integration/spec_elm_single_segwit_wallet.js index c37a25ec1..128456a0a 100644 --- a/cypress/integration/spec_elm_single_segwit_wallet.js +++ b/cypress/integration/spec_elm_single_segwit_wallet.js @@ -12,10 +12,10 @@ describe('Operating with an elements singlesig wallet', () => { cy.addHotDevice("Hot Elements Device 1","elements") // Segwit Wallet - cy.addHotWallet("Elm Single Segwit Hot Wallet","elements", "segwit") + cy.addHotWallet("Elm Single Segwit Hot Wallet","Hot Elements Device 1", "elements", "segwit") // Nested Segwit Wallet - cy.addHotWallet("Elm Single Nested Hot Wallet","elements", "nested_segwit") + cy.addHotWallet("Elm Single Nested Hot Wallet","Hot Elements Device 1", "elements", "nested_segwit") }) it('send confidential transaction from segwit', () => { diff --git a/cypress/integration/spec_wallet_utxo.js b/cypress/integration/spec_wallet_utxo.js index 32e85fee2..939debfe4 100644 --- a/cypress/integration/spec_wallet_utxo.js +++ b/cypress/integration/spec_wallet_utxo.js @@ -1,26 +1,41 @@ describe('Send transactions from wallets', () => { it('Freeze and unfreeze UTXO', () => { + const name = "UTXO Hot Bitcoin3" + const wallet_name = name+" wallet" + var wallet_name_ref = wallet_name.toLowerCase().replace(/ /g,"_") cy.viewport(1200,660) - cy.task("btc:mine") - cy.wait(10000) - cy.task("btc:mine") - cy.wait(10000) - cy.task("btc:mine") - cy.wait(10000) + cy.visit('/') + cy.addHotDevice(name+" device","bitcoin") + cy.addHotWallet(wallet_name,name+" device", "bitcoin", "segwit") + cy.get('#fullbalance_amount').then(($div) => { + const balance = parseFloat($div.text()) + if ( balance <= 20) { + cy.log("balance " + balance + " too low. Mining!") + cy.mine2wallet("btc") + cy.mine2wallet("btc") + cy.mine2wallet("btc") + } + }) + + cy.contains(wallet_name).click() - cy.visit('/wallets/wallet/test_hot_wallet_1/history') - - cy.wait(1000) - cy.get('tx-table').shadow().find('.utxo-view-btn').click() + // The table as component is only available through the shadow tree + // That's why we have this stupid .shadow() ... + cy.get('tx-table').shadow().find('.utxo-view-btn').click({ force: true }) + cy.log("Check that nothis is frozen") cy.get('tx-table').shadow().find('tx-row').each(($el, index, $list) => { cy.wrap($el).shadow().find('.tx-row').should('not.have.class', 'frozen') cy.wrap($el).shadow().find('.frozen-img').should('have.class', 'hidden') }) - // Freeze the UTXO + + cy.log("First select it, then freeze it") cy.get('tx-table').shadow().find('tx-row').eq(0).shadow().find('.select-tx-img').click() + cy.wait(100) + // then click the freeze-button cy.get('tx-table').shadow().find('.freeze-tx-btn').click() + cy.wait(100) cy.get('tx-table').shadow().find('tx-row').each(($el, index, $list) => { if (index == 0) { @@ -33,6 +48,7 @@ describe('Send transactions from wallets', () => { }) // Test freeze UTXO can't be spend, and unfreeze works for coin selection option + cy.log("Select 3 UTXOs and freeze them") cy.get('tx-table').shadow().find('tx-row').eq(0).shadow().find('.select-tx-img').click() cy.get('tx-table').shadow().find('tx-row').eq(1).shadow().find('.select-tx-img').click() cy.get('tx-table').shadow().find('tx-row').eq(3).shadow().find('.select-tx-img').click() diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 9d7e5f8f3..06bbb61da 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -24,6 +24,8 @@ // -- This will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) +import 'cypress-wait-until'; + Cypress.Commands.add("addDevice", (name) => { cy.get('body').then(($body) => { if ($body.text().includes(name)) { @@ -61,6 +63,8 @@ Cypress.Commands.add("addHotDevice", (name, node_type) => { var refName = "#device_list_item_"+name.toLowerCase().replace(/ /g,"_") cy.get(refName).click() cy.get('#forget_device').click() + // We might get an error here, if the device is used in a wallet + // We assume therefore that this is ok (see below) } cy.get('#side-content').click() cy.get('#btn_new_device').click() @@ -69,17 +73,22 @@ Cypress.Commands.add("addHotDevice", (name, node_type) => { cy.get('#submit-mnemonic').click() cy.get('#device_name').type(name) cy.get('#submit-keys').click() + // It's a bit hackish as if the device already exists, we'll get an error + // but continue flaslessly nevertheless cy.get('#devices_list > .item > div', { timeout: 8000 }).contains(name) }) }) -Cypress.Commands.add("addHotWallet", (name, node_type, wallet_type, single_multi) => { +Cypress.Commands.add("addHotWallet", (wallet_name, device_name, node_type, wallet_type, single_multi) => { if (wallet_type == null) { wallet_type = "segwit" } + if (device_name == null) { + device_name = "Hot Elements Device 1" + } cy.get('body').then(($body) => { - if ($body.text().includes(name)) { - cy.contains(name).click() + if ($body.text().includes(wallet_name)) { + cy.contains(wallet_name).click() cy.get('#btn_settings' ).click( {force: true}) cy.get('#advanced_settings_tab_btn').click() cy.get('#delete_wallet').click() @@ -89,8 +98,9 @@ Cypress.Commands.add("addHotWallet", (name, node_type, wallet_type, single_multi cy.get('#btn_new_wallet').click() cy.get('[href="./simple/"]').click() - cy.get('#hot_elements_device_1').click() - cy.get('#wallet_name').type(name) + var device_button = "#"+device_name.toLowerCase().replace(/ /g,"_") + cy.get(device_button).click() + cy.get('#wallet_name').type(wallet_name) if (wallet_type == "nested_segwit") { cy.get(':nth-child(1) > #type_nested_segwit_btn').click() } @@ -103,7 +113,7 @@ Cypress.Commands.add("addHotWallet", (name, node_type, wallet_type, single_multi cy.get('#btn_continue').click() //Get some funds - cy.mine2wallet("elm") + cy.mine2wallet(node_type) }) }) @@ -127,21 +137,23 @@ Cypress.Commands.add("mine2wallet", (chain) => { cy.get('#btn_transactions').click() cy.get('#fullbalance_amount').then(($div) => { const oldBalance = parseFloat($div.text()) - if (chain=="elm") { + if (chain=="elm" || chain=="elements") { cy.task("elm:mine") - } else if (chain=="btc") { + } else if (chain=="btc" || chain=="bitcoin") { cy.task("btc:mine") } else { throw new Error("Unknown chain: " + chain) } - cy.wait(15000) - cy.reload() - cy.get('#fullbalance_amount') // Wait 5 secs + 15 secs timeout - .should(($div) => { + cy.waitUntil( () => cy.reload().get('#fullbalance_amount', { timeout: 3000 }) + .then(($div) => { const n = parseFloat($div.text()) - expect(n).to.be.gt(oldBalance) - } - ) + return n > oldBalance + }) + , { + errorMsg: 'Waited for the funds arriving in the wallet from chain mining but it never did (timeout 30s) ', + timeout: 30000, + interval: 2000 + }) }) }) diff --git a/package-lock.json b/package-lock.json index 5c8eaa85c..0bdf834dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,8 +1,2059 @@ { "name": "specter-desktop-cypress-testing", "version": "0.0.1", - "lockfileVersion": 1, + "lockfileVersion": 2, "requires": true, + "packages": { + "": { + "name": "specter-desktop-cypress-testing", + "version": "0.0.1", + "license": "ISC", + "dependencies": { + "cypress": "^7.1.0", + "rimraf": "^3.0.2", + "wait-on": "^5.3.0" + }, + "devDependencies": { + "cypress-wait-until": "^1.7.1" + } + }, + "node_modules/@cypress/listr-verbose-renderer": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz", + "integrity": "sha1-p3SS9LEdzHxEajSz4ochr9M8ZCo=", + "dependencies": { + "chalk": "^1.1.3", + "cli-cursor": "^1.0.2", + "date-fns": "^1.27.2", + "figures": "^1.7.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@cypress/listr-verbose-renderer/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@cypress/listr-verbose-renderer/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@cypress/request": { + "version": "2.88.5", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.5.tgz", + "integrity": "sha512-TzEC1XMi1hJkywWpRfD2clreTa/Z+lOrXDCxxBTBPEcY5azdPi56A6Xw+O4tWJnaJH3iIE7G5aDXZC6JgRZLcA==", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@cypress/xvfb": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", + "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", + "dependencies": { + "debug": "^3.1.0", + "lodash.once": "^4.1.1" + } + }, + "node_modules/@cypress/xvfb/node_modules/debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.1.0.tgz", + "integrity": "sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw==" + }, + "node_modules/@hapi/topo": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.0.0.tgz", + "integrity": "sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@samverschueren/stream-to-observable": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz", + "integrity": "sha512-c/qwwcHyafOQuVQJj0IlBjf5yYgBI7YPJ77k4fOJYesb41jio65eaJODRUmfYKhTOFBrIZ66kgvGPlNbjuoRdQ==", + "dependencies": { + "any-observable": "^0.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sideway/address": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.0.tgz", + "integrity": "sha512-wAH/JYRXeIFQRsxerIuLjgUu2Xszam+O5xKeatJ4oudShOOirfmsQ1D6LL54XOU2tizpCYku+s1wmU0SYdpoSA==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.0.tgz", + "integrity": "sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, + "node_modules/@types/node": { + "version": "14.14.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.37.tgz", + "integrity": "sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw==" + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz", + "integrity": "sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg==" + }, + "node_modules/@types/sizzle": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz", + "integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==" + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "node_modules/ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/any-observable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz", + "integrity": "sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog==", + "engines": { + "node": ">=6" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==" + }, + "node_modules/asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", + "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", + "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" + }, + "node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, + "node_modules/axios/node_modules/follow-redirects": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz", + "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/blob-util": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", + "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "node_modules/cachedir": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz", + "integrity": "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "node_modules/chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-more-types": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", + "integrity": "sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ci-info": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.1.1.tgz", + "integrity": "sha512-kdRWLBIJwdsYJWYJFtAFFYxybguqeF91qpZaggjG5Nf8QKdizFG2hjqvaTXbxFIcYbSaD74KpAXv6BSm17DHEQ==" + }, + "node_modules/cli-cursor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", + "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", + "dependencies": { + "restore-cursor": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cli-table3": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.0.tgz", + "integrity": "sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ==", + "dependencies": { + "object-assign": "^4.1.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "colors": "^1.1.2" + } + }, + "node_modules/cli-truncate": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-0.2.1.tgz", + "integrity": "sha1-nxXPuwcFAFNpIWxiasfQWrkN1XQ=", + "dependencies": { + "slice-ansi": "0.0.4", + "string-width": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/common-tags": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz", + "integrity": "sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cypress": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-7.1.0.tgz", + "integrity": "sha512-AptQP9fVtN/FfOv8rJ9hTGJE2XQFc8saLHT38r/EeyWhzp0q/+P/DYRTDtjGZHeLTCNznAUrT4lal8jm+ouS7Q==", + "dependencies": { + "@cypress/listr-verbose-renderer": "^0.4.1", + "@cypress/request": "^2.88.5", + "@cypress/xvfb": "^1.2.4", + "@types/node": "^14.14.31", + "@types/sinonjs__fake-timers": "^6.0.2", + "@types/sizzle": "^2.3.2", + "arch": "^2.2.0", + "blob-util": "^2.0.2", + "bluebird": "^3.7.2", + "cachedir": "^2.3.0", + "chalk": "^4.1.0", + "check-more-types": "^2.24.0", + "cli-table3": "~0.6.0", + "commander": "^5.1.0", + "common-tags": "^1.8.0", + "dayjs": "^1.10.4", + "debug": "4.3.2", + "eventemitter2": "^6.4.3", + "execa": "4.1.0", + "executable": "^4.1.1", + "extract-zip": "^1.7.0", + "fs-extra": "^9.1.0", + "getos": "^3.2.1", + "is-ci": "^3.0.0", + "is-installed-globally": "~0.4.0", + "lazy-ass": "^1.6.0", + "listr": "^0.14.3", + "lodash": "^4.17.21", + "log-symbols": "^4.0.0", + "minimist": "^1.2.5", + "ospath": "^1.2.2", + "pretty-bytes": "^5.6.0", + "ramda": "~0.27.1", + "request-progress": "^3.0.0", + "supports-color": "^8.1.1", + "tmp": "~0.2.1", + "untildify": "^4.0.0", + "url": "^0.11.0", + "yauzl": "^2.10.0" + }, + "bin": { + "cypress": "bin/cypress" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cypress-wait-until": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/cypress-wait-until/-/cypress-wait-until-1.7.1.tgz", + "integrity": "sha512-8DL5IsBTbAxBjfYgCzdbohPq/bY+IKc63fxtso1C8RWhLnQkZbVESyaclNr76jyxfId6uyzX8+Xnt0ZwaXNtkA==", + "dev": true + }, + "node_modules/cypress/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/date-fns": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", + "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" + }, + "node_modules/dayjs": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz", + "integrity": "sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw==" + }, + "node_modules/debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/elegant-spinner": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz", + "integrity": "sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eventemitter2": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz", + "integrity": "sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ==" + }, + "node_modules/execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/executable": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", + "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", + "dependencies": { + "pify": "^2.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/exit-hook": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", + "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/extract-zip": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", + "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "dependencies": { + "concat-stream": "^1.6.2", + "debug": "^2.6.9", + "mkdirp": "^0.5.4", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + } + }, + "node_modules/extract-zip/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/extract-zip/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "engines": [ + "node >=0.6.0" + ] + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/figures": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", + "dependencies": { + "escape-string-regexp": "^1.0.5", + "object-assign": "^4.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/getos": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", + "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", + "dependencies": { + "async": "^3.2.0" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-dirs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz", + "integrity": "sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==", + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", + "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==" + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "engines": { + "node": ">=8.12.0" + } + }, + "node_modules/indent-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", + "integrity": "sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok=", + "engines": { + "node": ">=4" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/is-ci": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.0.tgz", + "integrity": "sha512-kDXyttuLeslKAHYL/K28F2YkM3x5jvFPEw3yXbRptXydjD9rpLEz+C5K5iutY9ZiUu6AP41JdvRQwF4Iqs4ZCQ==", + "dependencies": { + "ci-info": "^3.1.1" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-observable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-1.1.0.tgz", + "integrity": "sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA==", + "dependencies": { + "symbol-observable": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, + "node_modules/is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "node_modules/joi": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.3.0.tgz", + "integrity": "sha512-Qh5gdU6niuYbUIUV5ejbsMiiFmBdw8Kcp8Buj2JntszCkCfxJ9Cz76OtHxOZMPXrt5810iDIXs+n1nNVoquHgg==", + "dependencies": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.0", + "@sideway/formula": "^3.0.0", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" + }, + "node_modules/json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "node_modules/jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "node_modules/lazy-ass": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", + "integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=", + "engines": { + "node": "> 0.8" + } + }, + "node_modules/listr": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz", + "integrity": "sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA==", + "dependencies": { + "@samverschueren/stream-to-observable": "^0.3.0", + "is-observable": "^1.1.0", + "is-promise": "^2.1.0", + "is-stream": "^1.1.0", + "listr-silent-renderer": "^1.1.1", + "listr-update-renderer": "^0.5.0", + "listr-verbose-renderer": "^0.5.0", + "p-map": "^2.0.0", + "rxjs": "^6.3.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/listr-silent-renderer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz", + "integrity": "sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4=", + "engines": { + "node": ">=4" + } + }, + "node_modules/listr-update-renderer": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz", + "integrity": "sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA==", + "dependencies": { + "chalk": "^1.1.3", + "cli-truncate": "^0.2.1", + "elegant-spinner": "^1.0.1", + "figures": "^1.7.0", + "indent-string": "^3.0.0", + "log-symbols": "^1.0.2", + "log-update": "^2.3.0", + "strip-ansi": "^3.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/listr-update-renderer/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/listr-update-renderer/node_modules/log-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", + "integrity": "sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=", + "dependencies": { + "chalk": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/listr-update-renderer/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/listr-verbose-renderer": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz", + "integrity": "sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw==", + "dependencies": { + "chalk": "^2.4.1", + "cli-cursor": "^2.1.0", + "date-fns": "^1.27.2", + "figures": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/listr-verbose-renderer/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/listr-verbose-renderer/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/listr-verbose-renderer/node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/listr-verbose-renderer/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/listr-verbose-renderer/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "node_modules/listr-verbose-renderer/node_modules/figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/listr-verbose-renderer/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "engines": { + "node": ">=4" + } + }, + "node_modules/listr-verbose-renderer/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/listr-verbose-renderer/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/listr-verbose-renderer/node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/listr-verbose-renderer/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/listr/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, + "node_modules/log-symbols": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz", + "integrity": "sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==", + "dependencies": { + "chalk": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/log-update": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-2.3.0.tgz", + "integrity": "sha1-iDKP19HOeTiykoN0bwsbwSayRwg=", + "dependencies": { + "ansi-escapes": "^3.0.0", + "cli-cursor": "^2.0.0", + "wrap-ansi": "^3.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, + "node_modules/mime-db": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", + "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "dependencies": { + "mime-db": "1.44.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "node_modules/mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "engines": { + "node": "*" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ospath": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", + "integrity": "sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs=" + }, + "node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/psl": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/ramda": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", + "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==" + }, + "node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/request-progress": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", + "integrity": "sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4=", + "dependencies": { + "throttleit": "^1.0.0" + } + }, + "node_modules/restore-cursor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", + "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", + "dependencies": { + "exit-hook": "^1.0.0", + "onetime": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rxjs": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz", + "integrity": "sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==", + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "npm": ">=2.0.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" + }, + "node_modules/slice-ansi": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", + "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sshpk": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dependencies": { + "ansi-regex": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/throttleit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz", + "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=" + }, + "node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, + "node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/uri-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.0.tgz", + "integrity": "sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g==", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "dependencies": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "node_modules/url/node_modules/punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/wait-on": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-5.3.0.tgz", + "integrity": "sha512-DwrHrnTK+/0QFaB9a8Ol5Lna3k7WvUR4jzSKmz0YaPBpuN2sACyiPVKVfj6ejnjcajAcvn3wlbTyMIn9AZouOg==", + "dependencies": { + "axios": "^0.21.1", + "joi": "^17.3.0", + "lodash": "^4.17.21", + "minimist": "^1.2.5", + "rxjs": "^6.6.3" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-3.0.1.tgz", + "integrity": "sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo=", + "dependencies": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "engines": { + "node": ">=4" + } + }, + "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "engines": { + "node": ">=4" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dependencies": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + }, "dependencies": { "@cypress/listr-verbose-renderer": { "version": "0.4.1", @@ -480,6 +2531,12 @@ } } }, + "cypress-wait-until": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/cypress-wait-until/-/cypress-wait-until-1.7.1.tgz", + "integrity": "sha512-8DL5IsBTbAxBjfYgCzdbohPq/bY+IKc63fxtso1C8RWhLnQkZbVESyaclNr76jyxfId6uyzX8+Xnt0ZwaXNtkA==", + "dev": true + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -1411,6 +3468,21 @@ "tweetnacl": "~0.14.0" } }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, "string-width": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", @@ -1436,21 +3508,6 @@ } } }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "requires": { - "safe-buffer": "~5.1.0" - }, - "dependencies": { - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - } - } - }, "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", diff --git a/package.json b/package.json index 3be9f3162..f529e3b5b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "rimraf": "^3.0.2", "wait-on": "^5.3.0" }, - "devDependencies": {}, + "devDependencies": { + "cypress-wait-until": "^1.7.1" + }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, diff --git a/requirements.in b/requirements.in index a747e0ccd..4cb3a8ee9 100644 --- a/requirements.in +++ b/requirements.in @@ -16,7 +16,7 @@ requests==2.25.0 pysocks==1.7.1 six==1.12.0 stem==1.8.0 -embit==0.4.9 +embit==0.4.10 psutil==5.7.3 pyopenssl==20.0.1 flask_wtf==0.14.3 diff --git a/requirements.txt b/requirements.txt index 8011d0e75..e4e5a6fc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -108,8 +108,8 @@ ecdsa==0.17.0 \ --hash=sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676 \ --hash=sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa \ # via bitbox02, hwi -embit==0.4.9 \ - --hash=sha256:992332bd89af6e2d027e26fe437eb14aa33997db08c882c49064d49c3e6f4ab9 \ +embit==0.4.10 \ + --hash=sha256:f6484bc495b45da27f3eb7fbe21a24c00cd72c0ab83c6e195660cf17db5cb5e2 \ # via -r requirements.in flask-babel==2.0.0 \ --hash=sha256:e6820a052a8d344e178cdd36dd4bb8aea09b4bda3d5f9fa9f008df2c7f2f5468 \ diff --git a/src/cryptoadvance/specter/addresslist.py b/src/cryptoadvance/specter/addresslist.py index 7fb4d56fc..06a03b905 100644 --- a/src/cryptoadvance/specter/addresslist.py +++ b/src/cryptoadvance/specter/addresslist.py @@ -53,11 +53,17 @@ class Address(dict): def is_external(self): return self.index is None + @property + def is_mine(self): + return not self.is_external + @property def is_receiving(self): - # change can be True, False or None - # None means it's external - return not self.is_external and not self.change + return self.is_mine and not self.change + + @property + def is_change(self): + return self.is_mine and self.change @property def index(self): @@ -206,7 +212,11 @@ class AddressList(dict): return max( 0, 0, - *[addr.index for addr in self.values() if addr.change == change], + *[ + addr.index or 0 + for addr in self.values() + if addr.is_mine and addr.change == change + ], ) def max_used_index(self, change=False): @@ -214,9 +224,9 @@ class AddressList(dict): -1, -1, *[ - addr.index + addr.index or -1 for addr in self.values() - if addr.used and addr.change == change + if addr.is_mine and addr.used and addr.change == change ], ) diff --git a/src/cryptoadvance/specter/api/rest/resource_psbt.py b/src/cryptoadvance/specter/api/rest/resource_psbt.py index cf171e058..19dd01a9e 100644 --- a/src/cryptoadvance/specter/api/rest/resource_psbt.py +++ b/src/cryptoadvance/specter/api/rest/resource_psbt.py @@ -25,8 +25,8 @@ class ResourcePsbt(SecureResource): wallet: Wallet = app.specter.user_manager.get_user( user ).wallet_manager.get_by_alias(wallet_alias) - pending_psbts = wallet.pending_psbts - return {"result": pending_psbts or []} + pending_psbts = wallet.pending_psbts_dict() + return {"result": pending_psbts or {}} def post(self, wallet_alias): user = auth.current_user() @@ -37,5 +37,5 @@ class ResourcePsbt(SecureResource): psbt_creator = PsbtCreator( app.specter, wallet, "json", request_json=request.json ) - psbt_creator.create_psbt(wallet) - return {"result": psbt_creator.psbt} + psbt = psbt_creator.create_psbt(wallet) + return {"result": psbt} diff --git a/src/cryptoadvance/specter/devices/bitcoin_core.py b/src/cryptoadvance/specter/devices/bitcoin_core.py index b2660f591..2efaec160 100644 --- a/src/cryptoadvance/specter/devices/bitcoin_core.py +++ b/src/cryptoadvance/specter/devices/bitcoin_core.py @@ -24,6 +24,9 @@ class BitcoinCore(Device): hot_wallet = True taproot_support = True + # default sighash to use + SIGHASH = "ALL" + def __init__(self, *args, **kwargs): self._use_descriptors = None super().__init__(*args, **kwargs) @@ -212,7 +215,7 @@ class BitcoinCore(Device): ) if file_password: rpc.walletpassphrase(file_password, 60) - signed_psbt = rpc.walletprocesspsbt(base64_psbt) + signed_psbt = rpc.walletprocesspsbt(base64_psbt, True, self.SIGHASH) if base64_psbt == signed_psbt["psbt"]: raise Exception( "Make sure you have entered the wallet file password correctly. (If your wallet is not encrypted submit empty password)" diff --git a/src/cryptoadvance/specter/devices/elements_core.py b/src/cryptoadvance/specter/devices/elements_core.py index 64c10558b..0e38e397d 100644 --- a/src/cryptoadvance/specter/devices/elements_core.py +++ b/src/cryptoadvance/specter/devices/elements_core.py @@ -16,6 +16,9 @@ class ElementsCore(BitcoinCore): bitcoin_core_support = False liquid_support = True + # default sighash to use + SIGHASH = "ALL|RANGEPROOF" + def add_hot_wallet_keys( self, mnemonic, diff --git a/src/cryptoadvance/specter/devices/generic.py b/src/cryptoadvance/specter/devices/generic.py index 8491779eb..8ee617525 100644 --- a/src/cryptoadvance/specter/devices/generic.py +++ b/src/cryptoadvance/specter/devices/generic.py @@ -1,6 +1,5 @@ from . import DeviceTypes from ..device import Device -from ..liquid.util.pset import to_canonical_pset class GenericDevice(Device): @@ -13,7 +12,6 @@ class GenericDevice(Device): taproot_support = True def create_psbts(self, base64_psbt, wallet): - base64_psbt = to_canonical_pset(base64_psbt) # in QR codes keep only xpubs qr_psbt = wallet.fill_psbt(base64_psbt, non_witness=False, xpubs=True) # in SD card put as much as possible diff --git a/src/cryptoadvance/specter/helpers.py b/src/cryptoadvance/specter/helpers.py index edfdab5ef..bfc41d367 100644 --- a/src/cryptoadvance/specter/helpers.py +++ b/src/cryptoadvance/specter/helpers.py @@ -32,10 +32,7 @@ EMOJIS = "😀😃😄😁😆😅😂🤣😊😇🙂🙃😉😌😍😘😗 def get_asset_label(asset, known_assets={}): # TODO: lookup in the registry - if ( - asset is None - or asset == "0000000000000000000000000000000000000000000000000000000000000000" - ): + if asset in [None, "00" * 32, "ff" * 32]: return "???" if asset == "bitcoin": return "LBTC" @@ -345,4 +342,4 @@ def get_address_from_dict(data_dict): addr = data_dict.get("address") if addr and addr != "Fee": return addr - raise RuntimeError("Missing address info in object") + raise RuntimeError(f"Missing address info in object {data_dict}") diff --git a/src/cryptoadvance/specter/key.py b/src/cryptoadvance/specter/key.py index f8e3a355b..2f24c06ae 100644 --- a/src/cryptoadvance/specter/key.py +++ b/src/cryptoadvance/specter/key.py @@ -2,7 +2,7 @@ from collections import OrderedDict from binascii import hexlify from embit import base58 from .util.xpub import get_xpub_fingerprint - +from embit.descriptor import Key as DescriptorKey purposes = OrderedDict( { diff --git a/src/cryptoadvance/specter/liquid/addresslist.py b/src/cryptoadvance/specter/liquid/addresslist.py index e78971924..7c37c958e 100644 --- a/src/cryptoadvance/specter/liquid/addresslist.py +++ b/src/cryptoadvance/specter/liquid/addresslist.py @@ -26,7 +26,7 @@ class LAddressList(AddressList): self._update_scripts() def _update_scripts(self): - for addr in self: + for addr in list(self.keys()): sc, _ = addr_decode(addr) if sc and sc not in self._scripts: self._scripts[sc] = super().__getitem__(addr) @@ -39,9 +39,12 @@ class LAddressList(AddressList): def __contains__(self, addr): """finds address by confidential or unconfidential address by converting to scriptpubkey""" - sc, _ = addr_decode(addr) - if sc and self._scripts.__contains__(sc): - return True + try: # can fail if addr is "Fee", "Dummy" or hex-scriptpubkey + sc, _ = addr_decode(addr) + if sc and self._scripts.__contains__(sc): + return True + except: + pass return super().__contains__(addr) def __getitem__(self, addr): diff --git a/src/cryptoadvance/specter/liquid/rpc.py b/src/cryptoadvance/specter/liquid/rpc.py index 5a18cb86a..28eac53c2 100644 --- a/src/cryptoadvance/specter/liquid/rpc.py +++ b/src/cryptoadvance/specter/liquid/rpc.py @@ -13,7 +13,6 @@ from embit.liquid import slip77 from embit.psbt import read_string import copy from io import BytesIO - from .util.pset import to_canonical_pset import logging @@ -179,6 +178,23 @@ class LiquidRPC(BitcoinRPC): inputs, outputs, locktime, options, *args, **kwargs ) psbt = res.get("psbt", None) + + # remove zero-output (bug in Elements) + # TODO: remove after release + if psbt: + try: + tx = PSET.from_string(psbt) + # check if there are zero outputs + has_zero = len([out for out in tx.outputs if out.value == 0]) > 0 + has_blinded = any([out.blinding_pubkey for out in tx.outputs]) + logger.error(has_zer, has_blinded) + if has_blinded and has_zero: + tx.outputs = [out for out in tx.outputs if out.value > 0] + psbt = str(tx) + res["psbt"] = psbt + except: + pass + # replace change addresses from the transactions if we can if change_addresses and psbt: try: @@ -230,6 +246,9 @@ class LiquidRPC(BitcoinRPC): # check that change is also blinded - fixes a bug in pset branch tx = PSET.from_string(psbt) changepos = res.get("changepos", None) + # no change output + if changepos < 0: + changepos = None # generate all blinding stuff ourselves in deterministic way tx.unblind( @@ -300,9 +319,7 @@ class LiquidRPC(BitcoinRPC): tx.xpubs.update(t2.xpubs) tx.unknown.update(t2.unknown) - for i in range(len(tx.inputs)): - inp1 = tx.inputs[i] - inp2 = t2.inputs[i] + for inp1, inp2 in zip(tx.inputs, t2.inputs): inp1.value = inp1.value or inp2.value inp1.value_blinding_factor = ( inp1.value_blinding_factor or inp2.value_blinding_factor @@ -327,9 +344,7 @@ class LiquidRPC(BitcoinRPC): inp1.unknown.update(inp2.unknown) inp1.range_proof = inp1.range_proof or inp2.range_proof - for i in range(len(tx.outputs)): - out1 = tx.outputs[i] - out2 = t2.outputs[i] + for out1, out2 in zip(tx.outputs, t2.outputs): out1.value_commitment = out1.value_commitment or out2.value_commitment out1.value_blinding_factor = ( out1.value_blinding_factor or out2.value_blinding_factor @@ -353,66 +368,6 @@ class LiquidRPC(BitcoinRPC): out1.unknown.update(out2.unknown) return str(tx) - def decodepsbt(self, b64psbt, *args, **kwargs): - tx = PSET.from_string(b64psbt) - # pre-processing of the transaction - # so Elements Core doesn't complain - inputs = [ - (inp.value or inp.utxo.value, inp.asset or inp.utxo.asset) - for inp in tx.inputs - ] - for inp in tx.inputs: - inp.value = None - inp.asset = None - inp.value_blinding_factor = None - inp.asset_blinding_factor = None - - for out in tx.outputs: - if out.asset and out.value: - # out.asset = None - out.asset_blinding_factor = None - # out.value = None - out.value_blinding_factor = None - out.asset_commitment = None - out.value_commitment = None - out.range_proof = None - out.surjection_proof = None - out.ecdh_pubkey = None - b64psbt = str(tx) - - decoded = super().__getattr__("decodepsbt")(b64psbt, *args, **kwargs) - # pset branch - no fee and global tx fields... - if "fees" in decoded and "bitcoin" in decoded["fees"]: - decoded["fee"] = decoded["fees"]["bitcoin"] - if "tx" not in decoded or "fee" not in decoded: - pset = PSET.from_string(b64psbt) - if "tx" not in decoded: - decoded["tx"] = self.decoderawtransaction(str(pset.tx)) - if "fee" not in decoded: - decoded["fee"] = pset.fee() * 1e-8 - for out in decoded["outputs"]: - if "value" not in out: - out["value"] = -1 - for out in decoded["tx"]["vout"]: - if "value" not in out: - out["value"] = -1 - for i, (v, a) in enumerate(inputs): - inp = decoded["tx"]["vin"][i] # old psbt - inp2 = decoded["inputs"][i] # new psbt - if "utxo_rangeproof" in inp2: - inp2.pop("utxo_rangeproof") - a = bytes(reversed(a[-32:])).hex() - v = round(v * 1e-8, 8) - if "value" not in inp: - inp["value"] = v - if "asset" not in inp: - inp["asset"] = a - if "value" not in inp2: - inp2["value"] = v - if "asset" not in inp2: - inp2["asset"] = a - return decoded - def decoderawtransaction(self, tx): blinded = super().__getattr__("decoderawtransaction")(tx) try: diff --git a/src/cryptoadvance/specter/liquid/txlist.py b/src/cryptoadvance/specter/liquid/txlist.py index 683a7d199..b6dd6c580 100644 --- a/src/cryptoadvance/specter/liquid/txlist.py +++ b/src/cryptoadvance/specter/liquid/txlist.py @@ -1,5 +1,12 @@ from ..txlist import * -from embit.liquid.transaction import LTransaction +from embit.liquid.transaction import LTransaction, TxOutWitness, unblind +from embit.liquid.pset import PSET +from embit.liquid import slip77 +from embit.hashes import tagged_hash +from embit.ec import PrivateKey +from .util.pset import SpecterLTx, get_value, get_asset, SpecterPSET +from io import BytesIO +from embit.psbt import read_string class LTxItem(TxItem): @@ -33,6 +40,112 @@ class LTxItem(TxItem): bool, ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # unblind what's blinded and remove + # will fill automatically + self.vsize + self._unblind() + + def _unblind(self): + if not self.descriptor.is_blinded: + return + + b = self.tx + + mbpk = self.descriptor.blinding_key.key + net = self.network + + values = [0 for out in b.vout] + assets = [b"\xFF" * 32 for out in b.vout] + datas = [] + # search for datas encoded in rangeproofs + for i, out in enumerate(b.vout): + # unblinded + if isinstance(out.value, int): + values[i] = out.value + assets[i] = out.asset + continue + + pk = slip77.blinding_key(mbpk, out.script_pubkey) + try: + res = out.unblind(pk.secret, message_length=1000) + value, asset, vbf, abf, extra, *_ = res + if len(extra.rstrip(b"\x00")) > 0: + datas.append(extra) + values[i] = value + assets[i] = asset + except Exception as e: + logger.warn(e) # TODO: remove, it's ok + pass + + # to calculate blinding seed + tx = PSET(b) + seed = tagged_hash("liquid/blinding_seed", mbpk.secret) + txseed = tx.txseed(seed) + pubkeys = {} + + for extra in datas: + s = BytesIO(extra) + while True: + k = read_string(s) + if len(k) == 0: + break + v = read_string(s) + if k[0] == 1 and len(k) == 5: + idx = int.from_bytes(k[1:], "little") + pubkeys[idx] = v + elif k == b"\x01\x00": + txseed = v + + for i, out in enumerate(b.vout): + if out.witness.range_proof.is_empty: + continue + if i in pubkeys and len(pubkeys[i]) in [33, 65]: + nonce = tagged_hash( + "liquid/range_proof", txseed + i.to_bytes(4, "little") + ) + if out.ecdh_pubkey == PrivateKey(nonce).sec(): + try: + res = unblind( + pubkeys[i], + nonce, + out.witness.range_proof.data, + out.value, + out.asset, + out.script_pubkey, + ) + value, asset, vbf, abf, extra, min_value, max_value = res + assets[i] = asset + values[i] = value + except Exception as e: + logger.warn(f"Failed at unblinding output {i}: {e}") + else: + logger.warn(f"Failed at unblinding: {e}") + + for i, out in enumerate(b.vout): + out.asset = assets[i] + out.value = values[i] + out.witness = TxOutWitness() + + @property + def vsize(self): + if self.get("vsize"): + return self["vsize"] + tx = self.tx + txsize = len(tx.serialize()) + # tx size - flag - marker - witness + non_witness_size = ( + txsize + - 2 + - sum([len(inp.witness.serialize()) for inp in tx.vin]) + - sum([len(out.witness.serialize()) for out in tx.vout]) + ) + witness_size = txsize - non_witness_size + weight = non_witness_size * 4 + witness_size + vsize = math.ceil(weight / 4) + return vsize + def __dict__(self): return { "txid": self["txid"], @@ -52,102 +165,32 @@ class LTxItem(TxItem): class LTxList(TxList): ItemCls = LTxItem + PSBTCls = SpecterPSET counter = 0 - def fill_missing(self, tx): - raw_tx = self.decoderawtransaction(tx.hex) - tx["vsize"] = raw_tx["vsize"] - - category = "" - addresses = [] - amounts = {} - assets = {} - inputs_mine_count = 0 - for vin in raw_tx["vin"]: - # coinbase tx - if ( - vin["txid"] - == "0000000000000000000000000000000000000000000000000000000000000000" - ): - category = "generate" - break - if vin["txid"] in self: - try: - address = get_address_from_dict( - self.decoderawtransaction(self[vin["txid"]].hex)["vout"][ - vin["vout"] - ] - ) - address_info = self._addresses.get(address, None) - if address_info and not address_info.is_external: - inputs_mine_count += 1 - except Exception as e: - logger.error(e) - continue - - outputs_mine_count = 0 - for out in raw_tx["vout"]: - try: - address = get_address_from_dict(out) - except Exception as e: - # couldn't get address... - logger.error(e) - continue - address_info = self._addresses.get(address) - if address_info and not address_info.is_external: - outputs_mine_count += 1 - addresses.append(address) - amounts[address] = out.get("value", 0) - assets[address] = out.get("asset", "Unknown") - - if inputs_mine_count: - if outputs_mine_count == len(raw_tx["vout"]): - category = "selftransfer" - # remove change addresses from the dest list - addresses2 = [ - address - for address in addresses - if self._addresses.get(address, None) - and not self._addresses[address].change - ] - # use new list only if it's not empty - if addresses2: - addresses = addresses2 - else: - category = "send" - addresses = [ - address - for address in addresses - if not self._addresses.get(address, None) - or self._addresses[address].is_external - ] - else: - if not category: - category = "receive" - addresses = [ - address - for address in addresses - if self._addresses.get(address, None) - and not self._addresses[address].is_external - ] - - amounts = [amounts[address] for address in addresses] - assets = [assets[address] for address in addresses] + def _get_psbt(self, raw_tx): + psbt = self.PSBTCls.from_transaction(raw_tx, self.descriptor, self.network) + psbt.psbt.version = 2 + # fill derivation paths etc + updated = self.rpc.walletprocesspsbt(str(psbt), False).get("psbt", None) + if updated: + psbt.update(updated) + return psbt + def _update_destinations(self, tx, outs): + # remove dummy and fee + outs = [out for out in outs if out.get("address") not in ["Fee", "DUMMY"]] + # process the rest + addresses = [out.get("address", "Unknown") for out in outs] + amounts = [out.get("float_amount", 0) for out in outs] + assets = [out.get("asset", "ff" * 32) for out in outs] if len(addresses) == 1: addresses = addresses[0] amounts = amounts[0] assets = assets[0] - - tx["category"] = category tx["address"] = addresses tx["amount"] = amounts tx["asset"] = assets - if not addresses: - tx["ismine"] = False - else: - tx["ismine"] = True - def decoderawtransaction(self, txhex): - # TODO: using rpc for now, can be moved to utils - return self.rpc.decoderawtransaction(txhex) + def decoderawtransaction(self, tx: Union[LTransaction, str, bytes]): + return SpecterLTx(self, tx).to_dict() diff --git a/src/cryptoadvance/specter/liquid/util/pset.py b/src/cryptoadvance/specter/liquid/util/pset.py index bd5f941f0..44b697e72 100644 --- a/src/cryptoadvance/specter/liquid/util/pset.py +++ b/src/cryptoadvance/specter/liquid/util/pset.py @@ -1,7 +1,15 @@ -from embit.liquid.pset import PSET +from embit.liquid.pset import PSET, LInputScope, LOutputScope +from embit.liquid.transaction import LTransaction, LTransactionOutput +from embit.liquid.networks import get_network +from embit.liquid.addresses import address as liquid_address +from embit.liquid import slip77 +from embit import bip32, ec, script +from math import ceil +import time +from cryptoadvance.specter.util.psbt import * -def to_canonical_pset(pset): +def to_canonical_pset(pset: str) -> str: """ Removes unblinded information from the transaction so Elements Core can decode it @@ -24,3 +32,169 @@ def to_canonical_pset(pset): out.value = None out.value_blinding_factor = None return str(tx) + + +def get_address(script_pubkey: script.Script, network: dict) -> str: + if not script_pubkey.data: + return "Fee" + if script_pubkey.data.startswith(b"\x6a"): + if len(script_pubkey.data) == 1: + return "DUMMY" # dummy output to blind + else: + return "OP_RETURN " + script_pubkey.data[1:].hex() + try: + return script_pubkey.address(network) + except: + return script_pubkey.data.hex() + + +def get_value(value) -> int: + if isinstance(value, int): + return value + return 0 # confidential + + +def get_asset(asset) -> bytes: + if len(asset) != 32: + return (b"\xFF" * 32).hex() # confidential + return asset[::-1].hex() + + +class SpecterLTx(SpecterTx): + TxCls = LTransaction + + def vout_to_dict(self, vout: LTransactionOutput) -> dict: + i = self.tx.vout.index(vout) + return { + "value": round(1e-8 * get_value(vout.value), 8), + "sats": get_value(vout.value), + "n": i, + "asset": get_asset(vout.asset), + "scriptPubKey": { + "hex": vout.script_pubkey.data.hex(), + "addresses": [get_address(vout.script_pubkey, self.network)], + }, + } + + +class SpecterLInputScope(SpecterInputScope): + TxCls = SpecterLTx + + @property + def assetid(self) -> str: + if self.scope.asset is None: + return "???" + return self.scope.asset[::-1].hex() + + @property + def address(self) -> str: + # TODO: blinding key? + try: + return liquid_address(self.scope.script_pubkey, network=self.network) + except: + return None + + @property + def sat_amount(self) -> int: + return self.scope.value or 0 + + def to_dict(self) -> dict: + obj = super().to_dict() + obj.update({"asset": self.assetid}) + return obj + + +class SpecterLOutputScope(SpecterOutputScope): + @property + def assetid(self) -> str: + if self.scope.asset is None: + return "???" + return self.scope.asset[::-1].hex() + + @property + def address(self) -> str: + if not self.scope.script_pubkey.data: + return "Fee" + if self.scope.script_pubkey.data.startswith(b"\x6a"): + if len(self.scope.script_pubkey.data) == 1: + return "DUMMY" # dummy output to blind + else: + return "OP_RETURN " + self.scope.script_pubkey.data[1:].hex() + try: + # try making a liquid address + return liquid_address( + self.scope.script_pubkey, self.blinding_key, network=self.network + ) + except: + # if failed - return hex of the scriptpubkey + return self.scope.script_pubkey.data.hex() + + def extra_weight(self) -> int: + wit = 0 + if self.scope.is_blinded: + wit += 33 * 4 # nonce + wit += (33 - 9) * 4 # value + if self.scope.range_proof and self.scope.surjection_proof: + # serialized witness length + wit += ( + len(self.scope.surjection_proof) + len(self.scope.range_proof) + 3 + ) + else: + # we don't have proofs yet but we can estimate their size + wit += 4245 + return wit + + @property + def blinding_key(self) -> ec.PublicKey: + if self.scope.blinding_pubkey: + return ec.PublicKey.parse(self.scope.blinding_pubkey) + + @property + def sat_amount(self) -> int: + return self.scope.value or 0 + + def to_dict(self) -> dict: + obj = super().to_dict() + obj.update({"asset": self.assetid}) + return obj + + +class SpecterPSET(SpecterPSBT): + """Specter's PSBT class with some handy functions""" + + PSBTCls = PSET + InputCls = SpecterLInputScope + OutputCls = SpecterLOutputScope + TxCls = SpecterLTx + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.psbt.verify(ignore_missing=True) + + @property + def full_size(self) -> int: + size = len(self.psbt.tx.serialize()) * 4 + # witness and redeem script + size += len(self.inputs) * self.extra_input_weight + for out in self.outputs: + size += out.extra_weight() + return ceil(size / 4) + + def should_display(self, out: SpecterLOutputScope) -> bool: + """Checks if this output should be displayed""" + if not out.scope.script_pubkey.data: + # Fee + return False + if out.scope.value == 0 and out.scope.script_pubkey.data == b"\x6a": + # Dummy output + return False + return super().should_display(out) + + @property + def assets(self) -> List[str]: + return [out.assetid for out in self.outputs if self.should_display(out)] + + def to_dict(self) -> dict: + obj = super().to_dict() + obj.update({"asset": self.assets}) + return obj diff --git a/src/cryptoadvance/specter/liquid/wallet.py b/src/cryptoadvance/specter/liquid/wallet.py index 9a4a0a917..090f0a09e 100644 --- a/src/cryptoadvance/specter/liquid/wallet.py +++ b/src/cryptoadvance/specter/liquid/wallet.py @@ -3,10 +3,13 @@ from ..addresslist import Address from embit import ec from embit.liquid.pset import PSET from embit.liquid.transaction import LTransaction +from embit.liquid.descriptor import LDescriptor +from embit.descriptor.checksum import add_checksum from .txlist import LTxList from .addresslist import LAddressList from embit.liquid.addresses import to_unconfidential from ..specter_error import SpecterError +from .util.pset import SpecterPSET class LWallet(Wallet): @@ -14,51 +17,24 @@ class LWallet(Wallet): AddressListCls = LAddressList TxListCls = LTxList TxCls = LTransaction - PSBTCls = PSET + PSBTCls = SpecterPSET + DescriptorCls = LDescriptor @classmethod - def create( - cls, - rpc, - rpc_path, - working_folder, - device_manager, - wallet_manager, - name, - alias, - sigs_required, - key_type, - keys, - devices, - core_version=None, + def construct_descriptor( + cls, sigs_required, key_type, keys, devices, blinding_key=None ): - """Creates a wallet. If core_version is not specified - get it from rpc""" - # get xpubs in a form [fgp/der]xpub from all keys - xpubs = [key.metadata["combined"] for key in keys] - recv_keys = ["%s/0/*" % xpub for xpub in xpubs] - change_keys = ["%s/1/*" % xpub for xpub in xpubs] - is_multisig = len(keys) > 1 - # we start by constructing an argument for descriptor wrappers - if is_multisig: - recv_descriptor = "sortedmulti({},{})".format( - sigs_required, ",".join(recv_keys) - ) - change_descriptor = "sortedmulti({},{})".format( - sigs_required, ",".join(change_keys) - ) - else: - recv_descriptor = recv_keys[0] - change_descriptor = change_keys[0] - # now we iterate over script-type in reverse order - # to get sh(wpkh(xpub)) from sh-wpkh and xpub - arr = key_type.split("-") - for el in arr[::-1]: - recv_descriptor = "%s(%s)" % (el, recv_descriptor) - change_descriptor = "%s(%s)" % (el, change_descriptor) + """ + Creates a wallet descriptor from arguments. + We need to pass `devices` for Liquid wallet, here it's not used. + """ + # construct normal bitcoin descriptor first + btcdescriptor = Wallet.construct_descriptor( + sigs_required, key_type, keys, devices + ) - # get blinding key for the wallet - blinding_key = None - if len(devices) == 1: + # get blinding key for the wallet if it's not provided + if len(devices) == 1 and blinding_key is None: blinding_key = devices[0].blinding_key if not blinding_key: raise SpecterError( @@ -68,14 +44,13 @@ class LWallet(Wallet): # if we don't have slip77 key for a device or it is multisig # we use chaincodes to generate slip77 key. if not blinding_key: - desc = LDescriptor.from_string(recv_descriptor) # For now we use sha256(b"blinding_key", xor(chaincodes)) as a blinding key # where chaincodes are corresponding to xpub of the first receiving address. # It's not a standard but we use that until musig(blinding_xpubs) is implemented. # Chaincodes of the first address are not used anywhere else so they can be used # as a source for the blinding keys. They are also independent of the xpub's origin. xor = bytearray(32) - desc_keys = desc.derive(0).keys + desc_keys = btcdescriptor.derive(0, branch_index=0).keys for k in desc_keys: if k.is_extended: chaincode = k.key.chain_code @@ -83,81 +58,21 @@ class LWallet(Wallet): xor[i] = xor[i] ^ chaincode[i] secret = hashlib.sha256(b"blinding_key" + bytes(xor)).digest() blinding_key = ec.PrivateKey(secret).wif() - if blinding_key: - recv_descriptor = f"blinded(slip77({blinding_key}),{recv_descriptor})" - change_descriptor = f"blinded(slip77({blinding_key}),{change_descriptor})" - recv_descriptor = AddChecksum(recv_descriptor) - change_descriptor = AddChecksum(change_descriptor) - assert recv_descriptor != change_descriptor - - # get Core version if we don't know it - if core_version is None: - core_version = rpc.getnetworkinfo().get("version", 0) - - use_descriptors = core_version >= 209900 - # v20.99 is pre-v21 Elements Core for descriptors - if use_descriptors: - # Use descriptor wallet - rpc.createwallet(os.path.join(rpc_path, alias), True, True, "", False, True) - else: - rpc.createwallet(os.path.join(rpc_path, alias), True) - - wallet_rpc = rpc.wallet(os.path.join(rpc_path, alias)) - # import descriptors - args = [ - { - "desc": desc, - "internal": change, - "timestamp": "now", - "watchonly": True, - } - for (change, desc) in [(False, recv_descriptor), (True, change_descriptor)] - ] - for arg in args: - if use_descriptors: - arg["active"] = True - else: - arg["keypool"] = True - arg["range"] = [0, cls.GAP_LIMIT] - - assert args[0] != args[1] - - # Descriptor wallets were introduced in v0.21.0, but upgraded nodes may - # still have legacy wallets. Use getwalletinfo to check the wallet type. - # The "keypool" for descriptor wallets is automatically refilled - if use_descriptors: - res = wallet_rpc.importdescriptors(args) - else: - res = wallet_rpc.importmulti(args, {"rescan": False}) - - assert all([r["success"] for r in res]) - - return cls( - name, - alias, - "{} of {} {}".format(sigs_required, len(keys), purposes[key_type]) - if len(keys) > 1 - else purposes[key_type], - addrtypes[key_type], - "", - -1, - "", - -1, - 0, - 0, - recv_descriptor, - change_descriptor, - keys, - devices, - sigs_required, - {}, - [], - os.path.join(working_folder, "%s.json" % alias), - device_manager, - wallet_manager, + return cls.DescriptorCls.from_string( + f"blinded(slip77({blinding_key}),{str(btcdescriptor)})" ) + def derive_descriptor(self, index: int, change: bool, keep_xpubs=False): + """ + For derived descriptor for individual address we remove blinding key + as it is used in HWI calls that doesn't support blinding descriptors yet. + TODO: handle blinding keys in HWI + """ + desc = super().derive_descriptor(index, change, keep_xpubs) + desc.blinding_key = None + return desc + def getdata(self): self.fetch_transactions() self.check_utxo() @@ -246,8 +161,7 @@ class LWallet(Wallet): fee_rate: float = 1.0, selected_coins=[], readonly=False, - rbf=True, - existing_psbt=None, + rbf=False, rbf_edit_mode=False, assets=None, ): @@ -257,6 +171,9 @@ class LWallet(Wallet): if fee_rate > 0 and fee_rate < self.MIN_FEE_RATE: fee_rate = self.MIN_FEE_RATE + if not assets: + raise SpecterError("Missing assets information") + options = {"includeWatching": True, "replaceable": rbf} extra_inputs = [] # get change addresses for all assets + for LBTC @@ -265,163 +182,92 @@ class LWallet(Wallet): for i in range(len(assets) + 1) ] - if not existing_psbt: - # if not rbf_edit_mode: - # if self.full_available_balance < sum(amounts): - # raise SpecterError( - # "The wallet does not have sufficient funds to make the transaction." - # ) + if selected_coins: + extra_inputs = selected_coins - # if selected_coins != []: - # still_needed = sum(amounts) - # for coin in selected_coins: - # coin_txid = coin.split(",")[0] - # coin_vout = int(coin.split(",")[1]) - # coin_amount = self.gettransaction(coin_txid, decode=True)["vout"][ - # coin_vout - # ]["value"] - # extra_inputs.append({"txid": coin_txid, "vout": coin_vout}) - # still_needed -= coin_amount - # if still_needed < 0: - # break - # if still_needed > 0: - # raise SpecterError( - # "Selected coins does not cover Full amount! Please select more coins!" - # ) - # elif self.available_balance["trusted"] <= sum(amounts): - # txlist = self.rpc.listunspent(0, 0) - # b = sum(amounts) - self.available_balance["trusted"] - # for tx in txlist: - # extra_inputs.append({"txid": tx["txid"], "vout": tx["vout"]}) - # b -= tx["amount"] - # if b < 0: - # break + # subtract fee from amount of this output: + # currently only one address is supported, so either + # empty array (subtract from change) or [0] + subtract_arr = [subtract_from] if subtract else [] - # subtract fee from amount of this output: - # currently only one address is supported, so either - # empty array (subtract from change) or [0] - subtract_arr = [subtract_from] if subtract else [] - - options = { - "includeWatching": True, - # FIXME: get back change addresses - # "changeAddress": self.change_address, - "subtractFeeFromOutputs": subtract_arr, - "replaceable": rbf, - "changeAddresses": change_addresses, # not supported by Elements - custom field for out LiquidRPC - } - - # 209900 is pre-v21 for Elements Core - if self.manager.bitcoin_core_version_raw >= 209900: - options["add_inputs"] = selected_coins == [] - - if fee_rate > 0: - # bitcoin core needs us to convert sat/B to BTC/kB - options["feeRate"] = round((fee_rate * 1000) / 1e8, 8) - - # looks like change_type is required in nested segwit wallets - # but not in native segwit - if "changeAddress" not in options and self.address_type: - options["change_type"] = self.address_type - - r = self.rpc.walletcreatefundedpsbt( - extra_inputs, # inputs - [ - {addresses[i]: amounts[i], "asset": assets[i]} - for i in range(len(addresses)) - ], # output - 0, # locktime - options, # options - True, # bip32-der - ) - - b64psbt = r["psbt"] - psbt = self.rpc.decodepsbt(b64psbt) - else: - psbt = existing_psbt - # vins from psbt v0 or v2 - if "tx" in psbt: - extra_inputs = [ - {"txid": tx["txid"], "vout": tx["vout"]} for tx in psbt["tx"]["vin"] - ] - else: - extra_inputs = [ - {"txid": inp["previous_txid"], "vout": inp["previous_vout"]} - for inp in psbt["inputs"] - ] + options = { + "includeWatching": True, # FIXME: get back change addresses - # if "changeAddress" in psbt: - # options["changeAddress"] = psbt["changeAddress"] - # if "change_type" in options: - # options.pop("change_type") - if "base64" in psbt: - b64psbt = psbt["base64"] + # "changeAddress": self.change_address, + "subtractFeeFromOutputs": subtract_arr, + "replaceable": rbf, + "changeAddresses": change_addresses, # not supported by Elements - custom field for our LiquidRPC + } + + options["add_inputs"] = not selected_coins + + if fee_rate > 0: + # bitcoin core needs us to convert sat/B to BTC/kB + options["feeRate"] = round((fee_rate * 1000) / 1e8, 8) # looks like change_type is required in nested segwit wallets # but not in native segwit if "changeAddress" not in options and self.address_type: options["change_type"] = self.address_type - if fee_rate > 0.0: - if not existing_psbt: - adjusted_fee_rate = self.adjust_fee(psbt, fee_rate) - options["feeRate"] = "%.8f" % round((adjusted_fee_rate * 1000) / 1e8, 8) - else: - options["feeRate"] = "%.8f" % round((fee_rate * 1000) / 1e8, 8) + try: + locktime = min([tip["height"] for tip in self.rpc.getchaintips()]) + except: + locktime = 0 + + r = self.rpc.walletcreatefundedpsbt( + extra_inputs, # inputs + [ + {addresses[i]: amounts[i], "asset": assets[i]} + for i in range(len(addresses)) + ], # outputs + locktime, + options, # options + True, # bip32-der + ) + + b64psbt = r["psbt"] + psbt = self.PSBTCls( + b64psbt, + self.descriptor, + self.network, + devices=list(zip(self.keys, self._devices)), + ) + + if fee_rate > 0: + # scale by which Core misses the fee rate + scale = fee_rate / psbt.fee_rate + adjusted_fee_rate = fee_rate * scale + options["feeRate"] = round((adjusted_fee_rate * 1000) / 1e8, 8) + r = self.rpc.walletcreatefundedpsbt( extra_inputs, # inputs [ {addresses[i]: amounts[i], "asset": assets[i]} for i in range(len(addresses)) ], # output - 0, # locktime + locktime, options, # options True, # bip32-der ) b64psbt = r["psbt"] - psbt = self.rpc.decodepsbt(b64psbt) - psbt["fee_rate"] = options["feeRate"] - # estimate full size - tx_full_size = ceil( - psbt["tx"]["vsize"] + len(psbt["inputs"]) * self.weight_per_input / 4 - ) - psbt["tx_full_size"] = tx_full_size + psbt = self.PSBTCls( + b64psbt, + self.descriptor, + self.network, + devices=list(zip(self.keys, self._devices)), + ) - psbt["base64"] = b64psbt - psbt["amount"] = amounts - psbt["address"] = addresses - if assets: - psbt["asset"] = assets - psbt["time"] = time.time() - psbt["sigs_count"] = 0 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") - def adjust_fee(self, psbt, fee_rate): - psbt_fees_sats = int(psbt.get("fees", {}).get("bitcoin", 0) * 1e8) - - # TODO: handle non-blind outputs differently - num_blinded_outs = len(psbt["outputs"]) - 1 - # estimate final size: add weight of inputs and outputs - # out witness weight is 4245 (from some random tx) - # commitments in blinded tx: 33 for nonce and 33 for value - commitment_size = 33 + 33 - tx_full_size = ceil( - psbt["tx"]["vsize"] - + len(psbt["inputs"]) * self.weight_per_input / 4 - + num_blinded_outs * 4245 / 4 - # probably elements doesn't count commitments - + len(psbt["inputs"]) * commitment_size - + num_blinded_outs * commitment_size - ) - return ( - fee_rate - * (fee_rate / (psbt_fees_sats / psbt["tx"]["vsize"])) - * (tx_full_size / psbt["tx"]["vsize"]) - ) + def bumpfee(self, *args, **kwargs): + raise SpecterError("RBF is not implemented on Liquid") def addresses_info(self, is_change): """Create a list of (receive or change) addresses from cache and retrieve the @@ -432,7 +278,7 @@ class LWallet(Wallet): addresses_info = [] addresses_cache = [ - v for _, v in self._addresses.items() if v.change == is_change + v for _, v in self._addresses.items() if v.change == is_change and v.is_mine ] for addr in addresses_cache: diff --git a/src/cryptoadvance/specter/managers/wallet_manager.py b/src/cryptoadvance/specter/managers/wallet_manager.py index 43271f9d3..41586fdc1 100644 --- a/src/cryptoadvance/specter/managers/wallet_manager.py +++ b/src/cryptoadvance/specter/managers/wallet_manager.py @@ -161,16 +161,11 @@ class WalletManager: for psbt in loaded_wallet.pending_psbts: logger.info( "lock %s " % wallet_alias, - loaded_wallet.pending_psbts[psbt]["tx"]["vin"], + loaded_wallet.pending_psbts[psbt].utxo_dict(), ) loaded_wallet.rpc.lockunspent( False, - [ - utxo - for utxo in loaded_wallet.pending_psbts[ - psbt - ]["tx"]["vin"] - ], + loaded_wallet.pending_psbts[psbt].utxo_dict(), ) if len(loaded_wallet.frozen_utxo) > 0: loaded_wallet.rpc.lockunspent( @@ -285,7 +280,7 @@ class WalletManager: logger.debug(f"Updating WalletManager rpc {self._rpc} with None") self._rpc = value - def create_wallet(self, name, sigs_required, key_type, keys, devices): + def create_wallet(self, name, sigs_required, key_type, keys, devices, **kwargs): try: walletsindir = [ wallet["name"] for wallet in self.rpc.listwalletdir()["wallets"] @@ -314,6 +309,7 @@ class WalletManager: keys, devices, self.bitcoin_core_version_raw, + **kwargs, ) # save wallet file to disk if w and self.working_folder is not None: diff --git a/src/cryptoadvance/specter/server_endpoints/filters.py b/src/cryptoadvance/specter/server_endpoints/filters.py index 0dc822614..f5c42d945 100644 --- a/src/cryptoadvance/specter/server_endpoints/filters.py +++ b/src/cryptoadvance/specter/server_endpoints/filters.py @@ -3,7 +3,6 @@ from flask import current_app as app from flask import Blueprint from jinja2 import contextfilter from ..helpers import to_ascii20 -from ..liquid.util.pset import to_canonical_pset filters_bp = Blueprint("filters", __name__) @@ -16,6 +15,12 @@ def ascii20(context, name): return to_ascii20(name) +@contextfilter +@filters_bp.app_template_filter("unique_len") +def unique_len(context, arr): + return len(set(arr)) + + @contextfilter @filters_bp.app_template_filter("datetime") def timedatetime(context, s): @@ -31,12 +36,6 @@ def btcamount(context, value): return "{:,.8f}".format(value).rstrip("0").rstrip(".") -@contextfilter -@filters_bp.app_template_filter("to_canonical") -def to_canonical(context, psbt): - return to_canonical_pset(psbt) - - @contextfilter @filters_bp.app_template_filter("btc2sat") def btc2sat(context, value): diff --git a/src/cryptoadvance/specter/server_endpoints/wallets.py b/src/cryptoadvance/specter/server_endpoints/wallets.py index 022713788..4c6f75704 100644 --- a/src/cryptoadvance/specter/server_endpoints/wallets.py +++ b/src/cryptoadvance/specter/server_endpoints/wallets.py @@ -29,7 +29,6 @@ from ..helpers import ( get_devices_with_keys_by_type, get_txid, ) -from ..liquid.util.pset import to_canonical_pset from ..key import Key from ..persistence import delete_file from ..rpc import RpcError @@ -186,8 +185,8 @@ def new_wallet(wallet_type): cosigners=wallet_importer.cosigners, unknown_cosigners=wallet_importer.unknown_cosigners, unknown_cosigners_types=wallet_importer.unknown_cosigners_types, - sigs_required=wallet_importer.descriptor.multisig_M, - sigs_total=wallet_importer.descriptor.multisig_N, + sigs_required=wallet_importer.sigs_required, + sigs_total=wallet_importer.sigs_total, specter=app.specter, rand=rand, ) @@ -528,11 +527,24 @@ def send_new(wallet_alias): rand=rand, ) - elif action == "rbf": + elif action in ["rbf", "rbf_cancel"]: try: rbf_tx_id = request.form["rbf_tx_id"] rbf_fee_rate = float(request.form["rbf_fee_rate"]) - psbt = wallet.bumpfee(rbf_tx_id, rbf_fee_rate) + + if action == "rbf": + psbt = wallet.bumpfee(rbf_tx_id, rbf_fee_rate) + elif action == "rbf_cancel": + psbt = wallet.canceltx(rbf_tx_id, rbf_fee_rate) + else: + raise SpecterError("Invalid action") + + if psbt["fee_rate"] - rbf_fee_rate > wallet.MIN_FEE_RATE / 10: + flash( + _( + "We had to increase the fee rate from {} to {} sat/vbyte" + ).format(rbf_fee_rate, psbt["fee_rate"]) + ) return render_template( "wallet/send/sign/wallet_send_sign_psbt.jinja", psbt=psbt, @@ -544,24 +556,8 @@ def send_new(wallet_alias): ) except Exception as e: flash(_("Failed to perform RBF. Error: {}").format(e), "error") - elif action == "rbf_cancel": - try: - rbf_tx_id = request.form["rbf_tx_id"] - rbf_fee_rate = float(request.form["rbf_fee_rate"]) - psbt = wallet.canceltx(rbf_tx_id, rbf_fee_rate) - return render_template( - "wallet/send/sign/wallet_send_sign_psbt.jinja", - psbt=psbt, - labels=[], - wallet_alias=wallet_alias, - wallet=wallet, - specter=app.specter, - rand=rand, - ) - except Exception as e: - flash( - _("Failed to cancel transaction with RBF. Error: {}").format(e), - "error", + return redirect( + url_for("wallets_endpoint.history", wallet_alias=wallet_alias) ) elif action == "rbf_edit": try: @@ -589,7 +585,8 @@ def send_new(wallet_alias): elif action == "signhotwallet": passphrase = request.form["passphrase"] psbt = json.loads(request.form["psbt"]) - b64psbt = wallet.pending_psbts[psbt["tx"]["txid"]]["base64"] + current_psbt = wallet.pending_psbts[psbt["tx"]["txid"]] + b64psbt = str(current_psbt) device = request.form["device"] if "devices_signed" not in psbt or device not in psbt["devices_signed"]: try: @@ -597,15 +594,12 @@ def send_new(wallet_alias): signed_psbt = app.specter.device_manager.get_by_alias( device ).sign_psbt(b64psbt, wallet, passphrase) + raw = None if signed_psbt["complete"]: - if "devices_signed" not in psbt: - psbt["devices_signed"] = [] - psbt["devices_signed"].append(device) - psbt["sigs_count"] = len(psbt["devices_signed"]) raw = wallet.rpc.finalizepsbt(b64psbt) - if "hex" in raw: - psbt["raw"] = raw["hex"] + current_psbt.update(signed_psbt["psbt"], raw) signed_psbt = signed_psbt["psbt"] + psbt = current_psbt.to_dict() except Exception as e: signed_psbt = None flash(_("Failed to sign PSBT: {}").format(e), "error") @@ -692,13 +686,7 @@ def send_pending(wallet_alias): specter=app.specter, rand=rand, ) - pending_psbts = wallet.pending_psbts - ######## Migration to multiple recipients format ############### - for psbt in pending_psbts: - if not isinstance(pending_psbts[psbt]["address"], list): - pending_psbts[psbt]["address"] = [pending_psbts[psbt]["address"]] - pending_psbts[psbt]["amount"] = [pending_psbts[psbt]["amount"]] - ############################################################### + pending_psbts = wallet.pending_psbts_dict() return render_template( "wallet/send/pending/wallet_sendpending.jinja", pending_psbts=pending_psbts, @@ -910,9 +898,6 @@ def combine(wallet_alias): return e.error_msg, e.status_code except Exception as e: return _("Unknown error: {}").format(e), 500 - if "psbt" in raw: - # returned psbt should be valid for Bitcoin or Elements Core decoding - raw["psbt"] = to_canonical_pset(raw["psbt"]) return json.dumps(raw) @@ -1672,7 +1657,12 @@ def process_txlist(txlist, idx=0, limit=100, search=None, sortby=None, sortdir=" if app.specter.is_liquid: for tx in txlist: if "asset" in tx: - tx["assetlabel"] = app.specter.asset_label(tx["asset"]) + if isinstance(tx["asset"], list): + tx["assetlabel"] = [ + app.specter.asset_label(asset) for asset in tx["asset"] + ] + else: + tx["assetlabel"] = app.specter.asset_label(tx["asset"]) return {"txlist": json.dumps(txlist), "pageCount": page_count} diff --git a/src/cryptoadvance/specter/static/img/mixed.svg b/src/cryptoadvance/specter/static/img/mixed.svg new file mode 100644 index 000000000..a95b36b10 --- /dev/null +++ b/src/cryptoadvance/specter/static/img/mixed.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/cryptoadvance/specter/templates/includes/asset-label.html b/src/cryptoadvance/specter/templates/includes/asset-label.html index a9f073127..fd8b8bd3f 100644 --- a/src/cryptoadvance/specter/templates/includes/asset-label.html +++ b/src/cryptoadvance/specter/templates/includes/asset-label.html @@ -70,6 +70,16 @@ async connectedCallback() { this.asset = this.getAttribute('data-asset'); this.labelValue = this.getAttribute('data-label'); + // edit-mode can be 'hover', 'disabled', 'enabled' + this.mode = this.getAttribute('edit-mode'); + // we don't know the asset + if( (this.asset == null) || + (this.asset == "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") || + (this.asset == "0000000000000000000000000000000000000000000000000000000000000000")){ + this.asset = "Confidential"; + this.labelValue = "Confidential"; + this.mode = "disabled"; + } // Set the label - fetch if not specified if (this.labelValue) { @@ -78,8 +88,6 @@ await this.fetchAssetLabel(); } - // edit-mode can be 'hover', 'disabled', 'enabled' - this.mode = this.getAttribute('edit-mode'); if(!this.mode){ this.mode = 'hover'; } diff --git a/src/cryptoadvance/specter/templates/includes/tx-row.html b/src/cryptoadvance/specter/templates/includes/tx-row.html index 055ab4f7b..b3bda765a 100644 --- a/src/cryptoadvance/specter/templates/includes/tx-row.html +++ b/src/cryptoadvance/specter/templates/includes/tx-row.html @@ -9,7 +9,7 @@ .svg-receive, .svg-immature, .svg-generate { filter: invert(61%) sepia(48%) saturate(3609%) hue-rotate(189deg) brightness(91%) contrast(94%); } - .svg-selftransfer { + .svg-selftransfer, .svg-mixed { filter: invert(77%) sepia(68%) saturate(3384%) hue-rotate(44deg) brightness(112%) contrast(74%); } .svg-cancelled { @@ -145,12 +145,25 @@ this.amount = parseInt(this.amount * 1e8); } - if (this.amount == 1e-8) { + if (this.amount == 1e-8 || this.amount == 0) { this.amountText.innerText = '{{ _("Confidential") }}'; } else { - this.amountText.innerHTML = this.hideSensitiveInfo ? '#########' : `${numberWithCommas(this.amount.toString())}`; - if("assetlabel" in this.tx){ - this.amountText.innerHTML += ` `; + if(this.hideSensitiveInfo){ + this.amountText.innerHTML = '#########'; + }else{ + this.amountText.innerHTML = `${numberWithCommas(this.amount.toString())}`; + if("assetlabel" in this.tx){ // liquid + if (Array.isArray(this.tx.assetlabel)) { // multiple assets + let unique_assets = this.tx.assetlabel.filter((v, i, a) => { return a.indexOf(v) === i }); + if (unique_assets.length == 1){ + this.amountText.innerHTML += ` `; + }else{ + this.amountText.innerHTML = `${unique_assets.length} assets`; + } + }else{ + this.amountText.innerHTML += ` `; + } + } } } @@ -230,11 +243,8 @@ if (newFee <= 1.02) { newFee = 1; } - if (rbfType == 'cancel') { - newFee *= 1.5; // Tx likely to have lower size so will need higher fee - } else { - newFee += 2; - } + // TODO: get fee diff from specter + newFee += 2; txDataPopup.innerHTML = `

${rbfType == 'cancel' ? '{{ _("Cancel") }}' : '{{ _("Speed up") }}'} {{ _("the Transaction") }}

@@ -277,11 +287,12 @@ return `{{ url_for('static', filename='img') }}/send.svg`; case "receive": case "immature": - return `{{ url_for('static', filename='img') }}/receive.svg`; case "generate": - return `{{ url_for('static', filename='img') }}/generate_icon.svg`; + return `{{ url_for('static', filename='img') }}/receive.svg`; case "selftransfer": return `{{ url_for('static', filename='img') }}/transfer.svg`; + case "mixed": + return `{{ url_for('static', filename='img') }}/mixed.svg`; } } } diff --git a/src/cryptoadvance/specter/templates/wallet/send/pending/components/pending_psbt_item.jinja b/src/cryptoadvance/specter/templates/wallet/send/pending/components/pending_psbt_item.jinja index 8279cee4e..eb61cd60b 100644 --- a/src/cryptoadvance/specter/templates/wallet/send/pending/components/pending_psbt_item.jinja +++ b/src/cryptoadvance/specter/templates/wallet/send/pending/components/pending_psbt_item.jinja @@ -18,7 +18,15 @@ {% endif %} + {% if specter.is_liquid %} + {% if pending_psbt['asset'] | unique_len == 1 %} + {{ (pending_psbt['amount'] | sum) | btcunitamount }} + {% else %} + {{ pending_psbt['asset'] | unique_len }} assets + {% endif %} + {% else %} {{ (pending_psbt['amount'] | sum) | btcunitamount }}{% if specter.price_check %} ({{ (pending_psbt['amount'] | sum) | altunit }}){% endif %} + {% endif %} {{ pending_psbt['time'] | datetime }} diff --git a/src/cryptoadvance/specter/templates/wallet/send/sign/wallet_send_sign_psbt.jinja b/src/cryptoadvance/specter/templates/wallet/send/sign/wallet_send_sign_psbt.jinja index 5ec65078a..3aad0aff5 100644 --- a/src/cryptoadvance/specter/templates/wallet/send/sign/wallet_send_sign_psbt.jinja +++ b/src/cryptoadvance/specter/templates/wallet/send/sign/wallet_send_sign_psbt.jinja @@ -127,48 +127,15 @@ {{ _("Inputs count:") }} {{ psbt['tx']['vin'] | length }}
{{ _("Outputs count:") }} {{ psbt['tx']['vout'] | length }}

-

{{ _("Inputs") }} ({{psbt['tx']['vin'] | length}})

- {% for input in psbt['tx']['vin'] %} - {% set tx = wallet.gettransaction(input['txid'], decode=true) %} +

{{ _("Inputs") }} ({{psbt['inputs'] | length}})

+ {% for input in psbt['inputs'] %} {% set bg_color = '#131a24' %} - {% if tx and tx.vout and tx.vout|length > input['vout'] and 'addresses' in tx.vout[input['vout']] and tx.vout[input['vout']].addresses|length > 0 %} - {% set address = tx.vout[input['vout']].addresses[0] %} - {% set bg_color = '#925d07' if wallet.is_address_mine(address) else bg_color %} - {% endif %} + {% set address=input['address'] %} + {% set bg_color = '#925d07' if wallet.is_address_mine(address) else bg_color %}

Input #{{loop.index0}}

{{ _("Transaction id:") }}
- {{ explorer_link('tx', input['txid'], input['txid'], specter.explorer) }} - ({{ _("Output") }} #{{ input['vout'] }})
- {% if tx %} - {% if address %} - {% set addr_label = wallet.getlabel(address) %} - {{ _("Address:") }} - {{ address }} -
- {% if addr_label != address %} - {{ _("Label:") }} - -
- {% endif %} - {% endif %} - {% if specter.is_liquid %} - {% set amount = psbt['inputs'][loop.index0].get('value', -1) %} - {% set asset = psbt['inputs'][loop.index0].get('asset', "") %} - {{ _("Amount:") }} {{ amount|btcamount }} - {% else %} - {% set amount = psbt['inputs'][loop.index0].get('witness_utxo',{}).get('amount', -1) %} - {{ _("Amount:") }} {{ amount|btcamount }} BTC {% if specter.price_check %} ({{ amount | altunit }}){% endif %} - {% endif %} - {% endif %} -

- {% endfor %} -

{{ _("Outputs") }} ({{psbt['tx']['vout']|length}})

- {% for output in psbt['tx']['vout'] if output['scriptPubKey']['type'] != 'fee' %} - {% set address = output['scriptPubKey']['addresses'][0] if "addresses" in output["scriptPubKey"] else output["scriptPubKey"]["address"] %} - {% set bg_color = '#154984' if wallet.is_address_mine(address) else '#131a24' %} -

- {{ _("Output") }} #{{loop.index0}} {% if address not in psbt['address'] %}(Change){% endif %}

+ {{ explorer_link('tx', input['txid'], input['txid'], specter.explorer) }} : {{ input['vout'] }}
{% set addr_label = wallet.getlabel(address) %} {{ _("Address:") }} {{ address }} @@ -178,18 +145,44 @@
{% endif %} - {{ _("Amount:") }} {{ output['value']|btcamount }} BTC {% if specter.price_check %} ({{ output['value'] | altunit }}){% endif %} + {% if specter.is_liquid %} + {{ _("Amount:") }} {{ input['float_amount']|btcamount }} + {% else %} + {{ _("Amount:") }} {{ input['float_amount']|btcamount }} BTC {% if specter.price_check %} ({{ input['float_amount'] | altunit }}){% endif %} + {% endif %}

{% endfor %} - {{ _("Raw PSBT:") }} +

{{ _("Outputs") }} ({{psbt['outputs']|length}})

+ {% for output in psbt['outputs'] %} + {% set address = output['address'] %} + {% set bg_color = '#154984' if output['is_mine'] else '#131a24' %} +

+ {{ _("Output") }} #{{loop.index0}} {% if output['is_change'] %}(Change){% endif %}

+ {% set addr_label = wallet.getlabel(address) %} + {{ _("Address:") }} + {{ address }} +
+ {% if addr_label != address %} + {{ _("Label:") }} + +
+ {% endif %} + {% if specter.is_liquid %} + {{ _("Amount:") }} {{ output['float_amount']|btcamount }} + {% else %} + {{ _("Amount:") }} {{ output['float_amount']|btcamount }} BTC {% if specter.price_check %} ({{ output['float_amount'] | altunit }}){% endif %} + {% endif %} +

+ {% endfor %} + {{ _("Raw PSBT:") }}
+ download="binary_{{ psbt['tx']['hash'] }}.psbt" href="data:application/octet-stream;base64;content-disposition=attachment,{{ psbt['base64'] }}"> {{ _("Save binary") }}   + download="base64_{{ psbt['tx']['hash'] }}.psbt" href="data:text/plain;content-disposition=attachment,{{ psbt['base64'] }}"> {{ _("Save base64") }}   @@ -208,7 +201,8 @@

{{ _("Sign transaction with your:") }}

{% for device in wallet.devices %} - {% endfor %} @@ -244,7 +238,7 @@
- +
diff --git a/src/cryptoadvance/specter/txlist.py b/src/cryptoadvance/specter/txlist.py index 654452b4a..e24d44c86 100644 --- a/src/cryptoadvance/specter/txlist.py +++ b/src/cryptoadvance/specter/txlist.py @@ -1,14 +1,18 @@ """ Manages the list of transactions for the wallet """ +from typing import Union import os from .persistence import 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 from .util.tx import decoderawtransaction +from .util.psbt import SpecterTx, AbstractTxContext, SpecterPSBT logger = logging.getLogger(__name__) @@ -22,7 +26,21 @@ def parse_arr(v): return v -class TxItem(dict): +class AbstractTxListContext(AbstractTxContext): + @property + def rpc(self): + if hasattr(self, "parent"): + return self.parent.rpc + raise NotImplementedError("Implement this!") + + @property + def chain(self) -> str: + if hasattr(self, "parent"): + return self.parent.chain + raise NotImplementedError("Implement this!") + + +class TxItem(dict, AbstractTxListContext): TransactionCls = Transaction columns = [ "txid", # str, txid in hex @@ -51,11 +69,10 @@ class TxItem(dict): bool, ] - def __init__(self, rpc, addresses, rawdir, chain, **kwargs): - self.rpc = rpc + def __init__(self, parent, addresses, rawdir, **kwargs): + self.parent = parent self._addresses = addresses self.rawdir = rawdir - self.chain = chain # copy kwargs = dict(**kwargs) # replace with None or convert @@ -66,8 +83,11 @@ class TxItem(dict): super().__init__(**kwargs) self._tx = None # if we have hex data - if kwargs.get("hex"): + if "hex" in kwargs: self._tx = self.TransactionCls.from_string(kwargs["hex"]) + # conflicts were renamed to walletconflicts + if "walletconflicts" in kwargs: + self["conflicts"] = kwargs["walletconflicts"] @property def fname(self): @@ -99,16 +119,44 @@ class TxItem(dict): logger.error(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 if os.path.isfile(self.fname) or not self._tx: return - # create dir if it doesn't exist - if not os.path.isdir(self.rawdir): - os.mkdir(self.rawdir) - with open(self.fname, "wb") as f: - self.tx.write_to(f) + # Try to create a directory if it's not there + # and write raw tx to file + # Can fail if multiple threads create the same dir + try: + # create dir if it doesn't exist + if not os.path.isdir(self.rawdir): + os.mkdir(self.rawdir) + except Exception as e: + logger.error(e) + try: + with open(self.fname, "wb") as f: + self.tx.write_to(f) + except Exception as e: + logger.error(e) # clear cached tx as we saved the transaction to file self._tx = None @@ -142,15 +190,15 @@ class TxItem(dict): } -class TxList(dict): +class TxList(dict, AbstractTxListContext): ItemCls = TxItem # for inheritance + PSBTCls = SpecterPSBT - def __init__(self, path, rpc, addresses, chain): - self.chain = chain + def __init__(self, path, parent, addresses): + self.parent = parent self.path = path # folder to store transactions in binary form self.rawdir = path.replace(".csv", "_raw") - self.rpc = rpc self._addresses = addresses txs = [] file_exists = False @@ -159,10 +207,9 @@ class TxList(dict): txs = read_csv( self.path, self.ItemCls, - self.rpc, + self, self._addresses, self.rawdir, - self.chain, ) for tx in txs: self[tx.txid] = tx @@ -181,6 +228,18 @@ class TxList(dict): write_csv(self.path, list(self.values()), self.ItemCls) self._file_exists = True + def getfetch(self, txid): + """ + Returns TxItem instance if it is known, + otherwise tries to get it from rpc, adds to self and returns TxItem + """ + if txid not in self: + tx = self.rpc.gettransaction(txid) + if "time" not in tx: + tx["time"] = tx["timereceived"] + self.add({txid: tx}) + return self[txid] + def gettransaction(self, txid, blockheight=None, decode=False, full=True): """ Will ask Bitcoin Core for a transaction if blockheight is None or txid not known @@ -207,8 +266,8 @@ class TxList(dict): res.update(self.decoderawtransaction(tx.hex)) return res - def decoderawtransaction(self, txhex): - return decoderawtransaction(txhex, self.chain) + def decoderawtransaction(self, tx: Union[Transaction, str, bytes]): + return SpecterTx(self, tx).to_dict() def add(self, txs): """ @@ -247,9 +306,7 @@ class TxList(dict): "bip125-replaceable": tx.get("bip125-replaceable", "no"), "hex": tx.get("hex", None), } - txitem = self.ItemCls( - self.rpc, self._addresses, self.rawdir, self.chain, **obj - ) + txitem = self.ItemCls(self, self._addresses, self.rawdir, **obj) self[txid] = txitem if txitem.tx: for vout in txitem.tx.vout: @@ -265,97 +322,68 @@ class TxList(dict): self.fill_missing(tx) self.save() - def fill_missing(self, tx): - raw_tx = self.decoderawtransaction(tx.hex) - tx["vsize"] = raw_tx["vsize"] - - category = "" - addresses = [] - amounts = {} - inputs_mine_count = 0 - for vin in raw_tx["vin"]: - # coinbase tx - if ( - vin["txid"] - == "0000000000000000000000000000000000000000000000000000000000000000" - ): - category = "generate" - break - if vin["txid"] in self: - try: - address = get_address_from_dict( - self.decoderawtransaction(self[vin["txid"]].hex)["vout"][ - vin["vout"] - ] - ) - address_info = self._addresses.get(address, None) - if address_info and not address_info.is_external: - inputs_mine_count += 1 - except Exception as e: - logger.error(e) - continue - - outputs_mine_count = 0 - for out in raw_tx["vout"]: - try: - address = get_address_from_dict(out) - except Exception as e: - # couldn't get address... - logger.error(e) - continue - address_info = self._addresses.get(address) - if address_info and not address_info.is_external: - outputs_mine_count += 1 - addresses.append(address) - amounts[address] = out.get("value", 0) - - if inputs_mine_count: - if outputs_mine_count == len(raw_tx["vout"]): - category = "selftransfer" - # remove change addresses from the dest list - addresses2 = [ - address - for address in addresses - if self._addresses.get(address, None) - and not self._addresses[address].change - ] - # use new list only if it's not empty - if addresses2: - addresses = addresses2 - else: - category = "send" - addresses = [ - address - for address in addresses - if not self._addresses.get(address, None) - or self._addresses[address].is_external - ] - else: - if not category: - category = "receive" - addresses = [ - address - for address in addresses - if self._addresses.get(address, None) - and not self._addresses[address].is_external - ] - - amounts = [amounts[address] for address in addresses] - + 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["category"] = category tx["address"] = addresses tx["amount"] = amounts - if not addresses: - tx["ismine"] = False + + 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): + 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: - tx["ismine"] = True + # 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 Load transactions to Core with merkle proofs to avoid rescan arr should be a dict with dicts: "": { diff --git a/src/cryptoadvance/specter/util/psbt.py b/src/cryptoadvance/specter/util/psbt.py new file mode 100644 index 000000000..fb8e33355 --- /dev/null +++ b/src/cryptoadvance/specter/util/psbt.py @@ -0,0 +1,451 @@ +""" +The goal of this module is to slowly migrate from json-like representation of PSBT received from Bitcoin RPC +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 +""" +from cryptoadvance.specter.key import Key +from embit.psbt import PSBT, InputScope, OutputScope, DerivationPath +from embit.transaction import Transaction, TransactionOutput, TransactionInput +from embit.liquid.networks import get_network +from embit import bip32 +from embit.descriptor import Descriptor +from math import ceil +import time +from typing import Union, Tuple, List + + +class AbstractTxContext: + """Class inherited from this one must have the following properties: + - self.network : dict with network constants (see embit.networks) + - self.descriptor : Descriptor class that can check if it owns a PSBT scope or not + Allows to pass context Wallet -> SpecterPSBT -> SpecterScope -> SpecterTx + """ + + @property + def network(self) -> dict: + if hasattr(self, "parent"): + return self.parent.network + raise NotImplementedError("Implement this!") + + @property + def descriptor(self) -> Descriptor: + if hasattr(self, "parent"): + return self.parent.descriptor + raise NotImplementedError("Implement this!") + + +class SpecterTx(AbstractTxContext): + TxCls = Transaction + + def __init__(self, parent: AbstractTxContext, tx: Union[Transaction, str, bytes]): + self.parent = parent + if isinstance(tx, str): + tx = self.TxCls.from_string(tx) + elif isinstance(tx, bytes): + tx = self.TxCls.parse(tx) + self.tx = tx + + def vin_to_dict(self, vin: TransactionInput) -> dict: + return { + "txid": vin.txid.hex(), + "vout": vin.vout, + "sequence": vin.sequence, + } + + def vout_to_dict(self, vout: TransactionOutput) -> dict: + i = self.tx.vout.index(vout) + obj = { + "value": round(1e-8 * vout.value, 8), + "sats": vout.value, + "n": i, + "scriptPubKey": { + "hex": vout.script_pubkey.data.hex(), + }, + } + try: + if scope.script_pubkey.data.startswith(b"\x6a"): + obj["scriptPubKey"]["addresses"] = [ + "OP_RETURN " + scope.script_pubkey.data.hex() + ] + else: + obj["scriptPubKey"]["addresses"] = [ + vout.script_pubkey.address(self.network) + ] + except: + pass + return obj + + def to_dict(self) -> dict: + txid = self.tx.txid().hex() + size = len(self.tx.serialize()) + return { + "txid": txid, + "hash": txid, # not sure why it's the same + "version": self.tx.version, + "size": size, + "vsize": size, + "weight": 4 * size, + "locktime": self.tx.locktime, + "vin": [self.vin_to_dict(vin) for vin in self.tx.vin], + "vout": [self.vout_to_dict(vout) for vout in self.tx.vout], + } + + +class SpecterScope(AbstractTxContext): + def __init__( + self, parent: AbstractTxContext, scope: Union[InputScope, OutputScope] + ): + self.parent = parent + self.scope = scope + + @property + def is_mine(self) -> bool: + return self.descriptor.owns(self.scope) + + @property + def is_change(self) -> bool: + """Returns True only if the scope belongs to change descriptor (branch 1)""" + return self.descriptor.branch(1).owns(self.scope) + + @property + def is_receiving(self) -> bool: + return self.is_mine and not self.is_change + + @property + def address(self) -> str: + try: + if self.scope.script_pubkey.data.startswith(b"\x6a"): + return "OP_RETURN " + self.scope.script_pubkey.data.hex() + else: + return self.scope.script_pubkey.address(self.network) + except: + return None + + @property + def sat_amount(self) -> int: + """Implement this!""" + raise NotImplementedError("Not implemented for this scope") + + @property + def float_amount(self) -> float: + return round(self.sat_amount * 1e-8, 8) + + def to_dict(self) -> dict: + addr = self.address + try: + sats = self.sat_amount + except: + sats = None + obj = { + "change": self.is_change, + "is_mine": self.is_mine, + } + if addr: + obj["address"] = addr + if sats is not None: + obj.update( + { + "float_amount": round(sats * 1e-8, 8), + "sat_amount": sats, + } + ) + if self.scope.bip32_derivations: + obj["bip32_derivs"] = [ + { + "pubkey": pub.sec().hex(), + "master_fingerprint": der.fingerprint.hex(), + "path": bip32.path_to_str(der.derivation), + } + for pub, der in self.scope.bip32_derivations.items() + ] + return obj + + +class SpecterInputScope(SpecterScope): + TxCls = SpecterTx + + @property + def inp(self) -> InputScope: + return self.scope + + @property + def sat_amount(self) -> int: + return self.scope.utxo.value + + @property + def txid(self) -> bytes: + return self.scope.txid + + @property + def vout(self) -> int: + return self.scope.vout + + def to_dict(self) -> dict: + obj = super().to_dict() + obj.update( + { + "txid": self.scope.txid.hex(), + "vout": self.scope.vout, + } + ) + if self.scope.witness_utxo: + obj["witness_utxo"] = { + "amount": self.float_amount, + "sats": self.sat_amount, + "scriptPubKey": { + "hex": self.scope.script_pubkey.data.hex(), + "addresses": [self.address], + }, + } + else: + obj["non_witness_utxo"] = self.TxCls(self, self.scope.non_witness_utxo) + return obj + + +class SpecterOutputScope(SpecterScope): + @property + def out(self) -> OutputScope: + return self.scope + + @property + def sat_amount(self) -> int: + return self.out.value + + +class SpecterPSBT(AbstractTxContext): + """Specter's PSBT class with some handy functions""" + + PSBTCls = PSBT + InputCls = SpecterInputScope + OutputCls = SpecterOutputScope + TxCls = SpecterTx + + def __init__( + self, + psbt: Union[str, PSBT], + descriptor: Descriptor, + network: dict, + raw: Union[None, str] = None, + devices: List[Tuple[Key, str]] = [], # list of tuples: (Key, device_alias) + **kwargs + ): + """ + kwargs can contain: + - "time" - creation time of the transaction, time.time() is used if missing, + - other keys in kwargs are dropped + """ + if isinstance(psbt, str): + psbt = self.PSBTCls.from_string(psbt) + self.psbt = psbt + self._descriptor = descriptor + self._network = network + self.devices = devices + self.raw = bytes.fromhex(raw) if raw else None + self.time = kwargs.get("time", time.time()) + + @property + def network(self) -> dict: + return self._network + + @property + def descriptor(self) -> Descriptor: + return self._descriptor + + def update(self, b64psbt: str, raw: dict = {}) -> None: + """ + b64psbt - PSBT transaction with some extra data that we should take + raw dict can contain "hex" key with hex string of the finalized transaction. Or not. + """ + if raw and "hex" in raw: + self.raw = bytes.fromhex(raw["hex"]) + if not b64psbt: + return + psbt = self.PSBTCls.from_string(b64psbt) + for inp1, inp2 in zip(self.psbt.inputs, psbt.inputs): + inp1.update(inp2) + for out1, out2 in zip(self.psbt.outputs, psbt.outputs): + out1.update(out2) + + def utxo_dict(self) -> dict: + return [{"txid": inp.txid.hex(), "vout": inp.vout} for inp in self.psbt.inputs] + + @property + def extra_input_weight(self) -> int: + redeem_script = self.descriptor.redeem_script() + witness_script = self.descriptor.witness_script() + weight = 0 + if redeem_script: + weight += len(redeem_script.data) * 4 + if witness_script: + weight += ( + len(witness_script.data) + 2 + ) # number of items in witness + script length + if self.descriptor.is_basic_multisig: + threshold = self.descriptor.miniscript.args[0].num + num_keys = len(self.descriptor.keys) + weight += num_keys * 34 + weight += threshold * 75 + else: + # pubkey, signature + weight += 75 + 34 + return weight + + @property + def full_size(self) -> int: + weight = len(self.psbt.tx.serialize()) * 4 + 4 # marker will be added + weight += len(self.inputs) * self.extra_input_weight + return ceil(weight / 4) + + @property + def fee(self) -> int: + return self.psbt.fee() + + @property + def fee_rate(self) -> float: + return self.fee / self.full_size + + @property + def threshold(self) -> int: + if self.descriptor.is_basic_multisig: + return self.descriptor.miniscript.args[0].num + return 1 + + @property + def sigs_count(self) -> int: + # everything is signed if final witness is there or + if self.raw or any([inp.final_scriptwitness for inp in self.psbt.inputs]): + return self.threshold + # not quite true but ok for most common cases + return max([len(inp.partial_sigs) for inp in self.psbt.inputs]) + + def should_display(self, out: SpecterOutputScope) -> bool: + """Checks if this output should be displayed""" + return not self.descriptor.branch(1).owns(out.scope) + + @property + def addresses(self) -> List[str]: + return [out.address for out in self.outputs if self.should_display(out)] + + @property + def amounts(self) -> List[float]: + return [out.float_amount for out in self.outputs if self.should_display(out)] + + @property + def sats(self) -> List[int]: + return [out.value for out in self.psbt.outputs if not self.descriptor.owns(out)] + + @property + def txid(self) -> str: + return self.psbt.tx.txid().hex() + + @property + def inputs(self) -> List[SpecterInputScope]: + return [self.InputCls(self, inp) for inp in self.psbt.inputs] + + @property + def outputs(self) -> List[SpecterOutputScope]: + return [self.OutputCls(self, out) for out in self.psbt.outputs] + + @property + def tx(self) -> Transaction: + return self.TxCls(self, self.psbt.tx) + + def get_signed_devices(self) -> List[str]: + if not self.devices: + return [] + devices = [] + # for each devices check if there is a partial signature for any input + for key, device in self.devices: + device_signed = False + for inp in self.psbt.inputs: + if not inp.partial_sigs: + continue + for pub in inp.partial_sigs: + der = inp.bip32_derivations.get(pub) + if der and der.fingerprint.hex() == key.fingerprint: + device_signed = True + break + if device_signed: + break + if device_signed and device not in devices: + devices.append(device) + return devices + + @classmethod + def from_dict(cls, obj: dict, descriptor: Descriptor, network: dict, devices=[]): + psbt = cls.PSBTCls.from_string(obj["base64"]) + kwargs = {} + kwargs.update(obj) + kwargs.pop("base64") + return cls(psbt, descriptor, network, devices=devices, **kwargs) + + @classmethod + def from_transaction( + cls, + tx: Union[Transaction, str, bytes], + descriptor: Descriptor, + network: dict, + devices=[], + ): + if isinstance(tx, str): + tx = cls.TxCls.from_string(tx) + elif isinstance(tx, bytes): + tx = cls.TxCls.parse(tx) + psbt = cls.PSBTCls(tx) + return cls(psbt, descriptor, network, devices=devices) + + def to_dict(self) -> dict: + # fee calculation may fail if inputs info is missing + try: + fee = self.fee + except: + fee = 0 + full_size = self.full_size + obj = { + "tx": self.tx.to_dict(), + "inputs": [inp.to_dict() for inp in self.inputs], + "outputs": [out.to_dict() for out in self.outputs], + "base64": str(self.psbt), + "fee": round(fee * 1e-8, 8), + "fee_sat": fee, + "address": self.addresses, + "amount": self.amounts, + "sats": self.sats, + "tx_full_size": full_size, + "sigs_count": self.sigs_count, + "time": self.time, + "devices_signed": self.get_signed_devices(), + "fee_rate": round(fee / full_size, 2), + } + if self.raw: + obj.update({"raw": self.raw.hex()}) + return obj + + @classmethod + def from_string(cls, b64psbt: str) -> PSBT: + """Returns PSBTCls, not cls""" + return cls.PSBTCls.from_string(b64psbt) + + def to_string(self) -> str: + return str(self.psbt) + + def __str__(self): + return str(self.psbt) + + @classmethod + def fill_output(cls, out: OutputScope, desc: Descriptor) -> bool: + """ + Fills derivations and all other information in PSBT output + from derived descriptor + """ + if desc.script_pubkey() != out.script_pubkey: + return False + out.redeem_script = desc.redeem_script() + out.witness_script = desc.witness_script() + out.bip32_derivations = { + key.get_public_key(): DerivationPath( + key.origin.fingerprint, key.origin.derivation + ) + for key in desc.keys + } + return True diff --git a/src/cryptoadvance/specter/util/psbt_creator.py b/src/cryptoadvance/specter/util/psbt_creator.py index f4dbb7109..8c5ee7ebf 100644 --- a/src/cryptoadvance/specter/util/psbt_creator.py +++ b/src/cryptoadvance/specter/util/psbt_creator.py @@ -257,9 +257,13 @@ class PsbtCreator: rbf = bool(request_form.get("rbf", False)) # workaround for making the tests work with a dict if hasattr(request_form, "getlist"): - selected_coins = request_form.getlist("coinselect") + coins = [coin.split(",") for coin in request_form.getlist("coinselect")] + # convert to tuples + selected_coins = [ + {"txid": txid.strip(), "vout": int(vout)} for txid, vout in coins + ] else: - selected_coins = None + selected_coins = [] rbf_tx_id = request_form.get("rbf_tx_id", "") kwargs = { "subtract": subtract, @@ -267,8 +271,8 @@ class PsbtCreator: "fee_rate": fee_rate, "rbf": rbf, "selected_coins": selected_coins, - "readonly": "estimate_fee" - in request_form, # determines whether the psbt gets persisted + # determines whether the psbt gets persisted + "readonly": "estimate_fee" in request_form, "rbf_edit_mode": (rbf_tx_id != ""), } return kwargs diff --git a/src/cryptoadvance/specter/util/wallet_importer.py b/src/cryptoadvance/specter/util/wallet_importer.py index 5e60a9637..d9b2bb665 100644 --- a/src/cryptoadvance/specter/util/wallet_importer.py +++ b/src/cryptoadvance/specter/util/wallet_importer.py @@ -5,8 +5,10 @@ from cryptoadvance.specter.managers.wallet_manager import WalletManager from cryptoadvance.specter.specter_error import SpecterError -from ..helpers import is_testnet -from .descriptor import AddChecksum, Descriptor +from embit.descriptor import Descriptor +from embit.descriptor import Key as DescriptorKey +from embit.liquid.descriptor import LDescriptor +from cryptoadvance.specter.key import Key logger = logging.getLogger(__name__) @@ -27,6 +29,7 @@ class WalletImporter: * unknown_cosigners * unknown_cosigners_types """ + DescriptorCls = LDescriptor if specter.is_liquid else Descriptor if device_manager is None: device_manager = specter.device_manager try: @@ -40,12 +43,8 @@ class WalletImporter: logger.warning(f"Trying to import: {wallet_json}") raise SpecterError(f"Unsupported wallet import format:{e}") try: - self.descriptor = Descriptor.parse( - AddChecksum(self.recv_descriptor.split("#")[0]), - testnet=is_testnet(specter.chain), - ) - if self.descriptor is None: - raise SpecterError(f"Invalid wallet descriptor. (returns None)") + self.descriptor = DescriptorCls.from_string(self.recv_descriptor) + self.check_descriptor() except Exception as e: raise SpecterError(f"Invalid wallet descriptor: {e}") if self.wallet_name in specter.wallet_manager.wallets_names: @@ -55,8 +54,57 @@ class WalletImporter: self.cosigners, self.unknown_cosigners, self.unknown_cosigners_types, - ) = self.descriptor.parse_signers(device_manager.devices, self.cosigners_types) - self.wallet_type = "multisig" if self.descriptor.multisig_N > 1 else "simple" + ) = self.parse_signers(device_manager.devices, self.cosigners_types) + self.wallet_type = "multisig" if self.descriptor.is_basic_multisig else "simple" + + def check_descriptor(self): + for key in self.descriptor.keys: + if not key.is_extended: + raise SpecterError("Only HD keys are supported in descriptor") + if key.allowed_derivation is None or key.allowed_derivation.indexes != [ + 0, + None, + ]: + raise SpecterError( + "Descriptor key has wrong derivation, only /0/* derivation is supported." + ) + + def parse_signers(self, devices, cosigners_types): + keys = [] + cosigners = [] + unknown_cosigners = [] + unknown_cosigners_types = [] + + for i, descriptor_key in enumerate(self.descriptor.keys): + # remove derivation from the key for comparison + account_key = DescriptorKey.from_string(str(descriptor_key)) + account_key.allowed_derivation = None + # Specter Key class + desc_key = Key.parse_xpub(str(account_key)) + cosigner_found = False + for cosigner in devices.values(): + for key in cosigner.keys: + # check key matches + if key.to_string(slip132=False) == desc_key.to_string( + slip132=False + ): + keys.append(key) + cosigners.append(cosigner) + cosigner_found = True + break + if cosigner_found: + break + if not cosigner_found: + if len(cosigners_types) > i: + unknown_cosigners.append((desc_key, cosigners_types[i]["label"])) + else: + unknown_cosigners.append((desc_key, None)) + if len(unknown_cosigners) > len(cosigners_types): + unknown_cosigners_types.append("other") + else: + unknown_cosigners_types.append(cosigners_types[i]["type"]) + + return (keys, cosigners, unknown_cosigners, unknown_cosigners_types) def create_nonexisting_signers(self, device_manager, request_form): """creates non existinging signer via the device_manager @@ -82,17 +130,56 @@ class WalletImporter: self.keys.append(unknown_cosigner_key) self.cosigners.append(device) + @property + def address_type(self): + if self.descriptor.is_taproot: + return "tr" + res = "" + if self.descriptor.miniscript: + if self.descriptor.wsh: + res = "wsh" + else: + if self.descriptor.wpkh: + res = "wpkh" + else: + return "pkh" + if self.descriptor.sh: + if res: + return f"sh-{res}" + else: + return "sh" + return res + + @property + def sigs_required(self): + sigs_required = 1 + if self.descriptor.is_basic_multisig: + sigs_required = self.descriptor.miniscript.args[0].num + return sigs_required + + @property + def sigs_total(self): + return len(self.descriptor.keys) + def create_wallet(self, wallet_manager): """creates the wallet. Assumes all devices are there (create with create_nonexisting_signers) will also keypoolrefill and import_labels """ try: + kwargs = {} + if ( + isinstance(self.descriptor, LDescriptor) + and self.descriptor.blinding_key + ): + kwargs["blinding_key"] = self.descriptor.blinding_key.key + self.wallet = wallet_manager.create_wallet( name=self.wallet_name, - sigs_required=self.descriptor.multisig_M, - key_type=self.descriptor.address_type, + sigs_required=self.sigs_required, + key_type=self.address_type, keys=self.keys, devices=self.cosigners, + **kwargs, ) except Exception as e: raise SpecterError(f"Failed to create wallet: {e}") @@ -154,7 +241,7 @@ class WalletImporter: """ cosigners_types = [] - # Specter-DIY format + # Specter-Desktop format if "recv_descriptor" in wallet_data: wallet_name = wallet_data.get("name", "Imported Wallet") recv_descriptor = wallet_data.get("recv_descriptor", None) diff --git a/src/cryptoadvance/specter/wallet.py b/src/cryptoadvance/specter/wallet.py index a503016a0..ff2648379 100644 --- a/src/cryptoadvance/specter/wallet.py +++ b/src/cryptoadvance/specter/wallet.py @@ -6,8 +6,7 @@ from .key import Key from .util.merkleblock import is_valid_merkle_proof from .helpers import der_to_bytes, get_address_from_dict from embit import base58, bip32 -from .util.descriptor import Descriptor, sort_descriptor, AddChecksum -from embit.liquid.descriptor import LDescriptor +from embit.descriptor import Descriptor from embit.descriptor.checksum import add_checksum from embit.liquid.networks import get_network from embit.psbt import PSBT, DerivationPath @@ -24,6 +23,7 @@ import requests from math import ceil from .addresslist import AddressList from .txlist import TxList +from .util.psbt import SpecterPSBT logger = logging.getLogger(__name__) LISTTRANSACTIONS_BATCH_SIZE = 1000 @@ -57,31 +57,29 @@ class Wallet: IMPORT_KEYPOOL = 300 # a gap of 20 addresses is what many wallets do (not used with descriptor wallets) GAP_LIMIT = 20 - # minimal fee rate is slightly above 1 sat/vbyte - # to avoid rounding errors - MIN_FEE_RATE = 1.01 + MIN_FEE_RATE = 1 # for inheritance (to simplify LWallet logic) AddressListCls = AddressList TxListCls = TxList TxCls = Transaction - PSBTCls = PSBT + PSBTCls = SpecterPSBT + DescriptorCls = Descriptor def __init__( self, - name, - alias, - description, - address_type, - address, - address_index, - change_address, - change_index, - keypool, - change_keypool, - recv_descriptor, - change_descriptor, - keys, - devices, + name: str, + alias: str, + description: str, + address_type: str, + address: str, + address_index: int, + change_address: str, + change_index: int, + keypool: int, + change_keypool: int, + descriptor: str, + keys: list, + devices: list, sigs_required, pending_psbts, frozen_utxo, @@ -101,23 +99,41 @@ class Wallet: self.change_index = change_index self.keypool = keypool self.change_keypool = change_keypool - self.recv_descriptor = recv_descriptor - self.change_descriptor = change_descriptor + self.info = {} + # just to make sure we can accept both descriptor instance and string + if not isinstance(descriptor, str): + descriptor = str(descriptor) + self.descriptor = self.DescriptorCls.from_string(descriptor) + if self.descriptor.num_branches != 2: + raise SpecterError( + f"Descriptor has {self.descriptor.num_branches} branches, but we need 2." + ) + self.keys = keys self.device_manager = device_manager - self._devices = devices - # check that all of the devices exist - if None in self.devices: - raise Exception("A device used by this wallet could not have been found!") - self.sigs_required = int(sigs_required) - self.pending_psbts = pending_psbts - self.frozen_utxo = frozen_utxo - self.fullpath = fullpath self.manager = manager self.rpc = self.manager.rpc.wallet( os.path.join(self.manager.rpc_path, self.alias) ) + self._devices = devices + # check that all of the devices exist + if None in self.devices: + raise SpecterError( + "A device used by this wallet could not have been found!" + ) + self.sigs_required = int(sigs_required) + self.pending_psbts = { + psbtid: self.PSBTCls.from_dict( + psbtobj, + self.descriptor, + self.network, + devices=list(zip(self.keys, self._devices)), + ) + for psbtid, psbtobj in pending_psbts.items() + } + self.frozen_utxo = frozen_utxo + self.fullpath = fullpath self.last_block = last_block addr_path = self.fullpath.replace(".json", "_addr.csv") @@ -126,19 +142,31 @@ class Wallet: self.fetch_labels() txs_path = self.fullpath.replace(".json", "_txs.csv") - self._transactions = self.TxListCls( - txs_path, self.rpc, self._addresses, self.manager.chain - ) + self._transactions = self.TxListCls(txs_path, self, self._addresses) if address == "": - self.getnewaddress() + self.address = self.get_address(0, change=False) + self.address_index = 0 if change_address == "": - self.getnewaddress(change=True) + self.change_address = self.get_address(0, change=True) + self.change_index = 0 self.update() - if old_format_detected or self.last_block != last_block: + if ( + old_format_detected + or self.last_block != last_block + or "" in [address, change_address] + ): self.save_to_file() + @property + def recv_descriptor(self): + return add_checksum(str(self.descriptor.branch(0))) + + @property + def change_descriptor(self): + return add_checksum(str(self.descriptor.branch(1))) + @property def devices(self): return [ @@ -150,6 +178,61 @@ class Wallet: for device in self._devices ] + @property + def chain(self) -> str: + """String name of the chain""" + return self.manager.chain + + @property + def network(self) -> dict: + """Dictionary with network constants""" + return get_network(self.chain) + + @classmethod + def construct_descriptor(cls, sigs_required, key_type, keys, devices): + """ + Creates a wallet descriptor from arguments. + We need to pass `devices` for Liquid wallet, here it's not used. + """ + # get xpubs in a form [fgp/der]xpub from all keys + xpubs = [key.metadata["combined"] for key in keys] + # all keys joined with comma + desc_keys = ",".join(["%s/{0,1}/*" % xpub for xpub in xpubs]) + is_multisig = len(keys) > 1 + + # we start by constructing a base argument for descriptor wrappers + if is_multisig: + desc = f"sortedmulti({sigs_required},{desc_keys})" + else: + desc = desc_keys + + # now we iterate over script-type in reverse order + # to get sh(wpkh(xpub)) from sh-wpkh and xpub + arr = key_type.split("-") + for wrapper in arr[::-1]: + desc = f"{wrapper}({desc})" + return cls.DescriptorCls.from_string(desc) + + @classmethod + def merge_descriptors(cls, recv_descriptor: str, change_descriptor=None) -> str: + """Parses string with descriptors (change is optional) and creates a combined one""" + if change_descriptor is None and "/0/*" not in recv_descriptor: + raise SpecterError("Receive descriptor has strange derivation path") + if change_descriptor is None: + change_descriptor = recv_descriptor.split("#").replace("/0/*", "/1/*") + # remove checksums + recv_descriptor = recv_descriptor.split("#")[0] + change_descriptor = change_descriptor.split("#")[0] + if recv_descriptor.replace("/0/*", "/1/*") != change_descriptor: + raise SpecterError("Descriptors don't respect BIP-44") + combined = recv_descriptor.replace("/0/*", "/{0,1}/*") + # check it is valid + try: + cls.DescriptorCls.from_string(combined) + except Exception as e: + raise SpecterError(f"Invalid descriptor: {e}") + return combined + @classmethod def create( cls, @@ -165,59 +248,48 @@ class Wallet: keys, devices, core_version=None, + **kwargs, ): - """Creates a wallet. If core_version is not specified - get it from rpc""" - # get xpubs in a form [fgp/der]xpub from all keys - xpubs = [key.metadata["combined"] for key in keys] - recv_keys = ["%s/0/*" % xpub for xpub in xpubs] - change_keys = ["%s/1/*" % xpub for xpub in xpubs] - is_multisig = len(keys) > 1 - # we start by constructing an argument for descriptor wrappers - if is_multisig: - recv_descriptor = "sortedmulti({},{})".format( - sigs_required, ",".join(recv_keys) - ) - change_descriptor = "sortedmulti({},{})".format( - sigs_required, ",".join(change_keys) - ) - else: - recv_descriptor = recv_keys[0] - change_descriptor = change_keys[0] - # now we iterate over script-type in reverse order - # to get sh(wpkh(xpub)) from sh-wpkh and xpub - arr = key_type.split("-") - for el in arr[::-1]: - recv_descriptor = "%s(%s)" % (el, recv_descriptor) - change_descriptor = "%s(%s)" % (el, change_descriptor) - - recv_descriptor = AddChecksum(recv_descriptor) - change_descriptor = AddChecksum(change_descriptor) - if not recv_descriptor != change_descriptor: - raise SpecterError( - f"The recv_descriptor ({recv_descriptor}) is the same than the change_descriptor ({change_descriptor})" - ) + """Creates a wallet. If core_version is not specified - gets it from rpc""" + # we pass unknown kwargs here for inherited classes (see LWallet - there is a blinding key arg) + descriptor = cls.construct_descriptor( + sigs_required, key_type, keys, devices, **kwargs + ) # get Core version if we don't know it if core_version is None: core_version = rpc.getnetworkinfo().get("version", 0) + # Descriptor wallets were introduced in v0.21.0, but upgraded nodes may + # still have legacy wallets or can be compiled without sqlite support. + # Use getwalletinfo to check the wallet type. + # The "keypool" for descriptor wallets is automatically refilled use_descriptors = core_version >= 210000 + created = False if use_descriptors: # Use descriptor wallet - rpc.createwallet(os.path.join(rpc_path, alias), True, True, "", False, True) - else: + try: + rpc.createwallet( + os.path.join(rpc_path, alias), True, True, "", False, True + ) + created = True + except Exception as e: + logger.warning(e) + # if we failed to create or didn't try - create without descriptors + if not created: rpc.createwallet(os.path.join(rpc_path, alias), True) + use_descriptors = False wallet_rpc = rpc.wallet(os.path.join(rpc_path, alias)) # import descriptors args = [ { - "desc": desc, - "internal": change, + "desc": add_checksum(str(descriptor.branch(change))), + "internal": bool(change), "timestamp": "now", "watchonly": True, } - for (change, desc) in [(False, recv_descriptor), (True, change_descriptor)] + for change in [0, 1] ] for arg in args: if use_descriptors: @@ -229,9 +301,6 @@ class Wallet: if not args[0] != args[1]: raise SpecterError(f"{args[0]} is equal {args[1]}") - # Descriptor wallets were introduced in v0.21.0, but upgraded nodes may - # still have legacy wallets. Use getwalletinfo to check the wallet type. - # The "keypool" for descriptor wallets is automatically refilled if use_descriptors: res = wallet_rpc.importdescriptors(args) else: @@ -256,8 +325,7 @@ class Wallet: -1, 0, 0, - recv_descriptor, - change_descriptor, + str(descriptor), keys, devices, sigs_required, @@ -310,15 +378,13 @@ class Wallet: unconfirmed_selftransfers_txs = self.rpc.multi( [("gettransaction", txid) for txid in unconfirmed_selftransfers] ) + arr = [tx["result"] for tx in unconfirmed_selftransfers_txs if tx.get("result")] while True: - txlist = ( - self.rpc.listtransactions( - "*", - LISTTRANSACTIONS_BATCH_SIZE, - LISTTRANSACTIONS_BATCH_SIZE * idx, - True, - ) - + [tx["result"] for tx in unconfirmed_selftransfers_txs] + txlist = self.rpc.listtransactions( + "*", + LISTTRANSACTIONS_BATCH_SIZE, # count + LISTTRANSACTIONS_BATCH_SIZE * idx, # skip + True, ) # list of transactions that we don't know about, # or that it has a different blockhash (reorg / confirmed) @@ -327,21 +393,20 @@ class Wallet: res = [ tx for tx in txlist + # we don't know about tx if tx["txid"] not in self._transactions + # we don't know addresses or not self._transactions[tx["txid"]].get("address", None) + # blockhash is different (reorg / unconfirmed) or self._transactions[tx["txid"]].get("blockhash", None) != tx.get("blockhash", None) - or ( - self._transactions[tx["txid"]].get("blockhash", None) - and not self._transactions[tx["txid"]].get("blockheight", None) - ) # Fix for Core v19 with Specter v1 + # we have conflicts or self._transactions[tx["txid"]].get("conflicts", []) != tx.get("walletconflicts", []) ] - # TODO: Looks like Core ignore a consolidation (self-transfer) going into the change address (in listtransactions) - # This means it'll show unconfirmed for us forever... arr.extend(res) idx += 1 + # stop if we reached known transactions # not sure if Core <20 returns last batch or empty array at the end if ( len(res) < LISTTRANSACTIONS_BATCH_SIZE @@ -398,13 +463,11 @@ class Wallet: ] # Gets max index used receiving and change addresses - max_used_receiving = ( - self._addresses.max_index(change=False) - self.GAP_LIMIT - ) - max_used_change = self._addresses.max_index(change=True) - self.GAP_LIMIT + max_used_receiving = self._addresses.max_used_index(change=False) + max_used_change = self._addresses.max_used_index(change=True) for address in addresses_info: - desc = LDescriptor.from_string(address["desc"]) + desc = self.DescriptorCls.from_string(address["desc"]) indexes = [ { "idx": k.origin.derivation[-1], @@ -456,7 +519,14 @@ class Wallet: ] self._addresses.add(change_addresses, check_rpc=False) - self.delete_spent_pending_psbts([tx["hex"] for tx in txs.values()]) + # only delete with confirmed txs + self.delete_spent_pending_psbts( + [ + tx["hex"] + for tx in txs.values() + if tx.get("confirmations", 0) > 0 or tx.get("blockheight") + ] + ) self._transactions.add(txs) def import_electrum_label_export(self, electrum_label_export): @@ -535,11 +605,13 @@ class Wallet: else: max_recv = max(max_recv, a.index) updated = False - while max_recv >= self.address_index: - self.getnewaddress(change=False, save=False) + if max_recv >= self.address_index: + self.address = self.get_address(max_recv + 1, change=False) + self.address_index = max_recv + 1 updated = True - while max_change >= self.change_index: - self.getnewaddress(change=True, save=False) + if max_change >= self.change_index: + self.change_address = self.get_address(max_change + 1, change=True) + self.change_index = max_change + 1 updated = True # save only if needed if updated: @@ -634,6 +706,8 @@ class Wallet: logger.error("Could not construct a Wallet object from the data provided.") return + combined_descriptor = cls.merge_descriptors(recv_descriptor, change_descriptor) + return cls( name, alias, @@ -645,8 +719,7 @@ class Wallet: change_index, keypool, change_keypool, - recv_descriptor, - change_descriptor, + combined_descriptor, keys, devices, sigs_required, @@ -668,6 +741,8 @@ class Wallet: def check_utxo(self): try: + # listunspent only lists not locked utxos + # so we need to unlock, then list, then lock back locked_utxo = self.rpc.listlockunspent() if locked_utxo: self.rpc.lockunspent(True, locked_utxo) @@ -686,16 +761,9 @@ class Wallet: for tx in utxo: tx_data = self.gettransaction(tx["txid"], 0, full=False) tx["time"] = tx_data["time"] - tx["category"] = "send" + tx["category"] = tx_data.get("category") or "send" if "locked" not in tx: tx["locked"] = False - try: - # get category from the descriptor - recv or change - idx = tx["desc"].split("[")[1].split("]")[0].split("/")[-2] - if idx == "0": - tx["category"] = "receive" - except: - pass self.full_utxo = sorted(utxo, key=lambda utxo: utxo["time"], reverse=True) except Exception as e: self.full_utxo = [] @@ -719,10 +787,12 @@ class Wallet: self.keypoolrefill(0, end=self.change_keypool, change=True) value_on_address = 0 - # if not - just return + # if it was - update change address and save if value_on_address > 0: + self._addresses.set_used(self.change_address) self.change_index += 1 - self.getnewaddress(change=True) + self.change_address = self.get_address(self.change_index, change=True) + self.save_to_file() @property def utxo(self): @@ -744,6 +814,8 @@ class Wallet: "change_index": self.change_index, "keypool": self.keypool, "change_keypool": self.change_keypool, + # we still store two descriptors so it can directly copy-pasted from the file + # and to maintain backward compatibility "recv_descriptor": self.recv_descriptor, "change_descriptor": self.change_descriptor, "keys": [key.json for key in self.keys], @@ -754,11 +826,16 @@ class Wallet: if for_export: o["labels"] = self.export_labels() else: - o["pending_psbts"] = self.pending_psbts + o["pending_psbts"] = self.pending_psbts_dict() o["frozen_utxo"] = self.frozen_utxo o["last_block"] = self.last_block return o + def pending_psbts_dict(self): + return { + psbtid: psbtobj.to_dict() for psbtid, psbtobj in self.pending_psbts.items() + } + def save_to_file(self): write_json_file(self.to_json(), self.fullpath) self.manager.update() @@ -776,9 +853,9 @@ class Wallet: @property def use_descriptors(self): - if not hasattr(self, "info") or self.info != {}: + if not self.info: self.get_info() - return "descriptors" in self.info and self.info["descriptors"] == True + return self.info.get("descriptors", False) @property def is_multisig(self): @@ -791,14 +868,8 @@ class Wallet: @property def locked_amount(self): amount = 0 - for psbt in self.pending_psbts: - amount += sum( - [ - utxo.get("witness_utxo", {}).get("amount", 0) - or utxo.get("value", 0) - for utxo in self.pending_psbts[psbt]["inputs"] - ] - ) + for psbt in self.pending_psbts.values(): + amount += sum([inp.float_amount for inp in psbt.inputs]) return amount def delete_spent_pending_psbts(self, txs: list): @@ -818,9 +889,8 @@ class Wallet: utxos = set([(vin.txid, vin.vout) for vin in inputs]) # get psbt ids we need to delete psbtids = [] - for psbtid, psbtobj in self.pending_psbts.items(): - psbt = self.PSBTCls.from_string(psbtobj["base64"]) - psbtutxos = [(inp.vin.txid, inp.vin.vout) for inp in psbt.inputs] + for psbtid, psbt in self.pending_psbts.items(): + psbtutxos = [(inp.txid, inp.vout) for inp in psbt.inputs] for utxo in psbtutxos: if utxo in utxos: psbtids.append(psbtid) @@ -833,10 +903,10 @@ class Wallet: def delete_pending_psbt(self, txid, save=True): if txid and txid in self.pending_psbts: try: - self.rpc.lockunspent(True, self.pending_psbts[txid]["tx"]["vin"]) - except: + self.rpc.lockunspent(True, self.pending_psbts[txid].utxo_dict()) + except Exception as e: # UTXO was spent - pass + logger.warning(str(e)) del self.pending_psbts[txid] if save: self.save_to_file() @@ -870,27 +940,18 @@ class Wallet: self.save_to_file() def update_pending_psbt(self, psbt, txid, raw): - if txid in self.pending_psbts: - self.pending_psbts[txid]["base64"] = psbt - decodedpsbt = self.rpc.decodepsbt(psbt) - signed_devices = self.get_signed_devices(decodedpsbt) - self.pending_psbts[txid]["devices_signed"] = [ - dev.alias for dev in signed_devices - ] - if "hex" in raw: - self.pending_psbts[txid]["sigs_count"] = self.sigs_required - self.pending_psbts[txid]["raw"] = raw["hex"] - else: - self.pending_psbts[txid]["sigs_count"] = len(signed_devices) - self.save_to_file() - return self.pending_psbts[txid] - else: + if txid not in self.pending_psbts: raise SpecterError("Can't find pending PSBT with this txid") + cur_psbt = self.pending_psbts[txid] + cur_psbt.update(psbt, raw) + self.save_to_file() + return cur_psbt.to_dict() + def save_pending_psbt(self, psbt): - self.pending_psbts[psbt["tx"]["txid"]] = psbt + self.pending_psbts[psbt.txid] = psbt try: - self.rpc.lockunspent(False, psbt["tx"]["vin"]) + self.rpc.lockunspent(False, psbt.utxo_dict()) except: logger.debug( "Failed to lock UTXO for transaction, might be fine if the transaction is an RBF." @@ -1201,7 +1262,7 @@ class Wallet: """Returns None if rescanblockchain is not launched, value between 0 and 1 otherwise """ - if self.info.get("scanning", False) == False: + if not self.info.get("scanning", False): return None else: return self.info["scanning"]["progress"] @@ -1257,13 +1318,45 @@ class Wallet: pool = self.change_keypool if change else self.keypool if pool < index + self.GAP_LIMIT: self.keypoolrefill(pool, index + self.GAP_LIMIT, change=change) - desc = self.change_descriptor if change else self.recv_descriptor - return ( - LDescriptor.from_string(desc) - .derive(index) - .address(get_network(self.manager.chain)) + return self.descriptor.derive(index, branch_index=int(change)).address( + self.network ) + def derive_descriptor(self, index: int, change: bool, keep_xpubs=False): + """ + Derives descriptor for receiving or change address with `index`. + For sortedmulti descriptor also sorts keys. + keep_xpubs=False will replace xpubs with sec-serialized pubkeys, + keep_xpubs=True will fill derivation after xpub without actually deriving the key. + In both cases keys are sorted as will appear in witness script. + """ + branch = int(change) + if keep_xpubs: + # get receiving or change branch + desc = self.descriptor.branch(branch) + # sort keys + if desc.is_basic_multisig and desc.is_sorted: + args = desc.miniscript.args + # sort by derived sec(), first arg is a threshold + desc.miniscript.args = [args[0]] + sorted( + args[1:], key=lambda k: k.derive(index).sec() + ) + # fill indexes in allowed derivations + for k in desc.keys: + k.allowed_derivation.indexes = k.allowed_derivation.fill(index) + else: + desc = self.descriptor.derive(index, branch_index=branch) + # replace xpubs with pubkeys + for k in desc.keys: + k.key = k.key.get_public_key() + # sort keys + if desc.is_basic_multisig and desc.is_sorted: + args = desc.miniscript.args + desc.miniscript.args = [args[0]] + sorted( + args[1:], key=lambda k: k.sec() + ) + return desc + def get_descriptor(self, index=None, change=False, address=None): """ Returns address descriptor from index, change @@ -1279,16 +1372,14 @@ class Wallet: change = a.change if index is None: index = self.change_index if change else self.address_index - desc = self.change_descriptor if change else self.recv_descriptor - # remove blinding stuff from descriptor so HWI Descriptor can work - ldesc = LDescriptor.from_string(desc) - ldesc.blinding_key = None - desc = str(ldesc) - derived_desc = Descriptor.parse(desc).derive(index).serialize() - derived_desc_xpubs = ( - Descriptor.parse(desc).derive(index, keep_xpubs=True).serialize() - ) - return {"descriptor": derived_desc, "xpubs_descriptor": derived_desc_xpubs} + return { + "descriptor": add_checksum( + str(self.derive_descriptor(index, change, keep_xpubs=False)) + ), + "xpubs_descriptor": add_checksum( + str(self.derive_descriptor(index, change, keep_xpubs=True)) + ), + } def get_address_info(self, address): return self._addresses.get(address) @@ -1471,7 +1562,7 @@ class Wallet: @property def fullbalance(self): balance = self.balance - return balance["trusted"] + balance["untrusted_pending"] + return round(balance["trusted"] + balance["untrusted_pending"], 8) @property def available_balance(self): @@ -1480,7 +1571,7 @@ class Wallet: @property def full_available_balance(self): balance = self.available_balance - return balance["trusted"] + balance["untrusted_pending"] + return round(balance["trusted"] + balance["untrusted_pending"], 8) @property def addresses(self): @@ -1503,135 +1594,120 @@ class Wallet: amounts: [float], subtract: bool = False, subtract_from: int = 0, - fee_rate: float = 1.0, - selected_coins=[], - readonly=False, + fee_rate: float = 0, # fee rate to use, if less than MIN_FEE_RATE will use MIN_FEE_RATE, 0 for automatic + selected_coins=[], # list of dicts [{"txid": txid, "vout": vout}] + readonly=False, # fee estimation rbf=True, - existing_psbt=None, rbf_edit_mode=False, ): """ fee_rate: in sat/B or BTC/kB. If set to 0 Bitcoin Core sets feeRate automatically. """ - if fee_rate > 0 and fee_rate < self.MIN_FEE_RATE: + if fee_rate != 0 and fee_rate < self.MIN_FEE_RATE: fee_rate = self.MIN_FEE_RATE options = {"includeWatching": True, "replaceable": rbf} extra_inputs = [] + total_btc = round(sum(amounts), 8) + total_sats = round(sum(amounts) * 1e8) - if not existing_psbt: - if not rbf_edit_mode: - if self.full_available_balance < sum(amounts): - raise SpecterError( - f"Wallet {self.name} does not have sufficient funds to make the transaction." - ) + # if creating new tx - check we have enough balance + if not rbf_edit_mode and self.full_available_balance < total_btc: + raise SpecterError( + f"Wallet {self.name} does not have sufficient funds to make the transaction." + ) - if selected_coins != []: - still_needed = sum(amounts) - for coin in selected_coins: - coin_txid = coin.split(",")[0] - coin_vout = int(coin.split(",")[1]) - coin_amount = self.gettransaction(coin_txid, decode=True)["vout"][ - coin_vout - ]["value"] - extra_inputs.append({"txid": coin_txid, "vout": coin_vout}) - still_needed -= coin_amount - if still_needed < 0: - break - if still_needed > 0: - raise SpecterError( - "Selected coins does not cover Full amount! Please select more coins!" - ) - elif self.available_balance["trusted"] <= sum(amounts): - txlist = self.rpc.listunspent(0, 0) - b = sum(amounts) - self.available_balance["trusted"] - for tx in txlist: - extra_inputs.append({"txid": tx["txid"], "vout": tx["vout"]}) - b -= tx["amount"] - if b < 0: - break + if selected_coins: + # check we have enough balance in selected coins + sats_in_coins = sum( + [ + self._transactions.getfetch(coin["txid"]) + .tx.vout[coin["vout"]] + .value + for coin in selected_coins + ] + ) + if sats_in_coins < total_sats: + raise SpecterError( + "Selected coins does not cover Full amount! Please select more coins!" + ) + extra_inputs = selected_coins - # subtract fee from amount of this output: - # currently only one address is supported, so either - # empty array (subtract from change) or [0] - subtract_arr = [subtract_from] if subtract else [] + elif self.available_balance["trusted"] <= total_btc: + # if we don't have enough in confirmed txs - add unconfirmed outputs + txlist = self.rpc.listunspent(0, 0) + b = total_btc - self.available_balance["trusted"] + for tx in txlist: + extra_inputs.append({"txid": tx["txid"], "vout": int(tx["vout"])}) + b -= tx["amount"] + if b < 0: + break - options = { - "includeWatching": True, + # subtract fee from amount of this output: + subtract_arr = [subtract_from] if subtract else [] + + options.update( + { "changeAddress": self.change_address, "subtractFeeFromOutputs": subtract_arr, - "replaceable": rbf, } - - if self.manager.bitcoin_core_version_raw >= 210000: - options["add_inputs"] = selected_coins == [] - - if fee_rate > 0: - # bitcoin core needs us to convert sat/B to BTC/kB - options["feeRate"] = round((fee_rate * 1000) / 1e8, 8) - - r = self.rpc.walletcreatefundedpsbt( - extra_inputs, # inputs - [{addresses[i]: amounts[i]} for i in range(len(addresses))], # output - 0, # locktime - options, # options - True, # bip32-der - ) - - b64psbt = r["psbt"] - psbt = self.rpc.decodepsbt(b64psbt) - else: - psbt = existing_psbt - extra_inputs = [ - {"txid": tx["txid"], "vout": tx["vout"]} for tx in psbt["tx"]["vin"] - ] - if "changeAddress" in psbt: - options["changeAddress"] = psbt["changeAddress"] - if "base64" in psbt: - b64psbt = psbt["base64"] - - if fee_rate > 0.0: - if not existing_psbt: - psbt_fees_sats = int(psbt["fee"] * 1e8) - # estimate final size: add weight of inputs - tx_full_size = ceil( - psbt["tx"]["vsize"] - + len(psbt["inputs"]) * self.weight_per_input / 4 - ) - adjusted_fee_rate = ( - fee_rate - * (fee_rate / (psbt_fees_sats / psbt["tx"]["vsize"])) - * (tx_full_size / psbt["tx"]["vsize"]) - ) - options["feeRate"] = "%.8f" % round((adjusted_fee_rate * 1000) / 1e8, 8) - else: - options["feeRate"] = "%.8f" % round((fee_rate * 1000) / 1e8, 8) - r = self.rpc.walletcreatefundedpsbt( - extra_inputs, # inputs - [{addresses[i]: amounts[i]} for i in range(len(addresses))], # output - 0, # locktime - options, # options - True, # bip32-der - ) - - b64psbt = r["psbt"] - psbt = self.rpc.decodepsbt(b64psbt) - psbt["fee_rate"] = options["feeRate"] - # estimate full size - tx_full_size = ceil( - psbt["tx"]["vsize"] + len(psbt["inputs"]) * self.weight_per_input / 4 ) - psbt["tx_full_size"] = tx_full_size - psbt["base64"] = b64psbt - psbt["amount"] = amounts - psbt["address"] = addresses - psbt["time"] = time.time() - psbt["sigs_count"] = 0 + if self.manager.bitcoin_core_version_raw >= 210000: + options["add_inputs"] = not selected_coins + + if fee_rate > 0: + # bitcoin core needs us to convert sat/B to BTC/kB + options["feeRate"] = round((fee_rate * 1000) / 1e8, 8) + + try: + locktime = min([tip["height"] for tip in self.rpc.getchaintips()]) + except: + locktime = 0 + + # first run to get estimate + r = self.rpc.walletcreatefundedpsbt( + extra_inputs, # inputs + [{addresses[i]: amounts[i]} for i in range(len(addresses))], # outputs + locktime, + options, + True, # bip32-der + ) + + b64psbt = r["psbt"] + psbt = self.PSBTCls( + b64psbt, + self.descriptor, + self.network, + devices=list(zip(self.keys, self._devices)), + ) + + # Core's fee rate is wrong so we need to "adjust" it + if fee_rate > 0: + # scale by which Core misses the fee rate + scale = fee_rate / psbt.fee_rate + adjusted_fee_rate = fee_rate * scale + options["feeRate"] = round((adjusted_fee_rate * 1000) / 1e8, 8) + + r = self.rpc.walletcreatefundedpsbt( + extra_inputs, # inputs + [{addresses[i]: amounts[i]} for i in range(len(addresses))], # outputs + locktime, + options, + True, # bip32-der + ) + + b64psbt = r["psbt"] + psbt = self.PSBTCls( + b64psbt, + self.descriptor, + self.network, + devices=list(zip(self.keys, self._devices)), + ) + if not readonly: self.save_pending_psbt(psbt) - - return psbt + return psbt.to_dict() def get_rbf_utxo(self, rbf_tx_id): decoded_tx = self.decode_tx(rbf_tx_id) @@ -1660,118 +1736,89 @@ class Wallet: ] def decode_tx(self, txid): + psbt = self.psbt_from_txid(txid) + outputs = [out for out in psbt.outputs if not out.is_change] + return { + "addresses": [out.address for out in outputs], + "amounts": [out.float_amount for out in outputs], + "used_utxo": psbt.utxo_dict(), + } + + def psbt_from_txid(self, txid): + """Converts a transaction with txid to PSBT instance""" raw_tx = self.gettransaction(txid)["hex"] + # fill derivation info raw_psbt = self.rpc.utxoupdatepsbt( self.rpc.converttopsbt(raw_tx, True), [self.recv_descriptor, self.change_descriptor], ) - - psbt = self.rpc.decodepsbt(raw_psbt) - return { - "addresses": [ - get_address_from_dict(vout["scriptPubKey"]) - for i, vout in enumerate(psbt["tx"]["vout"]) - if not self.get_address_info( - get_address_from_dict(vout["scriptPubKey"]) - ) - or not self.get_address_info( - get_address_from_dict(vout["scriptPubKey"]) - ).change - ], - "amounts": [ - vout["value"] - for i, vout in enumerate(psbt["tx"]["vout"]) - if not self.get_address_info( - get_address_from_dict(vout["scriptPubKey"]) - ) - or not self.get_address_info( - get_address_from_dict(vout["scriptPubKey"]) - ).change - ], - "used_utxo": [ - {"txid": vin["txid"], "vout": vin["vout"]} for vin in psbt["tx"]["vin"] - ], - } + return self.PSBTCls( + raw_psbt, + self.descriptor, + self.network, + devices=list(zip(self.keys, self._devices)), + ) def canceltx(self, txid, fee_rate): self.check_unused() - raw_tx = self.gettransaction(txid)["hex"] - raw_psbt = self.rpc.utxoupdatepsbt( - self.rpc.converttopsbt(raw_tx, True), - [self.recv_descriptor, self.change_descriptor], - ) - - psbt = self.rpc.decodepsbt(raw_psbt) - decoded_tx = self.decode_tx(txid) - selected_coins = [ - f"{utxo['txid']}, {utxo['vout']}" for utxo in decoded_tx["used_utxo"] - ] - return self.createpsbt( - addresses=[self.address], - amounts=[ - sum( - vout["witness_utxo"]["amount"] - for i, vout in enumerate(psbt["inputs"]) - ) - ], - subtract=True, - fee_rate=float(fee_rate), - selected_coins=selected_coins, - readonly=False, - rbf=True, - rbf_edit_mode=True, - ) + psbt = self.psbt_from_txid(txid) + # find change output + change_index = None + for i, out in enumerate(psbt.outputs): + if out.is_change: + change_index = i + break + sum_outputs = sum([out.value for out in psbt.psbt.outputs]) + old_fee = psbt.fee + if change_index is not None: + change_out = psbt.psbt.outputs[change_index] + else: + # create fresh output and replace script_pubkey in output + desc = self.descriptor.branch(1).derive(self.change_index) + change_out = psbt.psbt.PSBTOUT_CLS(vout=psbt.psbt.outputs[0].vout) + change_out.script_pubkey = desc.script_pubkey() + self.PSBTCls.fill_output(change_out, desc) + # set new value - everything to us + change_out.value = sum_outputs + # set new outputs in psbt - only us + psbt.psbt.outputs = [change_out] + # how much we need to subtract? + fee_delta = psbt.full_size * fee_rate - old_fee + # we must use larger fees by at least the txsize * min + if fee_delta < psbt.full_size * self.MIN_FEE_RATE: + fee_delta = psbt.full_size * self.MIN_FEE_RATE + if change_out.value <= round(fee_delta): + raise SpecterError("Not enough funds in the transaction") + change_out.value -= round(fee_delta) + # fill derivation info + self.save_pending_psbt(psbt) + return psbt.to_dict() def bumpfee(self, txid, fee_rate): - raw_tx = self.gettransaction(txid)["hex"] - raw_psbt = self.rpc.utxoupdatepsbt( - self.rpc.converttopsbt(raw_tx, True), - [self.recv_descriptor, self.change_descriptor], - ) - - psbt = self.rpc.decodepsbt(raw_psbt) - psbt["changeAddress"] = [ - get_address_from_dict(vout["scriptPubKey"]) - for i, vout in enumerate(psbt["tx"]["vout"]) - if self.get_address_info(get_address_from_dict(vout["scriptPubKey"])) - and self.get_address_info( - get_address_from_dict(vout["scriptPubKey"]) - ).change - ] - if psbt["changeAddress"]: - psbt["changeAddress"] = psbt["changeAddress"][0] - else: - raise Exception("Cannot RBF a transaction with no change output") - return self.createpsbt( - addresses=[ - get_address_from_dict(vout["scriptPubKey"]) - for i, vout in enumerate(psbt["tx"]["vout"]) - if not self.get_address_info( - get_address_from_dict(vout["scriptPubKey"]) - ) - or not self.get_address_info( - get_address_from_dict(vout["scriptPubKey"]) - ).change - ], - amounts=[ - vout["value"] - for i, vout in enumerate(psbt["tx"]["vout"]) - if not self.get_address_info( - get_address_from_dict(vout["scriptPubKey"]) - ) - or not self.get_address_info( - get_address_from_dict(vout["scriptPubKey"]) - ).change - ], - fee_rate=fee_rate, - readonly=False, - rbf=True, - existing_psbt=psbt, - ) + psbt = self.psbt_from_txid(txid) + fee_delta = fee_rate * psbt.full_size - psbt.fee + if fee_delta < self.MIN_FEE_RATE * psbt.full_size: + raise SpecterError( + "Fee difference is too small to relay the RBF transaction" + ) + # find change output + change_index = None + for i, out in enumerate(psbt.outputs): + if out.is_change: + change_index = i + break + if change_index is None: + raise SpecterError("Can't bump fee in a transaction without change outputs") + psbt.psbt.outputs[i].value -= round(fee_delta) + if psbt.psbt.outputs[i].value <= 0: + # if we went negative - just drop the change output + psbt.psbt.outputs.remove(psbt.psbt.outputs[i]) + self.save_pending_psbt(psbt) + return psbt.to_dict() @property def is_taproot(self): - return "tr(" in self.recv_descriptor + return self.descriptor.is_taproot def fill_psbt( self, @@ -1785,17 +1832,14 @@ class Wallet: # Core doesn't fill derivations yet, so we do it ourselves if taproot_derivations and self.is_taproot: - net = get_network(self.manager.chain) + net = self.network for sc in psbt.inputs + psbt.outputs: addr = sc.script_pubkey.address(net) info = self._addresses.get(addr) if info and not info.is_external: - desc = ( - self.recv_descriptor - if info.is_receiving - else self.change_descriptor + d = self.descriptor.derive( + info.index, branch_index=int(info.change) ) - d = LDescriptor.from_string(desc).derive(info.index) for k in d.keys: sc.bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath( k.origin.fingerprint, k.origin.derivation @@ -1861,64 +1905,19 @@ class Wallet: return signed_devices def importpsbt(self, b64psbt): - # TODO: check maybe some of the inputs are already locked - psbt = self.rpc.decodepsbt(b64psbt) - psbt["base64"] = b64psbt - amount = [] - address = [] - # get output address and amount - for out in psbt["tx"]["vout"]: - if ( - "addresses" not in out["scriptPubKey"] - or len(out["scriptPubKey"]["addresses"]) == 0 - ) and "address" not in out["scriptPubKey"]: - # TODO: we need to handle it somehow differently - raise SpecterError("Sending to raw scripts is not supported yet") - addr = get_address_from_dict(out["scriptPubKey"]) - info = self.get_address_info(addr) - # check if it's a change - if info and info.change: - continue - address.append(addr) - amount.append(out["value"]) - - psbt = self.createpsbt( - addresses=address, - amounts=amount, - fee_rate=0.0, - readonly=False, - existing_psbt=psbt, + # TODO: check if some of the inputs are already locked + psbt = self.PSBTCls( + b64psbt, + self.descriptor, + self.network, + devices=list(zip(self.keys, self._devices)), ) - - signed_devices = self.get_signed_devices(psbt) - psbt["devices_signed"] = [dev.alias for dev in signed_devices] - psbt["sigs_count"] = len(signed_devices) raw = self.rpc.finalizepsbt(b64psbt) if "hex" in raw: - psbt["raw"] = raw["hex"] + psbt.update(None, raw) - return psbt - - @property - def weight_per_input(self): - """Calculates the weight of a signed input""" - if self.is_multisig: - input_size = 3 # OP_M OP_N ... OP_CHECKMULTISIG - # pubkeys - input_size += 34 * len(self.keys) - # signatures - input_size += 75 * self.sigs_required - - if not self.recv_descriptor.startswith("wsh"): - # P2SH scriptsig: 22 00 20 <32-byte-hash> - input_size += 35 * 4 - return input_size - # else: single-sig - if self.recv_descriptor.startswith("wpkh"): - # pubkey, signature - return 75 + 34 - # pubkey, signature, 4* P2SH: 16 00 14 20-byte-hash - return 75 + 34 + 23 * 4 + self.save_pending_psbt(psbt) + return psbt.to_dict() def addresses_info(self, is_change): """Create a list of (receive or change) addresses from cache and retrieve the @@ -1929,7 +1928,7 @@ class Wallet: addresses_info = [] addresses_cache = [ - v for _, v in self._addresses.items() if v.change == is_change + v for _, v in self._addresses.items() if v.change == is_change and v.is_mine ] for addr in addresses_cache: diff --git a/tests/elements_gitrev_pinned b/tests/elements_gitrev_pinned index 0ad1bdd8a..c6982dc38 100644 --- a/tests/elements_gitrev_pinned +++ b/tests/elements_gitrev_pinned @@ -1 +1 @@ -elements-0.21.0_rc1 \ No newline at end of file +elements-0.21.0_rc2 \ No newline at end of file diff --git a/tests/test_rest.py b/tests/test_rest.py index ed2ae2e4b..023add2da 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -2,6 +2,18 @@ import pytest import json import base64 import logging +from numbers import Number + + +def almost_equal(a: Number, b: Number, precision: float = 0.01) -> bool: + """ + Checks if a and b are not very different. + Default precision is 1% + """ + if a == b: + return True + diff = 2 * (a - b) / (a + b) + return (diff < precision) and (diff > -precision) def test_rr_psbt_get(client, caplog): @@ -55,7 +67,7 @@ def test_rr_psbt_get(client, caplog): ) assert result.status_code == 200 data = json.loads(result.data) - assert data["result"] == [] + assert data["result"] == {} def test_rr_psbt_post(specter_regtest_configured, client, caplog): @@ -125,7 +137,7 @@ def test_rr_psbt_post(specter_regtest_configured, client, caplog): assert data["result"]["tx"] assert data["result"]["inputs"] assert data["result"]["outputs"] - assert data["result"]["fee_rate"] == "0.00064000" + assert almost_equal(data["result"]["fee_rate"], 64) assert data["result"]["tx_full_size"] assert data["result"]["base64"] assert data["result"]["time"] diff --git a/tests/test_util_psbt_creator.py b/tests/test_util_psbt_creator.py index b887106fb..527d87b39 100644 --- a/tests/test_util_psbt_creator.py +++ b/tests/test_util_psbt_creator.py @@ -52,7 +52,7 @@ def test_PsbtCreator_ui(caplog): "rbf": True, "rbf_edit_mode": False, "readonly": False, - "selected_coins": None, + "selected_coins": [], "subtract": False, "subtract_from": 0, } @@ -107,7 +107,7 @@ bcrt1q3kfetuxpxvujasww6xas94nawklvpz0e52uw8a, 0.5 "rbf": True, "rbf_edit_mode": False, "readonly": False, - "selected_coins": None, + "selected_coins": [], "subtract": False, "subtract_from": 0, } diff --git a/tests/test_util_wallet_importer.py b/tests/test_util_wallet_importer.py index 22c35479d..495cd4498 100644 --- a/tests/test_util_wallet_importer.py +++ b/tests/test_util_wallet_importer.py @@ -39,7 +39,9 @@ def test_WalletImporter_unit(): {"label": "MyTestTrezor", "type": "trezor"}, ] # The descriptor (very briefly) - assert wallet_importer.descriptor.origin_fingerprint == ["fb7c1f11", "1ef4e492"] + assert [ + key.origin.fingerprint.hex() for key in wallet_importer.descriptor.keys + ] == ["fb7c1f11", "1ef4e492"] assert len(wallet_importer.keys) == 0 assert len(wallet_importer.cosigners) == 0 assert len(wallet_importer.unknown_cosigners) == 2 diff --git a/tests/test_wallet_manager.py b/tests/test_wallet_manager.py index ebf96ca8c..0dffc0077 100644 --- a/tests/test_wallet_manager.py +++ b/tests/test_wallet_manager.py @@ -142,9 +142,8 @@ def test_wallet_createpsbt(docker, request, devices_filled_data_folder, device_m unspents = wallet.rpc.listunspent(0) # Lets take 3 more or less random txs from the unspents: selected_coins = [ - "{},{}".format(unspents[5]["txid"], unspents[5]["vout"]), - "{},{}".format(unspents[9]["txid"], unspents[9]["vout"]), - "{},{}".format(unspents[12]["txid"], unspents[12]["vout"]), + {"txid": u["txid"], "vout": u["vout"]} + for u in [unspents[5], unspents[9], unspents[12]] ] selected_coins_amount_sum = ( unspents[5]["amount"] + unspents[9]["amount"] + unspents[12]["amount"] @@ -163,7 +162,7 @@ def test_wallet_createpsbt(docker, request, devices_filled_data_folder, device_m assert len(psbt["tx"]["vin"]) == 3 psbt_txs = [tx["txid"] for tx in psbt["tx"]["vin"]] for coin in selected_coins: - assert coin.split(",")[0] in psbt_txs + assert coin["txid"] in psbt_txs # Now let's spend more coins than we have selected. This should result in an exception: try: