|
| 1 | +# Adding a New Coin to wasm-utxo |
| 2 | + |
| 3 | +This guide covers adding support for a new UTXO coin to the wasm-utxo library. |
| 4 | +wasm-utxo handles low-level PSBT construction, transaction signing, and address |
| 5 | +encoding/decoding, compiled from Rust to WASM. It uses **foocoin** |
| 6 | +(`foo`/`tfoo`) as a worked example. |
| 7 | + |
| 8 | +## Overview of changes |
| 9 | + |
| 10 | +```mermaid |
| 11 | +graph TD |
| 12 | + N[src/networks.rs<br/>Network enum] --> A[src/address/mod.rs<br/>Codec constants] |
| 13 | + N --> C[js/coinName.ts<br/>CoinName type + helpers] |
| 14 | + A --> AN[src/address/networks.rs<br/>Codec wiring + script support] |
| 15 | + N --> P[src/fixed_script_wallet/bitgo_psbt/mod.rs<br/>PSBT deserialization + sighash] |
| 16 | + AN --> T[test/fixtures/<br/>Address + PSBT fixtures] |
| 17 | + C --> T |
| 18 | + P --> T |
| 19 | +``` |
| 20 | + |
| 21 | +## 1. Network enum |
| 22 | + |
| 23 | +**File:** `src/networks.rs` |
| 24 | + |
| 25 | +Add two variants to the `Network` enum (mainnet + testnet) and update every |
| 26 | +match arm. The Rust compiler will enforce exhaustive matching, so any missed arm |
| 27 | +will be a compile error. |
| 28 | + |
| 29 | +### Enum definition |
| 30 | + |
| 31 | +```rust |
| 32 | +pub enum Network { |
| 33 | + // ...existing variants... |
| 34 | + Foocoin, |
| 35 | + FoocoinTestnet, |
| 36 | +} |
| 37 | +``` |
| 38 | + |
| 39 | +### Match arms to update |
| 40 | + |
| 41 | +There are 5 match-based functions/arrays that need a new arm. Use the existing |
| 42 | +Dogecoin entries as a template for a simple coin. |
| 43 | + |
| 44 | +| Location | What to add | |
| 45 | +|----------|-------------| |
| 46 | +| `ALL` array | `Network::Foocoin, Network::FoocoinTestnet` | |
| 47 | +| `as_str()` | `"Foocoin"`, `"FoocoinTestnet"` | |
| 48 | +| `from_name_exact()` | `"Foocoin" => Some(Network::Foocoin)`, etc. | |
| 49 | +| `from_coin_name()` | `"foo" => Some(Network::Foocoin)`, `"tfoo" => ...` | |
| 50 | +| `to_coin_name()` | `Network::Foocoin => "foo"`, etc. | |
| 51 | +| `mainnet()` | `Network::Foocoin => Network::Foocoin`, `Network::FoocoinTestnet => Network::Foocoin` | |
| 52 | + |
| 53 | +> **Skip `from_utxolib_name()` / `to_utxolib_name()`** — these exist for |
| 54 | +> backwards compatibility with existing coins routed through the deprecated |
| 55 | +> utxo-lib. New coins must not be added to these functions. |
| 56 | +
|
| 57 | +Also update the test `test_all_networks` assertion count. |
| 58 | + |
| 59 | +## 2. TypeScript coin name |
| 60 | + |
| 61 | +**File:** `js/coinName.ts` |
| 62 | + |
| 63 | +Register the new coin's short names so that the TypeScript layer can reference |
| 64 | +them. The `CoinName` type is derived automatically from the `coinNames` tuple. |
| 65 | + |
| 66 | +1. Add `"foo"` and `"tfoo"` to the `coinNames` array. |
| 67 | +2. Add a `case "tfoo": return "foo"` arm to `getMainnet()`. |
| 68 | + |
| 69 | +No changes are needed to `isMainnet()` / `isTestnet()` — they delegate to |
| 70 | +`getMainnet()`. |
| 71 | + |
| 72 | +## 3. Address codec constants |
| 73 | + |
| 74 | +**File:** `src/address/mod.rs` |
| 75 | + |
| 76 | +Define the Base58Check version bytes for the coin. Find these in the coin's |
| 77 | +`chainparams.cpp` under `base58Prefixes[PUBKEY_ADDRESS]` and |
| 78 | +`base58Prefixes[SCRIPT_ADDRESS]`. |
| 79 | + |
| 80 | +```rust |
| 81 | +// Foocoin |
| 82 | +// https://github.com/example/foocoin/blob/master/src/chainparams.cpp |
| 83 | +pub const FOOCOIN: Base58CheckCodec = Base58CheckCodec::new(0x3f, 0x41); |
| 84 | +pub const FOOCOIN_TEST: Base58CheckCodec = Base58CheckCodec::new(0x6f, 0xc4); |
| 85 | +``` |
| 86 | + |
| 87 | +If the coin supports SegWit (bech32 addresses), also add: |
| 88 | + |
| 89 | +```rust |
| 90 | +pub const FOOCOIN_BECH32: Bech32Codec = Bech32Codec::new("foo"); |
| 91 | +pub const FOOCOIN_TEST_BECH32: Bech32Codec = Bech32Codec::new("tfoo"); |
| 92 | +``` |
| 93 | + |
| 94 | +If the coin uses CashAddr (like Bitcoin Cash), use `CashAddrCodec` instead. |
| 95 | + |
| 96 | +### Where to find version bytes |
| 97 | + |
| 98 | +| Coin | Source | |
| 99 | +|------|--------| |
| 100 | +| Bitcoin | `base58Prefixes[PUBKEY_ADDRESS] = {0}` → 0x00 | |
| 101 | +| Dogecoin | `base58Prefixes[PUBKEY_ADDRESS] = {30}` → 0x1e | |
| 102 | +| Zcash | Uses 2-byte versions: `{0x1C,0xB8}` → 0x1cb8 | |
| 103 | + |
| 104 | +## 4. Address codec wiring |
| 105 | + |
| 106 | +**File:** `src/address/networks.rs` |
| 107 | + |
| 108 | +Update three functions and one method. |
| 109 | + |
| 110 | +### get_decode_codecs() |
| 111 | + |
| 112 | +Returns the codecs to try when decoding an address string. |
| 113 | + |
| 114 | +```rust |
| 115 | +fn get_decode_codecs(network: Network) -> Vec<&'static dyn AddressCodec> { |
| 116 | + match network { |
| 117 | + // ...existing cases... |
| 118 | + Network::Foocoin => vec![&FOOCOIN, &FOOCOIN_BECH32], |
| 119 | + Network::FoocoinTestnet => vec![&FOOCOIN_TEST, &FOOCOIN_TEST_BECH32], |
| 120 | + } |
| 121 | +} |
| 122 | +``` |
| 123 | + |
| 124 | +If the coin does not support SegWit, omit the bech32 codec: |
| 125 | +```rust |
| 126 | +Network::Foocoin => vec![&FOOCOIN], |
| 127 | +``` |
| 128 | + |
| 129 | +### get_encode_codec() |
| 130 | + |
| 131 | +Returns the single codec to use when encoding an output script to an address. |
| 132 | + |
| 133 | +```rust |
| 134 | +fn get_encode_codec(network: Network, script: &Script, format: AddressFormat) |
| 135 | + -> Result<&'static dyn AddressCodec> |
| 136 | +{ |
| 137 | + match network { |
| 138 | + // ...existing cases... |
| 139 | + Network::Foocoin => { |
| 140 | + if is_witness { Ok(&FOOCOIN_BECH32) } else { Ok(&FOOCOIN) } |
| 141 | + } |
| 142 | + Network::FoocoinTestnet => { |
| 143 | + if is_witness { Ok(&FOOCOIN_TEST_BECH32) } else { Ok(&FOOCOIN_TEST) } |
| 144 | + } |
| 145 | + } |
| 146 | +} |
| 147 | +``` |
| 148 | + |
| 149 | +### output_script_support() |
| 150 | + |
| 151 | +Declares which script types the coin supports. |
| 152 | + |
| 153 | +```rust |
| 154 | +impl Network { |
| 155 | + pub fn output_script_support(&self) -> OutputScriptSupport { |
| 156 | + let segwit = matches!( |
| 157 | + self.mainnet(), |
| 158 | + Network::Bitcoin | Network::Litecoin | Network::BitcoinGold |
| 159 | + | Network::Foocoin // <-- add if coin supports segwit |
| 160 | + ); |
| 161 | + |
| 162 | + let taproot = segwit && matches!( |
| 163 | + self.mainnet(), |
| 164 | + Network::Bitcoin |
| 165 | + // Foocoin intentionally omitted — no taproot |
| 166 | + ); |
| 167 | + |
| 168 | + OutputScriptSupport { segwit, taproot } |
| 169 | + } |
| 170 | +} |
| 171 | +``` |
| 172 | + |
| 173 | +## 5. PSBT deserialization |
| 174 | + |
| 175 | +**File:** `src/fixed_script_wallet/bitgo_psbt/mod.rs` |
| 176 | + |
| 177 | +### BitGoPsbt::deserialize() |
| 178 | + |
| 179 | +The `BitGoPsbt` enum has three variants: |
| 180 | + |
| 181 | +| Variant | When to use | |
| 182 | +|---------|-------------| |
| 183 | +| `BitcoinLike(Psbt, Network)` | Standard Bitcoin transaction format (most coins) | |
| 184 | +| `Dash(DashBitGoPsbt, Network)` | Dash special transaction format | |
| 185 | +| `Zcash(ZcashBitGoPsbt, Network)` | Zcash overwintered transaction format | |
| 186 | + |
| 187 | +For most Bitcoin forks, use `BitcoinLike`: |
| 188 | + |
| 189 | +```rust |
| 190 | +pub fn deserialize(psbt_bytes: &[u8], network: Network) -> Result<BitGoPsbt, DeserializeError> { |
| 191 | + match network { |
| 192 | + // ...existing cases... |
| 193 | + |
| 194 | + // Add foocoin to the BitcoinLike arm: |
| 195 | + Network::Bitcoin |
| 196 | + | Network::BitcoinTestnet3 |
| 197 | + // ... |
| 198 | + | Network::Foocoin // <-- add |
| 199 | + | Network::FoocoinTestnet // <-- add |
| 200 | + => Ok(BitGoPsbt::BitcoinLike( |
| 201 | + Psbt::deserialize(psbt_bytes)?, |
| 202 | + network, |
| 203 | + )), |
| 204 | + } |
| 205 | +} |
| 206 | +``` |
| 207 | + |
| 208 | +If the coin has a non-standard transaction format (like Zcash's overwintered |
| 209 | +format or Dash's special transactions), you'll need to create a dedicated PSBT |
| 210 | +type. See `zcash_psbt.rs` or `dash_psbt.rs` as examples. |
| 211 | + |
| 212 | +### BitGoPsbt::new() / new_internal() |
| 213 | + |
| 214 | +Similarly, add foocoin to the arm that creates empty PSBTs. If the coin is |
| 215 | +BitcoinLike, it will be handled by the existing fallthrough. |
| 216 | + |
| 217 | +### get_default_sighash_type() |
| 218 | + |
| 219 | +**Location:** Same file, `get_default_sighash_type()` function. |
| 220 | + |
| 221 | +If foocoin uses `SIGHASH_ALL|FORKID` (like BCH, BTG, BSV), add it to the |
| 222 | +`uses_forkid` match: |
| 223 | + |
| 224 | +```rust |
| 225 | +let uses_forkid = matches!( |
| 226 | + network.mainnet(), |
| 227 | + Network::BitcoinCash | Network::BitcoinGold | Network::BitcoinSV | Network::Ecash |
| 228 | + // | Network::Foocoin // <-- only if coin uses FORKID |
| 229 | +); |
| 230 | +``` |
| 231 | + |
| 232 | +If foocoin uses standard `SIGHASH_ALL`, no change is needed — it falls through |
| 233 | +to the default. |
| 234 | + |
| 235 | +## 6. Test fixtures |
| 236 | + |
| 237 | +### Address fixtures |
| 238 | + |
| 239 | +**Directory:** `test/fixtures/address/` |
| 240 | + |
| 241 | +Create `foocoin.json` with test vectors: `[scriptType, scriptHex, expectedAddress]`. |
| 242 | + |
| 243 | +The easiest way to generate these is to use the coin's reference implementation |
| 244 | +or a known address from a block explorer. You need vectors for each supported |
| 245 | +script type (P2PKH, P2SH, and P2WPKH/P2WSH if segwit-capable). |
| 246 | + |
| 247 | +```json |
| 248 | +[ |
| 249 | + ["p2pkh", "76a914...88ac", "F..."], |
| 250 | + ["p2sh", "a914...87", "3..."], |
| 251 | + ["p2wpkh","0014...", "foo1..."] |
| 252 | +] |
| 253 | +``` |
| 254 | + |
| 255 | +Also update `get_codecs_for_fixture()` in `src/address/mod.rs` (test section): |
| 256 | +```rust |
| 257 | +"foocoin.json" => vec![&FOOCOIN, &FOOCOIN_BECH32], |
| 258 | +``` |
| 259 | + |
| 260 | +### PSBT fixtures |
| 261 | + |
| 262 | +**Directory:** `test/fixtures/fixed-script/` |
| 263 | + |
| 264 | +PSBT fixtures are **auto-generated** when the JSON files don't exist on disk. |
| 265 | +The generator lives in `test/fixedScript/generateFixture.ts` and creates PSBTs |
| 266 | +with one input per supported script type plus a replay protection input, then |
| 267 | +signs progressively to produce all three signature states. |
| 268 | + |
| 269 | +Fixtures are generated for two transaction formats (`psbt` and `psbt-lite`), |
| 270 | +giving six files per coin: |
| 271 | + |
| 272 | +- `psbt.foo.unsigned.json` / `psbt-lite.foo.unsigned.json` |
| 273 | +- `psbt.foo.halfsigned.json` / `psbt-lite.foo.halfsigned.json` |
| 274 | +- `psbt.foo.fullsigned.json` / `psbt-lite.foo.fullsigned.json` |
| 275 | + |
| 276 | +The `psbt` format includes `non_witness_utxo` on every input; `psbt-lite` |
| 277 | +omits it. Zcash skips the `psbt` format because it does not support |
| 278 | +`non_witness_utxo`. |
| 279 | + |
| 280 | +**To generate fixtures for a new coin:** |
| 281 | + |
| 282 | +No manual registration step is needed — `mainnetCoinNames` in |
| 283 | +`test/fixedScript/networkSupport.util.ts` is derived automatically from |
| 284 | +`coinNames` in `js/coinName.ts` (step 2). On the first test run, |
| 285 | +`loadPsbtFixture()` detects missing fixture files, generates them, writes |
| 286 | +them to disk, and then throws an error prompting you to commit the new files. |
| 287 | +Re-run the tests after committing. |
| 288 | + |
| 289 | +The generator selects script types based on `output_script_support()`: |
| 290 | + |
| 291 | +| Network capability | Chains included | |
| 292 | +|--------------------|----------------| |
| 293 | +| Legacy only | 0 (p2sh) | |
| 294 | +| Segwit | 0, 10 (p2shP2wsh), 20 (p2wsh) | |
| 295 | +| Taproot | + 30 (p2trLegacy), 40 (p2trMusig2 script path + key path) | |
| 296 | + |
| 297 | +If the generated fixtures need updating (e.g. after changing signing logic), |
| 298 | +delete the JSON files and re-run the tests to regenerate them. |
| 299 | + |
| 300 | +## 7. TypeScript bindings |
| 301 | + |
| 302 | +The TypeScript layer wraps the WASM module. The `NetworkName` type should |
| 303 | +automatically include new networks if it's derived from the Rust enum's string |
| 304 | +representation. Verify that: |
| 305 | + |
| 306 | +- `fixedScriptWallet.BitGoPsbt.fromBytes(buf, "foo")` works |
| 307 | +- `fixedScriptWallet.address(rootWalletKeys, chainCode, index, network)` works |
| 308 | + |
| 309 | +If `NetworkName` is a manually maintained union type, add `'foo' | 'tfoo'` to it. |
| 310 | + |
| 311 | +## 8. Run tests |
| 312 | + |
| 313 | +```bash |
| 314 | +# Rust tests (address encoding, PSBT parsing, signing) |
| 315 | +cargo test |
| 316 | + |
| 317 | +# TypeScript integration tests |
| 318 | +npm test |
| 319 | +``` |
| 320 | + |
| 321 | +## 9. Checklist |
| 322 | + |
| 323 | +- [ ] `src/networks.rs`: `Foocoin` + `FoocoinTestnet` added to enum + all 7 match arms + `ALL` |
| 324 | +- [ ] `js/coinName.ts`: `"foo"` + `"tfoo"` added to `coinNames`, `getMainnet()` updated |
| 325 | +- [ ] `src/address/mod.rs`: Codec constants defined (Base58Check, optionally Bech32/CashAddr) |
| 326 | +- [ ] `src/address/networks.rs`: `get_decode_codecs()` updated |
| 327 | +- [ ] `src/address/networks.rs`: `get_encode_codec()` updated |
| 328 | +- [ ] `src/address/networks.rs`: `output_script_support()` updated (segwit/taproot flags) |
| 329 | +- [ ] `src/fixed_script_wallet/bitgo_psbt/mod.rs`: `deserialize()` case added |
| 330 | +- [ ] `src/fixed_script_wallet/bitgo_psbt/mod.rs`: `get_default_sighash_type()` updated (if FORKID) |
| 331 | +- [ ] `test/fixtures/address/foocoin.json` created |
| 332 | +- [ ] `test/fixtures/fixed-script/psbt.foo.*.json` + `psbt-lite.foo.*.json` auto-generated by `npm test` |
| 333 | +- [ ] TypeScript `NetworkName` includes new network |
| 334 | +- [ ] `cargo test` passes |
| 335 | +- [ ] `npm test` passes |
0 commit comments