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
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

weak string type

the optional argument here is only useful for zcash and leaks into general AbstractUtxo, we should look for a better solution

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,3 +1,4 @@
export * from './zec';
export * from './recipients';
export * from './tzec';
export * from './address';
107 changes: 107 additions & 0 deletions modules/abstract-utxo/src/impl/zec/recipients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @prettier
*/
import { fixedScriptWallet } from '@bitgo/wasm-utxo';

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;
}

/**
* 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.
*/
export function resolvePsbtRecipients(
psbt: fixedScriptWallet.ZcashBitGoPsbt,
walletKeys: fixedScriptWallet.RootWalletKeys
): PsbtRecipient[] {
const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, {
replayProtection: { publicKeys: getReplayProtectionPubkeys('zec') },
});

const recipients: PsbtRecipient[] = [];
parsed.outputs.forEach((output, i) => {
// Wallet-owned (change) outputs.
if (output.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;
}
144 changes: 137 additions & 7 deletions modules/abstract-utxo/src/impl/zec/zec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
/**
* @prettier
*/
import { BitGoBase } from '@bitgo/sdk-core';
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
import { fixedScriptWallet, hasPsbtMagic, 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 { isShieldedZcashAddress } from './address';
import { resolvePsbtRecipients, PsbtRecipient } from './recipients';

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
readonly name: UtxoCoinName = 'zec';
readonly name: 'zec' | 'tzec' = 'zec';

this should get rid of the as 'zec' | 'tzec' casts, please try

Expand All @@ -20,10 +21,139 @@ export class Zec extends AbstractUtxoCoin {
return new Zec(bitgo);
}

isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean {
if (super.isValidAddress(address, param)) {
return true;
/**
* 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 isShieldedZcashAddress(address, this.name as fixedScriptWallet.ZcashNetworkName);
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 {
try {
const unifiedAddress = fixedScriptWallet.ZcashUnifiedAddress.parse(address, this.name as 'zec' | 'tzec');
return unifiedAddress.transparentScript !== undefined || unifiedAddress.orchardReceiver !== undefined;
} catch (e) {
// Not a unified address for this network — defer to the base transparent-address
// validation.
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.
*/
getUnifiedRecipientPreference(txParams: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any tests

move this to a standalone func to recipients.ts and add better tests for it

you can also clean up the signature

getUnifiedRecipientPreference(
name: 'zec' | 'tzec',
recipients: { address: string | undefined; }[] // amount isn't actually used
)

recipients?: { address?: string; amount: number | bigint | string }[];
unifiedRecipientPreference?: string;
}): string | undefined {
if (txParams.unifiedRecipientPreference !== undefined) {
return txParams.unifiedRecipientPreference;
}
const shieldedness = (txParams.recipients ?? []).map((recipient) => {
if (recipient.address === undefined) {
// Raw script inherently transparent.
return 'transparent' as const;
Comment on lines +89 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

}
let unified: fixedScriptWallet.ZcashUnifiedAddress | undefined;
try {
unified = fixedScriptWallet.ZcashUnifiedAddress.parse(recipient.address, this.name as 'zec' | 'tzec');
} catch (e) {
// Not a unified address: the ordinary transparent address-decoding path handles it.
return 'transparent' as const;
Comment on lines +96 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't agree with that

ZcashUnifiedAddress.parse can fail for many different reasons, I don't think it's fair to assume transparent as a good default here

we should try to parse it as a transparent address first instead, return transparent on success, and then try to parse it as a Unified address and propagate the error if it is nothing recognizable

}
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;
}

/**
* Deserialize a Zcash PSBT (v4 Sapling-shaped or v6 Ironwood). `ZcashPsbt.fromBytes` reads
* the Zcash transaction version from the parsed metadata and returns the format-specific
* implementation — `ZcashBitGoPsbt` for v4, `ZcashIronwoodBitGoPsbt` for v6 — so no
* byte-level sniffing or fallback dispatch is needed here.
*/
override decodeTransaction(input: Buffer | string): fixedScriptWallet.BitGoPsbt {
const buffer = typeof input === 'string' ? stringToBufferTryFormats(input, ['hex', 'base64']) : input;
if (!hasPsbtMagic(buffer)) {
return super.decodeTransaction(input);
}
return fixedScriptWallet.ZcashPsbt.fromBytes(buffer, this.name as 'zec' | 'tzec');
}

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 outputs are excluded.
*/
resolveRecipientsFromPsbt(input: Buffer | string, walletKeys: fixedScriptWallet.RootWalletKeys): PsbtRecipient[] {
const psbt = this.decodeTransaction(input);
if (!(psbt instanceof fixedScriptWallet.ZcashBitGoPsbt)) {
throw new Error('expected a Zcash PSBT');
}
return resolvePsbtRecipients(psbt, walletKeys);
}
}
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