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
20 changes: 20 additions & 0 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,17 @@ export interface TransactionParams extends BaseTransactionParams {
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 {
txParams: TransactionParams;
txPrebuild: TransactionPrebuild<TNumber>;
Expand Down Expand Up @@ -560,6 +571,15 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici
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
84 changes: 72 additions & 12 deletions modules/abstract-utxo/src/impl/zec/zec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
/**
* @prettier
*/
import {
address as wasmAddress,
fixedScriptWallet,
hasPsbtMagic,
isWasmUtxoError,
zcashAddress as wasmZcashAddress,
} from '@bitgo/wasm-utxo';
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';

Expand Down Expand Up @@ -77,15 +71,81 @@ export class Zec extends AbstractUtxoCoin {
/**
* 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.
* 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 wasmAddress.toOutputScriptWithCoin(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;
}

/**
Expand Down
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';
}
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,13 @@ export async function parseTransaction<TNumber extends bigint | number>(
throw new Error('missing required txPrebuild property txHex');
}

const expectedOutputs = toExpectedOutputs(coin, txParams);
// Coins with Unified Addresses may infer the recipient preference from the recipients when
// the caller did not pass one (see Zec.getUnifiedRecipientPreference).
const effectiveTxParams = {
...txParams,
unifiedRecipientPreference: coin.getUnifiedRecipientPreference(txParams),
};
const expectedOutputs = toExpectedOutputs(coin, effectiveTxParams);

// get the keychains from the custom change wallet if needed
let customChange: CustomChangeOptions | undefined;
Expand Down Expand Up @@ -235,7 +241,7 @@ export async function parseTransaction<TNumber extends bigint | number>(
txParams: {
recipients: txParams.recipients ?? [],
changeAddress: txParams.changeAddress,
unifiedRecipientPreference: txParams.unifiedRecipientPreference,
unifiedRecipientPreference: effectiveTxParams.unifiedRecipientPreference,
},
customChange,
reqId,
Expand All @@ -252,7 +258,7 @@ 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, (address) =>
coin.resolveOutputScript(address, txParams.unifiedRecipientPreference)
coin.resolveOutputScript(address, effectiveTxParams.unifiedRecipientPreference)
),
value: output.amount === 'max' ? 'max' : (BigInt(output.amount) as bigint | 'max'),
external: output.external,
Expand Down
41 changes: 35 additions & 6 deletions modules/abstract-utxo/src/transaction/recipient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { address } from '@bitgo/wasm-utxo';
import { address, fixedScriptWallet } from '@bitgo/wasm-utxo';

import { UtxoCoinName } from '../names';
import { isZcashCoin, UtxoCoinName } from '../names';

const ScriptRecipientPrefix = 'scriptPubKey:';

Expand Down Expand Up @@ -58,16 +58,45 @@ export function toOutputScript(

const OP_RETURN = 0x6a;

/**
* Encode raw Orchard/Ironwood shielded-receiver bytes as a single-receiver ZIP-316 Unified
* Address, or return `undefined` when the bytes are not a valid receiver — `encodeOrchardReceiver`
* throws for anything that is not one, so the try/catch doubles as the receiver-validity check.
*/
function encodeShieldedReceiver(script: Buffer, coinName: 'zec' | 'tzec'): string | undefined {
try {
return fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver(new Uint8Array(script), coinName);
} catch {
return undefined;
}
}

/**
* Zcash extended-address format: a script is either a raw Orchard/Ironwood shielded receiver —
* which is not a scriptPubKey at all and can only be represented as a Unified Address — or an
* ordinary transparent scriptPubKey.
*/
function zcashToExtendedAddressFormat(script: Buffer, coinName: 'zec' | 'tzec'): string {
return encodeShieldedReceiver(script, coinName) ?? address.fromOutputScriptWithCoin(script, coinName);
}

/**
* Convert a script or address to the extended address format.
* @param script
* @param coinName
* @returns if the script is an OP_RETURN script, then it will be prefixed with `scriptPubKey:`, otherwise it will be converted to an address.
* @returns if the script is an OP_RETURN script, then it will be prefixed with `scriptPubKey:`; if
* it is a Zcash shielded receiver (a raw Orchard/Ironwood receiver, which is not a scriptPubKey at
* all), it will be encoded as a single-receiver ZIP-316 Unified Address; otherwise it will be
* converted to an address.
*/
export function toExtendedAddressFormat(script: Buffer, coinName: UtxoCoinName): string {
return script[0] === OP_RETURN
? `${ScriptRecipientPrefix}${script.toString('hex')}`
: address.fromOutputScriptWithCoin(script, coinName);
if (script[0] === OP_RETURN) {
return `${ScriptRecipientPrefix}${script.toString('hex')}`;
}
if (isZcashCoin(coinName)) {
return zcashToExtendedAddressFormat(script, coinName);
}
return address.fromOutputScriptWithCoin(script, coinName);
}

export function assertValidTransactionRecipient(output: { amount: bigint | number | string; address?: string }): void {
Expand Down
Loading