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
16 changes: 16 additions & 0 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,12 @@ 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;
}

export interface ParseTransactionOptions<TNumber extends number | bigint = number> extends BaseParseTransactionOptions {
Expand Down Expand Up @@ -544,6 +550,16 @@ 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);
}

/**
* 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;
}
126 changes: 125 additions & 1 deletion modules/abstract-utxo/src/impl/zec/zec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
/**
* @prettier
*/
import { BitGoBase } from '@bitgo/sdk-core';
import {
address as wasmAddress,
fixedScriptWallet,
hasPsbtMagic,
isWasmUtxoError,
zcashAddress as wasmZcashAddress,
} from '@bitgo/wasm-utxo';
import { BitGoBase, ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core';

import { AbstractUtxoCoin } 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 +41,103 @@ 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) instead of the default transparent scriptPubKey. Non-Unified addresses and any
* other `unifiedRecipientPreference` value are unaffected and resolve exactly as the base
* implementation would.
*/
override resolveOutputScript(address: string, unifiedRecipientPreference?: string): Uint8Array {
if (unifiedRecipientPreference === 'shielded') {
return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.name);
}
return wasmAddress.toOutputScriptWithCoin(address, this.name);
}

/**
* 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
Expand Up @@ -202,6 +202,7 @@ export interface ParseOutputOptions {
txParams: {
recipients: ITransactionRecipient[];
changeAddress?: string;
unifiedRecipientPreference?: string;
};
customChange?: CustomChangeOptions;
reqId?: IRequestTracer;
Expand Down Expand Up @@ -279,9 +280,11 @@ export async function parseOutput({
* recipient list is > 1000 This is not always a valid assumption and could lead greater apparent spend (but never lower)
*/
if (txParams.recipients !== undefined && txParams.recipients.length > RECIPIENT_THRESHOLD) {
const resolveScript = (address: string): Uint8Array =>
coin.resolveOutputScript(address, txParams.unifiedRecipientPreference);
const isCurrentAddressInRecipients = txParams.recipients.some((recipient) =>
fromExtendedAddressFormatToScript(recipient.address, coin.name).equals(
fromExtendedAddressFormatToScript(currentAddress, coin.name)
fromExtendedAddressFormatToScript(recipient.address, coin.name, resolveScript).equals(
fromExtendedAddressFormatToScript(currentAddress, coin.name, resolveScript)
)
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,11 @@ function toExpectedOutputs(
recipients?: ITransactionRecipient[];
allowExternalChangeAddress?: boolean;
changeAddress?: string;
unifiedRecipientPreference?: string;
}
): ExpectedOutput[] {
const resolveScript = (address: string): Uint8Array =>
coin.resolveOutputScript(address, txParams.unifiedRecipientPreference);
// verify that each recipient from txParams has their own output
const expectedOutputs: ExpectedOutput[] = (txParams.recipients ?? []).flatMap((output) => {
if (output.address === undefined) {
Expand All @@ -95,21 +98,21 @@ function toExpectedOutputs(
}
return [
{
script: toOutputScript(output, coin.name),
script: toOutputScript(output, coin.name, resolveScript),
value: output.amount === 'max' ? 'max' : BigInt(output.amount),
},
];
}
return [
{
script: fromExtendedAddressFormatToScript(output.address, coin.name),
script: fromExtendedAddressFormatToScript(output.address, coin.name, resolveScript),
value: output.amount === 'max' ? 'max' : BigInt(output.amount),
},
];
});
if (txParams.allowExternalChangeAddress && txParams.changeAddress) {
expectedOutputs.push({
script: toOutputScript(txParams.changeAddress, coin.name),
script: toOutputScript(txParams.changeAddress, coin.name, resolveScript),
// When an external change address is explicitly specified, count all outputs going towards that
// address in the expected outputs (regardless of the output amount)
value: 'max',
Expand Down Expand Up @@ -232,6 +235,7 @@ export async function parseTransaction<TNumber extends bigint | number>(
txParams: {
recipients: txParams.recipients ?? [],
changeAddress: txParams.changeAddress,
unifiedRecipientPreference: txParams.unifiedRecipientPreference,
},
customChange,
reqId,
Expand All @@ -247,7 +251,9 @@ export async function parseTransaction<TNumber extends bigint | number>(

function toComparableOutputsWithExternal(outputs: Output[]): ComparableOutputWithExternal<bigint | 'max'>[] {
return outputs.map((output) => ({
script: fromExtendedAddressFormatToScript(output.address, coin.name),
script: fromExtendedAddressFormatToScript(output.address, coin.name, (address) =>
coin.resolveOutputScript(address, txParams.unifiedRecipientPreference)
),
value: output.amount === 'max' ? 'max' : (BigInt(output.amount) as bigint | 'max'),
external: output.external,
}));
Expand Down
Loading
Loading