diff --git a/modules/abstract-utxo/src/impl/zec/address.ts b/modules/abstract-utxo/src/impl/zec/address.ts new file mode 100644 index 0000000000..a025b7b13e --- /dev/null +++ b/modules/abstract-utxo/src/impl/zec/address.ts @@ -0,0 +1,55 @@ +import { address as wasmAddress, fixedScriptWallet, isCoinName } from '@bitgo/wasm-utxo'; + +export type ZcashAddressKind = 'transparent' | 'shielded'; + +/** + * 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 + * (e.g. Sapling- or transparent-only) is not considered valid here. + */ +export function isShieldedZcashAddress(address: string, network: fixedScriptWallet.ZcashNetworkName): boolean { + try { + return fixedScriptWallet.ZcashUnifiedAddress.parse(address, network).hasOrchardReceiver; + } catch { + return false; + } +} + +/** + * Classify a Zcash address string as transparent or shielded, validating it in + * the process. Returns undefined if the address is neither a valid transparent + * address nor a well-formed ZIP-316 Unified Address for `network`. + */ +export function getZcashAddressKind( + address: string, + network: fixedScriptWallet.ZcashNetworkName +): ZcashAddressKind | undefined { + // ZcashNetworkName also permits 'zcash'/'zcashTest', which toOutputScriptWithCoin + // doesn't accept (it takes a CoinName, i.e. 'zec'/'tzec'). Skip straight to the + // shielded check for those rather than relying on an unsafe cast + caught throw. + if (isCoinName(network)) { + try { + wasmAddress.toOutputScriptWithCoin(address, network); + return 'transparent'; + } catch { + // not a valid transparent address; fall through to shielded check + } + } + return isShieldedZcashAddress(address, network) ? 'shielded' : undefined; +} + +/** + * Standalone counterpart to `Zec.isValidAddress`, parameterized by `network` + * instead of requiring a coin instance. Accepts transparent addresses and + * shielded ZIP-316 Unified Addresses. + * + * Not structurally identical to `Zec.isValidAddress`: the base class also + * round-trips the parsed script through each known encoding format (see + * `AbstractUtxoCoin.isValidAddress`), whereas this only calls + * `toOutputScriptWithCoin` once via `getZcashAddressKind`. They agree in + * practice since zec/tzec have no alternate transparent-address encoding to + * round-trip against, but that's not guaranteed to remain true. + */ +export function isValidZcashAddress(address: string, network: fixedScriptWallet.ZcashNetworkName): boolean { + return getZcashAddressKind(address, network) !== undefined; +} diff --git a/modules/abstract-utxo/src/impl/zec/index.ts b/modules/abstract-utxo/src/impl/zec/index.ts index 707e753101..3e05c1b7b9 100644 --- a/modules/abstract-utxo/src/impl/zec/index.ts +++ b/modules/abstract-utxo/src/impl/zec/index.ts @@ -1,2 +1,3 @@ export * from './zec'; export * from './tzec'; +export * from './address'; diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index 0ee1df3081..8aed41fd92 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -2,10 +2,13 @@ * @prettier */ import { BitGoBase } from '@bitgo/sdk-core'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; import { UtxoCoinName } from '../../names'; +import { isShieldedZcashAddress } from './address'; + export class Zec extends AbstractUtxoCoin { readonly name: UtxoCoinName = 'zec'; @@ -16,4 +19,11 @@ export class Zec extends AbstractUtxoCoin { static createInstance(bitgo: BitGoBase): Zec { return new Zec(bitgo); } + + isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean { + if (super.isValidAddress(address, param)) { + return true; + } + return isShieldedZcashAddress(address, this.name as fixedScriptWallet.ZcashNetworkName); + } } diff --git a/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts b/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts new file mode 100644 index 0000000000..d32423bf2a --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; + +import { BitGoAPI } from '@bitgo/sdk-api'; + +import { + Zec, + Tzec, + getZcashAddressKind, + isShieldedZcashAddress, + isValidZcashAddress, +} from '../../../../../src/impl/zec'; + +// ZIP-316 unified-address test vectors, copied from +// BitGoWASM/packages/wasm-utxo/test/fixtures/zcash/unified_address.json so +// both repos test against the same known-good data. +const zip316Mainnet = { + unified: + 'u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx', +}; +const testnetWallet = { + unified: + 'utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps', + transparentAddress: 'tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk', +}; + +describe('Zcash address validation', function () { + 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('recognizes a mainnet unified address as shielded', function () { + assert.strictEqual(zec.isValidAddress(zip316Mainnet.unified), true); + assert.strictEqual(getZcashAddressKind(zip316Mainnet.unified, 'zec'), 'shielded'); + assert.strictEqual(isShieldedZcashAddress(zip316Mainnet.unified, 'zec'), true); + assert.strictEqual(isValidZcashAddress(zip316Mainnet.unified, 'zec'), true); + }); + + it('recognizes a testnet unified address as shielded', function () { + assert.strictEqual(tzec.isValidAddress(testnetWallet.unified), true); + assert.strictEqual(getZcashAddressKind(testnetWallet.unified, 'tzec'), 'shielded'); + assert.strictEqual(isShieldedZcashAddress(testnetWallet.unified, 'tzec'), true); + assert.strictEqual(isValidZcashAddress(testnetWallet.unified, 'tzec'), true); + }); + + it('recognizes a testnet transparent address as transparent', function () { + assert.strictEqual(tzec.isValidAddress(testnetWallet.transparentAddress), true); + assert.strictEqual(getZcashAddressKind(testnetWallet.transparentAddress, 'tzec'), 'transparent'); + assert.strictEqual(isValidZcashAddress(testnetWallet.transparentAddress, 'tzec'), true); + }); + + it('recognizes a mainnet transparent (P2PKH) address as transparent', function () { + const address = 't1cN2ZVWzWcVRrnfeQzmkpLhzQ4dYRv8yRY'; + assert.strictEqual(zec.isValidAddress(address), true); + assert.strictEqual(getZcashAddressKind(address, 'zec'), 'transparent'); + assert.strictEqual(isValidZcashAddress(address, 'zec'), true); + }); + + it('rejects a garbage string', function () { + const garbage = 'not-a-real-address'; + assert.strictEqual(zec.isValidAddress(garbage), false); + assert.strictEqual(getZcashAddressKind(garbage, 'zec'), undefined); + assert.strictEqual(isValidZcashAddress(garbage, 'zec'), false); + }); + + it('rejects a unified address checked against the wrong network', function () { + assert.strictEqual(tzec.isValidAddress(zip316Mainnet.unified), false); + assert.strictEqual(getZcashAddressKind(zip316Mainnet.unified, 'tzec'), undefined); + assert.strictEqual(isShieldedZcashAddress(zip316Mainnet.unified, 'tzec'), false); + assert.strictEqual(isValidZcashAddress(zip316Mainnet.unified, 'tzec'), false); + }); +});