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