Skip to content
Draft
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
36 changes: 36 additions & 0 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,23 @@ 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: how to resolve a Unified Address recipient. `'shielded'` resolves it to its
* Orchard/Ironwood receiver (a shielded output); any other value (or omission) resolves it to
* its transparent receiver. Ignored for non-Zcash coins and for non-Unified-Address recipients.
*/
unifiedRecipientPreference?: string;
}

/**
* The slice of transaction params that Unified-Address preference inference (see
* AbstractUtxoCoin.getUnifiedRecipientPreference) needs. Deliberately wider than
* `ITransactionRecipient`: recipients may carry `script` instead of `address` (OP_RETURN / raw
* script recipients), and UTXO amounts may be bigint.
*/
export interface UnifiedRecipientPreferenceTxParams {
recipients?: { address?: string; script?: string; amount: number | bigint | string }[];
unifiedRecipientPreference?: string;
}

export interface ParseTransactionOptions<TNumber extends number | bigint = number> extends BaseParseTransactionOptions {
Expand Down Expand Up @@ -544,6 +561,25 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici
}
}

/**
* Resolve a transaction-address (not a raw scriptPubKey) to its output script. Base
* implementation defers to wasm-utxo's coin-agnostic address decoding. Overridable by coins
* whose address space needs additional context to resolve — e.g. Zcash Unified Addresses,
* which resolve differently depending on `unifiedRecipientPreference`.
*/
resolveOutputScript(address: string, unifiedRecipientPreference?: string): Uint8Array {
return wasmAddress.toOutputScriptWithCoin(address, this.name);
}

/**
* The effective Unified-Address recipient preference for a transaction. Coins without
* Unified Addresses just pass the caller's value through; coins that accept Unified
* Addresses may infer it from the recipients (see Zec).
*/
getUnifiedRecipientPreference(txParams: UnifiedRecipientPreferenceTxParams): string | undefined {
return txParams.unifiedRecipientPreference;
}

/**
* Run custom coin logic after a transaction prebuild has been received from BitGo
* @param prebuild
Expand Down
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 './recipients';
export * from './tzec';
125 changes: 125 additions & 0 deletions modules/abstract-utxo/src/impl/zec/recipients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* @prettier
*/
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
import { Triple } from '@bitgo/sdk-core';

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

/**
* How a recipient parsed from a Zcash PSBT is spent.
*
* The decode-side counterpart of utxo-core's `buildTransaction/zcash.ts` `ZcashDestination` on
* the build side: a shielded recipient is an Orchard/Ironwood output stored in the v6 (Ironwood)
* PSBT's orchard PCZT, and everything else is an ordinary transparent output. A transparent
* output resolved from a Unified Address carries that original UA (`zcashUnifiedTransparent`), a
* plain address does not.
*/
export type PsbtRecipientDestination =
| {
kind: 'zcashShielded';
/**
* The Unified Address the output was addressed to — the original multi-receiver UA the
* client passed when the PSBT stores one verbatim, otherwise a re-encoded single-receiver
* Orchard UA.
*/
unifiedAddress: string;
}
| {
kind: 'zcashUnifiedTransparent';
/** The original Unified Address the transparent receiver was resolved from. */
unifiedAddress: string;
}
| { kind: 'transparent' };

/** A recipient resolved from a decoded Zcash PSBT's external outputs. */
export interface PsbtRecipient {
/** Amount in satoshis. */
amount: bigint;
/**
* The recipient address. For a shielded output this is the Unified Address the output was
* addressed to — the original multi-receiver UA when the PSBT stores one verbatim, otherwise a
* re-encoded single-receiver Orchard UA. For a transparent output it is the original Unified
* Address when one was stored, else the decoded transparent address.
*/
address: string;
/**
* Raw receiver bytes: the 43-byte Orchard/Ironwood receiver for a shielded output, the
* scriptPubKey for a transparent one.
*/
script: Uint8Array;
/**
* 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 (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, 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,
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;
}
// The original client-passed Unified Address, stored verbatim in the PSBT's key-value
// pairs: the orchard PCZT for a shielded output (parsed `address` reports it in full), the
// transparent-output proprietary map for a v4 transparent output.
const unifiedAddress = output.isShielded ? output.address : psbt.transparentOutputUnifiedAddress(i) ?? undefined;
recipients.push({
amount: output.value,
address: output.address,
script: output.script,
unifiedAddress,
destination: output.isShielded
? { kind: 'zcashShielded', unifiedAddress: output.address }
: unifiedAddress
? { kind: 'zcashUnifiedTransparent', unifiedAddress }
: { kind: 'transparent' },
});
});
return recipients;
}
188 changes: 186 additions & 2 deletions modules/abstract-utxo/src/impl/zec/zec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
/**
* @prettier
*/
import { BitGoBase } from '@bitgo/sdk-core';
import { fixedScriptWallet, hasPsbtMagic, isWasmUtxoError, zcashAddress as wasmZcashAddress } from '@bitgo/wasm-utxo';
import { BitGoBase, ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core';

import { AbstractUtxoCoin } from '../../abstractUtxoCoin';
import { AbstractUtxoCoin, UnifiedRecipientPreferenceTxParams } from '../../abstractUtxoCoin';
import { stringToBufferTryFormats } from '../../transaction/decode';
import { UtxoCoinName } from '../../names';

import { resolvePsbtRecipients, ResolvePsbtRecipientsOptions, PsbtRecipient } from './recipients';

/**
* 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).
*/
function tryParseUnifiedAddress(
address: string,
network: 'zec' | 'tzec'
): fixedScriptWallet.ZcashUnifiedAddress | undefined {
try {
return fixedScriptWallet.ZcashUnifiedAddress.parse(address, network);
} catch (e) {
return undefined;
}
}

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

Expand All @@ -16,4 +35,169 @@ export class Zec extends AbstractUtxoCoin {
static createInstance(bitgo: BitGoBase): Zec {
return new Zec(bitgo);
}

/**
* 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 string | 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);
}

/**
* Resolve `address` to an output script. For a Unified Address, `unifiedRecipientPreference ===
* 'shielded'` resolves to the raw 43-byte Orchard/Ironwood receiver (a shielded output, no
* scriptPubKey); any other value resolves the Unified Address's transparent receiver (a plain
* transparent address decodes exactly as the base implementation would). A Unified Address
* without a transparent receiver cannot resolve transparently and throws.
*/
override resolveOutputScript(address: string, unifiedRecipientPreference?: string): Uint8Array {
if (unifiedRecipientPreference === 'shielded') {
return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.name);
}
return wasmZcashAddress.toTransparentReceiverWithCoin(address, this.name);
}

/**
* Infer the Unified-Address recipient preference from the recipients when the caller did not
* pass one — mirroring wallet-platform's utxo-core `buildTransaction` (`inferIsShielded` +
* `classifyRecipientShieldedness`): a Unified Address carrying only an Orchard receiver can
* only be spent shielded, one carrying only a transparent receiver only transparently, one
* carrying both is ambiguous, and a mix of shielded and transparent recipients is rejected.
*/
override getUnifiedRecipientPreference(txParams: UnifiedRecipientPreferenceTxParams): string | undefined {
const preference = txParams.unifiedRecipientPreference;
if (preference !== undefined) {
// Indexer parity (utxo-core buildTransaction): a shielded build requires every recipient
// to be shielded-capable — a plain transparent address mixed in is rejected rather than
// silently routed through the transparent builder.
if (preference === 'shielded') {
for (const recipient of txParams.recipients ?? []) {
if (!this.isShieldedCapable(recipient.address)) {
throw new Error('Mixed shielded and transparent recipients are not supported');
}
}
}
return preference;
}
const shieldedness = (txParams.recipients ?? []).map((recipient) => {
if (recipient.address === undefined) {
// Raw script and OP_RETURN recipients are inherently transparent.
return 'transparent' as const;
}
const unified = tryParseUnifiedAddress(recipient.address, this.name as 'zec' | 'tzec');
if (!unified) {
// Not a unified address: the ordinary transparent address-decoding path handles it.
return 'transparent' as const;
}
if (unified.hasOrchardReceiver && unified.hasTransparentReceiver) {
throw new Error(
`Unified address ${recipient.address} carries both transparent and Orchard receivers; specify unifiedRecipientPreference: "shielded" or "transparent"`
);
}
if (unified.hasTransparentReceiver) {
return 'transparent' as const;
}
if (unified.hasOrchardReceiver) {
return 'shielded' as const;
}
throw new Error(`Unified address ${recipient.address} carries no transparent or Orchard receiver`);
});
const hasShielded = shieldedness.includes('shielded');
const hasTransparent = shieldedness.includes('transparent');
if (hasShielded && hasTransparent) {
throw new Error('Mixed shielded and transparent recipients are not supported');
}
return hasShielded ? 'shielded' : undefined;
}

/**
* Whether `address` can be spent through the shielded (Orchard PCZT) path: a Unified Address
* carrying an Orchard/Ironwood receiver. Raw scripts, plain transparent addresses, and
* transparent-only Unified Addresses cannot.
*/
private isShieldedCapable(address?: string): boolean {
if (address === undefined) {
return false;
}
const unified = tryParseUnifiedAddress(address, this.name as 'zec' | 'tzec');
return unified !== undefined && unified.hasOrchardReceiver;
}

/**
* 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);
}
}
4 changes: 4 additions & 0 deletions modules/abstract-utxo/src/names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,7 @@ export function isTestnetCoin(coinName: UtxoCoinName): boolean {
export function isMainnetCoin(coinName: UtxoCoinName): boolean {
return isUtxoCoinNameMainnet(coinName);
}

export function isZcashCoin(coinName: UtxoCoinName): coinName is 'zec' | 'tzec' {
return coinName === 'zec' || coinName === 'tzec';
}
Loading