Skip to content
Merged
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
2 changes: 2 additions & 0 deletions modules/abstract-utxo/src/impl/zec/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './zec';
export * from './tzec';
export * from './address';
export * from './recipients';
export * from './types';
153 changes: 153 additions & 0 deletions modules/abstract-utxo/src/impl/zec/recipients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { fixedScriptWallet, zcashAddress } from '@bitgo/wasm-utxo';

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

import { ZcashCoinName, UnifiedRecipientPreference } from './types';

/**
* 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.
*/
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;
}

/**
* Infer the Unified-Address recipient preference for a Zcash transaction when the caller did
* not pass one — the counterpart of wallet-platform's utxo-core `buildTransaction`
* `classifyRecipientShieldedness`.
*
* A recipient that resolves to a transparent output — an ordinary transparent address, or a
* Unified Address carrying a transparent receiver — is classified `'transparent'`; a Unified
* Address carrying only an Orchard/Ironwood receiver is classified `'shielded'`. A mix of
* shielded and transparent recipients is rejected. An address that is neither a transparent
* address nor a Unified Address propagates the Unified-Address parse error — it is not
* silently defaulted to `'transparent'`.
*
* @returns `'shielded'` when every recipient resolves shielded, `undefined` when every
* recipient resolves transparent (the build's default). The `'transparent'` arm of the
* return type exists so callers can pass the explicit preference through unchanged; this
* function itself never returns `'transparent'`.
*/
export function getUnifiedRecipientPreference(
name: ZcashCoinName,
recipients: { address: string | undefined }[]
): UnifiedRecipientPreference | undefined {
const shieldedness = recipients.map((recipient) => {
if (recipient.address === undefined) {
// Raw script inherently transparent.
return 'transparent' as const;
}
// Ordinary transparent address, or a Unified Address carrying a transparent receiver:
// resolves transparently either way (the build's default when no preference is given).
if (zcashAddress.hasTransparentReceiver(recipient.address, name)) {
return 'transparent' as const;
}
// A shielded (Orchard/Ironwood-only) Unified Address is the only remaining resolvable
// form. An address that is none of the above propagates the parse error instead of
// assuming a default.
const unified = fixedScriptWallet.ZcashUnifiedAddress.parse(recipient.address, name);
if (unified.hasOrchardReceiver) {
return 'shielded' as const;
}
throw new Error(`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;
}
5 changes: 5 additions & 0 deletions modules/abstract-utxo/src/impl/zec/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** A Zcash coin name — the only UTXO coins with shielded (Orchard/Ironwood) support. */
export type ZcashCoinName = 'zec' | 'tzec';

/** How a Zcash Unified Address recipient should be resolved: to its shielded (Orchard/Ironwood) receiver or its transparent receiver. */
export type UnifiedRecipientPreference = 'shielded' | 'transparent';
11 changes: 4 additions & 7 deletions modules/abstract-utxo/src/impl/zec/zec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@
* @prettier
*/
import { BitGoBase } from '@bitgo/sdk-core';
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
import { zcashAddress } from '@bitgo/wasm-utxo';

import { AbstractUtxoCoin } from '../../abstractUtxoCoin';
import { UtxoCoinName } from '../../names';

import { isShieldedZcashAddress } from './address';

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

Expand All @@ -21,9 +19,8 @@ export class Zec extends AbstractUtxoCoin {
}

isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean {
if (super.isValidAddress(address, param)) {
return true;
}
return isShieldedZcashAddress(address, this.name as fixedScriptWallet.ZcashNetworkName);
return (
zcashAddress.hasTransparentReceiver(address, this.name) || zcashAddress.hasOrchardReceiver(address, this.name)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';

import { address as wasmAddress, fixedScriptWallet } from '@bitgo/wasm-utxo';

import { resolvePsbtRecipients } from '../../../../../src/impl/zec/recipients';
import { getDefaultWasmWalletKeys } from '../../../util';

// ZIP-316 unified-address test vector, copied from
// BitGoWASM/packages/wasm-utxo/test/fixtures/zcash/unified_address.json so both repos test
// against the same known-good data.
const testnetWallet = {
unified:
'utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps',
transparentAddress: 'tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk',
};
const IRONWOOD_HEIGHT = 4200000; // after the NU6.3 testnet activation (4134000)
const IRONWOOD_RECEIVER = Buffer.from(
'd632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5',
'hex'
);

describe('resolvePsbtRecipients', function () {
const { walletKeys } = getDefaultWasmWalletKeys();

function buildV4Psbt(unifiedAddress?: string): fixedScriptWallet.ZcashBitGoPsbt {
const psbt = fixedScriptWallet.ZcashBitGoPsbt.createEmpty('tzec', walletKeys, { blockHeight: 3146400 });
psbt.addWalletInput({ txid: '22'.repeat(32), vout: 0, value: 200000n }, walletKeys, {
scriptId: { chain: 0, index: 1 },
});
psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 100000n });
const externalScript = wasmAddress.toOutputScriptWithCoin(testnetWallet.transparentAddress, 'tzec');
psbt.addTransparentOutput(externalScript, 12345n, unifiedAddress);
return psbt;
}

function buildV6Psbt(unifiedAddress?: string): fixedScriptWallet.ZcashIronwoodBitGoPsbt {
const psbt = fixedScriptWallet.ZcashIronwoodBitGoPsbt.createEmpty('tzec', walletKeys, {
blockHeight: IRONWOOD_HEIGHT,
});
psbt.addWalletInput({ txid: '11'.repeat(32), vout: 0, value: 100000n }, walletKeys, {
scriptId: { chain: 0, index: 0 },
});
psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 90000n });
psbt.addShieldedOutputs(
[{ recipient: new Uint8Array(IRONWOOD_RECEIVER), amount: 5000n, unifiedAddress }],
new Uint8Array(32) // all-zero anchor, as in the utxo-core shielded build tests
);
return psbt;
}

it('resolves external transparent outputs and excludes wallet change (v4)', function () {
const recipients = resolvePsbtRecipients(buildV4Psbt(), walletKeys);
assert.strictEqual(recipients.length, 1);
const recipient = recipients[0];
assert.strictEqual(recipient.destination.kind, 'transparent');
assert.strictEqual(recipient.address, testnetWallet.transparentAddress);
assert.strictEqual(recipient.amount, 12345n);
assert.strictEqual(recipient.unifiedAddress, undefined);
assert.deepStrictEqual(
Buffer.from(recipient.script),
Buffer.from(wasmAddress.toOutputScriptWithCoin(testnetWallet.transparentAddress, 'tzec'))
);
});

it('reports the original UA verbatim for a transparent output built from a Unified Address (v4)', function () {
const recipients = resolvePsbtRecipients(buildV4Psbt(testnetWallet.unified), walletKeys);
assert.strictEqual(recipients.length, 1);
const recipient = recipients[0];
assert.deepStrictEqual(recipient.destination, {
kind: 'zcashUnifiedTransparent',
unifiedAddress: testnetWallet.unified,
});
assert.strictEqual(recipient.unifiedAddress, testnetWallet.unified);
// For a transparent output built from a Unified Address, the parsed address is the UA
// itself (verbatim), not just the transparent receiver.
assert.strictEqual(recipient.address, testnetWallet.unified);
});

it('resolves a shielded v6 output to its (re-encoded) Orchard Unified Address recipient', function () {
const recipients = resolvePsbtRecipients(buildV6Psbt(), walletKeys);
assert.strictEqual(recipients.length, 1);
const recipient = recipients[0];
const expectedAddress = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver(
new Uint8Array(IRONWOOD_RECEIVER),
'tzec'
);
assert.deepStrictEqual(recipient.destination, {
kind: 'zcashShielded',
unifiedAddress: expectedAddress,
});
assert.strictEqual(recipient.address, expectedAddress);
assert.strictEqual(recipient.amount, 5000n);
assert.deepStrictEqual(Buffer.from(recipient.script), IRONWOOD_RECEIVER);
});

it('reports the original UA verbatim for a shielded output built from a Unified Address (v6)', function () {
const orchardOnlyUa = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver(
new Uint8Array(IRONWOOD_RECEIVER),
'tzec'
);
const recipients = resolvePsbtRecipients(buildV6Psbt(orchardOnlyUa), walletKeys);
assert.strictEqual(recipients.length, 1);
const recipient = recipients[0];
assert.deepStrictEqual(recipient.destination, { kind: 'zcashShielded', unifiedAddress: orchardOnlyUa });
assert.strictEqual(recipient.unifiedAddress, orchardOnlyUa);
assert.strictEqual(recipient.address, orchardOnlyUa);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';

import { fixedScriptWallet } from '@bitgo/wasm-utxo';

import { getUnifiedRecipientPreference } from '../../../../../src/impl/zec/recipients';

// ZIP-316 unified-address test vectors, copied from
// BitGoWASM/packages/wasm-utxo/test/fixtures/zcash/unified_address.json so
// both repos test against the same known-good data.
const zip316Mainnet = {
unified:
'u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx',
};
const testnetWallet = {
unified:
'utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps',
transparentAddress: 'tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk',
};

describe('getUnifiedRecipientPreference', function () {
function orchardOnlyUa(network: 'zec' | 'tzec'): string {
return fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver(new Uint8Array(43).fill(0x42), network);
}

it('returns undefined for no recipients', function () {
assert.strictEqual(getUnifiedRecipientPreference('tzec', []), undefined);
});

it('returns undefined for an ordinary transparent address', function () {
assert.strictEqual(
getUnifiedRecipientPreference('tzec', [{ address: testnetWallet.transparentAddress }]),
undefined
);
});

it('returns undefined for a raw-script recipient with no address', function () {
assert.strictEqual(getUnifiedRecipientPreference('tzec', [{ address: undefined }]), undefined);
});

it("returns 'shielded' for an Orchard-only Unified Address", function () {
assert.strictEqual(getUnifiedRecipientPreference('tzec', [{ address: orchardOnlyUa('tzec') }]), 'shielded');
});

it("infers 'shielded' on mainnet", function () {
assert.strictEqual(getUnifiedRecipientPreference('zec', [{ address: orchardOnlyUa('zec') }]), 'shielded');
});

it('classifies a Unified Address carrying transparent + Orchard receivers as transparent', function () {
// Both official vectors carry a transparent receiver alongside the Orchard one, so the
// transparent-first classification applies even though they also classify as shielded.
assert.strictEqual(getUnifiedRecipientPreference('tzec', [{ address: testnetWallet.unified }]), undefined);
assert.strictEqual(getUnifiedRecipientPreference('zec', [{ address: zip316Mainnet.unified }]), undefined);
});

it('fails hard for an unrecognizable address instead of defaulting to transparent', function () {
assert.throws(() => getUnifiedRecipientPreference('tzec', [{ address: 'not-an-address' }]));
});

it('fails hard for a unified address on the wrong network', function () {
assert.throws(() => getUnifiedRecipientPreference('tzec', [{ address: zip316Mainnet.unified }]));
});

it('rejects a mix of shielded and transparent recipients', function () {
assert.throws(
() =>
getUnifiedRecipientPreference('tzec', [
{ address: orchardOnlyUa('tzec') },
{ address: testnetWallet.transparentAddress },
]),
/Mixed shielded and transparent recipients/
);
});
});
Loading