Skip to content

Commit 23443de

Browse files
committed
feat(abstract-utxo): recognize ZIP-316 shielded zcash addresses
Zec.isValidAddress previously only accepted transparent (base58check) addresses. Extend it to also recognize well-formed ZIP-316 Unified Addresses (Sapling/Orchard), and expose standalone helpers (getZcashAddressKind, isShieldedZcashAddress, isValidZcashAddress) for callers that need to classify or validate addresses without a coin/SDK instance. Ticket: CSHLD-1652
1 parent 4a45c72 commit 23443de

4 files changed

Lines changed: 142 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { address as wasmAddress, CoinName, fixedScriptWallet } from '@bitgo/wasm-utxo';
2+
3+
export type ZcashAddressKind = 'transparent' | 'shielded';
4+
5+
/**
6+
* Whether `address` is a well-formed ZIP-316 Unified Address for `network`.
7+
*/
8+
export function isShieldedZcashAddress(address: string, network: fixedScriptWallet.ZcashNetworkName): boolean {
9+
try {
10+
fixedScriptWallet.ZcashUnifiedAddress.parse(address, network);
11+
return true;
12+
} catch {
13+
return false;
14+
}
15+
}
16+
17+
/**
18+
* Classify a Zcash address string as transparent or shielded, validating it in
19+
* the process. Returns undefined if the address is neither a valid transparent
20+
* address nor a well-formed ZIP-316 Unified Address for `network`.
21+
*/
22+
export function getZcashAddressKind(
23+
address: string,
24+
network: fixedScriptWallet.ZcashNetworkName
25+
): ZcashAddressKind | undefined {
26+
try {
27+
// ZcashNetworkName also permits 'zcash'/'zcashTest', which aren't valid
28+
// CoinName values here; toOutputScriptWithCoin simply throws for those,
29+
// which is caught below and falls through to the shielded check.
30+
wasmAddress.toOutputScriptWithCoin(address, network as CoinName);
31+
return 'transparent';
32+
} catch {
33+
// not a valid transparent address; fall through to shielded check
34+
}
35+
return isShieldedZcashAddress(address, network) ? 'shielded' : undefined;
36+
}
37+
38+
/**
39+
* Standalone counterpart to `Zec.isValidAddress`, parameterized by `network`
40+
* instead of requiring a coin instance. Accepts transparent addresses and
41+
* shielded ZIP-316 Unified Addresses.
42+
*
43+
* Not structurally identical to `Zec.isValidAddress`: the base class also
44+
* round-trips the parsed script through each known encoding format (see
45+
* `AbstractUtxoCoin.isValidAddress`), whereas this only calls
46+
* `toOutputScriptWithCoin` once via `getZcashAddressKind`. They agree in
47+
* practice since zec/tzec have no alternate transparent-address encoding to
48+
* round-trip against, but that's not guaranteed to remain true.
49+
*/
50+
export function isValidZcashAddress(address: string, network: fixedScriptWallet.ZcashNetworkName): boolean {
51+
return getZcashAddressKind(address, network) !== undefined;
52+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './zec';
22
export * from './tzec';
3+
export * from './address';

modules/abstract-utxo/src/impl/zec/zec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
* @prettier
33
*/
44
import { BitGoBase } from '@bitgo/sdk-core';
5+
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
56

67
import { AbstractUtxoCoin } from '../../abstractUtxoCoin';
78
import { UtxoCoinName } from '../../names';
89

10+
import { isShieldedZcashAddress } from './address';
11+
912
export class Zec extends AbstractUtxoCoin {
1013
readonly name: UtxoCoinName = 'zec';
1114

@@ -16,4 +19,11 @@ export class Zec extends AbstractUtxoCoin {
1619
static createInstance(bitgo: BitGoBase): Zec {
1720
return new Zec(bitgo);
1821
}
22+
23+
isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean {
24+
if (super.isValidAddress(address, param)) {
25+
return true;
26+
}
27+
return isShieldedZcashAddress(address, this.name as fixedScriptWallet.ZcashNetworkName);
28+
}
1929
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import assert from 'node:assert/strict';
2+
3+
import { BitGoAPI } from '@bitgo/sdk-api';
4+
5+
import {
6+
Zec,
7+
Tzec,
8+
getZcashAddressKind,
9+
isShieldedZcashAddress,
10+
isValidZcashAddress,
11+
} from '../../../../../src/impl/zec';
12+
13+
// ZIP-316 unified-address test vectors, copied from
14+
// BitGoWASM/packages/wasm-utxo/test/fixtures/zcash/unified_address.json so
15+
// both repos test against the same known-good data.
16+
const zip316Mainnet = {
17+
unified:
18+
'u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx',
19+
};
20+
const testnetWallet = {
21+
unified:
22+
'utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps',
23+
transparentAddress: 'tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk',
24+
};
25+
26+
describe('Zcash address validation', function () {
27+
let bitgo: BitGoAPI;
28+
let zec;
29+
let tzec;
30+
31+
before(function () {
32+
bitgo = new BitGoAPI({ env: 'mock' });
33+
bitgo.register('zec', Zec.createInstance);
34+
bitgo.register('tzec', Tzec.createInstance);
35+
zec = bitgo.coin('zec');
36+
tzec = bitgo.coin('tzec');
37+
});
38+
39+
it('recognizes a mainnet unified address as shielded', function () {
40+
assert.strictEqual(zec.isValidAddress(zip316Mainnet.unified), true);
41+
assert.strictEqual(getZcashAddressKind(zip316Mainnet.unified, 'zec'), 'shielded');
42+
assert.strictEqual(isShieldedZcashAddress(zip316Mainnet.unified, 'zec'), true);
43+
assert.strictEqual(isValidZcashAddress(zip316Mainnet.unified, 'zec'), true);
44+
});
45+
46+
it('recognizes a testnet unified address as shielded', function () {
47+
assert.strictEqual(tzec.isValidAddress(testnetWallet.unified), true);
48+
assert.strictEqual(getZcashAddressKind(testnetWallet.unified, 'tzec'), 'shielded');
49+
assert.strictEqual(isShieldedZcashAddress(testnetWallet.unified, 'tzec'), true);
50+
assert.strictEqual(isValidZcashAddress(testnetWallet.unified, 'tzec'), true);
51+
});
52+
53+
it('recognizes a testnet transparent address as transparent', function () {
54+
assert.strictEqual(tzec.isValidAddress(testnetWallet.transparentAddress), true);
55+
assert.strictEqual(getZcashAddressKind(testnetWallet.transparentAddress, 'tzec'), 'transparent');
56+
assert.strictEqual(isValidZcashAddress(testnetWallet.transparentAddress, 'tzec'), true);
57+
});
58+
59+
it('recognizes a mainnet transparent (P2PKH) address as transparent', function () {
60+
const address = 't1cN2ZVWzWcVRrnfeQzmkpLhzQ4dYRv8yRY';
61+
assert.strictEqual(zec.isValidAddress(address), true);
62+
assert.strictEqual(getZcashAddressKind(address, 'zec'), 'transparent');
63+
assert.strictEqual(isValidZcashAddress(address, 'zec'), true);
64+
});
65+
66+
it('rejects a garbage string', function () {
67+
const garbage = 'not-a-real-address';
68+
assert.strictEqual(zec.isValidAddress(garbage), false);
69+
assert.strictEqual(getZcashAddressKind(garbage, 'zec'), undefined);
70+
assert.strictEqual(isValidZcashAddress(garbage, 'zec'), false);
71+
});
72+
73+
it('rejects a unified address checked against the wrong network', function () {
74+
assert.strictEqual(tzec.isValidAddress(zip316Mainnet.unified), false);
75+
assert.strictEqual(getZcashAddressKind(zip316Mainnet.unified, 'tzec'), undefined);
76+
assert.strictEqual(isShieldedZcashAddress(zip316Mainnet.unified, 'tzec'), false);
77+
assert.strictEqual(isValidZcashAddress(zip316Mainnet.unified, 'tzec'), false);
78+
});
79+
});

0 commit comments

Comments
 (0)