Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions modules/abstract-utxo/src/impl/zec/address.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions modules/abstract-utxo/src/impl/zec/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './zec';
export * from './tzec';
export * from './address';
10 changes: 10 additions & 0 deletions modules/abstract-utxo/src/impl/zec/zec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
}
}
79 changes: 79 additions & 0 deletions modules/abstract-utxo/test/unit/impl/zec/unit/address.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading