Skip to content
Open
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
81 changes: 80 additions & 1 deletion modules/abstract-utxo/src/impl/zec/address.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 7 additions & 5 deletions modules/abstract-utxo/src/impl/zec/zec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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