Skip to content
Closed
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
2 changes: 2 additions & 0 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ export interface TransactionParams extends BaseTransactionParams {
/** Parameters for bridging intents (e.g. BTC -> sBTC peg-in), present when `type === 'bridging'`. */
bridgingParams?: BridgingParams;
qr?: boolean;
/** Zcash-only preference for resolving Unified Address recipients. */
unifiedRecipientPreference?: string;
}

export interface ParseTransactionOptions<TNumber extends number | bigint = number> extends BaseParseTransactionOptions {
Expand Down
36 changes: 36 additions & 0 deletions modules/abstract-utxo/src/impl/zec/addressCodec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { fixedScriptWallet, zcashAddress as wasmZcashAddress } from '@bitgo/wasm-utxo';

import { AddressCodec } from '../../transaction/recipient';

import type { UnifiedRecipientPreference } from './types';

/**
* Parse `address` as a ZIP-316 Unified Address for `network`, or return `undefined` if it isn't
* one (malformed, wrong network, or not bech32m-shaped at all).
*/
export function tryParseUnifiedAddress(
address: string,
network: 'zec' | 'tzec'
): fixedScriptWallet.ZcashUnifiedAddress | undefined {
try {
return fixedScriptWallet.ZcashUnifiedAddress.parse(address, network);
} catch (e) {
return undefined;
}
}

export class ZcashAddressCodec extends AddressCodec {
constructor(coinName: 'zec' | 'tzec', private readonly unifiedRecipientPreference?: UnifiedRecipientPreference) {
super(coinName);
}

override decode(address: string): Uint8Array {
if (
this.unifiedRecipientPreference === 'shielded' &&
tryParseUnifiedAddress(address, this.coinName as 'zec' | 'tzec')
) {
return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.coinName);
}
return wasmZcashAddress.toTransparentReceiverWithCoin(address, this.coinName);
}
}
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,4 +1,5 @@
export * from './zec';
export * from './recipients';
export * from './tzec';
export * from './address';
export * from './recipients';
Expand Down
61 changes: 32 additions & 29 deletions modules/abstract-utxo/src/impl/zec/recipients.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/**
* @prettier
*/
import { fixedScriptWallet, zcashAddress } from '@bitgo/wasm-utxo';
import { Triple } from '@bitgo/sdk-core';

import { getReplayProtectionPubkeys } from '../../transaction/fixedScript/replayProtection';

import { ZcashCoinName, UnifiedRecipientPreference } from './types';
import { UnifiedRecipientPreference, ZcashCoinName } from './types';

/**
* How a recipient parsed from a Zcash PSBT is spent.
Expand Down Expand Up @@ -50,37 +54,55 @@ export interface PsbtRecipient {
* The original Unified Address the client supplied for this recipient, when the PSBT stores
* one: the v6 (Ironwood) PCZT for a shielded output, the transparent-output proprietary
* key-value map for a v4 transparent output. `undefined` when the recipient was built from a
* plain address.
* plain address (or the single-receiver UA re-encoding is byte-identical for a shielded
* output).
*/
unifiedAddress?: string;
destination: PsbtRecipientDestination;
}

export type ResolvePsbtRecipientsOptions = {
/**
* Custom change wallet xpubs, when the transaction spends to a custom change wallet. Outputs
* matching these keys are classified as change, not recipients — matching how
* `explainPsbtWasm` treats them.
*/
customChangeXpubs?: Triple<string>;
};

/**
* Resolve the recipient list of a decoded Zcash PSBT (v4 Sapling-shaped or v6 Ironwood).
*
* Mirrors the recipient resolution of wallet-platform's utxo-core `buildTransaction` in the
* decode direction: every non-wallet output with a resolvable address is a recipient. A
* shielded output parses with `isShielded: true`, its `script` being the raw 43-byte receiver;
* when the build stored the client's original Unified Address (the v6 PCZT for shielded
* outputs, the transparent-output proprietary key-value map for v4), both the parsed address
* and `unifiedAddress` report it verbatim. Opaque outputs with no address (e.g. OP_RETURN) are
* skipped, as they carry no recipient.
* decode direction: every non-wallet, non-custom-change output with a resolvable address is a
* recipient. A shielded output parses with `isShielded: true`, its `script` being the raw
* 43-byte receiver; when the build stored the client's original Unified Address (the v6 PCZT for
* shielded outputs, the transparent-output proprietary key-value map for v4), both the parsed
* address and `unifiedAddress` report it verbatim. Opaque outputs with no address (e.g.
* OP_RETURN) are skipped, as they carry no recipient.
*/
export function resolvePsbtRecipients(
psbt: fixedScriptWallet.ZcashBitGoPsbt,
walletKeys: fixedScriptWallet.RootWalletKeys
walletKeys: fixedScriptWallet.RootWalletKeys,
opts: ResolvePsbtRecipientsOptions = {}
): PsbtRecipient[] {
const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, {
replayProtection: { publicKeys: getReplayProtectionPubkeys('zec') },
});
const customChangeOutputs = opts.customChangeXpubs
? psbt.parseOutputsWithWalletKeys(opts.customChangeXpubs)
: undefined;

const recipients: PsbtRecipient[] = [];
parsed.outputs.forEach((output, i) => {
// Wallet-owned (change) outputs.
if (output.scriptId !== null) {
return;
}
// Outputs owned by the custom change wallet, if one was supplied.
if (customChangeOutputs?.[i]?.scriptId != null) {
return;
}
// Opaque outputs (e.g. OP_RETURN) carry no recipient address.
if (output.address === null) {
return;
Expand All @@ -106,38 +128,19 @@ export function resolvePsbtRecipients(

/**
* Infer the Unified-Address recipient preference for a Zcash transaction when the caller did
* not pass one — the counterpart of wallet-platform's utxo-core `buildTransaction`
* `classifyRecipientShieldedness`.
*
* A recipient that resolves to a transparent output — an ordinary transparent address, or a
* Unified Address carrying a transparent receiver — is classified `'transparent'`; a Unified
* Address carrying only an Orchard/Ironwood receiver is classified `'shielded'`. A mix of
* shielded and transparent recipients is rejected. An address that is neither a transparent
* address nor a Unified Address propagates the Unified-Address parse error — it is not
* silently defaulted to `'transparent'`.
*
* @returns `'shielded'` when every recipient resolves shielded, `undefined` when every
* recipient resolves transparent (the build's default). The `'transparent'` arm of the
* return type exists so callers can pass the explicit preference through unchanged; this
* function itself never returns `'transparent'`.
* not pass one. A mix of shielded and transparent recipients is rejected.
*/
export function getUnifiedRecipientPreference(
name: ZcashCoinName,
recipients: { address: string | undefined }[]
): UnifiedRecipientPreference | undefined {
const shieldedness = recipients.map((recipient) => {
if (recipient.address === undefined) {
// Raw script inherently transparent.
return 'transparent' as const;
}
// Ordinary transparent address, or a Unified Address carrying a transparent receiver:
// resolves transparently either way (the build's default when no preference is given).
if (zcashAddress.hasTransparentReceiver(recipient.address, name)) {
return 'transparent' as const;
}
// A shielded (Orchard/Ironwood-only) Unified Address is the only remaining resolvable
// form. An address that is none of the above propagates the parse error instead of
// assuming a default.
const unified = fixedScriptWallet.ZcashUnifiedAddress.parse(recipient.address, name);
if (unified.hasOrchardReceiver) {
return 'shielded' as const;
Expand Down
125 changes: 119 additions & 6 deletions modules/abstract-utxo/src/impl/zec/zec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
/**
* @prettier
*/
import { BitGoBase } from '@bitgo/sdk-core';
import { zcashAddress } from '@bitgo/wasm-utxo';
import { fixedScriptWallet, hasPsbtMagic, isWasmUtxoError } from '@bitgo/wasm-utxo';
import { BitGoBase, ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core';

import { AbstractUtxoCoin } from '../../abstractUtxoCoin';
import { AbstractUtxoCoin, ParseTransactionOptions, VerifyTransactionOptions } from '../../abstractUtxoCoin';
import { stringToBufferTryFormats } from '../../transaction/decode';
import type { ParsedTransaction } from '../../transaction/types';
import { UtxoCoinName } from '../../names';

import { ZcashAddressCodec, tryParseUnifiedAddress } from './addressCodec';
import { resolvePsbtRecipients, ResolvePsbtRecipientsOptions, PsbtRecipient } from './recipients';
import type { UnifiedRecipientPreference } from './types';

function getUnifiedRecipientPreference<TNumber extends number | bigint>(
txParams: ParseTransactionOptions<TNumber>['txParams']
): UnifiedRecipientPreference | undefined {
return (
txParams as ParseTransactionOptions<TNumber>['txParams'] & {
unifiedRecipientPreference?: UnifiedRecipientPreference;
}
).unifiedRecipientPreference;
}

export class Zec extends AbstractUtxoCoin {
readonly name: UtxoCoinName = 'zec';

Expand All @@ -18,9 +34,106 @@ export class Zec extends AbstractUtxoCoin {
return new Zec(bitgo);
}

isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean {
return (
zcashAddress.hasTransparentReceiver(address, this.name) || zcashAddress.hasOrchardReceiver(address, this.name)
/**
* Forward `unifiedRecipientPreference` alongside the standard extra build params. Zcash builds
* that carry this preference always go through the wasm-utxo (Ironwood/v6-capable) build path
* on Wallet Platform rather than the legacy utxolib path, since utxolib has no notion of
* Unified Addresses or shielded outputs.
*/
override async getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet }) {
const extraParams = await super.getExtraPrebuildParams(buildParams);
const unifiedRecipientPreference = buildParams.unifiedRecipientPreference as UnifiedRecipientPreference | undefined;
if (unifiedRecipientPreference === undefined) {
return extraParams;
}
return { ...extraParams, unifiedRecipientPreference };
}

/**
* In addition to ordinary transparent addresses, Zcash accepts ZIP-316 Unified Addresses that
* carry a transparent receiver, an Orchard/Ironwood receiver, or both. `unifiedRecipientPreference`
* (which of those receivers a build should spend to) is not this method's concern — it only
* answers whether `address` is a spendable address at all.
*/
override isValidAddress(
address: string,
param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean
): boolean {
const unifiedAddress = tryParseUnifiedAddress(address, this.name as 'zec' | 'tzec');
if (unifiedAddress !== undefined) {
return unifiedAddress.transparentScript !== undefined || unifiedAddress.orchardReceiver !== undefined;
}
return super.isValidAddress(address, param);
}

override parseTransaction<TNumber extends number | bigint = number>(
params: ParseTransactionOptions<TNumber>
): Promise<ParsedTransaction<TNumber>> {
return this.parseTransactionWithAddressCodec(
params,
new ZcashAddressCodec(this.name as 'zec' | 'tzec', getUnifiedRecipientPreference(params.txParams))
);
}

override verifyTransaction<TNumber extends number | bigint = number>(
params: VerifyTransactionOptions<TNumber>
): Promise<boolean> {
return this.verifyTransactionWithAddressCodec(
params,
new ZcashAddressCodec(this.name as 'zec' | 'tzec', getUnifiedRecipientPreference(params.txParams))
);
}

/**
* Zcash v6 (Ironwood) PSBTs carry their shielded side as an orchard PCZT and cannot be
* deserialized by the generic `ZcashBitGoPsbt` — attempt that first (the common, non-shielding
* case) and fall back to `ZcashIronwoodBitGoPsbt.fromBytes` for v6-shaped bytes.
*/
override decodeTransaction(input: Buffer | string): fixedScriptWallet.BitGoPsbt {
const buffer = typeof input === 'string' ? stringToBufferTryFormats(input, ['hex', 'base64']) : input;
if (!hasPsbtMagic(buffer)) {
return super.decodeTransaction(input);
}
try {
return fixedScriptWallet.ZcashBitGoPsbt.fromBytes(buffer, this.name as 'zec' | 'tzec');
} catch (e) {
// `ZcashBitGoPsbt.fromBytes` signals v6 (Ironwood) bytes with a plain Error (not a
// WasmUtxoError) telling the caller to use `ZcashIronwoodBitGoPsbt.fromBytes` instead —
// see its doc comment. Fall back for that message as well as wasm-layer errors.
if (isWasmUtxoError(e) || (e instanceof Error && e.message.includes('v6 (Ironwood)'))) {
return fixedScriptWallet.ZcashIronwoodBitGoPsbt.fromBytes(buffer, this.name as 'zec' | 'tzec');
}
throw e;
}
}

override decodeTransactionFromPrebuild(prebuild: {
txHex?: string;
txBase64?: string;
txHexPsbt?: string;
}): fixedScriptWallet.BitGoPsbt {
const string = prebuild.txHexPsbt ?? prebuild.txHex ?? prebuild.txBase64;
if (!string) {
throw new Error('missing required txHex or txBase64 property');
}
return this.decodeTransaction(string);
}

/**
* Decode a Zcash PSBT (v4 Sapling-shaped or v6 Ironwood) and resolve its recipient list.
* The decode-side counterpart of the wallet-platform build path's recipient resolution:
* shielded outputs resolve to their single-receiver Orchard Unified Address, transparent
* outputs to their transparent address. Change and custom-change outputs are excluded.
*/
resolveRecipientsFromPsbt(
input: Buffer | string,
walletKeys: fixedScriptWallet.RootWalletKeys,
opts: ResolvePsbtRecipientsOptions = {}
): PsbtRecipient[] {
const psbt = this.decodeTransaction(input);
if (!(psbt instanceof fixedScriptWallet.ZcashBitGoPsbt)) {
throw new Error('expected a Zcash PSBT');
}
return resolvePsbtRecipients(psbt, walletKeys, opts);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"_note": "ZIP-316 unified-address test vector for tzec (testnet), derived from wallet-data/testnet-wallet-full.json in the Ironwood reference sandbox. See @bitgo/wasm-utxo test/fixtures/zcash/unified_address.json (testnetWallet).",
"network": "tzec",
"unified": "utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps",
"transparentAddress": "tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk",
"ironwoodReceiverHex": "d632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5",
"transparentPubkeyHashHex": "7c6b843a25873c036aff575516e3802bcc47f634"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"_note": "ZIP-316 unified-address test vector for zec (mainnet), from the official zcash-test-vectors (unified_address.py). See @bitgo/wasm-utxo test/fixtures/zcash/unified_address.json (zip316Mainnet).",
"network": "zec",
"unified": "u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx",
"orchardReceiverHex": "cecbe5e689a453a3fe10ccf7617e6c1fb382819d7fc9200a1f42092ac84a30378f8c1fb90dff71a6d5042d",
"transparentPubkeyHashHex": "cad268758c5e71493066446b98e71df9d1d6a5ca"
}
Loading
Loading