Skip to content
Closed
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
4 changes: 2 additions & 2 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,9 +907,9 @@ 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
38 changes: 37 additions & 1 deletion modules/abstract-utxo/src/impl/zec/address.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { address as wasmAddress, fixedScriptWallet, isCoinName, zcashAddress } from '@bitgo/wasm-utxo';
import type { UnifiedRecipientPreference } from '@bitgo/sdk-core';

import { AddressCodec } from '../../transaction/recipient';
import { AddressCodec, type AddressCodecOutput } from '../../transaction/recipient';
import { UtxoCoinName, WasmUtxoCoinName } from '../../names';

export type ZcashAddressKind = 'transparent' | 'shielded';
Expand Down Expand Up @@ -79,6 +79,42 @@ 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: AddressCodecOutput): 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 = Reflect.get(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))
);
}
}

/**
Expand Down
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +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 { AddressCodec } from './recipient';

/**
* Decompose a raw transaction into useful information, such as the total amounts,
Expand All @@ -24,7 +25,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 +49,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
@@ -1,8 +1,9 @@
import { fixedScriptWallet, bip322 } from '@bitgo/wasm-utxo';
import { bip322, fixedScriptWallet } from '@bitgo/wasm-utxo';
import { Triple } from '@bitgo/sdk-core';

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

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

Expand Down Expand Up @@ -40,6 +41,7 @@ function toExternalOutputBigInt(output: ParsedExternalOutput): Output<bigint> {
}

interface ExplainPsbtWasmParams {
addressCodec: AddressCodec;
replayProtection: {
checkSignature?: boolean;
publicKeys: Buffer[];
Expand Down Expand Up @@ -102,6 +104,9 @@ export function explainPsbtWasmBigInt(
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,17 +106,19 @@ function toExpectedOutputs(
{
script: addressCodec.fromExtendedAddressFormatToScript(output.address),
value: output.amount === 'max' ? 'max' : BigInt(output.amount),
address: output.address,
},
];
});
if (txParams.allowExternalChangeAddress && txParams.changeAddress) {
expectedOutputs.push({
script: addressCodec.toOutputScript(txParams.changeAddress),
script: addressCodec.decodeChangeScript(txParams.changeAddress),
// 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',
// 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,16 @@ 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 === false
? addressCodec.decodeChangeScript(output.address)
: addressCodec.fromExtendedAddressFormatToScript(output.address),
value: output.amount === 'max' ? 'max' : (BigInt(output.amount) as bigint | 'max'),
external: output.external,
address: output.address,
}));
}

Expand All @@ -277,7 +287,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 +295,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.outputScriptToAddress(output.script, output.address),
amount: output.value.toString(),
external: output.external,
}));
Expand Down
42 changes: 42 additions & 0 deletions modules/abstract-utxo/src/transaction/recipient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { toWasmUtxoCoinName, UtxoCoinName, WasmUtxoCoinName } from '../names';
const ScriptRecipientPrefix = 'scriptPubKey:';
const OP_RETURN = 0x6a;

export interface AddressCodecOutput {
address?: string | null;
script: Uint8Array;
}

/** Address/network-aware recipient conversion. */
export class AddressCodec {
constructor(
Expand Down Expand Up @@ -41,6 +46,31 @@ export class AddressCodec {
return wasmAddress.toOutputScriptWithCoin(address, this.wasmName);
}

/**
* Resolve a change address to its script. Change addresses are always transparent wallet
* addresses, so coins whose address resolution depends on transaction context (e.g. Zcash
* Unified Addresses with a bound recipient preference) override this to bypass that
* context. The base implementation defers to decode.
*/
decodeChangeAddress(address: string): Uint8Array {
return this.decode(address);
}

/** Resolve a transparent change address directly to a Buffer script. */
decodeChangeScript(address: string): Buffer {
return Buffer.from(this.decodeChangeAddress(address));
}

/**
* Convert an output's scriptPubKey back to the address form the output should report. The
* base implementation encodes the script. Coins whose output scripts cannot always be
* re-encoded (e.g. Zcash shielded recipients, whose raw Orchard receiver has no scriptPubKey
* encoding) override this and may fall back to the output's original address.
*/
outputScriptToAddress(script: Buffer, address?: string): string {
return this.toExtendedAddressFormat(script);
}

encode(script: Uint8Array): string {
return wasmAddress.fromOutputScriptWithCoin(script, this.wasmName);
}
Expand All @@ -61,6 +91,18 @@ export class AddressCodec {
return Buffer.from(this.decode(result.address));
}

isMatchingScript(output: AddressCodecOutput): boolean {
if (output.address === undefined || output.address === null) {
return true;
}

try {
return this.fromExtendedAddressFormatToScript(output.address).equals(Buffer.from(output.script));
} catch {
return false;
}
}

toOutputScript(v: string | { address: string } | { script: string }): Buffer {
if (typeof v === 'string') {
return this.fromExtendedAddressFormatToScript(v);
Expand Down
16 changes: 13 additions & 3 deletions modules/abstract-utxo/test/unit/bip322.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { bip322 as wasmBip322, fixedScriptWallet, BIP32, type Triple } from '@bi
import { getKeyTriple } from '@bitgo/wasm-utxo/testutils';

import { explainPsbtWasm } from '../../src/transaction/fixedScript';
import { AddressCodec } from '../../src/transaction/recipient';
import {
BIP322MessageBroadcastable,
BIP322MessageInfo,
Expand Down Expand Up @@ -439,20 +440,29 @@ describe('BIP322', function () {

it('should successfully run with a user nonce', function () {
const psbt = createUnsignedPsbt();
assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 0);
assertCommon(
explainPsbtWasm(psbt, walletKeys, { addressCodec: new AddressCodec('btc'), replayProtection: { publicKeys: [] } }),
0
);
});

it('should successfully run with a user signature', function () {
const psbt = createUnsignedPsbt();
psbt.sign(BIP32.fromBase58(xprivs[0]));
assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 1);
assertCommon(
explainPsbtWasm(psbt, walletKeys, { addressCodec: new AddressCodec('btc'), replayProtection: { publicKeys: [] } }),
1
);
});

it('should successfully run with a hsm signature', function () {
const psbt = createUnsignedPsbt();
psbt.sign(BIP32.fromBase58(xprivs[0]));
psbt.sign(BIP32.fromBase58(xprivs[2]));
assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 2);
assertCommon(
explainPsbtWasm(psbt, walletKeys, { addressCodec: new AddressCodec('btc'), replayProtection: { publicKeys: [] } }),
2
);
});
});

Expand Down
Loading