From 6bfe3a76f7649e83c0907c63853c88c75c29dd3a Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Fri, 11 Sep 2026 19:53:37 +0530 Subject: [PATCH] feat(abstract-utxo): add zec address codec with unified address support Ticket: CSHLD-1639 --- modules/abstract-utxo/src/impl/zec/address.ts | 81 +++++++- modules/abstract-utxo/src/impl/zec/zec.ts | 12 +- .../test/unit/impl/zec/unit/address.ts | 183 ++++++++++++++++++ 3 files changed, 270 insertions(+), 6 deletions(-) diff --git a/modules/abstract-utxo/src/impl/zec/address.ts b/modules/abstract-utxo/src/impl/zec/address.ts index a025b7b13e..5273252d81 100644 --- a/modules/abstract-utxo/src/impl/zec/address.ts +++ b/modules/abstract-utxo/src/impl/zec/address.ts @@ -1,7 +1,86 @@ -import { address as wasmAddress, fixedScriptWallet, isCoinName } from '@bitgo/wasm-utxo'; +import { address as wasmAddress, fixedScriptWallet, isCoinName, zcashAddress } from '@bitgo/wasm-utxo'; +import type { UnifiedRecipientPreference } from '@bitgo/sdk-core'; + +import { AddressCodec } from '../../transaction/recipient'; +import { UtxoCoinName, WasmUtxoCoinName } from '../../names'; export type ZcashAddressKind = 'transparent' | 'shielded'; +/** + * Address codec for Zcash coins ('zec'/'tzec') that understands ZIP-316 + * Unified Addresses in addition to ordinary transparent addresses. + * + * - `decode` resolves the bytes a recipient pays to (see its doc): by default + * the transparent scriptPubKey; with `unifiedRecipientPreference: 'shielded'` + * the raw Orchard/Ironwood receiver of a Unified Address. + * - `encode`/`toExtendedAddressFormat` are unchanged: scripts can only be + * written back out as transparent addresses. + * - `isValidAddress` accepts transparent addresses and Unified Addresses with + * a transparent or Orchard receiver. + */ +export class ZecAddressCodec extends AddressCodec { + /** The Zcash network name for wasm calls — 'zec' or 'tzec'. */ + private readonly zcashNetworkName: fixedScriptWallet.ZcashNetworkName; + /** How Unified Address recipients resolve when this codec decodes them. */ + private readonly unifiedRecipientPreference: UnifiedRecipientPreference; + + constructor( + coinName: UtxoCoinName, + wasmName: WasmUtxoCoinName, + unifiedRecipientPreference: UnifiedRecipientPreference = 'transparent' + ) { + super(coinName, wasmName); + this.zcashNetworkName = wasmName as fixedScriptWallet.ZcashNetworkName; + this.unifiedRecipientPreference = unifiedRecipientPreference; + } + + /** + * Whether `address` is a valid Zcash address for this coin's network. + * + * Mirrors the pre-codec `Zec.isValidAddress` logic exactly: the address is + * valid iff it has a usable transparent receiver (an ordinary transparent + * address, or a UA carrying a transparent receiver) or an Orchard receiver + * (a UA carrying one). + */ + override isValidAddress(address: string): boolean { + return ( + zcashAddress.hasTransparentReceiver(address, this.wasmName) || + zcashAddress.hasOrchardReceiver(address, this.wasmName) + ); + } + + /** + * Resolve `address` to the bytes this codec's unified-recipient preference pays to. + * + * - `'transparent'` (default) resolves the scriptPubKey: an ordinary + * transparent address decodes to its own script, and a UA with a + * transparent receiver decodes to that receiver's script. A UA without a + * transparent receiver (e.g. Orchard-only) throws, since a shielded + * output has no script. + * - `'shielded'` resolves the raw Orchard (Ironwood) receiver — the + * 43-byte diversifier + `pk_d` — of a UA carrying one. Any other address + * (plain transparent, transparent-only UA, malformed, wrong network) + * throws, since it has no Orchard receiver to resolve. + */ + override decode(address: string): Uint8Array { + if (this.unifiedRecipientPreference !== 'shielded') { + return zcashAddress.toTransparentReceiverWithCoin(address, this.wasmName); + } + // The raw Orchard receiver exists only for a Unified Address carrying one; anything else + // (plain transparent address, transparent-only UA, malformed, wrong network) throws. + let unified: fixedScriptWallet.ZcashUnifiedAddress | undefined; + try { + unified = fixedScriptWallet.ZcashUnifiedAddress.parse(address, this.zcashNetworkName); + } catch { + throw new Error(`address ${address} is not a valid address for network ${this.zcashNetworkName}`); + } + if (!unified?.hasOrchardReceiver) { + throw new Error(`address ${address} has no Orchard receiver to resolve as shielded`); + } + return zcashAddress.toShieldedReceiverWithCoin(address, this.wasmName); + } +} + /** * Whether `address` is a well-formed ZIP-316 Unified Address for `network` * with an Orchard receiver. BitGo only supports Orchard, so a UA without one diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index ddbc25c301..0a69f14a0b 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -2,11 +2,12 @@ * @prettier */ import { BitGoBase, MPCAlgorithm } from '@bitgo/sdk-core'; -import { zcashAddress } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; import { UtxoCoinName } from '../../names'; +import { ZecAddressCodec } from './address'; + export class Zec extends AbstractUtxoCoin { readonly name: UtxoCoinName = 'zec'; @@ -28,10 +29,11 @@ export class Zec extends AbstractUtxoCoin { return new Zec(bitgo); } + override get addressCodec(): ZecAddressCodec { + return new ZecAddressCodec(this.name, this.wasmName); + } + isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean { - return ( - zcashAddress.hasTransparentReceiver(address, this.wasmName) || - zcashAddress.hasOrchardReceiver(address, this.wasmName) - ); + return this.addressCodec.isValidAddress(address); } } diff --git a/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts b/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts index d32423bf2a..511a8bfdb2 100644 --- a/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts +++ b/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; import { BitGoAPI } from '@bitgo/sdk-api'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; import { Zec, Tzec, + ZecAddressCodec, getZcashAddressKind, isShieldedZcashAddress, isValidZcashAddress, @@ -77,3 +79,184 @@ describe('Zcash address validation', function () { assert.strictEqual(isValidZcashAddress(zip316Mainnet.unified, 'tzec'), false); }); }); + +describe('ZecAddressCodec', function () { + // -- instantiation ---------------------------------------------------------- + + let bitgo: BitGoAPI; + let zec; + let tzec; + + before(function () { + bitgo = new BitGoAPI({ env: 'mock' }); + bitgo.register('zec', Zec.createInstance); + bitgo.register('tzec', Tzec.createInstance); + zec = bitgo.coin('zec'); + tzec = bitgo.coin('tzec'); + }); + + it('constructs for zec and tzec', function () { + const mainnet = new ZecAddressCodec('zec', 'zec'); + const testnet = new ZecAddressCodec('tzec', 'tzec'); + assert.strictEqual(mainnet.coinName, 'zec'); + assert.strictEqual(testnet.coinName, 'tzec'); + }); + + // -- isValidAddress -------------------------------------------------------- + + it('isValidAddress: accepts mainnet UA', function () { + assert.strictEqual(new ZecAddressCodec('zec', 'zec').isValidAddress(zip316Mainnet.unified), true); + }); + + it('isValidAddress: accepts testnet UA', function () { + assert.strictEqual(new ZecAddressCodec('tzec', 'tzec').isValidAddress(testnetWallet.unified), true); + }); + + it('isValidAddress: accepts mainnet P2PKH', function () { + assert.strictEqual(new ZecAddressCodec('zec', 'zec').isValidAddress('t1cN2ZVWzWcVRrnfeQzmkpLhzQ4dYRv8yRY'), true); + }); + + it('isValidAddress: accepts testnet transparent address', function () { + assert.strictEqual(new ZecAddressCodec('tzec', 'tzec').isValidAddress(testnetWallet.transparentAddress), true); + }); + + it('isValidAddress: rejects wrong-network UA', function () { + assert.strictEqual(new ZecAddressCodec('tzec', 'tzec').isValidAddress(zip316Mainnet.unified), false); + }); + + it('isValidAddress: rejects garbage', function () { + assert.strictEqual(new ZecAddressCodec('tzec', 'tzec').isValidAddress('not-a-real-address'), false); + }); + + // -- decode ---------------------------------------------------------------- + + it('decode: plain transparent address decodes to P2PKH script', function () { + const codec = new ZecAddressCodec('tzec', 'tzec'); + const script = Buffer.from(codec.decode(testnetWallet.transparentAddress)); + // P2PKH: OP_DUP OP_HASH160 <20-byte hash> OP_EQUALVERIFY OP_CHECKSIG + assert.strictEqual(script[0], 0x76); // OP_DUP + assert.strictEqual(script[1], 0xa9); // OP_HASH160 + assert.strictEqual(script[2], 0x14); // push 20 bytes + assert.strictEqual(script.length, 25); // total P2PKH length + }); + + it('decode: UA with transparent receiver decodes to the same transparent script', function () { + const codec = new ZecAddressCodec('tzec', 'tzec'); + // The testnet UA carries a transparent receiver; it should decode to the + // same scriptPubKey as the standalone transparent address. + const uaScript = Buffer.from(codec.decode(testnetWallet.unified)); + const tAddrScript = Buffer.from(codec.decode(testnetWallet.transparentAddress)); + assert.deepStrictEqual(uaScript, tAddrScript); + }); + + it('decode: UA with transparent receiver decodes on mainnet', function () { + const codec = new ZecAddressCodec('zec', 'zec'); + const script = Buffer.from(codec.decode(zip316Mainnet.unified)); + // P2PKH structure test + assert.strictEqual(script[0], 0x76); + assert.strictEqual(script[1], 0xa9); + assert.strictEqual(script[2], 0x14); + }); + + it('decode: throws for a UA without a transparent receiver (orchard-only)', function () { + // Construct an Orchard-only UA by encoding a dummy receiver + const orchardOnly = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + new Uint8Array(43).fill(0x42), + 'tzec' + ); + const codec = new ZecAddressCodec('tzec', 'tzec'); + assert.throws(() => codec.decode(orchardOnly)); + }); + + it('decode: explicit transparent-bound codec returns the transparent script', function () { + const codec = new ZecAddressCodec('tzec', 'tzec', 'transparent'); + const script = Buffer.from(codec.decode(testnetWallet.unified)); + const tAddrScript = Buffer.from(codec.decode(testnetWallet.transparentAddress)); + assert.deepStrictEqual(script, tAddrScript); + }); + + it('decode: shielded-bound codec returns the raw 43-byte Orchard receiver', function () { + const codec = new ZecAddressCodec('tzec', 'tzec', 'shielded'); + const receiver = Buffer.from(codec.decode(testnetWallet.unified)); + assert.strictEqual(receiver.length, 43); + const parsed = fixedScriptWallet.ZcashUnifiedAddress.parse(testnetWallet.unified, 'tzec'); + assert.deepStrictEqual(receiver, Buffer.from(parsed.orchardReceiver as Uint8Array)); + }); + + it('decode: shielded-bound codec on mainnet returns the raw 43-byte Orchard receiver', function () { + const codec = new ZecAddressCodec('zec', 'zec', 'shielded'); + const receiver = Buffer.from(codec.decode(zip316Mainnet.unified)); + assert.strictEqual(receiver.length, 43); + const parsed = fixedScriptWallet.ZcashUnifiedAddress.parse(zip316Mainnet.unified, 'zec'); + assert.deepStrictEqual(receiver, Buffer.from(parsed.orchardReceiver as Uint8Array)); + }); + + it('decode: shielded-bound codec resolves an Orchard-only UA to its receiver', function () { + const orchardOnly = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + new Uint8Array(43).fill(0x42), + 'tzec' + ); + const codec = new ZecAddressCodec('tzec', 'tzec', 'shielded'); + const receiver = Buffer.from(codec.decode(orchardOnly)); + assert.strictEqual(receiver.length, 43); + assert.deepStrictEqual(receiver, Buffer.from(new Uint8Array(43).fill(0x42))); + }); + + it('decode: shielded-bound codec throws invalid-address error for a plain transparent address', function () { + const codec = new ZecAddressCodec('tzec', 'tzec', 'shielded'); + assert.throws(() => codec.decode(testnetWallet.transparentAddress), /is not a valid address for network/); + }); + + it('decode: shielded-bound codec throws invalid-address error for a wrong-network UA', function () { + const codec = new ZecAddressCodec('tzec', 'tzec', 'shielded'); + assert.throws(() => codec.decode(zip316Mainnet.unified), /is not a valid address for network/); + }); + + it('decode: shielded-bound codec throws invalid-address error for garbage', function () { + const codec = new ZecAddressCodec('tzec', 'tzec', 'shielded'); + assert.throws(() => codec.decode('not-a-real-address'), /is not a valid address for network/); + }); + + it('decode: throws for garbage', function () { + const codec = new ZecAddressCodec('tzec', 'tzec'); + assert.throws(() => codec.decode('not-a-real-address')); + }); + + // -- encode (inherited) ---------------------------------------------------- + + it('encode: round-trips a transparent script to address', function () { + const codec = new ZecAddressCodec('tzec', 'tzec'); + const script = codec.decode(testnetWallet.transparentAddress); + const address = codec.encode(script); + assert.strictEqual(address, testnetWallet.transparentAddress); + }); + + it('encode: round-trips on mainnet', function () { + const codec = new ZecAddressCodec('zec', 'zec'); + const address = 't1cN2ZVWzWcVRrnfeQzmkpLhzQ4dYRv8yRY'; + const script = codec.decode(address); + assert.strictEqual(codec.encode(script), address); + }); + + // -- integration: coin.addressCodec ---------------------------------------- + + it('coin.addressCodec: zec returns ZecAddressCodec', function () { + assert.ok(zec.addressCodec instanceof ZecAddressCodec); + }); + + it('coin.addressCodec: tzec returns ZecAddressCodec', function () { + assert.ok(tzec.addressCodec instanceof ZecAddressCodec); + }); + + it('coin.addressCodec.isValidAddress matches Zec.isValidAddress', function () { + assert.strictEqual( + zec.addressCodec.isValidAddress(zip316Mainnet.unified), + zec.isValidAddress(zip316Mainnet.unified) + ); + assert.strictEqual( + zec.addressCodec.isValidAddress('t1cN2ZVWzWcVRrnfeQzmkpLhzQ4dYRv8yRY'), + zec.isValidAddress('t1cN2ZVWzWcVRrnfeQzmkpLhzQ4dYRv8yRY') + ); + assert.strictEqual(zec.addressCodec.isValidAddress('not-a-real-address'), zec.isValidAddress('not-a-real-address')); + }); +});