From 228615714109fa55b7d3303f2c014ab2a8dda125 Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Tue, 3 Feb 2026 10:07:48 +0700 Subject: [PATCH 01/15] update: add asdf version manager and update gitignore --- .gitignore | 2 ++ .tool-versions | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 .tool-versions diff --git a/.gitignore b/.gitignore index 028dd2ee8e..6d1b11b166 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ coverage /.direnv/ .claude/ .cursor/ +.serena/ +_bmad* diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000000..ae30f9d495 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +nodejs 24.6.0 +current 25.4.0 From 4b16b2fa4a2049a4ebd76716d002301cd2aece53 Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Tue, 3 Feb 2026 22:22:54 +0700 Subject: [PATCH 02/15] chore: add mpc research doc and example scripts --- .cursorignore | 2 + .gitignore | 6 + CLAUDE.md | 42 + README.bitgo.md | 71 ++ README.md | 73 +- .../mpc/create-wallet-mpcv2-script.md | 365 +++++++++ .../self-custody/mpc/terminology-guide.md | 191 +++++ .../mpc-self-custody-offline.js | 744 ++++++++++++++++++ .../mpc-self-custody-online.js | 381 +++++++++ .../mpc-workspace-schema.js | 40 + 10 files changed, 1849 insertions(+), 66 deletions(-) create mode 100644 .cursorignore create mode 100644 README.bitgo.md create mode 100644 examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md create mode 100644 examples/docs/self-custody/mpc/terminology-guide.md create mode 100644 examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js create mode 100644 examples/js/self-custody-mcp-v2/mpc-self-custody-online.js create mode 100644 examples/js/self-custody-mcp-v2/mpc-workspace-schema.js diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000000..97726ad6f7 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,2 @@ +.env +.env* diff --git a/.gitignore b/.gitignore index 6d1b11b166..72c55b96df 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ coverage .cursor/ .serena/ _bmad* +# Local MPC p-shares (never commit) +local-pshares/ +**/local-pshares/ +# MPCv2 two-script workspace (sensitive state; never commit) +mpc-keygen-workspace/ +**/mpc-keygen-workspace/ diff --git a/CLAUDE.md b/CLAUDE.md index afe2f45915..9ea387f03f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,3 +129,45 @@ This will generate the necessary boilerplate for a new coin implementation. ## Node.js Version Support BitGoJS supports Node.js versions >=20 and <25, with NPM >=3.10.10. + +## Tools guide + +## Code editing and discovery + +- **Serena MCP** — Use for all code editing and code discovery: + - Edit code via Serena’s symbolic or file-based editing tools. + - Find code by names, symbols, and patterns (e.g. `find_symbol`, `get_symbols_overview`, `search_for_pattern`). + - Prefer Serena over raw file reads when navigating or changing the codebase. + +## Definitions and references + +- **Knowledge-graph MCP** — Use whenever you need to understand code: + - Finding definitions or references of symbols, types, or files. + - Understanding how code is used and where it is referenced. + - Rely on the knowledge-graph as the primary source for "where is this defined?" and "who uses this?". + +## After code changes + +- **Knowledge-graph `index_project`** — After any code update: + - Call the knowledge-graph **index_project** (or equivalent) tool so the graph stays in sync with the codebase. + - Do this as part of your post-edit workflow so future lookups remain accurate. + +## Quick codebase understanding + +- **Knowledge-graph repo-map** — When you need a fast, high-level picture: + - Use the knowledge-graph **repo-map** (or equivalent) to grasp structure and relationships quickly. + - Use it at the start of saga work or when switching context to a new area of the codebase. + +## Research and documentation + +- **Perplexity MCP** — Use for online search: + - Searching for resources, patterns, solutions, or documentation on the web. + - Prefer Perplexity when the answer is likely to be in articles, docs, or discussions. + +- **Fetch (e.g. mcp web_fetch)** — Use for content from external URLs: + - Only when you need the actual content of a specific link. + - **Always evaluate security risk first** (e.g. URL origin, protocol, and sensitivity of the task) before calling fetch. + +- **Context7 MCP** — Use for up-to-date library docs: + - Fetch current documentation for any library or framework you need. + - Prefer Context7 when documenting or analyzing a specific library or stack. diff --git a/README.bitgo.md b/README.bitgo.md new file mode 100644 index 0000000000..90334624d5 --- /dev/null +++ b/README.bitgo.md @@ -0,0 +1,71 @@ +# BitGo JavaScript SDK + +The BitGo Platform and SDK makes it easy to build multi-signature crypto-currency applications today with support for Bitcoin, Ethereum and many other coins. +The SDK is fully integrated with the BitGo co-signing service for managing all of your BitGo wallets. + +Included in the SDK are examples for how to use the API to manage your multi-signature wallets. + +Please email us at support@bitgo.com if you have questions or comments about this API. + +## Module Overview + +The BitGo SDK repository is a monorepo composed of separate modules, each of which implement some subset of the features of the SDK. + +| Package Name | Module | Description | | +| ------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| bitgo | `bitgo` | Authentication, wallet management, user authentication, cryptographic primitives, abstract coin interfaces, coin implementations. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/bitgo) | +| @bitgo/account-lib | `account-lib` | Build and sign transactions for account-based coins. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/account-lib) | +| @bitgo/blake2b | `blake2b` | Blake2b (64-bit version) in pure JavaScript. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/blake2b) | +| @bitgo/blake2b-wasm | `blake2b-wasm` | Blake2b implemented in WASM. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/blake2b-wasm) | +| @bitgo/blockapis | `blockapis` | Access public block explorer APIs for a variety of coins. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/blockapis) | +| @bitgo/express | `express` | Local BitGo transaction signing server and proxy. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/express) | +| @bitgo/statics | `statics` | Static configuration values used across the BitGo platform. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/statics) | +| @bitgo/unspents | `unspents` | Defines the chain codes used for different unspent types and methods to calculate bitcoin transaction sizes. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/unspents) | +| @bitgo/utxo-bin | `utxo-bin` | Command-line utility for BitGo UTXO transactions. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/utxo-bin) | +| @bitgo/utxo-lib | `utxo-lib` | Build and sign transactions for utxo-based coins. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/utxo-lib) | + +# Release Notes + +Each module provides release notes in `modules/*/CHANGELOG.md`. + +The release notes for the `bitgo` module are [here](https://github.com/BitGo/BitGoJS/blob/master/modules/bitgo/CHANGELOG.md). + +## Release Cycle + +The BitGoJS SDK use a number of branches to control the development of various packages throughout the deployment lifecycle. Provided below is an overview to how branches relate to one another. + +| Branch | Status | NPM | Description | +| ------------ | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `master` | Unstable | N/A | Ongoing development of SDK packages | +| `rel/latest` | Stable | `latest` | Deployed packages from `master` to `rel/latest`. This includes Express and is recommended to use `rel/latest` when not using Docker images for Express. | + +Other tags may be released to npm (e.g. `hotfix`, `dev`, etc...), but are not considered critical to the common path for consumers usage of SDK packages unless otherwise stated. + +# Examples + +Examples can be found in each of the modules specific to the module use cases. Starter examples can be found [here](https://github.com/BitGo/BitGoJS/tree/master/examples). + +# NodeJS Version Support Policy + +BitGoJS currently provides support for the following Node versions per package.json engines policy: + +``` +"engines": { + "node": ">=20 <25", + "npm": ">=3.10.10" +} +``` + +We specifically limit our support to these versions of Node, not because this package won't work on other versions, but because these versions tend to be the most widely used in practice. It's possible the packages in this repository will work correctly on newer or older versions of Node, but we typically don't run automated tests against non-specified versions of Node (including odd versions), with the possible exception of the latest odd numbered version for advanced awareness of upcoming breaks in version support. + +As each Node LTS version reaches its end-of-life we will exclude that version from the node engines property of our package's package.json file. Removing a Node version is considered a breaking change and will entail the publishing of a new major version of this package. We will not accept any requests to support an end-of-life version of Node, and any pull requests or issues regarding support for an end-of-life version of Node will be closed. We will accept code that allows this package to run on newer, non-LTS, versions of Node. Furthermore, we will attempt to ensure our own changes work on the latest version of Node. To help in that commitment, our continuous integration setup runs the full test suite on the latest release of the following versions of node: + +- `20` +- `22` +- `24` + +JavaScript package managers should allow you to install this package with any version of Node, with, at most, a warning if your version of Node does not fall within the range specified by our node engines property. If you encounter issues installing this package on a supported version of Node, please report the issue to us. + +# Notes for Developers + +See [DEVELOPERS.md](https://github.com/BitGo/BitGoJS/blob/master/DEVELOPERS.md) diff --git a/README.md b/README.md index 90334624d5..d5441019bf 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,12 @@ # BitGo JavaScript SDK -The BitGo Platform and SDK makes it easy to build multi-signature crypto-currency applications today with support for Bitcoin, Ethereum and many other coins. -The SDK is fully integrated with the BitGo co-signing service for managing all of your BitGo wallets. +This is a forked version of BitGo JS SDK, checkout from tag `bitgo@50.23.0`. -Included in the SDK are examples for how to use the API to manage your multi-signature wallets. +The purpose is to document and research BitGo cryptography implementation. -Please email us at support@bitgo.com if you have questions or comments about this API. +## Document index -## Module Overview - -The BitGo SDK repository is a monorepo composed of separate modules, each of which implement some subset of the features of the SDK. - -| Package Name | Module | Description | | -| ------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -| bitgo | `bitgo` | Authentication, wallet management, user authentication, cryptographic primitives, abstract coin interfaces, coin implementations. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/bitgo) | -| @bitgo/account-lib | `account-lib` | Build and sign transactions for account-based coins. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/account-lib) | -| @bitgo/blake2b | `blake2b` | Blake2b (64-bit version) in pure JavaScript. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/blake2b) | -| @bitgo/blake2b-wasm | `blake2b-wasm` | Blake2b implemented in WASM. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/blake2b-wasm) | -| @bitgo/blockapis | `blockapis` | Access public block explorer APIs for a variety of coins. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/blockapis) | -| @bitgo/express | `express` | Local BitGo transaction signing server and proxy. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/express) | -| @bitgo/statics | `statics` | Static configuration values used across the BitGo platform. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/statics) | -| @bitgo/unspents | `unspents` | Defines the chain codes used for different unspent types and methods to calculate bitcoin transaction sizes. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/unspents) | -| @bitgo/utxo-bin | `utxo-bin` | Command-line utility for BitGo UTXO transactions. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/utxo-bin) | -| @bitgo/utxo-lib | `utxo-lib` | Build and sign transactions for utxo-based coins. | [Link](https://github.com/BitGo/BitGoJS/tree/master/modules/utxo-lib) | - -# Release Notes - -Each module provides release notes in `modules/*/CHANGELOG.md`. - -The release notes for the `bitgo` module are [here](https://github.com/BitGo/BitGoJS/blob/master/modules/bitgo/CHANGELOG.md). - -## Release Cycle - -The BitGoJS SDK use a number of branches to control the development of various packages throughout the deployment lifecycle. Provided below is an overview to how branches relate to one another. - -| Branch | Status | NPM | Description | -| ------------ | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `master` | Unstable | N/A | Ongoing development of SDK packages | -| `rel/latest` | Stable | `latest` | Deployed packages from `master` to `rel/latest`. This includes Express and is recommended to use `rel/latest` when not using Docker images for Express. | - -Other tags may be released to npm (e.g. `hotfix`, `dev`, etc...), but are not considered critical to the common path for consumers usage of SDK packages unless otherwise stated. - -# Examples - -Examples can be found in each of the modules specific to the module use cases. Starter examples can be found [here](https://github.com/BitGo/BitGoJS/tree/master/examples). - -# NodeJS Version Support Policy - -BitGoJS currently provides support for the following Node versions per package.json engines policy: - -``` -"engines": { - "node": ">=20 <25", - "npm": ">=3.10.10" -} -``` - -We specifically limit our support to these versions of Node, not because this package won't work on other versions, but because these versions tend to be the most widely used in practice. It's possible the packages in this repository will work correctly on newer or older versions of Node, but we typically don't run automated tests against non-specified versions of Node (including odd versions), with the possible exception of the latest odd numbered version for advanced awareness of upcoming breaks in version support. - -As each Node LTS version reaches its end-of-life we will exclude that version from the node engines property of our package's package.json file. Removing a Node version is considered a breaking change and will entail the publishing of a new major version of this package. We will not accept any requests to support an end-of-life version of Node, and any pull requests or issues regarding support for an end-of-life version of Node will be closed. We will accept code that allows this package to run on newer, non-LTS, versions of Node. Furthermore, we will attempt to ensure our own changes work on the latest version of Node. To help in that commitment, our continuous integration setup runs the full test suite on the latest release of the following versions of node: - -- `20` -- `22` -- `24` - -JavaScript package managers should allow you to install this package with any version of Node, with, at most, a warning if your version of Node does not fall within the range specified by our node engines property. If you encounter issues installing this package on a supported version of Node, please report the issue to us. - -# Notes for Developers - -See [DEVELOPERS.md](https://github.com/BitGo/BitGoJS/blob/master/DEVELOPERS.md) +1. The origin document is [here](README.bitgo.md) +2. MPC related research documents: + - [MPC terminologies](examples/docs/self-custody/mpc/terminology-guide.md) + - [MPC v2 examples](examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md) diff --git a/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md b/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md new file mode 100644 index 0000000000..bbb1de93c8 --- /dev/null +++ b/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md @@ -0,0 +1,365 @@ +# MPCv2 Self-Custody Wallet: Two-Script Flow (Offline / Online) + +This guide describes creating an **MPCv2 TSS self-custody hot wallet** using **two separate scripts**: an **offline script** (no network) that generates private material and encrypted payloads, and an **online script** that sends encrypted data to BitGo and creates the wallet. Raw private keys never leave the offline environment. + +## Overview + +- **MPCv2** uses the DKLS protocol (4-round DKG). User, Backup, and BitGo participate; User and Backup key shares are generated on the offline machine. +- **Offline script** (`mpc-self-custody-offline.js`): Runs on an air-gapped or offline machine. Generates and manages your **p-shares** (private key shares) - they **never leave** this machine. +- **Online script** (`mpc-self-custody-online.js`): Runs on a network-connected machine. Communicates with BitGo APIs, sending only **encrypted n-shares** and **passphrase-encrypted signing material**. +- **Workspace files**: JSON files transferred between offline and online machines containing only encrypted data. + +Communication between the two scripts is **file-based** in a shared workspace directory (e.g. `mpc-keygen-workspace/`). The offline machine and online machine can be the same (for testing) or different; in production, run the offline script on an air-gapped machine and copy only the payload/response files. + +## Workspace Files + +| File | Written by | Read by | Description | +|------|------------|---------|-------------| +| `bitgo-gpg-public-key.json` | Online (step 0) | Offline | BitGo public GPG key (armored). | +| `round1-payload.json` | Offline (step 1) | Online (step 1) | Payload for POST /mpc/generatekey R1. | +| `round1-response.json` | Online (step 1) | Offline (step 2) | BitGo R1 response (sessionId, bitgoMsg1, bitgoToUserMsg2, bitgoToBackupMsg2). | +| `round1-state.json` | Offline (step 1) | Offline (step 2) | **Sensitive.** DKG session state and GPG keys; keep on offline machine only. | +| `round2-payload.json` | Offline (step 2) | Online (step 2) | Payload for POST /mpc/generatekey R2. | +| `round2-response.json` | Online (step 2) | Offline (step 3) | BitGo R2 response. | +| `round2-state.json` | Offline (step 2) | Offline (step 3) | **Sensitive.** Session state; offline only. | +| `round3-payload.json` | Offline (step 3) | Online (step 3) | Payload for POST /mpc/generatekey R3. | +| `round3-response.json` | Online (step 3) | Offline (step 4) | BitGo R3 response (bitgoMsg4, commonKeychain). | +| `round3-state.json` | Offline (step 3) | Offline (step 4) | **Sensitive.** Session state; offline only. | +| `keychain-payloads.json` | Offline (step 4) | Online (step 4) | User/backup/bitgo keychain params (encryptedPrv only; no raw private). | +| `wallet-result.json` | Online (step 4) | User | Wallet ID, receive address, keychain IDs. | + +Set `MPC_WORKSPACE_DIR` to use a custom workspace path; default is `examples/js/mpc-keygen-workspace` when run from that directory. + +## Steps (Order of Execution) + +1. **Online step 0** (machine with network): Fetch BitGo public GPG key and write `bitgo-gpg-public-key.json`. Copy the workspace (or at least this file) to the offline machine. +2. **Offline step 1**: Read BitGo public key and passphrase; run DKG round 1; write `round1-payload.json` and `round1-state.json`. Copy `round1-payload.json` to the online machine. +3. **Online step 1**: Read `round1-payload.json`; POST to BitGo; write `round1-response.json`. Copy `round1-response.json` to the offline machine. +4. **Offline step 2**: Read round1 response and state; run DKG round 2; write `round2-payload.json` and `round2-state.json`. Copy `round2-payload.json` to the online machine. +5. **Online step 2**: Read `round2-payload.json`; POST; write `round2-response.json`. Copy `round2-response.json` to the offline machine. +6. **Offline step 3**: Read round2 response and state; run DKG round 3; write `round3-payload.json` and `round3-state.json`. Copy `round3-payload.json` to the online machine. +7. **Online step 3**: Read `round3-payload.json`; POST; write `round3-response.json`. Copy `round3-response.json` to the offline machine. +8. **Offline step 4**: Read round3 response and state; complete DKG round 4; get key shares; encrypt with passphrase; write `keychain-payloads.json`. Copy `keychain-payloads.json` to the online machine. +9. **Online step 4**: Read `keychain-payloads.json`; register user, backup, and BitGo keychains; create wallet; write `wallet-result.json`. + +## Environment Variables + +- **Offline**: `WALLET_PASSPHRASE` (required for step 4), `MPC_WORKSPACE_DIR` (optional). +- **Online**: `BITGO_ACCESS_TOKEN` (required), `COIN` (e.g. `teth`), `WALLET_LABEL`, `ENTERPRISE` (optional), `BITGO_ENV` (e.g. `test`), `MPC_WORKSPACE_DIR` (optional). + +## Commands (from repo root) + +```bash +# Online machine (with network) +# For bash/zsh +export BITGO_ACCESS_TOKEN=your_token +export COIN=teth +export WALLET_LABEL="My MPCv2 Wallet" +export ENTERPRISE=optional_enterprise_id +export BITGO_CUSTOM_ROOT_URI="bitgo-express-uri" +export WALLET_PASSPHRASE=your_passphrase +# OR use .env +# For fish +set -lx BITGO_ACCESS_TOKEN your_token +set -lx COIN teth +set -lx WALLET_LABEL "My MPCv2 Wallet" +set -lx WALLET_PASSPHRASE=your_passphrase +set -lx ENTERPRISE optional_enterprise_id +set -lx BITGO_CUSTOM_ROOT_URI "bitgo-express-uri" +# OR use .env + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-online.js --step 0 +# Copy workspace to offline machine, then: + +# Offline machine (no network) +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js --step 1 +# Copy round1-payload.json to online machine, then: + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-online.js --step 1 +# Copy round1-response.json to offline machine, then: + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js --step 2 +# Copy round2-payload.json to online machine +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-online.js --step 2 +# Copy round2-response.json to offline machine + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js --step 3 +# Copy round3-payload.json to online machine +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-online.js --step 3 +# Copy round3-response.json to offline machine + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js --step 4 +# Copy keychain-payloads.json to online machine +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-online.js --step 4 +``` + +## Step-by-Step Flow + +### Step 0: Fetch BitGo Configuration (Online) + +**Script:** `mpc-self-custody-online.js --step 0` + +**What it does:** +- Fetches TSS settings to verify MPCv2 support (maps to section 2.1) +- Fetches BitGo's GPG public key for encrypting n-shares (maps to section 2.2) + +**API Endpoints:** +- `GET {microservicesUrl}/api/v2/tss/settings` +- `GET {baseApiUrl}/api/v1/client/constants` + +**Output:** `bitgo-gpg-public-key.json` + +**Next:** Transfer workspace to offline machine + +--- + +### Step 1: Generate Key Shares (Offline) + +**Script:** `mpc-self-custody-offline.js --step 1` + +**What it does:** +- Generates user key share (index 1) - maps to section 2.3 +- Generates backup key share (index 2) - maps to section 2.4 +- Generates GPG key pairs for user and backup - maps to section 2.5 +- Creates DKG sessions with n=3, m=2 +- Generates round 1 broadcast messages (commitments) +- Encrypts messages with BitGo's GPG public key + +**Operations:** +```javascript +const userSession = new DklsDkg.Dkg(n, m, MPCv2PartiesEnum.USER); +const backupSession = new DklsDkg.Dkg(n, m, MPCv2PartiesEnum.BACKUP); +const userGpgKey = await generateGPGKeyPair('secp256k1'); +const backupGpgKey = await generateGPGKeyPair('secp256k1'); +const userRound1BroadcastMsg = await userSession.initDkg(); +const backupRound1BroadcastMsg = await backupSession.initDkg(); +``` + +**Output:** `round1-payload.json`, `round1-state.json` + +**Next:** Transfer `round1-payload.json` to online machine + +--- + +### Step 1: Send Round 1 Messages (Online) + +**Script:** `mpc-self-custody-online.js --step 1` + +**What it does:** +- Sends round 1 encrypted messages to BitGo (part of section 2.6) +- Receives BitGo's round 1 broadcast message +- Receives BitGo's round 2 P2P messages +- Receives `sessionId` for tracking this DKG session + +**API Endpoint:** +- `POST {baseApiUrl}/api/v2/mpc/generatekey` + - Payload: `{ enterprise, type: 'MPCv2', round: 'MPCv2-R1', payload: round1Payload }` + +**Output:** `round1-response.json` + +**Next:** Transfer `round1-response.json` to offline machine + +--- + +### Step 2: Process Round 1 Response (Offline) + +**Script:** `mpc-self-custody-offline.js --step 2` + +**What it does:** +- Restores DKG sessions from `round1-state.json` +- Decrypts and verifies BitGo's round 1 broadcast message +- Processes round 1 messages to generate round 2 P2P messages +- Encrypts round 2 messages for BitGo + +**Operations:** +```javascript +const userSession = await DklsDkg.Dkg.restoreSession(n, m, MPCv2PartiesEnum.USER, sessionData); +// Decrypt BitGo's message +const bitgoRound1BroadcastMsg = await DklsComms.decryptAndVerifyIncomingMessages(...); +// Generate round 2 P2P messages +const userRound2P2PMessages = userSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [bitgoRound1BroadcastMsg, backupRound1BroadcastMsg] +}); +``` + +**Output:** `round2-payload.json`, `round2-state.json` + +**Next:** Transfer `round2-payload.json` to online machine + +--- + +### Step 2: Send Round 2 Messages (Online) + +**Script:** `mpc-self-custody-online.js --step 2` + +**What it does:** +- Sends round 2 encrypted P2P messages to BitGo (part of section 2.6) +- Receives BitGo's round 2 P2P messages +- Receives BitGo's round 3 P2P messages (BitGo is one step ahead) +- Receives BitGo's commitment for round 2 + +**API Endpoint:** +- `POST {baseApiUrl}/api/v2/mpc/generatekey` + - Payload: `{ enterprise, type: 'MPCv2', round: 'MPCv2-R2', payload: round2Payload }` + +**Output:** `round2-response.json` + +**Next:** Transfer `round2-response.json` to offline machine + +--- + +### Step 3: Process Round 2 & 3 Responses (Offline) + +**Script:** `mpc-self-custody-offline.js --step 3` + +**What it does:** +- Restores DKG sessions from `round2-state.json` +- Decrypts BitGo's round 2 P2P messages (bitgoToUser, bitgoToBackup) +- Processes round 2 messages to generate round 3 P2P messages +- Decrypts BitGo's round 3 P2P messages +- Processes round 3 messages to generate round 4 broadcast messages +- Encrypts all messages for BitGo + +**Operations:** +```javascript +// Process round 2 P2P messages +const userRound3Messages = userSession.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [bitgoToUserRound2Msg, backupToUserMsg2] +}); +// Process round 3 P2P messages to generate round 4 broadcasts +const userRound4Messages = userSession.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [bitgoToUserRound3Msg, backupToUserMsg3] +}); +``` + +**Output:** `round3-payload.json`, `round3-state.json` + +**Next:** Transfer `round3-payload.json` to online machine + +--- + +### Step 3: Send Round 3 & 4 Messages (Online) + +**Script:** `mpc-self-custody-online.js --step 3` + +**What it does:** +- Sends round 3 & 4 encrypted messages to BitGo (part of section 2.6) +- Receives BitGo's round 4 broadcast message (final commitment) +- Receives `commonKeychain` (public keychain identifier) + +**API Endpoint:** +- `POST {baseApiUrl}/api/v2/mpc/generatekey` + - Payload: `{ enterprise, type: 'MPCv2', round: 'MPCv2-R3', payload: round3Payload }` + +**Output:** `round3-response.json` + +**Next:** Transfer `round3-response.json` to offline machine + +**Note:** At this point, all three participants have completed DKG and possess their key shares + +--- + +### Step 4: Finalize Key Shares (Offline) + +**Script:** `WALLET_PASSPHRASE="your-passphrase" mpc-self-custody-offline.js --step 4` + +**What it does:** +- Restores DKG sessions from `round3-state.json` +- Decrypts and verifies BitGo's round 4 broadcast message +- Processes round 4 broadcasts to finalize DKG +- Extracts key shares (p-share + received n-shares) +- Verifies `commonKeychain` matches across all participants +- Performs **key combine**: combines p-share with n-shares to produce signing material +- Encrypts signing material with `WALLET_PASSPHRASE` +- Prepares keychain params (maps to sections 2.7 & 2.8) + +**Operations:** +```javascript +// Finalize DKG +userSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [bitgoRound4BroadcastMsg, backupRound4BroadcastMsg] +}); +// Extract key shares +const userPrivateMaterial = userSession.getKeyShare(); +const backupPrivateMaterial = backupSession.getKeyShare(); +// Verify common keychain +const commonKeychain = DklsTypes.getCommonKeychain(userPrivateMaterial); +// Encrypt with passphrase +const encryptedPrvUser = bitgo.encrypt({ + input: userPrivateMaterial.toString('base64'), + password: passphrase +}); +``` + +**Output:** `keychain-payloads.json` (passphrase-encrypted signing material) + +**Next:** Transfer `keychain-payloads.json` to online machine + +--- + +### Step 4: Register Keychains and Create Wallet (Online) + +**Script:** `mpc-self-custody-online.js --step 4` + +**What it does:** +- Reads `keychain-payloads.json` from offline machine +- Registers user keychain with encrypted signing material (maps to section 2.7) +- Registers backup keychain with encrypted signing material (maps to section 2.8) +- Registers BitGo keychain (BitGo has its own p-share) +- Creates wallet linking all three keychains (maps to section 2.9) + +**API Endpoints:** +- `POST {baseApiUrl}/api/v2/{coin}/key` (called 3 times for user, backup, BitGo) +- `POST {baseApiUrl}/api/v2/{coin}/wallet` + +**Operations:** +```javascript +const userKeychain = await keychains.add({ + source: 'user', + keyType: 'tss', + commonKeychain, + encryptedPrv: encryptedPrvUser, + isMPCv2: true +}); +const backupKeychain = await keychains.add({ + source: 'backup', + keyType: 'tss', + commonKeychain, + encryptedPrv: encryptedPrvBackup, + isMPCv2: true +}); +const bitgoKeychain = await keychains.add({ + source: 'bitgo', + keyType: 'tss', + commonKeychain, + isMPCv2: true +}); +const newWallet = await bitgo.post(baseCoin.url('/wallet/add')).send({ + label, + m: 2, + n: 3, + keys: [userKeychain.id, backupKeychain.id, bitgoKeychain.id], + type: 'hot', + multisigType: 'tss' +}).result(); +``` + +**Output:** `wallet-result.json` (wallet ID, receive address, keychain IDs) + +**Result:** Fully operational self-custody MPC wallet + +## Security Notes + +- **Offline script** must never call `bitgo.get()` or `bitgo.post()`; it only reads BitGo public key from a file and uses `bitgo.encrypt()` locally for passphrase-based encryption. + - Your **p-shares** (private key shares) stay on the offline machine +- BitGo never receives your **p-shares** or full private keys + - Only **encrypted n-shares** and **passphrase-encrypted signing material** are transmitted +- **State files** (`round1-state.json`, `round2-state.json`, `round3-state.json`) contain sensitive DKG state and GPG private keys; keep them only on the offline machine and do not copy them to the online machine. + - Workspace files contain only encrypted data, safe to transfer via USB or other means +- **keychain-payloads.json** contains only passphrase-encrypted private material (`encryptedPrv`); the online script never sees raw private keys or p-shares. +- 2-of-3 threshold: transactions require 2 of 3 key shares to sign + - Back up the offline state and keychain payloads securely; you need them (and the passphrase) to sign transactions later. diff --git a/examples/docs/self-custody/mpc/terminology-guide.md b/examples/docs/self-custody/mpc/terminology-guide.md new file mode 100644 index 0000000000..45d742545f --- /dev/null +++ b/examples/docs/self-custody/mpc/terminology-guide.md @@ -0,0 +1,191 @@ +# ETH MPC & TSS: Terminology Guide (Beginner-Friendly) + +This document explains the cryptography and product terms used in the [ETH MPC self-custody wallet guide](eth-mpc-self-custody-wallet-guide.md). Concepts are ordered **from foundation to advanced**—understand each level before moving to the next. + +--- + +## Level 1: Basic Cryptography (Understand These First) + +### 1.1 Private key + +- **What it is:** A secret value (like a very long password) that only you should know. +- **Why it matters:** In crypto, the private key is what lets you prove “you” own an address and authorize actions (e.g. sending funds). Anyone with the private key has full control. +- **In our context:** For ETH, the “key” that controls your wallet is derived from a private key. In MPC we never build or store one single private key in one place; instead we split it into **shares**. + +### 1.2 Public key + +- **What it is:** A value mathematically derived from the private key that can be shared publicly. +- **Why it matters:** From a public key we can derive things like your **Ethereum address**. People can send you funds to that address without ever seeing your private key. +- **In our context:** The wallet’s public key (and thus address) is computed from the combined key; no one party holds the full private key, but together they can still produce a single public key and address. + +### 1.3 Signing (digital signature) + +- **What it is:** Using your private key to create a “signature” on a message (e.g. a transaction). The signature proves that someone who knows the private key approved that exact message. +- **Why it matters:** Sending funds on Ethereum requires signing the transaction. The network checks the signature against the public key/address to allow the transfer. +- **In our context:** In MPC/TSS, no single device has the full private key; instead, several parties each have a **share** and cooperate to produce a valid signature without any one of them ever seeing the full key. + +--- + +## Level 2: Multi-Signature and Threshold (Build on Level 1) + +### 2.1 Multi-signature (multisig) + +- **What it is:** A setup where **more than one** key must approve an action (e.g. a transaction). For example “2 out of 3 keys must sign.” +- **Why it matters:** Reduces risk: one stolen key is not enough; you need several parties to agree. Common in companies or shared wallets. +- **In our context:** BitGo uses **2-of-3**: you (user), a backup, and BitGo each hold one key; any two of the three can sign. + +### 2.2 Threshold (e.g. 2-of-3) + +- **What it is:** The rule that defines how many parties must participate to do something. “2-of-3” means: 3 parties have a share, and **at least 2** must cooperate to sign. +- **Why it matters:** You get security (no single point of failure) and flexibility (one party can be offline and the other two can still sign). +- **In our context:** Our ETH MPC wallets are **2-of-3**: user + backup + BitGo; any two can sign (e.g. you + BitGo, or backup + BitGo). + +### 2.3 Key / key pair + +- **What it is:** In this doc, “key” usually means the **private key** (and sometimes the associated public key). A “key pair” is private key + public key together. +- **Why it matters:** When we say “you control your key” we mean you control your **private** key (or your share of it). The public key (and address) can be shared. + +--- + +## Level 3: MPC and TSS (Core Ideas) + +### 3.1 MPC (Multi-Party Computation) + +- **What it is:** A branch of cryptography where **several parties** jointly compute a result (e.g. a signature) **without any one party ever seeing the full secret** (the full private key). +- **Why it matters:** In a normal wallet, one machine has the full private key—if that machine is compromised, everything is lost. In MPC, the key is split into **shares**; each party has only a share. Signing is done by combining **partial results** from each party, so the full key is never assembled in one place. +- **In our context:** User, backup, and BitGo each hold a **share** of one logical key. They run an MPC protocol to sign transactions; no server (including BitGo) ever has the full private key. + +### 3.2 TSS (Threshold Signature Scheme) + +- **What it is:** A **threshold** version of a **signature scheme**. It’s the specific MPC technique used so that: + - The key is split into shares (e.g. 3 shares for 2-of-3). + - Only **t** of **n** parties (e.g. 2 of 3) need to participate to produce a valid signature. + - The signature looks like a normal single-key signature to the outside world (Ethereum doesn’t “see” that it was made by multiple parties). +- **Why it matters:** TSS gives you multisig-like security (threshold) without storing a full private key anywhere. “TSS wallet” = wallet whose key is split and whose signatures are produced via a threshold protocol. +- **In our context:** “ETH MPC self-custody” uses **ECDSA TSS**: the underlying signature algorithm is ECDSA (used by Ethereum), and the way the key is split and signatures are produced is a threshold (2-of-3) scheme. + +### 3.3 ECDSA (Elliptic Curve Digital Signature Algorithm) + +- **What it is:** The standard signature algorithm used by Ethereum (and Bitcoin). It uses elliptic-curve math: the private key is a number, the public key is a point on the curve, and signing uses the private key to produce a signature that others can verify with the public key. +- **Why it matters:** When we say “ECDSA TSS,” we mean: we’re doing TSS (splitting the key and signing with a threshold) **for** an ECDSA key, so the resulting signatures are valid Ethereum signatures. +- **In our context:** ETH uses ECDSA; the SDK uses ECDSA TSS so that the combined key is an ECDSA key and the wallet works with standard ETH addresses and transactions. + +--- + +## Level 4: Shares and Key Material (TSS Details) + +### 4.1 Share (or key share) + +- **What it is:** One **piece** of the full key. By itself, a share is not the full private key and cannot sign alone. When the right number of shares (e.g. 2 out of 3) are used together in the protocol, they can produce a signature without ever reconstructing the full key. +- **Why it matters:** “You have full control of your private share” means: **your** part of the key (user share, and optionally backup share) is generated and stored only by you. BitGo has a different share; no one has the whole key. +- **In our context:** There are 3 shares—index 1 (user), index 2 (backup), index 3 (BitGo). You generate and hold 1 and 2 locally; BitGo holds 3. + +### 4.2 p-share (private share) + +- **What it is:** In ECDSA TSS, the **private** part of a participant’s key share—the piece that must stay secret and is used in the signing protocol. Each participant has one p-share (their own). +- **Why it matters:** This is the “private share” you must protect. In the guide, “your private share” refers to your p-share (and your backup’s p-share if you hold it). **Never** send your p-share to anyone. +- **In our context:** User has p-share for index 1, backup for index 2. They are created locally (e.g. `MPC.keyShare(1, ...)` and `MPC.keyShare(2, ...)`) and never leave your control. + +### 4.3 n-share (public / network share) + +- **What it is:** In ECDSA TSS, an **n-share** is the information that one participant sends to **another** participant so that the receiver can form their view of the joint key. Each participant has n-shares “to” the other participants (e.g. user has n-shares to backup and to BitGo). +- **Why it matters:** To set up the 2-of-3 key, participants must exchange some information; that exchange is done via n-shares (often encrypted). You do **not** send your p-share; you send encrypted n-shares so BitGo can build its keychain without ever seeing your secret. +- **In our context:** You encrypt “user→BitGo” and “backup→BitGo” n-shares with BitGo’s public key and send them; BitGo then has what it needs to be the third party without seeing your p-shares. + +### 4.4 Key combine (combining shares) + +- **What it is:** The step where each participant takes their **p-share** plus the **n-shares** they received from others and runs a mathematical “combine” procedure. The result is not the full private key, but a **signing material** (e.g. x-share + y-shares) that this participant will use later to produce their part of a signature. +- **Why it matters:** After key generation, each party has combined once to get their signing material. When signing, they use that material to create **signature shares**, which are then combined into the final signature—still without ever reconstructing the full private key. +- **In our context:** “Create user keychain” and “create backup keychain” include combining: you combine your p-share with n-shares from the others to get the encrypted signing material you store as your keychain. + +### 4.5 Common keychain (common key) + +- **What it is:** A **public** value that all parties can compute from the TSS key generation. It uniquely identifies the joint key (e.g. the public key + chaincode in a certain encoding). Everyone agrees on the same common keychain; it’s not secret. +- **Why it matters:** It ties the three keychains (user, backup, BitGo) to the same logical key. When you combine, you check that your result matches this common keychain to ensure everyone is talking about the same key. +- **In our context:** BitGo returns the common keychain when its keychain is created. You use it when creating and verifying user and backup keychains. + +--- + +## Level 5: Keychain and Product Terms (BitGo / SDK) + +### 5.1 Keychain (BitGo keychain) + +- **What it is:** In BitGo’s product and SDK, a **keychain** is the **record** that represents one of the three keys in the 2-of-3 wallet. It holds things like: key id, public key or common keychain, and optionally **encrypted** private material (encrypted signing material for TSS). +- **Why it matters:** “Create user keychain” means: create the **user’s** key record (with combined TSS material, encrypted), and register it with BitGo. Same for “backup keychain” and “BitGo keychain.” The word “keychain” here is **not** the same as “key share”—it’s the container/record for one of the three parties. +- **In our context:** You create three keychains: user, backup, BitGo. User and backup keychains are built from your local shares and then registered; BitGo keychain is created when you send encrypted n-shares to BitGo. + +### 5.2 User keychain / backup keychain / BitGo keychain + +- **What they are:** The three keychains in a 2-of-3 wallet: one for the user (you), one for the backup (you or another device), one for BitGo (the co-signing service). +- **Why it matters:** Each keychain corresponds to one “share holder.” When we say “you have full control of your private share,” we mean the **user** (and optionally **backup**) keychain material is generated and stored only by you; the **BitGo** keychain is created and held by BitGo from the encrypted n-shares you send. + +### 5.3 Encrypted signing material / encrypted prv + +- **What it is:** The result of **key combine** (your view of the joint key for signing), encrypted with a **passphrase** so it can be stored or sent to BitGo’s API without exposing the raw secret. +- **Why it matters:** You store your keychain’s secret part as “encrypted signing material” (or “encrypted prv” in the code). To sign, you decrypt it locally with the passphrase; the server never sees the decrypted value. +- **In our context:** User and backup keychains store encrypted signing material; you decrypt it only on your machine when generating signature shares. + +### 5.4 Signature share (vs key share) + +- **What it is:** During **signing**, each participant uses their (combined) signing material to compute a **signature share**—a partial contribution to the final signature. These shares are then combined (e.g. by BitGo) into one standard ECDSA signature. A **key share** is used in **key generation**; a **signature share** is used only in **signing**. +- **Why it matters:** You never send your key share (p-share) to BitGo. You only send **signature shares** for each transaction. From signature shares, the full signature can be computed without anyone ever having the full private key. +- **In our context:** The local signer (e.g. Express) loads your key, produces signature shares (e.g. K, MuDelta, S or MPCv2 rounds), and sends those to BitGo; BitGo combines with its share to broadcast the signed transaction. + +### 5.5 Wallet (in this context) + +- **What it is:** The BitGo object that represents one 2-of-3 TSS wallet: it links the three keychains (user, backup, BitGo) and holds metadata (label, wallet id, etc.). The “wallet” is the container; the keys live in the keychains. +- **Why it matters:** “Create the wallet” means create this container on BitGo’s side so you can later create addresses, build transactions, and sign (using your keychains). + +--- + +## Level 6: Supporting Terms (Encryption and Keys) + +### 6.1 GPG (GNU Privacy Guard) + +- **What it is:** A standard for **encrypting** and **signing** data using public-key cryptography. In our flow, we use GPG keys to **encrypt** n-shares so that only the intended recipient (e.g. BitGo) can decrypt them. +- **Why it matters:** When you send “user→BitGo” and “backup→BitGo” n-shares, you encrypt them with **BitGo’s public GPG key**. Only BitGo (with its private GPG key) can decrypt. So the network only ever sees encrypted blobs, not raw key material. +- **In our context:** You fetch BitGo’s public GPG key (e.g. from constants), generate your own GPG key pairs for user and backup, and use them to encrypt n-shares and to decrypt the n-shares you receive when combining. + +### 6.2 Passphrase (wallet passphrase) + +- **What it is:** A password you choose to **encrypt** your local key material (e.g. the combined signing material for the user or backup keychain). The same passphrase is used to decrypt when signing. +- **Why it matters:** It adds a second layer: even if someone gets the encrypted blob, they need the passphrase to use it. You must never send the passphrase to BitGo; it’s only used locally. +- **In our context:** You pass a passphrase when creating keychains; it encrypts the signing material. The local signer needs this passphrase (e.g. from env) to decrypt and produce signature shares. + +--- + +## Quick Reference: Dependency Order + +Read in this order if you want a clear build-up: + +1. **Level 1:** Private key → Public key → Signing +2. **Level 2:** Multi-signature → Threshold → Key / key pair +3. **Level 3:** MPC → TSS → ECDSA +4. **Level 4:** Share → p-share → n-share → Key combine → Common keychain +5. **Level 5:** Keychain (BitGo) → User/backup/BitGo keychain → Encrypted signing material → Signature share → Wallet +6. **Level 6:** GPG → Passphrase + +--- + +## One-Sentence Glossary + +| Term | One sentence | +|------|----------------| +| **Private key** | The secret that proves ownership and authorizes signing; must never be shared. | +| **Public key** | The public value derived from the private key; used to derive addresses and verify signatures. | +| **Signing** | Creating a cryptographic signature on a message (e.g. a transaction) using the private key. | +| **Multisig** | Requiring more than one key to approve an action (e.g. 2 of 3). | +| **Threshold** | The rule “at least t of n parties must participate” (e.g. 2-of-3). | +| **MPC** | Multiple parties compute a result (e.g. a signature) without any one seeing the full secret. | +| **TSS** | A threshold signature scheme: key is split into shares; t-of-n parties produce a normal-looking signature. | +| **ECDSA** | The signature algorithm used by Ethereum (and Bitcoin). | +| **Share / key share** | One piece of the full key; no one has the whole key. | +| **p-share** | The private part of a participant’s key share; must stay secret. | +| **n-share** | The information one participant sends to another so they can form the joint key; often encrypted. | +| **Key combine** | Combining one’s p-share with received n-shares to get signing material (not the full key). | +| **Common keychain** | The public value that identifies the joint key; same for all three parties. | +| **Keychain (BitGo)** | The record for one of the three keys (user, backup, BitGo) in the wallet. | +| **Encrypted signing material** | Your combined signing material encrypted with a passphrase for storage. | +| **Signature share** | A partial signature produced during signing; combined to form the final signature. | +| **GPG** | Standard used here to encrypt n-shares so only the recipient can read them. | +| **Passphrase** | Password used to encrypt/decrypt your key material locally. | diff --git a/examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js b/examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js new file mode 100644 index 0000000000..193b22d532 --- /dev/null +++ b/examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js @@ -0,0 +1,744 @@ +/** + * MPCv2 Self-Custody Wallet: OFFLINE Script (No Network) + * + * This script implements the LOCAL/OFFLINE portions of creating an MPC self-custody wallet using + * the TSS (Threshold Signature Scheme) flow. It generates and manages your p-shares (private shares) + * locally - they NEVER leave your machine. Only encrypted n-shares (to other participants) are sent + * to BitGo via the online script. + * + * What this script does: + * - Generates user and backup key shares (p-shares + n-shares) using DKG (Distributed Key Generation) + * - Generates GPG key pairs for encrypting n-shares between participants + * - Performs multi-round DKG protocol (MPCv2: 4 rounds) to establish key shares + * - Runs key combine (p-share + n-shares from others) to produce signing material + * - Encrypts signing material with your passphrase before sending to BitGo for registration + * + * Security model: + * - Your p-shares (private shares) stay on this offline machine + * - Only encrypted n-shares and passphrase-encrypted signing material are sent via online script + * - BitGo never receives your p-shares or full private keys + * - 2-of-3 threshold: transactions require 2 of 3 key shares to sign + * + * =========================================================================================== + * STEP-BY-STEP FLOW (corresponds to create-wallet.md sections 2.3-2.8): + * =========================================================================================== + * + * STEP 1 (Offline, local only): + * What: Generate user & backup key shares (index 1 & 2); generate GPG key pairs + * Maps to doc: 2.3 (Generate user key share), 2.4 (Generate backup key share), + * 2.5 (Generate GPG key pairs) + * Operations: + * - Create DKG sessions for user (index 1) and backup (index 2) with n=3, m=2 + * - Call initDkg() to generate round 1 broadcast messages (containing commitments) + * - Generate secp256k1 GPG key pairs for user and backup (for encrypting n-shares) + * - Encrypt round 1 messages with BitGo's GPG public key + * Output: round1-payload.json (encrypted messages for BitGo), round1-state.json (session state) + * Next: Run online script --step 1 to send round1-payload.json to BitGo + * + * STEP 2 (Offline, local only): + * What: Process BitGo's round 1 response and generate round 2 P2P messages + * Maps to doc: Part of 2.6 (Create BitGo keychain - DKG continues) + * Operations: + * - Restore user and backup DKG sessions from round1-state.json + * - Decrypt and verify BitGo's round 1 broadcast message + * - Call handleIncomingMessages() with all round 1 broadcasts to generate round 2 P2P messages + * - Round 2 messages are P2P (point-to-point) between each pair of participants + * - Encrypt round 2 messages with BitGo's GPG public key + * Input: round1-response.json (from online script, containing BitGo's round 1 message) + * Output: round2-payload.json (encrypted P2P messages to BitGo), round2-state.json + * Next: Run online script --step 2 to send round2-payload.json to BitGo + * + * STEP 3 (Offline, local only): + * What: Process BitGo's round 2 & 3 responses and generate round 3 & 4 messages + * Maps to doc: Part of 2.6 (Create BitGo keychain - DKG continues) + * Operations: + * - Restore sessions from round2-state.json + * - Decrypt BitGo's round 2 P2P messages (bitgoToUser, bitgoToBackup) + * - Process round 2 P2P messages to generate round 3 P2P messages + * - Decrypt BitGo's round 3 P2P messages (BitGo is one step ahead in MPCv2) + * - Process round 3 P2P messages to generate round 4 broadcast messages (final commitments) + * - Encrypt and authenticate all messages for BitGo + * Input: round2-response.json (from online script, containing BitGo's round 2 & 3 messages) + * Output: round3-payload.json (round 3 P2P + round 4 broadcasts), round3-state.json + * Next: Run online script --step 3 to send round3-payload.json to BitGo + * + * STEP 4 (Offline, local only - requires WALLET_PASSPHRASE): + * What: Finalize DKG, extract key shares, perform key combine, encrypt signing material + * Maps to doc: 2.6 (finalize BitGo keychain), 2.7 (Create user keychain), + * 2.8 (Create backup keychain - key combine + encrypt) + * Operations: + * - Restore sessions from round3-state.json + * - Decrypt and verify BitGo's round 4 broadcast message + * - Process all round 4 broadcasts to finalize DKG + * - Extract key shares (getKeyShare() - this is your p-share + received n-shares) + * - Verify common keychain matches across all participants + * - Perform key combine: combine p-share with n-shares to produce signing material + * - Encrypt signing material with your WALLET_PASSPHRASE (AES encryption) + * - Prepare keychain params with encrypted signing material (NOT raw p-shares) + * Input: round3-response.json (from online script, containing BitGo's round 4 message + commonKeychain) + * Output: keychain-payloads.json (passphrase-encrypted signing material for user/backup keychains) + * Next: Run online script --step 4 to register keychains and create wallet + * + * =========================================================================================== + * + * Usage: + * node mpc-self-custody-offline.js --step 1 + * (Then run: node mpc-self-custody-online.js --step 1) + * node mpc-self-custody-offline.js --step 2 + * (Then run: node mpc-self-custody-online.js --step 2) + * node mpc-self-custody-offline.js --step 3 + * (Then run: node mpc-self-custody-online.js --step 3) + * WALLET_PASSPHRASE="your-secure-passphrase" node mpc-self-custody-offline.js --step 4 + * (Then run: node mpc-self-custody-online.js --step 4) + * + * Prerequisites: + * - bitgo-gpg-public-key.json (from online script --step 0) + * - WALLET_PASSPHRASE environment variable (for step 4) + * - Workspace directory for storing state files (default: ./mpc-workspace/) + * + * Important security notes: + * - Your p-shares (private key shares) never leave this machine + * - Only encrypted n-shares and passphrase-encrypted signing material are written to workspace files + * - Protect your passphrase - it's needed to decrypt signing material for transactions + * - Keep workspace files secure - they contain your encrypted key material + * - Back up your encrypted key shares to a secure location + */ +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./mpc-workspace-schema'); + +// Configure OpenPGP to accept all curves (required for secp256k1 GPG keys) +const openpgp = require('openpgp'); +openpgp.config.rejectCurves = new Set(); + +const MPCv2PartiesEnum = { USER: 0, BACKUP: 1, BITGO: 2 }; + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[OFFLINE] Wrote ${name}`); +} + +function sessionDataToJson(sessionData) { + const o = { + dkgSessionBytes: Buffer.from(sessionData.dkgSessionBytes).toString('base64'), + dkgState: sessionData.dkgState, + }; + if (sessionData.chainCodeCommitment) { + o.chainCodeCommitment = Buffer.from(sessionData.chainCodeCommitment).toString('base64'); + } + if (sessionData.keyShareBuff) { + o.keyShareBuff = Buffer.from(sessionData.keyShareBuff).toString('base64'); + } + return o; +} + +function sessionDataFromJson(o) { + const sessionData = { + dkgSessionBytes: new Uint8Array(Buffer.from(o.dkgSessionBytes, 'base64')), + dkgState: o.dkgState, + }; + if (o.chainCodeCommitment) { + sessionData.chainCodeCommitment = new Uint8Array(Buffer.from(o.chainCodeCommitment, 'base64')); + } + if (o.keyShareBuff) { + sessionData.keyShareBuff = Buffer.from(o.keyShareBuff, 'base64'); + } + return sessionData; +} + +function formatBitgoBroadcastMessage(broadcastMessage) { + // Ensure from is always a number (BITGO = 2) + const from = typeof broadcastMessage.from === 'number' + ? broadcastMessage.from + : (typeof broadcastMessage.from === 'string' ? parseInt(broadcastMessage.from, 10) : MPCv2PartiesEnum.BITGO); + + // Handle different possible formats from API response + // Case 1: Already formatted with payload: { from: 2, payload: { message, signature } } + if (broadcastMessage.payload && broadcastMessage.payload.message && broadcastMessage.payload.signature) { + return { + from: from, + payload: { + message: String(broadcastMessage.payload.message), + signature: String(broadcastMessage.payload.signature), + }, + }; + } + // Case 2: Flat structure: { from: 2, message: "...", signature: "..." } + // This is the format returned by BitGo API (spreading bitgoMsg1.payload) + if (broadcastMessage.message && broadcastMessage.signature) { + return { + from: from, + payload: { + message: String(broadcastMessage.message), + signature: String(broadcastMessage.signature), + }, + }; + } + throw new Error(`Invalid bitgoMsg1 format. Expected { from, payload: { message, signature } } or { from, message, signature }, got: ${JSON.stringify(broadcastMessage)}`); +} + +function formatP2PMessage(p2pMessage, commitment) { + const payload = p2pMessage.payload || { + encryptedMessage: p2pMessage.encryptedMessage, + signature: p2pMessage.signature, + }; + return { payload, from: p2pMessage.from, to: p2pMessage.to, commitment: commitment || p2pMessage.commitment }; +} + +async function runStep1() { + const { DklsDkg, DklsComms, DklsTypes } = require('@bitgo/sdk-lib-mpc'); + const { generateGPGKeyPair } = require('@bitgo/sdk-core'); + + const config = readJson(FILES.bitgoGpgPublicKey); + const bitgoPublicGpgKey = config.bitgoGpgPublicKey; + if (!bitgoPublicGpgKey) throw new Error('bitgoGpgPublicKey required in bitgo-gpg-public-key.json'); + + const n = 3; + const m = 2; + const userSession = new DklsDkg.Dkg(n, m, MPCv2PartiesEnum.USER); + const backupSession = new DklsDkg.Dkg(n, m, MPCv2PartiesEnum.BACKUP); + const userGpgKey = await generateGPGKeyPair('secp256k1'); + const backupGpgKey = await generateGPGKeyPair('secp256k1'); + + const userRound1BroadcastMsg = await userSession.initDkg(); + const backupRound1BroadcastMsg = await backupSession.initDkg(); + + const bitgoGpgPubKey = { partyId: MPCv2PartiesEnum.BITGO, gpgKey: bitgoPublicGpgKey }; + const userGpgPrvKey = { partyId: MPCv2PartiesEnum.USER, gpgKey: userGpgKey.privateKey }; + const backupGpgPrvKey = { partyId: MPCv2PartiesEnum.BACKUP, gpgKey: backupGpgKey.privateKey }; + + const round1SerializedMessages = DklsTypes.serializeMessages({ + broadcastMessages: [userRound1BroadcastMsg, backupRound1BroadcastMsg], + p2pMessages: [], + }); + const round1Messages = await DklsComms.encryptAndAuthOutgoingMessages( + round1SerializedMessages, + [bitgoGpgPubKey], + [userGpgPrvKey, backupGpgPrvKey] + ); + + const userMsg1Payload = round1Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.USER)?.payload; + const backupMsg1Payload = round1Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BACKUP)?.payload; + if (!userMsg1Payload || !backupMsg1Payload) throw new Error('Round 1 broadcast messages not found'); + + const round1Payload = { + userGpgPublicKey: userGpgKey.publicKey, + backupGpgPublicKey: backupGpgKey.publicKey, + userMsg1: { from: 0, ...userMsg1Payload }, + backupMsg1: { from: 1, ...backupMsg1Payload }, + }; + writeJson(FILES.round1Payload, round1Payload); + + const round1State = { + userSessionData: sessionDataToJson(userSession.getSessionData()), + backupSessionData: sessionDataToJson(backupSession.getSessionData()), + userGpgPublicKey: userGpgKey.publicKey, + userGpgPrivateKey: userGpgKey.privateKey, + backupGpgPublicKey: backupGpgKey.publicKey, + backupGpgPrivateKey: backupGpgKey.privateKey, + userRound1BroadcastMsg: DklsTypes.serializeBroadcastMessage(userRound1BroadcastMsg), + backupRound1BroadcastMsg: DklsTypes.serializeBroadcastMessage(backupRound1BroadcastMsg), + }; + writeJson(FILES.round1State, round1State); + console.log('[OFFLINE] Step 1 done. Run online script for round 1, then run --step 2.'); +} + +async function runStep2() { + const { DklsDkg, DklsComms, DklsTypes } = require('@bitgo/sdk-lib-mpc'); + + const config = readJson(FILES.bitgoGpgPublicKey); + const bitgoPublicGpgKey = config.bitgoGpgPublicKey; + const round1State = readJson(FILES.round1State); + const round1Response = readJson(FILES.round1Response); + + const n = 3; + const m = 2; + const userSession = await DklsDkg.Dkg.restoreSession( + n, + m, + MPCv2PartiesEnum.USER, + sessionDataFromJson(round1State.userSessionData) + ); + const backupSession = await DklsDkg.Dkg.restoreSession( + n, + m, + MPCv2PartiesEnum.BACKUP, + sessionDataFromJson(round1State.backupSessionData) + ); + + const bitgoGpgPubKey = { partyId: MPCv2PartiesEnum.BITGO, gpgKey: bitgoPublicGpgKey }; + const userGpgPrvKey = { partyId: MPCv2PartiesEnum.USER, gpgKey: round1State.userGpgPrivateKey }; + const backupGpgPrvKey = { partyId: MPCv2PartiesEnum.BACKUP, gpgKey: round1State.backupGpgPrivateKey }; + + // Format the BitGo message for verification + const formattedBitgoMsg1 = formatBitgoBroadcastMessage(round1Response.bitgoMsg1); + + // Verify the structure is correct before passing to verification + if (!formattedBitgoMsg1.payload || !formattedBitgoMsg1.payload.message || !formattedBitgoMsg1.payload.signature) { + throw new Error(`Invalid formatted bitgoMsg1 structure: ${JSON.stringify(formattedBitgoMsg1)}`); + } + if (formattedBitgoMsg1.from !== MPCv2PartiesEnum.BITGO) { + throw new Error(`Invalid from field in bitgoMsg1: expected ${MPCv2PartiesEnum.BITGO}, got ${formattedBitgoMsg1.from}`); + } + + const bitgoRound1BroadcastMessages = await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [], + broadcastMessages: [formattedBitgoMsg1], + }, + [bitgoGpgPubKey], + [userGpgPrvKey, backupGpgPrvKey] + ); + const bitgoRound1BroadcastMsg = bitgoRound1BroadcastMessages.broadcastMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO + ); + if (!bitgoRound1BroadcastMsg) throw new Error('BitGo message 1 not found'); + + const userRound1BroadcastMsg = DklsTypes.deserializeBroadcastMessage(round1State.userRound1BroadcastMsg); + const backupRound1BroadcastMsg = DklsTypes.deserializeBroadcastMessage(round1State.backupRound1BroadcastMsg); + const userRound2P2PMessages = userSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [DklsTypes.deserializeBroadcastMessage(bitgoRound1BroadcastMsg), backupRound1BroadcastMsg], + }); + + const backupRound2P2PMessages = backupSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [userRound1BroadcastMsg, DklsTypes.deserializeBroadcastMessage(bitgoRound1BroadcastMsg)], + }); + + const userToBitgoMsg2 = userRound2P2PMessages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER && m.to === MPCv2PartiesEnum.BITGO + ); + const serializedBackupToBitgoMsg2 = DklsTypes.serializeMessages(backupRound2P2PMessages).p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.BITGO + ); + if (!userToBitgoMsg2 || !serializedBackupToBitgoMsg2) throw new Error('Round 2 P2P messages not found'); + + const round2Messages = await DklsComms.encryptAndAuthOutgoingMessages( + { + p2pMessages: [DklsTypes.serializeP2PMessage(userToBitgoMsg2), serializedBackupToBitgoMsg2], + broadcastMessages: [], + }, + [bitgoGpgPubKey], + [userGpgPrvKey, backupGpgPrvKey] + ); + + const userMsg2 = round2Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER && m.to === MPCv2PartiesEnum.BITGO + ); + const backupMsg2 = round2Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.BITGO + ); + if (!userMsg2?.commitment || !backupMsg2?.commitment) throw new Error('Round 2 commitments not found'); + + const round2Payload = { + sessionId: round1Response.sessionId, + userMsg2: { + from: MPCv2PartiesEnum.USER, + to: MPCv2PartiesEnum.BITGO, + signature: userMsg2.payload.signature, + encryptedMessage: userMsg2.payload.encryptedMessage, + }, + userCommitment2: userMsg2.commitment, + backupMsg2: { + from: MPCv2PartiesEnum.BACKUP, + to: MPCv2PartiesEnum.BITGO, + signature: backupMsg2.payload.signature, + encryptedMessage: backupMsg2.payload.encryptedMessage, + }, + backupCommitment2: backupMsg2.commitment, + }; + writeJson(FILES.round2Payload, round2Payload); + + const userToBackupMsg2 = userRound2P2PMessages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER && m.to === MPCv2PartiesEnum.BACKUP + ); + const backupToUserMsg2 = backupRound2P2PMessages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.USER + ); + const backupToBitgoMsg2Ser = backupRound2P2PMessages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.BITGO + ); + + const round2State = { + userSessionData: sessionDataToJson(userSession.getSessionData()), + backupSessionData: sessionDataToJson(backupSession.getSessionData()), + userGpgPublicKey: round1State.userGpgPublicKey, + userGpgPrivateKey: round1State.userGpgPrivateKey, + backupGpgPublicKey: round1State.backupGpgPublicKey, + backupGpgPrivateKey: round1State.backupGpgPrivateKey, + sessionId: round1Response.sessionId, + bitgoToUserMsg2: round1Response.bitgoToUserMsg2, + bitgoToBackupMsg2: round1Response.bitgoToBackupMsg2, + backupToUserMsg2: backupToUserMsg2 ? DklsTypes.serializeP2PMessage(backupToUserMsg2) : undefined, + userToBackupMsg2: userToBackupMsg2 ? DklsTypes.serializeP2PMessage(userToBackupMsg2) : undefined, + backupToBitgoMsg2: backupToBitgoMsg2Ser ? DklsTypes.serializeP2PMessage(backupToBitgoMsg2Ser) : undefined, + }; + writeJson(FILES.round2State, round2State); + console.log('[OFFLINE] Step 2 done. Run online script for round 2, then run --step 3.'); +} + +async function runStep3() { + const { DklsDkg, DklsComms, DklsTypes } = require('@bitgo/sdk-lib-mpc'); + + const config = readJson(FILES.bitgoGpgPublicKey); + const bitgoPublicGpgKey = config.bitgoGpgPublicKey; + const round2State = readJson(FILES.round2State); + const round2Response = readJson(FILES.round2Response); + + const n = 3; + const m = 2; + const userSession = await DklsDkg.Dkg.restoreSession( + n, + m, + MPCv2PartiesEnum.USER, + sessionDataFromJson(round2State.userSessionData) + ); + const backupSession = await DklsDkg.Dkg.restoreSession( + n, + m, + MPCv2PartiesEnum.BACKUP, + sessionDataFromJson(round2State.backupSessionData) + ); + + const bitgoGpgPubKey = { partyId: MPCv2PartiesEnum.BITGO, gpgKey: bitgoPublicGpgKey }; + const userGpgPrvKey = { partyId: MPCv2PartiesEnum.USER, gpgKey: round2State.userGpgPrivateKey }; + const backupGpgPrvKey = { partyId: MPCv2PartiesEnum.BACKUP, gpgKey: round2State.backupGpgPrivateKey }; + + // Decrypt BitGo's round 2 P2P messages (for generating round 3 messages) + const decryptedBitgoToUserRound2Msgs = await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [formatP2PMessage(round2State.bitgoToUserMsg2, round2Response.bitgoCommitment2)], + broadcastMessages: [], + }, + [bitgoGpgPubKey], + [userGpgPrvKey] + ); + const bitgoToUserRound2Msg = DklsTypes.deserializeP2PMessage( + decryptedBitgoToUserRound2Msgs.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO && m.to === MPCv2PartiesEnum.USER + ) + ); + + const decryptedBitgoToBackupRound2Msg = await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [formatP2PMessage(round2State.bitgoToBackupMsg2, round2Response.bitgoCommitment2)], + broadcastMessages: [], + }, + [bitgoGpgPubKey], + [backupGpgPrvKey] + ); + const bitgoToBackupRound2Msg = DklsTypes.deserializeP2PMessage( + decryptedBitgoToBackupRound2Msg.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO && m.to === MPCv2PartiesEnum.BACKUP + ) + ); + + const backupToUserMsg2 = round2State.backupToUserMsg2 + ? DklsTypes.deserializeP2PMessage(round2State.backupToUserMsg2) + : null; + const userToBackupMsg2 = round2State.userToBackupMsg2 + ? DklsTypes.deserializeP2PMessage(round2State.userToBackupMsg2) + : null; + + // Round 3 sub-round 1: Process round 2 P2P messages, generate round 3 P2P messages + const userRound3Messages = userSession.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [bitgoToUserRound2Msg, backupToUserMsg2].filter(Boolean), + }); + const backupRound3Messages = backupSession.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [bitgoToBackupRound2Msg, userToBackupMsg2].filter(Boolean), + }); + + // Extract round 3 P2P messages + const userToBitgoMsg3 = userRound3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER && m.to === MPCv2PartiesEnum.BITGO + ); + const backupToBitgoMsg3 = backupRound3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.BITGO + ); + const userToBackupMsg3 = userRound3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER && m.to === MPCv2PartiesEnum.BACKUP + ); + const backupToUserMsg3 = backupRound3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.USER + ); + + if (!userToBitgoMsg3 || !backupToBitgoMsg3) { + throw new Error('Round 3 P2P messages not found'); + } + + // Decrypt BitGo's round 3 P2P messages from round2Response (BitGo is one step ahead!) + // Note: Round 3 messages use the commitment calculated at end of Round 2 + const decryptedBitgoToUserRound3Msgs = await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [formatP2PMessage(round2Response.bitgoToUserMsg3, round2Response.bitgoCommitment2)], + broadcastMessages: [], + }, + [bitgoGpgPubKey], + [userGpgPrvKey] + ); + const bitgoToUserRound3Msg = DklsTypes.deserializeP2PMessage( + decryptedBitgoToUserRound3Msgs.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO && m.to === MPCv2PartiesEnum.USER + ) + ); + + const decryptedBitgoToBackupRound3Msgs = await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [formatP2PMessage(round2Response.bitgoToBackupMsg3, round2Response.bitgoCommitment2)], + broadcastMessages: [], + }, + [bitgoGpgPubKey], + [backupGpgPrvKey] + ); + const bitgoToBackupRound3Msg = DklsTypes.deserializeP2PMessage( + decryptedBitgoToBackupRound3Msgs.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO && m.to === MPCv2PartiesEnum.BACKUP + ) + ); + + // Round 3 sub-round 2: Process all round 3 P2P messages to generate round 4 broadcast messages + const userRound4Messages = userSession.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [bitgoToUserRound3Msg, backupToUserMsg3].filter(Boolean), + }); + const backupRound4Messages = backupSession.handleIncomingMessages({ + broadcastMessages: [], + p2pMessages: [bitgoToBackupRound3Msg, userToBackupMsg3].filter(Boolean), + }); + + const userRound4BroadcastMsg = userRound4Messages.broadcastMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER + ); + const backupRound4BroadcastMsg = backupRound4Messages.broadcastMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP + ); + + if (!userRound4BroadcastMsg || !backupRound4BroadcastMsg) { + throw new Error('Failed to generate round 4 broadcast messages'); + } + + // Encrypt and authenticate messages for BitGo + const round3Messages = await DklsComms.encryptAndAuthOutgoingMessages( + { + p2pMessages: [ + DklsTypes.serializeP2PMessage(userToBitgoMsg3), + DklsTypes.serializeP2PMessage(backupToBitgoMsg3), + ], + broadcastMessages: [], + }, + [bitgoGpgPubKey], + [userGpgPrvKey, backupGpgPrvKey] + ); + + const round4Messages = await DklsComms.encryptAndAuthOutgoingMessages( + { + p2pMessages: [], + broadcastMessages: [ + DklsTypes.serializeBroadcastMessage(userRound4BroadcastMsg), + DklsTypes.serializeBroadcastMessage(backupRound4BroadcastMsg), + ], + }, + [bitgoGpgPubKey], + [userGpgPrvKey, backupGpgPrvKey] + ); + + const userMsg3 = round3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER && m.to === MPCv2PartiesEnum.BITGO + )?.payload; + const backupMsg3 = round3Messages.p2pMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.BITGO + )?.payload; + const userMsg4 = round4Messages.broadcastMessages.find( + (m) => m.from === MPCv2PartiesEnum.USER + )?.payload; + const backupMsg4 = round4Messages.broadcastMessages.find( + (m) => m.from === MPCv2PartiesEnum.BACKUP + )?.payload; + + const round3Payload = { + sessionId: round2State.sessionId, + userMsg3: { from: 0, to: 2, ...userMsg3 }, + backupMsg3: { from: 1, to: 2, ...backupMsg3 }, + userMsg4: { from: 0, ...userMsg4 }, + backupMsg4: { from: 1, ...backupMsg4 }, + }; + writeJson(FILES.round3Payload, round3Payload); + + // Save session state and round 4 broadcast messages for step 4 + const round3State = { + userSessionData: sessionDataToJson(userSession.getSessionData()), + backupSessionData: sessionDataToJson(backupSession.getSessionData()), + userGpgPublicKey: round2State.userGpgPublicKey, + userGpgPrivateKey: round2State.userGpgPrivateKey, + backupGpgPublicKey: round2State.backupGpgPublicKey, + backupGpgPrivateKey: round2State.backupGpgPrivateKey, + sessionId: round2State.sessionId, + userRound4BroadcastMsg: DklsTypes.serializeBroadcastMessage(userRound4BroadcastMsg), + backupRound4BroadcastMsg: DklsTypes.serializeBroadcastMessage(backupRound4BroadcastMsg), + }; + writeJson(FILES.round3State, round3State); + console.log('[OFFLINE] Step 3 done. Run online script for round 3, then run --step 4.'); +} + +async function runStep4() { + const { DklsDkg, DklsComms, DklsTypes } = require('@bitgo/sdk-lib-mpc'); + const BitGo = require('bitgo').BitGo; + + const passphrase = process.env.WALLET_PASSPHRASE || ''; + if (!passphrase) throw new Error('WALLET_PASSPHRASE required for step 4'); + + const config = readJson(FILES.bitgoGpgPublicKey); + const bitgoPublicGpgKey = config.bitgoGpgPublicKey; + const round3State = readJson(FILES.round3State); + const round3Response = readJson(FILES.round3Response); + + const n = 3; + const m = 2; + const userSession = await DklsDkg.Dkg.restoreSession( + n, + m, + MPCv2PartiesEnum.USER, + sessionDataFromJson(round3State.userSessionData) + ); + const backupSession = await DklsDkg.Dkg.restoreSession( + n, + m, + MPCv2PartiesEnum.BACKUP, + sessionDataFromJson(round3State.backupSessionData) + ); + + const bitgoGpgPubKey = { partyId: MPCv2PartiesEnum.BITGO, gpgKey: bitgoPublicGpgKey }; + const userGpgPrvKey = { partyId: MPCv2PartiesEnum.USER, gpgKey: round3State.userGpgPrivateKey }; + const backupGpgPrvKey = { partyId: MPCv2PartiesEnum.BACKUP, gpgKey: round3State.backupGpgPrivateKey }; + + // Round 4: Process BitGo's round 4 broadcast message to finalize key shares + // Load the round 4 broadcast messages generated in step 3 + const userRound4BroadcastMsg = DklsTypes.deserializeBroadcastMessage(round3State.userRound4BroadcastMsg); + const backupRound4BroadcastMsg = DklsTypes.deserializeBroadcastMessage(round3State.backupRound4BroadcastMsg); + + // Decrypt and verify BitGo's round 4 broadcast message + const bitgoRound4BroadcastMessages = DklsTypes.deserializeMessages( + await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [], + broadcastMessages: [formatBitgoBroadcastMessage(round3Response.bitgoMsg4)], + }, + [bitgoGpgPubKey], + [] + ) + ).broadcastMessages; + const bitgoRound4BroadcastMsg = bitgoRound4BroadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BITGO); + if (!bitgoRound4BroadcastMsg) throw new Error('BitGo message 4 not found'); + + // Process all round 4 broadcast messages to finalize the DKG + userSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [bitgoRound4BroadcastMsg, backupRound4BroadcastMsg], + }); + backupSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [bitgoRound4BroadcastMsg, userRound4BroadcastMsg], + }); + + const userPrivateMaterial = userSession.getKeyShare(); + const backupPrivateMaterial = backupSession.getKeyShare(); + const userReducedPrivateMaterial = userSession.getReducedKeyShare(); + const backupReducedPrivateMaterial = backupSession.getReducedKeyShare(); + + const commonKeychain = DklsTypes.getCommonKeychain(userPrivateMaterial); + const commonKeychainBackup = DklsTypes.getCommonKeychain(backupPrivateMaterial); + if (commonKeychain !== round3Response.commonKeychain || commonKeychain !== commonKeychainBackup) { + throw new Error('Common keychain mismatch'); + } + + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + const encryptedPrvUser = bitgo.encrypt({ + input: userPrivateMaterial.toString('base64'), + password: passphrase, + }); + const reducedEncryptedPrvUser = bitgo.encrypt({ + input: Buffer.from(userReducedPrivateMaterial).toString('base64'), + password: passphrase, + }); + const encryptedPrvBackup = bitgo.encrypt({ + input: backupPrivateMaterial.toString('base64'), + password: passphrase, + }); + const reducedEncryptedPrvBackup = bitgo.encrypt({ + input: Buffer.from(backupReducedPrivateMaterial).toString('base64'), + password: passphrase, + }); + + const userKeychainParams = { + source: 'user', + keyType: 'tss', + commonKeychain, + encryptedPrv: encryptedPrvUser, + originalPasscodeEncryptionCode: process.env.ORIGINAL_PASSCODE_ENCRYPTION_CODE, + isMPCv2: true, + }; + const backupKeychainParams = { + source: 'backup', + keyType: 'tss', + commonKeychain, + encryptedPrv: encryptedPrvBackup, + originalPasscodeEncryptionCode: process.env.ORIGINAL_PASSCODE_ENCRYPTION_CODE, + isMPCv2: true, + }; + const bitgoKeychainParams = { + source: 'bitgo', + keyType: 'tss', + commonKeychain, + isMPCv2: true, + }; + + writeJson(FILES.keychainPayloads, { + userKeychainParams, + backupKeychainParams, + bitgoKeychainParams, + commonKeychain, + reducedEncryptedPrvUser, + reducedEncryptedPrvBackup, + }); + console.log('[OFFLINE] Step 4 done. Run online script to register keychains and create wallet.'); +} + +async function main() { + const step = process.argv.find((a) => a.startsWith('--step=')) + ? process.argv.find((a) => a.startsWith('--step=')).split('=')[1] + : process.argv[process.argv.indexOf('--step') + 1]; + if (!step || !['1', '2', '3', '4'].includes(step)) { + console.error('Usage: node mpc-self-custody-offline.js --step 1|2|3|4'); + process.exit(1); + } + if (process.env.MPC_WORKSPACE_DIR) { + console.log('[OFFLINE] Workspace:', process.env.MPC_WORKSPACE_DIR); + } + if (step === '1') await runStep1(); + else if (step === '2') await runStep2(); + else if (step === '3') await runStep3(); + else if (step === '4') await runStep4(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-mcp-v2/mpc-self-custody-online.js b/examples/js/self-custody-mcp-v2/mpc-self-custody-online.js new file mode 100644 index 0000000000..0b53a7e490 --- /dev/null +++ b/examples/js/self-custody-mcp-v2/mpc-self-custody-online.js @@ -0,0 +1,381 @@ +/** + * MPCv2 Self-Custody Wallet: ONLINE Script (Requires Network) + * + * This script implements the ONLINE/NETWORK portions of creating an MPC self-custody wallet using + * the TSS (Threshold Signature Scheme) flow. It communicates with BitGo's APIs to: + * - Fetch BitGo's configuration (TSS settings, GPG public key) + * - Execute the multi-round DKG (Distributed Key Generation) protocol with BitGo + * - Register keychains (with passphrase-encrypted signing material from offline script) + * - Create the wallet linking all three keychains + * + * This script NEVER receives or handles raw p-shares (private shares). It only transmits: + * - Encrypted n-shares (to BitGo) during DKG rounds + * - Passphrase-encrypted signing material (from offline script) during keychain registration + * + * =========================================================================================== + * STEP-BY-STEP FLOW (corresponds to create-wallet.md sections 2.1-2.2 and 2.6-2.9): + * =========================================================================================== + * + * STEP 0 (Online - requires network): + * What: Fetch BitGo's TSS settings and GPG public key + * Maps to doc: 2.1 (Fetch BitGo TSS settings), 2.2 (Fetch BitGo public GPG key) + * API Endpoints: + * - GET {microservicesUrl}/api/v2/tss/settings + * Purpose: Determine MPC version (MPCv1 vs MPCv2) for the coin family + * - GET {baseApiUrl}/api/v1/client/constants + * Purpose: Obtain BitGo's GPG public key for encrypting n-shares sent to BitGo + * Operations: + * - Verify the coin supports MPCv2 (multisigTypeVersion === 'MPCv2') + * - Fetch BitGo's GPG public key (secp256k1) for encrypting n-shares + * Output: bitgo-gpg-public-key.json (BitGo's public GPG key) + * Next: Copy workspace to offline machine and run offline script --step 1 + * + * STEP 1 (Online - requires network): + * What: Send round 1 DKG messages to BitGo and receive BitGo's round 1 response + * Maps to doc: Part of 2.6 (Create BitGo keychain - DKG round 1) + * API Endpoint: + * - POST {baseApiUrl}/api/v2/mpc/generatekey + * Payload: { enterprise, type: 'MPCv2', round: 'MPCv2-R1', payload: round1Payload } + * Operations: + * - Send encrypted round 1 broadcast messages (user & backup commitments) to BitGo + * - Send user & backup GPG public keys to BitGo + * - Receive BitGo's round 1 broadcast message and round 2 P2P messages + * - Receive sessionId for tracking this DKG session + * Input: round1-payload.json (from offline script --step 1) + * Output: round1-response.json (BitGo's round 1 broadcast + round 2 P2P messages) + * Next: Copy response to offline machine and run offline script --step 2 + * + * STEP 2 (Online - requires network): + * What: Send round 2 DKG P2P messages to BitGo and receive BitGo's round 2 & 3 responses + * Maps to doc: Part of 2.6 (Create BitGo keychain - DKG round 2) + * API Endpoint: + * - POST {baseApiUrl}/api/v2/mpc/generatekey + * Payload: { enterprise, type: 'MPCv2', round: 'MPCv2-R2', payload: round2Payload } + * Operations: + * - Send sessionId (from round 1 response) + * - Send encrypted round 2 P2P messages (user→BitGo, backup→BitGo) + * - Send commitments for round 2 messages + * - Receive BitGo's round 2 P2P messages (BitGo→user, BitGo→backup) + * - Receive BitGo's round 3 P2P messages (BitGo is one step ahead in MPCv2) + * - Receive BitGo's commitment for round 2 + * Input: round2-payload.json (from offline script --step 2) + * Output: round2-response.json (BitGo's round 2 & 3 P2P messages + commitment) + * Next: Copy response to offline machine and run offline script --step 3 + * + * STEP 3 (Online - requires network): + * What: Send round 3 & 4 DKG messages to BitGo and receive BitGo's final round 4 response + * Maps to doc: Part of 2.6 (Create BitGo keychain - DKG rounds 3 & 4, finalize) + * API Endpoint: + * - POST {baseApiUrl}/api/v2/mpc/generatekey + * Payload: { enterprise, type: 'MPCv2', round: 'MPCv2-R3', payload: round3Payload } + * Operations: + * - Send sessionId (from previous rounds) + * - Send encrypted round 3 P2P messages (user→BitGo, backup→BitGo) + * - Send encrypted round 4 broadcast messages (user & backup final commitments) + * - Receive BitGo's round 4 broadcast message (final commitment) + * - Receive commonKeychain (the public keychain identifier - NOT a private key) + * Input: round3-payload.json (from offline script --step 3) + * Output: round3-response.json (BitGo's round 4 broadcast + commonKeychain) + * Next: Copy response to offline machine and run offline script --step 4 + * Note: At this point, all three participants have completed DKG and possess their key shares + * + * STEP 4 (Online - requires network): + * What: Register keychains and create wallet + * Maps to doc: 2.7 (Create user keychain), 2.8 (Create backup keychain), 2.9 (Create wallet) + * API Endpoints: + * - POST {baseApiUrl}/api/v2/{coin}/key (called 3 times - user, backup, BitGo) + * Purpose: Register each keychain with passphrase-encrypted signing material + * - POST {baseApiUrl}/api/v2/{coin}/wallet + * Purpose: Create wallet linking the three keychains by their IDs + * Operations: + * - Read keychain-payloads.json (contains passphrase-encrypted signing material from offline script) + * - Register user keychain (source: 'user', encrypted signing material) + * - Register backup keychain (source: 'backup', encrypted signing material) + * - Register BitGo keychain (source: 'bitgo', no encrypted material - BitGo has its own p-share) + * - Determine wallet version based on coin type (e.g. walletVersion: 5 for EVM MPCv2) + * - Create wallet with m=2, n=3, multisigType='tss', linking all three keychain IDs + * Input: keychain-payloads.json (from offline script --step 4) + * Output: wallet-result.json (wallet ID, receive address, keychain IDs) + * Result: Fully operational self-custody MPC wallet + * + * =========================================================================================== + * + * Usage: + * BITGO_ACCESS_TOKEN="your-token" COIN="teth" node mpc-self-custody-online.js --step 0 + * (Then copy workspace to offline machine and run offline --step 1) + * node mpc-self-custody-online.js --step 1 + * (Then copy round1-response.json to offline machine and run offline --step 2) + * node mpc-self-custody-online.js --step 2 + * (Then copy round2-response.json to offline machine and run offline --step 3) + * node mpc-self-custody-online.js --step 3 + * (Then copy round3-response.json to offline machine and run offline --step 4) + * WALLET_LABEL="My Wallet" node mpc-self-custody-online.js --step 4 + * + * Environment Variables: + * Required: + * - BITGO_ACCESS_TOKEN: Your BitGo API access token + * - COIN: Coin identifier (e.g., 'teth' for testnet ETH, 'tsol' for testnet SOL) + * Optional: + * - WALLET_LABEL: Wallet display name (default: 'MPCv2 Self-Custody Wallet (two-script)') + * - ENTERPRISE: Enterprise ID (for enterprise accounts) + * - BITGO_ENV: BitGo environment - 'test' or 'prod' (default: 'test') + * - BITGO_CUSTOM_ROOT_URI: Custom BitGo API URL (e.g., for BitGo Express proxy) + * - MPC_WORKSPACE_DIR: Workspace directory path (default: './mpc-workspace/') + * + * Important notes: + * - This script never handles raw p-shares - only encrypted n-shares and signing material + * - All cryptographic operations on p-shares happen in the offline script + * - The workspace files transferred between online/offline contain only encrypted data + * - Use BitGo Express (local signing server) for additional security layer if desired + */ +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./mpc-workspace-schema'); + +const ROUNDS = { 1: 'MPCv2-R1', 2: 'MPCv2-R2', 3: 'MPCv2-R3' }; +const KEYGEN_TYPE = 'MPCv2'; + +function shouldForceV1AuthToProxy() { + // BitGo Express expects Authorization: Bearer . + // BitGoJS "v2 auth" sends a token hash + HMAC, which Express cannot use to extract the raw token. + // Set BITGO_FORCE_V1_AUTH=true to force this behavior explicitly. + const explicit = (process.env.BITGO_FORCE_V1_AUTH || '').toLowerCase(); + if (explicit === 'true' || explicit === '1' || explicit === 'yes') return true; + + const root = process.env.BITGO_CUSTOM_ROOT_URI || ''; + return /(^|\/\/)(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(root); +} + +/** + * Wrap BitGo instance to force v1 auth for all requests when using Express/proxy. + * This wraps only the public API request methods (get, post, put, del, patch, options), + * avoiding monkey-patching internal implementation. + */ +function wrapBitGoForV1Auth(bitgo) { + // if (!shouldForceV1AuthToProxy()) return bitgo; + + // const methods = ['get', 'post', 'put', 'del', 'patch', 'options']; + // methods.forEach((method) => { + // const original = bitgo[method].bind(bitgo); + // bitgo[method] = function (url) { + // const req = original(url); + // req.forceV1Auth = true; + // return req; + // }; + // }); + + return bitgo; +} + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[ONLINE] Wrote ${name}`); +} + +async function runStep0() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + + const bitgoOptions = { + env: process.env.BITGO_ENV || 'test', + accessToken: accessToken, + useProduction: false, + }; + + // Explicitly pass customRootURI if BITGO_CUSTOM_ROOT_URI is set + if (process.env.BITGO_CUSTOM_ROOT_URI) { + bitgoOptions.customRootURI = process.env.BITGO_CUSTOM_ROOT_URI; + } + + let bitgo = new BitGo(bitgoOptions); + bitgo = wrapBitGoForV1Auth(bitgo); + + // Authenticate with access token (required when using Express server) + bitgo.authenticateWithAccessToken({ accessToken }); + + const coin = process.env.COIN || 'hteth'; + const baseCoin = bitgo.coin(coin); + const enterprise = process.env.ENTERPRISE || ''; + + const tssSettings = await bitgo.get(bitgo.url('/tss/settings', 2)).result(); + const multisigTypeVersion = + tssSettings.coinSettings?.[baseCoin.getFamily()]?.walletCreationSettings?.multiSigTypeVersion; + if (multisigTypeVersion !== 'MPCv2') { + throw new Error(`Coin ${coin} does not use MPCv2 (got ${multisigTypeVersion}). Use a coin that supports MPCv2.`); + } + + const EcdsaMPCv2Utils = require('@bitgo/sdk-core').EcdsaMPCv2Utils; + const mpcUtils = new EcdsaMPCv2Utils(bitgo, baseCoin); + const bitgoPublicGpgKey = ( + (await mpcUtils.getBitgoGpgPubkeyBasedOnFeatureFlags(enterprise, true)) ?? mpcUtils.bitgoMPCv2PublicGpgKey + ).armor(); + + writeJson(FILES.bitgoGpgPublicKey, { bitgoGpgPublicKey: bitgoPublicGpgKey }); + console.log('[ONLINE] Step 0 done. Copy workspace to offline machine and run offline --step 1.'); +} + +async function runStep1() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + + let bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test', customRootURI: process.env.BITGO_CUSTOM_ROOT_URI }); + bitgo = wrapBitGoForV1Auth(bitgo); + bitgo.authenticateWithAccessToken({ accessToken }); + + const enterprise = process.env.ENTERPRISE || ''; + const payload = readJson(FILES.round1Payload); + + const result = await bitgo + .post(bitgo.url('/mpc/generatekey', 2)) + .send({ enterprise, type: KEYGEN_TYPE, round: ROUNDS[1], payload }) + .result(); + + writeJson(FILES.round1Response, result); + console.log('[ONLINE] Step 1 done. Copy round1-response.json to offline machine and run offline --step 2.'); +} + +async function runStep2() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + + let bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test', customRootURI: process.env.BITGO_CUSTOM_ROOT_URI }); + bitgo = wrapBitGoForV1Auth(bitgo); + bitgo.authenticateWithAccessToken({ accessToken }); + + const enterprise = process.env.ENTERPRISE || ''; + const payload = readJson(FILES.round2Payload); + + const result = await bitgo + .post(bitgo.url('/mpc/generatekey', 2)) + .send({ enterprise, type: KEYGEN_TYPE, round: ROUNDS[2], payload }) + .result(); + + writeJson(FILES.round2Response, result); + console.log('[ONLINE] Step 2 done. Copy round2-response.json to offline machine and run offline --step 3.'); +} + +async function runStep3() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + + let bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test', customRootURI: process.env.BITGO_CUSTOM_ROOT_URI }); + bitgo = wrapBitGoForV1Auth(bitgo); + bitgo.authenticateWithAccessToken({ accessToken }); + + const enterprise = process.env.ENTERPRISE || ''; + const payload = readJson(FILES.round3Payload); + + const result = await bitgo + .post(bitgo.url('/mpc/generatekey', 2)) + .send({ enterprise, type: KEYGEN_TYPE, round: ROUNDS[3], payload }) + .result(); + + writeJson(FILES.round3Response, result); + console.log('[ONLINE] Step 3 done. Copy round3-response.json to offline machine and run offline --step 4.'); +} + +async function runStep4() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + + let bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test', customRootURI: process.env.BITGO_CUSTOM_ROOT_URI }); + bitgo = wrapBitGoForV1Auth(bitgo); + bitgo.authenticateWithAccessToken({ accessToken }); + + const coin = process.env.COIN || 'teth'; + const label = process.env.WALLET_LABEL || 'MPCv2 Self-Custody Wallet (two-script)'; + const enterprise = process.env.ENTERPRISE || ''; + + const keychainPayloads = readJson(FILES.keychainPayloads); + const { userKeychainParams, backupKeychainParams, bitgoKeychainParams } = keychainPayloads; + + const baseCoin = bitgo.coin(coin); + const keychains = baseCoin.keychains(); + + const userKeychain = await keychains.add({ ...userKeychainParams, enterprise }); + const backupKeychain = await keychains.add({ ...backupKeychainParams, enterprise }); + const bitgoKeychain = await keychains.add({ ...bitgoKeychainParams, enterprise }); + + const walletParams = { + label, + m: 2, + n: 3, + keys: [userKeychain.id, backupKeychain.id, bitgoKeychain.id], + type: 'hot', + multisigType: 'tss', + enterprise: enterprise || undefined, + }; + + const tssSettings = await bitgo.get(bitgo.microservicesUrl('/api/v2/tss/settings')).result(); + const multisigTypeVersion = + tssSettings.coinSettings?.[baseCoin.getFamily()]?.walletCreationSettings?.multiSigTypeVersion; + let walletVersion; + if (typeof baseCoin.isEVM === 'function' && baseCoin.isEVM() && multisigTypeVersion === 'MPCv2') { + walletVersion = 5; + } + if (walletVersion) walletParams.walletVersion = walletVersion; + + const finalWalletParams = await baseCoin.supplementGenerateWallet(walletParams, { + userKeychain, + backupKeychain, + bitgoKeychain, + }); + const newWallet = await bitgo.post(baseCoin.url('/wallet/add')).send(finalWalletParams).result(); + + const walletResult = { + walletId: newWallet.id, + receiveAddress: newWallet.receiveAddress, + userKeychainId: userKeychain.id, + backupKeychainId: backupKeychain.id, + bitgoKeychainId: bitgoKeychain.id, + }; + writeJson(FILES.walletResult, walletResult); + + console.log('\n[ONLINE] Wallet created (MPCv2 two-script).'); + console.log('Wallet ID:', walletResult.walletId); + console.log('Receive address:', walletResult.receiveAddress); + console.log('Result written to', FILES.walletResult); +} + +async function main() { + const step = process.argv.find((a) => a.startsWith('--step=')) + ? process.argv.find((a) => a.startsWith('--step=')).split('=')[1] + : process.argv[process.argv.indexOf('--step') + 1]; + if (!step || !['0', '1', '2', '3', '4'].includes(step)) { + console.error('Usage: node mpc-self-custody-online.js --step 0|1|2|3|4'); + process.exit(1); + } + if (process.env.MPC_WORKSPACE_DIR) { + console.log('[ONLINE] Workspace:', process.env.MPC_WORKSPACE_DIR); + } + if (step === '0') await runStep0(); + else if (step === '1') await runStep1(); + else if (step === '2') await runStep2(); + else if (step === '3') await runStep3(); + else if (step === '4') await runStep4(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-mcp-v2/mpc-workspace-schema.js b/examples/js/self-custody-mcp-v2/mpc-workspace-schema.js new file mode 100644 index 0000000000..fc2986ba95 --- /dev/null +++ b/examples/js/self-custody-mcp-v2/mpc-workspace-schema.js @@ -0,0 +1,40 @@ +/** + * MPCv2 two-script workspace: file names and directory. + * Used by mpc-self-custody-offline.js and mpc-self-custody-online.js. + * Do NOT commit the workspace directory; it may contain sensitive state. + * + * File schema (all JSON): + * - bitgo-gpg-public-key.json: { bitgoGpgPublicKey: string } (armored; written by online, read by offline) + * - round1-payload.json: payload for POST /mpc/generatekey R1 (userGpgPublicKey, backupGpgPublicKey, userMsg1, backupMsg1) + * - round1-response.json: BitGo R1 response (sessionId, bitgoMsg1, bitgoToUserMsg2, bitgoToBackupMsg2) + * - round1-state.json: { userSessionData, backupSessionData, userGpgKey, backupGpgKey, round1BroadcastMessages } (sensitive; offline only) + * - round2-payload.json, round2-response.json, round2-state.json: same pattern + * - round3-payload.json, round3-response.json, round3-state.json: same pattern + * - keychain-payloads.json: { userKeychainParams, backupKeychainParams, bitgoKeychainParams, commonKeychain } (encryptedPrv only, no raw private) + * - wallet-result.json: { walletId, receiveAddress, userKeychainId, backupKeychainId, bitgoKeychainId } (written by online) + */ + +const path = require('path'); + +const WORKSPACE_DIR = process.env.MPC_WORKSPACE_DIR || path.join(__dirname, 'mpc-keygen-workspace'); + +const FILES = { + bitgoGpgPublicKey: 'bitgo-gpg-public-key.json', + round1Payload: 'round1-payload.json', + round1Response: 'round1-response.json', + round1State: 'round1-state.json', + round2Payload: 'round2-payload.json', + round2Response: 'round2-response.json', + round2State: 'round2-state.json', + round3Payload: 'round3-payload.json', + round3Response: 'round3-response.json', + round3State: 'round3-state.json', + keychainPayloads: 'keychain-payloads.json', + walletResult: 'wallet-result.json', +}; + +function workspacePath(filename) { + return path.join(WORKSPACE_DIR, filename); +} + +module.exports = { WORKSPACE_DIR, FILES, workspacePath }; From bd5ae31b5ed74341439696bf7261529357f88f22 Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Wed, 4 Feb 2026 00:34:42 +0700 Subject: [PATCH 03/15] add agents.md --- AGENTS.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..ab5b3d0370 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# AGENTS.md — MCP Routing Cheat Sheet + +## Golden rules +- Use **ONE primary MCP** per task +- Semantic → Physical → External +- Persist only **stable human decisions** +- Never duplicate sources of truth + +--- + +## MCP Router + +| MCP | Use when you need… | Avoid when… | +|---|---|---| +| **Serena** | Understand / modify existing code | Only file I/O | +| **filesystem** | Read/write/create files | Code semantics | +| **GKG** | Architecture & dependencies | Single symbol | +| **server-memory** | Long-term decisions & rules | Temporary state | +| **Context7** | Exact official APIs | Exploratory research | +| **Perplexity** | Broad / up-to-date web info | Docs already known | +| **sequential-thinking** | Hard design / planning / resolve complicated issues or errors | Simple Q&A | + +--- + +## Memory policy +- **Code truth** → Serena / GKG +- **Decision truth** → server-memory +- One fact = one short observation + +--- + +## Quick routing examples +- Trace logic / refactor → **Serena** +- Create ADR / doc → **filesystem** +- Map system structure → **GKG** +- Remember invariant → **server-memory** +- Check exact API → **Context7** +- Compare approaches → **Perplexity** +- Design architecture → **sequential-thinking** + From 5b1726c19a103aad969fb461af140fb42690cbdd Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Wed, 4 Feb 2026 02:20:02 +0700 Subject: [PATCH 04/15] mcpv2 ecdsa self-custody sign script --- .../mpc/create-wallet-mpcv2-script.md | 64 ++++ .../mpc/sign-transaction-mpcv2-script.md | 302 ++++++++++++++++ .../self-custody/mpc/terminology-guide.md | 29 +- .../mpc-self-custody-sign-offline.js | 322 ++++++++++++++++++ .../mpc-self-custody-sign-online.js | 233 +++++++++++++ .../mpc-sign-workspace-schema.js | 46 +++ 6 files changed, 992 insertions(+), 4 deletions(-) create mode 100644 examples/docs/self-custody/mpc/sign-transaction-mpcv2-script.md create mode 100644 examples/js/self-custody-mcp-v2/mpc-self-custody-sign-offline.js create mode 100644 examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js create mode 100644 examples/js/self-custody-mcp-v2/mpc-sign-workspace-schema.js diff --git a/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md b/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md index bbb1de93c8..94805b5b38 100644 --- a/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md +++ b/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md @@ -11,6 +11,70 @@ This guide describes creating an **MPCv2 TSS self-custody hot wallet** using **t Communication between the two scripts is **file-based** in a shared workspace directory (e.g. `mpc-keygen-workspace/`). The offline machine and online machine can be the same (for testing) or different; in production, run the offline script on an air-gapped machine and copy only the payload/response files. +## Sequence Diagram (Offline ↔ Online ↔ BitGo Express) + +```mermaid +sequenceDiagram + autonumber + participant Offline as Offline (stores keys, no network) + participant Online as Online (proxy / networked) + participant BitGo as BitGo Express + + Note over Offline,BitGo: Offline never communicates directly with BitGo Express + + %% Step 0 + rect rgb(14, 37, 57) + Note over Online,BitGo: Step 0 — Fetch BitGo configuration + Online->>+BitGo: GET TSS settings + GET client/constants (GPG public key) + BitGo-->>-Online: bitgo-gpg-public-key + Online->>Offline: Transfer bitgo-gpg-public-key.json (file / API) + end + + %% Step 1 — Round 1 + rect rgb(255, 248, 240) + Note over Offline: Step 1 — DKG round 1 (local) + Offline->>Offline: initDkg, generate GPG keys, encrypt round 1 + Offline->>Online: round1-payload.json (file / API) + Online->>+BitGo: POST /api/v2/mpc/generatekey (round MPCv2-R1) + BitGo-->>-Online: round1-response (sessionId, bitgoMsg1, P2P R2) + Online->>Offline: round1-response.json (file / API) + end + + %% Step 2 — Round 2 + rect rgb(24, 75, 24) + Note over Offline: Step 2 — DKG round 2 (local) + Offline->>Offline: handleIncomingMessages R1, generate P2P R2 + Offline->>Online: round2-payload.json + Online->>+BitGo: POST generatekey (round MPCv2-R2) + BitGo-->>-Online: round2-response (P2P R2, R3) + Online->>Offline: round2-response.json + end + + %% Step 3 — Round 3 & 4 + rect rgb(72, 23, 72) + Note over Offline: Step 3 — DKG rounds 3 & 4 (local) + Offline->>Offline: process R2/R3, generate R3 P2P + R4 broadcast + Offline->>Online: round3-payload.json + Online->>+BitGo: POST generatekey (round MPCv2-R3) + BitGo-->>-Online: round3-response (bitgoMsg4, commonKeychain) + Online->>Offline: round3-response.json + end + + %% Step 4 — Keychains & Wallet + rect rgb(22, 22, 68) + Note over Offline: Step 4 — key combine, encrypt signing material (local) + Offline->>Offline: finalize DKG, combine keys, encrypt with passphrase + Offline->>Online: keychain-payloads.json (encryptedPrv only) + Online->>+BitGo: POST /api/v2/{coin}/key (user, backup, bitgo) + BitGo-->>Online: keychain IDs + Online->>+BitGo: POST /api/v2/{coin}/wallet + BitGo-->>-Online: wallet-result (wallet ID, address) + Online->>Offline: (optional) wallet-result.json + end +``` +/End of Selection +``` + ## Workspace Files | File | Written by | Read by | Description | diff --git a/examples/docs/self-custody/mpc/sign-transaction-mpcv2-script.md b/examples/docs/self-custody/mpc/sign-transaction-mpcv2-script.md new file mode 100644 index 0000000000..a912ff5af4 --- /dev/null +++ b/examples/docs/self-custody/mpc/sign-transaction-mpcv2-script.md @@ -0,0 +1,302 @@ +# MPCv2 Self-Custody: Sign Transaction — Two-Script Flow (Offline / Online) + +This guide describes **signing a transaction** for an **MPCv2 TSS self-custody wallet** using **two separate scripts**: an **offline script** (no network) that produces signature shares from your key material, and an **online script** that sends those shares to BitGo and finalizes the transaction. Raw key material never leaves the offline environment. + +## Overview + +- **MPCv2 signing** uses a 3-round **DSG (Distributed Signing Generation)** protocol. User and BitGo participate (2-of-3 threshold); the user’s key share stays offline. +- **Offline script** (`mpc-self-custody-sign-offline.js`): Runs on an air-gapped or offline machine. Reads the **transaction request** and your **encrypted user key** (from keychain creation); produces **signature shares** for each round — they are the only data sent out. +- **Online script** (`mpc-self-custody-sign-online.js`): Runs on a network-connected machine. Creates the **TxRequest** (unsigned transaction), sends signature shares to BitGo for each round, then finalizes the transaction. +- **Workspace files**: JSON files exchanged between offline and online machines (tx request, round payloads/responses/state, sign result). + +Communication between the two scripts is **file-based** in a shared workspace directory (e.g. `mpc-sign-workspace/` or the same as keygen). In production, run the offline script on an air-gapped machine and copy only the payload/response files. + +## Sequence Diagram (Offline ↔ Online ↔ BitGo) + +```mermaid +sequenceDiagram + autonumber + participant Offline as Offline (keys, no network) + participant Online as Online (proxy / networked) + participant BitGo as BitGo API + + Note over Offline,BitGo: Offline never communicates directly with BitGo + + rect rgb(14, 37, 57) + Note over Online,BitGo: Step 0 — Create TxRequest + Online->>+BitGo: POST /wallet/.../txrequests (intent) + BitGo-->>-Online: TxRequest (txRequestId, unsignedTx) + Online->>Offline: Transfer tx-request.json + end + + rect rgb(255, 248, 240) + Note over Offline: Step 1 — DSG round 1 (local) + Offline->>Offline: Dsg.init, getSignatureShareRound1 + Offline->>Online: sign-round1-payload.json + Online->>+BitGo: POST .../transactions/0/sign (R1) + BitGo-->>-Online: TxRequest (R1 response) + Online->>Offline: sign-round1-response.json + end + + rect rgb(24, 75, 24) + Note over Offline: Step 2 — DSG round 2 (local) + Offline->>Offline: handleIncomingMessages R1, getSignatureShareRound2 + Offline->>Online: sign-round2-payload.json + Online->>+BitGo: POST .../sign (R2) + BitGo-->>-Online: TxRequest (R2 response) + Online->>Offline: sign-round2-response.json + end + + rect rgb(72, 23, 72) + Note over Offline: Step 3 — DSG round 3 (local) + Offline->>Offline: handleIncomingMessages R2, getSignatureShareRound3 + Offline->>Online: sign-round3-payload.json + Online->>+BitGo: POST .../sign (R3) + BitGo-->>-Online: TxRequest (R3 response) + Online->>+BitGo: POST .../txrequests/.../send (finalize) + BitGo-->>-Online: sign-result.json (signed tx) + end +``` + +## Workspace Files + +| File | Written by | Read by | Description | +|------|------------|---------|-------------| +| `tx-request.json` | Online (step 0) | Offline (step 1) | Full TxRequest (txRequestId, walletId, unsignedTx with signableHex, derivationPath). | +| `bitgo-gpg-public-key.json` | Online (step 0) or keygen | Offline (steps 2, 3) | BitGo public GPG key (armored). Can reuse from keygen workspace. | +| `sign-round1-payload.json` | Offline (step 1) | Online (step 1) | Signature share round 1 + user GPG public key. | +| `sign-round1-response.json` | Online (step 1) | Offline (step 2) | BitGo response (TxRequest with R1 signature shares). | +| `sign-round1-state.json` | Offline (step 1) | Offline (step 2) | **Sensitive.** Encrypted DSG session and user GPG private key; offline only. | +| `sign-round2-payload.json` | Offline (step 2) | Online (step 2) | Signature share round 2. | +| `sign-round2-response.json` | Online (step 2) | Offline (step 3) | BitGo response (TxRequest with R2 signature shares). | +| `sign-round2-state.json` | Offline (step 2) | Offline (step 3) | **Sensitive.** Encrypted DSG session; offline only. | +| `sign-round3-payload.json` | Offline (step 3) | Online (step 3) | Signature share round 3. | +| `sign-result.json` | Online (step 3) | User | Final TxRequest or signed transaction result. | + +Set `MPC_WORKSPACE_DIR` (or `MPC_SIGN_WORKSPACE_DIR` if using a separate signing schema) to use a custom workspace path. + +## Steps (Order of Execution) + +1. **Online step 0** (machine with network): Create TxRequest via `wallet.prebuildTransaction(...)` (or `prebuildTxWithIntent`); write `tx-request.json`. Optionally fetch BitGo GPG public key → `bitgo-gpg-public-key.json` (or reuse from keygen). Copy `tx-request.json` (and `bitgo-gpg-public-key.json` if needed) to the offline machine. +2. **Offline step 1**: Read `tx-request.json`, encrypted user key (from `keychain-payloads.json` or env/file), and passphrase; run DSG round 1; write `sign-round1-payload.json` and `sign-round1-state.json`. Copy `sign-round1-payload.json` to the online machine. +3. **Online step 1**: Read `sign-round1-payload.json`; POST signature share R1 to BitGo; write `sign-round1-response.json`. Copy `sign-round1-response.json` to the offline machine. +4. **Offline step 2**: Read `sign-round1-response.json`, `sign-round1-state.json`, and BitGo GPG public key; run DSG round 2; write `sign-round2-payload.json` and `sign-round2-state.json`. Copy `sign-round2-payload.json` to the online machine. +5. **Online step 2**: Read `sign-round2-payload.json`; POST R2; write `sign-round2-response.json`. Copy `sign-round2-response.json` to the offline machine. +6. **Offline step 3**: Read `sign-round2-response.json`, `sign-round2-state.json`, and BitGo GPG public key; run DSG round 3; write `sign-round3-payload.json`. Copy `sign-round3-payload.json` to the online machine. +7. **Online step 3**: Read `sign-round3-payload.json`; POST R3; call `sendTxRequest` to finalize; write `sign-result.json`. + +## Environment Variables + +- **Offline**: `WALLET_PASSPHRASE` (required), `MPC_WORKSPACE_DIR` or `MPC_SIGN_WORKSPACE_DIR` (optional), `COIN` (e.g. `teth`) for hash function. Encrypted user key: from `keychain-payloads.json` (userKeychainParams.encryptedPrv) in workspace or from a separate file/env. +- **Online**: `BITGO_ACCESS_TOKEN` (required), `COIN` (e.g. `teth`), `WALLET_ID`, `MPC_WORKSPACE_DIR` or `MPC_SIGN_WORKSPACE_DIR` (optional), `BITGO_ENV` (e.g. `test`), `BITGO_CUSTOM_ROOT_URI` (optional). Tx params: e.g. `RECIPIENT_ADDRESS`, `AMOUNT` (or pass via file) for building the TxRequest in step 0. + +## Commands (from repo root) + +```bash +# Online machine (with network) +export BITGO_ACCESS_TOKEN=your_token +export COIN=teth +export WALLET_ID=your_mpcv2_wallet_id +export RECIPIENT_ADDRESS=0x... +export AMOUNT=1000000000000000 +# Optional: same workspace as keygen, or set MPC_SIGN_WORKSPACE_DIR +export MPC_WORKSPACE_DIR=./examples/js/self-custody-mcp-v2/mpc-sign-workspace + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js --step 0 +# Copy tx-request.json (and bitgo-gpg-public-key.json if needed) to offline machine + +# Offline machine (no network) +export WALLET_PASSPHRASE=your_passphrase +export COIN=teth +# Ensure keychain-payloads.json (or user encrypted key) and tx-request.json are in workspace + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-sign-offline.js --step 1 +# Copy sign-round1-payload.json to online machine + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js --step 1 +# Copy sign-round1-response.json to offline machine + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-sign-offline.js --step 2 +# Copy sign-round2-payload.json to online machine + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js --step 2 +# Copy sign-round2-response.json to offline machine + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-sign-offline.js --step 3 +# Copy sign-round3-payload.json to online machine + +node ./examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js --step 3 +# sign-result.json contains the finalized transaction +``` + +## Step-by-Step Flow + +### Step 0: Create TxRequest (Online) + +**Script:** `mpc-self-custody-sign-online.js --step 0` + +**What it does:** +- Optionally fetches BitGo GPG public key and writes `bitgo-gpg-public-key.json` (or reuses from keygen workspace). +- Builds the transaction intent (recipients, amount, etc.) and calls `wallet.prebuildTransaction(...)` (TSS wallets use `prebuildTxWithIntent` internally) to create a **TxRequest**. +- Writes the full TxRequest (txRequestId, walletId, unsignedTx with signableHex and derivationPath) to `tx-request.json`. + +**API Endpoint:** +- `POST {baseApiUrl}/wallet/{walletId}/txrequests` (with intent) + +**Output:** `tx-request.json`, optionally `bitgo-gpg-public-key.json` + +**Next:** Transfer `tx-request.json` (and BitGo GPG key if needed) to the offline machine. + +--- + +### Step 1: DSG Round 1 (Offline) + +**Script:** `mpc-self-custody-sign-offline.js --step 1` + +**What it does:** +- Reads `tx-request.json` and extracts signableHex and derivationPath from the unsigned transaction. +- Decrypts the user key (from `keychain-payloads.json` or env/file) with `WALLET_PASSPHRASE`. +- Computes the message hash (e.g. keccak256 for ETH) and creates a DSG session (`DklsDsg.Dsg`, `init()`). +- Produces signature share round 1 via `getSignatureShareRoundOne`; generates a one-time GPG key pair for this signing session. +- Encrypts the DSG session and user GPG private key with the passphrase (adata = hash:derivationPath) and saves them in state. + +**Operations:** +```javascript +const { hashBuffer, derivationPath } = getHashAndDerivationPath(txRequest); +const userKeyShare = Buffer.from(decryptedPrv, 'base64'); +const userSigner = new DklsDsg.Dsg(userKeyShare, 0, derivationPath, hashBuffer); +const userSignerBroadcastMsg1 = await userSigner.init(); +const signatureShareRound1 = await getSignatureShareRoundOne(userSignerBroadcastMsg1, userGpgKey); +const encryptedRound1Session = bitgo.encrypt({ input: session, password: walletPassphrase, adata }); +``` + +**Output:** `sign-round1-payload.json` (signatureShareRound1, userGpgPubKey), `sign-round1-state.json` (encryptedRound1Session, encryptedUserGpgPrvKey) + +**Next:** Transfer `sign-round1-payload.json` to the online machine. + +--- + +### Step 1: Send Signature Share R1 (Online) + +**Script:** `mpc-self-custody-sign-online.js --step 1` + +**What it does:** +- Reads `sign-round1-payload.json` and `tx-request.json` (for walletId, txRequestId). +- POSTs the signature share to BitGo (`sendSignatureShareV2`: POST `/wallet/{walletId}/txrequests/{txRequestId}/transactions/0/sign`, type `ecdsaMpcV2`). +- Writes the returned TxRequest (including BitGo’s R1 signature share) to `sign-round1-response.json`. + +**API Endpoint:** +- `POST {baseApiUrl}/wallet/{walletId}/txrequests/{txRequestId}/transactions/0/sign` + - Body: `{ type: 'ecdsaMpcV2', signatureShares, signerGpgPublicKey }` + +**Output:** `sign-round1-response.json` + +**Next:** Transfer `sign-round1-response.json` to the offline machine. + +--- + +### Step 2: DSG Round 2 (Offline) + +**Script:** `mpc-self-custody-sign-offline.js --step 2` + +**What it does:** +- Restores DSG session and user GPG key from `sign-round1-state.json` (decrypt with passphrase). +- Verifies and parses BitGo’s R1 response from `sign-round1-response.json` (`verifyBitGoMessagesAndSignaturesRoundOne`). +- Runs `handleIncomingMessages` for BitGo’s broadcast, then for BitGo’s P2P messages, to produce user→BitGo messages for round 2 and 3. +- Builds signature share round 2 via `getSignatureShareRoundTwo`; encrypts and saves the new session state. + +**Operations:** +```javascript +const round1Session = bitgo.decrypt({ input: encryptedRound1Session, password: walletPassphrase }); +await userSigner.setSession(round1Session); +const userToBitGoMessagesRound2 = userSigner.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: deserializedMessages.broadcastMessages, +}); +const userToBitGoMessagesRound3 = userSigner.handleIncomingMessages({ + p2pMessages: deserializedMessages.p2pMessages, + broadcastMessages: [], +}); +const signatureShareRound2 = await getSignatureShareRoundTwo( + userToBitGoMessagesRound2, + userToBitGoMessagesRound3, + userGpgKey, + bitgoGpgKey +); +``` + +**Output:** `sign-round2-payload.json`, `sign-round2-state.json` + +**Next:** Transfer `sign-round2-payload.json` to the online machine. + +--- + +### Step 2: Send Signature Share R2 (Online) + +**Script:** `mpc-self-custody-sign-online.js --step 2` + +**What it does:** +- Reads `sign-round2-payload.json`; POSTs signature share R2 to BitGo. +- Writes the TxRequest (with R2 signature shares) to `sign-round2-response.json`. + +**API Endpoint:** Same as step 1, with R2 signature share. + +**Output:** `sign-round2-response.json` + +**Next:** Transfer `sign-round2-response.json` to the offline machine. + +--- + +### Step 3: DSG Round 3 (Offline) + +**Script:** `mpc-self-custody-sign-offline.js --step 3` + +**What it does:** +- Restores DSG session from `sign-round2-state.json`. +- Verifies and parses BitGo’s R2 response (`verifyBitGoMessagesAndSignaturesRoundTwo`) to get BitGo’s R3 P2P messages. +- Runs `handleIncomingMessages` for those P2P messages to produce user→BitGo round 4 (final) messages. +- Builds signature share round 3 via `getSignatureShareRoundThree`. + +**Operations:** +```javascript +const userToBitGoMessagesRound4 = userSigner.handleIncomingMessages({ + p2pMessages: deserializedBitGoToUserMessagesRound3.p2pMessages, + broadcastMessages: [], +}); +const signatureShareRound3 = await getSignatureShareRoundThree( + userToBitGoMessagesRound4, + userGpgKey, + bitgoGpgKey +); +``` + +**Output:** `sign-round3-payload.json` + +**Next:** Transfer `sign-round3-payload.json` to the online machine. + +--- + +### Step 3: Send Signature Share R3 and Finalize (Online) + +**Script:** `mpc-self-custody-sign-online.js --step 3` + +**What it does:** +- Reads `sign-round3-payload.json`; POSTs signature share R3 to BitGo. +- Calls `sendTxRequest` to finalize the transaction (POST `/wallet/{walletId}/txrequests/{txRequestId}/send` or equivalent). +- Writes the result (final TxRequest or signed tx) to `sign-result.json`. + +**API Endpoints:** +- `POST .../transactions/0/sign` (R3) +- `POST .../txrequests/.../send` (finalize) + +**Output:** `sign-result.json` + +**Result:** Transaction is signed and submitted; you can use the result for broadcast or tracking. + +## Security Notes + +- **Offline script** must never call `bitgo.get()` or `bitgo.post()`; it only reads the TxRequest and BitGo public key from files and uses `bitgo.encrypt()` / `bitgo.decrypt()` locally. + - Your **decrypted user key share** is only used in memory to produce signature shares; it never leaves the offline machine. +- **State files** (`sign-round1-state.json`, `sign-round2-state.json`) contain passphrase-encrypted session and GPG private key; keep them only on the offline machine. +- **Payload files** (`sign-round1/2/3-payload.json`) contain only signature shares (and public GPG key); safe to transfer to the online machine. +- 2-of-3 threshold: signing requires the user (offline) and BitGo (online); the backup key share is not used in this flow unless you configure a different signer set. diff --git a/examples/docs/self-custody/mpc/terminology-guide.md b/examples/docs/self-custody/mpc/terminology-guide.md index 45d742545f..3182ba9578 100644 --- a/examples/docs/self-custody/mpc/terminology-guide.md +++ b/examples/docs/self-custody/mpc/terminology-guide.md @@ -129,13 +129,31 @@ This document explains the cryptography and product terms used in the [ETH MPC s - **What it is:** During **signing**, each participant uses their (combined) signing material to compute a **signature share**—a partial contribution to the final signature. These shares are then combined (e.g. by BitGo) into one standard ECDSA signature. A **key share** is used in **key generation**; a **signature share** is used only in **signing**. - **Why it matters:** You never send your key share (p-share) to BitGo. You only send **signature shares** for each transaction. From signature shares, the full signature can be computed without anyone ever having the full private key. -- **In our context:** The local signer (e.g. Express) loads your key, produces signature shares (e.g. K, MuDelta, S or MPCv2 rounds), and sends those to BitGo; BitGo combines with its share to broadcast the signed transaction. +- **In our context:** The local signer (e.g. Express) loads your key, produces signature shares (e.g. K, MuDelta, S or MPCv2 rounds), and sends those to BitGo; BitGo combines with its share to broadcast the signed transaction. In **MPCv2 ECDSA**, signing has **3 rounds**; each round each party sends one signature share (round 1, 2, 3). -### 5.5 Wallet (in this context) +### 5.5 TxRequest (transaction request) + +- **What it is:** An object that represents an **unsigned transaction** and the state of the TSS signing process. It contains: `txRequestId`, `walletId`, the **unsigned transaction** (e.g. `signableHex`, `derivationPath`), and after each signing round the **signature shares** from each participant. +- **Why it matters:** For TSS wallets, you do not build a raw tx and sign it in one go. Instead, you create a **TxRequest** (prebuild), then run a multi-round signing protocol; the TxRequest is updated after each round with new signature shares until the transaction is complete. +- **In our context:** The online script creates a TxRequest via `wallet.prebuildTransaction(...)` (or `prebuildTxWithIntent`). The offline script reads the TxRequest from a file (`tx-request.json`), produces signature shares for each round, and the online script sends those shares to BitGo; the TxRequest is passed back and forth (as payload/response files) until signing is finalized. + +### 5.6 DSG (Distributed Signing Generation) / signing round + +- **What it is:** **DSG** is the multi-round phase of **threshold signing**: each participant uses their key share and the transaction hash to produce a **signature share** for that round. For **MPCv2 ECDSA**, there are **3 signing rounds**. Each round: the user (offline) produces a signature share, the online machine sends it to BitGo, and BitGo responds with its share and any messages needed for the next round. +- **Why it matters:** The full signature is never computed in one place; each round only exchanges partial (signature) shares. This keeps the key material on the offline machine while still producing a valid on-chain signature. +- **In our context:** The two-script signing flow (offline + online) implements DSG: offline steps 1–3 correspond to rounds 1–3; each offline step reads the previous round’s response and state, produces the next signature share, and writes a payload file for the online script to POST to BitGo. + +### 5.7 Wallet (in this context) - **What it is:** The BitGo object that represents one 2-of-3 TSS wallet: it links the three keychains (user, backup, BitGo) and holds metadata (label, wallet id, etc.). The “wallet” is the container; the keys live in the keychains. - **Why it matters:** “Create the wallet” means create this container on BitGo’s side so you can later create addresses, build transactions, and sign (using your keychains). +### 5.8 Workspace (signing) + +- **What it is:** A **directory** used in the two-script (offline/online) signing flow. It holds the files exchanged between the offline and online machines: e.g. `tx-request.json`, `sign-round1-payload.json`, `sign-round1-response.json`, `sign-round1-state.json`, and so on for rounds 2 and 3, plus `sign-result.json`. +- **Why it matters:** The offline machine never talks to the network; the online machine never sees raw keys. They communicate only by copying files in and out of this workspace (e.g. via USB). The workspace can be the same directory as keygen or a separate signing directory. +- **In our context:** Set `MPC_WORKSPACE_DIR` or `MPC_SIGN_WORKSPACE_DIR` to point to this directory; the scripts read and write the filenames defined in the signing flow doc. + --- ## Level 6: Supporting Terms (Encryption and Keys) @@ -162,7 +180,7 @@ Read in this order if you want a clear build-up: 2. **Level 2:** Multi-signature → Threshold → Key / key pair 3. **Level 3:** MPC → TSS → ECDSA 4. **Level 4:** Share → p-share → n-share → Key combine → Common keychain -5. **Level 5:** Keychain (BitGo) → User/backup/BitGo keychain → Encrypted signing material → Signature share → Wallet +5. **Level 5:** Keychain (BitGo) → User/backup/BitGo keychain → Encrypted signing material → Signature share → TxRequest → DSG / signing round → Wallet → Workspace (signing) 6. **Level 6:** GPG → Passphrase --- @@ -186,6 +204,9 @@ Read in this order if you want a clear build-up: | **Common keychain** | The public value that identifies the joint key; same for all three parties. | | **Keychain (BitGo)** | The record for one of the three keys (user, backup, BitGo) in the wallet. | | **Encrypted signing material** | Your combined signing material encrypted with a passphrase for storage. | -| **Signature share** | A partial signature produced during signing; combined to form the final signature. | +| **Signature share** | A partial signature produced during signing; combined to form the final signature. In MPCv2 ECDSA there are 3 rounds; each round each party sends one signature share. | +| **TxRequest** | Object holding the unsigned transaction and per-round signature shares; used for TSS prebuild and step-by-step signing. | +| **DSG / signing round** | Distributed Signing Generation: the multi-round phase (3 rounds in MPCv2 ECDSA) where each party produces a signature share per round. | +| **Workspace (signing)** | Directory of files (tx-request, sign-round payload/response/state, sign-result) exchanged between offline and online machines when signing. | | **GPG** | Standard used here to encrypt n-shares so only the recipient can read them. | | **Passphrase** | Password used to encrypt/decrypt your key material locally. | diff --git a/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-offline.js b/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-offline.js new file mode 100644 index 0000000000..54d82dec8c --- /dev/null +++ b/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-offline.js @@ -0,0 +1,322 @@ +/** + * MPCv2 Self-Custody SIGN: OFFLINE Script (No Network) + * + * Produces signature shares for MPCv2 ECDSA signing (3-round DSG). Runs on an air-gapped + * or offline machine. Reads TxRequest and encrypted user key from workspace; writes + * round payloads and state. Raw key material never leaves this machine. + * + * Steps: + * 1: DSG round 1 — init signer, produce signature share R1, encrypt session + GPG key + * 2: DSG round 2 — restore session, process BitGo R1 response, produce R2 share + * 3: DSG round 3 — restore session, process BitGo R2 response, produce R3 share + * + * Usage: + * WALLET_PASSPHRASE=... COIN=teth node mpc-self-custody-sign-offline.js --step 1 + * node mpc-self-custody-sign-offline.js --step 2 + * node mpc-self-custody-sign-offline.js --step 3 + * + * Prerequisites: + * - tx-request.json (from online step 0) + * - User encrypted key: keychain-payloads.json (userKeychainParams.encryptedPrv) in workspace, or ENCRYPTED_USER_KEY env + * - bitgo-gpg-public-key.json (for steps 2, 3; can reuse from keygen workspace) + * - WALLET_PASSPHRASE, COIN (e.g. teth) + */ +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./mpc-sign-workspace-schema'); + +const openpgp = require('openpgp'); +openpgp.config.rejectCurves = new Set(); + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[OFFLINE] Wrote ${name}`); +} + +function getHashAndDerivationPath(txRequest) { + if (!txRequest.transactions || txRequest.transactions.length !== 1) { + throw new Error('TxRequest must have exactly one transaction'); + } + const signableHex = txRequest.transactions[0].unsignedTx.signableHex; + const derivationPath = txRequest.transactions[0].unsignedTx.derivationPath; + let hash; + try { + const BitGo = require('bitgo').BitGo; + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + const coin = bitgo.coin(process.env.COIN || 'teth'); + hash = coin.getHashFunction(); + } catch (err) { + const createKeccakHash = require('keccak'); + hash = createKeccakHash('keccak256'); + } + const hashBuffer = hash.update(Buffer.from(signableHex, 'hex')).digest(); + return { hashBuffer, derivationPath }; +} + +function getEncryptedUserKey() { + if (process.env.ENCRYPTED_USER_KEY) return process.env.ENCRYPTED_USER_KEY; + const keychainPayloadsPath = workspacePath(FILES.keychainPayloads); + if (!fs.existsSync(keychainPayloadsPath)) { + throw new Error('Missing keychain-payloads.json and ENCRYPTED_USER_KEY not set'); + } + const payloads = JSON.parse(fs.readFileSync(keychainPayloadsPath, 'utf8')); + if (!payloads.userKeychainParams || !payloads.userKeychainParams.encryptedPrv) { + throw new Error('keychain-payloads.json must contain userKeychainParams.encryptedPrv'); + } + return payloads.userKeychainParams.encryptedPrv; +} + +async function runStep1() { + const { DklsDsg, DklsTypes } = require('@bitgo/sdk-lib-mpc'); + const BitGo = require('bitgo').BitGo; + const { generateGPGKeyPair, DKLSMethods } = require('@bitgo/sdk-core'); + const { getSignatureShareRoundOne } = DKLSMethods; + + const passphrase = process.env.WALLET_PASSPHRASE || ''; + if (!passphrase) throw new Error('WALLET_PASSPHRASE required'); + + const txRequest = readJson(FILES.txRequest); + const { hashBuffer, derivationPath } = getHashAndDerivationPath(txRequest); + const adata = `${hashBuffer.toString('hex')}:${derivationPath}`; + + const encryptedPrv = getEncryptedUserKey(); + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + const prv = bitgo.decrypt({ input: encryptedPrv, password: passphrase }); + const userKeyShare = Buffer.from(prv, 'base64'); + + const userGpgKey = await generateGPGKeyPair('secp256k1'); + const userSigner = new DklsDsg.Dsg(userKeyShare, 0, derivationPath, hashBuffer); + const userSignerBroadcastMsg1 = await userSigner.init(); + const signatureShareRound1 = await getSignatureShareRoundOne(userSignerBroadcastMsg1, userGpgKey); + + const session = userSigner.getSession(); + const encryptedRound1Session = bitgo.encrypt({ input: session, password: passphrase, adata }); + const encryptedUserGpgPrvKey = bitgo.encrypt({ + input: userGpgKey.privateKey, + password: passphrase, + adata, + }); + + writeJson(FILES.signRound1Payload, { + signatureShareRound1: { + from: signatureShareRound1.from, + to: signatureShareRound1.to, + share: signatureShareRound1.share, + }, + userGpgPubKey: userGpgKey.publicKey, + }); + writeJson(FILES.signRound1State, { + encryptedRound1Session, + encryptedUserGpgPrvKey, + }); + console.log('[OFFLINE] Step 1 done. Copy sign-round1-payload.json to online machine, run online step 1.'); +} + +async function runStep2() { + const { DklsDsg, DklsTypes } = require('@bitgo/sdk-lib-mpc'); + const BitGo = require('bitgo').BitGo; + const { DKLSMethods } = require('@bitgo/sdk-core'); + const { getSignatureShareRoundTwo, verifyBitGoMessagesAndSignaturesRoundOne } = DKLSMethods; + const pgp = require('openpgp'); + + const passphrase = process.env.WALLET_PASSPHRASE || ''; + if (!passphrase) throw new Error('WALLET_PASSPHRASE required'); + + const txRequest = readJson(FILES.signRound1Response); + const { hashBuffer, derivationPath } = getHashAndDerivationPath(txRequest); + const adata = `${hashBuffer.toString('hex')}:${derivationPath}`; + + const config = readJson(FILES.bitgoGpgPublicKey); + const bitgoPublicGpgKey = config.bitgoGpgPublicKey; + if (!bitgoPublicGpgKey) throw new Error('bitgo-gpg-public-key.json required'); + + const signRound1State = readJson(FILES.signRound1State); + const { encryptedRound1Session, encryptedUserGpgPrvKey } = signRound1State; + + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + const round1Session = bitgo.decrypt({ input: encryptedRound1Session, password: passphrase }); + const cypherJson = JSON.parse(encryptedRound1Session); + if (decodeURIComponent(cypherJson.adata) !== decodeURIComponent(adata)) { + throw new Error('Adata does not match encrypted round1 session'); + } + + const bitgoGpgKey = await pgp.readKey({ armoredKey: bitgoPublicGpgKey }); + const userDecryptedKey = await pgp.readKey({ + armoredKey: bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: passphrase }), + }); + const userGpgKey = { + privateKey: userDecryptedKey.armor(), + publicKey: userDecryptedKey.toPublic().armor(), + }; + + const signatureShares = txRequest.transactions?.[0]?.signatureShares; + if (!signatureShares || signatureShares.length === 0) { + throw new Error('Missing signature shares in round 1 response'); + } + const lastShare = signatureShares[signatureShares.length - 1]; + const parsedBitGoRound1 = JSON.parse(lastShare.share); + if (parsedBitGoRound1.type !== 'round1Output') { + throw new Error('Unexpected signature share type: ' + (parsedBitGoRound1.type || 'unknown')); + } + const serializedBitGoToUserMessagesRound1 = await verifyBitGoMessagesAndSignaturesRoundOne( + parsedBitGoRound1, + userGpgKey, + bitgoGpgKey + ); + const deserializedMessages = DklsTypes.deserializeMessages(serializedBitGoToUserMessagesRound1); + + const encryptedPrv = getEncryptedUserKey(); + const prv = bitgo.decrypt({ input: encryptedPrv, password: passphrase }); + const userKeyShare = Buffer.from(prv, 'base64'); + const userSigner = new DklsDsg.Dsg(userKeyShare, 0, derivationPath, hashBuffer); + await userSigner.setSession(round1Session); + + const userToBitGoMessagesRound2 = userSigner.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: deserializedMessages.broadcastMessages, + }); + const userToBitGoMessagesRound3 = userSigner.handleIncomingMessages({ + p2pMessages: deserializedMessages.p2pMessages, + broadcastMessages: [], + }); + const signatureShareRound2 = await getSignatureShareRoundTwo( + userToBitGoMessagesRound2, + userToBitGoMessagesRound3, + userGpgKey, + bitgoGpgKey + ); + + const session = userSigner.getSession(); + const encryptedRound2Session = bitgo.encrypt({ input: session, password: passphrase, adata }); + + writeJson(FILES.signRound2Payload, { + signatureShareRound2: { + from: signatureShareRound2.from, + to: signatureShareRound2.to, + share: signatureShareRound2.share, + }, + }); + writeJson(FILES.signRound2State, { encryptedRound2Session }); + console.log('[OFFLINE] Step 2 done. Copy sign-round2-payload.json to online machine, run online step 2.'); +} + +async function runStep3() { + const { DklsDsg, DklsTypes } = require('@bitgo/sdk-lib-mpc'); + const BitGo = require('bitgo').BitGo; + const { DKLSMethods } = require('@bitgo/sdk-core'); + const { getSignatureShareRoundThree, verifyBitGoMessagesAndSignaturesRoundTwo } = DKLSMethods; + const pgp = require('openpgp'); + + const passphrase = process.env.WALLET_PASSPHRASE || ''; + if (!passphrase) throw new Error('WALLET_PASSPHRASE required'); + + const txRequest = readJson(FILES.signRound2Response); + const { hashBuffer, derivationPath } = getHashAndDerivationPath(txRequest); + const adata = `${hashBuffer.toString('hex')}:${derivationPath}`; + + const config = readJson(FILES.bitgoGpgPublicKey); + const bitgoPublicGpgKey = config.bitgoGpgPublicKey; + if (!bitgoPublicGpgKey) throw new Error('bitgo-gpg-public-key.json required'); + + const signRound2State = readJson(FILES.signRound2State); + const { encryptedRound2Session } = signRound2State; + const signRound1State = readJson(FILES.signRound1State); + const { encryptedUserGpgPrvKey } = signRound1State; + + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + const cypherJson = JSON.parse(encryptedRound2Session); + if (decodeURIComponent(cypherJson.adata) !== decodeURIComponent(adata)) { + throw new Error('Adata does not match encrypted round2 session'); + } + const round2Session = bitgo.decrypt({ input: encryptedRound2Session, password: passphrase }); + + const bitgoGpgKey = await pgp.readKey({ armoredKey: bitgoPublicGpgKey }); + const userDecryptedKey = await pgp.readKey({ + armoredKey: bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: passphrase }), + }); + const userGpgKey = { + privateKey: userDecryptedKey.armor(), + publicKey: userDecryptedKey.toPublic().armor(), + }; + + const signatureShares = txRequest.transactions?.[0]?.signatureShares; + if (!signatureShares || signatureShares.length === 0) { + throw new Error('Missing signature shares in round 2 response'); + } + const lastShare = signatureShares[signatureShares.length - 1]; + const parsedBitGoRound2 = JSON.parse(lastShare.share); + if (parsedBitGoRound2.type !== 'round2Output') { + throw new Error('Unexpected signature share type: ' + (parsedBitGoRound2.type || 'unknown')); + } + const serializedBitGoToUserMessagesRound3 = await verifyBitGoMessagesAndSignaturesRoundTwo( + parsedBitGoRound2, + userGpgKey, + bitgoGpgKey + ); + const deserializedBitGoToUserMessagesRound3 = DklsTypes.deserializeMessages({ + p2pMessages: serializedBitGoToUserMessagesRound3.p2pMessages, + broadcastMessages: [], + }); + + const encryptedPrv = getEncryptedUserKey(); + const prv = bitgo.decrypt({ input: encryptedPrv, password: passphrase }); + const userKeyShare = Buffer.from(prv, 'base64'); + const userSigner = new DklsDsg.Dsg(userKeyShare, 0, derivationPath, hashBuffer); + await userSigner.setSession(round2Session); + + const userToBitGoMessagesRound4 = userSigner.handleIncomingMessages({ + p2pMessages: deserializedBitGoToUserMessagesRound3.p2pMessages, + broadcastMessages: [], + }); + const signatureShareRound3 = await getSignatureShareRoundThree( + userToBitGoMessagesRound4, + userGpgKey, + bitgoGpgKey + ); + + writeJson(FILES.signRound3Payload, { + signatureShareRound3: { + from: signatureShareRound3.from, + to: signatureShareRound3.to, + share: signatureShareRound3.share, + }, + }); + console.log('[OFFLINE] Step 3 done. Copy sign-round3-payload.json to online machine, run online step 3.'); +} + +async function main() { + const step = + process.argv.find((a) => a.startsWith('--step=')) + ? process.argv.find((a) => a.startsWith('--step=')).split('=')[1] + : process.argv[process.argv.indexOf('--step') + 1]; + if (!step || !['1', '2', '3'].includes(step)) { + console.error('Usage: node mpc-self-custody-sign-offline.js --step 1|2|3'); + process.exit(1); + } + if (process.env.MPC_WORKSPACE_DIR || process.env.MPC_SIGN_WORKSPACE_DIR) { + console.log('[OFFLINE] Workspace:', WORKSPACE_DIR); + } + if (step === '1') await runStep1(); + else if (step === '2') await runStep2(); + else if (step === '3') await runStep3(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js b/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js new file mode 100644 index 0000000000..f58ca6f09f --- /dev/null +++ b/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js @@ -0,0 +1,233 @@ +/** + * MPCv2 Self-Custody SIGN: ONLINE Script (Requires Network) + * + * Creates the TxRequest (unsigned transaction), sends signature shares to BitGo for each + * DSG round, and finalizes the transaction. Never handles raw key material. + * + * Steps: + * 0: Create TxRequest (prebuildTransaction), optionally fetch BitGo GPG key; write tx-request.json + * 1: POST signature share R1; write sign-round1-response.json + * 2: POST signature share R2; write sign-round2-response.json + * 3: POST signature share R3, sendTxRequest (finalize); write sign-result.json + * + * Usage: + * BITGO_ACCESS_TOKEN=... COIN=teth WALLET_ID=... node mpc-self-custody-sign-online.js --step 0 + * node mpc-self-custody-sign-online.js --step 1 + * node mpc-self-custody-sign-online.js --step 2 + * node mpc-self-custody-sign-online.js --step 3 + * + * Environment (step 0): BITGO_ACCESS_TOKEN, COIN, WALLET_ID; RECIPIENT_ADDRESS, AMOUNT (for tx build) + * Environment (steps 1–3): BITGO_ACCESS_TOKEN, COIN (workspace has tx-request and payloads) + */ +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./mpc-sign-workspace-schema'); + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[ONLINE] Wrote ${name}`); +} + +function getBitGo() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + const opts = { + env: process.env.BITGO_ENV || 'test', + accessToken, + }; + if (process.env.BITGO_CUSTOM_ROOT_URI) opts.customRootURI = process.env.BITGO_CUSTOM_ROOT_URI; + const bitgo = new BitGo(opts); + bitgo.authenticateWithAccessToken({ accessToken }); + return bitgo; +} + +async function runStep0() { + const bitgo = getBitGo(); + const coinId = process.env.COIN || 'teth'; + const walletId = process.env.WALLET_ID || ''; + if (!walletId) throw new Error('WALLET_ID required for step 0'); + + const coin = bitgo.coin(coinId); + const wallet = await coin.wallets().get({ id: walletId }); + + const recipientAddress = process.env.RECIPIENT_ADDRESS || ''; + const amount = process.env.AMOUNT || '0'; + if (!recipientAddress) throw new Error('RECIPIENT_ADDRESS required for step 0'); + const recipients = [{ address: recipientAddress, amount }]; + + const prebuildResult = await wallet.prebuildTransaction({ + recipients, + apiVersion: 'full', + }); + const txRequestId = prebuildResult.txRequestId; + const reqWalletId = prebuildResult.walletId || walletId; + + const { commonTssMethods } = require('@bitgo/sdk-core'); + const { getTxRequest } = commonTssMethods; + const RequestTracer = require('@bitgo/sdk-core').RequestTracer; + const txRequest = await getTxRequest(bitgo, reqWalletId, txRequestId, new RequestTracer()); + + writeJson(FILES.txRequest, txRequest); + + const bitgoGpgPath = workspacePath(FILES.bitgoGpgPublicKey); + if (!fs.existsSync(bitgoGpgPath)) { + const EcdsaMPCv2Utils = require('@bitgo/sdk-core').EcdsaMPCv2Utils; + const baseCoin = bitgo.coin(coinId); + const mpcUtils = new EcdsaMPCv2Utils(bitgo, baseCoin); + const enterprise = process.env.ENTERPRISE || ''; + const bitgoPublicGpgKey = ( + (await mpcUtils.getBitgoGpgPubkeyBasedOnFeatureFlags(enterprise, true)) ?? mpcUtils.bitgoMPCv2PublicGpgKey + ).armor(); + writeJson(FILES.bitgoGpgPublicKey, { bitgoGpgPublicKey: bitgoPublicGpgKey }); + } + + console.log('[ONLINE] Step 0 done. Copy tx-request.json (and bitgo-gpg-public-key.json if needed) to offline machine.'); +} + +async function runStep1() { + const bitgo = getBitGo(); + const { commonTssMethods, RequestType } = require('@bitgo/sdk-core'); + const { sendSignatureShareV2 } = commonTssMethods; + + const txRequest = readJson(FILES.txRequest); + const walletId = txRequest.walletId; + const txRequestId = txRequest.txRequestId; + + const payload = readJson(FILES.signRound1Payload); + const signatureShareRound1 = payload.signatureShareRound1; + const userGpgPubKey = payload.userGpgPubKey; + if (!signatureShareRound1 || !userGpgPubKey) throw new Error('sign-round1-payload must have signatureShareRound1 and userGpgPubKey'); + + const coinId = process.env.COIN || 'teth'; + const coin = bitgo.coin(coinId); + const RequestTracer = require('@bitgo/sdk-core').RequestTracer; + + const round1TxRequest = await sendSignatureShareV2( + bitgo, + walletId, + txRequestId, + [signatureShareRound1], + RequestType.tx, + 'ecdsa', + userGpgPubKey, + undefined, + 'MPCv2', + new RequestTracer() + ); + + writeJson(FILES.signRound1Response, round1TxRequest); + console.log('[ONLINE] Step 1 done. Copy sign-round1-response.json to offline machine.'); +} + +async function runStep2() { + const bitgo = getBitGo(); + const { commonTssMethods, RequestType } = require('@bitgo/sdk-core'); + const { sendSignatureShareV2 } = commonTssMethods; + + const txRequest = readJson(FILES.txRequest); + const walletId = txRequest.walletId; + const txRequestId = txRequest.txRequestId; + + const payload = readJson(FILES.signRound2Payload); + const signatureShareRound2 = payload.signatureShareRound2; + if (!signatureShareRound2) throw new Error('sign-round2-payload must have signatureShareRound2'); + + const round1Payload = readJson(FILES.signRound1Payload); + const userGpgPubKey = round1Payload.userGpgPubKey; + if (!userGpgPubKey) throw new Error('sign-round1-payload must have userGpgPubKey'); + + const RequestTracer = require('@bitgo/sdk-core').RequestTracer; + + const round2TxRequest = await sendSignatureShareV2( + bitgo, + walletId, + txRequestId, + [signatureShareRound2], + RequestType.tx, + 'ecdsa', + userGpgPubKey, + undefined, + 'MPCv2', + new RequestTracer() + ); + + writeJson(FILES.signRound2Response, round2TxRequest); + console.log('[ONLINE] Step 2 done. Copy sign-round2-response.json to offline machine.'); +} + +async function runStep3() { + const bitgo = getBitGo(); + const { commonTssMethods, RequestType } = require('@bitgo/sdk-core'); + const { sendSignatureShareV2, sendTxRequest } = commonTssMethods; + + const txRequest = readJson(FILES.txRequest); + const walletId = txRequest.walletId; + const txRequestId = txRequest.txRequestId; + + const payload = readJson(FILES.signRound3Payload); + const signatureShareRound3 = payload.signatureShareRound3; + if (!signatureShareRound3) throw new Error('sign-round3-payload must have signatureShareRound3'); + + const round1Payload = readJson(FILES.signRound1Payload); + const userGpgPubKey = round1Payload.userGpgPubKey; + if (!userGpgPubKey) throw new Error('sign-round1-payload must have userGpgPubKey'); + + const RequestTracer = require('@bitgo/sdk-core').RequestTracer; + const reqId = new RequestTracer(); + + await sendSignatureShareV2( + bitgo, + walletId, + txRequestId, + [signatureShareRound3], + RequestType.tx, + 'ecdsa', + userGpgPubKey, + undefined, + 'MPCv2', + reqId + ); + + const finalTxRequest = await sendTxRequest(bitgo, walletId, txRequestId, RequestType.tx, reqId); + writeJson(FILES.signResult, finalTxRequest); + console.log('[ONLINE] Step 3 done. Sign result written to sign-result.json'); +} + +async function main() { + const step = + process.argv.find((a) => a.startsWith('--step=')) + ? process.argv.find((a) => a.startsWith('--step=')).split('=')[1] + : process.argv[process.argv.indexOf('--step') + 1]; + if (!step || !['0', '1', '2', '3'].includes(step)) { + console.error('Usage: node mpc-self-custody-sign-online.js --step 0|1|2|3'); + process.exit(1); + } + if (process.env.MPC_WORKSPACE_DIR || process.env.MPC_SIGN_WORKSPACE_DIR) { + console.log('[ONLINE] Workspace:', WORKSPACE_DIR); + } + if (step === '0') await runStep0(); + else if (step === '1') await runStep1(); + else if (step === '2') await runStep2(); + else if (step === '3') await runStep3(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-mcp-v2/mpc-sign-workspace-schema.js b/examples/js/self-custody-mcp-v2/mpc-sign-workspace-schema.js new file mode 100644 index 0000000000..14081d199a --- /dev/null +++ b/examples/js/self-custody-mcp-v2/mpc-sign-workspace-schema.js @@ -0,0 +1,46 @@ +/** + * MPCv2 two-script SIGNING workspace: file names and directory. + * Used by mpc-self-custody-sign-offline.js and mpc-self-custody-sign-online.js. + * Do NOT commit the workspace directory; it may contain sensitive state. + * + * File schema (all JSON): + * - tx-request.json: full TxRequest (txRequestId, walletId, unsignedTx with signableHex, derivationPath); written by online step 0 + * - bitgo-gpg-public-key.json: { bitgoGpgPublicKey: string } (armored; can reuse from keygen workspace) + * - sign-round1-payload.json: { signatureShareRound1, userGpgPubKey }; written by offline step 1 + * - sign-round1-response.json: TxRequest after R1 (from BitGo); written by online step 1 + * - sign-round1-state.json: { encryptedRound1Session, encryptedUserGpgPrvKey } (sensitive; offline only) + * - sign-round2-payload.json: { signatureShareRound2 }; written by offline step 2 + * - sign-round2-response.json: TxRequest after R2; written by online step 2 + * - sign-round2-state.json: { encryptedRound2Session } (sensitive; offline only) + * - sign-round3-payload.json: { signatureShareRound3 }; written by offline step 3 + * - sign-result.json: final TxRequest or signed tx result; written by online step 3 + * + * Optional: use same directory as keygen (MPC_WORKSPACE_DIR) so keychain-payloads.json is available for user encrypted key. + */ + +const path = require('path'); + +const WORKSPACE_DIR = + process.env.MPC_SIGN_WORKSPACE_DIR || + process.env.MPC_WORKSPACE_DIR || + path.join(__dirname, 'mpc-sign-workspace'); + +const FILES = { + txRequest: 'tx-request.json', + bitgoGpgPublicKey: 'bitgo-gpg-public-key.json', + keychainPayloads: 'keychain-payloads.json', + signRound1Payload: 'sign-round1-payload.json', + signRound1Response: 'sign-round1-response.json', + signRound1State: 'sign-round1-state.json', + signRound2Payload: 'sign-round2-payload.json', + signRound2Response: 'sign-round2-response.json', + signRound2State: 'sign-round2-state.json', + signRound3Payload: 'sign-round3-payload.json', + signResult: 'sign-result.json', +}; + +function workspacePath(filename) { + return path.join(WORKSPACE_DIR, filename); +} + +module.exports = { WORKSPACE_DIR, FILES, workspacePath }; From a4009b4786e540d3c6af0dc1d0615baa9db89f85 Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Wed, 4 Feb 2026 02:21:00 +0700 Subject: [PATCH 05/15] update readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d5441019bf..c111f41a23 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,5 @@ The purpose is to document and research BitGo cryptography implementation. 1. The origin document is [here](README.bitgo.md) 2. MPC related research documents: - [MPC terminologies](examples/docs/self-custody/mpc/terminology-guide.md) - - [MPC v2 examples](examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md) + - [MPC v2 create wallet example](examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md) + - [MPC v2 sign tx example](examples/docs/self-custody/mpc/sign-transaction-mpcv2-script.md) From 252b0d4d428f78a6e4ee5f2dad990dc2609d374e Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Wed, 4 Feb 2026 08:22:07 +0700 Subject: [PATCH 06/15] create wallet multisig --- .../multisig/create-wallet-multisig-script.md | 126 ++++++++++++++ .../multisig/terminology-guide.md | 145 ++++++++++++++++ .../multisig-self-custody-offline.js | 117 +++++++++++++ .../multisig-self-custody-online.js | 158 ++++++++++++++++++ .../multisig-workspace-schema.js | 34 ++++ 5 files changed, 580 insertions(+) create mode 100644 examples/docs/self-custody/multisig/create-wallet-multisig-script.md create mode 100644 examples/docs/self-custody/multisig/terminology-guide.md create mode 100644 examples/js/self-custody-multisig/multisig-self-custody-offline.js create mode 100644 examples/js/self-custody-multisig/multisig-self-custody-online.js create mode 100644 examples/js/self-custody-multisig/multisig-workspace-schema.js diff --git a/examples/docs/self-custody/multisig/create-wallet-multisig-script.md b/examples/docs/self-custody/multisig/create-wallet-multisig-script.md new file mode 100644 index 0000000000..00aadb2e89 --- /dev/null +++ b/examples/docs/self-custody/multisig/create-wallet-multisig-script.md @@ -0,0 +1,126 @@ +# Multisig Self-Custody Wallet: Two-Script Flow (Offline / Online) + +This guide describes creating an **on-chain 2-of-3 multisig self-custody wallet** using **two separate scripts**: an **offline script** that generates user and backup keypairs and key signatures (no network), and an **online script** that creates the BitGo keychain, adds keychains, and creates the wallet. User and backup **private keys** exist only on the offline machine; **encryptedPrv is never sent to BitGo** — only public keys and key signatures are sent. + +## Overview + +- **On-chain multisig** is 2-of-3 with three **full keypairs** (public + private). User and backup each hold one private key locally; BitGo holds the third. This is **not** TSS/MPC (no key shares). +- **Offline script** (`multisig-self-custody-offline.js`): Runs on an air-gapped or offline machine. Creates user and backup keypairs, encrypts private keys with a passphrase, and signs backup and BitGo public keys with the user key to produce **key signatures**. Only `pub` and `source` are written to the params files that are copied to online; **encryptedPrv is never sent to BitGo**. Encrypted keys are written to `local-encrypted-keys.json` (keep offline; use for local signing). +- **Online script** (`multisig-self-custody-online.js`): Runs on a network-connected machine. Step 0: creates BitGo keychain and writes `bitgo-keychain.json`. Step 1: adds user and backup keychains (pub + source only), then POSTs to create the wallet with keys and keySignatures. +- **Workspace**: A directory of JSON files exchanged between offline and online. Keychain params contain only pub + source; encrypted keys stay in `local-encrypted-keys.json` on the offline machine. + +## Sequence Diagram + +```mermaid +sequenceDiagram + participant Online + participant BitGo + participant Offline + + Note over Online,BitGo: Step 0 - Create BitGo keychain + Online->>BitGo: POST /key (source: bitgo) + BitGo-->>Online: bitgo keychain (id, pub) + Online->>Offline: bitgo-keychain.json + + Note over Offline: Step 1 - Generate user and backup keys (local) + Offline->>Offline: keychains().create() x2, encrypt prv, signMessage + Offline->>Online: user/backup keychain params, key-signatures.json + + Note over Online,BitGo: Step 1 - Add keychains and create wallet + Online->>BitGo: POST /key (user), POST /key (backup) + Online->>BitGo: POST /wallet/add (keys, keySignatures) + BitGo-->>Online: wallet (id, receiveAddress, etc.) + Online->>Online: wallet-result.json +``` + +## Workspace Files + +| File | Written by | Read by | Description | +|------|------------|---------|-------------| +| `bitgo-keychain.json` | Online (step 0) | Offline (step 1) | BitGo keychain: `id`, `pub`. | +| `user-keychain-params.json` | Offline (step 1) | Online (step 1) | User keychain params: `pub`, `source: 'user'` (no encryptedPrv). | +| `backup-keychain-params.json` | Offline (step 1) | Online (step 1) | Backup keychain params: `pub`, `source: 'backup'` (no encryptedPrv). | +| `key-signatures.json` | Offline (step 1) | Online (step 1) | Key signatures: `backup`, `bitgo` (hex). User signs backup.pub and bitgo.pub. | +| `local-encrypted-keys.json` | Offline (step 1) | — | **Offline only.** Encrypted user/backup keys for local signing; do not copy to online. | +| `wallet-result.json` | Online (step 1) | User | Wallet ID, receive address, keychain IDs. | + +Set `MULTISIG_WORKSPACE_DIR` (or `MPC_WORKSPACE_DIR`) to use a custom workspace path; default is `multisig-workspace` inside the script directory. + +## Steps (Order of Execution) + +1. **Online step 0** (machine with network): Create BitGo keychain via `keychains().createBitGo({ enterprise })`, write `bitgo-keychain.json` (id, pub). Copy this file to the offline machine. +2. **Offline step 1**: Read `bitgo-keychain.json` and `WALLET_PASSPHRASE`. Create user and backup keypairs with `keychains().create()`, encrypt private keys with passphrase, compute key signatures (user signs backup.pub and bitgo.pub). Write `user-keychain-params.json`, `backup-keychain-params.json` (pub + source only; no encryptedPrv), `key-signatures.json`, and `local-encrypted-keys.json` (encrypted keys — keep offline only, use for local signing). +3. **Online step 1**: Read user/backup params and key-signatures. `keychains().add(userKeychainParams)`, `keychains().add(backupKeychainParams)`. Build wallet params (keys, keySignatures, label, m: 2, n: 3), call `supplementGenerateWallet` if needed, POST `/wallet/add`, write `wallet-result.json`. + +## Environment Variables + +- **Offline**: `WALLET_PASSPHRASE` (required for step 1), `COIN` (e.g. `tbtc`, `teth`), `MULTISIG_WORKSPACE_DIR` (optional). +- **Online**: `BITGO_ACCESS_TOKEN` (required), `COIN` (e.g. `tbtc`, `teth`), `WALLET_LABEL`, `ENTERPRISE` (optional), `BITGO_ENV` (e.g. `test`), `MULTISIG_WORKSPACE_DIR` (optional). + +## Commands (from repo root) + +```bash +# Online machine (with network) +export BITGO_ACCESS_TOKEN=your_token +export COIN=tbtc +export WALLET_LABEL="My Multisig Wallet" +export ENTERPRISE=optional_enterprise_id + +node ./examples/js/self-custody-multisig/multisig-self-custody-online.js --step 0 +# Copy bitgo-keychain.json to offline machine. + +# Offline machine (no network) +export WALLET_PASSPHRASE=your_passphrase +export COIN=tbtc +node ./examples/js/self-custody-multisig/multisig-self-custody-offline.js --step 1 +# Copy user-keychain-params.json, backup-keychain-params.json, key-signatures.json to online machine (do NOT copy local-encrypted-keys.json). + +# Online machine +node ./examples/js/self-custody-multisig/multisig-self-custody-online.js --step 1 +# wallet-result.json is written; wallet is created. +``` + +## Step-by-Step Flow + +### Online Step 0 — Create BitGo keychain + +- **Script**: `multisig-self-custody-online.js --step 0` +- **Input**: Env: `BITGO_ACCESS_TOKEN`, `COIN`, optional `ENTERPRISE`. +- **Operations**: Authenticate BitGo, `baseCoin.keychains().createBitGo({ enterprise })`, write `bitgo-keychain.json` with `id` and `pub`. +- **Output**: `bitgo-keychain.json` +- **API**: `POST /key` with `source: 'bitgo'`. + +### Offline Step 1 — Generate user and backup keys, encrypt, key signatures + +- **Script**: `multisig-self-custody-offline.js --step 1` +- **Input**: `bitgo-keychain.json`, env: `WALLET_PASSPHRASE`, `COIN`. +- **Operations**: Load BitGo SDK (no token). `baseCoin.keychains().create()` for user and backup (get `pub` + `prv`). `bitgo.encrypt()` for both keys. `baseCoin.signMessage()` for key signatures. Build user params `{ pub, source: 'user' }`, backup params `{ pub, source: 'backup' }` (no encryptedPrv). Write keychain params, key-signatures, and local-encrypted-keys.json (encrypted keys; keep offline). +- **Output**: `user-keychain-params.json`, `backup-keychain-params.json`, `key-signatures.json`, `local-encrypted-keys.json`. No raw `prv` on disk. +- **API**: None (offline). + +### Online Step 1 — Add keychains and create wallet + +- **Script**: `multisig-self-custody-online.js --step 1` +- **Input**: `user-keychain-params.json`, `backup-keychain-params.json`, `key-signatures.json`, `bitgo-keychain.json`; env: `BITGO_ACCESS_TOKEN`, `COIN`, `WALLET_LABEL`, optional `ENTERPRISE`. +- **Operations**: `keychains().add(userKeychainParams)`, `keychains().add(backupKeychainParams)`. Build `walletParams`: `keys = [userKeychainId, backupKeychainId, bitgoKeychainId]`, `keySignatures`, `label`, `m: 2`, `n: 3`. Call `baseCoin.supplementGenerateWallet(walletParams, keychains)` if needed. `bitgo.post(baseCoin.url('/wallet/add')).send(finalWalletParams).result()`. +- **Output**: `wallet-result.json` (walletId, receiveAddress, keychain IDs). +- **API**: `POST /key` (user), `POST /key` (backup), `POST /wallet/add`. + +## Security Notes + +- **Encrypted keys never sent to BitGo.** Keychain params contain only `pub` and `source`. Encrypted user/backup keys are stored in `local-encrypted-keys.json` on the offline machine. For spending you must sign from a **local signer** (e.g. BitGo Express with key loaded from that file + passphrase). The 2-of-3 is: your local signer + BitGo key. +- **Offline script never calls the network.** It uses the BitGo SDK only for `bitgo.coin(COIN)`, `baseCoin.keychains().create()`, `bitgo.encrypt()`, and `baseCoin.signMessage()`. No `bitgo.get()` or `bitgo.post()`. +- **Private keys** exist only in memory during the offline step; only passphrase-encrypted keys are written to `local-encrypted-keys.json` (keychain params have no encryptedPrv). +- **Backup** `local-encrypted-keys.json` and your passphrase securely. Loss of both user and backup key material can make the wallet unrecoverable. +- Do not commit the workspace directory; do not copy `local-encrypted-keys.json` to the online machine. + +## Coin Support + +- `keychains().create()` and `signMessage()` depend on the base coin (e.g. BTC vs ETH). Use `COIN` (e.g. `tbtc`, `teth`, `tsol`) appropriate for your chain. Test with the target coin. +- Key format: add keychain uses `pub` (and optionally `encryptedPrv`; this flow omits it). Format of `pub` (xpub vs address) is determined by the coin; the script uses the format returned by `baseCoin.keychains().create()`. +- Some coins require extra wallet params (e.g. `walletVersion`). The online script calls `baseCoin.supplementGenerateWallet(walletParams, keychains)` before POST so coin-specific params are included. + +## Reference + +- Implementation: `modules/sdk-core/src/bitgo/wallet/wallets.ts` (`generateWallet`: userKeychainPromise, backupKeychainPromise, createBitGo, keySignatures, supplementGenerateWallet). +- Keychains: `modules/sdk-core/src/bitgo/keychain/keychains.ts` (`create`, `add`, `createBitGo`, `createBackup`). diff --git a/examples/docs/self-custody/multisig/terminology-guide.md b/examples/docs/self-custody/multisig/terminology-guide.md new file mode 100644 index 0000000000..b83177fa85 --- /dev/null +++ b/examples/docs/self-custody/multisig/terminology-guide.md @@ -0,0 +1,145 @@ +# On-Chain Multisig: Terminology Guide + +This document explains the terms used in the [multisig self-custody wallet script](create-wallet-multisig-script.md). Concepts are ordered **from foundation to advanced**. This guide is for **on-chain multisig** (2-of-3 with full keypairs), not TSS/MPC. For TSS/MPC terms, see [MPC terminology](../mpc/terminology-guide.md). + +--- + +## Level 1: Basic Cryptography + +### 1.1 Private key + +- **What it is:** A secret value that only you should know. It proves ownership and authorizes signing. +- **Why it matters:** In on-chain multisig, each of the three parties (user, backup, BitGo) holds one **full** private key. Whoever has a private key can sign for that key; there are no "shares" of one key. +- **In our context:** The user and backup private keys are generated on your offline machine and never leave it in raw form. They are encrypted with a passphrase before being sent to BitGo for storage. + +### 1.2 Public key + +- **What it is:** A value derived from the private key that can be shared publicly. Used to derive addresses and verify signatures. +- **Why it matters:** The wallet’s receive address is derived from the combined multisig script (e.g. 2-of-3); each participant has a public key that others need to verify signatures and build the multisig output. +- **In our context:** Each keychain has a `pub` (public key). For HD coins this is often an xpub; for some coins it may be an address. Format is determined by the coin. + +### 1.3 Signing (digital signature) + +- **What it is:** Using a private key to create a signature on a message. The signature proves that the holder of that private key approved that message. +- **Why it matters:** To spend from a 2-of-3 multisig wallet, two of the three key holders must sign the transaction. Each signer uses their **own** full private key. +- **In our context:** During wallet creation, the **user** signs the backup and BitGo **public keys** with the user private key to produce **key signatures**. This proves to BitGo that the user controls the user key when adding the wallet. + +--- + +## Level 2: Multi-Signature and Key Pairs + +### 2.1 Multi-signature (multisig) + +- **What it is:** A setup where **more than one** key must approve an action (e.g. a transaction). For example "2 out of 3 keys must sign." +- **Why it matters:** Reduces single-point-of-failure risk; common for custody and shared wallets. +- **In our context:** BitGo on-chain multisig is **2-of-3**: user, backup, and BitGo each hold one key; any two can sign. + +### 2.2 Threshold (e.g. 2-of-3) + +- **What it is:** The rule "at least **m** of **n** parties must participate." Here, m=2, n=3. +- **Why it matters:** You get redundancy (one party can be offline) while still requiring two approvals. +- **In our context:** Wallet is created with `m: 2`, `n: 3`. + +### 2.3 Key pair + +- **What it is:** A private key and its corresponding public key together. +- **Why it matters:** In on-chain multisig, each of the three participants has one **independent** key pair. There is no single "shared" key split into shares. +- **In our context:** User key pair, backup key pair, and BitGo key pair are created separately. User and backup key pairs are created on your offline machine. + +--- + +## Level 3: On-Chain Multisig vs TSS + +### 3.1 On-chain multisig + +- **What it is:** Multisig where each participant holds a **full** key pair (one private key, one public key per party). The blockchain’s native multisig (e.g. P2SH, P2WSH for Bitcoin; multisig contract for Ethereum) requires **m** signatures from **n** distinct public keys. +- **Why it matters:** Simple model: three keys, each fully controlled by one party. No MPC protocol; signing is "each party signs with their key." +- **In our context:** User and backup each have one private key (often called "p-share" in casual language here, meaning "the private key you hold locally"). BitGo holds the third key. Two of the three must sign to spend. + +### 3.2 TSS (Threshold Signature Scheme) + +- **What it is:** A scheme where one **logical** key is split into **shares**; no single party has the full private key. Signing is done by a multi-party protocol that never reconstructs the full key. +- **Why it matters:** Different from on-chain multisig: in TSS there is one key split across parties; in on-chain multisig there are three separate keys. Do not confuse terminology (e.g. "p-share" in TSS means a key share; in multisig we use "p-share" only informally for "your local private key"). +- **In our context:** This guide and the multisig scripts are for **on-chain** multisig only. For TSS/MPC, see the [MPC docs](../mpc/) and [MPC terminology](../mpc/terminology-guide.md). + +--- + +## Level 4: Keychains and Key Material + +### 4.1 xpub / xprv (extended public / private key) + +- **What it is:** For HD (hierarchical deterministic) coins, the extended public key (xpub) and extended private key (xprv) allow deriving many addresses from one seed. Not all coins use xpub/xprv; some use a single address as "pub." +- **Why it matters:** BitGo keychains store `pub` (and optionally `encryptedPrv`). The format of `pub` is coin-specific (xpub for UTXO, sometimes address for account-based). +- **In our context:** The offline script uses `baseCoin.keychains().create()`, which returns `pub` and `prv` in the format expected by that coin. We store only `pub` and passphrase-encrypted `prv` in keychain params. + +### 4.2 Keychain (BitGo) + +- **What it is:** In BitGo’s API, a **keychain** is the **record** for one of the three keys in a 2-of-3 wallet. It contains: key id, `pub`, and optionally `encryptedPrv` (encrypted private key). +- **Why it matters:** "Create BitGo keychain" means create the BitGo-held key record. "Add user keychain" means register the user’s key (pub + encryptedPrv) with BitGo. +- **In our context:** We have three keychains: user, backup, BitGo. User and backup keychains are created offline (keypair + encrypt), then added via the online script. BitGo keychain is created by BitGo in online step 0. + +### 4.3 User / backup / BitGo keychain + +- **What they are:** The three keychains in the wallet: one for the user (you), one for the backup (you or another device), one for BitGo (co-signing service). +- **Why it matters:** User and backup private keys are under your control (generated and encrypted offline). BitGo’s key is created and held by BitGo. + +### 4.4 Encrypted prv (encrypted private key) + +- **What it is:** The private key encrypted with a **passphrase** (e.g. via BitGo’s encrypt API) so it can be stored or sent to BitGo without exposing the raw key. +- **Why it matters:** BitGo stores `encryptedPrv` for user and backup keychains. To sign later, you decrypt locally with the passphrase; BitGo never sees the decrypted key. +- **In our context:** The offline script encrypts user and backup private keys with `WALLET_PASSPHRASE` and writes only `encryptedPrv` in the keychain params files. + +--- + +## Level 5: Key Signatures and Workspace + +### 5.1 Key signatures + +- **What it is:** When creating the wallet, the **user** signs the **backup** and **BitGo** public keys with the user’s private key. These signatures are sent as `keySignatures` (backup, bitgo) so BitGo can verify that the user key is in your possession. +- **Why it matters:** BitGo requires proof that the keychains being added are controlled by you. Key signatures provide that proof without sending the raw user private key. +- **In our context:** Offline step 1 computes `backup: signMessage(userPrv, backupPub).toString('hex')` and `bitgo: signMessage(userPrv, bitgoPub).toString('hex')`, written to `key-signatures.json`. + +### 5.2 Wallet (in this context) + +- **What it is:** The BitGo object that represents one 2-of-3 wallet: it links the three keychains and holds metadata (label, wallet id, receive address, etc.). +- **Why it matters:** "Create the wallet" means POST `/wallet/add` with keys (keychain IDs), keySignatures, label, m, n. The result is the wallet id and receive address. + +### 5.3 Workspace (multisig) + +- **What it is:** A **directory** used in the two-script (offline/online) flow. It holds the JSON files exchanged between offline and online machines: `bitgo-keychain.json`, `user-keychain-params.json`, `backup-keychain-params.json`, `key-signatures.json`, `wallet-result.json`. +- **Why it matters:** The offline machine never talks to the network; the online machine never sees raw private keys. They communicate by copying files in and out of this workspace (e.g. USB). Set `MULTISIG_WORKSPACE_DIR` to point to this directory. + +--- + +## Quick Reference: Dependency Order + +1. **Level 1:** Private key → Public key → Signing +2. **Level 2:** Multi-signature → Threshold → Key pair +3. **Level 3:** On-chain multisig vs TSS +4. **Level 4:** xpub/xprv → Keychain (BitGo) → User/backup/BitGo keychain → Encrypted prv +5. **Level 5:** Key signatures → Wallet → Workspace + +--- + +## One-Sentence Glossary + +| Term | One sentence | +|------|----------------| +| **Private key** | The secret that proves ownership and authorizes signing; must never be shared. | +| **Public key** | The public value derived from the private key; used to derive addresses and verify signatures. | +| **Signing** | Creating a cryptographic signature on a message using the private key. | +| **Multisig** | Requiring more than one key to approve an action (e.g. 2 of 3). | +| **Threshold** | The rule "at least m of n parties must participate" (e.g. 2-of-3). | +| **Key pair** | A private key and its corresponding public key. | +| **On-chain multisig** | Multisig where each party holds a full key pair; the chain requires m-of-n signatures. | +| **TSS** | Threshold Signature Scheme: one key split into shares; see [MPC terminology](../mpc/terminology-guide.md) if using TSS. | +| **xpub / xprv** | Extended public/private key (HD); format of keychain pub/prv depends on coin. | +| **Keychain (BitGo)** | The record for one of the three keys (user, backup, BitGo) in the wallet. | +| **Encrypted prv** | Private key encrypted with a passphrase for storage or transmission. | +| **Key signatures** | User signs backup.pub and bitgo.pub with user.prv to prove key ownership when creating the wallet. | +| **Wallet** | The BitGo object linking the three keychains (keys, keySignatures, label, m, n). | +| **Workspace (multisig)** | Directory of files (bitgo-keychain, user/backup params, key-signatures, wallet-result) exchanged between offline and online machines. | + +--- + +For **TSS/MPC** concepts (shares, p-share, n-share, DKG, key combine, etc.), see [MPC terminology guide](../mpc/terminology-guide.md). diff --git a/examples/js/self-custody-multisig/multisig-self-custody-offline.js b/examples/js/self-custody-multisig/multisig-self-custody-offline.js new file mode 100644 index 0000000000..dced7e6b48 --- /dev/null +++ b/examples/js/self-custody-multisig/multisig-self-custody-offline.js @@ -0,0 +1,117 @@ +/** + * Multisig (on-chain 2-of-3) Self-Custody Wallet: OFFLINE Script (No Network) + * + * This script implements the OFFLINE portion of creating an on-chain multisig self-custody wallet. + * It generates user and backup keypairs locally and produces keychain params and key signatures. + * encryptedPrv is never sent to BitGo: only pub + source are written to the params files copied to online. + * Encrypted keys are written to local-encrypted-keys.json (KEEP OFFLINE — use for local signing only). + * + * What this script does (step 1 only): + * - Reads bitgo-keychain.json (BitGo key id and pub) + * - Creates user keypair: baseCoin.keychains().create() -> userPub, userPrv + * - Creates backup keypair: baseCoin.keychains().create() -> backupPub, backupPrv + * - Encrypts userPrv and backupPrv with WALLET_PASSPHRASE (bitgo.encrypt) + * - Computes key signatures: user signs backup.pub and bitgo.pub (hex) + * - Writes: user-keychain-params.json, backup-keychain-params.json (pub + source only), key-signatures.json, + * and local-encrypted-keys.json (encrypted keys for local signing — do not copy to online). + * + * Security: + * - No network calls. Raw private keys never written to disk; only encrypted keys in local-encrypted-keys.json. + * - BitGo never receives encryptedPrv; you must sign from a local signer for spends. + * + * Usage: + * WALLET_PASSPHRASE="your-passphrase" COIN=tbtc node multisig-self-custody-offline.js --step 1 + * + * Prerequisites: + * - bitgo-keychain.json in workspace (from online script --step 0) + * - WALLET_PASSPHRASE, COIN environment variables + */ +require('dotenv').config(); +const fs = require('fs'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./multisig-workspace-schema'); + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[OFFLINE] Wrote ${name}`); +} + +async function runStep1() { + const passphrase = process.env.WALLET_PASSPHRASE || ''; + if (!passphrase) throw new Error('WALLET_PASSPHRASE required for step 1'); + + const coin = process.env.COIN || 'tbtc'; + const BitGo = require('bitgo').BitGo; + // No access token - we only use coin(), keychains().create(), encrypt(), signMessage() + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + const baseCoin = bitgo.coin(coin); + + const bitgoKeychain = readJson(FILES.bitgoKeychain); + const bitgoPub = bitgoKeychain.pub; + if (!bitgoPub) throw new Error('bitgo-keychain.json must contain pub'); + + const keychains = baseCoin.keychains(); + const userKeypair = keychains.create(); + const backupKeypair = keychains.create(); + + const userPrv = userKeypair.prv; + const backupPrv = backupKeypair.prv; + if (!userPrv || !backupPrv) throw new Error('keychains.create() must return prv'); + + const userEncryptedPrv = bitgo.encrypt({ input: userPrv, password: passphrase }); + const backupEncryptedPrv = bitgo.encrypt({ input: backupPrv, password: passphrase }); + + const keySignatures = { + backup: (await baseCoin.signMessage({ prv: userPrv }, backupKeypair.pub)).toString('hex'), + bitgo: (await baseCoin.signMessage({ prv: userPrv }, bitgoPub)).toString('hex'), + }; + + const userKeychainParams = { pub: userKeypair.pub, source: 'user' }; + const backupKeychainParams = { pub: backupKeypair.pub, source: 'backup' }; + + writeJson(FILES.userKeychainParams, userKeychainParams); + writeJson(FILES.backupKeychainParams, backupKeychainParams); + writeJson(FILES.keySignatures, keySignatures); + + const localEncryptedKeysPath = workspacePath('local-encrypted-keys.json'); + fs.writeFileSync( + localEncryptedKeysPath, + JSON.stringify({ userEncryptedPrv, backupEncryptedPrv }, null, 0), + { mode: 0o600 } + ); + console.log('[OFFLINE] Wrote local-encrypted-keys.json (KEEP OFFLINE — do not copy to online; use for local signing).'); + + console.log('[OFFLINE] Step 1 done. Copy user-keychain-params.json, backup-keychain-params.json, key-signatures.json to online machine and run multisig-self-custody-online.js --step 1.'); +} + +async function main() { + const step = process.argv.find((a) => a.startsWith('--step=')) + ? process.argv.find((a) => a.startsWith('--step=')).split('=')[1] + : process.argv[process.argv.indexOf('--step') + 1]; + if (!step || step !== '1') { + console.error('Usage: node multisig-self-custody-offline.js --step 1'); + process.exit(1); + } + if (process.env.MULTISIG_WORKSPACE_DIR) { + console.log('[OFFLINE] Workspace:', process.env.MULTISIG_WORKSPACE_DIR); + } + await runStep1(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-multisig/multisig-self-custody-online.js b/examples/js/self-custody-multisig/multisig-self-custody-online.js new file mode 100644 index 0000000000..36deefaa6e --- /dev/null +++ b/examples/js/self-custody-multisig/multisig-self-custody-online.js @@ -0,0 +1,158 @@ +/** + * Multisig (on-chain 2-of-3) Self-Custody Wallet: ONLINE Script (Requires Network) + * + * This script implements the ONLINE portions of creating an on-chain multisig self-custody wallet. + * It creates the BitGo keychain, adds user and backup keychains, and creates the wallet. + * + * Step 0: Create BitGo keychain (keychains().createBitGo({ enterprise })), write bitgo-keychain.json. + * Step 1: Read user/backup keychain params and key-signatures; add user and backup keychains; + * build wallet params (keys, keySignatures, label, m: 2, n: 3); supplementGenerateWallet + * if needed; POST /wallet/add; write wallet-result.json. + * + * Usage: + * BITGO_ACCESS_TOKEN="..." COIN=tbtc node multisig-self-custody-online.js --step 0 + * (Copy bitgo-keychain.json to offline machine) + * BITGO_ACCESS_TOKEN="..." COIN=tbtc WALLET_LABEL="My Wallet" node multisig-self-custody-online.js --step 1 + * + * Environment: + * Required: BITGO_ACCESS_TOKEN, COIN (e.g. tbtc, teth) + * Optional: WALLET_LABEL, ENTERPRISE, BITGO_ENV, MULTISIG_WORKSPACE_DIR + */ +require('dotenv').config(); +const fs = require('fs'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./multisig-workspace-schema'); + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[ONLINE] Wrote ${name}`); +} + +async function runStep0() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + + const bitgoOptions = { + env: process.env.BITGO_ENV || 'test', + accessToken, + }; + if (process.env.BITGO_CUSTOM_ROOT_URI) { + bitgoOptions.customRootURI = process.env.BITGO_CUSTOM_ROOT_URI; + } + + const bitgo = new BitGo(bitgoOptions); + bitgo.authenticateWithAccessToken({ accessToken }); + + const coin = process.env.COIN || 'tbtc'; + const enterprise = process.env.ENTERPRISE || ''; + const baseCoin = bitgo.coin(coin); + const keychains = baseCoin.keychains(); + + const bitgoKeychain = await keychains.createBitGo({ enterprise }); + const bitgoKeychainFile = { + id: bitgoKeychain.id, + pub: bitgoKeychain.pub, + }; + writeJson(FILES.bitgoKeychain, bitgoKeychainFile); + console.log('[ONLINE] Step 0 done. Copy bitgo-keychain.json to offline machine and run multisig-self-custody-offline.js --step 1.'); +} + +async function runStep1() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + + const bitgoOptions = { + env: process.env.BITGO_ENV || 'test', + accessToken, + }; + if (process.env.BITGO_CUSTOM_ROOT_URI) { + bitgoOptions.customRootURI = process.env.BITGO_CUSTOM_ROOT_URI; + } + + const bitgo = new BitGo(bitgoOptions); + bitgo.authenticateWithAccessToken({ accessToken }); + + const coin = process.env.COIN || 'tbtc'; + const label = process.env.WALLET_LABEL || 'Multisig Self-Custody Wallet (two-script)'; + const enterprise = process.env.ENTERPRISE || ''; + + const baseCoin = bitgo.coin(coin); + const keychains = baseCoin.keychains(); + + const userKeychainParams = readJson(FILES.userKeychainParams); + const backupKeychainParams = readJson(FILES.backupKeychainParams); + const keySignatures = readJson(FILES.keySignatures); + const bitgoKeychainFile = readJson(FILES.bitgoKeychain); + + const userKeychain = await keychains.add({ ...userKeychainParams, enterprise }); + const backupKeychain = await keychains.add({ ...backupKeychainParams, enterprise }); + + const walletParams = { + label, + m: 2, + n: 3, + keys: [userKeychain.id, backupKeychain.id, bitgoKeychainFile.id], + keySignatures, + }; + if (enterprise) { + walletParams.enterprise = enterprise; + } + + const keychainsTriplet = { + userKeychain, + backupKeychain, + bitgoKeychain: { id: bitgoKeychainFile.id, pub: bitgoKeychainFile.pub }, + }; + const finalWalletParams = await baseCoin.supplementGenerateWallet(walletParams, keychainsTriplet); + + const newWallet = await bitgo.post(baseCoin.url('/wallet/add')).send(finalWalletParams).result(); + + const walletResult = { + walletId: newWallet.id, + receiveAddress: newWallet.receiveAddress, + userKeychainId: userKeychain.id, + backupKeychainId: backupKeychain.id, + bitgoKeychainId: bitgoKeychainFile.id, + }; + writeJson(FILES.walletResult, walletResult); + + console.log('\n[ONLINE] Wallet created (on-chain multisig two-script).'); + console.log('Wallet ID:', walletResult.walletId); + console.log('Receive address:', walletResult.receiveAddress); + console.log('Result written to', FILES.walletResult); +} + +async function main() { + const step = process.argv.find((a) => a.startsWith('--step=')) + ? process.argv.find((a) => a.startsWith('--step=')).split('=')[1] + : process.argv[process.argv.indexOf('--step') + 1]; + if (!step || !['0', '1'].includes(step)) { + console.error('Usage: node multisig-self-custody-online.js --step 0|1'); + process.exit(1); + } + if (process.env.MULTISIG_WORKSPACE_DIR) { + console.log('[ONLINE] Workspace:', process.env.MULTISIG_WORKSPACE_DIR); + } + if (step === '0') await runStep0(); + else if (step === '1') await runStep1(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-multisig/multisig-workspace-schema.js b/examples/js/self-custody-multisig/multisig-workspace-schema.js new file mode 100644 index 0000000000..c1e14168ce --- /dev/null +++ b/examples/js/self-custody-multisig/multisig-workspace-schema.js @@ -0,0 +1,34 @@ +/** + * Multisig (on-chain 2-of-3) two-script workspace: file names and directory. + * Used by multisig-self-custody-offline.js and multisig-self-custody-online.js. + * Do NOT commit the workspace directory; it may contain sensitive state. + * + * File schema (all JSON): + * - bitgo-keychain.json: { id, pub } — BitGo keychain (written by online step 0, read by offline step 1) + * - user-keychain-params.json: { pub, source: 'user' } — params for keychains().add (written by offline; no encryptedPrv) + * - backup-keychain-params.json: { pub, source: 'backup' } — params for keychains().add (written by offline; no encryptedPrv) + * - key-signatures.json: { backup, bitgo } — hex signatures (user signs backup.pub and bitgo.pub; written by offline) + * - local-encrypted-keys.json: { userEncryptedPrv, backupEncryptedPrv } — OFFLINE ONLY; do not copy to online; use for local signing. + * - wallet-result.json: { walletId, receiveAddress, userKeychainId, backupKeychainId, bitgoKeychainId } (written by online step 1) + */ + +const path = require('path'); + +const WORKSPACE_DIR = + process.env.MULTISIG_WORKSPACE_DIR || + process.env.MPC_WORKSPACE_DIR || + path.join(__dirname, 'multisig-workspace'); + +const FILES = { + bitgoKeychain: 'bitgo-keychain.json', + userKeychainParams: 'user-keychain-params.json', + backupKeychainParams: 'backup-keychain-params.json', + keySignatures: 'key-signatures.json', + walletResult: 'wallet-result.json', +}; + +function workspacePath(filename) { + return path.join(WORKSPACE_DIR, filename); +} + +module.exports = { WORKSPACE_DIR, FILES, workspacePath }; From 2d0be0b64485ffd6ed70d6d240343f3400786353 Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Wed, 4 Feb 2026 08:35:26 +0700 Subject: [PATCH 07/15] sign wallet multisig --- .../multisig/create-wallet-multisig-script.md | 4 + .../sign-transaction-multisig-script.md | 126 ++++++++++++++++ .../multisig-sign-offline.js | 92 ++++++++++++ .../multisig-sign-online.js | 142 ++++++++++++++++++ .../multisig-sign-workspace-schema.js | 34 +++++ 5 files changed, 398 insertions(+) create mode 100644 examples/docs/self-custody/multisig/sign-transaction-multisig-script.md create mode 100644 examples/js/self-custody-multisig/multisig-sign-offline.js create mode 100644 examples/js/self-custody-multisig/multisig-sign-online.js create mode 100644 examples/js/self-custody-multisig/multisig-sign-workspace-schema.js diff --git a/examples/docs/self-custody/multisig/create-wallet-multisig-script.md b/examples/docs/self-custody/multisig/create-wallet-multisig-script.md index 00aadb2e89..ededd49c77 100644 --- a/examples/docs/self-custody/multisig/create-wallet-multisig-script.md +++ b/examples/docs/self-custody/multisig/create-wallet-multisig-script.md @@ -120,6 +120,10 @@ node ./examples/js/self-custody-multisig/multisig-self-custody-online.js --step - Key format: add keychain uses `pub` (and optionally `encryptedPrv`; this flow omits it). Format of `pub` (xpub vs address) is determined by the coin; the script uses the format returned by `baseCoin.keychains().create()`. - Some coins require extra wallet params (e.g. `walletVersion`). The online script calls `baseCoin.supplementGenerateWallet(walletParams, keychains)` before POST so coin-specific params are included. +## Signing transactions + +To spend from a wallet created with this flow, use the **two-script sign flow**: online step 0 builds the transaction and writes `tx-prebuild.json`; the offline script signs with your user or backup key from `local-encrypted-keys.json` and produces `half-signed.json`; online step 1 submits to BitGo. See [Sign transaction (multisig script)](sign-transaction-multisig-script.md). + ## Reference - Implementation: `modules/sdk-core/src/bitgo/wallet/wallets.ts` (`generateWallet`: userKeychainPromise, backupKeychainPromise, createBitGo, keySignatures, supplementGenerateWallet). diff --git a/examples/docs/self-custody/multisig/sign-transaction-multisig-script.md b/examples/docs/self-custody/multisig/sign-transaction-multisig-script.md new file mode 100644 index 0000000000..721f572e00 --- /dev/null +++ b/examples/docs/self-custody/multisig/sign-transaction-multisig-script.md @@ -0,0 +1,126 @@ +# Multisig Self-Custody: Sign Transaction — Two-Script Flow (Offline / Online) + +This guide describes **signing a transaction** for an **on-chain 2-of-3 multisig self-custody wallet** using **two scripts**: an **online script** that builds the transaction and gets public keys, and an **offline script** that signs with your user or backup key from `local-encrypted-keys.json`. The half-signed result is then submitted by the online script. No TSS; a single offline sign step produces the half-signed transaction. + +The wallet must have been created with the [multisig wallet creation flow](create-wallet-multisig-script.md) (no `encryptedPrv` at BitGo; keys in `local-encrypted-keys.json`). For terminology, see the [terminology guide](terminology-guide.md). + +## Overview + +- **On-chain multisig signing**: `wallet.prebuildTransaction(buildParams)` returns **txPrebuild**; signing with your key (user or backup) produces **halfSigned** (or **txHex** for UTXO); `wallet.submitTransaction({ halfSigned })` (or `{ txHex }`) sends to BitGo, which adds the second signature and broadcasts. +- **Online script** (`multisig-sign-online.js`): Step 0 — get wallet, prebuild transaction (recipients from env or file), get keychain pubs, optionally verify; write **tx-prebuild.json**. Step 1 — read **half-signed.json**, submit to BitGo, write **sign-result.json**. +- **Offline script** (`multisig-sign-offline.js`): Reads tx-prebuild.json and local-encrypted-keys.json, decrypts user or backup key with passphrase, calls `baseCoin.signTransaction(...)`, writes **half-signed.json**. No network. +- **Workspace**: Same directory as keygen when possible (so `local-encrypted-keys.json` is present), or a dedicated sign workspace. Files: `tx-prebuild.json`, `half-signed.json`, `sign-result.json`. + +## Sequence Diagram + +```mermaid +sequenceDiagram + participant Online + participant BitGo + participant Offline + + Note over Online,BitGo: Step 0 - Prebuild and get pubs + Online->>BitGo: GET wallet, prebuildTransaction(recipients) + BitGo-->>Online: txPrebuild + Online->>BitGo: getKeysForSigning (or keychains.get) + BitGo-->>Online: pubs + Online->>Offline: tx-prebuild.json + + Note over Offline: Step 1 - Sign with user or backup key (local) + Offline->>Offline: decrypt prv from local-encrypted-keys, baseCoin.signTransaction + Offline->>Online: half-signed.json + + Note over Online,BitGo: Step 1 - Submit half-signed tx + Online->>BitGo: POST /wallet/:id/tx/send (halfSigned) + BitGo-->>Online: sign-result (tx, status) +``` + +## Workspace Files + +| File | Written by | Read by | Description | +|------|------------|---------|-------------| +| `tx-prebuild.json` | Online (step 0) | Offline (step 1) | `{ txPrebuild, walletId, pubs, txParams? }`. | +| `half-signed.json` | Offline (step 1) | Online (step 1) | SignedTransaction: `{ halfSigned: { ... } }` (ETH) or `{ txHex }` (UTXO). | +| `sign-result.json` | Online (step 1) | User | Result from `submitTransaction`. | +| `local-encrypted-keys.json` | Keygen (wallet creation) | Offline (step 1) | **Offline only.** User/backup encrypted keys; keep in same workspace when reusing keygen dir. | + +Set `MULTISIG_SIGN_WORKSPACE_DIR` or `MULTISIG_WORKSPACE_DIR` to use a custom workspace path; default is `multisig-workspace` in the script directory. + +## Steps (Order of Execution) + +1. **Online step 0** (machine with network): Load wallet, call `prebuildTransaction({ recipients })` (recipients from env `RECIPIENT_ADDRESS`/`AMOUNT` or from `tx-params.json`), optionally `verifyTransaction`, get `pubs` via `getKeysForSigning({ wallet })`. Write **tx-prebuild.json** with `txPrebuild`, `walletId`, `pubs` (and optionally `txParams`). Copy tx-prebuild.json to the offline machine. +2. **Offline step 1**: Read tx-prebuild.json, local-encrypted-keys.json, and `WALLET_PASSPHRASE`. Choose signer with `SIGNER=user` (default) or `backup`. Decrypt that key, call `baseCoin.signTransaction({ txPrebuild: { ...txPrebuild, walletId }, prv, pubs })`. Write **half-signed.json** (exact return: `{ halfSigned: { ... } }` or `{ txHex }` per coin). Copy half-signed.json to the online machine. +3. **Online step 1**: Read half-signed.json, call `wallet.submitTransaction(params)` (params = file contents). Write **sign-result.json**. + +## Environment Variables + +- **Offline**: `WALLET_PASSPHRASE` (required), `COIN` (e.g. `tbtc`, `teth`), `SIGNER` (`user` or `backup`, default `user`), `MULTISIG_SIGN_WORKSPACE_DIR` or `MULTISIG_WORKSPACE_DIR` (optional). +- **Online step 0**: `BITGO_ACCESS_TOKEN` (required), `COIN`, `WALLET_ID`, `RECIPIENT_ADDRESS`, `AMOUNT` (or use `tx-params.json` with `recipients`). Optional: `VERIFY_TX=false` to skip verification, `TX_PARAMS_FILE` (default `tx-params.json`), `BITGO_ENV`, `MULTISIG_SIGN_WORKSPACE_DIR`, `MULTISIG_WORKSPACE_DIR`. +- **Online step 1**: `BITGO_ACCESS_TOKEN`, `COIN`; `WALLET_ID` optional if tx-prebuild.json (with `walletId`) is in workspace. + +## Commands (from repo root) + +```bash +# Online machine (with network) +export BITGO_ACCESS_TOKEN=your_token +export COIN=tbtc +export WALLET_ID=your_multisig_wallet_id +export RECIPIENT_ADDRESS=recipient_address +export AMOUNT=amount_in_base_units + +node ./examples/js/self-custody-multisig/multisig-sign-online.js --step 0 +# Copy tx-prebuild.json to offline machine (use same workspace dir so offline has local-encrypted-keys.json) + +# Offline machine (no network) +export WALLET_PASSPHRASE=your_passphrase +export COIN=tbtc +# SIGNER=user (default) or SIGNER=backup +node ./examples/js/self-custody-multisig/multisig-sign-offline.js +# Copy half-signed.json to online machine + +# Online machine +node ./examples/js/self-custody-multisig/multisig-sign-online.js --step 1 +# sign-result.json is written +``` + +## Step-by-Step Flow + +### Online Step 0 — Prebuild and get pubs + +- **Script**: `multisig-sign-online.js --step 0` +- **Input**: Env: `BITGO_ACCESS_TOKEN`, `COIN`, `WALLET_ID`; recipients from `RECIPIENT_ADDRESS`/`AMOUNT` or from `tx-params.json` (`recipients` array). +- **Operations**: Authenticate BitGo, get wallet, `wallet.prebuildTransaction({ recipients })`, `baseCoin.keychains().getKeysForSigning({ wallet })` → `pubs`. Optionally `baseCoin.verifyTransaction({ txPrebuild, txParams: { recipients }, wallet })`. Write `{ txPrebuild, walletId, pubs, txParams }` to tx-prebuild.json. +- **Output**: `tx-prebuild.json` +- **Next**: Copy tx-prebuild.json to the offline machine (offline machine must also have local-encrypted-keys.json in the same workspace when using the keygen directory). + +### Offline Step 1 — Sign with user or backup key + +- **Script**: `multisig-sign-offline.js` +- **Input**: tx-prebuild.json, local-encrypted-keys.json, env: `WALLET_PASSPHRASE`, `COIN`, `SIGNER` (user | backup). +- **Operations**: Load BitGo SDK (no token). Read txPrebuild, walletId, pubs. Decrypt `userEncryptedPrv` or `backupEncryptedPrv` with passphrase. `baseCoin.signTransaction({ txPrebuild: { ...txPrebuild, walletId }, prv, pubs })`. Write the exact return value (SignedTransaction) to half-signed.json. +- **Output**: `half-signed.json` (shape is coin-specific: UTXO → `{ txHex }`, ETH → `{ halfSigned: { txHex, recipients, ... } }`). +- **API**: None (offline). + +### Online Step 1 — Submit half-signed transaction + +- **Script**: `multisig-sign-online.js --step 1` +- **Input**: half-signed.json, tx-prebuild.json (for walletId) or env `WALLET_ID`; env: `BITGO_ACCESS_TOKEN`, `COIN`. +- **Operations**: Get wallet (from walletId in tx-prebuild or env), read half-signed.json, `wallet.submitTransaction(params)` (params = file contents: either `{ txHex }` or `{ halfSigned }`). +- **Output**: `sign-result.json` +- **API**: `POST /wallet/:id/tx/send` (halfSigned or txHex). + +## Security Notes + +- **Offline script never calls the network.** It only reads tx-prebuild.json and local-encrypted-keys.json, decrypts with the passphrase, signs, and writes half-signed.json. Raw private key exists only in memory. +- **local-encrypted-keys.json** must remain on the offline machine (same as wallet-creation flow). Do not copy it to the online machine. +- **Optional**: In online step 0, call `baseCoin.verifyTransaction({ txPrebuild, txParams: { recipients }, wallet })` before writing tx-prebuild so the offline machine only signs a verified intent. Set `VERIFY_TX=false` to skip. + +## Coin Support + +- **txPrebuild** and **halfSigned** shape are coin-specific. UTXO: prebuild has `txHex`, `txInfo`, etc.; sign returns `{ txHex }`. ETH: prebuild has `txHex`, `recipients`, `eip1559`, etc.; sign returns `{ halfSigned: { txHex, recipients, eip1559, ... } }`. The scripts use the same SDK interfaces; test with one UTXO (e.g. tbtc) and one ETH (e.g. teth) coin. + +## Reference + +- Wallet creation: [create-wallet-multisig-script.md](create-wallet-multisig-script.md) +- Terminology: [terminology-guide.md](terminology-guide.md) +- SDK: `wallet.prebuildTransaction`, `wallet.signTransaction`, `wallet.submitTransaction`; `baseCoin.signTransaction`, `baseCoin.keychains().getKeysForSigning`. diff --git a/examples/js/self-custody-multisig/multisig-sign-offline.js b/examples/js/self-custody-multisig/multisig-sign-offline.js new file mode 100644 index 0000000000..443a8d06e4 --- /dev/null +++ b/examples/js/self-custody-multisig/multisig-sign-offline.js @@ -0,0 +1,92 @@ +/** + * Multisig (on-chain 2-of-3) Self-Custody SIGN: OFFLINE Script (No Network) + * + * Signs a transaction using the user or backup key from local-encrypted-keys.json. + * Reads tx-prebuild.json (from online step 0), decrypts the chosen key with WALLET_PASSPHRASE, + * and calls baseCoin.signTransaction(...). Writes half-signed.json for the online script to submit. + * + * No network calls. Raw private key exists only in memory. + * + * Usage: + * WALLET_PASSPHRASE="..." COIN=tbtc SIGNER=user node multisig-sign-offline.js + * WALLET_PASSPHRASE="..." COIN=teth SIGNER=backup node multisig-sign-offline.js + * + * Prerequisites: + * - tx-prebuild.json in workspace (from online script --step 0) + * - local-encrypted-keys.json in workspace (from keygen; same dir when using MULTISIG_WORKSPACE_DIR) + * - WALLET_PASSPHRASE, COIN (e.g. tbtc, teth); SIGNER=user (default) or backup + */ +require('dotenv').config(); +const fs = require('fs'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./multisig-sign-workspace-schema'); + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[OFFLINE] Wrote ${name}`); +} + +async function run() { + const passphrase = process.env.WALLET_PASSPHRASE || ''; + if (!passphrase) throw new Error('WALLET_PASSPHRASE required'); + + const coin = process.env.COIN || 'tbtc'; + const signer = (process.env.SIGNER || 'user').toLowerCase(); + if (signer !== 'user' && signer !== 'backup') { + throw new Error('SIGNER must be user or backup'); + } + + const BitGo = require('bitgo').BitGo; + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + const baseCoin = bitgo.coin(coin); + + const txPrebuildPayload = readJson(FILES.txPrebuild); + const { txPrebuild, walletId, pubs } = txPrebuildPayload; + if (!txPrebuild || !walletId) { + throw new Error('tx-prebuild.json must contain txPrebuild and walletId'); + } + + const localKeys = readJson(FILES.localEncryptedKeys); + const encryptedPrv = signer === 'user' ? localKeys.userEncryptedPrv : localKeys.backupEncryptedPrv; + if (!encryptedPrv) { + throw new Error(`local-encrypted-keys.json must contain ${signer}EncryptedPrv`); + } + + const prv = bitgo.decrypt({ input: encryptedPrv, password: passphrase }); + + const signParams = { + txPrebuild: { ...txPrebuild, walletId }, + prv, + pubs: pubs || undefined, + }; + + const signed = await baseCoin.signTransaction(signParams); + writeJson(FILES.halfSigned, signed); + + console.log('[OFFLINE] Step 1 done. Copy half-signed.json to online machine and run multisig-sign-online.js --step 1.'); +} + +async function main() { + if (process.env.MULTISIG_SIGN_WORKSPACE_DIR || process.env.MULTISIG_WORKSPACE_DIR) { + console.log('[OFFLINE] Workspace:', WORKSPACE_DIR); + } + await run(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-multisig/multisig-sign-online.js b/examples/js/self-custody-multisig/multisig-sign-online.js new file mode 100644 index 0000000000..3afe07b8f4 --- /dev/null +++ b/examples/js/self-custody-multisig/multisig-sign-online.js @@ -0,0 +1,142 @@ +/** + * Multisig (on-chain 2-of-3) Self-Custody SIGN: ONLINE Script (Requires Network) + * + * Step 0: Get wallet, prebuildTransaction(recipients), getKeysForSigning; optionally verifyTransaction. + * Write tx-prebuild.json. Copy to offline machine. + * Step 1: Read half-signed.json, wallet.submitTransaction(params), write sign-result.json. + * + * Usage: + * BITGO_ACCESS_TOKEN="..." COIN=tbtc WALLET_ID="..." RECIPIENT_ADDRESS="..." AMOUNT="..." node multisig-sign-online.js --step 0 + * BITGO_ACCESS_TOKEN="..." COIN=tbtc node multisig-sign-online.js --step 1 + * + * Environment: + * Required: BITGO_ACCESS_TOKEN, COIN, WALLET_ID (step 0); RECIPIENT_ADDRESS, AMOUNT (step 0, or use tx-params.json) + * Optional: BITGO_ENV, MULTISIG_SIGN_WORKSPACE_DIR, MULTISIG_WORKSPACE_DIR, TX_PARAMS_FILE + */ +require('dotenv').config(); +const fs = require('fs'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./multisig-sign-workspace-schema'); + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[ONLINE] Wrote ${name}`); +} + +function getBitGo() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + const opts = { + env: process.env.BITGO_ENV || 'test', + accessToken, + }; + if (process.env.BITGO_CUSTOM_ROOT_URI) opts.customRootURI = process.env.BITGO_CUSTOM_ROOT_URI; + const bitgo = new BitGo(opts); + bitgo.authenticateWithAccessToken({ accessToken }); + return bitgo; +} + +function getRecipients() { + const txParamsFile = process.env.TX_PARAMS_FILE || 'tx-params.json'; + const txParamsPath = workspacePath(txParamsFile); + if (fs.existsSync(txParamsPath)) { + const txParams = JSON.parse(fs.readFileSync(txParamsPath, 'utf8')); + if (txParams.recipients && Array.isArray(txParams.recipients)) { + return txParams.recipients; + } + } + const address = process.env.RECIPIENT_ADDRESS || ''; + const amount = process.env.AMOUNT || '0'; + if (!address) throw new Error('RECIPIENT_ADDRESS required for step 0 (or provide tx-params.json with recipients)'); + return [{ address, amount }]; +} + +async function runStep0() { + const bitgo = getBitGo(); + const coinId = process.env.COIN || 'tbtc'; + const walletId = process.env.WALLET_ID || ''; + if (!walletId) throw new Error('WALLET_ID required for step 0'); + + const baseCoin = bitgo.coin(coinId); + const wallet = await baseCoin.wallets().get({ id: walletId }); + + const recipients = getRecipients(); + const buildParams = { recipients }; + + const txPrebuild = await wallet.prebuildTransaction(buildParams); + + const keychains = await baseCoin.keychains().getKeysForSigning({ wallet }); + const pubs = keychains.map((k) => { + if (!k.pub) throw new Error('Keychain missing pub'); + return k.pub; + }); + + const txParams = { recipients }; + const optionalVerify = process.env.VERIFY_TX !== 'false'; + if (optionalVerify && txPrebuild.txHex && baseCoin.verifyTransaction) { + await baseCoin.verifyTransaction({ + txPrebuild: { ...txPrebuild, walletId: wallet.id() }, + txParams: { recipients }, + wallet, + }); + } + + const payload = { + txPrebuild, + walletId: wallet.id(), + pubs, + txParams, + }; + writeJson(FILES.txPrebuild, payload); + console.log('[ONLINE] Step 0 done. Copy tx-prebuild.json to offline machine and run multisig-sign-offline.js.'); +} + +async function runStep1() { + const bitgo = getBitGo(); + const coinId = process.env.COIN || 'tbtc'; + const txPrebuildPayload = readJson(FILES.txPrebuild); + const walletId = txPrebuildPayload.walletId || process.env.WALLET_ID || ''; + if (!walletId) throw new Error('walletId missing in tx-prebuild.json and WALLET_ID not set'); + + const baseCoin = bitgo.coin(coinId); + const wallet = await baseCoin.wallets().get({ id: walletId }); + + const halfSignedPayload = readJson(FILES.halfSigned); + const result = await wallet.submitTransaction(halfSignedPayload); + writeJson(FILES.signResult, result); + console.log('[ONLINE] Step 1 done. Sign result written to', FILES.signResult); +} + +async function main() { + const step = process.argv.find((a) => a.startsWith('--step=')) + ? process.argv.find((a) => a.startsWith('--step=')).split('=')[1] + : process.argv[process.argv.indexOf('--step') + 1]; + if (!step || !['0', '1'].includes(step)) { + console.error('Usage: node multisig-sign-online.js --step 0|1'); + process.exit(1); + } + if (process.env.MULTISIG_SIGN_WORKSPACE_DIR || process.env.MULTISIG_WORKSPACE_DIR) { + console.log('[ONLINE] Workspace:', WORKSPACE_DIR); + } + if (step === '0') await runStep0(); + else await runStep1(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-multisig/multisig-sign-workspace-schema.js b/examples/js/self-custody-multisig/multisig-sign-workspace-schema.js new file mode 100644 index 0000000000..a9529325c2 --- /dev/null +++ b/examples/js/self-custody-multisig/multisig-sign-workspace-schema.js @@ -0,0 +1,34 @@ +/** + * Multisig (on-chain 2-of-3) SIGN workspace: file names and directory. + * Used by multisig-sign-offline.js and multisig-sign-online.js. + * Do NOT commit the workspace directory; it may contain sensitive state. + * + * Reuse the same directory as keygen (MULTISIG_WORKSPACE_DIR) when possible so + * local-encrypted-keys.json is present; or set MULTISIG_SIGN_WORKSPACE_DIR. + * + * File schema (all JSON): + * - tx-prebuild.json: { txPrebuild, walletId, pubs, txParams? }; written by online step 0 + * - half-signed.json: SignedTransaction (halfSigned or txHex per coin); written by offline step 1 + * - sign-result.json: result from submitTransaction; written by online step 1 + * - local-encrypted-keys.json: from keygen workspace; read by offline step 1 (same dir when reusing keygen workspace) + */ + +const path = require('path'); + +const WORKSPACE_DIR = + process.env.MULTISIG_SIGN_WORKSPACE_DIR || + process.env.MULTISIG_WORKSPACE_DIR || + path.join(__dirname, 'multisig-workspace'); + +const FILES = { + txPrebuild: 'tx-prebuild.json', + halfSigned: 'half-signed.json', + signResult: 'sign-result.json', + localEncryptedKeys: 'local-encrypted-keys.json', +}; + +function workspacePath(filename) { + return path.join(WORKSPACE_DIR, filename); +} + +module.exports = { WORKSPACE_DIR, FILES, workspacePath }; From e468152ff84c77fc136cc54b35ffe6568a0c59d9 Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Wed, 4 Feb 2026 15:25:03 +0700 Subject: [PATCH 08/15] fetch coin supporting wallet creation types --- .../self-custody/mpc/coins-supporting-mpc.md | 78 +++++++++++++++++++ examples/js/get-multisig-type-versions.js | 73 +++++++++++++++++ examples/js/multisig-type-versions.json | 42 ++++++++++ 3 files changed, 193 insertions(+) create mode 100644 examples/docs/self-custody/mpc/coins-supporting-mpc.md create mode 100644 examples/js/get-multisig-type-versions.js create mode 100644 examples/js/multisig-type-versions.json diff --git a/examples/docs/self-custody/mpc/coins-supporting-mpc.md b/examples/docs/self-custody/mpc/coins-supporting-mpc.md new file mode 100644 index 0000000000..0f8188cffc --- /dev/null +++ b/examples/docs/self-custody/mpc/coins-supporting-mpc.md @@ -0,0 +1,78 @@ +# Chains và coins hỗ trợ tạo ví MPC (TSS) + +Tài liệu này liệt kê các chain/coin trong BitGoJS **có thể tạo ví MPC (TSS)** theo logic SDK, và phân biệt hỗ trợ **TSS nói chung** với **MPCv2** (wallet version 5/6). + +--- + +## Điều kiện trong SDK + +Trong [wallets.ts](modules/sdk-core/src/bitgo/wallet/wallets.ts): + +1. **Tạo ví TSS (bất kỳ version)** + Coin phải có `supportsTss() === true` (implement trong từng module coin). + +2. **Tạo ví MPCv2 (walletVersion 5 hoặc 6)** + Ngoài `supportsTss()`, coin còn phải có feature **`CoinFeature.MPCV2`** trong statics (`getConfig().features`), ví dụ từ [coinFeatures.ts](modules/statics/src/coinFeatures.ts). + +3. **Enterprise** + Tạo ví TSS luôn yêu cầu `enterprise` (và với MPCv2, backend có thể yêu cầu cấu hình TSS cho từng coin). + +**Lưu ý:** Backend BitGo (API) có thể giới hạn thêm theo môi trường (test/prod) hoặc theo từng coin; nếu backend không hỗ trợ TSS cho một coin thì dù SDK cho phép, việc tạo ví vẫn có thể thất bại. + +--- + +## Coins có `supportsTss() === true` (có thể tạo ví TSS trong SDK) + +Danh sách dựa trên các module override `supportsTss()` trả về `true`: + +| Family / Chain | Coin (ví dụ) | Ghi chú | +|------------------|--------------|--------| +| **EVM / ETH-like** | eth, hteth, bsc, tbsc, polygon, tpolygon, arbeth, opeth, topeth, bera, oas, coredao, apechain, soneium, tempo, mon, world, wemix, xdc, flr, vet | Nhiều chain EVM dùng EVM_FEATURES hoặc feature set riêng có TSS/MPCV2 | +| **Solana** | sol, tsol | EdDSA TSS | +| **Cosmos** | atom, tatom, tia, ttia, … (cosmos sidechains) | CosmosCoin, COSMOS_SIDECHAIN_FEATURES | +| **Polkadot** | dot | Substrate, EdDSA | +| **Sui** | sui | EdDSA | +| **Aptos** | apt | EdDSA | +| **TON** | ton | EdDSA | +| **Near** | near | EdDSA | +| **Cardano** | ada | EdDSA | +| **ICP** | icp, ticp | ECDSA, SHA256_WITH_ECDSA_TSS | +| **Stellar** | sgb | | +| **Other** | stt, iota, canton, tao, polyx, vet, ton, xdc | Một số có TSS/MPCV2 trong statics | + +*(Danh sách coin đăng ký đầy đủ nằm trong [coinFactory](modules/bitgo/src/v2/coinFactory.ts) và [statics](modules/statics/src/allCoinsAndTokens.ts).)* + +--- + +## Coins có feature MPCV2 (có thể tạo ví MPCv2 – walletVersion 5/6) + +Các coin có **`CoinFeature.MPCV2`** trong statics mới vượt qua check khi gọi `generateWallet` với `walletVersion: 5` hoặc `6` (và `multisigType: 'tss'`). Ví dụ từ [coinFeatures.ts](modules/statics/src/coinFeatures.ts): + +- **EVM_FEATURES** (dùng cho nhiều EVM chain): baseeth, opbnb, fantom, og, tempo, wemix, … +- **POLYGON_FEATURES**: polygon, tpolygon +- **BSC_FEATURES**: bsc, tbsc +- **ARBETH_FEATURES**: arbeth, tarbeth +- **OPETH_FEATURES**: opeth, topeth +- **BERA_FEATURES**: bera +- **OAS_FEATURES**: oas +- **COREDAO_FEATURES**: coredao +- **APECHAIN_FEATURES**: apechain +- **SONEIUM_FEATURES**: soneium +- **VET_FEATURES**: vet +- **ICP_FEATURES**: icp (và có thể ticp tùy statics) +- **COSMOS_SIDECHAIN_FEATURES**: atom, tia, … (cosmos sidechains dùng chung feature set có MPCV2) + +*(Chi tiết từng coin xem trong [coinFeatures.ts](modules/statics/src/coinFeatures.ts) và [allCoinsAndTokens.ts](modules/statics/src/allCoinsAndTokens.ts).)* + +**BTC và SOL:** +- **BTC** (abstract-utxo): không override `getMPCAlgorithm()`, mặc định base coin throw; trong SDK không đi luồng TSS ECDSA/MPCv2. Backend có thể hỗ trợ TSS BTC theo cơ chế khác. +- **SOL**: dùng EdDSA; hỗ trợ TSS (`supportsTss() === true`) nhưng không dùng khái niệm MPCv2 (MPCv1/MPCv2 là cho ECDSA). Tạo ví TSS SOL không qua `walletVersion` 5/6 như EVM. + +--- + +## Tóm tắt + +- **Có thể tạo ví MPC (TSS)** cho mọi coin có `supportsTss() === true` trong SDK (bảng trên); điều kiện bắt buộc là có **enterprise**. +- **Có thể tạo ví MPCv2** (walletVersion 5/6) chỉ cho các coin có **`CoinFeature.MPCV2`** trong statics (EVM có MPCV2, polygon, bsc, arbeth, opeth, bera, oas, coredao, apechain, soneium, vet, icp, cosmos sidechains, …). +- **Không phải mọi chain/coin** trong repo đều hỗ trợ TSS: chỉ những coin đã implement `supportsTss(): true` và (nếu cần MPCv2) có feature MPCV2. +- Hỗ trợ thực tế còn phụ thuộc **backend BitGo** (cấu hình TSS theo môi trường và theo coin); có thể dùng script [get-multisig-type-versions.js](../../js/get-multisig-type-versions.js) để xem `multiSigTypeVersion` trả về từ API cho từng coin. diff --git a/examples/js/get-multisig-type-versions.js b/examples/js/get-multisig-type-versions.js new file mode 100644 index 0000000000..4bec23c84f --- /dev/null +++ b/examples/js/get-multisig-type-versions.js @@ -0,0 +1,73 @@ +/** + * Get supporting multiSigTypeVersion (and cold/custodial variants) for selected coins + * from BitGo TSS settings. Writes result to multisig-type-versions.json in this directory. + * + * Usage: + * BITGO_ACCESS_TOKEN="your-token" node get-multisig-type-versions.js + * BITGO_ACCESS_TOKEN="your-token" BITGO_ENV=prod node get-multisig-type-versions.js + * + * Environment: + * BITGO_ACCESS_TOKEN (required) + * BITGO_ENV (optional, default: 'test') + * BITGO_CUSTOM_ROOT_URI (optional) + */ + +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); + +const COINS = ['eth', 'btc', 'sol', 'bsc', 'polygon', 'sonic']; +const OUTPUT_FILE = path.join(__dirname, 'multisig-type-versions.json'); + +async function main() { + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) { + throw new Error('BITGO_ACCESS_TOKEN is required'); + } + + const bitgoOptions = { + env: process.env.BITGO_ENV || 'test', + accessToken, + useProduction: false, + }; + if (process.env.BITGO_CUSTOM_ROOT_URI) { + bitgoOptions.customRootURI = process.env.BITGO_CUSTOM_ROOT_URI; + } + + const BitGo = require('bitgo').BitGo; + const bitgo = new BitGo(bitgoOptions); + bitgo.authenticateWithAccessToken({ accessToken }); + + const tssSettings = await bitgo.get(bitgo.url('/tss/settings', 2)).result(); + const bitgoEnv = process.env.BITGO_ENV || 'test'; + + const results = COINS.map((coinId) => { + const baseCoin = bitgo.coin(coinId); + const family = baseCoin.getFamily(); + const walletCreationSettings = + tssSettings.coinSettings?.[family]?.walletCreationSettings; + return { + coin: coinId, + family, + multiSigTypeVersion: walletCreationSettings?.multiSigTypeVersion, + coldMultiSigTypeVersion: walletCreationSettings?.coldMultiSigTypeVersion, + custodialMultiSigTypeVersion: + walletCreationSettings?.custodialMultiSigTypeVersion, + }; + }); + + const output = { + generatedAt: new Date().toISOString(), + bitgoEnv, + coins: results, + }; + + console.log(JSON.stringify(output, null, 2)); + fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2), 'utf8'); + console.error(`\nWrote ${OUTPUT_FILE}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/multisig-type-versions.json b/examples/js/multisig-type-versions.json new file mode 100644 index 0000000000..532164f49c --- /dev/null +++ b/examples/js/multisig-type-versions.json @@ -0,0 +1,42 @@ +{ + "generatedAt": "2026-02-04T02:00:50.918Z", + "bitgoEnv": "test", + "coins": [ + { + "coin": "eth", + "family": "eth", + "multiSigTypeVersion": "MPCv2", + "coldMultiSigTypeVersion": "MPCv2", + "custodialMultiSigTypeVersion": "MPCv2" + }, + { + "coin": "btc", + "family": "btc" + }, + { + "coin": "sol", + "family": "sol" + }, + { + "coin": "bsc", + "family": "bsc", + "multiSigTypeVersion": "MPCv2", + "coldMultiSigTypeVersion": "MPCv2", + "custodialMultiSigTypeVersion": "MPCv2" + }, + { + "coin": "polygon", + "family": "polygon", + "multiSigTypeVersion": "MPCv2", + "coldMultiSigTypeVersion": "MPCv2", + "custodialMultiSigTypeVersion": "MPCv2" + }, + { + "coin": "sonic", + "family": "sonic", + "multiSigTypeVersion": "MPCv2", + "coldMultiSigTypeVersion": "MPCv2", + "custodialMultiSigTypeVersion": "MPCv2" + } + ] +} \ No newline at end of file From 0db79740a90b6570a4cc1b315460495af71b46d3 Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Wed, 4 Feb 2026 23:28:41 +0700 Subject: [PATCH 09/15] add bitgo auth utils for bitgo express --- AGENTS.md | 68 +++++++++++-------- .../self-custody-mcp-v2/bitgo-auth-utils.js | 50 ++++++++++++++ .../mpc-self-custody-online.js | 32 +-------- .../mpc-self-custody-sign-online.js | 4 +- 4 files changed, 94 insertions(+), 60 deletions(-) create mode 100644 examples/js/self-custody-mcp-v2/bitgo-auth-utils.js diff --git a/AGENTS.md b/AGENTS.md index ab5b3d0370..bc03b45976 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,40 +1,52 @@ -# AGENTS.md — MCP Routing Cheat Sheet +# AGENTS.md — MCP Routing (Always Apply) -## Golden rules -- Use **ONE primary MCP** per task -- Semantic → Physical → External -- Persist only **stable human decisions** -- Never duplicate sources of truth +The agent must choose **exactly one primary MCP** per task. Priority order: **Semantic (code) → Physical (file) → External (web/docs)**. Do not duplicate sources of truth; only persist **stable human decisions**. --- -## MCP Router +## 1. Choose MCP by user request type -| MCP | Use when you need… | Avoid when… | -|---|---|---| -| **Serena** | Understand / modify existing code | Only file I/O | -| **filesystem** | Read/write/create files | Code semantics | -| **GKG** | Architecture & dependencies | Single symbol | -| **server-memory** | Long-term decisions & rules | Temporary state | -| **Context7** | Exact official APIs | Exploratory research | -| **Perplexity** | Broad / up-to-date web info | Docs already known | -| **sequential-thinking** | Hard design / planning / resolve complicated issues or errors | Simple Q&A | +| User request / Intent | Action: use which MCP | +|------------------------|------------------------| +| **Read or modify existing code** — trace logic, refactor, find symbol, understand flow, fix bug in codebase | → **Serena** (prefer symbolic/overview; avoid reading whole files unless needed). | +| **Plain file operations** — create/edit/delete files, docs, ADRs, config, copy files | → **filesystem**. | +| **View structure / architecture** — map modules, dependencies, packages, overall architecture | → **GKG**. | +| **Long-term memory** — invariants, design decisions, fixed conventions | → **server-memory** (one fact = one short observation). | +| **Look up official API** — official docs, signatures, specific versions | → **Context7**. | +| **Broad research / comparison** — news, tech comparisons, high-level research | → **Perplexity**. | +| **Complex reasoning** — architecture design, hard bug analysis, multi-step planning | → **sequential-thinking**. | --- -## Memory policy -- **Code truth** → Serena / GKG -- **Decision truth** → server-memory -- One fact = one short observation +## 2. When NOT to use each MCP + +- **Serena**: Do not use when you only need simple read/write of files (use filesystem). +- **filesystem**: Do not use when you need code semantics or symbol relationships (use Serena/GKG). +- **GKG**: Do not use when you only need a single symbol in one file (Serena is enough). +- **server-memory**: Do not use for temporary state or information already in code/docs. +- **Context7**: Do not use for open-ended research or broad comparison (use Perplexity). +- **Perplexity**: Do not use when docs/source are already known (use Context7 or Serena). +- **sequential-thinking**: Do not use for simple questions or quick Q&A. + +--- + +## 3. Sources of truth (no duplication) + +- **Code truth** → get from **Serena** or **GKG**; do not store copies in memory. +- **Decision truth** (conventions, invariants, ADRs) → **server-memory**; one fact = one short observation. --- -## Quick routing examples -- Trace logic / refactor → **Serena** -- Create ADR / doc → **filesystem** -- Map system structure → **GKG** -- Remember invariant → **server-memory** -- Check exact API → **Context7** -- Compare approaches → **Perplexity** -- Design architecture → **sequential-thinking** +## 4. Quick examples by request + +| User says / wants | MCP to use | +|-------------------|------------| +| "Find where function X is called", "Refactor module Y", "Bug in file Z" | **Serena** | +| "Create README", "Write ADR", "Edit config" | **filesystem** | +| "Draw dependencies", "Package structure", "System architecture" | **GKG** | +| "Remember to always use pattern A", "Naming convention B" | **server-memory** | +| "React 19 use() API", "Signature of function X in lib Y" | **Context7** | +| "Compare Next vs Remix", "Latest way to do X" | **Perplexity** | +| "Design login flow", "Analyze root cause of complex bug" | **sequential-thinking** | +If the request does not match any row: follow **Semantic → Physical → External** and pick the single most fitting MCP; do not invoke multiple MCPs for the same purpose. diff --git a/examples/js/self-custody-mcp-v2/bitgo-auth-utils.js b/examples/js/self-custody-mcp-v2/bitgo-auth-utils.js new file mode 100644 index 0000000000..2e0b76ea9b --- /dev/null +++ b/examples/js/self-custody-mcp-v2/bitgo-auth-utils.js @@ -0,0 +1,50 @@ +/** + * Utilities for BitGo authentication, particularly for Express/proxy compatibility. + * + * BitGo Express expects Authorization: Bearer . + * BitGoJS "v2 auth" sends a token hash + HMAC, which Express cannot use to extract the raw token. + * These utilities help force v1 auth when needed. + */ + +/** + * Determines whether to force v1 auth for BitGo Express/proxy compatibility. + * @returns {boolean} True if v1 auth should be forced + */ +function shouldForceV1AuthToProxy() { + // BitGo Express expects Authorization: Bearer . + // BitGoJS "v2 auth" sends a token hash + HMAC, which Express cannot use to extract the raw token. + // Set BITGO_FORCE_V1_AUTH=true to force this behavior explicitly. + const explicit = (process.env.BITGO_FORCE_V1_AUTH || '').toLowerCase(); + if (explicit === 'true' || explicit === '1' || explicit === 'yes') return true; + + const root = process.env.BITGO_CUSTOM_ROOT_URI || ''; + return /(^|\/\/)(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(root); +} + +/** + * Wrap BitGo instance to force v1 auth for all requests when using Express/proxy. + * This wraps only the public API request methods (get, post, put, del, patch, options), + * avoiding monkey-patching internal implementation. + * @param {Object} bitgo - BitGo instance to wrap + * @returns {Object} The wrapped BitGo instance (or original if wrapping not needed) + */ +function wrapBitGoForV1Auth(bitgo) { + if (!shouldForceV1AuthToProxy()) return bitgo; + + const methods = ['get', 'post', 'put', 'del', 'patch', 'options']; + methods.forEach((method) => { + const original = bitgo[method].bind(bitgo); + bitgo[method] = function (url) { + const req = original(url); + req.forceV1Auth = true; + return req; + }; + }); + + return bitgo; +} + +module.exports = { + shouldForceV1AuthToProxy, + wrapBitGoForV1Auth, +}; diff --git a/examples/js/self-custody-mcp-v2/mpc-self-custody-online.js b/examples/js/self-custody-mcp-v2/mpc-self-custody-online.js index 0b53a7e490..f7f1d901f2 100644 --- a/examples/js/self-custody-mcp-v2/mpc-self-custody-online.js +++ b/examples/js/self-custody-mcp-v2/mpc-self-custody-online.js @@ -136,37 +136,7 @@ const { WORKSPACE_DIR, FILES, workspacePath } = require('./mpc-workspace-schema' const ROUNDS = { 1: 'MPCv2-R1', 2: 'MPCv2-R2', 3: 'MPCv2-R3' }; const KEYGEN_TYPE = 'MPCv2'; -function shouldForceV1AuthToProxy() { - // BitGo Express expects Authorization: Bearer . - // BitGoJS "v2 auth" sends a token hash + HMAC, which Express cannot use to extract the raw token. - // Set BITGO_FORCE_V1_AUTH=true to force this behavior explicitly. - const explicit = (process.env.BITGO_FORCE_V1_AUTH || '').toLowerCase(); - if (explicit === 'true' || explicit === '1' || explicit === 'yes') return true; - - const root = process.env.BITGO_CUSTOM_ROOT_URI || ''; - return /(^|\/\/)(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(root); -} - -/** - * Wrap BitGo instance to force v1 auth for all requests when using Express/proxy. - * This wraps only the public API request methods (get, post, put, del, patch, options), - * avoiding monkey-patching internal implementation. - */ -function wrapBitGoForV1Auth(bitgo) { - // if (!shouldForceV1AuthToProxy()) return bitgo; - - // const methods = ['get', 'post', 'put', 'del', 'patch', 'options']; - // methods.forEach((method) => { - // const original = bitgo[method].bind(bitgo); - // bitgo[method] = function (url) { - // const req = original(url); - // req.forceV1Auth = true; - // return req; - // }; - // }); - - return bitgo; -} +const { wrapBitGoForV1Auth } = require('./bitgo-auth-utils'); function ensureWorkspace() { if (!fs.existsSync(WORKSPACE_DIR)) { diff --git a/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js b/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js index f58ca6f09f..b76d6a3876 100644 --- a/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js +++ b/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js @@ -23,6 +23,7 @@ require('dotenv').config(); const fs = require('fs'); const path = require('path'); const { WORKSPACE_DIR, FILES, workspacePath } = require('./mpc-sign-workspace-schema'); +const { wrapBitGoForV1Auth } = require('./bitgo-auth-utils'); function ensureWorkspace() { if (!fs.existsSync(WORKSPACE_DIR)) { @@ -52,7 +53,8 @@ function getBitGo() { accessToken, }; if (process.env.BITGO_CUSTOM_ROOT_URI) opts.customRootURI = process.env.BITGO_CUSTOM_ROOT_URI; - const bitgo = new BitGo(opts); + let bitgo = new BitGo(opts); + bitgo = wrapBitGoForV1Auth(bitgo); bitgo.authenticateWithAccessToken({ accessToken }); return bitgo; } From 7dbced78572efaa11f5a17a35ecbc0b125d84aa3 Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Thu, 5 Feb 2026 00:01:22 +0700 Subject: [PATCH 10/15] fix: mpc self-custody sign script --- .gitignore | 6 ++++++ .../js/self-custody-mcp-v2/mpc-self-custody-sign-online.js | 1 + 2 files changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 72c55b96df..e847587d6d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,9 @@ local-pshares/ # MPCv2 two-script workspace (sensitive state; never commit) mpc-keygen-workspace/ **/mpc-keygen-workspace/ +mpc-sign-workspace/ +**/mpc-sign-workspace/ +multisig-workspace/ +**/multisig-workspace/ +multisig-sign-workspace/ +**/multisig-sign-workspace/ diff --git a/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js b/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js index b76d6a3876..e8b8531fae 100644 --- a/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js +++ b/examples/js/self-custody-mcp-v2/mpc-self-custody-sign-online.js @@ -74,6 +74,7 @@ async function runStep0() { const recipients = [{ address: recipientAddress, amount }]; const prebuildResult = await wallet.prebuildTransaction({ + type: 'transfer', recipients, apiVersion: 'full', }); From 16e2f53cfea0886b3e71d0dfd32157c01a617bed Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Thu, 5 Feb 2026 00:03:03 +0700 Subject: [PATCH 11/15] update readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index c111f41a23..943e0f96d9 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,7 @@ The purpose is to document and research BitGo cryptography implementation. - [MPC terminologies](examples/docs/self-custody/mpc/terminology-guide.md) - [MPC v2 create wallet example](examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md) - [MPC v2 sign tx example](examples/docs/self-custody/mpc/sign-transaction-mpcv2-script.md) +3. Multisig self-custody: + - [Multisig terminologies](examples/docs/self-custody/multisig/terminology-guide.md) + - [Multisig create wallet example](examples/docs/self-custody/multisig/create-wallet-multisig-script.md) + - [Multisig sign tx example](examples/docs/self-custody/multisig/sign-transaction-multisig-script.md) From 02f73c7a4cd661b7d9f182998ff7a22c84f9fa61 Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Thu, 5 Feb 2026 14:17:03 +0700 Subject: [PATCH 12/15] update agent file --- CLAUDE.md | 42 ------------------------------------------ 1 file changed, 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ea387f03f..afe2f45915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,45 +129,3 @@ This will generate the necessary boilerplate for a new coin implementation. ## Node.js Version Support BitGoJS supports Node.js versions >=20 and <25, with NPM >=3.10.10. - -## Tools guide - -## Code editing and discovery - -- **Serena MCP** — Use for all code editing and code discovery: - - Edit code via Serena’s symbolic or file-based editing tools. - - Find code by names, symbols, and patterns (e.g. `find_symbol`, `get_symbols_overview`, `search_for_pattern`). - - Prefer Serena over raw file reads when navigating or changing the codebase. - -## Definitions and references - -- **Knowledge-graph MCP** — Use whenever you need to understand code: - - Finding definitions or references of symbols, types, or files. - - Understanding how code is used and where it is referenced. - - Rely on the knowledge-graph as the primary source for "where is this defined?" and "who uses this?". - -## After code changes - -- **Knowledge-graph `index_project`** — After any code update: - - Call the knowledge-graph **index_project** (or equivalent) tool so the graph stays in sync with the codebase. - - Do this as part of your post-edit workflow so future lookups remain accurate. - -## Quick codebase understanding - -- **Knowledge-graph repo-map** — When you need a fast, high-level picture: - - Use the knowledge-graph **repo-map** (or equivalent) to grasp structure and relationships quickly. - - Use it at the start of saga work or when switching context to a new area of the codebase. - -## Research and documentation - -- **Perplexity MCP** — Use for online search: - - Searching for resources, patterns, solutions, or documentation on the web. - - Prefer Perplexity when the answer is likely to be in articles, docs, or discussions. - -- **Fetch (e.g. mcp web_fetch)** — Use for content from external URLs: - - Only when you need the actual content of a specific link. - - **Always evaluate security risk first** (e.g. URL origin, protocol, and sensitivity of the task) before calling fetch. - -- **Context7 MCP** — Use for up-to-date library docs: - - Fetch current documentation for any library or framework you need. - - Prefer Context7 when documenting or analyzing a specific library or stack. From 70437f3777985ee050379a816e319e112d3ed7a8 Mon Sep 17 00:00:00 2001 From: Nguyen Viet Hung Date: Mon, 9 Feb 2026 02:01:28 +0700 Subject: [PATCH 13/15] add simplest form of MPC step to readme --- .../mpc/create-wallet-mpcv2-script.md | 39 ++++++++++++++++++ examples/js/get-multisig-type-versions.js | 2 +- examples/js/multisig-type-versions.json | 9 +++- .../mpc-self-custody-offline.js | 41 +++++++++---------- 4 files changed, 68 insertions(+), 23 deletions(-) diff --git a/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md b/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md index 94805b5b38..a1fb0da7ba 100644 --- a/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md +++ b/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md @@ -427,3 +427,42 @@ const newWallet = await bitgo.post(baseCoin.url('/wallet/add')).send({ - **keychain-payloads.json** contains only passphrase-encrypted private material (`encryptedPrv`); the online script never sees raw private keys or p-shares. - 2-of-3 threshold: transactions require 2 of 3 key shares to sign - Back up the offline state and keychain payloads securely; you need them (and the passphrase) to sign transactions later. + + +## Simplest form of MPC creation steps: + +Round 0: +- Obtain BitGo public GPG key + +Round 1: +- Initialize GPG key pair +- Create broadcast message and encrypt with BitGo public GPG key +- Send to BitGo, receive: + + BitGo round 1 broadcast message + + BitGo round 2 P2P messages (processed in round 3) + +Round 2: +- Decrypt and verify BitGo round 1 broadcast messages +- From the 2 broadcast messages of the other parties, create round 2 P2P messages: + + From yourself to the other party (store this for round 3) + + From yourself to BitGo (this will be submitted to BitGo) +- Submit to BitGo, receive: + + BitGo round 2 commitment + + BitGo round 3 P2P messages (BitGo is one step ahead – at this point, user and backup haven't verified round 2 P2P messages from BitGo or each other yet) + +Round 3: +- From encrypted BitGo round 2 P2P messages + BitGo round 2 commitment => BitGo's round 2 P2P messages to the parties +- From the round 2 P2P messages (BitGo and the other party) => generate your own round 3 P2P messages (these must be submitted to the other party) +- From encrypted round 3 P2P messages from BitGo + round 3 P2P messages from the other party => generate your own round 4 broadcast messages +- Submit to BitGo, receive: + + BitGo round 4 broadcast message + +Round 4: +- From BitGo round 4 broadcast message, verify and obtain your private key share + common keychain +- Encrypt private key share with passphrase +- Send to BitGo to create keychains, receive: + + Keychain ID for each party +- Format params to create wallet, submit to BitGo, receive: + + Wallet ID + + Receive address + + Keychain IDs for all parties diff --git a/examples/js/get-multisig-type-versions.js b/examples/js/get-multisig-type-versions.js index 4bec23c84f..4961c78208 100644 --- a/examples/js/get-multisig-type-versions.js +++ b/examples/js/get-multisig-type-versions.js @@ -16,7 +16,7 @@ require('dotenv').config(); const fs = require('fs'); const path = require('path'); -const COINS = ['eth', 'btc', 'sol', 'bsc', 'polygon', 'sonic']; +const COINS = ['eth', 'btc', 'sol', 'bsc', 'polygon', 'sonic', 'usdt']; const OUTPUT_FILE = path.join(__dirname, 'multisig-type-versions.json'); async function main() { diff --git a/examples/js/multisig-type-versions.json b/examples/js/multisig-type-versions.json index 532164f49c..7c52a1f22d 100644 --- a/examples/js/multisig-type-versions.json +++ b/examples/js/multisig-type-versions.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-02-04T02:00:50.918Z", + "generatedAt": "2026-02-06T09:07:10.366Z", "bitgoEnv": "test", "coins": [ { @@ -37,6 +37,13 @@ "multiSigTypeVersion": "MPCv2", "coldMultiSigTypeVersion": "MPCv2", "custodialMultiSigTypeVersion": "MPCv2" + }, + { + "coin": "usdt", + "family": "eth", + "multiSigTypeVersion": "MPCv2", + "coldMultiSigTypeVersion": "MPCv2", + "custodialMultiSigTypeVersion": "MPCv2" } ] } \ No newline at end of file diff --git a/examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js b/examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js index 193b22d532..4328895f24 100644 --- a/examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js +++ b/examples/js/self-custody-mcp-v2/mpc-self-custody-offline.js @@ -1,3 +1,5 @@ +/* eslint-disable no-console */ +/* eslint-disable no-sync */ /** * MPCv2 Self-Custody Wallet: OFFLINE Script (No Network) * @@ -105,7 +107,6 @@ */ require('dotenv').config(); const fs = require('fs'); -const path = require('path'); const { WORKSPACE_DIR, FILES, workspacePath } = require('./mpc-workspace-schema'); // Configure OpenPGP to accept all curves (required for secp256k1 GPG keys) @@ -163,9 +164,12 @@ function sessionDataFromJson(o) { function formatBitgoBroadcastMessage(broadcastMessage) { // Ensure from is always a number (BITGO = 2) - const from = typeof broadcastMessage.from === 'number' - ? broadcastMessage.from - : (typeof broadcastMessage.from === 'string' ? parseInt(broadcastMessage.from, 10) : MPCv2PartiesEnum.BITGO); + const from = + typeof broadcastMessage.from === 'number' + ? broadcastMessage.from + : typeof broadcastMessage.from === 'string' + ? parseInt(broadcastMessage.from, 10) + : MPCv2PartiesEnum.BITGO; // Handle different possible formats from API response // Case 1: Already formatted with payload: { from: 2, payload: { message, signature } } @@ -189,7 +193,11 @@ function formatBitgoBroadcastMessage(broadcastMessage) { }, }; } - throw new Error(`Invalid bitgoMsg1 format. Expected { from, payload: { message, signature } } or { from, message, signature }, got: ${JSON.stringify(broadcastMessage)}`); + throw new Error( + `Invalid bitgoMsg1 format. Expected { from, payload: { message, signature } } or { from, message, signature }, got: ${JSON.stringify( + broadcastMessage + )}` + ); } function formatP2PMessage(p2pMessage, commitment) { @@ -293,7 +301,9 @@ async function runStep2() { throw new Error(`Invalid formatted bitgoMsg1 structure: ${JSON.stringify(formattedBitgoMsg1)}`); } if (formattedBitgoMsg1.from !== MPCv2PartiesEnum.BITGO) { - throw new Error(`Invalid from field in bitgoMsg1: expected ${MPCv2PartiesEnum.BITGO}, got ${formattedBitgoMsg1.from}`); + throw new Error( + `Invalid from field in bitgoMsg1: expected ${MPCv2PartiesEnum.BITGO}, got ${formattedBitgoMsg1.from}` + ); } const bitgoRound1BroadcastMessages = await DklsComms.decryptAndVerifyIncomingMessages( @@ -524,9 +534,7 @@ async function runStep3() { p2pMessages: [bitgoToBackupRound3Msg, userToBackupMsg3].filter(Boolean), }); - const userRound4BroadcastMsg = userRound4Messages.broadcastMessages.find( - (m) => m.from === MPCv2PartiesEnum.USER - ); + const userRound4BroadcastMsg = userRound4Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.USER); const backupRound4BroadcastMsg = backupRound4Messages.broadcastMessages.find( (m) => m.from === MPCv2PartiesEnum.BACKUP ); @@ -538,10 +546,7 @@ async function runStep3() { // Encrypt and authenticate messages for BitGo const round3Messages = await DklsComms.encryptAndAuthOutgoingMessages( { - p2pMessages: [ - DklsTypes.serializeP2PMessage(userToBitgoMsg3), - DklsTypes.serializeP2PMessage(backupToBitgoMsg3), - ], + p2pMessages: [DklsTypes.serializeP2PMessage(userToBitgoMsg3), DklsTypes.serializeP2PMessage(backupToBitgoMsg3)], broadcastMessages: [], }, [bitgoGpgPubKey], @@ -566,12 +571,8 @@ async function runStep3() { const backupMsg3 = round3Messages.p2pMessages.find( (m) => m.from === MPCv2PartiesEnum.BACKUP && m.to === MPCv2PartiesEnum.BITGO )?.payload; - const userMsg4 = round4Messages.broadcastMessages.find( - (m) => m.from === MPCv2PartiesEnum.USER - )?.payload; - const backupMsg4 = round4Messages.broadcastMessages.find( - (m) => m.from === MPCv2PartiesEnum.BACKUP - )?.payload; + const userMsg4 = round4Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.USER)?.payload; + const backupMsg4 = round4Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BACKUP)?.payload; const round3Payload = { sessionId: round2State.sessionId, @@ -626,8 +627,6 @@ async function runStep4() { ); const bitgoGpgPubKey = { partyId: MPCv2PartiesEnum.BITGO, gpgKey: bitgoPublicGpgKey }; - const userGpgPrvKey = { partyId: MPCv2PartiesEnum.USER, gpgKey: round3State.userGpgPrivateKey }; - const backupGpgPrvKey = { partyId: MPCv2PartiesEnum.BACKUP, gpgKey: round3State.backupGpgPrivateKey }; // Round 4: Process BitGo's round 4 broadcast message to finalize key shares // Load the round 4 broadcast messages generated in step 3 From 89d701db52c4ce9ba5b451d98044f16c99385bde Mon Sep 17 00:00:00 2001 From: Hung Nguyen <90240409+hungnv1702@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:26:30 +0700 Subject: [PATCH 14/15] Update Round 4 instructions for key share verification Clarify the source of the private key share in Round 4. --- examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md b/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md index a1fb0da7ba..6802980417 100644 --- a/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md +++ b/examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md @@ -458,7 +458,7 @@ Round 3: + BitGo round 4 broadcast message Round 4: -- From BitGo round 4 broadcast message, verify and obtain your private key share + common keychain +- From BitGo & other party round 4 broadcast message, verify and obtain your private key share + common keychain - Encrypt private key share with passphrase - Send to BitGo to create keychains, receive: + Keychain ID for each party From 9fcc9abcc10274af6ad3b561f80fcd4049d12999 Mon Sep 17 00:00:00 2001 From: phumaivan Date: Tue, 2 Jun 2026 10:56:56 +0700 Subject: [PATCH 15/15] flow eddsa mpc --- examples/docs/self-custody/eddsa/README.md | 32 ++ .../eddsa/create-wallet-eddsa-script.md | 105 ++++++ .../eddsa/sign-transaction-eddsa-script.md | 163 ++++++++++ examples/js/create-wallet-address.js | 6 +- .../js/{ => self-custody-eddsa}/.DS_Store | Bin 6148 -> 6148 bytes examples/js/self-custody-eddsa/README.md | 303 ++++++++++++++++++ .../eddsa-create-wallet-address.js | 96 ++++++ .../eddsa-keygen-helpers.js | 226 +++++++++++++ .../eddsa-keygen-workspace-schema.js | 29 ++ .../eddsa-self-custody-offline.js | 121 +++++++ .../eddsa-self-custody-online.js | 156 +++++++++ .../eddsa-self-custody-sign-offline.js | 185 +++++++++++ .../eddsa-self-custody-sign-online.js | 248 ++++++++++++++ .../self-custody-eddsa/eddsa-sign-helpers.js | 182 +++++++++++ .../eddsa-sign-workspace-schema.js | 46 +++ 15 files changed, 1895 insertions(+), 3 deletions(-) create mode 100644 examples/docs/self-custody/eddsa/README.md create mode 100644 examples/docs/self-custody/eddsa/create-wallet-eddsa-script.md create mode 100644 examples/docs/self-custody/eddsa/sign-transaction-eddsa-script.md rename examples/js/{ => self-custody-eddsa}/.DS_Store (74%) create mode 100644 examples/js/self-custody-eddsa/README.md create mode 100644 examples/js/self-custody-eddsa/eddsa-create-wallet-address.js create mode 100644 examples/js/self-custody-eddsa/eddsa-keygen-helpers.js create mode 100644 examples/js/self-custody-eddsa/eddsa-keygen-workspace-schema.js create mode 100644 examples/js/self-custody-eddsa/eddsa-self-custody-offline.js create mode 100644 examples/js/self-custody-eddsa/eddsa-self-custody-online.js create mode 100644 examples/js/self-custody-eddsa/eddsa-self-custody-sign-offline.js create mode 100644 examples/js/self-custody-eddsa/eddsa-self-custody-sign-online.js create mode 100644 examples/js/self-custody-eddsa/eddsa-sign-helpers.js create mode 100644 examples/js/self-custody-eddsa/eddsa-sign-workspace-schema.js diff --git a/examples/docs/self-custody/eddsa/README.md b/examples/docs/self-custody/eddsa/README.md new file mode 100644 index 0000000000..52f8fa1ef8 --- /dev/null +++ b/examples/docs/self-custody/eddsa/README.md @@ -0,0 +1,32 @@ +# EdDSA TSS Self-Custody — Documentation Index + +Scripts live in [`examples/js/self-custody-eddsa/`](../../../js/self-custody-eddsa/). + +**Start here:** [README in script folder](../../../js/self-custody-eddsa/README.md) — overview, file inventory, env vars, copy-between-machines guide, troubleshooting. + +## Detailed guides + +| Topic | Document | +|-------|----------| +| Create wallet (offline/online, 5 steps) | [create-wallet-eddsa-script.md](./create-wallet-eddsa-script.md) | +| Sign transaction (offline/online, 7 steps) | [sign-transaction-eddsa-script.md](./sign-transaction-eddsa-script.md) | + +## Scripts quick reference + +| Script | Purpose | +|--------|---------| +| `eddsa-self-custody-online.js` | Wallet creation — online steps 0, 1, 2 | +| `eddsa-self-custody-offline.js` | Wallet creation — offline steps 1, 2 | +| `eddsa-self-custody-sign-online.js` | Transaction signing — online steps 0–3 | +| `eddsa-self-custody-sign-offline.js` | Transaction signing — offline steps 1–3 | +| `eddsa-create-wallet-address.js` | Create receive address (online only) | + +## External references + +- [Create MPC Keys (EdDSA)](https://developers.bitgo.com/docs/wallets-create-mpc-keys) +- [Withdraw - Self-Custody MPC Hot (Manual)](https://developers.bitgo.com/docs/withdraw-wallet-type-self-custody-mpc-hot-manual) + +## Related + +- ECDSA MPCv2: [../mpc/create-wallet-mpcv2-script.md](../mpc/create-wallet-mpcv2-script.md) +- Single-host TSS: `examples/js/create-tss-wallet.js` diff --git a/examples/docs/self-custody/eddsa/create-wallet-eddsa-script.md b/examples/docs/self-custody/eddsa/create-wallet-eddsa-script.md new file mode 100644 index 0000000000..4c20fed17c --- /dev/null +++ b/examples/docs/self-custody/eddsa/create-wallet-eddsa-script.md @@ -0,0 +1,105 @@ +# EdDSA TSS Self-Custody: Create Wallet — Two-Script Flow (Offline / Online) + +> **Overview:** See [examples/js/self-custody-eddsa/README.md](../../../js/self-custody-eddsa/README.md) for the full script inventory, env vars, and file-transfer guide. + +Create an **EdDSA TSS self-custody hot wallet** (e.g. `tsol`, `tapt`, `tsui`) with user and backup key material generated offline. Aligns with [BitGo: Create MPC Keys (EdDSA)](https://developers.bitgo.com/docs/wallets-create-mpc-keys). + +**Note:** EdDSA uses **MPCv1 TSS** (not ECDSA MPCv2/DKLS). For ECDSA MPCv2 wallet creation, see `examples/docs/self-custody/mpc/create-wallet-mpcv2-script.md`. + +## Overview + +| Party | Role | +|-------|------| +| **Offline** | `MPC.keyShare` for user (1) and backup (2); GPG keys; encrypt offline state | +| **Online** | Create BitGo keychain; register user/backup keychains; create wallet | + +Unlike `create-tss-wallet.js` (single host), raw key shares never touch the online machine. + +## Steps + +| # | Machine | Script | Output | +|---|---------|--------|--------| +| 0 | Online | `eddsa-self-custody-online.js --step 0` | `bitgo-gpg-public-key.json` | +| 1 | Offline | `eddsa-self-custody-offline.js --step 1` | `bitgo-keychain-payload.json`, `eddsa-offline-state.json` | +| 2 | Online | `eddsa-self-custody-online.js --step 1` | `bitgo-keychain-response.json` | +| 3 | Offline | `eddsa-self-custody-offline.js --step 2` | `user-keychain-params.json`, `backup-keychain-params.json`, `user-signing-material.json` | +| 4 | Online | `eddsa-self-custody-online.js --step 2` | `wallet-result.json` | + +## Environment + +**Online:** `BITGO_ACCESS_TOKEN`, `COIN`, optional `ENTERPRISE`, `WALLET_LABEL`, `BITGO_ENV`, `BITGO_CUSTOM_ROOT_URI` + +**Offline:** `WALLET_PASSPHRASE`, `COIN`, optional `ORIGINAL_PASSCODE_ENCRYPTION_CODE` + +**Workspace:** `examples/js/self-custody-eddsa/eddsa-keygen-workspace/` or `EDDSA_KEYGEN_WORKSPACE_DIR` + +## Commands (from repo root) + +```bash +# ONLINE step 0 +export BITGO_ACCESS_TOKEN=your_token +export COIN=tsol +export ENTERPRISE=your_enterprise_id # if required +export BITGO_ENV=test + +node ./examples/js/self-custody-eddsa/eddsa-self-custody-online.js --step 0 +``` + +Copy `bitgo-gpg-public-key.json` to offline machine. + +```bash +# OFFLINE step 1 +export WALLET_PASSPHRASE=your_passphrase +export COIN=tsol + +node ./examples/js/self-custody-eddsa/eddsa-self-custody-offline.js --step 1 +``` + +Copy `bitgo-keychain-payload.json` to online. **Keep `eddsa-offline-state.json` on offline only.** + +```bash +# ONLINE step 1 +node ./examples/js/self-custody-eddsa/eddsa-self-custody-online.js --step 1 +``` + +Copy `bitgo-keychain-response.json` to offline. + +```bash +# OFFLINE step 2 +node ./examples/js/self-custody-eddsa/eddsa-self-custody-offline.js --step 2 +``` + +Copy `user-keychain-params.json` and `backup-keychain-params.json` to online. Keep `user-signing-material.json` for [signing](sign-transaction-eddsa-script.md). + +```bash +# ONLINE step 2 +export WALLET_LABEL="My EdDSA Wallet" + +node ./examples/js/self-custody-eddsa/eddsa-self-custody-online.js --step 2 +``` + +## After wallet creation + +- Use `wallet-result.json` for `WALLET_ID` and receive address. +- **Create another receive address** (online only): + +```bash +export BITGO_ACCESS_TOKEN=your_token +export COIN=tsol +# WALLET_ID optional if wallet-result.json is in eddsa-keygen-workspace/ + +node ./examples/js/self-custody-eddsa/eddsa-create-wallet-address.js +``` + +- For withdrawals, use `eddsa-self-custody-sign-online.js` / `eddsa-self-custody-sign-offline.js` with the same `user-signing-material.json` (or copy `encryptedPrv` into `eddsa-sign-workspace/user-signing-material.json`). + +## Security + +- `eddsa-offline-state.json` and `user-signing-material.json` are passphrase-encrypted; do not copy to online if you require strict air-gap policy for signing material. +- `bitgo-keychain-payload.json` only contains encrypted private shares to BitGo (GPG), safe to transfer. + +## Related + +- [Sign transaction (EdDSA)](sign-transaction-eddsa-script.md) +- [Create MPC Keys (BitGo)](https://developers.bitgo.com/docs/wallets-create-mpc-keys) +- Simple single-host: `examples/js/create-tss-wallet.js` diff --git a/examples/docs/self-custody/eddsa/sign-transaction-eddsa-script.md b/examples/docs/self-custody/eddsa/sign-transaction-eddsa-script.md new file mode 100644 index 0000000000..e882d09926 --- /dev/null +++ b/examples/docs/self-custody/eddsa/sign-transaction-eddsa-script.md @@ -0,0 +1,163 @@ +# EdDSA TSS Self-Custody: Sign Transaction — Two-Script Flow (Offline / Online) + +> **Overview:** See [examples/js/self-custody-eddsa/README.md](../../../js/self-custody-eddsa/README.md) for the full script inventory, env vars, troubleshooting, and file-transfer guide. + +This guide describes **signing a transaction** for an **EdDSA TSS self-custody wallet** (e.g. `tsol`, `tapt`, `tsui`) using two scripts: an **offline script** that holds your encrypted signing material and produces commitment/R/G shares, and an **online script** that talks to BitGo APIs. The flow matches [BitGo's self-custody MPC hot wallet (manual) withdraw guide](https://developers.bitgo.com/docs/withdraw-wallet-type-self-custody-mpc-hot-manual) and the EdDSA signing path in `@bitgo/sdk-core` (`EddsaUtils.signTxRequest` / `signEddsaTssUsingExternalSigner`). + +**Note:** This is **EdDSA MPCv1 TSS**, not ECDSA MPCv2 (DKLS). For ECDSA MPCv2, use `examples/js/self-custody-mcp-v2/mpc-self-custody-sign-*.js`. + +## Overview + +- **EdDSA signing** uses commitment exchange, then **R-share** and **G-share** rounds between user and BitGo (2-of-3 threshold; backup share is not used in this flow). +- **Offline script** (`eddsa-self-custody-sign-offline.js`): decrypts `encryptedPrv` locally, builds commitment/R/G material; writes only safe payloads for the online machine. +- **Online script** (`eddsa-self-custody-sign-online.js`): creates TxRequest, `POST .../commit`, submits signature shares; step 3 submits the G share and fetches the finalized TxRequest (BitGo auto-delivers on G share — no separate `send` call, unlike ECDSA MPCv2). +- **Workspace**: `examples/js/self-custody-eddsa/eddsa-sign-workspace/` (or set `EDDSA_SIGN_WORKSPACE_DIR`). + +## Prerequisites + +- EdDSA TSS wallet (e.g. from `examples/js/create-tss-wallet.js` with `multisigType: 'tss'` and an EdDSA coin like `tsol`). +- `user-signing-material.json` in the workspace: + +```json +{ "encryptedPrv": "" } +``` + +- Wallet funded with enough balance for amount + fees. +- `BITGO_ACCESS_TOKEN`, `WALLET_ID`, `WALLET_PASSPHRASE`, `COIN`, `RECIPIENT_ADDRESS`, `AMOUNT`. + +## Sequence (7 steps) + +| # | Machine | Script | Output | +|---|---------|--------|--------| +| 0 | Online | `eddsa-self-custody-sign-online.js --step 0` | `tx-request.json`, `bitgo-gpg-public-key.json` | +| 1 | Offline | `eddsa-self-custody-sign-offline.js --step 1` | `sign-commitment-payload.json`, `sign-eddsa-state.json` | +| 2 | Online | `eddsa-self-custody-sign-online.js --step 1` | `sign-commitment-response.json` | +| 3 | Offline | `eddsa-self-custody-sign-offline.js --step 2` | `sign-r-payload.json` | +| 4 | Online | `eddsa-self-custody-sign-online.js --step 2` | `sign-r-response.json` | +| 5 | Offline | `eddsa-self-custody-sign-offline.js --step 3` | `sign-g-payload.json` | +| 6 | Online | `eddsa-self-custody-sign-online.js --step 3` | `sign-result.json` | + +```mermaid +sequenceDiagram + participant Offline + participant Online + participant BitGo + + Online->>BitGo: prebuildTransaction / txrequests + BitGo-->>Online: tx-request.json + Online->>Offline: tx-request + bitgo GPG key + + Offline->>Offline: commitment + encrypt SignShare + Offline->>Online: sign-commitment-payload.json + + Online->>BitGo: POST .../commit + BitGo-->>Online: sign-commitment-response.json + Online->>Offline: commitment response + + Offline->>Offline: user R signature share + Offline->>Online: sign-r-payload.json + + Online->>BitGo: POST .../signatureshares (R) + Online->>BitGo: get BitGo R share + BitGo-->>Online: sign-r-response.json + Online->>Offline: sign-r-response.json + + Offline->>Offline: user G share + Offline->>Online: sign-g-payload.json + + Online->>BitGo: POST .../signatureshares (G) + Note over BitGo: state → delivered + Online->>BitGo: GET txrequest (final state) + BitGo-->>Online: sign-result.json +``` + +## Commands (from repo root) + +```bash +# ONLINE — step 0 +export BITGO_ACCESS_TOKEN=your_token +export COIN=tsol +export WALLET_ID=your_wallet_id +export RECIPIENT_ADDRESS=destination_address +export AMOUNT=500000 +export BITGO_ENV=test +# optional: export BITGO_CUSTOM_ROOT_URI=http://localhost:3080 + +node ./examples/js/self-custody-eddsa/eddsa-self-custody-sign-online.js --step 0 +``` + +Copy `examples/js/self-custody-eddsa/eddsa-sign-workspace/` to the offline machine (or share only the two JSON files above plus `user-signing-material.json`). + +```bash +# OFFLINE — step 1 +export WALLET_PASSPHRASE=your_passphrase +export COIN=tsol + +node ./examples/js/self-custody-eddsa/eddsa-self-custody-sign-offline.js --step 1 +``` + +Copy `sign-commitment-payload.json` to online; keep `sign-eddsa-state.json` on offline only. + +```bash +# ONLINE — step 1 +node ./examples/js/self-custody-eddsa/eddsa-self-custody-sign-online.js --step 1 +``` + +Copy `sign-commitment-response.json` to offline. + +```bash +# OFFLINE — step 2 +node ./examples/js/self-custody-eddsa/eddsa-self-custody-sign-offline.js --step 2 +``` + +Copy `sign-r-payload.json` to online. + +```bash +# ONLINE — step 2 +node ./examples/js/self-custody-eddsa/eddsa-self-custody-sign-online.js --step 2 +``` + +Copy `sign-r-response.json` to offline. + +```bash +# OFFLINE — step 3 +node ./examples/js/self-custody-eddsa/eddsa-self-custody-sign-offline.js --step 3 +``` + +Copy `sign-g-payload.json` to online. + +```bash +# ONLINE — step 3 +node ./examples/js/self-custody-eddsa/eddsa-self-custody-sign-online.js --step 3 +``` + +## BitGo API mapping + +| Step | BitGo API (full tx request) | +|------|-----------------------------| +| 0 | `wallet.prebuildTransaction` → [Create transaction request](https://developers.bitgo.com/reference/v2wallettxrequestcreate) | +| 1 | `POST /api/v2/wallet/{walletId}/txrequests/{id}/transactions/0/commit` | +| 2 | `POST .../transactions/0/signatureshares` (user R share) | +| 3 | `POST .../transactions/0/signatureshares` (user G share); state becomes `delivered` (no `/send` for EdDSA) | + +## Troubleshooting + +**`Expected transaction request to be in state pendingDelivery but it is in state delivered`** on online step 3: the G share was already accepted and the tx is finalized. Re-run step 3 with the updated script (it fetches the TxRequest instead of calling `/send`). The transaction may already be broadcast — check the wallet in BitGo UI or `sign-result.json` after re-run. + +See also: + +- [Withdraw - Self-Custody MPC Hot (Manual)](https://developers.bitgo.com/docs/withdraw-wallet-type-self-custody-mpc-hot-manual) +- [Create a signature share](https://developers.bitgo.com/reference/v2wallettxrequestsignaturesharecreate) +- [Sign MPC transaction (Express)](https://developers.bitgo.com/reference/expresswalletsigntxtss) — simpler single-host flow if you do not need air-gapped signing + +## Security notes + +- **`sign-eddsa-state.json`** contains passphrase-encrypted User SignShare; keep on offline machine only. +- **`user-signing-material.json`** is your encrypted TSS signing material; never copy to the online machine if you require strict self-custody. +- Payload files (`sign-commitment-payload.json`, `sign-r-payload.json`, `sign-g-payload.json`) contain only shares safe to transfer (no raw `encryptedPrv`). + +## Related examples + +- Create wallet: `examples/js/create-tss-wallet.js` +- ECDSA MPCv2 sign: `examples/docs/self-custody/mpc/sign-transaction-mpcv2-script.md` +- Terminology: `examples/docs/self-custody/mpc/terminology-guide.md` diff --git a/examples/js/create-wallet-address.js b/examples/js/create-wallet-address.js index acccfbf2d8..38e7bf6995 100644 --- a/examples/js/create-wallet-address.js +++ b/examples/js/create-wallet-address.js @@ -9,13 +9,13 @@ const Promise = require('bluebird'); // change this to env: 'production' when you are ready for production const bitgo = new BitGoJS.BitGo({ env: 'test' }); -const coin = 'talgo'; +const coin = 'hteth'; // TODO: set your access token here -const accessToken = ''; +const accessToken = 'v2xb58badf7783094daa13f95dcc1459ba8a34664d0fc37e2b81201886d0c8cd66a'; // set your wallet from the YYYYY parameter here in the URL on app.bitgo-test.com // https://test.bitgo.com/enterprise/XXXXXXXXX/coin/talgo/YYYYY/transactions -const walletId = ''; +const walletId = '6a026814bfd7a52ae607883d0da5a7c7'; Promise.coroutine(function* () { bitgo.authenticateWithAccessToken({ accessToken: accessToken }); diff --git a/examples/js/.DS_Store b/examples/js/self-custody-eddsa/.DS_Store similarity index 74% rename from examples/js/.DS_Store rename to examples/js/self-custody-eddsa/.DS_Store index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..009be9b38e410bc769b21aaeab89b4f4fee472cd 100644 GIT binary patch literal 6148 zcmeHK%}T>S5T0$TO)WwXReD_TTCrBEh?h|73mDOZN=;1AV9b`LHApGstS{t~_&m<+ zZoygwy=W;rVfLH-nN9YCnauzIYY+WxfC>OusDyb%u#jq7}L&}`KC?#|wLT(Z_Swhm9a_t8Tv zo)yUqbCQ%*jT3l5<5y>Xboy~5V#;Z>XLX9N1VYSqi_F!J&-;m|3Eu|ilK~wcR(U=@ z&wQ0(z%cNS3{ZaHsD!r0OrcC2*vJw9k$xkjpiQ*|`EZT4#!Mm3pfKf%s9c#YF_?14 zajw_d8Z(8;9hfdYn0_0V48uv>Q?CfKmNY{pLQ}m!+>F6 zp%`F=j?-!5k@VdxBEuSR- delta 66 zcmZoMXfc=|#>AjHu~2NHo+1YW5HK<@2y7PQ5M$Y_z%h$?Gdl-A2T%b}u84%9H(hI5x+KtY8KJ$3PAT diff --git a/examples/js/self-custody-eddsa/README.md b/examples/js/self-custody-eddsa/README.md new file mode 100644 index 0000000000..70680e0fdb --- /dev/null +++ b/examples/js/self-custody-eddsa/README.md @@ -0,0 +1,303 @@ +# EdDSA TSS Self-Custody — Hướng dẫn scripts + +Thư mục này chứa **ví dụ tách máy offline/online** để tạo ví, tạo địa chỉ nhận và ký giao dịch cho **EdDSA TSS (MPCv1)** trên BitGo — ví dụ coin: `tsol`, `tapt`, `tsui`. + +> **Không phải ECDSA MPCv2 (DKLS).** Flow ECDSA nằm ở `examples/js/self-custody-mcp-v2/`. + +--- + +## Tổng quan end-to-end + +```mermaid +flowchart LR + subgraph keygen [1. Tạo ví] + K0[online step 0] --> K1[offline step 1] + K1 --> K2[online step 1] + K2 --> K3[offline step 2] + K3 --> K4[online step 2] + end + + subgraph ops [2. Vận hành] + F[Nạp testnet coin] + A[eddsa-create-wallet-address.js] + S[Sign flow 7 bước] + end + + keygen --> F --> A + F --> S +``` + +| Giai đoạn | Script chính | Máy | Chi tiết | +|-----------|--------------|-----|----------| +| Tạo ví | `eddsa-self-custody-online.js` + `eddsa-self-custody-offline.js` | Offline + Online | [create-wallet-eddsa-script.md](../../docs/self-custody/eddsa/create-wallet-eddsa-script.md) | +| Tạo địa chỉ nhận | `eddsa-create-wallet-address.js` | Online only | Mục [Tạo địa chỉ](#tạo-địa-chỉ-nhận) bên dưới | +| Rút / ký tx | `eddsa-self-custody-sign-online.js` + `eddsa-self-custody-sign-offline.js` | Offline + Online | [sign-transaction-eddsa-script.md](../../docs/self-custody/eddsa/sign-transaction-eddsa-script.md) | + +--- + +## Chuẩn bị trước khi chạy + +1. **Cài dependency** (từ root repo): + + ```bash + yarn install + ``` + +2. **File `.env`** ở root repo (hoặc export biến môi trường). Các script dùng `require('dotenv').config()`. + +3. **BitGo Express** (tuỳ chọn): set `BITGO_CUSTOM_ROOT_URI=http://localhost:3080` nếu không gọi thẳng BitGo API. Express **không** tự chạy — phải start riêng. + +4. **Node.js**: repo hỗ trợ `>=20 <25`. + +5. **Token & enterprise**: `BITGO_ACCESS_TOKEN`, `ENTERPRISE` (nếu API yêu cầu), `BITGO_ENV=test` cho testnet. + +--- + +## Danh sách file trong thư mục + +### Script có thể chạy trực tiếp + +| File | Mục đích | Mạng | +|------|----------|------| +| `eddsa-self-custody-online.js` | Tạo ví — bước online (0, 1, 2) | Có | +| `eddsa-self-custody-offline.js` | Tạo ví — bước offline (1, 2) | Không | +| `eddsa-self-custody-sign-online.js` | Ký tx — bước online (0–3) | Có | +| `eddsa-self-custody-sign-offline.js` | Ký tx — bước offline (1–3) | Không | +| `eddsa-create-wallet-address.js` | Tạo địa chỉ nhận mới | Có | + +### Module hỗ trợ (không chạy trực tiếp) + +| File | Vai trò | +|------|---------| +| `eddsa-keygen-helpers.js` | Logic offline tạo key share, GPG, combine keychain (mirror `EddsaUtils` trong sdk-core) | +| `eddsa-sign-helpers.js` | Logic offline commitment / G-share (gọi `EDDSAMethods` từ sdk-core) | +| `eddsa-keygen-workspace-schema.js` | Tên file + đường dẫn workspace **tạo ví** | +| `eddsa-sign-workspace-schema.js` | Tên file + đường dẫn workspace **ký tx** | +| `../self-custody-mcp-v2/bitgo-auth-utils.js` | Auth Express / v1 token (dùng chung với MPCv2) | + +### Thư mục workspace (dữ liệu runtime — **không commit**) + +| Thư mục | Dùng cho | +|---------|----------| +| `eddsa-keygen-workspace/` | Tạo ví (mặc định) | +| `eddsa-sign-workspace/` | Ký giao dịch (mặc định) | + +Có thể tách nhiều ví / nhiều lần rút bằng biến môi trường: + +```bash +export EDDSA_KEYGEN_WORKSPACE_DIR=./examples/js/self-custody-eddsa/eddsa-keygen-workspace/tsol-wallet-1 +export EDDSA_SIGN_WORKSPACE_DIR=./examples/js/self-custody-eddsa/eddsa-sign-workspace/tsol-withdrawal-1 +``` + +--- + +## Flow 1: Tạo ví (5 bước, 2 script) + +**Mục tiêu:** User + backup key sinh trên máy offline; BitGo chỉ nhận share đã mã hoá GPG. + +``` +Online 0 → Offline 1 → Online 1 → Offline 2 → Online 2 +``` + +| Bước | Máy | Lệnh | Output chính | +|------|-----|------|--------------| +| 0 | Online | `eddsa-self-custody-online.js --step 0` | `bitgo-gpg-public-key.json` | +| 1 | Offline | `eddsa-self-custody-offline.js --step 1` | `bitgo-keychain-payload.json`, `eddsa-offline-state.json` | +| 2 | Online | `eddsa-self-custody-online.js --step 1` | `bitgo-keychain-response.json` | +| 3 | Offline | `eddsa-self-custody-offline.js --step 2` | `user-keychain-params.json`, `backup-keychain-params.json`, `user-signing-material.json` | +| 4 | Online | `eddsa-self-custody-online.js --step 2` | `wallet-result.json` | + +**Env thường dùng** + +| Biến | Bước | Bắt buộc | +|------|------|----------| +| `BITGO_ACCESS_TOKEN` | Online | Có | +| `COIN` | Cả hai | Có (vd. `tsol`) | +| `WALLET_PASSPHRASE` | Offline | Có | +| `ENTERPRISE` | Online | Tuỳ enterprise | +| `WALLET_LABEL` | Online step 2 | Tuỳ chọn | +| `BITGO_ENV` | Online | Mặc định `test` | +| `BITGO_CUSTOM_ROOT_URI` | Online | Tuỳ chọn | + +**Ví dụ nhanh (từ root repo):** + +```bash +# --- ONLINE step 0 --- +export BITGO_ACCESS_TOKEN=your_token +export COIN=tsol +export BITGO_ENV=test +node ./examples/js/self-custody-eddsa/eddsa-self-custody-online.js --step 0 +# Copy bitgo-gpg-public-key.json sang máy offline + +# --- OFFLINE step 1 --- +export WALLET_PASSPHRASE=your_passphrase +export COIN=tsol +node ./examples/js/self-custody-eddsa/eddsa-self-custody-offline.js --step 1 +# Copy bitgo-keychain-payload.json sang online; GIỮ eddsa-offline-state.json trên offline + +# --- ONLINE step 1 --- +node ./examples/js/self-custody-eddsa/eddsa-self-custody-online.js --step 1 +# Copy bitgo-keychain-response.json sang offline + +# --- OFFLINE step 2 --- +node ./examples/js/self-custody-eddsa/eddsa-self-custody-offline.js --step 2 +# Copy user-keychain-params.json + backup-keychain-params.json sang online +# GIỮ user-signing-material.json cho ký tx sau này + +# --- ONLINE step 2 --- +export WALLET_LABEL="My EdDSA Wallet" +node ./examples/js/self-custody-eddsa/eddsa-self-custody-online.js --step 2 +# → wallet-result.json (walletId, receive address) +``` + +Chi tiết + bảo mật: [create-wallet-eddsa-script.md](../../docs/self-custody/eddsa/create-wallet-eddsa-script.md) + +--- + +## Tạo địa chỉ nhận + +**Chỉ cần online.** Dùng sau khi có `wallet-result.json` hoặc biết `WALLET_ID`. + +```bash +export BITGO_ACCESS_TOKEN=your_token +export COIN=tsol +# export WALLET_ID=... # bỏ qua nếu wallet-result.json nằm trong keygen workspace + +node ./examples/js/self-custody-eddsa/eddsa-create-wallet-address.js +``` + +Output: `address-result.json` trong keygen workspace (hoặc thư mục `EDDSA_KEYGEN_WORKSPACE_DIR`). + +--- + +## Flow 2: Ký giao dịch / rút tiền (7 bước, 2 script) + +**Mục tiêu:** Ký EdDSA TSS qua commitment → R-share → G-share; backup share **không** tham gia ký. + +``` +Online 0 → Offline 1 → Online 1 → Offline 2 → Online 2 → Offline 3 → Online 3 +``` + +| Bước | Máy | Lệnh | Output chính | +|------|-----|------|--------------| +| 0 | Online | `eddsa-self-custody-sign-online.js --step 0` | `tx-request.json`, `bitgo-gpg-public-key.json` | +| 1 | Offline | `eddsa-self-custody-sign-offline.js --step 1` | `sign-commitment-payload.json`, `sign-eddsa-state.json` | +| 2 | Online | `eddsa-self-custody-sign-online.js --step 1` | `sign-commitment-response.json` | +| 3 | Offline | `eddsa-self-custody-sign-offline.js --step 2` | `sign-r-payload.json` | +| 4 | Online | `eddsa-self-custody-sign-online.js --step 2` | `sign-r-response.json` | +| 5 | Offline | `eddsa-self-custody-sign-offline.js --step 3` | `sign-g-payload.json` | +| 6 | Online | `eddsa-self-custody-sign-online.js --step 3` | `sign-result.json` | + +**Điều kiện trước step 0** + +- Ví đã có **số dư** ≥ `AMOUNT` + phí (lỗi `insufficient balance` = ví trống hoặc `AMOUNT` quá lớn). +- File `user-signing-material.json` trong sign workspace: + + ```json + { "encryptedPrv": "" } + ``` + +- Hoặc set `ENCRYPTED_USER_KEY` thay cho file trên (offline). + +**Env step 0 (online)** + +| Biến | Ví dụ `tsol` | +|------|----------------| +| `WALLET_ID` | Từ `wallet-result.json` | +| `RECIPIENT_ADDRESS` | Địa chỉ đích | +| `AMOUNT` | Lamports: `5000000` = 0.005 SOL; `100000000` = 0.1 SOL | +| `COIN` | `tsol` | + +**Lưu ý EdDSA vs ECDSA MPCv2:** Sau khi gửi G-share, BitGo tự chuyển TxRequest sang `delivered`. **Không** gọi `POST .../send` (endpoint đó dành cho ECDSA MPCv2). + +Chi tiết + diagram: [sign-transaction-eddsa-script.md](../../docs/self-custody/eddsa/sign-transaction-eddsa-script.md) + +--- + +## Copy file giữa máy offline ↔ online + +### Tạo ví + +| File | Hướng copy | Ghi chú | +|------|------------|---------| +| `bitgo-gpg-public-key.json` | Online → Offline | An toàn | +| `bitgo-keychain-payload.json` | Offline → Online | Chỉ share GPG-encrypted | +| `bitgo-keychain-response.json` | Online → Offline | An toàn | +| `user-keychain-params.json` | Offline → Online | An toàn | +| `backup-keychain-params.json` | Offline → Online | An toàn | +| `eddsa-offline-state.json` | **Không copy** | Nhạy cảm — giữ offline | +| `user-signing-material.json` | **Không copy lên online** (policy strict) | Cần cho ký tx trên offline | + +### Ký tx + +| File | Hướng copy | Ghi chú | +|------|------------|---------| +| `tx-request.json` | Online → Offline | An toàn | +| `bitgo-gpg-public-key.json` | Online → Offline | An toàn | +| `sign-commitment-payload.json` | Offline → Online | An toàn | +| `sign-commitment-response.json` | Online → Offline | An toàn | +| `sign-r-payload.json` | Offline → Online | An toàn | +| `sign-r-response.json` | Online → Offline | An toàn | +| `sign-g-payload.json` | Offline → Online | An toàn | +| `sign-eddsa-state.json` | **Không copy** | SignShare mã hoá passphrase | +| `user-signing-material.json` | Chỉ trên offline | `encryptedPrv` | + +--- + +## Phân loại bảo mật file + +| Mức | File | +|-----|------| +| **Cực nhạy cảm** | `eddsa-offline-state.json`, `user-signing-material.json`, `sign-eddsa-state.json` | +| **An toàn chuyển qua kênh không tin cậy** | `*-payload.json`, `*-response.json`, `tx-request.json`, `bitgo-gpg-public-key.json` | +| **Kết quả công khai / tra cứu** | `wallet-result.json`, `sign-result.json`, `address-result.json` | + +--- + +## Nguồn logic trong SDK (để kiểm chứng) + +Logic crypto **không tự implement** trong examples — gọi `@bitgo/sdk-core`: + +| Example | SDK tham chiếu | +|---------|----------------| +| `eddsa-keygen-helpers.js` | `modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts` (keygen, combine shares) | +| `eddsa-sign-helpers.js` | `createCommitmentShareFromTxRequest`, `createGShareFromTxRequest`, `signRequestBase` cùng file | +| Online sign | `modules/sdk-core/src/bitgo/tss/eddsa/eddsa.ts` (`EDDSAMethods`) | +| Online keygen | BitGo API keychain + wallet create | + +Tài liệu BitGo: + +- [Create MPC Keys (EdDSA)](https://developers.bitgo.com/docs/wallets-create-mpc-keys) +- [Withdraw - Self-Custody MPC Hot (Manual)](https://developers.bitgo.com/docs/withdraw-wallet-type-self-custody-mpc-hot-manual) + +--- + +## Xử lý lỗi thường gặp + +| Lỗi | Nguyên nhân | Cách xử lý | +|-----|-------------|------------| +| `insufficient balance` | Ví balance = 0 hoặc `AMOUNT` + fee quá lớn | Nạp testnet coin; giảm `AMOUNT`; kiểm tra đúng `WALLET_ID` / `COIN` | +| `Cannot find module 'dotenv'` | Chưa `yarn install` | Chạy `yarn install` ở root | +| `Missing workspace file: ...` | Chưa chạy bước trước hoặc copy file thiếu | Chạy đúng thứ tự; kiểm tra `EDDSA_*_WORKSPACE_DIR` | +| `Expected ... pendingDelivery but ... delivered` | Step 3 sign chạy lại sau khi đã gửi G-share | Chạy lại online step 3 (script mới fetch TxRequest); tx có thể đã broadcast | +| `Message is not signed` (keygen offline step 2) | Decrypt BitGo private share sai cách | Đã fix trong `eddsa-keygen-helpers.js` — dùng bản mới nhất | + +--- + +## So sánh với flow khác + +| Flow | Thư mục | Khi nào dùng | +|------|---------|--------------| +| EdDSA air-gap (này) | `self-custody-eddsa/` | TSS EdDSA, tách offline/online | +| ECDSA MPCv2 air-gap | `self-custody-mcp-v2/` | BTC, ETH, … DKLS | +| Single-host TSS | `create-tss-wallet.js` | Prototype nhanh, không air-gap | +| Multisig | `examples/docs/self-custody/multisig/` | Multisig truyền thống, không MPC | + +--- + +## Tài liệu chi tiết + +- [Tạo ví — chi tiết từng bước](../../docs/self-custody/eddsa/create-wallet-eddsa-script.md) +- [Ký giao dịch — chi tiết từng bước](../../docs/self-custody/eddsa/sign-transaction-eddsa-script.md) +- [Thuật ngữ MPC](../../docs/self-custody/mpc/terminology-guide.md) +- [Coin hỗ trợ MPC](../../docs/self-custody/mpc/coins-supporting-mpc.md) diff --git a/examples/js/self-custody-eddsa/eddsa-create-wallet-address.js b/examples/js/self-custody-eddsa/eddsa-create-wallet-address.js new file mode 100644 index 0000000000..1e25d8ebd5 --- /dev/null +++ b/examples/js/self-custody-eddsa/eddsa-create-wallet-address.js @@ -0,0 +1,96 @@ +/** + * Create a new receive address on an existing EdDSA TSS (MPC) wallet. + * + * Works with wallets created via eddsa-self-custody-online/offline.js or create-tss-wallet.js. + * + * Usage (from repo root): + * BITGO_ACCESS_TOKEN=... COIN=tsol WALLET_ID=... node ./examples/js/self-custody-eddsa/eddsa-create-wallet-address.js + * + * Optional: read WALLET_ID from keygen workspace if omitted: + * EDDSA_KEYGEN_WORKSPACE_DIR=./examples/js/self-custody-eddsa/eddsa-keygen-workspace + * + * Environment: + * BITGO_ACCESS_TOKEN (required) + * COIN (default: tsol) — EdDSA TSS coin, e.g. tsol, tapt, tsui + * WALLET_ID (required unless wallet-result.json exists in keygen workspace) + * ADDRESS_LABEL (optional) — label for the new address + * BITGO_ENV (default: test) + * BITGO_CUSTOM_ROOT_URI (optional) — BitGo Express URL + * + * Copyright 2022, BitGo, Inc. All Rights Reserved. + */ +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./eddsa-keygen-workspace-schema'); +const { wrapBitGoForV1Auth } = require('../self-custody-mcp-v2/bitgo-auth-utils'); + +function resolveWalletId() { + if (process.env.WALLET_ID) { + return process.env.WALLET_ID; + } + const walletResultPath = workspacePath(FILES.walletResult); + if (fs.existsSync(walletResultPath)) { + const result = JSON.parse(fs.readFileSync(walletResultPath, 'utf8')); + if (result.walletId) { + console.log('[INFO] Using walletId from', FILES.walletResult); + return result.walletId; + } + } + throw new Error('WALLET_ID required (set env or create wallet first — wallet-result.json in keygen workspace)'); +} + +async function main() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + + const coinId = process.env.COIN || 'tsol'; + const walletId = resolveWalletId(); + + const opts = { + env: process.env.BITGO_ENV || 'test', + accessToken, + }; + if (process.env.BITGO_CUSTOM_ROOT_URI) { + opts.customRootURI = process.env.BITGO_CUSTOM_ROOT_URI; + } + + let bitgo = new BitGo(opts); + bitgo = wrapBitGoForV1Auth(bitgo); + bitgo.authenticateWithAccessToken({ accessToken }); + + const wallet = await bitgo.coin(coinId).wallets().get({ id: walletId }); + + const createParams = {}; + if (process.env.ADDRESS_LABEL) { + createParams.label = process.env.ADDRESS_LABEL; + } + + const newAddress = await wallet.createAddress(createParams); + + const addressRecord = { + walletId: wallet.id(), + coin: coinId, + defaultReceiveAddress: wallet.receiveAddress(), + newAddress, + createdAt: new Date().toISOString(), + }; + + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } + const outPath = path.join(WORKSPACE_DIR, 'address-result.json'); + fs.writeFileSync(outPath, JSON.stringify(addressRecord, null, 2), { mode: 0o600 }); + + console.log('Wallet ID:', addressRecord.walletId); + console.log('Coin:', addressRecord.coin); + console.log('Default receive address:', addressRecord.defaultReceiveAddress); + console.log('New receive address:', typeof newAddress === 'string' ? newAddress : newAddress.address || newAddress); + console.log('Saved to', outPath); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-eddsa/eddsa-keygen-helpers.js b/examples/js/self-custody-eddsa/eddsa-keygen-helpers.js new file mode 100644 index 0000000000..a00cd5dd6e --- /dev/null +++ b/examples/js/self-custody-eddsa/eddsa-keygen-helpers.js @@ -0,0 +1,226 @@ +/** + * Helpers for EdDSA TSS self-custody wallet creation (offline/online split). + * Aligns with https://developers.bitgo.com/docs/wallets-create-mpc-keys (EdDSA section) + * and EddsaUtils in @bitgo/sdk-core. + */ + +const openpgp = require('openpgp'); +openpgp.config.rejectCurves = new Set(); + +const BITGO_INDEX = 3; +const M = 2; +const N = 3; + +async function generateLocalKeyMaterial() { + const { Eddsa, generateGPGKeyPair } = require('@bitgo/sdk-core'); + const { Ed25519Bip32HdTree } = require('@bitgo/sdk-lib-mpc'); + + const hdTree = await Ed25519Bip32HdTree.initialize(); + const MPC = await Eddsa.initialize(hdTree); + + const userKeyShare = MPC.keyShare(1, M, N); + const backupKeyShare = MPC.keyShare(2, M, N); + const userGpgKey = await generateGPGKeyPair('secp256k1'); + const backupGpgKey = await generateGPGKeyPair('secp256k1'); + + return { userKeyShare, backupKeyShare, userGpgKey, backupGpgKey }; +} + +async function buildToBitgoKeyShare(keyShare, gpgPrivateKey) { + const { createShareProof } = require('@bitgo/sdk-core'); + + const publicShare = Buffer.concat([ + Buffer.from(keyShare.uShare.y, 'hex'), + Buffer.from(keyShare.uShare.chaincode, 'hex'), + ]).toString('hex'); + + const privateShare = Buffer.concat([ + Buffer.from(keyShare.yShares[BITGO_INDEX].u, 'hex'), + Buffer.from(keyShare.yShares[BITGO_INDEX].chaincode, 'hex'), + ]).toString('hex'); + + return { + publicShare, + privateShare, + privateShareProof: await createShareProof(gpgPrivateKey, privateShare.slice(0, 64), 'eddsa'), + vssProof: keyShare.yShares[BITGO_INDEX].v, + }; +} + +async function buildBitgoKeychainPayload(userKeyShare, backupKeyShare, userGpgKey, backupGpgKey, bitgoGpgPublicKeyArmored) { + const { encryptText } = require('@bitgo/sdk-core'); + const bitgoKey = await openpgp.readKey({ armoredKey: bitgoGpgPublicKeyArmored }); + + const userToBitgo = await buildToBitgoKeyShare(userKeyShare, userGpgKey.privateKey); + const backupToBitgo = await buildToBitgoKeyShare(backupKeyShare, backupGpgKey.privateKey); + + const encUserPrivateShare = await encryptText(userToBitgo.privateShare, bitgoKey); + const encBackupPrivateShare = await encryptText(backupToBitgo.privateShare, bitgoKey); + + return { + keyType: 'tss', + source: 'bitgo', + keyShares: [ + { + from: 'user', + to: 'bitgo', + publicShare: userToBitgo.publicShare, + privateShare: encUserPrivateShare, + privateShareProof: userToBitgo.privateShareProof, + vssProof: userToBitgo.vssProof, + }, + { + from: 'backup', + to: 'bitgo', + publicShare: backupToBitgo.publicShare, + privateShare: encBackupPrivateShare, + privateShareProof: backupToBitgo.privateShareProof, + vssProof: backupToBitgo.vssProof, + }, + ], + userGPGPublicKey: userGpgKey.publicKey, + backupGPGPublicKey: backupGpgKey.publicKey, + }; +} + +/** + * Decrypt BitGo→party private share (GPG-encrypted only, not signed). + * Matches MpcUtils.decryptPrivateShare in @bitgo/sdk-core — do not use readSignedMessage here. + */ +async function decryptPrivateShare(encryptedPrivateShare, gpgKeyPair) { + const { readMessage, readPrivateKey, decrypt } = openpgp; + const privateShareMessage = await readMessage({ armoredMessage: encryptedPrivateShare }); + const userGpgPrivateKey = await readPrivateKey({ armoredKey: gpgKeyPair.privateKey }); + const decrypted = await decrypt({ + message: privateShareMessage, + decryptionKeys: [userGpgPrivateKey], + format: 'utf8', + }); + return decrypted.data; +} + +async function buildUserAndBackupKeychainParams(bitgoKeychain, offlineState, passphrase, bitgo) { + const { Eddsa, EddsaUtils } = require('@bitgo/sdk-core'); + + const stateJson = bitgo.decrypt({ input: offlineState.encryptedState, password: passphrase }); + const state = JSON.parse(stateJson); + const { userKeyShare, backupKeyShare, userGpgKey, backupGpgKey } = state; + + const bitgoKeyShares = bitgoKeychain.keyShares; + if (!bitgoKeyShares) { + throw new Error('BitGo keychain response missing keyShares'); + } + + const coinId = process.env.COIN || 'tsol'; + const baseCoin = bitgo.coin(coinId); + const eddsaUtils = new EddsaUtils(bitgo, baseCoin); + + const bitGoToUserShare = bitgoKeyShares.find((ks) => ks.from === 'bitgo' && ks.to === 'user'); + const bitGoToBackupShare = bitgoKeyShares.find((ks) => ks.from === 'bitgo' && ks.to === 'backup'); + if (!bitGoToUserShare || !bitGoToBackupShare) { + throw new Error('Missing bitgo→user or bitgo→backup key share in BitGo keychain response'); + } + + const bitGoToUserPrivateShare = await decryptPrivateShare(bitGoToUserShare.privateShare, userGpgKey); + await eddsaUtils.verifyWalletSignatures( + userGpgKey.publicKey, + backupGpgKey.publicKey, + bitgoKeychain, + bitGoToUserPrivateShare, + 1 + ); + + const bitGoToBackupPrivateShare = await decryptPrivateShare(bitGoToBackupShare.privateShare, backupGpgKey); + await eddsaUtils.verifyWalletSignatures( + userGpgKey.publicKey, + backupGpgKey.publicKey, + bitgoKeychain, + bitGoToBackupPrivateShare, + 2 + ); + + const hdTree = await require('@bitgo/sdk-lib-mpc').Ed25519Bip32HdTree.initialize(); + const MPC = await Eddsa.initialize(hdTree); + + const bitgoToUser = { + i: 1, + j: 3, + y: bitGoToUserShare.publicShare.slice(0, 64), + v: bitGoToUserShare.vssProof, + u: bitGoToUserPrivateShare.slice(0, 64), + chaincode: bitGoToUserPrivateShare.slice(64), + }; + + const bitgoToBackup = { + i: 2, + j: 3, + y: bitGoToBackupShare.publicShare.slice(0, 64), + v: bitGoToBackupShare.vssProof, + u: bitGoToBackupPrivateShare.slice(0, 64), + chaincode: bitGoToBackupPrivateShare.slice(64), + }; + + const userCombined = MPC.keyCombine(userKeyShare.uShare, [backupKeyShare.yShares[1], bitgoToUser]); + const commonKeychain = userCombined.pShare.y + userCombined.pShare.chaincode; + if (commonKeychain !== bitgoKeychain.commonKeychain) { + throw new Error('Failed to create user keychain - commonKeychains do not match'); + } + + const backupCombined = MPC.keyCombine(backupKeyShare.uShare, [userKeyShare.yShares[2], bitgoToBackup]); + const backupCommon = backupCombined.pShare.y + backupCombined.pShare.chaincode; + if (backupCommon !== bitgoKeychain.commonKeychain) { + throw new Error('Failed to create backup keychain - commonKeychains do not match'); + } + + const userSigningMaterial = { + uShare: userKeyShare.uShare, + bitgoYShare: bitgoToUser, + backupYShare: backupKeyShare.yShares[1], + }; + + const backupSigningMaterial = { + uShare: backupKeyShare.uShare, + bitgoYShare: bitgoToBackup, + userYShare: userKeyShare.yShares[2], + }; + + const encryptedPrvUser = bitgo.encrypt({ + input: JSON.stringify(userSigningMaterial), + password: passphrase, + }); + + const backupPrv = JSON.stringify(backupSigningMaterial); + const encryptedPrvBackup = bitgo.encrypt({ input: backupPrv, password: passphrase }); + + const originalPasscodeEncryptionCode = process.env.ORIGINAL_PASSCODE_ENCRYPTION_CODE; + + const userKeychainParams = { + source: 'user', + keyType: 'tss', + commonKeychain: bitgoKeychain.commonKeychain, + encryptedPrv: encryptedPrvUser, + }; + if (originalPasscodeEncryptionCode) { + userKeychainParams.originalPasscodeEncryptionCode = originalPasscodeEncryptionCode; + } + + const backupKeychainParams = { + source: 'backup', + keyType: 'tss', + commonKeychain: bitgoKeychain.commonKeychain, + prv: backupPrv, + encryptedPrv: encryptedPrvBackup, + }; + + return { + userKeychainParams, + backupKeychainParams, + userSigningMaterial: { encryptedPrv: encryptedPrvUser }, + }; +} + +module.exports = { + generateLocalKeyMaterial, + buildBitgoKeychainPayload, + buildUserAndBackupKeychainParams, +}; diff --git a/examples/js/self-custody-eddsa/eddsa-keygen-workspace-schema.js b/examples/js/self-custody-eddsa/eddsa-keygen-workspace-schema.js new file mode 100644 index 0000000000..87cec4bc75 --- /dev/null +++ b/examples/js/self-custody-eddsa/eddsa-keygen-workspace-schema.js @@ -0,0 +1,29 @@ +/** + * EdDSA TSS self-custody KEYGEN workspace file names. + * Used by eddsa-self-custody-offline.js and eddsa-self-custody-online.js. + * Do NOT commit the workspace directory. + */ + +const path = require('path'); + +const WORKSPACE_DIR = + process.env.EDDSA_KEYGEN_WORKSPACE_DIR || + process.env.MPC_WORKSPACE_DIR || + path.join(__dirname, 'eddsa-keygen-workspace'); + +const FILES = { + bitgoGpgPublicKey: 'bitgo-gpg-public-key.json', + bitgoKeychainPayload: 'bitgo-keychain-payload.json', + bitgoKeychainResponse: 'bitgo-keychain-response.json', + eddsaOfflineState: 'eddsa-offline-state.json', + userKeychainParams: 'user-keychain-params.json', + backupKeychainParams: 'backup-keychain-params.json', + userSigningMaterial: 'user-signing-material.json', + walletResult: 'wallet-result.json', +}; + +function workspacePath(filename) { + return path.join(WORKSPACE_DIR, filename); +} + +module.exports = { WORKSPACE_DIR, FILES, workspacePath }; diff --git a/examples/js/self-custody-eddsa/eddsa-self-custody-offline.js b/examples/js/self-custody-eddsa/eddsa-self-custody-offline.js new file mode 100644 index 0000000000..0d6399fbc0 --- /dev/null +++ b/examples/js/self-custody-eddsa/eddsa-self-custody-offline.js @@ -0,0 +1,121 @@ +/** + * EdDSA TSS Self-Custody Wallet: OFFLINE Script (No Network) + * + * Generates user/backup TSS key shares and GPG keys locally; prepares BitGo keychain payload. + * After BitGo returns key shares, combines keys and encrypts signing material for registration. + * + * Steps: + * 1: Generate key shares + GPG keys; write bitgo-keychain-payload.json + encrypted offline state + * 2: Read bitgo-keychain-response.json; build user/backup keychain params + user-signing-material.json + * + * Usage: + * WALLET_PASSPHRASE=... node eddsa-self-custody-offline.js --step 1 + * WALLET_PASSPHRASE=... node eddsa-self-custody-offline.js --step 2 + * + * BitGo docs: https://developers.bitgo.com/docs/wallets-create-mpc-keys + */ +require('dotenv').config(); +const fs = require('fs'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./eddsa-keygen-workspace-schema'); +const { + generateLocalKeyMaterial, + buildBitgoKeychainPayload, + buildUserAndBackupKeychainParams, +} = require('./eddsa-keygen-helpers'); + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[OFFLINE] Wrote ${name}`); +} + +async function runStep1() { + const passphrase = process.env.WALLET_PASSPHRASE || ''; + if (!passphrase) throw new Error('WALLET_PASSPHRASE required for step 1'); + + const gpgConfig = readJson(FILES.bitgoGpgPublicKey); + const bitgoGpgPublicKey = gpgConfig.bitgoGpgPublicKey; + if (!bitgoGpgPublicKey) { + throw new Error('Missing bitgo-gpg-public-key.json — run online script --step 0 first'); + } + + const BitGo = require('bitgo').BitGo; + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + + const { userKeyShare, backupKeyShare, userGpgKey, backupGpgKey } = await generateLocalKeyMaterial(); + + const bitgoPayload = await buildBitgoKeychainPayload( + userKeyShare, + backupKeyShare, + userGpgKey, + backupGpgKey, + bitgoGpgPublicKey + ); + + const encryptedState = bitgo.encrypt({ + input: JSON.stringify({ userKeyShare, backupKeyShare, userGpgKey, backupGpgKey }), + password: passphrase, + }); + + writeJson(FILES.bitgoKeychainPayload, bitgoPayload); + writeJson(FILES.eddsaOfflineState, { encryptedState }); + + console.log('[OFFLINE] Step 1 done. Copy bitgo-keychain-payload.json to online machine, run online --step 1.'); +} + +async function runStep2() { + const passphrase = process.env.WALLET_PASSPHRASE || ''; + if (!passphrase) throw new Error('WALLET_PASSPHRASE required for step 2'); + + const BitGo = require('bitgo').BitGo; + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + + const bitgoKeychain = readJson(FILES.bitgoKeychainResponse); + const offlineState = readJson(FILES.eddsaOfflineState); + + const { userKeychainParams, backupKeychainParams, userSigningMaterial } = + await buildUserAndBackupKeychainParams(bitgoKeychain, offlineState, passphrase, bitgo); + + writeJson(FILES.userKeychainParams, userKeychainParams); + writeJson(FILES.backupKeychainParams, backupKeychainParams); + writeJson(FILES.userSigningMaterial, userSigningMaterial); + + console.log( + '[OFFLINE] Step 2 done. Copy user-keychain-params.json and backup-keychain-params.json to online machine, run online --step 2.' + ); + console.log('[OFFLINE] user-signing-material.json is for local signing — keep on offline machine.'); +} + +async function main() { + const step = + process.argv.find((a) => a.startsWith('--step=')) + ? process.argv.find((a) => a.startsWith('--step=')).split('=')[1] + : process.argv[process.argv.indexOf('--step') + 1]; + if (!step || !['1', '2'].includes(step)) { + console.error('Usage: node eddsa-self-custody-offline.js --step 1|2'); + process.exit(1); + } + if (process.env.EDDSA_KEYGEN_WORKSPACE_DIR) { + console.log('[OFFLINE] Workspace:', WORKSPACE_DIR); + } + if (step === '1') await runStep1(); + else await runStep2(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-eddsa/eddsa-self-custody-online.js b/examples/js/self-custody-eddsa/eddsa-self-custody-online.js new file mode 100644 index 0000000000..44a6b216d6 --- /dev/null +++ b/examples/js/self-custody-eddsa/eddsa-self-custody-online.js @@ -0,0 +1,156 @@ +/** + * EdDSA TSS Self-Custody Wallet: ONLINE Script (Requires Network) + * + * Fetches BitGo GPG key, creates BitGo TSS keychain from offline payload, registers user/backup + * keychains, and creates the wallet. + * + * Steps: + * 0: Fetch BitGo MPCv1 GPG public key → bitgo-gpg-public-key.json + * 1: POST /key (BitGo keychain) from bitgo-keychain-payload.json → bitgo-keychain-response.json + * 2: POST user + backup keychains, POST /wallet/add → wallet-result.json + * + * Usage: + * BITGO_ACCESS_TOKEN=... COIN=tsol node eddsa-self-custody-online.js --step 0 + * node eddsa-self-custody-online.js --step 1 + * WALLET_LABEL="My EdDSA Wallet" node eddsa-self-custody-online.js --step 2 + * + * BitGo docs: https://developers.bitgo.com/docs/wallets-create-mpc-keys + */ +require('dotenv').config(); +const fs = require('fs'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./eddsa-keygen-workspace-schema'); +const { wrapBitGoForV1Auth } = require('../self-custody-mcp-v2/bitgo-auth-utils'); + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[ONLINE] Wrote ${name}`); +} + +function getBitGo() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + const opts = { + env: process.env.BITGO_ENV || 'test', + accessToken, + }; + if (process.env.BITGO_CUSTOM_ROOT_URI) opts.customRootURI = process.env.BITGO_CUSTOM_ROOT_URI; + let bitgo = new BitGo(opts); + bitgo = wrapBitGoForV1Auth(bitgo); + bitgo.authenticateWithAccessToken({ accessToken }); + return bitgo; +} + +async function runStep0() { + const bitgo = getBitGo(); + + const constants = await bitgo.fetchConstants(); + const bitgoPublicKey = constants.mpc && constants.mpc.bitgoPublicKey; + if (!bitgoPublicKey) { + throw new Error('Unable to fetch BitGo MPCv1 GPG public key (constants.mpc.bitgoPublicKey missing)'); + } + + writeJson(FILES.bitgoGpgPublicKey, { bitgoGpgPublicKey: bitgoPublicKey }); + console.log('[ONLINE] Step 0 done. Copy bitgo-gpg-public-key.json to offline machine, run offline --step 1.'); +} + +async function runStep1() { + const bitgo = getBitGo(); + const coinId = process.env.COIN || 'tsol'; + const enterprise = process.env.ENTERPRISE || ''; + + const payload = readJson(FILES.bitgoKeychainPayload); + const baseCoin = bitgo.coin(coinId); + const keychains = baseCoin.keychains(); + + const bitgoKeychain = await keychains.add({ + ...payload, + enterprise: enterprise || undefined, + }); + + writeJson(FILES.bitgoKeychainResponse, bitgoKeychain); + console.log('[ONLINE] Step 1 done. Copy bitgo-keychain-response.json to offline machine, run offline --step 2.'); +} + +async function runStep2() { + const bitgo = getBitGo(); + const coinId = process.env.COIN || 'tsol'; + const label = process.env.WALLET_LABEL || 'EdDSA Self-Custody Wallet (two-script)'; + const enterprise = process.env.ENTERPRISE || ''; + + const userKeychainParams = readJson(FILES.userKeychainParams); + const backupKeychainParams = readJson(FILES.backupKeychainParams); + const bitgoKeychain = readJson(FILES.bitgoKeychainResponse); + + const baseCoin = bitgo.coin(coinId); + const keychains = baseCoin.keychains(); + + const userKeychain = await keychains.add({ ...userKeychainParams, enterprise: enterprise || undefined }); + const backupKeychain = await keychains.createBackup({ + ...backupKeychainParams, + enterprise: enterprise || undefined, + }); + + const walletParams = { + label, + m: 2, + n: 3, + keys: [userKeychain.id, backupKeychain.id, bitgoKeychain.id], + type: 'hot', + multisigType: 'tss', + enterprise: enterprise || undefined, + }; + + const keychainsTriplet = { userKeychain, backupKeychain, bitgoKeychain }; + const finalWalletParams = await baseCoin.supplementGenerateWallet(walletParams, keychainsTriplet); + const newWallet = await bitgo.post(baseCoin.url('/wallet/add')).send(finalWalletParams).result(); + + const walletResult = { + walletId: newWallet.id, + receiveAddress: newWallet.receiveAddress, + userKeychainId: userKeychain.id, + backupKeychainId: backupKeychain.id, + bitgoKeychainId: bitgoKeychain.id, + }; + writeJson(FILES.walletResult, walletResult); + + console.log('\n[ONLINE] Wallet created (EdDSA TSS two-script).'); + console.log('Wallet ID:', walletResult.walletId); + console.log('Receive address:', walletResult.receiveAddress); +} + +async function main() { + const step = + process.argv.find((a) => a.startsWith('--step=')) + ? process.argv.find((a) => a.startsWith('--step=')).split('=')[1] + : process.argv[process.argv.indexOf('--step') + 1]; + if (!step || !['0', '1', '2'].includes(step)) { + console.error('Usage: node eddsa-self-custody-online.js --step 0|1|2'); + process.exit(1); + } + if (process.env.EDDSA_KEYGEN_WORKSPACE_DIR) { + console.log('[ONLINE] Workspace:', WORKSPACE_DIR); + } + if (step === '0') await runStep0(); + else if (step === '1') await runStep1(); + else if (step === '2') await runStep2(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-eddsa/eddsa-self-custody-sign-offline.js b/examples/js/self-custody-eddsa/eddsa-self-custody-sign-offline.js new file mode 100644 index 0000000000..d7481eea99 --- /dev/null +++ b/examples/js/self-custody-eddsa/eddsa-self-custody-sign-offline.js @@ -0,0 +1,185 @@ +/** + * EdDSA TSS Self-Custody SIGN: OFFLINE Script (No Network) + * + * Produces commitment, R-share, and G-share material for EdDSA MPCv1 TSS signing. + * Raw signing material (User SignShare / encryptedPrv) never leaves this machine. + * + * Steps: + * 1: Build commitment + encrypted signer share; encrypt User SignShare for later rounds + * 2: Build user→BitGo R signature share (from decrypted SignShare) + * 3: Build user→BitGo G share (after BitGo commitment + R shares are available) + * + * Usage: + * WALLET_PASSPHRASE=... COIN=tsol node eddsa-self-custody-sign-offline.js --step 1 + * node eddsa-self-custody-sign-offline.js --step 2 + * node eddsa-self-custody-sign-offline.js --step 3 + * + * Prerequisites: + * - tx-request.json (from online step 0) + * - bitgo-gpg-public-key.json (from online step 0) + * - user-signing-material.json with encryptedPrv (from create-tss-wallet.js), or ENCRYPTED_USER_KEY + * - WALLET_PASSPHRASE, COIN (e.g. tsol, tapt, tsui) + * + * BitGo docs: + * https://developers.bitgo.com/docs/withdraw-wallet-type-self-custody-mpc-hot-manual + */ +require('dotenv').config(); +const fs = require('fs'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./eddsa-sign-workspace-schema'); +const { + getEncryptedUserKey, + decryptUserSigningMaterial, + buildCommitmentPayload, + buildGSharePayload, +} = require('./eddsa-sign-helpers'); + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[OFFLINE] Wrote ${name}`); +} + +async function runStep1() { + const passphrase = process.env.WALLET_PASSPHRASE || ''; + if (!passphrase) throw new Error('WALLET_PASSPHRASE required'); + + const BitGo = require('bitgo').BitGo; + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + + const txRequest = readJson(FILES.txRequest); + const gpgConfig = readJson(FILES.bitgoGpgPublicKey); + const bitgoGpgPublicKey = gpgConfig.bitgoGpgPublicKey; + if (!bitgoGpgPublicKey) throw new Error('bitgo-gpg-public-key.json must contain bitgoGpgPublicKey'); + + const encryptedPrv = getEncryptedUserKey(workspacePath, FILES); + const signingMaterial = decryptUserSigningMaterial(bitgo, encryptedPrv, passphrase); + + const payload = await buildCommitmentPayload( + txRequest, + signingMaterial, + bitgoGpgPublicKey, + bitgo, + passphrase + ); + + writeJson(FILES.signCommitmentPayload, { + commitmentShare: payload.commitmentShare, + encryptedSignerShare: payload.encryptedSignerShare, + }); + + writeJson(FILES.signEddsaState, { + encryptedUserToBitgoRShare: payload.encryptedUserToBitgoRShare, + }); + + console.log('[OFFLINE] Step 1 done. Copy sign-commitment-payload.json to online machine, run online --step 1.'); +} + +async function runStep2() { + const passphrase = process.env.WALLET_PASSPHRASE || ''; + if (!passphrase) throw new Error('WALLET_PASSPHRASE required'); + + const BitGo = require('bitgo').BitGo; + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + + const commitmentPayload = readJson(FILES.signCommitmentPayload); + const state = readJson(FILES.signEddsaState); + if (!state.encryptedUserToBitgoRShare) { + throw new Error('sign-eddsa-state.json must contain encryptedUserToBitgoRShare'); + } + + const decrypted = bitgo.decrypt({ + input: state.encryptedUserToBitgoRShare.share, + password: passphrase, + }); + const userSignShare = JSON.parse(decrypted); + const bitgoIndex = 3; + const rShare = userSignShare.rShares[bitgoIndex]; + if (!rShare) { + throw new Error('userToBitgo RShare not found in decrypted User SignShare'); + } + + writeJson(FILES.signRPayload, { + userToBitgoRSignatureShare: { + from: 'user', + to: 'bitgo', + share: rShare.r + rShare.R, + }, + encryptedSignerShare: commitmentPayload.encryptedSignerShare, + }); + + console.log('[OFFLINE] Step 2 done. Copy sign-r-payload.json to online machine, run online --step 2.'); +} + +async function runStep3() { + const passphrase = process.env.WALLET_PASSPHRASE || ''; + if (!passphrase) throw new Error('WALLET_PASSPHRASE required'); + + const BitGo = require('bitgo').BitGo; + const bitgo = new BitGo({ env: process.env.BITGO_ENV || 'test' }); + + const txRequest = readJson(FILES.txRequest); + const commitmentResponse = readJson(FILES.signCommitmentResponse); + const rResponse = readJson(FILES.signRResponse); + const state = readJson(FILES.signEddsaState); + + const bitgoToUserCommitment = commitmentResponse.commitmentShare; + if (!bitgoToUserCommitment) { + throw new Error('sign-commitment-response.json must contain commitmentShare'); + } + + const bitgoToUserRShare = rResponse.bitgoToUserRShare; + if (!bitgoToUserRShare) { + throw new Error('sign-r-response.json must contain bitgoToUserRShare'); + } + + const encryptedPrv = getEncryptedUserKey(workspacePath, FILES); + const signingMaterial = decryptUserSigningMaterial(bitgo, encryptedPrv, passphrase); + + const { gShare } = await buildGSharePayload( + txRequest, + signingMaterial, + bitgoToUserRShare, + bitgoToUserCommitment, + state.encryptedUserToBitgoRShare, + bitgo, + passphrase + ); + + writeJson(FILES.signGPayload, { gShare }); + console.log('[OFFLINE] Step 3 done. Copy sign-g-payload.json to online machine, run online --step 3.'); +} + +async function main() { + const step = + process.argv.find((a) => a.startsWith('--step=')) + ? process.argv.find((a) => a.startsWith('--step=')).split('=')[1] + : process.argv[process.argv.indexOf('--step') + 1]; + if (!step || !['1', '2', '3'].includes(step)) { + console.error('Usage: node eddsa-self-custody-sign-offline.js --step 1|2|3'); + process.exit(1); + } + if (process.env.EDDSA_SIGN_WORKSPACE_DIR || process.env.MPC_SIGN_WORKSPACE_DIR) { + console.log('[OFFLINE] Workspace:', WORKSPACE_DIR); + } + if (step === '1') await runStep1(); + else if (step === '2') await runStep2(); + else if (step === '3') await runStep3(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-eddsa/eddsa-self-custody-sign-online.js b/examples/js/self-custody-eddsa/eddsa-self-custody-sign-online.js new file mode 100644 index 0000000000..3f8382cdb3 --- /dev/null +++ b/examples/js/self-custody-eddsa/eddsa-self-custody-sign-online.js @@ -0,0 +1,248 @@ +/** + * EdDSA TSS Self-Custody SIGN: ONLINE Script (Requires Network) + * + * Creates TxRequest, exchanges EdDSA commitments with BitGo, submits R/G signature shares, + * and finalizes the transaction. Never handles raw user signing material. + * + * Steps: + * 0: prebuildTransaction → tx-request.json; fetch BitGo MPCv1 GPG public key + * 1: POST .../commit (exchange commitments) + * 2: POST signature share (user R) + fetch BitGo→user R share + * 3: POST signature share (user G) + fetch finalized TxRequest (EdDSA auto-delivers on G share) + * + * Usage: + * BITGO_ACCESS_TOKEN=... COIN=tsol WALLET_ID=... node eddsa-self-custody-sign-online.js --step 0 + * node eddsa-self-custody-sign-online.js --step 1 + * node eddsa-self-custody-sign-online.js --step 2 + * node eddsa-self-custody-sign-online.js --step 3 + * + * Environment (step 0): BITGO_ACCESS_TOKEN, COIN, WALLET_ID, RECIPIENT_ADDRESS, AMOUNT + * + * BitGo docs: + * https://developers.bitgo.com/docs/withdraw-wallet-type-self-custody-mpc-hot-manual + * https://developers.bitgo.com/reference/v2wallettxrequestcreate + * https://developers.bitgo.com/reference/v2wallettxrequestsignaturesharecreate + */ +require('dotenv').config(); +const fs = require('fs'); +const { WORKSPACE_DIR, FILES, workspacePath } = require('./eddsa-sign-workspace-schema'); +const { wrapBitGoForV1Auth } = require('../self-custody-mcp-v2/bitgo-auth-utils'); + +function ensureWorkspace() { + if (!fs.existsSync(WORKSPACE_DIR)) { + fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + } +} + +function readJson(name) { + const p = workspacePath(name); + if (!fs.existsSync(p)) throw new Error(`Missing workspace file: ${name}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeJson(name, obj) { + ensureWorkspace(); + const p = workspacePath(name); + fs.writeFileSync(p, JSON.stringify(obj, null, 0), { mode: 0o600 }); + console.log(`[ONLINE] Wrote ${name}`); +} + +function getBitGo() { + const BitGo = require('bitgo').BitGo; + const accessToken = process.env.BITGO_ACCESS_TOKEN || ''; + if (!accessToken) throw new Error('BITGO_ACCESS_TOKEN required'); + const opts = { + env: process.env.BITGO_ENV || 'test', + accessToken, + }; + if (process.env.BITGO_CUSTOM_ROOT_URI) opts.customRootURI = process.env.BITGO_CUSTOM_ROOT_URI; + let bitgo = new BitGo(opts); + bitgo = wrapBitGoForV1Auth(bitgo); + bitgo.authenticateWithAccessToken({ accessToken }); + return bitgo; +} + +function getApiVersion(txRequest) { + return txRequest.apiVersion === 'full' ? 'full' : 'lite'; +} + +async function runStep0() { + const bitgo = getBitGo(); + const coinId = process.env.COIN || 'tsol'; + const walletId = process.env.WALLET_ID || ''; + if (!walletId) throw new Error('WALLET_ID required for step 0'); + + const coin = bitgo.coin(coinId); + const wallet = await coin.wallets().get({ id: walletId }); + + const recipientAddress = process.env.RECIPIENT_ADDRESS || ''; + const amount = process.env.AMOUNT || '0'; + if (!recipientAddress) throw new Error('RECIPIENT_ADDRESS required for step 0'); + const recipients = [{ address: recipientAddress, amount }]; + + const prebuildResult = await wallet.prebuildTransaction({ + type: 'transfer', + recipients, + apiVersion: 'full', + }); + const txRequestId = prebuildResult.txRequestId; + const reqWalletId = prebuildResult.walletId || walletId; + + const { commonTssMethods } = require('@bitgo/sdk-core'); + const { getTxRequest } = commonTssMethods; + const RequestTracer = require('@bitgo/sdk-core').RequestTracer; + const txRequest = await getTxRequest(bitgo, reqWalletId, txRequestId, new RequestTracer()); + + writeJson(FILES.txRequest, txRequest); + + const bitgoGpgPath = workspacePath(FILES.bitgoGpgPublicKey); + if (!fs.existsSync(bitgoGpgPath)) { + const constants = await bitgo.fetchConstants(); + const bitgoPublicKey = constants.mpc && constants.mpc.bitgoPublicKey; + if (!bitgoPublicKey) { + throw new Error('Unable to fetch BitGo MPCv1 GPG public key (constants.mpc.bitgoPublicKey missing)'); + } + writeJson(FILES.bitgoGpgPublicKey, { bitgoGpgPublicKey: bitgoPublicKey }); + } + + console.log( + '[ONLINE] Step 0 done. Copy tx-request.json and bitgo-gpg-public-key.json to offline machine, run offline --step 1.' + ); +} + +async function runStep1() { + const bitgo = getBitGo(); + const { commonTssMethods } = require('@bitgo/sdk-core'); + const { exchangeEddsaCommitments } = commonTssMethods; + + const txRequest = readJson(FILES.txRequest); + const walletId = txRequest.walletId; + const txRequestId = txRequest.txRequestId; + const apiVersion = getApiVersion(txRequest); + + const payload = readJson(FILES.signCommitmentPayload); + const { commitmentShare, encryptedSignerShare } = payload; + if (!commitmentShare || !encryptedSignerShare) { + throw new Error('sign-commitment-payload.json must contain commitmentShare and encryptedSignerShare'); + } + + const RequestTracer = require('@bitgo/sdk-core').RequestTracer; + const response = await exchangeEddsaCommitments( + bitgo, + walletId, + txRequestId, + commitmentShare, + encryptedSignerShare, + apiVersion, + new RequestTracer() + ); + + writeJson(FILES.signCommitmentResponse, response); + console.log('[ONLINE] Step 1 done. Copy sign-commitment-response.json to offline machine, run offline --step 2.'); +} + +async function runStep2() { + const bitgo = getBitGo(); + const { EDDSAMethods, RequestType } = require('@bitgo/sdk-core'); + const { sendSignatureShare, getBitgoToUserRShare } = EDDSAMethods; + + const txRequest = readJson(FILES.txRequest); + const walletId = txRequest.walletId; + const txRequestId = txRequest.txRequestId; + const apiVersion = getApiVersion(txRequest); + + const payload = readJson(FILES.signRPayload); + const { userToBitgoRSignatureShare, encryptedSignerShare } = payload; + if (!userToBitgoRSignatureShare || !encryptedSignerShare) { + throw new Error('sign-r-payload.json must contain userToBitgoRSignatureShare and encryptedSignerShare'); + } + + const RequestTracer = require('@bitgo/sdk-core').RequestTracer; + const reqId = new RequestTracer(); + + await sendSignatureShare( + bitgo, + walletId, + txRequestId, + userToBitgoRSignatureShare, + RequestType.tx, + encryptedSignerShare.share, + 'eddsa', + apiVersion, + undefined, + reqId + ); + + const bitgoToUserRShare = await getBitgoToUserRShare(bitgo, walletId, txRequestId, reqId, RequestType.tx); + + writeJson(FILES.signRResponse, { bitgoToUserRShare }); + console.log('[ONLINE] Step 2 done. Copy sign-r-response.json to offline machine, run offline --step 3.'); +} + +async function runStep3() { + const bitgo = getBitGo(); + const { EDDSAMethods, commonTssMethods, RequestType } = require('@bitgo/sdk-core'); + const { sendUserToBitgoGShare } = EDDSAMethods; + const { getTxRequest } = commonTssMethods; + + const txRequest = readJson(FILES.txRequest); + const walletId = txRequest.walletId; + const txRequestId = txRequest.txRequestId; + const apiVersion = getApiVersion(txRequest); + + const RequestTracer = require('@bitgo/sdk-core').RequestTracer; + const reqId = new RequestTracer(); + + let currentTxRequest = await getTxRequest(bitgo, walletId, txRequestId, reqId); + const alreadyDelivered = currentTxRequest.state === 'delivered'; + + if (!alreadyDelivered) { + const payload = readJson(FILES.signGPayload); + const { gShare } = payload; + if (!gShare) throw new Error('sign-g-payload.json must contain gShare'); + + await sendUserToBitgoGShare(bitgo, walletId, txRequestId, gShare, apiVersion, reqId, RequestType.tx); + currentTxRequest = await getTxRequest(bitgo, walletId, txRequestId, reqId); + } else { + console.log( + '[ONLINE] TxRequest is already delivered (G share was accepted earlier). Skipping G share submit; fetching final state.' + ); + } + + // EdDSA TSS: BitGo delivers the tx when the user G share is submitted (see EddsaUtils.signTxRequest). + // Do not call sendTxRequest here — that endpoint expects pendingDelivery and is used for ECDSA MPCv2. + writeJson(FILES.signResult, currentTxRequest); + + console.log('[ONLINE] Step 3 done. TxRequest state:', currentTxRequest.state); + console.log('[ONLINE] Sign result written to sign-result.json'); + const txHash = + currentTxRequest.transactions?.[0]?.txHash || + currentTxRequest.transactions?.[0]?.signedTx?.txHash || + currentTxRequest.transactions?.[0]?.signedTx?.id; + if (txHash) { + console.log('Tx hash:', txHash); + } +} + +async function main() { + const step = + process.argv.find((a) => a.startsWith('--step=')) + ? process.argv.find((a) => a.startsWith('--step=')).split('=')[1] + : process.argv[process.argv.indexOf('--step') + 1]; + if (!step || !['0', '1', '2', '3'].includes(step)) { + console.error('Usage: node eddsa-self-custody-sign-online.js --step 0|1|2|3'); + process.exit(1); + } + if (process.env.EDDSA_SIGN_WORKSPACE_DIR) { + console.log('[ONLINE] Workspace:', WORKSPACE_DIR); + } + if (step === '0') await runStep0(); + else if (step === '1') await runStep1(); + else if (step === '2') await runStep2(); + else if (step === '3') await runStep3(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/js/self-custody-eddsa/eddsa-sign-helpers.js b/examples/js/self-custody-eddsa/eddsa-sign-helpers.js new file mode 100644 index 0000000000..9c81d4f05e --- /dev/null +++ b/examples/js/self-custody-eddsa/eddsa-sign-helpers.js @@ -0,0 +1,182 @@ +/** + * Shared helpers for EdDSA TSS self-custody signing scripts (no network in offline helpers). + */ + +const fs = require('fs'); +const openpgp = require('openpgp'); +openpgp.config.rejectCurves = new Set(); + +const { + CommitmentType, + EncryptedSignerShareType, + SignatureShareType, +} = require('@bitgo/sdk-core'); + +const ShareKeyPosition = { USER: 1, BACKUP: 2, BITGO: 3 }; + +function getUnsignedTx(txRequest) { + if (txRequest.apiVersion === 'full') { + if (!txRequest.transactions || txRequest.transactions.length !== 1) { + throw new Error('TxRequest must have exactly one transaction (apiVersion full)'); + } + return txRequest.transactions[0].unsignedTx; + } + if (!txRequest.unsignedTxs || txRequest.unsignedTxs.length !== 1) { + throw new Error('TxRequest must have exactly one unsignedTx (apiVersion lite)'); + } + return txRequest.unsignedTxs[0]; +} + +function getEncryptedUserKey(workspacePath, files) { + if (process.env.ENCRYPTED_USER_KEY) { + return process.env.ENCRYPTED_USER_KEY; + } + const userMaterialPath = workspacePath(files.userSigningMaterial); + if (fs.existsSync(userMaterialPath)) { + const data = JSON.parse(fs.readFileSync(userMaterialPath, 'utf8')); + if (data.encryptedPrv) return data.encryptedPrv; + throw new Error('user-signing-material.json must contain encryptedPrv'); + } + throw new Error('Missing user-signing-material.json and ENCRYPTED_USER_KEY not set'); +} + +function decryptUserSigningMaterial(bitgo, encryptedPrv, passphrase) { + const prv = bitgo.decrypt({ input: encryptedPrv, password: passphrase }); + const signingMaterial = JSON.parse(prv); + if (!signingMaterial.backupYShare || !signingMaterial.bitgoYShare) { + throw new Error('Invalid user signing material: missing backupYShare or bitgoYShare'); + } + return signingMaterial; +} + +async function encryptTextToBitgo(text, bitgoGpgPublicKeyArmored) { + const publicKey = await openpgp.readKey({ armoredKey: bitgoGpgPublicKeyArmored }); + const message = await openpgp.createMessage({ text }); + return openpgp.encrypt({ + message, + encryptionKeys: [publicKey], + format: 'armored', + config: { + rejectCurves: new Set(), + showVersion: false, + showComment: false, + }, + }); +} + +async function deriveSigningKeyAndUserSignShare(txRequest, signingMaterial) { + const { Ed25519Bip32HdTree } = require('@bitgo/sdk-lib-mpc'); + const { Eddsa, EDDSAMethods } = require('@bitgo/sdk-core'); + const { createUserSignShare } = EDDSAMethods; + + const unsignedTx = getUnsignedTx(txRequest); + const hdTree = await Ed25519Bip32HdTree.initialize(); + const MPC = await Eddsa.initialize(hdTree); + + const signingKey = MPC.keyDerive( + signingMaterial.uShare, + [signingMaterial.bitgoYShare, signingMaterial.backupYShare], + unsignedTx.derivationPath + ); + + const signablePayload = Buffer.from(unsignedTx.signableHex, 'hex'); + const userSignShare = await createUserSignShare(signablePayload, signingKey.pShare); + + return { userSignShare, signingKey, signablePayload, unsignedTx }; +} + +function createCommitmentShareRecord(commitment) { + return { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: commitment, + type: CommitmentType.COMMITMENT, + }; +} + +function createEncryptedSignerShareRecord(encryptedArmored) { + return { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: encryptedArmored, + type: EncryptedSignerShareType.ENCRYPTED_SIGNER_SHARE, + }; +} + +function createEncryptedRShareRecord(encryptedArmored) { + return { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: encryptedArmored, + type: EncryptedSignerShareType.ENCRYPTED_R_SHARE, + }; +} + +async function buildCommitmentPayload(txRequest, signingMaterial, bitgoGpgPublicKeyArmored, bitgo, passphrase) { + const { userSignShare, signingKey } = await deriveSigningKeyAndUserSignShare(txRequest, signingMaterial); + + const bitgoIndex = ShareKeyPosition.BITGO; + const commitment = userSignShare.rShares[bitgoIndex]?.commitment; + if (!commitment) { + throw new Error('Missing user→BitGo commitment in user sign share'); + } + + const signerShare = signingKey.yShares[bitgoIndex].u + signingKey.yShares[bitgoIndex].chaincode; + const encryptedSignerShareArmored = await encryptTextToBitgo(signerShare, bitgoGpgPublicKeyArmored); + + const commitmentShare = createCommitmentShareRecord(commitment); + const encryptedSignerShare = createEncryptedSignerShareRecord(encryptedSignerShareArmored); + + const stringifiedSignShare = JSON.stringify(userSignShare); + const encryptedSignShare = bitgo.encrypt({ input: stringifiedSignShare, password: passphrase }); + const encryptedUserToBitgoRShare = createEncryptedRShareRecord(encryptedSignShare); + + const rShare = userSignShare.rShares[bitgoIndex]; + const userToBitgoRSignatureShare = { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: rShare.r + rShare.R, + }; + + return { + commitmentShare, + encryptedSignerShare, + encryptedUserToBitgoRShare, + userToBitgoRSignatureShare, + }; +} + +async function buildGSharePayload(txRequest, signingMaterial, bitgoToUserRShare, bitgoToUserCommitment, encryptedUserToBitgoRShare, bitgo, passphrase) { + const { createUserToBitGoGShare } = require('@bitgo/sdk-core').EDDSAMethods; + + const decrypted = bitgo.decrypt({ + input: encryptedUserToBitgoRShare.share, + password: passphrase, + }); + const userSignShare = JSON.parse(decrypted); + if (!userSignShare.xShare || !userSignShare.rShares) { + throw new Error('Decrypted sign share missing xShare or rShares'); + } + + const { signablePayload } = await deriveSigningKeyAndUserSignShare(txRequest, signingMaterial); + + const userToBitGoGShare = await createUserToBitGoGShare( + userSignShare, + bitgoToUserRShare, + signingMaterial.backupYShare, + signingMaterial.bitgoYShare, + signablePayload, + bitgoToUserCommitment + ); + + return { gShare: userToBitGoGShare }; +} + +module.exports = { + ShareKeyPosition, + getUnsignedTx, + getEncryptedUserKey, + decryptUserSigningMaterial, + buildCommitmentPayload, + buildGSharePayload, +}; diff --git a/examples/js/self-custody-eddsa/eddsa-sign-workspace-schema.js b/examples/js/self-custody-eddsa/eddsa-sign-workspace-schema.js new file mode 100644 index 0000000000..ebd9c96d9d --- /dev/null +++ b/examples/js/self-custody-eddsa/eddsa-sign-workspace-schema.js @@ -0,0 +1,46 @@ +/** + * EdDSA TSS self-custody SIGNING workspace: file names and directory. + * Used by eddsa-self-custody-sign-offline.js and eddsa-self-custody-sign-online.js. + * Do NOT commit the workspace directory; it may contain sensitive state. + * + * Flow (commitment → R share → G share), aligned with BitGo EdDSA MPCv1 TSS signing: + * - tx-request.json: TxRequest from online step 0 + * - bitgo-gpg-public-key.json: BitGo MPCv1 GPG public key (armored) + * - sign-commitment-payload.json: user commitment + encrypted signer share + encrypted R-share state + * - sign-commitment-response.json: BitGo commitment from POST .../commit + * - sign-eddsa-state.json: encrypted User SignShare (sensitive; offline only) + * - sign-r-payload.json: user→BitGo R signature share + encryptedSignerShare reference + * - sign-r-response.json: BitGo→user R share (from refreshed TxRequest) + * - sign-g-payload.json: user→BitGo G share fields for sendUserToBitgoGShare + * - sign-result.json: finalized TxRequest + * + * User signing material: user-signing-material.json { encryptedPrv } or ENCRYPTED_USER_KEY env. + * (From create-tss-wallet.js: wallet.userKeychain.encryptedPrv) + */ + +const path = require('path'); + +const WORKSPACE_DIR = + process.env.EDDSA_SIGN_WORKSPACE_DIR || + process.env.MPC_SIGN_WORKSPACE_DIR || + process.env.MPC_WORKSPACE_DIR || + path.join(__dirname, 'eddsa-sign-workspace'); + +const FILES = { + txRequest: 'tx-request.json', + bitgoGpgPublicKey: 'bitgo-gpg-public-key.json', + userSigningMaterial: 'user-signing-material.json', + signCommitmentPayload: 'sign-commitment-payload.json', + signCommitmentResponse: 'sign-commitment-response.json', + signEddsaState: 'sign-eddsa-state.json', + signRPayload: 'sign-r-payload.json', + signRResponse: 'sign-r-response.json', + signGPayload: 'sign-g-payload.json', + signResult: 'sign-result.json', +}; + +function workspacePath(filename) { + return path.join(WORKSPACE_DIR, filename); +} + +module.exports = { WORKSPACE_DIR, FILES, workspacePath };