From c8307f96c218275d55b881e499a418663f54b5ed Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Tue, 2 Sep 2025 16:38:21 +0200 Subject: [PATCH 01/74] [prices] remove unused internal API --- backend/src/api/prices/prices.routes.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/backend/src/api/prices/prices.routes.ts b/backend/src/api/prices/prices.routes.ts index e395fb44b..cdf435f32 100644 --- a/backend/src/api/prices/prices.routes.ts +++ b/backend/src/api/prices/prices.routes.ts @@ -1,14 +1,11 @@ import { Application, Request, Response } from 'express'; import config from '../../config'; import pricesUpdater from '../../tasks/price-updater'; -import logger from '../../logger'; -import PricesRepository from '../../repositories/PricesRepository'; class PricesRoutes { public initRoutes(app: Application): void { app .get(config.MEMPOOL.API_URL_PREFIX + 'prices', this.$getCurrentPrices.bind(this)) - .get(config.MEMPOOL.API_URL_PREFIX + 'internal/usd-price-history', this.$getAllPrices.bind(this)) ; } @@ -19,23 +16,6 @@ class PricesRoutes { res.json(pricesUpdater.getLatestPrices()); } - - private async $getAllPrices(req: Request, res: Response): Promise { - res.header('Pragma', 'public'); - res.header('Cache-control', 'public'); - res.setHeader('Expires', new Date(Date.now() + 360_0000 / config.MEMPOOL.PRICE_UPDATES_PER_HOUR).toUTCString()); - - try { - const usdPriceHistory = await PricesRepository.$getPricesTimesAndId(); - const responseData = usdPriceHistory.map(p => { - return { time: p.time, USD: p.USD }; - }); - res.status(200).json(responseData); - } catch (e: any) { - logger.err(`Exception ${e} in PricesRoutes::$getAllPrices. Code: ${e.code}. Message: ${e.message}`); - res.status(403).send(); - } - } } export default new PricesRoutes(); From a00b67a0a266b40ad6d58cc19506653598c06ab3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 03:34:48 +0000 Subject: [PATCH 02/74] Bump express from 4.21.2 to 4.22.1 in /backend Bumps [express](https://github.com/expressjs/express) from 4.21.2 to 4.22.1. - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/v4.22.1/History.md) - [Commits](https://github.com/expressjs/express/compare/4.21.2...v4.22.1) --- updated-dependencies: - dependency-name: express dependency-version: 4.22.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- backend/package-lock.json | 343 ++++++++++++++++++++------------------ backend/package.json | 2 +- 2 files changed, 183 insertions(+), 162 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 1cab2025f..d06a59f3d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,7 +15,7 @@ "axios": "1.12.2", "bitcoinjs-lib": "~6.1.3", "crypto-js": "~4.2.0", - "express": "~4.21.1", + "express": "~4.22.1", "maxmind": "~4.3.11", "mysql2": "~3.14.1", "redis": "^4.7.0", @@ -2816,24 +2816,6 @@ "node": ">= 0.8" } }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2846,6 +2828,21 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3126,22 +3123,6 @@ "node": ">=0.10.0" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3647,39 +3628,38 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", - "license": "MIT", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -3705,6 +3685,20 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/express/node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4183,17 +4177,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -5642,9 +5625,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "engines": { "node": ">= 0.4" }, @@ -6306,22 +6289,6 @@ "node": ">= 0.8.0" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -6349,14 +6316,65 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -7187,11 +7205,7 @@ } }, "rust-gbt": { - "name": "gbt", - "version": "3.0.1", - "engines": { - "node": ">= 12" - } + "version": "0.0.1" } }, "dependencies": { @@ -9170,18 +9184,6 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" }, - "call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "requires": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - } - }, "call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -9191,6 +9193,15 @@ "function-bind": "^1.1.2" } }, + "call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + } + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -9372,16 +9383,6 @@ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true }, - "define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "requires": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - } - }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -9741,38 +9742,38 @@ } }, "express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -9790,6 +9791,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "requires": { + "side-channel": "^1.1.0" + } } } }, @@ -10130,14 +10139,6 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, - "has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "requires": { - "es-define-property": "^1.0.0" - } - }, "has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -11156,9 +11157,9 @@ } }, "object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==" + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, "on-finished": { "version": "2.4.1", @@ -11595,19 +11596,6 @@ "send": "0.19.0" } }, - "set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "requires": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - } - }, "setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -11629,14 +11617,47 @@ "dev": true }, "side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "requires": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" } }, "signal-exit": { diff --git a/backend/package.json b/backend/package.json index 0facd684b..15577fcef 100644 --- a/backend/package.json +++ b/backend/package.json @@ -46,7 +46,7 @@ "axios": "1.12.2", "bitcoinjs-lib": "~6.1.3", "crypto-js": "~4.2.0", - "express": "~4.21.1", + "express": "~4.22.1", "maxmind": "~4.3.11", "mysql2": "~3.14.1", "rust-gbt": "file:./rust-gbt", From d380d504f8c17f05965c1425ac81a6fba9f8e333 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 26 Nov 2025 17:27:09 +0100 Subject: [PATCH 03/74] Keep object type in ScriptInfo clone method --- frontend/src/app/shared/script.utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/script.utils.ts b/frontend/src/app/shared/script.utils.ts index c777b2f5b..287b30efb 100644 --- a/frontend/src/app/shared/script.utils.ts +++ b/frontend/src/app/shared/script.utils.ts @@ -198,7 +198,11 @@ export class ScriptInfo { } public clone(): ScriptInfo { - return { ...this }; + const cloned = new ScriptInfo(this.type, this.hex, this.asm, undefined, this.taprootInfo, this.vinId); + if (this.template) { + cloned.template = this.template; + } + return cloned; } get key(): string { From 2859b08d4882420c994a5f312e10ba2e698314ff Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 26 Nov 2025 17:35:00 +0100 Subject: [PATCH 04/74] Refactor decodePsbt to return maps for inputs and outputs --- frontend/src/app/shared/transaction.utils.ts | 162 +++++++++---------- 1 file changed, 80 insertions(+), 82 deletions(-) diff --git a/frontend/src/app/shared/transaction.utils.ts b/frontend/src/app/shared/transaction.utils.ts index 28d27c4b1..d6ece934b 100644 --- a/frontend/src/app/shared/transaction.utils.ts +++ b/frontend/src/app/shared/transaction.utils.ts @@ -1175,7 +1175,7 @@ export function addInnerScriptsToVin(vin: Vin): void { * @param inputs Additional information from a PSBT, if available * @returns The decoded transaction object and the raw hex */ -function fromBuffer(buffer: Uint8Array, network: string, inputs?: { key: Uint8Array; value: Uint8Array }[][]): { tx: Transaction, hex: string } { +function fromBuffer(buffer: Uint8Array, network: string, inputs?: PsbtKeyValueMap[]): { tx: Transaction, hex: string } { let offset = 0; // Parse raw transaction @@ -1257,52 +1257,18 @@ function fromBuffer(buffer: Uint8Array, network: string, inputs?: { key: Uint8Ar const inputRecords = inputs[i]; const groups = { - nonWitnessUtxo: null, - witnessUtxo: null, - finalScriptSig: null, - finalScriptWitness: null, - redeemScript: null, - witnessScript: null, - partialSigs: [], - tapLeafScripts: [], - tapScriptSigs: [], - tapInternalKey: null, + nonWitnessUtxo: inputRecords.get(0x00)?.[0] || null, + witnessUtxo: inputRecords.get(0x01)?.[0] || null, + finalScriptSig: inputRecords.get(0x07)?.[0] || null, + finalScriptWitness: inputRecords.get(0x08)?.[0] || null, + redeemScript: inputRecords.get(0x04)?.[0] || null, + witnessScript: inputRecords.get(0x05)?.[0] || null, + partialSigs: inputRecords.get(0x02) || [], + tapLeafScripts: inputRecords.get(0x15) || [], + tapScriptSigs: inputRecords.get(0x14) || [], + tapInternalKey: inputRecords.get(0x17)?.[0] || null, }; - for (const record of inputRecords) { - switch (record.key[0]) { - case 0x00: - groups.nonWitnessUtxo = record; - break; - case 0x01: - groups.witnessUtxo = record; - break; - case 0x07: - groups.finalScriptSig = record; - break; - case 0x08: - groups.finalScriptWitness = record; - break; - case 0x04: - groups.redeemScript = record; - break; - case 0x05: - groups.witnessScript = record; - break; - case 0x02: - groups.partialSigs.push(record); - break; - case 0x14: - groups.tapScriptSigs.push(record); - break; - case 0x15: - groups.tapLeafScripts.push(record); - break; - case 0x17: - groups.tapInternalKey = record; - } - } - // Fill prevout if (groups.witnessUtxo && !vin.prevout) { let value, scriptpubkeyArray, scriptpubkey, outputOffset = 0; @@ -1404,7 +1370,7 @@ function fromBuffer(buffer: Uint8Array, network: string, inputs?: { key: Uint8Ar if (uint8ArrayToHexString(groups.tapInternalKey.value) === '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0') { // unspendable internal key, use the first tap leaf script provided const record = groups.tapLeafScripts[0]; - const controlBlock = uint8ArrayToHexString(record.key.slice(1)); + const controlBlock = uint8ArrayToHexString(record.keyData); const tapLeaf = uint8ArrayToHexString(record.value.slice(0, -1)); vin.witness = vin.witness || []; vin.witness.unshift(tapLeaf, controlBlock); @@ -1417,7 +1383,7 @@ function fromBuffer(buffer: Uint8Array, network: string, inputs?: { key: Uint8Ar let scriptMostSigs = ''; for (const record of groups.tapScriptSigs) { - const leafHash = uint8ArrayToHexString(record.key.slice(33)); + const leafHash = uint8ArrayToHexString(record.keyData.slice(32)); if (!leafScriptSignatures[leafHash]) { leafScriptSignatures[leafHash] = 0; } @@ -1435,14 +1401,14 @@ function fromBuffer(buffer: Uint8Array, network: string, inputs?: { key: Uint8Ar const scriptSize = uint8ArrayToHexString(compactSize(record.value.length - 1)); if (taggedHash('TapLeaf', leafVersion + scriptSize + script) === scriptMostSigs) { // add the script - const controlBlock = uint8ArrayToHexString(record.key.slice(1)); + const controlBlock = uint8ArrayToHexString(record.keyData); const tapLeaf = uint8ArrayToHexString(record.value.slice(0, -1)); vin.witness = vin.witness || []; vin.witness.unshift(tapLeaf, controlBlock); vin.inner_witnessscript_asm = convertScriptSigAsm(tapLeaf); // add the signatures that are part of this script for (const sigRecord of groups.tapScriptSigs) { - const sigLeafHash = uint8ArrayToHexString(sigRecord.key.slice(33)); + const sigLeafHash = uint8ArrayToHexString(sigRecord.keyData.slice(32)); if (sigLeafHash === scriptMostSigs) { vin.witness.unshift(uint8ArrayToHexString(sigRecord.value)); } @@ -1479,14 +1445,17 @@ function fromBuffer(buffer: Uint8Array, network: string, inputs?: { key: Uint8Ar return { tx, hex: uint8ArrayToHexString(rawHex) }; } +type PsbtKeyValueMap = Map; + /** - * Decodes a PSBT buffer into the unsigned raw transaction and input map + * Decodes a PSBT buffer into the unsigned raw transaction and input/output maps * @param psbtBuffer * @returns - * - the unsigned transaction from a PSBT (txHex) - * - the full input map for each input in to fill signatures and prevouts later (inputs) + * - the unsigned transaction from a PSBT + * - the full input map for each input + * - the full output map for each output */ -function decodePsbt(psbtBuffer: Uint8Array): { rawTx: Uint8Array; inputs: { key: Uint8Array; value: Uint8Array }[][] } { +function decodePsbt(psbtBuffer: Uint8Array): { rawTx: Uint8Array; inputs: PsbtKeyValueMap[]; outputs: PsbtKeyValueMap[]; } { let offset = 0; // magic: "psbt" in ASCII @@ -1530,7 +1499,46 @@ function decodePsbt(psbtBuffer: Uint8Array): { rawTx: Uint8Array; inputs: { key: throw new Error("Unsigned transaction not found in PSBT"); } + const readMaps = (count: number, startOffset: number): { map: PsbtKeyValueMap[]; offset: number } => { + const map: PsbtKeyValueMap[] = []; + let offset = startOffset; + + for (let i = 0; i < count; i++) { + const records: PsbtKeyValueMap = new Map(); + const seenKeys = new Set(); + while (offset < psbtBuffer.length) { + const [keyLen, newOffset] = readVarInt(psbtBuffer, offset); + offset = newOffset; + if (keyLen === 0) { + break; + } + const key = psbtBuffer.slice(offset, offset + keyLen); + offset += keyLen; + + const keyHex = uint8ArrayToHexString(key); + if (seenKeys.has(keyHex)) { + throw new Error('Duplicate key in map'); + } + seenKeys.add(keyHex); + + const [valLen, newOffset2] = readVarInt(psbtBuffer, offset); + offset = newOffset2; + const value = psbtBuffer.slice(offset, offset + valLen); + offset += valLen; + + const [keyType, keyDataOffset] = readVarInt(key, 0); + const bucket = records.get(keyType) || []; + bucket.push({ keyData: key.slice(keyDataOffset), value }); + records.set(keyType, bucket); + } + map.push(records); + } + + return { map, offset }; + }; + let numInputs: number; + let numOutputs: number; let txOffset = 0; // Skip version (4 bytes) txOffset += 4; @@ -1540,39 +1548,29 @@ function decodePsbt(psbtBuffer: Uint8Array): { rawTx: Uint8Array; inputs: { key: const [inputCount, newTxOffset] = readVarInt(rawTx, txOffset); txOffset = newTxOffset; numInputs = inputCount; + for (let i = 0; i < numInputs; i++) { + txOffset += 32; // prev txid + txOffset += 4; // vout + const [scriptLength, scriptOffset] = readVarInt(rawTx, txOffset); + txOffset = scriptOffset; + txOffset += scriptLength; + txOffset += 4; // sequence + } + const [outputCount, outputOffset] = readVarInt(rawTx, txOffset); + txOffset = outputOffset; + numOutputs = outputCount; // INPUT MAPS - const inputs: { key: Uint8Array; value: Uint8Array }[][] = []; - for (let i = 0; i < numInputs; i++) { - const inputRecords: { key: Uint8Array; value: Uint8Array }[] = []; - const seenKeys = new Set(); - while (offset < psbtBuffer.length) { - const [keyLen, newOffset] = readVarInt(psbtBuffer, offset); - offset = newOffset; - // key length of 0 means the end of the input map - if (keyLen === 0) { - break; - } - const key = psbtBuffer.slice(offset, offset + keyLen); - offset += keyLen; + const inputMaps = readMaps(numInputs, offset); + offset = inputMaps.offset; + const inputs = inputMaps.map; - const keyHex = uint8ArrayToHexString(key); - if (seenKeys.has(keyHex)) { - throw new Error(`Duplicate key in input map`); - } - seenKeys.add(keyHex); + // OUTPUT MAPS + const outputMaps = readMaps(numOutputs, offset); + offset = outputMaps.offset; + const outputs = outputMaps.map; - const [valLen, newOffset2] = readVarInt(psbtBuffer, offset); - offset = newOffset2; - const value = psbtBuffer.slice(offset, offset + valLen); - offset += valLen; - - inputRecords.push({ key, value }); - } - inputs.push(inputRecords); - } - - return { rawTx, inputs }; + return { rawTx, inputs, outputs }; } export function decodeRawTransaction(input: string, network: string): { tx: Transaction, hex: string, psbt?: string } { From dba492dc75a956baceab47e39ac372c87c9c5e0d Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 26 Nov 2025 18:02:50 +0100 Subject: [PATCH 05/74] Full taptree visualisation from PSBT Expand taptree with internal key and public key --- frontend/package-lock.json | 15 + frontend/package.json | 1 + .../components/address/address.component.html | 15 +- .../components/address/address.component.scss | 11 + .../components/address/address.component.ts | 57 +++- .../taproot-address-scripts.component.ts | 146 +++++--- frontend/src/app/shared/address-utils.ts | 6 +- frontend/src/app/shared/transaction.utils.ts | 317 ++++++++++++++++-- 8 files changed, 480 insertions(+), 88 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 80f3ed2af..378fe266e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "@fortawesome/fontawesome-svg-core": "~6.7.2", "@fortawesome/free-solid-svg-icons": "~6.7.2", "@ng-bootstrap/ng-bootstrap": "^19.0.0", + "@noble/secp256k1": "^3.0.0", "@types/qrcode": "~1.5.0", "bootstrap": "~4.6.2", "clipboard": "^2.0.11", @@ -6022,6 +6023,15 @@ "webpack": "^5.54.0" } }, + "node_modules/@noble/secp256k1": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz", + "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -22213,6 +22223,11 @@ "integrity": "sha512-ePuofHOtbgvEq2t+hcmL30s4q9HQ/nv9ABwpLiELdVIObcWUnrnizAvM7hujve/9CQL6gRCeEkxPLPS4ZrK9AQ==", "requires": {} }, + "@noble/secp256k1": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz", + "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 37d479b6a..33544c2f2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -76,6 +76,7 @@ "@fortawesome/free-solid-svg-icons": "~6.7.2", "@ng-bootstrap/ng-bootstrap": "^19.0.0", "@types/qrcode": "~1.5.0", + "@noble/secp256k1": "^3.0.0", "bootstrap": "~4.6.2", "clipboard": "^2.0.11", "domino": "^2.1.6", diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index b1d96a620..65c515ead 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -73,7 +73,7 @@ - +

Taproot Tree

@@ -81,7 +81,13 @@
- +
+
+ +
{{ psbtError }}
+
+
+
@@ -302,7 +308,10 @@ - + + diff --git a/frontend/src/app/components/address/address.component.scss b/frontend/src/app/components/address/address.component.scss index be562337a..ebd3130a1 100644 --- a/frontend/src/app/components/address/address.component.scss +++ b/frontend/src/app/components/address/address.component.scss @@ -121,4 +121,15 @@ h1 { .inactive { color: var(--transparent-fg); +} + +.taproot-psbt-btn:focus:not(:focus-visible) { + outline: none; + box-shadow: none; +} + +.taproot-psbt-btn { + padding-top: 0; + padding-bottom: 0; + font-size: 0.8rem; } \ No newline at end of file diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts index 90eca87d3..89b683b52 100644 --- a/frontend/src/app/components/address/address.component.ts +++ b/frontend/src/app/components/address/address.component.ts @@ -1,4 +1,5 @@ import { Component, OnInit, OnDestroy, HostListener } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { ActivatedRoute, ParamMap } from '@angular/router'; import { ElectrsApiService } from '@app/services/electrs-api.service'; import { switchMap, filter, catchError, map, tap } from 'rxjs/operators'; @@ -12,6 +13,7 @@ import { SeoService } from '@app/services/seo.service'; import { seoDescriptionNetwork } from '@app/shared/common.utils'; import { AddressInformation } from '@interfaces/node-api.interface'; import { AddressTypeInfo } from '@app/shared/address-utils'; +import { extractTapLeavesFromPsbt, fillTapTree } from '@app/shared/transaction.utils'; class AddressStats implements ChainStats { address: string; @@ -116,7 +118,10 @@ export class AddressComponent implements OnInit, OnDestroy { addressLoadingStatus$: Observable; addressInfo: null | AddressInformation = null; addressTypeInfo: null | AddressTypeInfo; - hasTapTree: boolean; + tapTreeIncomplete: boolean = false; + taprootPsbtExpanded: boolean = false; + psbtForm: UntypedFormGroup; + psbtError?: string; fullyLoaded = false; chainStats: AddressStats; @@ -139,11 +144,13 @@ export class AddressComponent implements OnInit, OnDestroy { private audioService: AudioService, private apiService: ApiService, private seoService: SeoService, + private formBuilder: UntypedFormBuilder, ) { } ngOnInit(): void { this.stateService.networkChanged$.subscribe((network) => this.network = network); this.websocketService.want(['blocks']); + this.psbtForm = this.formBuilder.group({ psbt: [''] }); this.onResize(); @@ -165,7 +172,10 @@ export class AddressComponent implements OnInit, OnDestroy { this.utxos = null; this.addressInfo = null; this.exampleChannel = null; - this.hasTapTree = false; + this.tapTreeIncomplete = false; + this.taprootPsbtExpanded = false; + this.psbtForm?.reset({ psbt: '' }); + this.psbtError = undefined; document.body.scrollTo(0, 0); this.addressString = params.get('id') || ''; if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(this.addressString)) { @@ -296,7 +306,9 @@ export class AddressComponent implements OnInit, OnDestroy { }); } this.addressTypeInfo.processInputs(addressVin, vinIds); - this.hasTapTree = this.addressTypeInfo.tapscript && this.addressTypeInfo.scripts.values().next().value.taprootInfo.scriptPath.merkleBranches.length > 0; + if (this.addressTypeInfo.type === 'v1_p2tr' && !this.addressTypeInfo.tapscript) { + this.tapTreeIncomplete = true; + } // hack to trigger change detection this.addressTypeInfo = this.addressTypeInfo.clone(); @@ -500,6 +512,45 @@ export class AddressComponent implements OnInit, OnDestroy { ); } + submitPsbt(): void { + if (!this.psbtForm) { + return; + } + const control = this.psbtForm.get('psbt'); + const sanitized = (control?.value || '').trim(); + if (control && control.value !== sanitized) { + control.setValue(sanitized, { emitEvent: false }); + } + if (!sanitized) { + this.psbtError = undefined; + return; + } + try { + fillTapTree(this.addressTypeInfo, extractTapLeavesFromPsbt(sanitized)); + this.addressTypeInfo = this.addressTypeInfo.clone(); + this.psbtForm.reset(); + this.psbtError = undefined; + } catch (error) { + if (error instanceof Error) { + this.psbtError = error.message; + } else { + this.psbtError = 'An error occurred while processing the PSBT'; + } + } + } + + setTapTreeIncomplete(incomplete: boolean): void { + if (!incomplete) { + this.taprootPsbtExpanded = false; + } + this.tapTreeIncomplete = incomplete; + } + + showTaprootPsbtButton(): boolean { + const isBitcoin = this.stateService.network !== 'liquid' && this.stateService.network !== 'liquidtestnet'; + return this.addressTypeInfo.type === 'v1_p2tr' && isBitcoin && this.tapTreeIncomplete; + } + @HostListener('window:resize', ['$event']) onResize(): void { this.isMobile = window.innerWidth < 768; diff --git a/frontend/src/app/components/taproot-address-scripts/taproot-address-scripts.component.ts b/frontend/src/app/components/taproot-address-scripts/taproot-address-scripts.component.ts index 399efc257..009f6c38d 100644 --- a/frontend/src/app/components/taproot-address-scripts/taproot-address-scripts.component.ts +++ b/frontend/src/app/components/taproot-address-scripts/taproot-address-scripts.component.ts @@ -1,10 +1,9 @@ -import { Component, ChangeDetectionStrategy, Input, OnChanges, NgZone, SimpleChanges, ChangeDetectorRef } from '@angular/core'; +import { Component, ChangeDetectionStrategy, Input, OnChanges, NgZone, Output, SimpleChanges, ChangeDetectorRef, EventEmitter } from '@angular/core'; import { Router } from '@angular/router'; import { Location } from '@angular/common'; -import { AddressTypeInfo } from '@app/shared/address-utils'; import { EChartsOption } from '@app/graphs/echarts'; import { ScriptInfo } from '@app/shared/script.utils'; -import { compactSize, taggedHash, uint8ArrayToHexString } from '@app/shared/transaction.utils'; +import { computeLeafHash, taggedHash, taprootAddressToOutputKey } from '@app/shared/transaction.utils'; import { StateService } from '@app/services/state.service'; import { AsmStylerPipe } from '@app/shared/pipes/asm-styler/asm-styler.pipe'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; @@ -12,6 +11,7 @@ import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pip interface TaprootTree { name: string; // the TapBranch hash or TapLeaf script hash value?: LeafNode; + special?: 'internalKey' | 'merkleRoot'; depth?: number; children?: [TaprootTree, TaprootTree]; // ECharts properties @@ -36,7 +36,9 @@ interface LeafNode { changeDetection: ChangeDetectionStrategy.OnPush, }) export class TaprootAddressScriptsComponent implements OnChanges { - @Input() address: AddressTypeInfo; + @Input() address: string; + @Input() scripts: Map; + @Output() tapTreeIncomplete = new EventEmitter(true); tree: TaprootTree; croppedTree: TaprootTree; @@ -46,6 +48,8 @@ export class TaprootAddressScriptsComponent implements OnChanges { height: number; levelHeight: number = 40; fullTreeShown: boolean; + maybetapTreeIncomplete: boolean = false; + isNUMS: boolean = false; chartOptions: EChartsOption = {}; chartInitOptions = { @@ -65,61 +69,71 @@ export class TaprootAddressScriptsComponent implements OnChanges { ) { } ngOnChanges(changes: SimpleChanges) { - if (changes.address?.currentValue.scripts && changes.address.currentValue.scripts.size) { - this.buildTree(Array.from(this.address.scripts.values())); + if (changes.scripts?.currentValue && changes.scripts.currentValue.size) { + this.buildTree(Array.from(this.scripts.values())); this.prepareTree(this.tree, 0); + this.tapTreeIncomplete.emit(this.maybetapTreeIncomplete); this.cropTree(); this.toggleTree(this.fullTreeShown, false); } } buildTree(scripts: ScriptInfo[]): void { - // Parse script paths into merklePaths list and calculate depth - const merklePaths: { leafVersion: number, merklePath: string[] }[] = []; this.depth = 0; - for (const script of scripts) { - const scriptInfo = script.taprootInfo; - if (!scriptInfo.scriptPath?.merkleBranches?.length) { - throw new Error("Merkle path length must be >= 1"); - } - const leafVersion = scriptInfo.scriptPath.leafVersion; - const merklePath = scriptInfo.scriptPath.merkleBranches.slice(); - if (merklePath.length > this.depth) { - this.depth = merklePath.length; - } - merklePaths.push({ leafVersion, merklePath }); - } + this.maybetapTreeIncomplete = false; // treeStructure is a list of maps, where each map contains as keys the hashes of the nodes at that depth, and as values the hashes of its two children const treeStructure: Map[] = []; - for (let i = 0; i < this.depth; i++) { - treeStructure.push(new Map()); - } const leaves = new Map(); + const ensureLevels = (levels: number) => { + while (treeStructure.length < levels) { + treeStructure.push(new Map()); + } + }; - for (let i = 0; i < scripts.length; i++) { - const script = scripts[i]; - const merklePath = merklePaths[i].merklePath; - const leafVersion = merklePaths[i].leafVersion; - const tapLeaf = taggedHash('TapLeaf', leafVersion.toString(16) + uint8ArrayToHexString(compactSize(script.hex.length / 2)) + script.hex); + for (const script of scripts) { + const leafVersion = script.taprootInfo.scriptPath.leafVersion; + const merklePath = script.taprootInfo.scriptPath.merkleBranches.slice(); + this.depth = Math.max(this.depth, merklePath.length); + ensureLevels(merklePath.length); + const tapLeaf = computeLeafHash(script.hex, leafVersion); leaves.set(tapLeaf, { leafVersion, script, merklePath }); let k = tapLeaf; for (let j = 0; j < merklePath.length; j++) { const e = merklePath[j]; - const [firstChild, secondChild] = [k, e].sort((a, b) => a.localeCompare(b)); + const firstChild = k < e ? k : e; + const secondChild = firstChild === k ? e : k; const parentHash = taggedHash('TapBranch', firstChild + secondChild); - treeStructure[merklePath.length - j - 1].set(parentHash, [firstChild, secondChild]); + const previousLevelIndex = merklePath.length - j - 1; + const level = treeStructure[previousLevelIndex]; + if (level.has(parentHash)) { + break; + } + level.set(parentHash, [firstChild, secondChild]); k = parentHash; } } + // Expand the tree to include public key, internal key and merkle root + const internalKeyNode = scripts[0].taprootInfo.scriptPath.internalKey; + const merkleRootNode = treeStructure.length > 0 ? treeStructure[0].keys().next().value : leaves.keys().next().value; + const outputKeyNode = taprootAddressToOutputKey(this.address).outputKey; + treeStructure.unshift(new Map()); + treeStructure[0].set(outputKeyNode, [internalKeyNode, merkleRootNode]); + this.depth++; + this.isNUMS = scripts[0].taprootInfo.scriptPath.isNUMS; + // Build the tree recursively - const recursiveBuild = (hash: string, depth: number): TaprootTree => { + const recursiveBuild = (hash: string, depth: number, special?: TaprootTree['special']): TaprootTree => { const node: TaprootTree = { name: hash, depth: depth }; + if (special) { + node.special = special; + } + if (leaves.has(hash)) { node.value = leaves.get(hash); return node; @@ -128,8 +142,8 @@ export class TaprootAddressScriptsComponent implements OnChanges { if (depth < treeStructure.length && treeStructure[depth].has(hash)) { const [firstChild, secondChild] = treeStructure[depth].get(hash); node.children = [ - recursiveBuild(firstChild, depth + 1), - recursiveBuild(secondChild, depth + 1) + recursiveBuild(firstChild, depth + 1, depth === 0 ? 'internalKey' : undefined), + recursiveBuild(secondChild, depth + 1, depth === 0 ? 'merkleRoot' : undefined) ]; } @@ -200,31 +214,48 @@ export class TaprootAddressScriptsComponent implements OnChanges { if (depth === 0) { node.symbol = 'none'; node.label = { - formatter: '{pill|Taproot}', + formatter: '{pill|Public Key}', offset: [0, -5], rich: { pill: { ...basePillStyle, - backgroundColor: 'var(--tertiary)', + backgroundColor: 'var(--primary)', color: '#fff', }, }, }; node.tooltip = [ - { label: 'TapRoot Hash', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) }, + { label: 'Public Key', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) }, ]; } if (node.children) { if (depth > 0) { - node.symbol = 'circle'; - node.symbolSize = 10; - node.symbolOffset = [0, 5]; - node.label = { formatter: '' }; - node.tooltip = [ - { label: 'TapBranch Hash', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) }, - { label: 'Depth', content: depth.toString() }, - ]; + if (!node.special) { + node.symbol = 'circle'; + node.symbolSize = 10; + node.symbolOffset = [0, 5]; + node.label = { formatter: '' }; + node.tooltip = [ + { label: 'TapBranch Hash', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) }, + { label: 'Depth', content: (depth - 1).toString() }, + ]; + } else if (node.special === 'merkleRoot') { + node.label = { + formatter: '{pill|Taproot}', + offset: [0, -5], + rich: { + pill: { + ...basePillStyle, + backgroundColor: 'var(--tertiary)', + color: '#fff', + }, + }, + }; + node.tooltip = [ + { label: 'Merkle Root', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) }, + ]; + } } this.prepareTree(node.children[0], depth + 1); this.prepareTree(node.children[1], depth + 1); @@ -248,10 +279,26 @@ export class TaprootAddressScriptsComponent implements OnChanges { node.tooltip = [ { label: 'TapLeaf Hash', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) }, - { label: 'Depth', content: depth.toString() }, + { label: 'Depth', content: (depth - 1).toString() }, { label: 'Leaf Version', content: node.value.leafVersion.toString(16) }, ]; + } else if (node.special === 'internalKey') { + const internalKeyLabel = this.isNUMS ? 'Internal Key 🚫' : 'Internal Key'; + node.label = { + formatter: `{pill|${internalKeyLabel}}`, + offset: [0, -5], + rich: { + pill: { + ...basePillStyle, + backgroundColor: this.isNUMS ? 'var(--grey)' : 'var(--tertiary)', + color: '#fff', + }, + }, + }; + node.tooltip = [ + { label: 'Internal Key', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) }, + ]; } else { node.symbol = 'circle'; node.symbolSize = 10; @@ -259,8 +306,9 @@ export class TaprootAddressScriptsComponent implements OnChanges { node.label = { formatter: '' }; node.tooltip = [ { label: 'Hash', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) }, - { label: 'Depth', content: depth.toString() }, + { label: 'Depth', content: (depth - 1).toString() }, ]; + this.maybetapTreeIncomplete = true; } } } @@ -291,7 +339,7 @@ export class TaprootAddressScriptsComponent implements OnChanges { (item) => ` ${item.label} - ${item.content} + ${item.content} ` ).join(''); @@ -323,7 +371,7 @@ export class TaprootAddressScriptsComponent implements OnChanges { let hiddenScriptsMessage = ''; if (node.tooltip[0].label === 'Hash') { - const remaining = 128 - (node.depth ?? 0); + const remaining = 128 - (node.depth - 1); let upperBoundHtml: string; if (remaining === 0) { upperBoundHtml = '1'; @@ -404,4 +452,4 @@ export class TaprootAddressScriptsComponent implements OnChanges { }); } -} \ No newline at end of file +} diff --git a/frontend/src/app/shared/address-utils.ts b/frontend/src/app/shared/address-utils.ts index 71e4bd5f0..0b27aaf90 100644 --- a/frontend/src/app/shared/address-utils.ts +++ b/frontend/src/app/shared/address-utils.ts @@ -151,6 +151,7 @@ export class AddressTypeInfo { cloned.scripts = new Map(Array.from(this.scripts, ([key, value]) => [key, value?.clone()])); cloned.isMultisig = this.isMultisig; cloned.tapscript = this.tapscript; + cloned.simplicity = this.simplicity; return cloned; } @@ -236,14 +237,15 @@ export class AddressTypeInfo { return this.compareTo(otherInfo); } - private processScript(script: ScriptInfo): void { + public processScript(script: ScriptInfo): boolean { if (this.scripts.has(script.key)) { - return; + return false; } this.scripts.set(script.key, script); if (script.template?.type === 'multisig') { this.isMultisig = { m: script.template['m'], n: script.template['n'] }; } + return true; } } diff --git a/frontend/src/app/shared/transaction.utils.ts b/frontend/src/app/shared/transaction.utils.ts index d6ece934b..f1848f4b0 100644 --- a/frontend/src/app/shared/transaction.utils.ts +++ b/frontend/src/app/shared/transaction.utils.ts @@ -1,10 +1,11 @@ import { TransactionFlags } from '@app/shared/filters.utils'; -import { getVarIntLength, parseMultisigScript, isPoint, parseTapscriptMultisig, parseTapscriptUnanimousMultisig } from '@app/shared/script.utils'; +import { getVarIntLength, parseMultisigScript, isPoint, parseTapscriptMultisig, parseTapscriptUnanimousMultisig, ScriptInfo } from '@app/shared/script.utils'; import { Transaction, Vin } from '@interfaces/electrs.interface'; import { CpfpInfo, RbfInfo, TransactionStripped } from '@interfaces/node-api.interface'; import { StateService } from '@app/services/state.service'; import { hash, Hash } from '@app/shared/sha256'; -import { AddressType } from '@app/shared/address-utils'; +import { AddressType, AddressTypeInfo } from '@app/shared/address-utils'; +import * as secp256k1 from '@noble/secp256k1'; // Bitcoin Core default policy settings const MAX_STANDARD_TX_WEIGHT = 400_000; @@ -22,9 +23,9 @@ const MAX_OP_RETURN_RELAY = 83; const DEFAULT_PERMIT_BAREMULTISIG = true; const MAX_TX_LEGACY_SIGOPS = 2_500 * 4; // witness-adjusted sigops -/** - * Calculate the witness-adjusted sigops cost of an asm script - */ +const TAPROOT_NUMS_INTERNAL_KEY = '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0'; +const SECP256K1_ORDER = BigInt('0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'); + export function countScriptSigops(script: string, isRawScript: boolean = false, witness: boolean = false): number { if (!script?.length) { return 0; @@ -1367,7 +1368,7 @@ function fromBuffer(buffer: Uint8Array, network: string, inputs?: PsbtKeyValueMa if (groups.tapLeafScripts.length && groups.tapInternalKey && !finalizedWitness) { // If no signature is present, assume key spend *except* if internal key is provably unspendable if (!groups.tapScriptSigs.length) { - if (uint8ArrayToHexString(groups.tapInternalKey.value) === '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0') { + if (isInternalKeyNUMS(uint8ArrayToHexString(groups.tapInternalKey.value))) { // unspendable internal key, use the first tap leaf script provided const record = groups.tapLeafScripts[0]; const controlBlock = uint8ArrayToHexString(record.keyData); @@ -1542,9 +1543,6 @@ function decodePsbt(psbtBuffer: Uint8Array): { rawTx: Uint8Array; inputs: PsbtKe let txOffset = 0; // Skip version (4 bytes) txOffset += 4; - if (rawTx[txOffset] === 0x00 && rawTx[txOffset + 1] === 0x01) { - txOffset += 2; - } const [inputCount, newTxOffset] = readVarInt(rawTx, txOffset); txOffset = newTxOffset; numInputs = inputCount; @@ -1574,18 +1572,7 @@ function decodePsbt(psbtBuffer: Uint8Array): { rawTx: Uint8Array; inputs: PsbtKe } export function decodeRawTransaction(input: string, network: string): { tx: Transaction, hex: string, psbt?: string } { - if (!input.length) { - throw new Error('Empty input'); - } - - let buffer: Uint8Array; - if (input.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(input)) { - buffer = hexStringToUint8Array(input); - } else if (/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)|[A-Za-z0-9+/]{3}=)?$/.test(input)) { - buffer = base64ToUint8Array(input); - } else { - throw new Error('Invalid input: not a valid transaction or PSBT'); - } + const buffer = convertTextToBuffer(input); if (buffer[0] === 0x70 && buffer[1] === 0x73 && buffer[2] === 0x62 && buffer[3] === 0x74) { // PSBT magic bytes const { rawTx, inputs } = decodePsbt(buffer); @@ -1808,12 +1795,12 @@ function p2wsh(scriptHash: string, network: string): string { return bech32Address; } -function p2tr(pubKeyHash: string, network: string): string { - const pubkeyHashArray = hexStringToUint8Array(pubKeyHash); +function p2tr(pubKey: string, network: string): string { + const pubkeyArray = hexStringToUint8Array(pubKey); const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc'; const version = 1; - const words = [version].concat(toWords(pubkeyHashArray)); - const bech32Address = bech32Encode(hrp, words, 0x2bc830a3); + const words = [version].concat(toWords(pubkeyArray)); + const bech32Address = bech32Encode(hrp, words, 'bech32m'); return bech32Address; } @@ -1822,10 +1809,17 @@ function p2a(network: string): string { const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc'; const version = 1; const words = [version].concat(toWords(pubkeyHashArray)); - const bech32Address = bech32Encode(hrp, words, 0x2bc830a3); + const bech32Address = bech32Encode(hrp, words, 'bech32m'); return bech32Address; } +/* Convert a *valid* P2TR address to its x-only pubkey */ +export function taprootAddressToOutputKey(address: string): { outputKey: string, network: string } { + const { prefix, words } = bech32Decode(address); + const programBytes = fromWords(words.slice(1)); + return { outputKey: uint8ArrayToHexString(programBytes), network: prefix === 'tb' ? 'testnet' : 'mainnet' }; +} + // base58 encoding function base58Encode(data: Uint8Array): string { const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; @@ -1854,11 +1848,13 @@ function base58Encode(data: Uint8Array): string { return encoded; } -// bech32 encoding +// bech32 encoding / decoding // Adapted from https://github.com/bitcoinjs/bech32/blob/5ceb0e3d4625561a459c85643ca6947739b2d83c/src/index.ts -function bech32Encode(prefix: string, words: number[], constant: number = 1) { - const BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +type Bech32Encoding = 'bech32' | 'bech32m'; +function bech32Encode(prefix: string, words: number[], encoding: Bech32Encoding = 'bech32'): string { + const constant = encoding === 'bech32m' ? 0x2bc830a3 : 1; const checksum = createChecksum(prefix, words, constant); const combined = words.concat(checksum); let result = prefix + '1'; @@ -1868,6 +1864,37 @@ function bech32Encode(prefix: string, words: number[], constant: number = 1) { return result; } +function bech32Decode(address: string): { prefix: string, words: number[], encoding: Bech32Encoding } { + const normalized = address.toLowerCase(); + const separator = normalized.lastIndexOf('1'); + const prefix = normalized.slice(0, separator); + const encodedWords = normalized.slice(separator + 1); + const words: number[] = []; + for (let i = 0; i < encodedWords.length; i++) { + words.push(BECH32_ALPHABET.indexOf(encodedWords.charAt(i))); + } + + const polymod = bech32Polymod(prefix, words); + let encoding: Bech32Encoding; + if (polymod === 1) { + encoding = 'bech32'; + } else if (polymod === 0x2bc830a3) { + encoding = 'bech32m'; + } else { + throw new Error('Invalid bech32 checksum'); + } + + return { prefix, words: words.slice(0, -6), encoding }; +} + +function bech32Polymod(prefix: string, words: number[]): number { + let chk = prefixChk(prefix); + for (let i = 0; i < words.length; ++i) { + chk = polymodStep(chk) ^ words[i]; + } + return chk; +} + function polymodStep(pre) { const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; const b = pre >> 25; @@ -1944,6 +1971,10 @@ function toWords(bytes) { return convertBits(bytes, 8, 5, true); } +function fromWords(words: number[]) { + return new Uint8Array(convertBits(words, 5, 8, false)); +} + // Helper functions export function uint8ArrayToHexString(uint8Array: Uint8Array): string { return Array.from(uint8Array).map(byte => byte.toString(16).padStart(2, '0')).join(''); @@ -2283,7 +2314,7 @@ export function parseTaproot(witness: string[]): ParsedTaproot | null { leafVersion: leafVersionParity & 0xfe, parity: leafVersionParity & 0x01, internalKey: internalKey, - isNUMS: internalKey === '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0', + isNUMS: isInternalKeyNUMS(internalKey), merkleBranches: [], }; for (let i = 66; (i + 64) <= parsed.controlBlock.length; i += 64) { @@ -2299,4 +2330,228 @@ export function parseTaproot(witness: string[]): ParsedTaproot | null { } } return parsed; -} \ No newline at end of file +} + +function convertTextToBuffer(input: string): Uint8Array { + if (!input.length) { + throw new Error('Empty input'); + } + + let buffer: Uint8Array; + if (input.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(input)) { + buffer = hexStringToUint8Array(input); + } else if (/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)|[A-Za-z0-9+/]{3}=)?$/.test(input)) { + buffer = base64ToUint8Array(input); + } else { + throw new Error('Invalid input: not hex or base64'); + } + return buffer; +} + +export function computeLeafHash(scriptHex: string, leafVersion: number): string { + const versionHex = leafVersion.toString(16).padStart(2, '0'); + const scriptSizeHex = uint8ArrayToHexString(compactSize(scriptHex.length / 2)); + return taggedHash('TapLeaf', versionHex + scriptSizeHex + scriptHex); +}; + +function computeMerkleRoot(scriptHex: string, leafVersion: number, merkleBranches: string[]): string { + const leafHash = computeLeafHash(scriptHex, leafVersion); + return (merkleBranches || []).reduce((acc, branch) => { + const firstChild = acc < branch ? acc : branch; + const secondChild = firstChild === acc ? branch : acc; + return taggedHash('TapBranch', firstChild + secondChild); + }, leafHash); +} + +export function computeTaprootOutputKey(internalKey: string, merkleRoot: string): { outputKey: string; parity: number } { + const tweakHash = taggedHash('TapTweak', internalKey + merkleRoot); // HashTapTweak(internalKey || m) + const tweak = BigInt('0x' + tweakHash) % SECP256K1_ORDER; // int(HashTapTweak(internalKey || m)) + const internalKeyPoint = secp256k1.Point.fromHex(`02${internalKey}`); // P = lift_x(internalKey) + const outputKeyPoint = internalKeyPoint.add(secp256k1.Point.BASE.multiply(tweak)); // Q = P + int(HashTapTweak(internalKey || m)) * G + const parity = Number(outputKeyPoint.y & 1n); + const outputKey = outputKeyPoint.x.toString(16).padStart(64, '0'); + return { outputKey, parity }; +} + +function deriveTaprootAddress(internalKey: string, merkleRoot: string, network: string): { address: string; parity: number } { + const { outputKey, parity } = computeTaprootOutputKey(internalKey, merkleRoot); + return { address: scriptPubKeyToAddress(`5120${outputKey}`, network).address, parity }; +} + +interface TapLeaf { + leafVersion: number; + scriptHex: string; + merkleBranches: string[]; +} + +interface ExpandedTapLeaf extends TapLeaf { + internalKey: string; +} + +/** Decode a PSBT_OUT_TAP_TREE into its leaves */ +function parseTapTreeRecord(value: Uint8Array): TapLeaf[] { + const leaves: TapLeaf[] = []; + const stack: { depth: number; hash: string; leaves: TapLeaf[] }[] = []; + let offset = 0; + + while (offset < value.length) { + const depth = value[offset++]; + const leafVersion = value[offset++]; + const [scriptLength, scriptOffset] = readVarInt(value, offset); + offset = scriptOffset; + const [scriptBytes, nextOffset] = readSlice(value, offset, scriptLength); + offset = nextOffset; + const scriptHex = uint8ArrayToHexString(scriptBytes); + const leaf: TapLeaf = { + leafVersion, + scriptHex, + merkleBranches: [], + }; + leaves.push(leaf); + stack.push({ depth, hash: computeLeafHash(scriptHex, leafVersion), leaves: [leaf] }); + + while (stack.length >= 2 && stack[stack.length - 1].depth === stack[stack.length - 2].depth) { + const right = stack.pop(); + const left = stack.pop(); + for (const l of left.leaves) { + l.merkleBranches.push(right.hash); + } + for (const r of right.leaves) { + r.merkleBranches.push(left.hash); + } + const firstChild = left.hash < right.hash ? left.hash : right.hash; + const secondChild = firstChild === left.hash ? right.hash : left.hash; + stack.push({ depth: left.depth - 1, hash: taggedHash('TapBranch', firstChild + secondChild), leaves: left.leaves.concat(right.leaves) }); + } + } + + return leaves; +} + +/* Extract tapleaves from a PSBT */ +export function extractTapLeavesFromPsbt(input: string): ExpandedTapLeaf[] { + const psbt = decodePsbt(convertTextToBuffer(input)); + + try { + const leaves: ExpandedTapLeaf[] = []; + const seenLeaves = new Set(); + const addLeaf = (leaf: ExpandedTapLeaf): void => { + const key = `${leaf.leafVersion}${leaf.internalKey}${leaf.merkleBranches.join('')}${leaf.scriptHex}`; + if (seenLeaves.has(key)) { + return; + } + seenLeaves.add(key); + leaves.push(leaf); + }; + + for (const input of psbt.inputs) { + for (const record of input.get(0x15) || []) { // PSBT_IN_TAP_LEAF_SCRIPT (0x15) + const leafVersion = record.value[record.value.length - 1]; + const scriptHex = uint8ArrayToHexString(record.value.slice(0, -1)); + const controlBlock = record.keyData; + const internalKey = uint8ArrayToHexString(controlBlock.slice(1, 33)); + const merkleBranches: string[] = []; + for (let offset = 33; offset < controlBlock.length; offset += 32) { + merkleBranches.push(uint8ArrayToHexString(controlBlock.slice(offset, offset + 32))); + } + addLeaf({ leafVersion, scriptHex, merkleBranches, internalKey }); + } + } + + for (const output of psbt.outputs) { + const tapInternalKeyRecord = output.get(0x05)?.[0]; // PSBT_OUT_TAP_INTERNAL_KEY (0x05) + const tapTreeRecord = output.get(0x06)?.[0]; // PSBT_OUT_TAP_TREE (0x06) + if (tapInternalKeyRecord && tapTreeRecord) { + const internalKey = uint8ArrayToHexString(tapInternalKeyRecord.value); + for (const leaf of parseTapTreeRecord(tapTreeRecord.value)) { + addLeaf({ ...leaf, internalKey }); + } + } + } + + return leaves; + } catch (error) { + throw new Error('Failed to extract taproot leaves from PSBT'); + } +} + +/** Populate an address' taptree using a PSBT involving the taproot address */ +export function fillTapTree(addressTypeInfo: AddressTypeInfo, leaves: ExpandedTapLeaf[]) { + if (addressTypeInfo?.type !== 'v1_p2tr') { + return; + } + + let commitment: { internalKey: string; merkleRoot: string; parity: number }; + if (addressTypeInfo.scripts.size) { + const tapInfo: ParsedTaproot = addressTypeInfo.scripts.values().next().value.taprootInfo; + commitment = { + internalKey: tapInfo.scriptPath.internalKey, + merkleRoot: computeMerkleRoot(tapInfo.scriptPath.script, tapInfo.scriptPath.leafVersion, tapInfo.scriptPath.merkleBranches), + parity: tapInfo.scriptPath.parity, + }; + } + + const leafMatchesAddress = (leaf: ExpandedTapLeaf): boolean => { + if (commitment) { + if (leaf.internalKey !== commitment.internalKey) { + return false; + } + const merkleRoot = computeMerkleRoot(leaf.scriptHex, leaf.leafVersion, leaf.merkleBranches); + if (merkleRoot !== commitment.merkleRoot) { + return false; + } + return true; + } else { + const merkleRoot = computeMerkleRoot(leaf.scriptHex, leaf.leafVersion, leaf.merkleBranches); + const { address, parity } = deriveTaprootAddress(leaf.internalKey, merkleRoot, addressTypeInfo.network); + if (addressTypeInfo.address !== address) { + return false; + } + commitment = { + internalKey: leaf.internalKey, + merkleRoot, + parity, + }; + return true; + } + }; + + let addedScript = false; + try { + for (const leaf of leaves) { + if (leafMatchesAddress(leaf)) { + const controlBlockPrefix = (leaf.leafVersion | commitment.parity).toString(16).padStart(2, '0'); + const controlBlock = controlBlockPrefix + leaf.internalKey + leaf.merkleBranches.join(''); + const taprootInfo: ParsedTaproot = { + keyPath: false, + stack: [], + controlBlock, + scriptPath: { + script: leaf.scriptHex, + leafVersion: leaf.leafVersion, + parity: commitment.parity, + internalKey: leaf.internalKey, + merkleBranches: leaf.merkleBranches.slice(), + isNUMS: isInternalKeyNUMS(leaf.internalKey), + }, + }; + const scriptInfo = new ScriptInfo('inner_witnessscript', leaf.scriptHex, convertScriptSigAsm(leaf.scriptHex), undefined, taprootInfo); + const scriptAdded = addressTypeInfo.processScript(scriptInfo); + if (scriptAdded) { + addressTypeInfo.tapscript = true; + addedScript = true; + } + } + } + } catch (error) { + throw new Error('An error occurred while filling the taproot tree'); + } + + if (!addedScript) { + throw new Error('No new taproot scripts matching this address'); + } +} + +function isInternalKeyNUMS(internalKey: string): boolean { + return internalKey === TAPROOT_NUMS_INTERNAL_KEY; +} From d7f321a30d03cdd86d15a5285eb75a6600e8bdad Mon Sep 17 00:00:00 2001 From: hunicus <93150691+hunicus@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:34:58 +0900 Subject: [PATCH 06/74] Add api docs for /api/v1/fees/precise --- .../src/app/docs/api-docs/api-docs-data.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/frontend/src/app/docs/api-docs/api-docs-data.ts b/frontend/src/app/docs/api-docs/api-docs-data.ts index 32570348d..fd365c43e 100644 --- a/frontend/src/app/docs/api-docs/api-docs-data.ts +++ b/frontend/src/app/docs/api-docs/api-docs-data.ts @@ -7725,6 +7725,88 @@ export const restApiDocsData = [ hourFee: 0.1, economyFee: 0.1, minimumFee: 0.1 +}` + }, + } + } + }, + { + type: "endpoint", + category: "fees", + httpRequestMethod: "GET", + fragment: "get-recommended-fees-precise", + title: "GET Recommended Fees (Precise)", + description: { + default: "Returns our currently-suggested feerates with up to 3 decimal places, including sub-sat feerates down to 0.1 s/vb." + }, + urlString: "/v1/fees/precise", + showConditions: bitcoinNetworks.concat(liquidNetworks), + showJsExamples: showJsExamplesDefaultFalse, + codeExample: { + default: { + codeTemplate: { + curl: `/api/v1/fees/precise`, + commonJS: ``, + esModule: ``, + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "fastestFee": 2.023, + "halfHourFee": 1.094, + "hourFee": 0.502, + "economyFee": 0.2, + "minimumFee": 0.1 +}` + }, + codeSampleTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "fastestFee": 1.5, + "halfHourFee": 1.25, + "hourFee": 1, + "economyFee": 1, + "minimumFee": 1 +}` + }, + codeSampleSignet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "fastestFee": 1.5, + "halfHourFee": 1.25, + "hourFee": 1, + "economyFee": 1, + "minimumFee": 1 +}` + }, + codeSampleLiquid: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "fastestFee": 0.1, + "halfHourFee": 0.1, + "hourFee": 0.1, + "economyFee": 0.1, + "minimumFee": 0.1 +}` + }, + codeSampleLiquidTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "fastestFee": 0.1, + "halfHourFee": 0.1, + "hourFee": 0.1, + "economyFee": 0.1, + "minimumFee": 0.1 }` }, } From c43d0a922e98d7191a4204a40aa7b3de5782d082 Mon Sep 17 00:00:00 2001 From: hunicus <93150691+hunicus@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:20:08 +0900 Subject: [PATCH 07/74] Change header logos: docs/book to enterprise/db Only if officialMempoolSpace. --- .../app/components/master-page/master-page.component.html | 7 ++++++- .../app/components/svg-images/svg-images.component.html | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index 8d017fc7d..813c1f6a4 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -113,11 +113,16 @@
- + - -
From eea32a0b6559d7e61e34f1f87dda9ada8d52fbd6 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 10 Dec 2025 11:23:41 +0100 Subject: [PATCH 20/74] Taptree reconstruction from URL fragment --- .../components/address/address.component.html | 15 +- .../components/address/address.component.ts | 107 +++++++-- .../taproot-address-scripts.component.ts | 8 +- frontend/src/app/shared/transaction.utils.ts | 222 +++++++++++------- 4 files changed, 241 insertions(+), 111 deletions(-) diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index 65c515ead..9b5aea70f 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -73,7 +73,7 @@ - +

Taproot Tree

@@ -83,11 +83,18 @@
- -
{{ psbtError }}
+ + + + + + + + +
{{ psbtError }}
- +
diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts index 89b683b52..c1b3febb3 100644 --- a/frontend/src/app/components/address/address.component.ts +++ b/frontend/src/app/components/address/address.component.ts @@ -13,7 +13,7 @@ import { SeoService } from '@app/services/seo.service'; import { seoDescriptionNetwork } from '@app/shared/common.utils'; import { AddressInformation } from '@interfaces/node-api.interface'; import { AddressTypeInfo } from '@app/shared/address-utils'; -import { extractTapLeavesFromPsbt, fillTapTree } from '@app/shared/transaction.utils'; +import { extractTapLeaves, fillTapTree, convertTextToBuffer, PsbtKeyValue } from '@app/shared/transaction.utils'; class AddressStats implements ChainStats { address: string; @@ -115,6 +115,8 @@ export class AddressComponent implements OnInit, OnDestroy { mempoolTxSubscription: Subscription; mempoolRemovedTxSubscription: Subscription; blockTxSubscription: Subscription; + fragmentSubscription: Subscription; + taprootFragment: URLSearchParams; addressLoadingStatus$: Observable; addressInfo: null | AddressInformation = null; addressTypeInfo: null | AddressTypeInfo; @@ -150,9 +152,17 @@ export class AddressComponent implements OnInit, OnDestroy { ngOnInit(): void { this.stateService.networkChanged$.subscribe((network) => this.network = network); this.websocketService.want(['blocks']); - this.psbtForm = this.formBuilder.group({ psbt: [''] }); + this.psbtForm = this.formBuilder.group({ psbt: [''], tapleaf: [''], taptree: [''], ikey: [''] }); this.onResize(); + this.fragmentSubscription = this.route.fragment.subscribe((fragment) => { + if (fragment) { + this.taprootFragment = new URLSearchParams(fragment.replace(/\+/g, '%2B')); // URLSearchParams decodes "+" as space, so normalize to preserve base64 fragments + this.submitPsbt(); + } else { + this.taprootFragment = undefined; + } + }); this.addressLoadingStatus$ = this.route.paramMap .pipe( @@ -174,7 +184,7 @@ export class AddressComponent implements OnInit, OnDestroy { this.exampleChannel = null; this.tapTreeIncomplete = false; this.taprootPsbtExpanded = false; - this.psbtForm?.reset({ psbt: '' }); + this.psbtForm?.reset({ psbt: '', tapleaf: '', taptree: '', ikey: '' }); this.psbtError = undefined; document.body.scrollTo(0, 0); this.addressString = params.get('id') || ''; @@ -307,7 +317,7 @@ export class AddressComponent implements OnInit, OnDestroy { } this.addressTypeInfo.processInputs(addressVin, vinIds); if (this.addressTypeInfo.type === 'v1_p2tr' && !this.addressTypeInfo.tapscript) { - this.tapTreeIncomplete = true; + this.setTapTreeIncomplete(true); } // hack to trigger change detection this.addressTypeInfo = this.addressTypeInfo.clone(); @@ -512,29 +522,74 @@ export class AddressComponent implements OnInit, OnDestroy { ); } - submitPsbt(): void { - if (!this.psbtForm) { - return; - } - const control = this.psbtForm.get('psbt'); + sanitizeFormControl(controlName: string): string { + const control = this.psbtForm?.get(controlName); const sanitized = (control?.value || '').trim(); if (control && control.value !== sanitized) { control.setValue(sanitized, { emitEvent: false }); } - if (!sanitized) { - this.psbtError = undefined; - return; - } - try { - fillTapTree(this.addressTypeInfo, extractTapLeavesFromPsbt(sanitized)); - this.addressTypeInfo = this.addressTypeInfo.clone(); - this.psbtForm.reset(); - this.psbtError = undefined; - } catch (error) { - if (error instanceof Error) { - this.psbtError = error.message; - } else { - this.psbtError = 'An error occurred while processing the PSBT'; + return sanitized; + } + + submitPsbt(): void { + if (this.psbtForm && this.tapTreeIncomplete) { + if (this.taprootFragment) { // If pending fragment, apply it first + const fragment = this.taprootFragment; + this.taprootFragment = undefined; + + const patch = {}; + ['psbt', 'tapleaf', 'taptree', 'ikey'].forEach((key) => { + const value = fragment.get(key); + if (value) { + patch[key] = value; + } + }); + + if (Object.keys(patch).length) { + this.psbtForm.patchValue(patch, { emitEvent: false }); + } + } + + try { + const psbt = this.sanitizeFormControl('psbt'); + const tapleavesRaw = this.sanitizeFormControl('tapleaf'); + const taptree = this.sanitizeFormControl('taptree'); + const internalKey = this.sanitizeFormControl('ikey'); + const tapleaves = tapleavesRaw ? tapleavesRaw.split(',').map((leaf) => leaf.trim()).filter(Boolean) : []; + + const hasInput = !!(psbt || tapleaves.length || taptree); + if (!hasInput) { + this.psbtError = undefined; + return; + } + + const psbtBuffer = psbt ? convertTextToBuffer(psbt) : undefined; + const tapleafRecords = tapleaves.reduce((records, leaf) => { + const parts = leaf.split(':'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error('Tapleaves must be in the format ":