commit 40a284136181df92ad8ac104530600ab0b956566 Author: Harvey Zuccon Date: Tue May 19 11:43:03 2026 +0200 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40db833 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/dist +*.sqlite* \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a45afe6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1356 @@ +{ + "name": "xpub-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xpub-backend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@fastify/cors": "^11.2.0", + "@xo/stack": "file:../stack", + "@xocash/stack": "file:../stack/packages/stack", + "dbug": "^0.4.2", + "fastify": "^5.8.5", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/debug": "^4.1.13", + "@types/node": "^25.8.0", + "debug": "^4.4.3", + "tsx": "^4.22.1", + "typescript": "^6.0.3" + } + }, + "../stack": { + "name": "@xo/stack", + "version": "0.0.1", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@tailwindcss/vite": "^4.3.0", + "tailwindcss": "^4.3.0" + }, + "devDependencies": { + "@eslint/js": "^9.10.0", + "@types/eslint__js": "^8.42.3", + "@typescript-eslint/eslint-plugin": "^8.5.0", + "@typescript-eslint/parser": "^8.5.0", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.10.0", + "eslint-config-prettier": "^8.10.0", + "eslint-plugin-prettier": "^5.2.1", + "lerna": "^9.0.3", + "madge": "^8.0.0", + "prettier": "^3.3.3", + "typescript": "5.7.2", + "typescript-eslint": "^8.6.0", + "vitest": "^3.2.4" + } + }, + "../stack/packages/stack": { + "name": "@xocash/stack", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@electrum-cash/network": "^4.1.1", + "@electrum-cash/protocol": "2.1.0-development.8075178089", + "@generalprotocols/oracle-client": "^0.0.1-development.11878773826", + "@xocash/primitives": "*", + "async-mutex": "^0.5.0", + "better-sqlite3": "^11.8.1", + "idb": "8.0.3", + "json-to-graphql-query": "^2.3.0", + "msgpackr": "^1.11.2", + "zod": "^4.0.10" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "rollup": "^4.45.0", + "rollup-plugin-dts": "^6.2.1" + } + }, + "node_modules/@bitauth/libauth": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.0.0.tgz", + "integrity": "sha512-3yoL31XpnhAnf5nDVMFk4xPqebxDwXrgYAwpa31ARJnV5A/eXWlpNYvCd6FTZPFM4VvKfjCBi+jRCrw1hOZ0Jg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@xo/stack": { + "resolved": "../stack", + "link": true + }, + "node_modules/@xocash/stack": { + "resolved": "../stack/packages/stack", + "link": true + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dbug": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/dbug/-/dbug-0.4.2.tgz", + "integrity": "sha512-nrmsMK1msY0WXwfA2czrKVDgpIYJR2JJaq5cX4DwW7Rxm11nXHqouh9wmubEs44bHYxk8CqeP/Jx4URqSB961w==", + "license": "MPLv2.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "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, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz", + "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a033f9d --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "xpub-backend", + "version": "1.0.0", + "description": "", + "license": "ISC", + "author": "", + "type": "module", + "main": "index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@fastify/cors": "^11.2.0", + "@xo/stack": "file:../stack", + "@xocash/stack": "file:../stack/packages/stack", + "dbug": "^0.4.2", + "fastify": "^5.8.5", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/debug": "^4.1.13", + "@types/node": "^25.8.0", + "debug": "^4.4.3", + "tsx": "^4.22.1", + "typescript": "^6.0.3" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..cf1647f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,102 @@ +import { BlockchainElectrum, StorageSQLite } from '@xocash/stack'; + +import { WalletHDWatch, type WalletHDWatchGenesisData } from './xo-extensions/index.js'; +import { WalletRoutes } from './routes/wallet.js'; +import { HTTPService } from './services/router.js'; +import Debug from 'debug'; + +export type AppDependencies = { + blockchain: BlockchainElectrum; + wallet: WalletHDWatch; + httpService: HTTPService; + debug: Debug.Debugger; +} + +// Eh, this is just a temp function. CBF making this nice right now, and doesnt hurt to leave this in. +const getCurrentRamUsage = () => { + const memoryUsage = process.memoryUsage(); + return memoryUsage.heapUsed / 1024 / 1024; +} + +export class App { + static async create(genesisData: WalletHDWatchGenesisData) { + const debug = Debug('xpub'); + + debug('Creating storage...'); + const storage = await StorageSQLite.createOrOpen('xpub-backend.sqlite'); + + debug('Creating blockchain cache...'); + const blockchainCache = await storage.createOrGetStore('blockchain', { + syncInMemory: false + }); + + debug('Creating wallet cache...'); + const walletCache = await storage.createOrGetStore('wallet', { + syncInMemory: false + }); + + debug('Creating blockchain...'); + const blockchain = await BlockchainElectrum.from({ + store: blockchainCache, + servers: [ 'cashnode.bch.ninja' ] + }); + + debug('Creating wallet...'); + const wallet = await WalletHDWatch.from({ + blockchain, + cache: walletCache + }, genesisData); + + debug('Creating Routes...'); + const routes = new WalletRoutes(wallet); + + debug('Creating Router...'); + const router = new HTTPService({ routes: [routes], debug }); + + debug('Creating App...'); + const app = new App({ blockchain, wallet, httpService: router, debug }); + + await app.start(); + return app; + + } + + private constructor(private readonly deps: AppDependencies) {} + + async start() { + this.deps.debug('Starting app...'); + + await Promise.all([ + // Start the blockchain + this.deps.blockchain.start() + .then(() => this.deps.debug('Blockchain started')), + + // Start the wallet + this.deps.wallet.start() + .then(() => this.deps.debug('Wallet started')), + + // Start the router + this.deps.httpService.start() + .then(() => this.deps.debug('Router started')), + ]); + + this.deps.debug(`Current RAM usage: ${getCurrentRamUsage()} MB`); + this.deps.debug('App started'); + } +} + +// The XPUB used by isMikeKomaranskyAlive.com. +const MIKE_KOMARANSKY_ALIVE_XPUB = 'xpub6D7LhEKSJwQV3eNZ6MN56wnazz3oEgoJtEDG2fpH6bpvBjUoSQFYNmigvD6Coj3HadQz4CqiNcCDDKKMmAjtcXf9zS37dFTxUxaDTzji8PQ'; + +// The derivation path used by isMikeKomaranskyAlive.com. +const MIKE_KOMARANSKY_ALIVE_DERIVATION_PATH = "m/44'/145'/0'"; + +// Create the genesis data for the app. +const genesisData: WalletHDWatchGenesisData = { + type: 'WalletHDWatch', + xpub: MIKE_KOMARANSKY_ALIVE_XPUB, + derivationPath: MIKE_KOMARANSKY_ALIVE_DERIVATION_PATH, +} + +// Create the app. +export const app = await App.create(genesisData); diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/wallet.ts b/src/routes/wallet.ts new file mode 100644 index 0000000..4760ac6 --- /dev/null +++ b/src/routes/wallet.ts @@ -0,0 +1,158 @@ +import type { FastifyRequest, FastifyReply } from 'fastify'; + +import { Address, Bytes } from '@xocash/stack'; +import { binToUtf8, disassembleBytecodeBCH, hexToBin } from '@bitauth/libauth'; + +import { toCsv } from '../utils/to-csv.js'; +import type { WalletHDWatch } from '../xo-extensions/wallet-hd-watch.js'; + +export class WalletRoutes { + private wallet: WalletHDWatch; + + /** + * Constructor for the WalletRoutes class + * @param wallet - The wallet to get the transactions from + */ + constructor(wallet: WalletHDWatch) { + this.wallet = wallet; + } + + /** + * Get all of the routes for the wallet + * @returns The routes for the wallet + */ + async getRoutes() { + return [ + { + method: 'GET', + url: '/transactions', + handler: this.getTransactions.bind(this), + }, + { + method: 'GET', + url: '/addresses', + handler: this.getAddresses.bind(this), + }, + ] + } + + /** + * Get all of the transactions from the wallet + * @param wallet - The wallet to get the transactions from + * @returns The transactions from the wallet + */ + static async getWalletTransactions(wallet: WalletHDWatch) { + // Once the wallet has fully synced, we want to save all the transactions to a csv file + // start syncs the wallet, so we can just save it now + const transactions = await wallet.getTransactions(); + + const formattedTransactions = transactions.toArray().map(tx => { + // Get all of the outputs from the transaction + const outputs = tx.transaction.getOutputs(); + + // Get all of the locking bytecodes from the outputs + const lockingBytecodes = outputs.map(output => Bytes.from(output.lockingBytecode).toHex()); + + // Find the op_return locking bytecode + const op_return = lockingBytecodes.find(lockingBytecode => lockingBytecode.startsWith('6a')); + + // If there is no op_return, return + if (!op_return) return; + + // Disassemble the op_return locking bytecode, grabbing the timestamp and heart rate + const [_script, _op_return, timestamp, _op_push, heartRate] = disassembleBytecodeBCH(hexToBin(op_return)).split(' '); + + // If there is no timestamp or heart rate, return + if (!timestamp || !heartRate) return; + + // Strip the 0x prefix from the timestamp and heart rate + const strippedTimestamp = timestamp.replace('0x', ''); + const strippedHeartRate = heartRate.replace('0x', ''); + + // Get the sats output (need to rethink this, should probably make sure its the one using p2pkh) + const addressOutput = outputs[1] + if (!addressOutput) return; + + // Convert the address output to a cash address + const address = Address.fromLockscriptBytes(addressOutput.lockingBytecode).toCashAddr('bitcoincash'); + + // Return the transaction data + return { + address: address, + hash: tx.hash.toHex(), + timestamp: Number(binToUtf8(hexToBin(strippedTimestamp))), + heartRate: Number(binToUtf8(hexToBin(strippedHeartRate))), + op_return: op_return, + } + }) + // Filter out any transactions that don't have an address + .filter(tx => tx !== undefined); + + // Sort the transactions by timestamp (Timestamp was pulled from the data inside the op_return) + const sortedTransactions = formattedTransactions.sort((a, b) => a?.timestamp - b?.timestamp); + + // Return the sorted transactions + return sortedTransactions; + } + + /** + * Get all of the transactions from the wallet and return them as a csv file + * @param req - The request object + * @param res - The response object + * @returns The transactions from the wallet as a csv file + */ + async getTransactions(_req: FastifyRequest, res: FastifyReply) { + // Get all of the transactions from the wallet + const sortedTransactions = await WalletRoutes.getWalletTransactions(this.wallet); + + // Convert the transactions to a csv + const transactionsCsv = toCsv(sortedTransactions); + + // Set the headers to download the file + res.header('Content-Disposition', `attachment; filename="isMikeKomaranskyAlive-transactions-${new Date().toDateString()}.csv"`); + res.header('Content-Type', 'text/csv'); + res.header('Content-Length', transactionsCsv.length.toString()); + + // Disable caching + res.header('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.header('Pragma', 'no-cache'); + res.header('Expires', '0'); + + return res.send(transactionsCsv); + } + + /** + * Get the current wallet address, locking bytecode, and derivation index based on the current time. + * @returns The current wallet address, locking bytecode, and derivation index based on the current time. + */ + async getAddresses(_req: FastifyRequest, res: FastifyReply) { + // Get all of the transactions from the wallet + const sortedTransactions = await WalletRoutes.getWalletTransactions(this.wallet); + + // Then create a set to get all of the unique addresses + const addresses = Array.from(new Set(sortedTransactions.map(tx => tx.address))); + + // Set the headers to download the file + res.header('Content-Disposition', `attachment; filename="isMikeKomaranskyAlive-addresses-${new Date().toDateString()}.csv"`); + res.header('Content-Type', 'text/csv'); + res.header('Content-Length', addresses.length.toString()); + + // Disable caching + res.header('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.header('Pragma', 'no-cache'); + res.header('Expires', '0'); + + // Format the addresses for the csv + const formattedAddresses = addresses.map(address => { + return { + address + } + }); + + // Convert the addresses to a csv + const addressesCsv = toCsv(formattedAddresses); + + // Send the csv file + return res.send(addressesCsv); + } +} \ No newline at end of file diff --git a/src/services/router.ts b/src/services/router.ts new file mode 100644 index 0000000..642edbd --- /dev/null +++ b/src/services/router.ts @@ -0,0 +1,167 @@ +import fastify, { type FastifyInstance, type RouteOptions } from "fastify"; +import cors from "@fastify/cors"; +import { z } from "zod"; +import Debug from "debug"; +import { + decodeExtendedJsonObject, + encodeExtendedJsonObject, +} from "@xocash/stack"; + +// Interface to add to our route classes so that we can register them. +// NOTE: I hate this pattern. But ExpressJS is odd in that it is structured as a singleton that still needs registration. +export interface APIRoutes { + getRoutes(): Promise>; +} + +type HTTPServiceOptions = { + port: number; + host: string; + routes: Array; +} + +type HTTPServiceDependencies = { + debug: debug.Debugger; +} + +export class HTTPService { + private readonly debug: debug.Debugger; + private readonly server: FastifyInstance; + + private readonly port: number; + private readonly host: string; + private readonly routes: Array; + + constructor( + args: Partial + ) { + // Default values + const mergedArgs: HTTPServiceOptions & HTTPServiceDependencies = { + debug: Debug(''), + port: 3000, + host: "0.0.0.0", + routes: [], + ...args, + }; + + const { debug, port, host, routes } = mergedArgs; + + // Extend the debug namespace with the http-router namespace + this.debug = debug.extend("http-router"); + + // Assign the port, host, and routes + this.port = port; + this.host = host; + this.routes = routes; + + this.server = fastify({ + logger: false, + }); + } + + async start(): Promise { + this.debug(`Starting on http://${this.host}:${this.port}`); + + // Setup ExtJSON handling. + this.handleExtJSON(); + + // Setup Error Handling (to give more verbose Zod errors) + this.handleErrors(); + + // Allow CORS requests. This allows requests from any origin/domain. + // Capacitor apps (like XO Wallet) use localhost:3000 as the origin, making it difficult to meaningfully restrict requests by origin for security. + await this.server.register(cors); + + // Register your routes here before starting the server + this.server.get("/health", async () => { + return { status: "ok" }; + }); + + // Register each route. + for (const routes of this.routes) { + for (const routeOptions of await routes.getRoutes()) { + this.server.route(routeOptions); + } + } + + await this.server.ready(); + + await this.server.listen({ + port: this.port, + host: this.host, + }); + + this.debug(`Started on http://${this.host}:${this.port}`); + } + + // Helper method to access the server instance + getInstance(): FastifyInstance { + return this.server; + } + + private handleErrors() { + // Customize our error handler to give better errors. + // NOTE: This will nicely format the Zod validation errors. + this.server.setErrorHandler((error, _request, reply) => { + if (error instanceof z.ZodError) { + const formattedErrors = error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })); + + this.debug(`Error: ${error}`); + + return reply.status(400).send({ + statusCode: 400, + error: "Validation Error", + details: formattedErrors, + }); + } + + this.debug(`Error: ${error}`); + + // Handle other types of errors + reply.status(500).send({ error: "Internal Server Error" }); + }); + } + + private handleExtJSON() { + // Add onRequest hook to decode requests from ExtJSON + this.server.addHook("onRequest", async (request, _reply) => { + this.debug(`Request: ${JSON.stringify(request.body)}`); + this.debug(`Request URL: ${request.method} ${request.url}`); + // Only transform JSON requests + if ( + request.headers["content-type"]?.includes("application/json") && + request.body + ) { + try { + // Decode ExtJSON body + request.body = decodeExtendedJsonObject(request.body); + } catch (error) { + request.log.error( + { + err: error, + body: request.body, + }, + "Failed to decode ExtJSON request body", + ); + throw new Error("Invalid JSON in request body"); + } + } + }); + + // Add onSend hook to encode responses to ExtJSON + this.server.addHook("onSend", async (_request, reply, payload) => { + // Only transform JSON responses + if ( + reply.getHeader("content-type")?.toString().includes("application/json") + ) { + // If payload is a string (already serialized), parse it first + const data = + typeof payload === "string" ? JSON.parse(payload) : payload; + return JSON.stringify(encodeExtendedJsonObject(data)); + } + return payload; + }); + } +} diff --git a/src/utils/to-csv.ts b/src/utils/to-csv.ts new file mode 100644 index 0000000..c7fa12e --- /dev/null +++ b/src/utils/to-csv.ts @@ -0,0 +1,77 @@ +/** + * Represents a flat object that can be serialized to CSV. + */ +export type CsvRecord = Record; + +/** + * Escapes a single CSV cell value. + * + * CSV requires wrapping in double quotes when a value contains: + * - comma + * - double quote + * - line break + * + * Any double quote inside the value is escaped by doubling it. + * + * @param value Raw cell value. + * @returns CSV-safe string representation of the value. + */ +function escapeCsvValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + + const normalizedValue = String(value); + const escapedValue = normalizedValue.replaceAll('"', '""'); + const mustQuote = /[",\n\r]/.test(escapedValue); + + return mustQuote ? `"${escapedValue}"` : escapedValue; +} + +/** + * Creates a stable ordered list of all keys used by rows. + * + * This makes sure we keep all fields, even if later objects contain keys + * that are not present in the first object. + * + * @param rows Flat object rows. + * @returns Ordered header keys. + */ +function collectHeaders(rows: T[]): string[] { + const headers = new Set(); + + for (const row of rows) { + for (const key of Object.keys(row)) { + headers.add(key); + } + } + + return Array.from(headers); +} + +/** + * Converts an array of flat objects to CSV text. + * + * @example + * toCsv([ + * { name: 'Alice', age: 30 }, + * { name: 'Bob', age: 28 } + * ]); + * + * @param rows Array of non-nested objects. + * @returns CSV string including header row. + */ +export function toCsv(rows: T[]): string { + if (rows.length === 0) { + return ''; + } + + const headers = collectHeaders(rows); + const csvHeaderRow = headers.map((header) => escapeCsvValue(header)).join(','); + + const csvDataRows = rows.map((row) => + headers.map((header) => escapeCsvValue(row[header])).join(','), + ); + + return [csvHeaderRow, ...csvDataRows].join('\n'); +} diff --git a/src/xo-extensions/hd-private-node.ts b/src/xo-extensions/hd-private-node.ts new file mode 100644 index 0000000..bc8f6bb --- /dev/null +++ b/src/xo-extensions/hd-private-node.ts @@ -0,0 +1,296 @@ +import { Bytes, Mnemonic, PrivateKey } from '@xocash/stack'; + +import { HDPublicNode } from './hd-public-node.js'; + +import { + encodeHdPrivateKey, + decodeHdPrivateKey, + deriveHdPath, + deriveHdPathRelative, + deriveHdPublicNode, + deriveHdPrivateNodeFromSeed, + generateRandomBytes, +} from '@bitauth/libauth'; + +import type { + DecodedHdKey, + HdKeyNetwork, + HdPrivateNodeValid, +} from '@bitauth/libauth'; + +/** + * Hierarchically Deterministic Private Node Entity. + * + * @example + * + * ```ts + * // Import our primitives. + * import { Mnemonic, HDPrivateNode } from '@xocash/primitives'; + * + * // Generate a mnemonic. + * const mnemonic = Mnemonic.generateRandom(); + * + * // Initialize a HD Private Node from Mnemonic. + * const hdPrivateNode = HDPrivateNode.fromMnemonic(mnemonic); + * + * // Derive the first Private Key. + * const privateKey = hdPrivateNode.derivePath("m/44'/145'/0'/0/0").toPrivateKey(); + * + * // Output the BCH Address. + * console.log(privateKey.derivePublicKey().deriveAddress().toCashAddr()); + * ``` + */ +export class HDPrivateNode { + // The HD Private Node. + protected readonly node: HdPrivateNodeValid; + + /** + * Construct a new HD Private Node. + * + * @param node The HD Private Node. + */ + protected constructor(node: HdPrivateNodeValid) { + this.node = node; + } + + /** + * Creates a HD Private Node from a raw seed. + * + * @param seed The raw seed bytes. + * + * @throws {Error} If HD Private Node cannot be created. + * + * @returns The created HD Private Node. + */ + public static fromSeed(seed: Uint8Array | string): HDPrivateNode { + // Cast from hex if a string was provided. + if (typeof seed === 'string') { + seed = Bytes.fromHex(seed); + } + + // If the seed is not 64 bytes, throw an error. + if (seed.length !== 64) { + throw new Error( + `Failed to derive Private Node from seed: Seed must be 64 bytes (${seed.length} given)`, + ); + } + + // Attempt to derive HD Private Node from the given seed. + const deriveResult = deriveHdPrivateNodeFromSeed(seed); + + // If a string is returned, this indicates an error... + if (typeof deriveResult === 'string') { + throw new Error(deriveResult); + } + + // Return a new HD Private Node from the given seed. + return new this(deriveResult); + } + + /** + * Creates a HD Private Node from the given mnemonic. + * + * @param mnemonic The mnemonic to use. + * + * @throws {Error} If HD Private Node cannot be created. + * + * @returns The created HD Private Node. + */ + public static fromMnemonic(mnemonic: Mnemonic): HDPrivateNode { + // Return a new HDPrivateNode from the given seed. + return this.fromSeed(mnemonic.toSeed()); + } + + /** + * Creates a HD Private Node from the given XPriv Key. + * + * @param xpriv The XPriv Key. + * + * @throws {Error} If HD Private Node cannot be created. + * + * @returns The created HD Private Node. + */ + public static fromXPriv(xpriv: string): HDPrivateNode { + // Attempt to decode the XPriv Key. + const decodeResult = decodeHdPrivateKey(xpriv); + + // If a string is returned, this indicates an error... + if (typeof decodeResult === 'string') { + throw new Error(decodeResult); + } + + // Return a new HD Private Node from the given XPriv. + return new this(decodeResult.node); + } + + /** + * Creates a HD Private Node from raw node data. + * + * @param node The raw node data. + * + * @returns The created HD Private Node. + */ + public static fromRaw(node: HdPrivateNodeValid) { + return new this(node); + } + + /** + * Generates a random HD Private Node. + * + * @throws {Error} If HD Private Node cannot be created. + * + * @returns The created HD Private Node. + */ + public static generateRandom(): HDPrivateNode { + // Attempt to derive HD Private Node from randomly generated bytes. + const deriveResult = deriveHdPrivateNodeFromSeed(generateRandomBytes(32)); + + // If a string is returned, this indicates an error... + if (typeof deriveResult === 'string') { + throw new Error(deriveResult); + } + + // Return the new HD Private Node from the random bytes. + return new this(deriveResult); + } + + /** + * Checks if the given XPriv string is a valid HD Private Key. + * + * @param xpriv The XPriv string to check. + * + * @returns True if the XPriv string is valid, false otherwise. + */ + public static isXPriv(xpriv: string): boolean { + try { + this.fromXPriv(xpriv); + return true; + } catch (_error) { + return false; + } + } + + /** + * Returns the Private Key of this node (as a Uint8Array). + * + * @returns The Node's Private Key (as a Uint8Array). + */ + public toBytes(): Bytes { + // Encode the XPriv from our node info. + return Bytes.from(this.node.privateKey); + } + + /** + * Gets the Private Key entity for this node. + * + * @returns The Private Key Entity for this node. + */ + public toPrivateKey(): PrivateKey { + // Return a Private Key Entity from the private key of our node. + return PrivateKey.fromRaw(this.node.privateKey); + } + + /** + * Converts this node to an XPriv Key for export. + * + * @param network {HdKeyNetwork} The network to encode for. + * + * @returns The XPriv Key. + */ + public toXPriv(network: HdKeyNetwork = 'mainnet'): string { + // Create our node info structure. + const nodeInfo: DecodedHdKey = { + network: network, + node: this.node, + }; + + // Encode the XPriv from our node info. + return encodeHdPrivateKey(nodeInfo).hdPrivateKey; + } + + /** + * Returns the XPriv string for this node. + * + * @returns The XPriv string for this node. + */ + public toString(): string { + // Return the Mainnet XPriv. + return this.toXPriv(); + } + + /** + * Returns the raw node data. + * + * @returns The raw node data. + */ + public toRaw(): HdPrivateNodeValid { + return this.node; + } + + /** + * Derives a HD Public Node from this Private Node. + * + * @returns The derived HD Public Node + */ + public deriveHDPublicNode(): HDPublicNode { + // Derive a HD Public Node from this HD Private Node. + const publicNode = deriveHdPublicNode(this.node); + + // Return a new HD Public Node entity from our derived Public Node. + return HDPublicNode.fromRaw(publicNode); + } + + /** + * Derives a HD Private Node from the given BIP32 path. + * + * @remarks + * + * This method automatically detects whether the path is absolute (starts with 'm/') + * or relative (e.g. '0/1') and uses the appropriate derivation method. + * + * @param path {string} The BIP32 Path (e.g. m/44'/145'/0'/0/1 or 0/1). + * + * @throws {Error} If HD Private Node cannot be derived. + * + * @returns The derived HD Private Node + */ + public derivePath(path: string): HDPrivateNode { + // Determine if this is an absolute path (starts with 'm' or 'M'). + const isAbsolutePath = path.startsWith('m') || path.startsWith('M'); + + // Use the appropriate derivation function. + const derivePathResult = isAbsolutePath + ? deriveHdPath(this.node, path) + : deriveHdPathRelative(this.node, path); + + // If a string is returned, this indicates an error... + if (typeof derivePathResult === 'string') { + throw new Error(derivePathResult); + } + + // Return a new HD Private Node Entity derived from the given path. + return new HDPrivateNode(derivePathResult); + } + + /** + * Derives a HD Private Node from the given relative BIP32 path. + * + * @param path {string} The BIP32 Path (e.g. 0/0). + * + * @throws {Error} If HD Private Node cannot be derived. + * + * @returns The derived HD Private Node + */ + public deriveRelativePath(path: string): HDPrivateNode { + // Attempt to derive the given path. + const derivePathResult = deriveHdPathRelative(this.node, path); + + // If a string is returned, this indicates an error... + if (typeof derivePathResult === 'string') { + throw new Error(derivePathResult); + } + + // Return a new HD Private Node Entity derived from the given path. + return new HDPrivateNode(derivePathResult); + } +} diff --git a/src/xo-extensions/hd-public-node.ts b/src/xo-extensions/hd-public-node.ts new file mode 100644 index 0000000..c3b9d68 --- /dev/null +++ b/src/xo-extensions/hd-public-node.ts @@ -0,0 +1,175 @@ +import { PublicKey } from '@xocash/stack'; + +import { + encodeHdPublicKey, + decodeHdPublicKey, + deriveHdPath, + deriveHdPathRelative, +} from '@bitauth/libauth'; + +import type { + DecodedHdKey, + HdKeyNetwork, + HdPublicNodeValid, +} from '@bitauth/libauth'; + +/** + * Hierarchically Deterministic Public Node Entity. + */ +export class HDPublicNode { + // The HD Public Node. + protected readonly node: HdPublicNodeValid; + + /** + * Construct a new HD Public Node. + * + * @param node {HdPublicNode} The HD Public Node. + */ + protected constructor(node: HdPublicNodeValid) { + this.node = node; + } + + /** + * Creates a HD Public Node from the given XPriv Key. + * + * @param xpub {string} The XPriv Key. + * + * @throws {Error} If HD Public Node cannot be created. + * + * @returns {HDPrivateNode} The created HD Public Node. + */ + public static fromXPub(xpub: string): HDPublicNode { + // Attempt to decode the XPub Key. + const decodeResult = decodeHdPublicKey(xpub); + + // If a string is returned, this indicates an error... + if (typeof decodeResult === 'string') { + throw new Error(decodeResult); + } + + // Return a new HD Public Node from the given XPub. + return new HDPublicNode(decodeResult.node); + } + + /** + * Creates a HDPublicNode instance from a raw object representation (LibAuth's HdPublicNodeValid). + * + * @remarks This method is UNSAFE and MUST only be used where input is already verified or trusted. + * + * @param obj - LibAuth Object of type HdPublicNodeValid + * @returns A new HDPublicNode instance + */ + public static fromRaw(node: HdPublicNodeValid) { + return new this(node); + } + + public static isXPub(xpub: string): boolean { + try { + this.fromXPub(xpub); + return true; + } catch (_error) { + return false; + } + } + + /** + * Gets the Public Key Entity for this node. + * + * @returns The Public Key Entity for this node. + */ + public toPublicKey(): PublicKey { + // Return a Public Key Entity from the public key of our node. + return PublicKey.fromRaw(this.node.publicKey); + } + + /** + * Converts this node to an XPub Key for export. + * + * @param network The network to encode for. + * + * @returns The XPub Key. + */ + public toXPub(network: HdKeyNetwork = 'mainnet'): string { + // Create our node info structure. + const nodeInfo = { + network: network, + node: this.node, + } as DecodedHdKey; + + // Encode the XPub from our node info. + return encodeHdPublicKey(nodeInfo).hdPublicKey; + } + + /** + * Returns the XPub string for this node. + * + * @returns {string} The XPub string for this node. + */ + public toString(): string { + // Return the Mainnet XPub. + return this.toXPub(); + } + + /** + * Converts the HDPublicKey to its raw Libauth representation (HDPublicNodeValid). + * + * @returns A LibAuth HDPublicNodeValid object + */ + public toRaw(): HdPublicNodeValid { + return { ...this.node }; + } + + /** + * Derives a HD Public Node from the given BIP32 path. + * + * @remarks + * + * This method automatically detects whether the path is absolute (starts with 'm/') + * or relative (e.g. '0/1') and uses the appropriate derivation method. + * + * @param path The BIP32 Path (e.g. m/44'/145'/0'/0/1 or 0/1). + * + * @throws {Error} If HD Public Node cannot be derived. + * + * @returns The derived HD Public Node + */ + public derivePath(path: string): HDPublicNode { + // Determine if this is an absolute path (starts with 'm' or 'M'). + const isAbsolutePath = path.startsWith('m') || path.startsWith('M'); + + // Use the appropriate derivation function. + const derivePathResult = isAbsolutePath + ? deriveHdPath(this.node, path) + : deriveHdPathRelative(this.node, path); + + // If a string is returned, this indicates an error... + if (typeof derivePathResult === 'string') { + throw new Error(derivePathResult); + } + + // Return a new HD Public Node Entity derived from the given path. + return new HDPublicNode(derivePathResult); + } + + /** + * Derives a HD Public Node from the given relative BIP32 path. + * + * @param path The relative BIP32 Path (e.g. 0/0 or 0/1). + * + * @throws {Error} If HD Public Node cannot be derived. + * + * @returns The derived HD Public Node + */ + public deriveRelativePath(path: string): HDPublicNode { + // Attempt to derive the given relative path. + const derivePathResult = deriveHdPathRelative(this.node, path); + + // If a string is returned, this indicates an error... + if (typeof derivePathResult === 'string') { + throw new Error(derivePathResult); + } + + // Return a new HD Public Node Entity derived from the given path. + return new HDPublicNode(derivePathResult); + } +} diff --git a/src/xo-extensions/index.ts b/src/xo-extensions/index.ts new file mode 100644 index 0000000..73020e3 --- /dev/null +++ b/src/xo-extensions/index.ts @@ -0,0 +1,4 @@ +export * from './wallet-hd-watch.js'; +export * from './wallet-p2pkh-watch.js'; +export * from './hd-public-node.js'; +export * from './hd-private-node.js'; \ No newline at end of file diff --git a/src/xo-extensions/wallet-hd-watch.ts b/src/xo-extensions/wallet-hd-watch.ts new file mode 100644 index 0000000..354bb26 --- /dev/null +++ b/src/xo-extensions/wallet-hd-watch.ts @@ -0,0 +1,525 @@ +import { + BaseWallet, + type TransactionTemplate, + type WalletBlockchain, + type WalletDependencies, + TransactionBuilder, + ADDRESS_GAP, + CHAIN_EXTERNAL, + CHAIN_INTERNAL, + BaseStore, + Activities, + StoreInMemory, + type DistributiveOmit, + type TokenBalances, + ExtMap, + calculateBalanceSats, + calculateBalanceTokens, +} from '@xocash/stack'; + +import { HDPublicNode } from './hd-public-node.js'; +import { WalletP2PKHWatch } from './wallet-p2pkh-watch.js'; + +/** + * Type for creating a watch-only HD wallet from an xpub. + */ +export type WalletHDWatchEntropy = { xpub: string }; + +/** + * Derivation data for the HD wallet. + */ +export type WalletHDWatchDerivationData = { + /** + * The full derivation path for display purposes. + * Example: "m/44'/145'/0'" for an account-level xpub. + */ + derivationPath: string; + + addressGap?: number; +}; + +/** + * Genesis data for the watch-only HD wallet. + */ +export type WalletHDWatchGenesisData = { + type: 'WalletHDWatch'; +} & WalletHDWatchEntropy & + WalletHDWatchDerivationData; + +/** + * A Watch-Only Hierarchical Deterministic (BIP44) Wallet. + * + * @remarks + * + * This wallet uses an extended public key (xpub) to derive addresses and monitor + * transactions. It cannot sign transactions because it does not have access to + * private keys. + * + * @example + * + * ```ts + * // Define services to use. + * const services = { + * blockchain, + * cache, + * } + * + * // Instantiate an instance of WalletHDWatch. + * const walletHDWatch = await WalletHDWatch.from({ + * derivationPath: "m/44'/145'/0'", + * xpub: 'xpub...' + * }, services); + * + * // Scan for transactions and start monitoring. + * await walletHDWatch.start(); + * + * // Get the balance (watch-only). + * const balance = await walletHDWatch.getBalanceSats(); + * ``` + */ +export class WalletHDWatch extends BaseWallet { + /** + * Factory method to create a new WalletHDWatch instance. + * + * @param deps - The wallet dependencies (blockchain, cache, activities). + * @param genesisData - The genesis data containing the xpub and derivation info. + * @returns A new WalletHDWatch instance. + */ + static async from( + this: T, + deps: WalletDependencies, + genesisData: DistributiveOmit, + ) { + // Assign defaults if not otherwise provided. + const activities = deps.activities + ? deps.activities + : await Activities.from(); + const cache = deps.cache ? deps.cache : StoreInMemory.from(); + + // Assign our final dependencies. + const depsFinal = { + activities, + cache, + blockchain: deps.blockchain, + }; + + // Return a new instance of WalletHDWatch. + return new WalletHDWatch(depsFinal, genesisData); + } + + // Dependencies. + public readonly activities: Activities; + public readonly blockchain: WalletBlockchain; + public readonly cache: BaseStore; + + // Genesis Data. + public readonly genesisData: WalletHDWatchGenesisData; + + // Optimizations/Simplifications. + public readonly hdPublicNode: HDPublicNode; + + // Mutable State. + public shouldMonitor = false; + + // Store child wallets, grouped by chain ("change") path in BIP44 spec. + public wallets: { [chainPath: number]: Array } = {}; + + /** + * Constructs a new WalletHDWatch instance. + * + * @param dependencies - The required wallet dependencies. + * @param genesisData - The genesis data containing the xpub and derivation info. + */ + constructor( + dependencies: Required, + genesisData: DistributiveOmit, + ) { + super(); + + // Assign our dependencies. + this.activities = dependencies.activities; + this.blockchain = dependencies.blockchain; + this.cache = dependencies.cache; + + // Assign our state. + this.genesisData = { + type: 'WalletHDWatch', + ...genesisData, + }; + + // Instantiate the HDPublicNode from the xpub in our genesis data. + this.hdPublicNode = HDPublicNode.fromXPub(genesisData.xpub); + + // Initialize empty arrays for our wallets. + this.wallets[CHAIN_EXTERNAL] = []; + this.wallets[CHAIN_INTERNAL] = []; + } + + /** + * Starts monitoring all derived addresses for transaction activity. + */ + async start() { + // Get the address gap. + // TODO: This should come from activities. + const addressGap = this.genesisData.addressGap || ADDRESS_GAP; + + // Scan the HDWallet for addresses with activity. + await this.scan(0, addressGap); + + // Declare storage for our start promises. + const startPromises: Promise[] = []; + + // Iterate over each chain path and start each wallet. + for (const chainWallets of Object.values(this.wallets)) { + for (const wallet of chainWallets) { + startPromises.push(wallet.start()); + } + } + + // Wait for all of our start promises to complete. + await Promise.all(startPromises); + } + + /** + * Stops monitoring all derived addresses. + */ + async stop() { + // Declare storage for our stop promises. + const stopPromises: Promise[] = []; + + // Iterate over each chain path and stop each wallet. + for (const chainWallets of Object.values(this.wallets)) { + for (const wallet of chainWallets) { + stopPromises.push(wallet.stop()); + } + } + + // Wait for all of our stop promises to complete. + await Promise.all(stopPromises); + } + + /** + * Removes all event listeners from this wallet. + */ + async destroy() { + this.removeAllListeners(); + } + + /** + * Fetches all transactions involving any of the watched addresses. + * + * @returns A map of transactions indexed by their transaction hash. + */ + async getTransactions() { + // Declare storage for our fetch promises. + const fetchPromises = []; + + // Iterate over each chain path and fetch transactions for each wallet. + for (const chainWallets of Object.values(this.wallets)) { + for (const wallet of chainWallets) { + fetchPromises.push(wallet.getTransactions()); + } + } + + // Wait for all of our fetch promises to complete. + const txs = await Promise.all(fetchPromises); + + // Flatten (and deduplicate) the transactions. + const txsFlattened = ExtMap.flatten(txs); + + // Emit an event to notify that transactions have been updated. + this.emit('transactionsUpdated', txsFlattened); + + // Return the deduplicated transactions. + return txsFlattened; + } + + /** + * Fetches all unspent outputs belonging to any of the watched addresses. + * + * @returns A map of unspent outputs. + */ + async getUnspents() { + // Declare storage for our fetch promises. + const fetchPromises = []; + + // Iterate over each chain path and fetch unspents for each wallet. + for (const chainWallets of Object.values(this.wallets)) { + for (const wallet of chainWallets) { + fetchPromises.push(wallet.getUnspents()); + } + } + + // Wait for all of our fetch promises to complete. + const unspents = await Promise.all(fetchPromises); + + // Flatten them. + const unspentsFlattened = ExtMap.flatten(unspents); + + // Return the unspents. + return unspentsFlattened; + } + + /** + * Throws an error because watch-only wallets cannot produce signing directives. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async getUnspentDirectives(): Promise { + throw new Error( + 'WalletHDWatch is a watch-only wallet and cannot produce signing directives. ' + + 'Use WalletHD with a private key or seed phrase to sign transactions.', + ); + } + + /** + * Calculates the total satoshi balance across all watched addresses. + * + * @returns The total balance in satoshis. + */ + async getBalanceSats() { + // Fetch our unspents. + const unspents = await this.getUnspents(); + + // Get the individual UTXOs. + const utxos = unspents.map((blockchainUTXO) => blockchainUTXO.utxo).toArray(); + + // Calculate the balance. + return calculateBalanceSats(utxos); + } + + /** + * Calculates the token balances across all watched addresses. + * + * @returns A map of token balances by category. + */ + async getBalanceTokens(): Promise { + // Fetch our unspents. + const unspents = await this.getUnspents(); + + // Get the individual UTXOs. + const utxos = unspents.map((blockchainUTXO) => blockchainUTXO.utxo).toArray(); + + // Calculate the token balances. + return calculateBalanceTokens(utxos); + } + + /** + * Gets all addresses currently being watched. + * + * @returns A map of addresses indexed by their lockscript hex. + */ + async getAddresses() { + const chainPaths = [CHAIN_EXTERNAL, CHAIN_INTERNAL]; + + const addressPromises = []; + + for (const chainPath of chainPaths) { + addressPromises.push( + ...this.wallets[chainPath].map((wallet) => + wallet.getReceivingAddress(), + ), + ); + } + + const addresses = await Promise.all(addressPromises); + + return ExtMap.fromArray(addresses, (address) => address.toLockscriptHex()); + } + + /** + * Gets the next receiving address for this wallet. + * + * @param chainPath - The chain path to use (default: CHAIN_EXTERNAL). + * @returns The receiving address. + */ + async getReceivingAddress(chainPath = CHAIN_EXTERNAL) { + // TODO: Derive a new wallet, not the first one you lazy fuckhead. + // You'll probably need to refactor your scan() approach. + let receivingWallet = this.wallets[chainPath][0]; + + // If there is no receiving wallet, create one. + // TODO: This is fucked. You need to rethink deriveWallets. + if (!receivingWallet) { + this.deriveWallets(1, chainPath); + + receivingWallet = this.wallets[chainPath][0]; + } + + // Return the receiving address of the derived wallet. + return receivingWallet.publicKey.deriveAddress(); + } + + /** + * Throws an error because watch-only wallets cannot sign transactions. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async signTransaction( + _txTemplate: Partial, + ): Promise { + throw new Error( + 'WalletHDWatch is a watch-only wallet and cannot sign transactions. ' + + 'Use WalletHD with a private key or seed phrase to sign transactions.', + ); + } + + /** + * Throws an error because watch-only wallets cannot send transactions. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async sendTransaction( + _txTemplate: Partial, + ): Promise { + throw new Error( + 'WalletHDWatch is a watch-only wallet and cannot send transactions. ' + + 'Use WalletHD with a private key or seed phrase to send transactions.', + ); + } + + /** + * Derives a specified number of watch-only wallets from the HD public key. + * + * @param count - The number of wallets to derive. + * @param chainPath - The chain path (0 = external, 1 = internal). + * @param startIndex - The starting index for derivation. + * @returns An array of newly derived WalletP2PKHWatch instances. + */ + async deriveWallets( + count: number, + chainPath = CHAIN_EXTERNAL, + startIndex = 0, + ) { + // Create an array to store our wallets. + const newWallets: Array = []; + + // Create the given count of wallets. + for (let i = 0; i < count; i++) { + // Define the derivation path (relative from the account-level xpub). + // The xpub should already be at account level, so we only need chainPath/index. + const relativePath = `${chainPath}/${startIndex + i}`; + + // Derive the public key at the given path. + const publicKeyHex = await this.cache.getOrSet(relativePath, () => { + const childNode = this.hdPublicNode.derivePath(relativePath); + return childNode.toPublicKey().toHex(); + }); + + // Create a child P2PKHWatch wallet for this Public Key. + const wallet = await WalletP2PKHWatch.from( + { + activities: this.activities, + blockchain: this.blockchain, + cache: this.cache, + }, + { + publicKey: publicKeyHex, + }, + ); + + // Subscribe to wallet-state updates. + wallet.on('stateUpdated', async () => { + this.emit('stateUpdated', null); + }); + + // Start monitoring the wallet. + wallet.start(); + + // Add the wallet to our list of wallets. + newWallets.push(wallet); + } + + // Set our wallets. + this.wallets[chainPath] = [...this.wallets[chainPath], ...newWallets]; + + // Return the wallets. + return newWallets; + } + + /** + * Scans the blockchain for addresses with transaction activity. + * + * @param startIndex - The starting index for scanning. + * @param addressGap - The number of consecutive empty addresses before stopping. + * @param chainPaths - The chain paths to scan. + */ + async scan( + startIndex = 0, + addressGap = 20, + chainPaths: Array = [CHAIN_EXTERNAL, CHAIN_INTERNAL], + ) { + // Iterate over each of the provided chain (change) paths. + for (const chainPath of chainPaths) { + // Array to store active wallet nodes. + const wallets: Array = []; + + let currentIndex = startIndex; + let emptyAddressCount = 0; + + // Continue scanning until we find 'addressGap' consecutive empty addresses. + while (emptyAddressCount < addressGap) { + // Derive a number of wallets equivalent to our addressGap. + const batch: Array = await this.deriveWallets( + addressGap, + chainPath, + currentIndex, + ); + + // Get the transaction history of each wallet in the batch. + const results = await Promise.all( + batch.map((wallet) => wallet.getTransactions()), + ); + + // Process the results. + for (let i = 0; i < results.length; i++) { + if (results[i].size > 0) { + // This address has transactions, add it to nodes and reset empty address count. + wallets.push(batch[i]); + emptyAddressCount = 0; + } else { + // This address is empty, increment the empty address count. + emptyAddressCount++; + } + + currentIndex++; + + // If we've found 'addressGap' consecutive empty addresses, stop scanning. + if (emptyAddressCount >= addressGap) { + break; + } + } + } + } + + this.emit('stateUpdated', null); + } +} + +//----------------------------------------------------------------------------- +// Factory Types/Functions +//----------------------------------------------------------------------------- + +/** + * Factory type for creating WalletHDWatch instances. + */ +export type WalletHDWatchFactory = ( + services: WalletDependencies, + genesisData: WalletHDWatchGenesisData, +) => Promise; + +/** + * Factory function to create a new WalletHDWatch instance. + * + * @param services - The wallet dependencies. + * @param genesisData - The genesis data containing the xpub and derivation info. + * @returns A new WalletHDWatch instance. + */ +export async function useWalletHDWatch< + T extends WalletHDWatch = WalletHDWatch, +>( + services: WalletDependencies, + genesisData: WalletHDWatchGenesisData, +): Promise { + return (await WalletHDWatch.from(services, genesisData)) as T; +} diff --git a/src/xo-extensions/wallet-p2pkh-watch.ts b/src/xo-extensions/wallet-p2pkh-watch.ts new file mode 100644 index 0000000..6bf145a --- /dev/null +++ b/src/xo-extensions/wallet-p2pkh-watch.ts @@ -0,0 +1,459 @@ +import { + BaseWallet, + type TransactionTemplate, + type WalletBlockchain, + type WalletDependencies, + TransactionBuilder, + type AddressStatusPayload, + type BlockchainTransaction, + type BaseStore, + Activities, + StoreInMemory, + ExtMap, + PublicKey, + calculateBalanceSats, + calculateBalanceTokens, +} from '@xocash/stack'; + +import { type Input, binToHex, type Output } from '@bitauth/libauth'; + +/** + * Genesis data for a watch-only P2PKH wallet. + */ +export type WalletP2PKHWatchGenesisData = { + type: 'WalletP2PKHWatch'; + publicKey: string; +}; + +/** + * A Watch-Only P2PKH (Pay-to-Public-Key-Hash) Wallet. + * + * @remarks + * + * This wallet uses a public key to derive an address and monitor transactions. + * It cannot sign transactions because it does not have access to the private key. + * + * @example + * ```ts + * // Instantiate P2PKH Watch Wallet from a public key. + * const walletP2PKHWatch = await WalletP2PKHWatch.from( + * { blockchain }, + * { publicKey: '02...' } + * ); + * + * // Start monitoring the balance. + * await walletP2PKHWatch.start(); + * + * // Get the balance (watch-only). + * const balance = await walletP2PKHWatch.getBalanceSats(); + * ``` + */ +export class WalletP2PKHWatch extends BaseWallet { + /** + * Factory method to create a new WalletP2PKHWatch instance. + * + * @param deps - The wallet dependencies (blockchain, cache, activities). + * @param genesisData - The genesis data containing the public key. + * @returns A new WalletP2PKHWatch instance. + */ + static async from( + this: T, + deps: WalletDependencies, + genesisData: Omit, + ) { + // Assign defaults if not otherwise provided. + const activities = deps.activities + ? deps.activities + : await Activities.from(); + const cache = deps.cache ? deps.cache : StoreInMemory.from(); + + // Assign our final dependencies. + const depsFinal = { + activities, + cache, + blockchain: deps.blockchain, + }; + + // Return a new instance of WalletP2PKHWatch. + const wallet = new this(depsFinal, genesisData) as InstanceType; + + // Return the wallet instance. + return wallet; + } + + /** + * Creates a WalletP2PKHWatch from raw public key bytes. + * + * @param services - The wallet dependencies. + * @param publicKeyBytes - The public key as a Uint8Array. + * @returns A new WalletP2PKHWatch instance. + */ + static fromBytes( + this: T, + services: WalletDependencies, + publicKeyBytes: Uint8Array, + ) { + // Create a Public Key entity from the provided bytes. + const publicKey = PublicKey.fromBytes(publicKeyBytes); + + // Return a new instance of WalletP2PKHWatch. + return this.from(services, { + publicKey: publicKey.toHex(), + }); + } + + /** + * Creates a WalletP2PKHWatch from a hex-encoded public key. + * + * @param services - The wallet dependencies. + * @param publicKeyHex - The public key as a hex string. + * @returns A new WalletP2PKHWatch instance. + */ + static fromHex( + this: T, + services: WalletDependencies, + publicKeyHex: string, + ) { + // Create a Public Key entity from the provided hex. + const publicKey = PublicKey.fromHex(publicKeyHex); + + // Return a new instance of WalletP2PKHWatch. + return this.from(services, { + publicKey: publicKey.toHex(), + }); + } + + // Dependencies. + public readonly activities: Activities; + public readonly blockchain: WalletBlockchain; + public readonly cache: BaseStore; + + // Genesis Data. + public readonly genesisData: WalletP2PKHWatchGenesisData; + + // Optimizations/Simplifications. + public readonly publicKey: PublicKey; + + // Mutable State. + public isStarted = false; + public status: string | null = null; + + /** + * Constructs a new WalletP2PKHWatch instance. + * + * @param dependencies - The required wallet dependencies. + * @param genesisData - The genesis data containing the public key. + */ + constructor( + dependencies: Required, + genesisData: Omit, + ) { + super(); + + // Assign our dependencies. + this.activities = dependencies.activities; + this.blockchain = dependencies.blockchain; + this.cache = dependencies.cache; + + // Assign our Genesis Data. + this.genesisData = { + type: 'WalletP2PKHWatch', + ...genesisData, + }; + + // Optimizations/Simplifications. + this.publicKey = PublicKey.fromHex(genesisData.publicKey); + } + + /** + * Starts monitoring this wallet's address for transaction activity. + */ + async start() { + // Do nothing if already started. + if (this.isStarted) { + return; + } + + // Get the receiving address of this wallet so we can monitor it. + const receivingAddress = await this.getReceivingAddress(); + + // Start monitoring the receiving address. + await this.blockchain.subscribeAddress( + receivingAddress.toCashAddr(), + this.onAddressNotification.bind(this), + ); + + // Mark this wallet as started. + this.isStarted = true; + } + + /** + * Stops monitoring this wallet's address. + */ + async stop() { + // Do nothing if not started. + if (!this.isStarted) { + return; + } + + // Get the receiving address of this wallet so we can stop monitoring it. + const receivingAddress = await this.getReceivingAddress(); + + // Stop monitoring the receiving address. + await this.blockchain.unsubscribeAddress( + receivingAddress.toCashAddr(), + this.onAddressNotification.bind(this), + ); + + // Mark this wallet as stopped. + this.isStarted = false; + } + + /** + * Removes all event listeners from this wallet. + */ + async destroy() { + this.removeAllListeners(); + } + + /** + * Fetches all transactions involving this wallet's address. + * + * @returns A map of transactions indexed by their transaction hash. + */ + async getTransactions() { + // Get this node's address. + const address = await this.getReceivingAddress(); + + // Get transactions involving this address. + const transactions = await this.blockchain.fetchAddressHistory( + address.toCashAddr(), + ); + + // Define a function to get a single source output for an input. + const getSourceOutput = async (input: Input) => { + const sourceTx = await this.blockchain.fetchTransaction( + binToHex(input.outpointTransactionHash), + ); + + const sourceOutput = sourceTx.getOutputs().at(input.outpointIndex); + + if (!sourceOutput) { + throw new Error(`Failed to find source output`); + } + + return sourceOutput; + }; + + // Define a function to get all source outputs for a tx. + const getSourceOutputs = async (tx: BlockchainTransaction) => { + const sourceOutputPromises = tx.transaction + .getInputs() + // Coinbase transactions do not have source output, so we return null. + .map((input) => + getSourceOutput(input).catch(() => { + return undefined; + }), + ); + + return (await Promise.all(sourceOutputPromises)).filter( + (output): output is Output => output !== undefined, + ); + }; + + // Get the source outputs for each of the transactions. + const walletTxs = await Promise.all( + transactions.toArray().map(async (tx) => ({ + ...tx, + sourceOutputs: await getSourceOutputs(tx), + })), + ); + + // Wait for source outputs to be populated. + const walletTxsIndexed = ExtMap.fromArray(walletTxs, (tx) => + tx.hash.toHex(), + ); + + // TODO: Merge activity information into a WalletTransaction type. + // 2024-11-17: Actually, that might not belong here? + + // Emit an event to notify that transactions have been updated. + this.emit('transactionsUpdated', walletTxsIndexed); + + // Return the fetched transactions. + return walletTxsIndexed; + } + + /** + * Fetches all unspent outputs belonging to this wallet's address. + * + * @returns A map of unspent outputs. + */ + async getUnspents() { + // Get this wallet's address. + const address = await this.getReceivingAddress(); + + // Fetch unspents belonging to this address. + const unspents = await this.blockchain.fetchUnspents(address.toCashAddr()); + + // TODO: Merge activity information into a WalletUnspent type. + + // Emit an event to notify that unspents have been updated. + this.emit('unspentsUpdated', unspents); + + // Return the unspents. + return unspents; + } + + /** + * Throws an error because watch-only wallets cannot produce signing directives. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async getUnspentDirectives(): Promise { + throw new Error( + 'WalletP2PKHWatch is a watch-only wallet and cannot produce signing directives. ' + + 'Use WalletP2PKH with a private key to sign transactions.', + ); + } + + /** + * Calculates the satoshi balance of this wallet. + * + * @returns The balance in satoshis. + */ + async getBalanceSats() { + // Fetch our unspents. + const unspents = await this.getUnspents(); + + // Get the individual UTXOs. + const utxos = unspents + .map((blockchainUTXO) => blockchainUTXO.utxo) + .toArray(); + + // Calculate the balance. + return calculateBalanceSats(utxos); + } + + /** + * Calculates the token balances of this wallet. + * + * @returns A map of token balances by category. + */ + async getBalanceTokens() { + // Fetch our unspents. + const unspents = await this.getUnspents(); + + // Get the individual UTXOs. + const utxos = unspents + .map((blockchainUTXO) => blockchainUTXO.utxo) + .toArray(); + + // Calculate the token balances. + return calculateBalanceTokens(utxos); + } + + /** + * Gets all addresses belonging to this wallet. + * + * @returns A map of addresses indexed by their lockscript hex. + */ + async getAddresses() { + const addresses = await Promise.all([this.getReceivingAddress()]); + + return ExtMap.fromArray(addresses, (address) => address.toLockscriptHex()); + } + + /** + * Gets the receiving address for this wallet. + * + * @returns The address derived from the public key. + */ + async getReceivingAddress() { + return this.publicKey.deriveAddress(); + } + + /** + * Throws an error because watch-only wallets cannot sign transactions. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async signTransaction( + _txTemplate: Partial, + ): Promise { + throw new Error( + 'WalletP2PKHWatch is a watch-only wallet and cannot sign transactions. ' + + 'Use WalletP2PKH with a private key to sign transactions.', + ); + } + + /** + * Throws an error because watch-only wallets cannot send transactions. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async sendTransaction( + _txTemplate: Partial, + ): Promise { + throw new Error( + 'WalletP2PKHWatch is a watch-only wallet and cannot send transactions. ' + + 'Use WalletP2PKH with a private key to send transactions.', + ); + } + + /** + * Handles notifications when the address status changes. + * + * @param _address - The address string (unused). + * @param payload - The status payload containing the new status. + */ + private async onAddressNotification( + _address: string, + payload?: AddressStatusPayload, + ) { + // Not all blockchain adapters will provide a status payload. But if they do, we check if it's the same as our current status. + // If it is, we don't want the wallet to try to update its state as it will be the same and is a waste of resources. + if (payload && payload.status === this.status) { + return; + } + + if (payload) { + // Set our new status. + this.status = payload.status; + } + + // Emit an event. + this.emit('stateUpdated', this.status); + } +} + +//----------------------------------------------------------------------------- +// Factory Types/Functions +//----------------------------------------------------------------------------- + +/** + * Factory type for creating WalletP2PKHWatch instances. + */ +export type WalletP2PKHWatchFactory< + T extends WalletP2PKHWatch = WalletP2PKHWatch, +> = ( + dependencies: WalletDependencies, + genesisData: WalletP2PKHWatchGenesisData, +) => Promise; + +/** + * Factory function to create a new WalletP2PKHWatch instance. + * + * @param dependencies - The wallet dependencies. + * @param genesisData - The genesis data containing the public key. + * @returns A new WalletP2PKHWatch instance. + */ +export async function useWalletP2PKHWatch< + T extends WalletP2PKHWatch = WalletP2PKHWatch, +>( + dependencies: WalletDependencies, + genesisData: WalletP2PKHWatchGenesisData, +): Promise { + return (await WalletP2PKHWatch.from(dependencies, genesisData)) as T; +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5f3dcc9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // File Layout + "rootDir": "./src", + "outDir": "./dist", + + // Environment Settings + "module": "nodenext", + "target": "esnext", + "types": ["node"], + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + // Stricter Typechecking Options + "exactOptionalPropertyTypes": true, + + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + } +}