diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c8aebcf651..612ee31670 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -115,6 +115,7 @@ /packages/eth-json-rpc-middleware/src/methods @MetaMask/confirmations @MetaMask/wallet-api-platform-engineers /packages/eth-json-rpc-middleware/src/wallet.* @MetaMask/confirmations @MetaMask/wallet-api-platform-engineers /packages/eth-json-rpc-provider @MetaMask/wallet-integrations @MetaMask/core-platform +/packages/bitcoin-regtest-up @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks /packages/foundryup @MetaMask/mobile-platform @MetaMask/extension-platform /packages/json-rpc-engine @MetaMask/wallet-integrations @MetaMask/core-platform /packages/json-rpc-middleware-stream @MetaMask/wallet-integrations @MetaMask/core-platform @@ -221,6 +222,8 @@ /packages/bridge-status-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/core-platform /packages/app-metadata-controller/package.json @MetaMask/mobile-platform @MetaMask/core-platform /packages/app-metadata-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/core-platform +/packages/bitcoin-regtest-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/bitcoin-regtest-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform /packages/foundryup/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform /packages/foundryup/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform /packages/seedless-onboarding-controller/package.json @MetaMask/web3auth @MetaMask/core-platform diff --git a/README.md b/README.md index 64a2300566..6a4d651603 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/authenticated-user-storage`](packages/authenticated-user-storage) - [`@metamask/base-controller`](packages/base-controller) - [`@metamask/base-data-service`](packages/base-data-service) +- [`@metamask/bitcoin-regtest-up`](packages/bitcoin-regtest-up) - [`@metamask/bridge-controller`](packages/bridge-controller) - [`@metamask/bridge-status-controller`](packages/bridge-status-controller) - [`@metamask/build-utils`](packages/build-utils) @@ -127,6 +128,7 @@ linkStyle default opacity:0.5 authenticated_user_storage(["@metamask/authenticated-user-storage"]); base_controller(["@metamask/base-controller"]); base_data_service(["@metamask/base-data-service"]); + bitcoin_regtest_up(["@metamask/bitcoin-regtest-up"]); bridge_controller(["@metamask/bridge-controller"]); bridge_status_controller(["@metamask/bridge-status-controller"]); build_utils(["@metamask/build-utils"]); diff --git a/packages/bitcoin-regtest-up/CHANGELOG.md b/packages/bitcoin-regtest-up/CHANGELOG.md new file mode 100644 index 0000000000..d4fa43346d --- /dev/null +++ b/packages/bitcoin-regtest-up/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Add the `@metamask/bitcoin-regtest-up` package ([#8827](https://github.com/MetaMask/core/pull/8827)). + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/bitcoin-regtest-up/LICENSE b/packages/bitcoin-regtest-up/LICENSE new file mode 100644 index 0000000000..9ec4f4514e --- /dev/null +++ b/packages/bitcoin-regtest-up/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/bitcoin-regtest-up/LICENSE.APACHE2 b/packages/bitcoin-regtest-up/LICENSE.APACHE2 new file mode 100644 index 0000000000..e6e77b0890 --- /dev/null +++ b/packages/bitcoin-regtest-up/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/bitcoin-regtest-up/LICENSE.MIT b/packages/bitcoin-regtest-up/LICENSE.MIT new file mode 100644 index 0000000000..fe29e78e0f --- /dev/null +++ b/packages/bitcoin-regtest-up/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/bitcoin-regtest-up/README.md b/packages/bitcoin-regtest-up/README.md new file mode 100644 index 0000000000..8e537e2040 --- /dev/null +++ b/packages/bitcoin-regtest-up/README.md @@ -0,0 +1,116 @@ +# `@metamask/bitcoin-regtest-up` + +`bitcoin-regtest-up` installs a pinned Bitcoin Core runtime for local +development and CI. It follows the same runtime-only shape as +`@metamask/foundryup`: this package installs external runtime artifacts into the +MetaMask cache and exposes binaries in `node_modules/.bin`; the consuming test +harness owns process startup, regtest config, readiness checks, and seeding. + +This package does not use Docker and does not start or seed a Bitcoin node. + +## Usage + +Install the package in the consuming repo: + +```bash +yarn add @metamask/bitcoin-regtest-up +npm install @metamask/bitcoin-regtest-up +``` + +For Yarn v4 projects, it is usually simplest to add package scripts in the +consuming repo: + +```json +{ + "scripts": { + "bitcoin-regtest-up": "node_modules/.bin/bitcoin-regtest-up", + "bitcoind": "node_modules/.bin/bitcoind", + "bitcoin-cli": "node_modules/.bin/bitcoin-cli" + } +} +``` + +Install bitcoind and bitcoin-cli: + +```bash +yarn bitcoin-regtest-up install +``` + +Run the installed Bitcoin Core wrappers: + +```bash +node_modules/.bin/bitcoind -regtest +node_modules/.bin/bitcoin-cli -regtest getblockchaininfo +``` + +For MetaMask Extension E2E tests, the Bitcoin seeder should spawn +`node_modules/.bin/bitcoind`, pass its generated regtest datadir and ports, use +`node_modules/.bin/bitcoin-cli` for node setup, poll JSON-RPC directly, and +perform all wallet/funding seeding itself. + +## Installed Artifacts + +`bitcoin-regtest-up` installs: + +- a platform-specific Bitcoin Core release archive +- a `node_modules/.bin/bitcoind` wrapper +- a `node_modules/.bin/bitcoin-cli` wrapper + +## CLI + +```bash +bitcoin-regtest-up [install] [options] +bitcoin-regtest-up cache clean [options] +``` + +Options: + +- `--bin-directory `: directory for generated wrappers. Defaults to + `node_modules/.bin`. +- `--cache-directory `: artifact cache directory. Defaults to + `.metamask/cache`. +- `--bitcoin-core-url ` and `--bitcoin-core-checksum `: override the + Bitcoin Core archive for the current platform. +- `--platform `: override platform selection, for example + `linux-x64`. + +## Default Release + +The package currently pins Bitcoin Core `30.2` for `darwin-arm64`, +`darwin-x64`, `linux-arm64`, and `linux-x64`. + +## Cache + +The cache defaults to `.metamask/cache` in the current repo. If `.yarnrc.yml` +contains `enableGlobalCache: true`, the cache moves to `~/.cache/metamask`, +matching the `@metamask/foundryup` behavior. + +Clean only this package's cache namespace: + +```bash +yarn bitcoin-regtest-up cache clean +``` + +## Package Config + +The consuming repo can override the pinned artifact URLs and checksums in its +root `package.json`: + +```json +{ + "bitcoinRegtestUp": { + "bitcoinCore": { + "version": "30.2", + "platforms": { + "linux-x64": { + "url": "https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-x86_64-linux-gnu.tar.gz", + "checksum": "6aa7bb4feb699c4c6262dd23e4004191f6df7f373b5d5978b5bcdd4bb72f75d8" + } + } + } + } +} +``` + +Supported package config keys are `bitcoinRegtestUp`, `bitcoinregtestup`, and +`bitcoin-regtest-up`. diff --git a/packages/bitcoin-regtest-up/jest.config.js b/packages/bitcoin-regtest-up/jest.config.js new file mode 100644 index 0000000000..fc9274eb1e --- /dev/null +++ b/packages/bitcoin-regtest-up/jest.config.js @@ -0,0 +1,32 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // The CLI entrypoint is exercised through package builds and installed-bin smoke tests. + coveragePathIgnorePatterns: [ + ...baseConfig.coveragePathIgnorePatterns, + './src/bin/bitcoin-regtest-up.ts', + ], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 35, + functions: 60, + lines: 65, + statements: 65, + }, + }, +}); diff --git a/packages/bitcoin-regtest-up/package.json b/packages/bitcoin-regtest-up/package.json new file mode 100644 index 0000000000..095ad68bd2 --- /dev/null +++ b/packages/bitcoin-regtest-up/package.json @@ -0,0 +1,71 @@ +{ + "name": "@metamask/bitcoin-regtest-up", + "version": "0.0.0", + "description": "Bitcoin Core regtest runtime installer for MetaMask E2E tests", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/bitcoin-regtest-up#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "bin": "./dist/bin/bitcoin-regtest-up.mjs", + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/bitcoin-regtest-up", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/bitcoin-regtest-up", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/bitcoin-regtest-up/src/bin/bitcoin-regtest-up.ts b/packages/bitcoin-regtest-up/src/bin/bitcoin-regtest-up.ts new file mode 100644 index 0000000000..c9cd9985b9 --- /dev/null +++ b/packages/bitcoin-regtest-up/src/bin/bitcoin-regtest-up.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/* eslint-disable no-restricted-globals */ +import { + cleanBitcoinRegtestCache, + installBitcoinRegtest, + parseBitcoinRegtestInstallCliOptions, + readBitcoinRegtestInstallOptionsFromPackageJson, +} from '../install'; + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + + if (command === '--help' || command === 'help') { + printHelp(); + return; + } + + if (command === 'cache' && args[0] === 'clean') { + await cleanBitcoinRegtestCache({ + ...readBitcoinRegtestInstallOptionsFromPackageJson(), + ...parseBitcoinRegtestInstallCliOptions(args.slice(1)), + }); + console.log('[bitcoin-regtest-up] cache cleaned'); + return; + } + + const installArgs = command === 'install' ? args : process.argv.slice(2); + const result = await installBitcoinRegtest({ + ...readBitcoinRegtestInstallOptionsFromPackageJson(), + ...parseBitcoinRegtestInstallCliOptions(installArgs), + }); + + console.log( + `[bitcoin-regtest-up] Bitcoin Core ${ + result.cacheHit ? 'found in cache' : 'installed' + }`, + ); + console.log( + `[bitcoin-regtest-up] bitcoind installed at ${result.bitcoindBinary}`, + ); + console.log( + `[bitcoin-regtest-up] bitcoin-cli installed at ${result.bitcoinCliBinary}`, + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); + +function printHelp(): void { + console.log(`Usage: bitcoin-regtest-up [install] [options] + bitcoin-regtest-up cache clean [options] + +Commands: + install Install Bitcoin Core bitcoind and bitcoin-cli. Default command. + cache clean Remove cached bitcoin-regtest-up artifacts. + +Options: + --bin-directory Directory for executable wrappers. + Defaults to node_modules/.bin. + --cache-directory Cache directory. Defaults to .metamask/cache. + --bitcoin-core-url Bitcoin Core archive URL for the current platform. + --bitcoin-core-checksum Expected Bitcoin Core SHA-256 checksum. + --platform Override platform key, e.g. linux-x64. + --help Show this help text.`); +} diff --git a/packages/bitcoin-regtest-up/src/index.ts b/packages/bitcoin-regtest-up/src/index.ts new file mode 100644 index 0000000000..7e36171c69 --- /dev/null +++ b/packages/bitcoin-regtest-up/src/index.ts @@ -0,0 +1,15 @@ +export { + BITCOIN_REGTEST_DEFAULT_CORE, + cleanBitcoinRegtestCache, + getBitcoinRegtestCacheDirectory, + installBitcoinRegtest, + parseBitcoinRegtestInstallCliOptions, + readBitcoinRegtestInstallOptionsFromPackageJson, +} from './install'; +export type { + BitcoinRegtestArtifactConfig, + BitcoinRegtestArtifactPlatformConfig, + BitcoinRegtestInstallDependencies, + BitcoinRegtestInstallOptions, + BitcoinRegtestInstallResult, +} from './install'; diff --git a/packages/bitcoin-regtest-up/src/install.test.ts b/packages/bitcoin-regtest-up/src/install.test.ts new file mode 100644 index 0000000000..01ac0c9d3a --- /dev/null +++ b/packages/bitcoin-regtest-up/src/install.test.ts @@ -0,0 +1,446 @@ +/* eslint-disable jest/expect-expect, n/no-sync */ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + BITCOIN_REGTEST_DEFAULT_CORE, + cleanBitcoinRegtestCache, + getBitcoinRegtestCacheDirectory, + installBitcoinRegtest, + parseBitcoinRegtestInstallCliOptions, + readBitcoinRegtestInstallOptionsFromPackageJson, +} from './install'; +import type { BitcoinRegtestInstallDependencies } from './install'; + +describe('bitcoin-regtest-up installer', () => { + let tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs) { + rmSync(tempDir, { force: true, recursive: true }); + } + tempDirs = []; + }); + + it('pins a runnable Bitcoin Core release', () => { + assert.equal(BITCOIN_REGTEST_DEFAULT_CORE.version, '30.2'); + assert.equal( + BITCOIN_REGTEST_DEFAULT_CORE.platforms['darwin-arm64']?.checksum, + 'c2ecab62891de22228043815cb6211549a32272be3d5d052ff19847d3420bd10', + ); + assert.equal( + BITCOIN_REGTEST_DEFAULT_CORE.platforms['linux-x64']?.checksum, + '6aa7bb4feb699c4c6262dd23e4004191f6df7f373b5d5978b5bcdd4bb72f75d8', + ); + }); + + it('uses the local MetaMask cache when Yarn global cache is disabled', () => { + const cwd = createTempDir(); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: false\n'); + + assert.equal( + getBitcoinRegtestCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); + + it('reads pinned installer options from package.json', () => { + const cwd = createTempDir(); + writeFileSync( + join(cwd, 'package.json'), + JSON.stringify({ + bitcoinRegtestUp: { + bitcoinCore: { + platforms: { + 'linux-x64': { + checksum: sha256('bitcoin-core-from-package-json'), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'test-version', + }, + }, + }), + ); + + assert.deepEqual(readBitcoinRegtestInstallOptionsFromPackageJson({ cwd }), { + bitcoinCore: { + platforms: { + 'linux-x64': { + checksum: sha256('bitcoin-core-from-package-json'), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'test-version', + }, + }); + }); + + it('parses installer CLI options', () => { + assert.deepEqual( + parseBitcoinRegtestInstallCliOptions([ + '--cache-directory', + '/tmp/cache', + '--bin-directory', + '/tmp/bin', + '--bitcoin-core-url', + 'https://example.test/bitcoin.tar.gz', + '--bitcoin-core-checksum', + 'abc123', + ]), + { + binDirectory: '/tmp/bin', + bitcoinCore: { + platforms: { + current: { + checksum: 'abc123', + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + }, + cacheDirectory: '/tmp/cache', + }, + ); + }); + + it('downloads, verifies, caches, and installs Bitcoin Core wrappers', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const downloads: { destination: string; url: string }[] = []; + const bitcoinCoreContent = 'fake bitcoin core archive'; + const dependencies = createDependencies({ bitcoinCoreContent, downloads }); + + const result = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: { + platforms: { + 'darwin-arm64': { + checksum: sha256(bitcoinCoreContent), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'test-bitcoin', + }, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + dependencies, + ); + + assert.equal(result.cacheHit, false); + assert.equal(result.version, 'test-bitcoin'); + assert.equal(result.bitcoindBinary, join(binDirectory, 'bitcoind')); + assert.equal(result.bitcoinCliBinary, join(binDirectory, 'bitcoin-cli')); + assert.ok(existsSync(result.bitcoindBinary)); + assert.ok(existsSync(result.bitcoinCliBinary)); + assert.ok( + readFileSync(result.bitcoindBinary, 'utf8').includes( + `const executablePath = ${JSON.stringify(result.sourceBitcoindBinary)};`, + ), + ); + assert.deepEqual( + downloads.map(({ url }) => url), + ['https://example.test/bitcoin.tar.gz'], + ); + + const wrapperOutput = execFileSync( + process.execPath, + [result.bitcoindBinary, '-version'], + { encoding: 'utf8' }, + ); + assert.equal(wrapperOutput.trim(), 'bitcoind -version'); + }); + + it('replaces stale bin symlinks without modifying their targets', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const bitcoinCoreContent = 'fake bitcoin core archive'; + const staleBitcoindTarget = join(cwd, 'stale-bitcoind-target'); + const staleBitcoinCliTarget = join(cwd, 'stale-bitcoin-cli-target'); + + await mkdir(binDirectory, { recursive: true }); + writeFileSync(staleBitcoindTarget, 'do not overwrite bitcoind'); + writeFileSync(staleBitcoinCliTarget, 'do not overwrite bitcoin-cli'); + symlinkSync(staleBitcoindTarget, join(binDirectory, 'bitcoind')); + symlinkSync(staleBitcoinCliTarget, join(binDirectory, 'bitcoin-cli')); + + const result = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: { + platforms: { + 'darwin-arm64': { + checksum: sha256(bitcoinCoreContent), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + }, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + createDependencies({ bitcoinCoreContent }), + ); + + assert.equal( + readFileSync(staleBitcoindTarget, 'utf8'), + 'do not overwrite bitcoind', + ); + assert.equal( + readFileSync(staleBitcoinCliTarget, 'utf8'), + 'do not overwrite bitcoin-cli', + ); + assert.equal(lstatSync(result.bitcoindBinary).isSymbolicLink(), false); + assert.equal(lstatSync(result.bitcoinCliBinary).isSymbolicLink(), false); + }); + + it('installs a bitcoind wrapper for Bitcoin Core archives that ship bitcoin-node', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const bitcoinCoreContent = 'fake bitcoin core archive with bitcoin-node'; + + const result = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: { + platforms: { + 'darwin-arm64': { + checksum: sha256(bitcoinCoreContent), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'test-bitcoin-node', + }, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + createDependencies({ + bitcoinCoreContent, + daemonBinaryName: 'bitcoin-node', + }), + ); + + assert.ok(result.sourceBitcoindBinary.endsWith('/libexec/bitcoin-node')); + assert.ok(existsSync(result.bitcoindBinary)); + + const wrapperOutput = execFileSync( + process.execPath, + [result.bitcoindBinary, '-version'], + { encoding: 'utf8' }, + ); + assert.equal(wrapperOutput.trim(), 'bitcoin-node -version'); + }); + + it('installs a bitcoind wrapper for Bitcoin Core archives that ship the bitcoin launcher', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const bitcoinCoreContent = + 'fake bitcoin core archive with bitcoin launcher'; + + const result = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: { + platforms: { + 'darwin-arm64': { + checksum: sha256(bitcoinCoreContent), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'test-bitcoin-launcher', + }, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + createDependencies({ + bitcoinCoreContent, + daemonBinaryName: 'bitcoin', + }), + ); + + assert.ok(result.sourceBitcoindBinary.endsWith('/bin/bitcoin')); + assert.ok(existsSync(result.bitcoindBinary)); + + const wrapperOutput = execFileSync( + process.execPath, + [result.bitcoindBinary, '-version'], + { encoding: 'utf8' }, + ); + assert.equal(wrapperOutput.trim(), 'bitcoin node -version'); + }); + + it('reuses cached Bitcoin Core artifacts without downloading again', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const bitcoinCoreContent = 'cached bitcoin core archive'; + const bitcoinCore = { + platforms: { + 'linux-x64': { + checksum: sha256(bitcoinCoreContent), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'cached-version', + }; + + await installBitcoinRegtest( + { binDirectory, bitcoinCore, cacheDirectory, cwd, platform: 'linux-x64' }, + createDependencies({ bitcoinCoreContent }), + ); + + const result = await installBitcoinRegtest( + { binDirectory, bitcoinCore, cacheDirectory, cwd, platform: 'linux-x64' }, + { + downloadFile: async (): Promise => { + throw new Error('cache miss'); + }, + }, + ); + + assert.equal(result.cacheHit, true); + }); + + it('replaces cached Bitcoin Core artifacts when the daemon is not runnable', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const bitcoinCoreContent = 'fresh bitcoin core archive'; + const checksum = sha256(bitcoinCoreContent); + const url = 'https://example.test/bitcoin.tar.gz'; + const cacheKey = sha256(`${url}:${checksum}`); + const cachedBinDirectory = join( + cacheDirectory, + 'bitcoin-regtest-up', + 'bitcoin-core', + cacheKey, + 'bitcoin-30.2', + 'bin', + ); + const downloads: { destination: string; url: string }[] = []; + + await mkdir(cachedBinDirectory, { recursive: true }); + await writeFile( + join(cachedBinDirectory, '..', '..', '.source-checksum'), + checksum, + ); + await writeFile( + join(cachedBinDirectory, 'bitcoin'), + '#!/usr/bin/env node\nprocess.exit(1);\n', + { mode: 0o755 }, + ); + await writeExecutable( + join(cachedBinDirectory, 'bitcoin-cli'), + 'bitcoin-cli', + ); + + const result = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: { + platforms: { + 'darwin-arm64': { + checksum, + url, + }, + }, + version: 'cached-version', + }, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + createDependencies({ bitcoinCoreContent, downloads }), + ); + + assert.equal(result.cacheHit, false); + assert.equal(downloads.length, 1); + assert.ok(result.sourceBitcoindBinary.endsWith('/bin/bitcoind')); + }); + + it('cleans only the bitcoin-regtest-up cache namespace', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + await mkdir(join(cacheDirectory, 'bitcoin-regtest-up', 'old'), { + recursive: true, + }); + await mkdir(join(cacheDirectory, 'foundryup', 'kept'), { + recursive: true, + }); + + await cleanBitcoinRegtestCache({ cacheDirectory, cwd }); + + assert.equal(existsSync(join(cacheDirectory, 'bitcoin-regtest-up')), false); + assert.equal(existsSync(join(cacheDirectory, 'foundryup', 'kept')), true); + }); + + function createTempDir(): string { + const tempDir = mkdtempSync(join(tmpdir(), 'bitcoin-regtest-up-test-')); + tempDirs.push(tempDir); + return tempDir; + } +}); + +function createDependencies({ + bitcoinCoreContent, + daemonBinaryName = 'bitcoind', + downloads = [], +}: { + bitcoinCoreContent: string; + daemonBinaryName?: string; + downloads?: { destination: string; url: string }[]; +}): BitcoinRegtestInstallDependencies { + return { + downloadFile: async (url, destination): Promise => { + downloads.push({ destination, url }); + await writeFile(destination, bitcoinCoreContent); + }, + extractArchive: async (_archivePath, destination): Promise => { + const binDirectory = join(destination, 'bitcoin-30.2', 'bin'); + const libexecDirectory = join(destination, 'bitcoin-30.2', 'libexec'); + await mkdir(binDirectory, { recursive: true }); + await mkdir(libexecDirectory, { recursive: true }); + await writeExecutable( + join( + daemonBinaryName === 'bitcoin-node' ? libexecDirectory : binDirectory, + daemonBinaryName, + ), + daemonBinaryName, + ); + await writeExecutable(join(binDirectory, 'bitcoin-cli'), 'bitcoin-cli'); + }, + }; +} + +async function writeExecutable(path: string, name: string): Promise { + await writeFile( + path, + `#!/usr/bin/env node\nconsole.log(${JSON.stringify(name)} + ' ' + process.argv.slice(2).join(' '));\n`, + { mode: 0o755 }, + ); +} + +function sha256(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} diff --git a/packages/bitcoin-regtest-up/src/install.ts b/packages/bitcoin-regtest-up/src/install.ts new file mode 100644 index 0000000000..023832f5dd --- /dev/null +++ b/packages/bitcoin-regtest-up/src/install.ts @@ -0,0 +1,654 @@ +/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals */ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + createWriteStream, + existsSync, + readdirSync, + readFileSync, + statSync, +} from 'node:fs'; +import { + chmod, + mkdir, + readFile, + rename, + rm, + unlink, + writeFile, +} from 'node:fs/promises'; +import { request as requestHttp } from 'node:http'; +import { request as requestHttps } from 'node:https'; +import { arch as osArch, homedir, platform as osPlatform } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +const BITCOIN_REGTEST_CACHE_NAMESPACE = 'bitcoin-regtest-up'; +const BITCOIN_CORE_CACHE_NAMESPACE = 'bitcoin-core'; + +export type BitcoinRegtestArtifactConfig = { + platforms: Record; + version?: string; +}; + +export type BitcoinRegtestArtifactPlatformConfig = { + checksum: string; + size?: number; + url: string; +}; + +export type BitcoinRegtestInstallOptions = { + binDirectory?: string; + bitcoinCore?: BitcoinRegtestArtifactConfig; + cacheDirectory?: string; + cwd?: string; + platform?: string; +}; + +export type BitcoinRegtestInstallResult = { + bitcoinCliBinary: string; + bitcoindBinary: string; + cacheHit: boolean; + checksum: string; + sourceBitcoindArgs: string[]; + sourceBitcoinCliBinary: string; + sourceBitcoindBinary: string; + version?: string; +}; + +export type BitcoinRegtestInstallDependencies = { + downloadFile?: (url: string, destination: string) => Promise; + extractArchive?: (archivePath: string, destination: string) => Promise; +}; + +type BitcoinRegtestPackageJson = { + 'bitcoin-regtest-up'?: BitcoinRegtestPackageJsonConfig; + bitcoinRegtestUp?: BitcoinRegtestPackageJsonConfig; + bitcoinregtestup?: BitcoinRegtestPackageJsonConfig; +}; + +type BitcoinRegtestPackageJsonConfig = Pick< + BitcoinRegtestInstallOptions, + 'binDirectory' | 'bitcoinCore' | 'cacheDirectory' +>; + +export const BITCOIN_REGTEST_DEFAULT_CORE: BitcoinRegtestArtifactConfig = { + version: '30.2', + platforms: { + 'darwin-arm64': { + checksum: + 'c2ecab62891de22228043815cb6211549a32272be3d5d052ff19847d3420bd10', + url: 'https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-arm64-apple-darwin.tar.gz', + }, + 'darwin-x64': { + checksum: + '99d5cee9b9c37be506396c30837a4b98e320bfea71c474d6120a7e8eb6075c7b', + url: 'https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-x86_64-apple-darwin.tar.gz', + }, + 'linux-arm64': { + checksum: + '73e76c14edc79808a0511c744d102ffbb494807ee90cbcba176568243254b532', + url: 'https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-aarch64-linux-gnu.tar.gz', + }, + 'linux-x64': { + checksum: + '6aa7bb4feb699c4c6262dd23e4004191f6df7f373b5d5978b5bcdd4bb72f75d8', + url: 'https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-x86_64-linux-gnu.tar.gz', + }, + }, +}; + +export function getBitcoinRegtestCacheDirectory({ + cwd = process.cwd(), + homeDirectory = homedir(), +}: { + cwd?: string; + homeDirectory?: string; +} = {}): string { + const yarnRcPath = join(cwd, '.yarnrc.yml'); + + try { + const yarnRc = readFileSync(yarnRcPath, 'utf8'); + if (/^\s*enableGlobalCache:\s*true\s*$/mu.test(yarnRc)) { + return join(homeDirectory, '.cache', 'metamask'); + } + } catch (error) { + if (!isFileMissingError(error)) { + console.warn( + `Warning: Error reading ${yarnRcPath}, using local bitcoin-regtest-up cache:`, + error, + ); + } + } + + return join(cwd, '.metamask', 'cache'); +} + +export function readBitcoinRegtestInstallOptionsFromPackageJson({ + cwd = process.cwd(), + packageJsonPath = join(cwd, 'package.json'), +}: { + cwd?: string; + packageJsonPath?: string; +} = {}): BitcoinRegtestInstallOptions { + const packageJson = JSON.parse( + readFileSync(packageJsonPath, 'utf8'), + ) as BitcoinRegtestPackageJson; + const config = + packageJson.bitcoinRegtestUp ?? + packageJson.bitcoinregtestup ?? + packageJson['bitcoin-regtest-up']; + const options: BitcoinRegtestInstallOptions = {}; + + if (config?.binDirectory) { + options.binDirectory = config.binDirectory; + } + if (config?.bitcoinCore) { + options.bitcoinCore = config.bitcoinCore; + } + if (config?.cacheDirectory) { + options.cacheDirectory = config.cacheDirectory; + } + + return options; +} + +export function parseBitcoinRegtestInstallCliOptions( + args: string[], +): BitcoinRegtestInstallOptions { + const options: BitcoinRegtestInstallOptions = {}; + const bitcoinCore: Partial = {}; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const value = args[index + 1]; + + switch (arg) { + case '--bin-directory': + options.binDirectory = readCliValue(arg, value); + index += 1; + break; + case '--bitcoin-core-checksum': + bitcoinCore.checksum = readCliValue(arg, value); + index += 1; + break; + case '--bitcoin-core-url': + bitcoinCore.url = readCliValue(arg, value); + index += 1; + break; + case '--cache-directory': + options.cacheDirectory = readCliValue(arg, value); + index += 1; + break; + case '--platform': + options.platform = readCliValue(arg, value); + index += 1; + break; + default: + throw new Error(`Unknown bitcoin-regtest-up install option: ${arg}`); + } + } + + if (bitcoinCore.url || bitcoinCore.checksum) { + options.bitcoinCore = { + platforms: { + current: requireCompletePlatformConfig( + bitcoinCore, + 'Bitcoin Core CLI options', + ), + }, + }; + } + + return options; +} + +export async function installBitcoinRegtest( + options: BitcoinRegtestInstallOptions = {}, + dependencies: BitcoinRegtestInstallDependencies = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getBitcoinRegtestCacheDirectory({ cwd }); + const binDirectory = + options.binDirectory ?? join(cwd, 'node_modules', '.bin'); + const platformKey = options.platform ?? getPlatformKey(); + const bitcoinCore = options.bitcoinCore ?? BITCOIN_REGTEST_DEFAULT_CORE; + const bitcoinCoreConfig = resolvePlatformConfig( + bitcoinCore, + platformKey, + 'Bitcoin Core archive', + ); + const bitcoinCoreResult = await installBitcoinCoreArchive( + { cacheDirectory, config: bitcoinCoreConfig }, + dependencies, + ); + const bitcoindBinary = await installExecutableWrapper({ + binDirectory, + commandName: 'bitcoind', + executableArgs: bitcoinCoreResult.sourceBitcoindArgs, + executablePath: bitcoinCoreResult.sourceBitcoindBinary, + }); + const bitcoinCliBinary = await installExecutableWrapper({ + binDirectory, + commandName: 'bitcoin-cli', + executablePath: bitcoinCoreResult.sourceBitcoinCliBinary, + }); + + return { + bitcoinCliBinary, + bitcoindBinary, + cacheHit: bitcoinCoreResult.cacheHit, + checksum: bitcoinCoreConfig.checksum, + sourceBitcoindArgs: bitcoinCoreResult.sourceBitcoindArgs, + sourceBitcoinCliBinary: bitcoinCoreResult.sourceBitcoinCliBinary, + sourceBitcoindBinary: bitcoinCoreResult.sourceBitcoindBinary, + version: bitcoinCore.version, + }; +} + +export async function cleanBitcoinRegtestCache( + options: Pick = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getBitcoinRegtestCacheDirectory({ cwd }); + + await rm(join(cacheDirectory, BITCOIN_REGTEST_CACHE_NAMESPACE), { + force: true, + recursive: true, + }); +} + +async function installBitcoinCoreArchive( + { + cacheDirectory, + config, + }: { + cacheDirectory: string; + config: BitcoinRegtestArtifactPlatformConfig; + }, + dependencies: BitcoinRegtestInstallDependencies, +): Promise<{ + cacheHit: boolean; + sourceBitcoindArgs: string[]; + sourceBitcoinCliBinary: string; + sourceBitcoindBinary: string; +}> { + const cacheKey = getCacheKey(config); + const cacheRoot = join( + cacheDirectory, + BITCOIN_REGTEST_CACHE_NAMESPACE, + BITCOIN_CORE_CACHE_NAMESPACE, + cacheKey, + ); + const checksumPath = join(cacheRoot, '.source-checksum'); + const cached = findBitcoinCoreBinaries(cacheRoot); + + if ( + cached && + existsSync(checksumPath) && + readFileSync(checksumPath, 'utf8') === config.checksum && + (await areBitcoinCoreBinariesRunnable(cached)) + ) { + return { cacheHit: true, ...cached }; + } + + const tempRoot = `${cacheRoot}.downloading`; + const archivePath = join(tempRoot, 'bitcoin-core.tar.gz'); + const downloadFile = dependencies.downloadFile ?? downloadFileFromUrl; + const extractArchive = dependencies.extractArchive ?? extractTarGzArchive; + + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + await mkdir(tempRoot, { recursive: true }); + + try { + await downloadFile(config.url, archivePath); + await verifyFileChecksum( + archivePath, + config.checksum, + 'Downloaded Bitcoin Core', + ); + await extractArchive(archivePath, tempRoot); + + const binaries = findBitcoinCoreBinaries(tempRoot); + if (!binaries) { + throw new Error( + 'Bitcoin Core archive did not contain a node daemon (bitcoind, bitcoin-node, or bitcoin) and bin/bitcoin-cli.', + ); + } + await assertBitcoinCoreBinariesRunnable(binaries); + + await writeFile(checksumPath.replace(cacheRoot, tempRoot), config.checksum); + await mkdir(dirname(cacheRoot), { recursive: true }); + await rename(tempRoot, cacheRoot); + + return { + cacheHit: false, + sourceBitcoindArgs: binaries.sourceBitcoindArgs, + sourceBitcoinCliBinary: binaries.sourceBitcoinCliBinary.replace( + tempRoot, + cacheRoot, + ), + sourceBitcoindBinary: binaries.sourceBitcoindBinary.replace( + tempRoot, + cacheRoot, + ), + }; + } catch (error) { + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + throw error; + } +} + +async function installExecutableWrapper({ + binDirectory, + commandName, + executableArgs = [], + executablePath, +}: { + binDirectory: string; + commandName: string; + executableArgs?: string[]; + executablePath: string; +}): Promise { + const binaryPath = join(binDirectory, commandName); + const resolvedExecutablePath = resolve(executablePath); + + await mkdir(binDirectory, { recursive: true }); + await unlink(binaryPath).catch((error) => { + if (!isFileMissingError(error)) { + throw error; + } + }); + await writeFile( + binaryPath, + `#!/usr/bin/env node +const { spawnSync } = require('node:child_process'); + +const executablePath = ${JSON.stringify(resolvedExecutablePath)}; +const result = spawnSync(executablePath, ${JSON.stringify(executableArgs)}.concat(process.argv.slice(2)), { + stdio: 'inherit', +}); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +if (result.signal) { + process.kill(process.pid, result.signal); +} + +process.exit(result.status ?? 0); +`, + ); + await chmod(binaryPath, 0o755); + + return binaryPath; +} + +async function areBitcoinCoreBinariesRunnable(binaries: { + sourceBitcoindArgs: string[]; + sourceBitcoinCliBinary: string; + sourceBitcoindBinary: string; +}): Promise { + try { + await assertBitcoinCoreBinariesRunnable(binaries); + return true; + } catch { + return false; + } +} + +async function assertBitcoinCoreBinariesRunnable(binaries: { + sourceBitcoindArgs: string[]; + sourceBitcoinCliBinary: string; + sourceBitcoindBinary: string; +}): Promise { + await runCommand(binaries.sourceBitcoindBinary, [ + ...binaries.sourceBitcoindArgs, + '-version', + ]); + await runCommand(binaries.sourceBitcoinCliBinary, ['-version']); +} + +function findBitcoinCoreBinaries(root: string): + | { + sourceBitcoindArgs: string[]; + sourceBitcoinCliBinary: string; + sourceBitcoindBinary: string; + } + | undefined { + const sourceBitcoinCliBinary = findExecutable(root, 'bitcoin-cli'); + const sourceBitcoindBinary = findBitcoinCoreDaemonBinary(root); + + if (!sourceBitcoindBinary || !sourceBitcoinCliBinary) { + return undefined; + } + + return { + sourceBitcoindArgs: sourceBitcoindBinary.name === 'bitcoin' ? ['node'] : [], + sourceBitcoinCliBinary, + sourceBitcoindBinary: sourceBitcoindBinary.path, + }; +} + +function findBitcoinCoreDaemonBinary( + root: string, +): { name: string; path: string } | undefined { + for (const name of ['bitcoind', 'bitcoin-node', 'bitcoin']) { + const path = findExecutable(root, name); + if (path) { + return { name, path }; + } + } + + return undefined; +} + +function findExecutable(root: string, name: string): string | undefined { + if (!existsSync(root)) { + return undefined; + } + + for (const entry of readdirSync(root)) { + const entryPath = join(root, entry); + const stat = statSync(entryPath); + if (stat.isDirectory()) { + const found = findExecutable(entryPath, name); + if (found) { + return found; + } + } else if (entry === name) { + return entryPath; + } + } + + return undefined; +} + +function resolvePlatformConfig( + config: BitcoinRegtestArtifactConfig, + platform: string, + label: string, +): BitcoinRegtestArtifactPlatformConfig { + const platformConfig = config.platforms[platform] ?? config.platforms.current; + + if (!platformConfig) { + throw new Error(`No ${label} is configured for ${platform}.`); + } + + return platformConfig; +} + +function requireCompletePlatformConfig( + config: Partial, + label: string, +): BitcoinRegtestArtifactPlatformConfig { + if (!config.url || !config.checksum) { + throw new Error(`${label} require both a URL and a checksum.`); + } + + return { + checksum: config.checksum, + url: config.url, + }; +} + +function getCacheKey(config: BitcoinRegtestArtifactPlatformConfig): string { + return createHash('sha256') + .update(`${config.url}:${config.checksum}`) + .digest('hex'); +} + +async function verifyFileChecksum( + filePath: string, + expectedChecksum: string, + label: string, +): Promise { + const checksum = createHash('sha256') + .update(await readFile(filePath)) + .digest('hex'); + + if (checksum !== expectedChecksum) { + throw new Error( + `${label} checksum mismatch. Expected ${expectedChecksum}, got ${checksum}.`, + ); + } +} + +async function downloadFileFromUrl( + url: string, + destination: string, +): Promise { + await mkdir(dirname(destination), { recursive: true }); + await pipeline( + await openDownloadStream(new URL(url)), + createWriteStream(destination), + ); +} + +async function openDownloadStream( + url: URL, + redirectsRemaining = 5, +): Promise { + const request = url.protocol === 'http:' ? requestHttp : requestHttps; + + return await new Promise((resolvePromise, rejectPromise) => { + const req = request(url, (response) => { + const { headers, statusCode, statusMessage } = response; + + if ( + statusCode && + statusCode >= 300 && + statusCode < 400 && + headers.location + ) { + response.resume(); + if (redirectsRemaining <= 0) { + rejectPromise(new Error(`Too many redirects downloading ${url}`)); + return; + } + + openDownloadStream( + new URL(headers.location, url), + redirectsRemaining - 1, + ) + .then(resolvePromise) + .catch(rejectPromise); + return; + } + + if (!statusCode || statusCode < 200 || statusCode >= 300) { + response.resume(); + rejectPromise( + new Error( + `Request to ${url} failed with ${statusCode ?? 'unknown'} ${ + statusMessage ?? '' + }`.trim(), + ), + ); + return; + } + + resolvePromise(response); + }); + + req.on('error', rejectPromise); + req.end(); + }); +} + +async function extractTarGzArchive( + archivePath: string, + destination: string, +): Promise { + await runCommand('tar', ['-xzf', archivePath, '-C', destination]); +} + +async function runCommand(command: string, args: string[]): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + shell: false, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', rejectPromise); + child.on('close', (code, signal) => { + if (code === 0) { + resolvePromise(); + return; + } + const exitStatus = signal ? `signal ${signal}` : `code ${code ?? 'null'}`; + rejectPromise( + new Error( + `${command} ${args.join(' ')} failed with ${exitStatus}: ${stderr}`, + ), + ); + }); + }); +} + +function getPlatformKey(): string { + const platform = osPlatform(); + const arch = osArch(); + + if (platform === 'darwin' && arch === 'arm64') { + return 'darwin-arm64'; + } + if (platform === 'darwin' && arch === 'x64') { + return 'darwin-x64'; + } + if (platform === 'linux' && arch === 'arm64') { + return 'linux-arm64'; + } + if (platform === 'linux' && arch === 'x64') { + return 'linux-x64'; + } + + return `${platform}-${arch}`; +} + +function readCliValue(arg: string, value: string | undefined): string { + if (!value || value.startsWith('--')) { + throw new Error(`${arg} requires a value.`); + } + + return value; +} + +function isFileMissingError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + Object.prototype.hasOwnProperty.call(error, 'code') && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/packages/bitcoin-regtest-up/tsconfig.build.json b/packages/bitcoin-regtest-up/tsconfig.build.json new file mode 100644 index 0000000000..02a0eea03f --- /dev/null +++ b/packages/bitcoin-regtest-up/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/bitcoin-regtest-up/tsconfig.json b/packages/bitcoin-regtest-up/tsconfig.json new file mode 100644 index 0000000000..025ba2ef7f --- /dev/null +++ b/packages/bitcoin-regtest-up/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/bitcoin-regtest-up/typedoc.json b/packages/bitcoin-regtest-up/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/bitcoin-regtest-up/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index 2c19709511..92977da152 100644 --- a/teams.json +++ b/teams.json @@ -67,6 +67,7 @@ "metamask/eth-block-tracker": "team-wallet-integrations,team-core-platform", "metamask/eth-json-rpc-middleware": "team-core-platform,team-confirmations,team-wallet-integrations", "metamask/eth-json-rpc-provider": "team-wallet-integrations,team-core-platform", + "metamask/bitcoin-regtest-up": "team-mobile-platform,team-extension-platform,team-networks", "metamask/foundryup": "team-mobile-platform,team-extension-platform", "metamask/json-rpc-engine": "team-wallet-integrations,team-core-platform", "metamask/json-rpc-middleware-stream": "team-wallet-integrations,team-core-platform", diff --git a/tsconfig.build.json b/tsconfig.build.json index 1604f654f5..19dce4b6db 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -49,6 +49,9 @@ { "path": "./packages/base-data-service/tsconfig.build.json" }, + { + "path": "./packages/bitcoin-regtest-up/tsconfig.build.json" + }, { "path": "./packages/bridge-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 9ca28b560c..a083779b8a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -50,6 +50,9 @@ { "path": "./packages/base-data-service" }, + { + "path": "./packages/bitcoin-regtest-up" + }, { "path": "./packages/bridge-controller" }, diff --git a/yarn.lock b/yarn.lock index d177fa888e..1fe705dcc5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3015,6 +3015,25 @@ __metadata: languageName: unknown linkType: soft +"@metamask/bitcoin-regtest-up@workspace:packages/bitcoin-regtest-up": + version: 0.0.0-use.local + resolution: "@metamask/bitcoin-regtest-up@workspace:packages/bitcoin-regtest-up" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + bin: + bitcoin-regtest-up: ./dist/bin/bitcoin-regtest-up.mjs + languageName: unknown + linkType: soft + "@metamask/bridge-controller@npm:^72.0.4, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller"