diff --git a/package-lock.json b/package-lock.json index 7ca7370..4a3fff1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.0.49", "license": "MIT", "dependencies": { - "@bitcoinerlab/secp256k1": "1.0.5", + "@bitcoinerlab/coinselect": "1.2.1", + "@bitcoinerlab/descriptors": "2.2.0", + "@bitcoinerlab/secp256k1": "1.1.1", "bech32": "2.0.0", "bip21": "2.0.3", "bip32": "4.0.0", @@ -34,7 +36,7 @@ "eslint": "8.4.1", "eslint-config-prettier": "9.0.0", "eslint-plugin-prettier": "5.0.0", - "mocha": "10.1.0", + "mocha": "^10.8.2", "prettier": "3.0.2", "sinon": "18.0.0", "ts-node": "10.9.0", @@ -43,10 +45,48 @@ "typescript": "4.9" } }, + "node_modules/@bitcoinerlab/coinselect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/coinselect/-/coinselect-1.2.1.tgz", + "integrity": "sha512-4wCo/beY2qq9qFv9YZJl4tUf3O6jY1tjhGE43t+0K+moxsRvqR+43wsS3ZTVHpQJ5pjeUowR8q+hF3wQIdx8iQ==", + "dependencies": { + "@bitcoinerlab/descriptors": "^2.1.0" + } + }, + "node_modules/@bitcoinerlab/descriptors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors/-/descriptors-2.2.0.tgz", + "integrity": "sha512-z9GOZYT5jO8oE4aq8XF0czGW1W9JrJXdslUaCfUMEMo5KnygKZhOFXZCHEvtdrEXBACURh1+b/MVhy3pH9Pi7g==", + "dependencies": { + "@bitcoinerlab/miniscript": "^1.4.0", + "@bitcoinerlab/secp256k1": "^1.1.1", + "bip32": "^4.0.0", + "bitcoinjs-lib": "^6.1.3", + "ecpair": "^2.1.0", + "lodash.memoize": "^4.1.2", + "varuint-bitcoin": "^1.1.2" + }, + "peerDependencies": { + "ledger-bitcoin": "^0.2.2" + }, + "peerDependenciesMeta": { + "ledger-bitcoin": { + "optional": true + } + } + }, + "node_modules/@bitcoinerlab/miniscript": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/miniscript/-/miniscript-1.4.0.tgz", + "integrity": "sha512-BsG3dmwQmgKHnRZecDgUsPjwcpnf1wgaZbolcMTByS10k1zYzIx97W51LzG7GvokRJ+wnzTX/GhC8Y3L2X0CQA==", + "dependencies": { + "bip68": "^1.0.4" + } + }, "node_modules/@bitcoinerlab/secp256k1": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.0.5.tgz", - "integrity": "sha512-8gT+ukTCFN2rTxn4hD9Jq3k+UJwcprgYjfK/SQUSLgznXoIgsBnlPuARMkyyuEjycQK9VvnPiejKdszVTflh+w==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.1.1.tgz", + "integrity": "sha512-uhjW51WfVLpnHN7+G0saDcM/k9IqcyTbZ+bDgLF3AX8V/a3KXSE9vn7UPBrcdU72tp0J4YPR7BHp2m7MLAZ/1Q==", "dependencies": { "@noble/hashes": "^1.1.5", "@noble/secp256k1": "^1.7.1" @@ -677,6 +717,14 @@ "@noble/hashes": "^1.2.0" } }, + "node_modules/bip68": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bip68/-/bip68-1.0.4.tgz", + "integrity": "sha512-O1htyufFTYy3EO0JkHg2CLykdXEtV2ssqw47Gq9A0WByp662xpJnMEB9m43LZjsSDjIAOozWRExlFQk2hlV1XQ==", + "engines": { + "node": ">=4.5.0" + } + }, "node_modules/bitcoin-address-validation": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/bitcoin-address-validation/-/bitcoin-address-validation-2.2.3.tgz", @@ -701,23 +749,6 @@ "lodash": ">=4.17.21" } }, - "node_modules/bitcoin-json-rpc/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/bitcoin-units": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/bitcoin-units/-/bitcoin-units-0.3.0.tgz", @@ -983,9 +1014,9 @@ "dev": true }, "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==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "dependencies": { "path-key": "^3.1.0", @@ -997,12 +1028,12 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1072,9 +1103,9 @@ } }, "node_modules/diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", "dev": true, "engines": { "node": ">=0.3.1" @@ -2067,6 +2098,11 @@ "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", "dev": true }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2133,12 +2169,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -2167,32 +2203,31 @@ } }, "node_modules/mocha": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.1.0.tgz", - "integrity": "sha512-vUF7IYxEoN7XhQpFLxQAEMtE4W91acW4B6En9l97MwE9stL1A9gusXfoHZCLVHDUJ/7V5+lbCM6yMqzo5vNymg==", - "dev": true, - "dependencies": { - "ansi-colors": "4.1.1", - "browser-stdout": "1.3.1", - "chokidar": "3.5.3", - "debug": "4.3.4", - "diff": "5.0.0", - "escape-string-regexp": "4.0.0", - "find-up": "5.0.0", - "glob": "7.2.0", - "he": "1.2.0", - "js-yaml": "4.1.0", - "log-symbols": "4.1.0", - "minimatch": "5.0.1", - "ms": "2.1.3", - "nanoid": "3.3.3", - "serialize-javascript": "6.0.0", - "strip-json-comments": "3.1.1", - "supports-color": "8.1.1", - "workerpool": "6.2.1", - "yargs": "16.2.0", - "yargs-parser": "20.2.4", - "yargs-unparser": "2.0.0" + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "dev": true, + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" }, "bin": { "_mocha": "bin/_mocha", @@ -2200,19 +2235,6 @@ }, "engines": { "node": ">= 14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mochajs" - } - }, - "node_modules/mocha/node_modules/ansi-colors": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", - "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", - "dev": true, - "engines": { - "node": ">=6" } }, "node_modules/mocha/node_modules/brace-expansion": { @@ -2224,10 +2246,30 @@ "balanced-match": "^1.0.0" } }, + "node_modules/mocha/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mocha/node_modules/minimatch": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", - "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -2236,12 +2278,6 @@ "node": ">=10" } }, - "node_modules/mocha/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -2258,23 +2294,11 @@ } }, "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==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, - "node_modules/nanoid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", - "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", - "dev": true, - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2418,9 +2442,9 @@ } }, "node_modules/path-to-regexp": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", - "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, "node_modules/path-type": { @@ -2701,9 +2725,9 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", - "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "dependencies": { "randombytes": "^2.1.0" @@ -2810,15 +2834,6 @@ "url": "https://opencollective.com/sinon" } }, - "node_modules/sinon/node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -3240,9 +3255,9 @@ "dev": true }, "node_modules/workerpool": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", - "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", "dev": true }, "node_modules/wrap-ansi": { @@ -3296,9 +3311,9 @@ } }, "node_modules/yargs-parser": { - "version": "20.2.4", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", - "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, "engines": { "node": ">=10" diff --git a/package.json b/package.json index 52b8817..a79eff5 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "dist/index.js", "scripts": { "test": "yarn build && env mocha --exit -r ts-node/register 'tests/**/*.test.ts'", + "test:send": "yarn build && env mocha --exit -r ts-node/register 'tests/send.test.ts'", "test:boost": "yarn build && env mocha --exit -r ts-node/register 'tests/boost.test.ts'", "test:wallet": "yarn build && env mocha --exit -r ts-node/register 'tests/wallet.test.ts'", "test:receive": "yarn build && env mocha --exit -r ts-node/register 'tests/receive.test.ts'", @@ -39,7 +40,9 @@ }, "homepage": "https://github.com/synonymdev/beignet#readme", "dependencies": { - "@bitcoinerlab/secp256k1": "1.0.5", + "@bitcoinerlab/coinselect": "1.2.1", + "@bitcoinerlab/descriptors": "2.2.0", + "@bitcoinerlab/secp256k1": "1.1.1", "bech32": "2.0.0", "bip21": "2.0.3", "bip32": "4.0.0", @@ -64,7 +67,7 @@ "eslint": "8.4.1", "eslint-config-prettier": "9.0.0", "eslint-plugin-prettier": "5.0.0", - "mocha": "10.1.0", + "mocha": "^10.8.2", "prettier": "3.0.2", "sinon": "18.0.0", "ts-node": "10.9.0", diff --git a/src/shapes/wallet.ts b/src/shapes/wallet.ts index ca22c9e..90f620e 100644 --- a/src/shapes/wallet.ts +++ b/src/shapes/wallet.ts @@ -3,7 +3,8 @@ import { TAddressTypeContent, IHeader, TAddressTypes, - IOnchainFees + IOnchainFees, + ECoinSelect } from '../types'; import cloneDeep from 'lodash.clonedeep'; import { @@ -61,6 +62,7 @@ export const defaultAddressContent: Readonly = { export const defaultSendTransaction: ISendTransaction = { outputs: [], inputs: [], + availableInputs: [], changeAddress: '', fiatAmount: 0, fee: 512, @@ -73,7 +75,8 @@ export const defaultSendTransaction: ISendTransaction = { max: false, tags: [], lightningInvoice: '', - selectedFeeId: EFeeId.none + selectedFeeId: EFeeId.none, + coinselect: ECoinSelect.default }; export const getDefaultSendTransaction = (): ISendTransaction => { diff --git a/src/transaction/index.ts b/src/transaction/index.ts index ed7e65e..ddc2f50 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -1,45 +1,47 @@ +import { coinselect as cs, maxFunds } from '@bitcoinerlab/coinselect'; +import { DescriptorsFactory } from '@bitcoinerlab/descriptors'; +import ecc, * as secp256k1 from '@bitcoinerlab/secp256k1'; +import { BIP32Interface } from 'bip32'; +import { getAddressInfo } from 'bitcoin-address-validation'; +import * as bitcoin from 'bitcoinjs-lib'; +import { networks, Psbt } from 'bitcoinjs-lib'; +import { ECPairInterface } from 'ecpair'; + +import { getDefaultSendTransaction } from '../shapes'; import { EAddressType, EBoostType, + ECoinSelect, EFeeId, + IAddInput, IAddresses, + ICreateTransaction, IOutput, ISendTransaction, + ISetupTransaction, + ITargets, IUtxo, - TGetTotalFeeObj + TGetTotalFeeObj, + TSetupTransactionResponse } from '../types'; -import { getDefaultSendTransaction } from '../shapes'; -import { Wallet } from '../wallet'; -import { - Result, - ok, - err, - validateTransaction, - getTapRootAddressFromPublicKey, - isP2trPrefix -} from '../utils'; -import { reduceValue, shuffleArray } from '../utils'; -import { TRANSACTION_DEFAULTS } from '../wallet/constants'; import { constructByteCountParam, + err, getByteCount, + getTapRootAddressFromPublicKey, + isP2trPrefix, + ok, + reduceValue, removeDustOutputs, - setReplaceByFee + Result, + setReplaceByFee, + shuffleArray, + validateTransaction } from '../utils'; -import { - IAddInput, - ICreateTransaction, - ISetupTransaction, - ITargets, - TSetupTransactionResponse -} from '../types'; -import { networks, Psbt } from 'bitcoinjs-lib'; -import { BIP32Interface } from 'bip32'; -import ecc from '@bitcoinerlab/secp256k1'; -import * as bitcoin from 'bitcoinjs-lib'; -import { getAddressInfo } from 'bitcoin-address-validation'; -import { ECPairInterface } from 'ecpair'; +import { Wallet } from '../wallet'; +import { TRANSACTION_DEFAULTS } from '../wallet/constants'; +const { Output } = DescriptorsFactory(secp256k1); bitcoin.initEccLib(ecc); export class Transaction { @@ -70,7 +72,8 @@ export class Transaction { utxos, rbf = false, satsPerByte = 1, - outputs + outputs, + coinselect = ECoinSelect.default }: ISetupTransaction = {}): Promise { try { const addressType = this._wallet.addressType; @@ -80,27 +83,20 @@ export class Transaction { const transaction = currentWallet.transaction; // Gather required inputs. - let inputs: IUtxo[] = []; + let availableInputs: IUtxo[] = []; if (inputTxHashes) { // If specified, filter for the desired tx_hash and push the utxo as an input. - inputs = currentWallet.utxos.filter((utxo) => { + availableInputs = currentWallet.utxos.filter((utxo) => { return inputTxHashes.includes(utxo.tx_hash); }); } else if (utxos) { - inputs = utxos; + availableInputs = utxos; } else { - inputs = currentWallet.utxos; + availableInputs = currentWallet.utxos; } - if (!inputs.length) { - // If inputs were previously selected, leave them. - if (transaction.inputs.length > 0) { - inputs = transaction.inputs; - } else { - // Otherwise, lets use our available utxo's. - inputs = this.removeBlackListedUtxos(currentWallet.utxos); - } - } + availableInputs = this.removeBlackListedUtxos(availableInputs); + const inputs = this.removeBlackListedUtxos(availableInputs); if (!inputs.length) { return err('No inputs specified in setupTransaction.'); @@ -146,18 +142,21 @@ export class Transaction { message: '', transaction: { ...transaction, + availableInputs, inputs, outputs } }); const payload = { + availableInputs, inputs, changeAddress, fee, outputs, rbf, - satsPerByte + satsPerByte, + coinselect }; this._data = { @@ -174,6 +173,122 @@ export class Transaction { } } + recalculate = ({ + transaction = this.data, + satsPerByte = this._data.satsPerByte + }: { + transaction?: ISendTransaction; + satsPerByte?: number; + }): Result => { + const { availableInputs, coinselect } = transaction; + + try { + const targets = transaction.outputs.map((output) => { + return { + output: new Output({ + network: networks[this._wallet.network], + descriptor: `addr(${output.address})` + }), + value: output.value + }; + }); + + let selection: ReturnType = undefined; + + const utxos = availableInputs.map((input) => { + return { + output: new Output({ + network: networks[this._wallet.network], + descriptor: `addr(${input.address})` + }), + value: input.value + }; + }); + + if (coinselect === ECoinSelect.maxFunds) { + if (transaction.outputs.length !== 1) { + throw new Error('Max send requires a single output.'); + } + const remainder = new Output({ + network: networks[this._wallet.network], + descriptor: `addr(${transaction.outputs[0].address})` + }); + selection = maxFunds({ + utxos, + targets: [], + remainder, + feeRate: satsPerByte + }); + } else if (coinselect === ECoinSelect.manual) { + const remainder = new Output({ + network: networks[this._wallet.network], + descriptor: `addr(${transaction.changeAddress})` + }); + + selection = cs({ + utxos, + targets, + remainder, + feeRate: satsPerByte + }); + } else { + const remainder = new Output({ + network: networks[this._wallet.network], + descriptor: `addr(${transaction.changeAddress})` + }); + selection = cs({ + utxos, + targets, + remainder, + feeRate: satsPerByte + }); + } + + if (selection === undefined) { + throw new Error('Unable to find a suitable selection.'); + } + + const inputs = availableInputs.filter((oi) => { + // Redundant check, just to make TS happy. + if (selection === undefined) { + throw new Error('Unable to find a suitable selection.'); + } + return selection.utxos.find( + (output) => output.output.getAddress() === oi.address + ); + }); + + // we need to update the outputs, because in case of max send we need to re-calculate the amount + const outputs = selection.targets + .filter( + (target) => target.output.getAddress() !== transaction.changeAddress + ) + .map((target, index) => ({ + address: target.output.getAddress(), + value: target.value, + index + })); + + // find change, it might not exist + // const change = selection.targets.find((target) => { + // return target.output.getAddress() === transaction.changeAddress; + // }); + + const data = { + ...this._data, + inputs, + outputs, + fee: selection.fee + }; + + // await this._wallet.saveWalletData('transaction', this.data); + + return ok(data); + } catch (e) { + return err(e); + } + }; + /** * This completely resets the send transaction state. * @returns {Promise>} @@ -204,6 +319,29 @@ export class Transaction { }); } + getTotalFeeNew = ({ + // message = '', + // fundingLightning = false + satsPerByte, + transaction = this.data + }: { + // message?: string; + // fundingLightning?: boolean; + satsPerByte: number; + transaction?: ISendTransaction; + }): number => { + const baseTransactionSize = TRANSACTION_DEFAULTS.recommendedBaseFee; + try { + const data = this.recalculate({ transaction, satsPerByte }); + if (data.isErr()) { + throw new Error(data.error.message); + } + return data.value.fee; + } catch { + return baseTransactionSize * satsPerByte; + } + }; + /** * Attempt to estimate the current fee for a given transaction and its UTXO's * @param {number} [satsPerByte] @@ -795,11 +933,12 @@ export class Transaction { }); if (feeInfo.isErr()) return err(feeInfo.error.message); const feeUpdateRes = this.updateFee({ - satsPerByte, - transaction: { - ...transaction, - inputs: _inputs - } + satsPerByte + // FIXME + // transaction: { + // ...transaction, + // inputs: _inputs + // } }); if (feeUpdateRes.isErr()) return err(feeUpdateRes.error.message); const updateSendRes = this.updateSendTransaction({ @@ -897,6 +1036,21 @@ export class Transaction { } }; + public updateFee({ + satsPerByte, + selectedFeeId = EFeeId.custom + }: { + satsPerByte: number; + selectedFeeId?: EFeeId; + }): Result<{ fee: number }> { + const updateRes = this.recalculate({ satsPerByte }); + if (updateRes.isErr()) return err(updateRes.error.message); + const transaction = updateRes.value; + transaction.selectedFeeId = selectedFeeId; + this.updateSendTransaction({ transaction: updateRes.value }); + return ok({ fee: transaction.fee }); + } + /** * Updates the fee for the current transaction by the specified amount. * @param {number} [satsPerByte] @@ -905,7 +1059,7 @@ export class Transaction { * @param {ISendTransaction} [transaction] * @returns {Result<{ fee: number }>} */ - public updateFee({ + public updateFeeOld({ satsPerByte, selectedFeeId = EFeeId.custom, index = 0, diff --git a/src/types/transaction.ts b/src/types/transaction.ts index 7abfe29..05cc044 100644 --- a/src/types/transaction.ts +++ b/src/types/transaction.ts @@ -28,6 +28,7 @@ export interface ISetupTransaction { rbf?: boolean; // Enable or disable rbf satsPerByte?: number; // Used to specify the fee rate in sats per vbyte outputs?: IOutput[]; // Used to pre-specify outputs to use + coinselect?: ECoinSelect; // Used to specify the coin selection algorithm to use } export enum EFeeId { @@ -65,3 +66,10 @@ export type TGapLimitOptions = { lookAheadChange: number; lookBehindChange: number; }; + +// https://github.com/bitcoinerlab/coinselect#algorithms +export enum ECoinSelect { + default = 'default', + maxFunds = 'maxFunds', + manual = 'manual' // use all transaction.availableUtxos +} diff --git a/src/types/wallet.ts b/src/types/wallet.ts index 7d9033d..1f541e5 100644 --- a/src/types/wallet.ts +++ b/src/types/wallet.ts @@ -1,16 +1,16 @@ +import { BIP32Interface } from 'bip32'; +import { ECPairInterface } from 'ecpair'; import { Result } from '../utils'; import { EElectrumNetworks, IHeader, INewBlock, Net, + Tls, TServer, - TTxResult, - Tls + TTxResult } from './electrum'; -import { EFeeId, TGapLimitOptions } from './transaction'; -import { ECPairInterface } from 'ecpair'; -import { BIP32Interface } from 'bip32'; +import { ECoinSelect, EFeeId, TGapLimitOptions } from './transaction'; export type TAvailableNetworks = 'bitcoin' | 'testnet' | 'regtest'; export type TAddressType = 'p2wpkh' | 'p2sh' | 'p2pkh'; @@ -122,7 +122,8 @@ export enum EBoostType { export interface ISendTransaction { outputs: IOutput[]; - inputs: IUtxo[]; + availableInputs: IUtxo[]; // inputs available to choose from. + inputs: IUtxo[]; // inputs to be used in the transaction. changeAddress: string; fiatAmount: number; fee: number; //Total fee in sats @@ -137,6 +138,7 @@ export interface ISendTransaction { tags: string[]; slashTagsUrl?: string; // TODO: Remove after migration. lightningInvoice?: string; // TODO: Remove after migration. + coinselect: ECoinSelect; } export interface IAddresses { diff --git a/tests/send.test.ts b/tests/send.test.ts new file mode 100644 index 0000000..e90780d --- /dev/null +++ b/tests/send.test.ts @@ -0,0 +1,262 @@ +import BitcoinJsonRpc from 'bitcoin-json-rpc'; +import { expect } from 'chai'; +import net from 'net'; +import tls from 'tls'; + +import { + EAddressType, + EAvailableNetworks, + EProtocol, + generateMnemonic, + validateTransaction, + Wallet +} from '../'; +import { + bitcoinURL, + electrumHost, + electrumPort, + initWaitForElectrumToSync, + MessageListener, + TWaitForElectrum +} from './utils'; + +const testTimeout = 60000; +let wallet: Wallet; +let waitForElectrum: TWaitForElectrum; +const rpc = new BitcoinJsonRpc(bitcoinURL); +const ml = new MessageListener(); + +describe('Send', async function () { + this.timeout(testTimeout); + + beforeEach(async function () { + this.timeout(testTimeout); + ml.clear(); + + // Ensure sufficient balance in regtest + let balance = await rpc.getBalance(); + const address = await rpc.getNewAddress(); + await rpc.generateToAddress(1, address); + + while (balance < 10) { + await rpc.generateToAddress(10, address); + balance = await rpc.getBalance(); + } + + waitForElectrum = await initWaitForElectrumToSync( + { host: electrumHost, port: electrumPort }, + bitcoinURL + ); + await waitForElectrum(); + + const res = await Wallet.create({ + mnemonic: generateMnemonic(), + network: EAvailableNetworks.regtest, + addressType: EAddressType.p2wpkh, + electrumOptions: { + servers: [ + { + host: '127.0.0.1', + ssl: 60002, + tcp: 60001, + protocol: EProtocol.tcp + } + ], + net, + tls + }, + // reduce gap limit to speed up tests + gapLimitOptions: { + lookAhead: 2, + lookBehind: 2, + lookAheadChange: 2, + lookBehindChange: 2 + }, + addressTypesToMonitor: [EAddressType.p2wpkh], + onMessage: ml.onMessage + }); + if (res.isErr()) throw res.error; + wallet = res.value; + await wallet.refreshWallet({}); + }); + + afterEach(async function () { + await wallet?.stop(); + }); + + it('one input - one output transaction, no RBF', async () => { + const r = await wallet.getNextAvailableAddress(); + if (r.isErr()) throw r.error; + const wAddress = r.value.addressIndex.address; + await rpc.sendToAddress(wAddress, '0.1'); + await rpc.generateToAddress(3, await rpc.getNewAddress()); + await waitForElectrum(); + await wallet.refreshWallet(); + + const address = await rpc.getNewAddress(); + + const sendRes = await wallet.send({ + address, + amount: 10000, // amount in sats + satsPerByte: 1 + }); + if (sendRes.isErr()) throw sendRes.error; + const txid = sendRes.value; + + await wallet.refreshWallet({}); + expect(wallet.data.transactions).to.have.property(txid); + const tx = wallet.data.transactions[txid]; + // expect(tx.fee).to.equal(0.00000256); + expect(tx.type).to.equal('sent'); + // expect(tx.value).to.equal(-0.00010256); + expect(tx.txid).to.equal(txid); + expect(tx.rbf).to.equal(false); + }); + + it('one input - one output transaction, with RBF', async () => { + const r = await wallet.getNextAvailableAddress(); + if (r.isErr()) throw r.error; + const wAddress = r.value.addressIndex.address; + await rpc.sendToAddress(wAddress, '0.1'); + await rpc.generateToAddress(3, await rpc.getNewAddress()); + await waitForElectrum(); + await wallet.refreshWallet(); + + const address = await rpc.getNewAddress(); + + const sendRes = await wallet.send({ + address, + amount: 10000, // amount in sats + satsPerByte: 10, + rbf: true + }); + if (sendRes.isErr()) throw sendRes.error; + const txid = sendRes.value; + + await wallet.refreshWallet({}); + expect(wallet.data.transactions).to.have.property(txid); + const tx = wallet.data.transactions[txid]; + // expect(tx.fee).to.equal(0.0000166); + expect(tx.type).to.equal('sent'); + // expect(tx.value).to.equal(-0.0001166); + expect(tx.txid).to.equal(txid); + expect(tx.rbf).to.equal(true); + }); + + it('two inputs - two outputs, both inputs should be used', async () => { + const [a1, a2] = Object.values(wallet.data.addresses.p2wpkh).map( + (v) => v.address + ); + await rpc.sendToAddress(a1, '0.0001'); + await rpc.sendToAddress(a2, '0.0001'); + await rpc.generateToAddress(3, await rpc.getNewAddress()); + await waitForElectrum(); + await wallet.refreshWallet(); + + const resetRes = await wallet.resetSendTransaction(); + if (resetRes.isErr()) throw resetRes.error; + const setupRes = await wallet.setupTransaction({}); + if (setupRes.isErr()) throw setupRes.error; + const updateRes = wallet.transaction.updateSendTransaction({ + transaction: { + outputs: [ + { + index: 0, + address: await rpc.getNewAddress(), + value: 6000 + }, + { + index: 1, + address: await rpc.getNewAddress(), + value: 6000 + } + ] + } + }); + if (updateRes.isErr()) throw updateRes.error; + + const validateRes = validateTransaction(wallet.transaction.data); + if (validateRes.isErr()) throw validateRes.error; + const createRes = await wallet.transaction.createTransaction(); + if (createRes.isErr()) throw createRes.error; + const broadcastRes = await wallet.electrum.broadcastTransaction({ + rawTx: createRes.value.hex + }); + const txid = createRes.value.id; + if (broadcastRes.isErr()) throw broadcastRes.error; + + await rpc.generateToAddress(3, await rpc.getNewAddress()); + await wallet.refreshWallet({}); + expect(wallet.data.transactions).to.have.property(txid); + + // TODO: check tx inputs and outputs + }); + + it('two inputs - two outputs, only one input should be used', async () => { + const [a1, a2] = Object.values(wallet.data.addresses.p2wpkh).map( + (v) => v.address + ); + await rpc.sendToAddress(a1, '0.0001'); + await rpc.sendToAddress(a2, '0.0001'); + await rpc.generateToAddress(3, await rpc.getNewAddress()); + await waitForElectrum(); + await wallet.refreshWallet(); + + const resetRes = await wallet.resetSendTransaction(); + if (resetRes.isErr()) throw resetRes.error; + const setupRes = await wallet.setupTransaction({}); + if (setupRes.isErr()) throw setupRes.error; + const updateRes = wallet.transaction.updateSendTransaction({ + transaction: { + outputs: [ + { + index: 0, + address: await rpc.getNewAddress(), + value: 500 + }, + { + index: 1, + address: await rpc.getNewAddress(), + value: 500 + } + ] + } + }); + if (updateRes.isErr()) throw updateRes.error; + const feeRes = wallet.transaction.updateFee({ satsPerByte: 1 }); + if (feeRes.isErr()) throw feeRes.error; + const validateRes = validateTransaction(wallet.transaction.data); + if (validateRes.isErr()) throw validateRes.error; + const createRes = await wallet.transaction.createTransaction(); + if (createRes.isErr()) throw createRes.error; + const broadcastRes = await wallet.electrum.broadcastTransaction({ + rawTx: createRes.value.hex + }); + const txid = createRes.value.id; + if (broadcastRes.isErr()) throw broadcastRes.error; + + await rpc.generateToAddress(3, await rpc.getNewAddress()); + await wallet.refreshWallet({}); + expect(wallet.data.transactions).to.have.property(txid); + + const txRes = await wallet.electrum.getTransactions({ + txHashes: [{ tx_hash: txid }] + }); + if (txRes.isErr()) throw txRes.error; + const txData = txRes.value.data[0].result; + expect(txData.vin.length).to.equal(1); + }); + + it('should fail to send with insufficient balance', async () => { + const r = await wallet.getNextAvailableAddress(); + if (r.isErr()) throw r.error; + const address = r.value.addressIndex.address; + + const sendRes = await wallet.send({ + address, + amount: 1000000000, + satsPerByte: 1 + }); + expect(sendRes.isErr()).to.be.true; + }); +});