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
9 changes: 7 additions & 2 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,9 +907,14 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici
if (wallet && isDescriptorWallet(wallet)) {
// Descriptor wallets decode prebuild bytes straight into the wasm-utxo
// descriptor Psbt, skipping the fixedScriptWallet.BitGoPsbt intermediate.
return explainTx(decodeDescriptorPsbt(params), { ...params, wallet }, this.wasmName);
return explainTx(decodeDescriptorPsbt(params), { ...params, wallet }, this.wasmName, this.addressCodec);
}
return explainTx(this.decodeTransactionFromPrebuild(params), { ...params, wallet }, this.wasmName);
return explainTx(
this.decodeTransactionFromPrebuild(params),
{ ...params, wallet },
this.wasmName,
this.addressCodec
);
}

/**
Expand Down
43 changes: 43 additions & 0 deletions modules/abstract-utxo/src/impl/zec/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { UnifiedRecipientPreference } from '@bitgo/sdk-core';
import { AddressCodec } from '../../transaction/recipient';
import { UtxoCoinName, WasmUtxoCoinName } from '../../names';

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

export type ZcashAddressKind = 'transparent' | 'shielded';

/**
Expand Down Expand Up @@ -79,6 +81,47 @@ export class ZecAddressCodec extends AddressCodec {
}
return zcashAddress.toShieldedReceiverWithCoin(address, this.wasmName);
}

/** Change addresses are always transparent wallet addresses. */
override decodeChangeAddress(address: string): Uint8Array {
return zcashAddress.toTransparentReceiverWithCoin(address, this.wasmName);
}
override isMatchingScript(output: ZecAddressCodecOutput): boolean {
const address = output.address;
if (address === undefined || address === null) {
return true;
}
if (AddressCodec.isScriptRecipient(address)) {
return super.isMatchingScript(output);
}

const matchesOutput = (decode: () => Uint8Array): boolean => {
try {
return Buffer.from(decode()).equals(Buffer.from(output.script));
} catch {
return false;
}
};
const isShielded = output.isShielded;
if (isShielded === true) {
return matchesOutput(() => zcashAddress.toShieldedReceiverWithCoin(address, this.wasmName));
}
if (isShielded === false) {
return matchesOutput(() => zcashAddress.toTransparentReceiverWithCoin(address, this.wasmName));
}
return (
matchesOutput(() => zcashAddress.toTransparentReceiverWithCoin(address, this.wasmName)) ||
matchesOutput(() => zcashAddress.toShieldedReceiverWithCoin(address, this.wasmName))
);
}

/** Preserve a shielded output's original UA only after validating it against the raw script. */
override toExtendedAddressFormat(script: Buffer, address?: string): string {
if (address !== undefined && !this.isMatchingScript({ address, script })) {
throw new Error(`address ${address} does not match the output script`);
}
return address ?? super.toExtendedAddressFormat(script);
}
}

/**
Expand Down
10 changes: 8 additions & 2 deletions modules/abstract-utxo/src/impl/zec/recipients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fixedScriptWallet, zcashAddress } from '@bitgo/wasm-utxo';
import type { UnifiedRecipientPreference } from '@bitgo/sdk-core';

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

import { ZcashCoinName } from './types';

Expand Down Expand Up @@ -70,7 +71,8 @@ export interface PsbtRecipient {
*/
export function resolvePsbtRecipients(
psbt: fixedScriptWallet.ZcashBitGoPsbt,
walletKeys: fixedScriptWallet.RootWalletKeys
walletKeys: fixedScriptWallet.RootWalletKeys,
addressCodec: AddressCodec
): PsbtRecipient[] {
const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, {
replayProtection: { publicKeys: getReplayProtectionPubkeys('zec') },
Expand All @@ -86,8 +88,12 @@ export function resolvePsbtRecipients(
if (output.address === null) {
return;
}
// The raw parsed receiver/script is authoritative; proprietary Unified Address metadata
// must match it before it is exposed to callers.
if (!addressCodec.isMatchingScript(output)) {
throw new Error(`Output ${i} address ${output.address} does not match its raw recipient`);
}
// 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({
Expand Down
7 changes: 7 additions & 0 deletions modules/abstract-utxo/src/impl/zec/types.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
import type { AddressCodecOutput } from '../../transaction/recipient';

/** A Zcash coin name — the only UTXO coins with shielded (Orchard/Ironwood) support. */
export type ZcashCoinName = 'zec' | 'tzec';

/** Parsed output metadata used to distinguish Zcash transparent and shielded receivers. */
export interface ZecAddressCodecOutput extends AddressCodecOutput {
isShielded?: boolean;
}
78 changes: 75 additions & 3 deletions modules/abstract-utxo/src/impl/zec/zec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
/**
* @prettier
*/
import { BitGoBase, MPCAlgorithm } from '@bitgo/sdk-core';
import { fixedScriptWallet, hasPsbtMagic, zcashAddress } from '@bitgo/wasm-utxo';
import {
BitGoBase,
ExtraPrebuildParamsOptions,
MPCAlgorithm,
Wallet,
UnifiedRecipientPreference,
} from '@bitgo/sdk-core';

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

import { ZecAddressCodec } from './address';
import { resolvePsbtRecipients, PsbtRecipient } from './recipients';
import type { ZcashCoinName } from './types';

export class Zec extends AbstractUtxoCoin {
readonly name: UtxoCoinName = 'zec';
Expand Down Expand Up @@ -36,4 +48,64 @@ export class Zec extends AbstractUtxoCoin {
isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean {
return this.addressCodec.isValidAddress(address);
}

private inferUnifiedRecipientPreference(
recipients: { address: string | undefined }[] | undefined
): UnifiedRecipientPreference | undefined {
const shieldedness = (recipients ?? []).map((recipient): UnifiedRecipientPreference | undefined => {
const address = recipient.address;
if (address === undefined) {
return 'transparent';
}
if (AddressCodec.isScriptRecipient(address)) {
return 'transparent';
}
const hasTransparentReceiver = zcashAddress.hasTransparentReceiver(address, this.wasmName);
const hasOrchardReceiver = zcashAddress.hasOrchardReceiver(address, this.wasmName);
return hasOrchardReceiver && !hasTransparentReceiver
? 'shielded'
: hasTransparentReceiver
? 'transparent'
: undefined;
});
if (shieldedness.includes('shielded') && shieldedness.includes('transparent')) {
throw new Error('Mixed shielded and transparent recipients are not supported');
}
return shieldedness.includes('shielded') ? 'shielded' : undefined;
}

override async parseTransaction<TNumber extends number | bigint = number>(
params: ParseTransactionOptions<TNumber>
): Promise<ParsedTransaction<TNumber>> {
const preference =
params.txParams.unifiedRecipientPreference ?? this.inferUnifiedRecipientPreference(params.txParams.recipients);
return this.parseTransactionWithAddressCodec(
params,
new ZecAddressCodec(this.name, this.wasmName, preference ?? 'transparent')
);
}

override async getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet }) {
const extraParams = await super.getExtraPrebuildParams(buildParams);
const { unifiedRecipientPreference } = buildParams;
if (unifiedRecipientPreference === undefined) {
return extraParams;
}
return { ...extraParams, unifiedRecipientPreference };
}

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, toWasmUtxoCoinName(this.name) as ZcashCoinName);
}
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, this.addressCodec);
}
}
6 changes: 4 additions & 2 deletions modules/abstract-utxo/src/transaction/explainTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getReplayProtectionPubkeys } from './fixedScript/replayProtection';
import type { TransactionExplanationUtxolibPsbt, TransactionExplanationWasm } from './fixedScript/explainTransaction';
import * as fixedScript from './fixedScript';
import * as descriptor from './descriptor';

import type { AddressCodec } from './recipient';
/**
* Decompose a raw transaction into useful information, such as the total amounts,
* change amounts, and transaction outputs.
Expand All @@ -24,7 +24,8 @@ export function explainTx<TNumber extends number | bigint>(
customChangeXpubs?: Triple<string>;
txInfo?: { unspents?: Unspent<TNumber>[] };
},
coinName: UtxoCoinName | WasmUtxoCoinName
coinName: UtxoCoinName | WasmUtxoCoinName,
addressCodec: AddressCodec
): TransactionExplanationUtxolibPsbt | TransactionExplanationWasm {
if (params.wallet && isDescriptorWallet(params.wallet)) {
if (!(tx instanceof WasmPsbt)) {
Expand All @@ -47,6 +48,7 @@ export function explainTx<TNumber extends number | bigint>(
throw new Error('pub triple must be valid triple or RootWalletKeys');
}
return fixedScript.explainPsbtWasm(tx, walletXpubs, {
addressCodec,
replayProtection: {
publicKeys: getReplayProtectionPubkeys(coinName),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { Triple } from '@bitgo/sdk-core';

import type { FixedScriptWalletOutput, Output, BitGoPsbt } from '../types';
import type { Bip322Message } from '../../abstractUtxoCoin';
import { AddressCodec } from '../recipient';

import type { TransactionExplanationWasm } from './explainTransaction';

function scriptToAddress(script: Uint8Array): string {
return `scriptPubKey:${Buffer.from(script).toString('hex')}`;
}
Expand Down Expand Up @@ -40,6 +40,7 @@ function toExternalOutputBigInt(output: ParsedExternalOutput): Output<bigint> {
}

interface ExplainPsbtWasmParams {
addressCodec: AddressCodec;
replayProtection: {
checkSignature?: boolean;
publicKeys: Buffer[];
Expand Down Expand Up @@ -98,10 +99,11 @@ export function explainPsbtWasmBigInt(
const parsedCustomChangeOutputs = params.customChangeWalletXpubs
? psbt.parseOutputsWithWalletKeys(params.customChangeWalletXpubs)
: undefined;

const customChangeOutputs: FixedScriptWalletOutput<bigint>[] = [];

parsed.outputs.forEach((output, i) => {
if (!params.addressCodec.isMatchingScript(output)) {
throw new Error(`Output ${i} address ${output.address} does not match its raw script`);
}
const parseCustomChangeOutput = parsedCustomChangeOutputs?.[i];
if (isParsedWalletOutput(output)) {
changeOutputs.push(toChangeOutputBigInt(output));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export type ComparableOutputWithExternal<TValue> = (ComparableOutput<TValue> | E
external: boolean | undefined;
};

type ExpectedOutputWithAddress = ExpectedOutput & { address?: string };
type ComparableOutputWithAddress<TValue> = ComparableOutputWithExternal<TValue> & { address: string };

function toCanonicalTransactionRecipient(
coin: AbstractUtxoCoin,
output: { valueString: string; address?: string }
Expand Down Expand Up @@ -84,9 +87,9 @@ function toExpectedOutputs(
allowExternalChangeAddress?: boolean;
changeAddress?: string;
}
): ExpectedOutput[] {
): ExpectedOutputWithAddress[] {
// verify that each recipient from txParams has their own output
const expectedOutputs: ExpectedOutput[] = (txParams.recipients ?? []).flatMap((output) => {
const expectedOutputs: ExpectedOutputWithAddress[] = (txParams.recipients ?? []).flatMap((output) => {
if (output.address === undefined) {
assert('script' in output, 'script is required for non-encodeable scriptPubkeys');
if (output.amount.toString() !== '0') {
Expand All @@ -103,6 +106,7 @@ function toExpectedOutputs(
{
script: addressCodec.fromExtendedAddressFormatToScript(output.address),
value: output.amount === 'max' ? 'max' : BigInt(output.amount),
address: output.address,
},
];
});
Expand All @@ -114,6 +118,7 @@ function toExpectedOutputs(
value: 'max',
// Note that the change output is not required to exist, so we mark it as optional.
optional: true,
address: txParams.changeAddress,
});
}
return expectedOutputs;
Expand Down Expand Up @@ -246,11 +251,15 @@ export async function parseTransaction<TNumber extends bigint | number>(

const changeOutputs = _.filter(allOutputDetails, { external: false });

function toComparableOutputsWithExternal(outputs: Output[]): ComparableOutputWithExternal<bigint | 'max'>[] {
function toComparableOutputsWithExternal(outputs: Output[]): ComparableOutputWithAddress<bigint | 'max'>[] {
return outputs.map((output) => ({
script: addressCodec.fromExtendedAddressFormatToScript(output.address),
// Change/custom-change outputs are always transparent wallet addresses.
script: output.external
? addressCodec.fromExtendedAddressFormatToScript(output.address)
: Buffer.from(addressCodec.decodeChangeAddress(output.address)),
value: output.amount === 'max' ? 'max' : (BigInt(output.amount) as bigint | 'max'),
external: output.external,
address: output.address,
}));
}

Expand All @@ -277,7 +286,6 @@ export async function parseTransaction<TNumber extends bigint | number>(
*
* This has become obsolete with the intoduction of `utxocore.paygo.verifyPayGoAddressProof()`.
*/

// make sure that all the extra addresses are change addresses
// get all the additional external outputs the server added and calculate their values
const implicitExternalOutputs = implicitOutputs.filter((output) => output.external);
Expand All @@ -286,9 +294,9 @@ export async function parseTransaction<TNumber extends bigint | number>(
coin.amountType
) as TNumber;

function toOutputs(outputs: ExpectedOutput[] | ComparableOutputWithExternal<bigint | 'max'>[]): Output[] {
function toOutputs(outputs: ExpectedOutputWithAddress[] | ComparableOutputWithAddress<bigint | 'max'>[]): Output[] {
return outputs.map((output) => ({
address: addressCodec.toExtendedAddressFormat(output.script),
address: addressCodec.toExtendedAddressFormat(output.script, output.address),
amount: output.value.toString(),
external: output.external,
}));
Expand Down
Loading
Loading