From 1514d0e9c8734f1df438991e1e17a30301763464 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 18 Feb 2026 15:16:43 +0100 Subject: [PATCH] docs(wasm-utxo): add comprehensive new coin integration guide Add detailed guide for integrating new UTXO coins into wasm-utxo. Include step-by-step instructions for network enum updates, address codec configuration, PSBT handling, and fixture generation. Document match arm requirements, version byte sources, script type support flags, and sighash configuration. Provide worked example using foocoin throughout with code snippets, architecture diagram, and complete testing checklist. BTC-3047 Co-authored-by: llm-git --- packages/wasm-utxo/docs/adding-new-coin.md | 337 +++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 packages/wasm-utxo/docs/adding-new-coin.md diff --git a/packages/wasm-utxo/docs/adding-new-coin.md b/packages/wasm-utxo/docs/adding-new-coin.md new file mode 100644 index 0000000..4c67166 --- /dev/null +++ b/packages/wasm-utxo/docs/adding-new-coin.md @@ -0,0 +1,337 @@ +# Adding a New Coin to wasm-utxo + +This guide covers adding support for a new UTXO coin to the wasm-utxo library. +wasm-utxo handles low-level PSBT construction, transaction signing, and address +encoding/decoding, compiled from Rust to WASM. It uses **foocoin** +(`foo`/`tfoo`) as a worked example. + +## Overview of changes + +```mermaid +graph TD + N[src/networks.rs
Network enum] --> A[src/address/mod.rs
Codec constants] + N --> C[js/coinName.ts
CoinName type + helpers] + A --> AN[src/address/networks.rs
Codec wiring + script support] + N --> P[src/fixed_script_wallet/bitgo_psbt/mod.rs
PSBT deserialization + sighash] + AN --> T[test/fixtures/
Address + PSBT fixtures] + C --> T + P --> T +``` + +## 1. Network enum + +**File:** `src/networks.rs` + +Add two variants to the `Network` enum (mainnet + testnet) and update every +match arm. The Rust compiler will enforce exhaustive matching, so any missed arm +will be a compile error. + +### Enum definition + +```rust +pub enum Network { + // ...existing variants... + Foocoin, + FoocoinTestnet, +} +``` + +### Match arms to update + +There are 5 match-based functions/arrays that need a new arm. Use the existing +Dogecoin entries as a template for a simple coin. + +| Location | What to add | +| ------------------- | ------------------------------------------------------------------------------------- | +| `ALL` array | `Network::Foocoin, Network::FoocoinTestnet` | +| `as_str()` | `"Foocoin"`, `"FoocoinTestnet"` | +| `from_name_exact()` | `"Foocoin" => Some(Network::Foocoin)`, etc. | +| `from_coin_name()` | `"foo" => Some(Network::Foocoin)`, `"tfoo" => ...` | +| `to_coin_name()` | `Network::Foocoin => "foo"`, etc. | +| `mainnet()` | `Network::Foocoin => Network::Foocoin`, `Network::FoocoinTestnet => Network::Foocoin` | + +> **Skip `from_utxolib_name()` / `to_utxolib_name()`** — these exist for +> backwards compatibility with existing coins routed through the deprecated +> utxo-lib. New coins must not be added to these functions. + +Also update the test `test_all_networks` assertion count. + +## 2. TypeScript coin name + +**File:** `js/coinName.ts` + +Register the new coin's short names so that the TypeScript layer can reference +them. The `CoinName` type is derived automatically from the `coinNames` tuple. + +1. Add `"foo"` and `"tfoo"` to the `coinNames` array. +2. Add a `case "tfoo": return "foo"` arm to `getMainnet()`. + +No changes are needed to `isMainnet()` / `isTestnet()` — they delegate to +`getMainnet()`. + +## 3. Address codec constants + +**File:** `src/address/mod.rs` + +Define the Base58Check version bytes for the coin. Find these in the coin's +`chainparams.cpp` under `base58Prefixes[PUBKEY_ADDRESS]` and +`base58Prefixes[SCRIPT_ADDRESS]`. + +```rust +// Foocoin +// https://github.com/example/foocoin/blob/master/src/chainparams.cpp +pub const FOOCOIN: Base58CheckCodec = Base58CheckCodec::new(0x3f, 0x41); +pub const FOOCOIN_TEST: Base58CheckCodec = Base58CheckCodec::new(0x6f, 0xc4); +``` + +If the coin supports SegWit (bech32 addresses), also add: + +```rust +pub const FOOCOIN_BECH32: Bech32Codec = Bech32Codec::new("foo"); +pub const FOOCOIN_TEST_BECH32: Bech32Codec = Bech32Codec::new("tfoo"); +``` + +If the coin uses CashAddr (like Bitcoin Cash), use `CashAddrCodec` instead. + +### Where to find version bytes + +| Coin | Source | +| -------- | ---------------------------------------------- | +| Bitcoin | `base58Prefixes[PUBKEY_ADDRESS] = {0}` → 0x00 | +| Dogecoin | `base58Prefixes[PUBKEY_ADDRESS] = {30}` → 0x1e | +| Zcash | Uses 2-byte versions: `{0x1C,0xB8}` → 0x1cb8 | + +## 4. Address codec wiring + +**File:** `src/address/networks.rs` + +Update three functions and one method. + +### get_decode_codecs() + +Returns the codecs to try when decoding an address string. + +```rust +fn get_decode_codecs(network: Network) -> Vec<&'static dyn AddressCodec> { + match network { + // ...existing cases... + Network::Foocoin => vec![&FOOCOIN, &FOOCOIN_BECH32], + Network::FoocoinTestnet => vec![&FOOCOIN_TEST, &FOOCOIN_TEST_BECH32], + } +} +``` + +If the coin does not support SegWit, omit the bech32 codec: + +```rust +Network::Foocoin => vec![&FOOCOIN], +``` + +### get_encode_codec() + +Returns the single codec to use when encoding an output script to an address. + +```rust +fn get_encode_codec(network: Network, script: &Script, format: AddressFormat) + -> Result<&'static dyn AddressCodec> +{ + match network { + // ...existing cases... + Network::Foocoin => { + if is_witness { Ok(&FOOCOIN_BECH32) } else { Ok(&FOOCOIN) } + } + Network::FoocoinTestnet => { + if is_witness { Ok(&FOOCOIN_TEST_BECH32) } else { Ok(&FOOCOIN_TEST) } + } + } +} +``` + +### output_script_support() + +Declares which script types the coin supports. + +```rust +impl Network { + pub fn output_script_support(&self) -> OutputScriptSupport { + let segwit = matches!( + self.mainnet(), + Network::Bitcoin | Network::Litecoin | Network::BitcoinGold + | Network::Foocoin // <-- add if coin supports segwit + ); + + let taproot = segwit && matches!( + self.mainnet(), + Network::Bitcoin + // Foocoin intentionally omitted — no taproot + ); + + OutputScriptSupport { segwit, taproot } + } +} +``` + +## 5. PSBT deserialization + +**File:** `src/fixed_script_wallet/bitgo_psbt/mod.rs` + +### BitGoPsbt::deserialize() + +The `BitGoPsbt` enum has three variants: + +| Variant | When to use | +| -------------------------------- | ------------------------------------------------ | +| `BitcoinLike(Psbt, Network)` | Standard Bitcoin transaction format (most coins) | +| `Dash(DashBitGoPsbt, Network)` | Dash special transaction format | +| `Zcash(ZcashBitGoPsbt, Network)` | Zcash overwintered transaction format | + +For most Bitcoin forks, use `BitcoinLike`: + +```rust +pub fn deserialize(psbt_bytes: &[u8], network: Network) -> Result { + match network { + // ...existing cases... + + // Add foocoin to the BitcoinLike arm: + Network::Bitcoin + | Network::BitcoinTestnet3 + // ... + | Network::Foocoin // <-- add + | Network::FoocoinTestnet // <-- add + => Ok(BitGoPsbt::BitcoinLike( + Psbt::deserialize(psbt_bytes)?, + network, + )), + } +} +``` + +If the coin has a non-standard transaction format (like Zcash's overwintered +format or Dash's special transactions), you'll need to create a dedicated PSBT +type. See `zcash_psbt.rs` or `dash_psbt.rs` as examples. + +### BitGoPsbt::new() / new_internal() + +Similarly, add foocoin to the arm that creates empty PSBTs. If the coin is +BitcoinLike, it will be handled by the existing fallthrough. + +### get_default_sighash_type() + +**Location:** Same file, `get_default_sighash_type()` function. + +If foocoin uses `SIGHASH_ALL|FORKID` (like BCH, BTG, BSV), add it to the +`uses_forkid` match: + +```rust +let uses_forkid = matches!( + network.mainnet(), + Network::BitcoinCash | Network::BitcoinGold | Network::BitcoinSV | Network::Ecash + // | Network::Foocoin // <-- only if coin uses FORKID +); +``` + +If foocoin uses standard `SIGHASH_ALL`, no change is needed — it falls through +to the default. + +## 6. Test fixtures + +### Address fixtures + +**Directory:** `test/fixtures/address/` + +Create `foocoin.json` with test vectors: `[scriptType, scriptHex, expectedAddress]`. + +The easiest way to generate these is to use the coin's reference implementation +or a known address from a block explorer. You need vectors for each supported +script type (P2PKH, P2SH, and P2WPKH/P2WSH if segwit-capable). + +```json +[ + ["p2pkh", "76a914...88ac", "F..."], + ["p2sh", "a914...87", "3..."], + ["p2wpkh", "0014...", "foo1..."] +] +``` + +Also update `get_codecs_for_fixture()` in `src/address/mod.rs` (test section): + +```rust +"foocoin.json" => vec![&FOOCOIN, &FOOCOIN_BECH32], +``` + +### PSBT fixtures + +**Directory:** `test/fixtures/fixed-script/` + +PSBT fixtures are **auto-generated** when the JSON files don't exist on disk. +The generator lives in `test/fixedScript/generateFixture.ts` and creates PSBTs +with one input per supported script type plus a replay protection input, then +signs progressively to produce all three signature states. + +Fixtures are generated for two transaction formats (`psbt` and `psbt-lite`), +giving six files per coin: + +- `psbt.foo.unsigned.json` / `psbt-lite.foo.unsigned.json` +- `psbt.foo.halfsigned.json` / `psbt-lite.foo.halfsigned.json` +- `psbt.foo.fullsigned.json` / `psbt-lite.foo.fullsigned.json` + +The `psbt` format includes `non_witness_utxo` on every input; `psbt-lite` +omits it. Zcash skips the `psbt` format because it does not support +`non_witness_utxo`. + +**To generate fixtures for a new coin:** + +No manual registration step is needed — `mainnetCoinNames` in +`test/fixedScript/networkSupport.util.ts` is derived automatically from +`coinNames` in `js/coinName.ts` (step 2). On the first test run, +`loadPsbtFixture()` detects missing fixture files, generates them, writes +them to disk, and then throws an error prompting you to commit the new files. +Re-run the tests after committing. + +The generator selects script types based on `output_script_support()`: + +| Network capability | Chains included | +| ------------------ | --------------------------------------------------------- | +| Legacy only | 0 (p2sh) | +| Segwit | 0, 10 (p2shP2wsh), 20 (p2wsh) | +| Taproot | + 30 (p2trLegacy), 40 (p2trMusig2 script path + key path) | + +If the generated fixtures need updating (e.g. after changing signing logic), +delete the JSON files and re-run the tests to regenerate them. + +## 7. TypeScript bindings + +The TypeScript layer wraps the WASM module. The `NetworkName` type should +automatically include new networks if it's derived from the Rust enum's string +representation. Verify that: + +- `fixedScriptWallet.BitGoPsbt.fromBytes(buf, "foo")` works +- `fixedScriptWallet.address(rootWalletKeys, chainCode, index, network)` works + +If `NetworkName` is a manually maintained union type, add `'foo' | 'tfoo'` to it. + +## 8. Run tests + +```bash +# Rust tests (address encoding, PSBT parsing, signing) +cargo test + +# TypeScript integration tests +npm test +``` + +## 9. Checklist + +- [ ] `src/networks.rs`: `Foocoin` + `FoocoinTestnet` added to enum + all 7 match arms + `ALL` +- [ ] `js/coinName.ts`: `"foo"` + `"tfoo"` added to `coinNames`, `getMainnet()` updated +- [ ] `src/address/mod.rs`: Codec constants defined (Base58Check, optionally Bech32/CashAddr) +- [ ] `src/address/networks.rs`: `get_decode_codecs()` updated +- [ ] `src/address/networks.rs`: `get_encode_codec()` updated +- [ ] `src/address/networks.rs`: `output_script_support()` updated (segwit/taproot flags) +- [ ] `src/fixed_script_wallet/bitgo_psbt/mod.rs`: `deserialize()` case added +- [ ] `src/fixed_script_wallet/bitgo_psbt/mod.rs`: `get_default_sighash_type()` updated (if FORKID) +- [ ] `test/fixtures/address/foocoin.json` created +- [ ] `test/fixtures/fixed-script/psbt.foo.*.json` + `psbt-lite.foo.*.json` auto-generated by `npm test` +- [ ] TypeScript `NetworkName` includes new network +- [ ] `cargo test` passes +- [ ] `npm test` passes