Skip to content

Commit 587a818

Browse files
committed
feat(abstract-utxo): add client-side verify support for zec shielding transactions
Ticket: CSHLD-1642
1 parent d3a9a4f commit 587a818

8 files changed

Lines changed: 486 additions & 35 deletions

File tree

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,17 @@ export interface TransactionParams extends BaseTransactionParams {
262262
unifiedRecipientPreference?: string;
263263
}
264264

265+
/**
266+
* The slice of transaction params that Unified-Address preference inference (see
267+
* AbstractUtxoCoin.getUnifiedRecipientPreference) needs. Deliberately wider than
268+
* `ITransactionRecipient`: recipients may carry `script` instead of `address` (OP_RETURN / raw
269+
* script recipients), and UTXO amounts may be bigint.
270+
*/
271+
export interface UnifiedRecipientPreferenceTxParams {
272+
recipients?: { address?: string; script?: string; amount: number | bigint | string }[];
273+
unifiedRecipientPreference?: string;
274+
}
275+
265276
export interface ParseTransactionOptions<TNumber extends number | bigint = number> extends BaseParseTransactionOptions {
266277
txParams: TransactionParams;
267278
txPrebuild: TransactionPrebuild<TNumber>;
@@ -560,6 +571,15 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici
560571
return wasmAddress.toOutputScriptWithCoin(address, this.name);
561572
}
562573

574+
/**
575+
* The effective Unified-Address recipient preference for a transaction. Coins without
576+
* Unified Addresses just pass the caller's value through; coins that accept Unified
577+
* Addresses may infer it from the recipients (see Zec).
578+
*/
579+
getUnifiedRecipientPreference(txParams: UnifiedRecipientPreferenceTxParams): string | undefined {
580+
return txParams.unifiedRecipientPreference;
581+
}
582+
563583
/**
564584
* Run custom coin logic after a transaction prebuild has been received from BitGo
565585
* @param prebuild

modules/abstract-utxo/src/impl/zec/zec.ts

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
/**
22
* @prettier
33
*/
4-
import {
5-
address as wasmAddress,
6-
fixedScriptWallet,
7-
hasPsbtMagic,
8-
isWasmUtxoError,
9-
zcashAddress as wasmZcashAddress,
10-
} from '@bitgo/wasm-utxo';
4+
import { fixedScriptWallet, hasPsbtMagic, isWasmUtxoError, zcashAddress as wasmZcashAddress } from '@bitgo/wasm-utxo';
115
import { BitGoBase, ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core';
126

13-
import { AbstractUtxoCoin } from '../../abstractUtxoCoin';
7+
import { AbstractUtxoCoin, UnifiedRecipientPreferenceTxParams } from '../../abstractUtxoCoin';
148
import { stringToBufferTryFormats } from '../../transaction/decode';
159
import { UtxoCoinName } from '../../names';
1610

@@ -77,15 +71,81 @@ export class Zec extends AbstractUtxoCoin {
7771
/**
7872
* Resolve `address` to an output script. For a Unified Address, `unifiedRecipientPreference ===
7973
* 'shielded'` resolves to the raw 43-byte Orchard/Ironwood receiver (a shielded output, no
80-
* scriptPubKey) instead of the default transparent scriptPubKey. Non-Unified addresses and any
81-
* other `unifiedRecipientPreference` value are unaffected and resolve exactly as the base
82-
* implementation would.
74+
* scriptPubKey); any other value resolves the Unified Address's transparent receiver (a plain
75+
* transparent address decodes exactly as the base implementation would). A Unified Address
76+
* without a transparent receiver cannot resolve transparently and throws.
8377
*/
8478
override resolveOutputScript(address: string, unifiedRecipientPreference?: string): Uint8Array {
8579
if (unifiedRecipientPreference === 'shielded') {
8680
return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.name);
8781
}
88-
return wasmAddress.toOutputScriptWithCoin(address, this.name);
82+
return wasmZcashAddress.toTransparentReceiverWithCoin(address, this.name);
83+
}
84+
85+
/**
86+
* Infer the Unified-Address recipient preference from the recipients when the caller did not
87+
* pass one — mirroring wallet-platform's utxo-core `buildTransaction` (`inferIsShielded` +
88+
* `classifyRecipientShieldedness`): a Unified Address carrying only an Orchard receiver can
89+
* only be spent shielded, one carrying only a transparent receiver only transparently, one
90+
* carrying both is ambiguous, and a mix of shielded and transparent recipients is rejected.
91+
*/
92+
override getUnifiedRecipientPreference(txParams: UnifiedRecipientPreferenceTxParams): string | undefined {
93+
const preference = txParams.unifiedRecipientPreference;
94+
if (preference !== undefined) {
95+
// Indexer parity (utxo-core buildTransaction): a shielded build requires every recipient
96+
// to be shielded-capable — a plain transparent address mixed in is rejected rather than
97+
// silently routed through the transparent builder.
98+
if (preference === 'shielded') {
99+
for (const recipient of txParams.recipients ?? []) {
100+
if (!this.isShieldedCapable(recipient.address)) {
101+
throw new Error('Mixed shielded and transparent recipients are not supported');
102+
}
103+
}
104+
}
105+
return preference;
106+
}
107+
const shieldedness = (txParams.recipients ?? []).map((recipient) => {
108+
if (recipient.address === undefined) {
109+
// Raw script and OP_RETURN recipients are inherently transparent.
110+
return 'transparent' as const;
111+
}
112+
const unified = tryParseUnifiedAddress(recipient.address, this.name as 'zec' | 'tzec');
113+
if (!unified) {
114+
// Not a unified address: the ordinary transparent address-decoding path handles it.
115+
return 'transparent' as const;
116+
}
117+
if (unified.hasOrchardReceiver && unified.hasTransparentReceiver) {
118+
throw new Error(
119+
`Unified address ${recipient.address} carries both transparent and Orchard receivers; specify unifiedRecipientPreference: "shielded" or "transparent"`
120+
);
121+
}
122+
if (unified.hasTransparentReceiver) {
123+
return 'transparent' as const;
124+
}
125+
if (unified.hasOrchardReceiver) {
126+
return 'shielded' as const;
127+
}
128+
throw new Error(`Unified address ${recipient.address} carries no transparent or Orchard receiver`);
129+
});
130+
const hasShielded = shieldedness.includes('shielded');
131+
const hasTransparent = shieldedness.includes('transparent');
132+
if (hasShielded && hasTransparent) {
133+
throw new Error('Mixed shielded and transparent recipients are not supported');
134+
}
135+
return hasShielded ? 'shielded' : undefined;
136+
}
137+
138+
/**
139+
* Whether `address` can be spent through the shielded (Orchard PCZT) path: a Unified Address
140+
* carrying an Orchard/Ironwood receiver. Raw scripts, plain transparent addresses, and
141+
* transparent-only Unified Addresses cannot.
142+
*/
143+
private isShieldedCapable(address?: string): boolean {
144+
if (address === undefined) {
145+
return false;
146+
}
147+
const unified = tryParseUnifiedAddress(address, this.name as 'zec' | 'tzec');
148+
return unified !== undefined && unified.hasOrchardReceiver;
89149
}
90150

91151
/**

modules/abstract-utxo/src/names.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,7 @@ export function isTestnetCoin(coinName: UtxoCoinName): boolean {
9696
export function isMainnetCoin(coinName: UtxoCoinName): boolean {
9797
return isUtxoCoinNameMainnet(coinName);
9898
}
99+
100+
export function isZcashCoin(coinName: UtxoCoinName): coinName is 'zec' | 'tzec' {
101+
return coinName === 'zec' || coinName === 'tzec';
102+
}

modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,13 @@ export async function parseTransaction<TNumber extends bigint | number>(
173173
throw new Error('missing required txPrebuild property txHex');
174174
}
175175

176-
const expectedOutputs = toExpectedOutputs(coin, txParams);
176+
// Coins with Unified Addresses may infer the recipient preference from the recipients when
177+
// the caller did not pass one (see Zec.getUnifiedRecipientPreference).
178+
const effectiveTxParams = {
179+
...txParams,
180+
unifiedRecipientPreference: coin.getUnifiedRecipientPreference(txParams),
181+
};
182+
const expectedOutputs = toExpectedOutputs(coin, effectiveTxParams);
177183

178184
// get the keychains from the custom change wallet if needed
179185
let customChange: CustomChangeOptions | undefined;
@@ -235,7 +241,7 @@ export async function parseTransaction<TNumber extends bigint | number>(
235241
txParams: {
236242
recipients: txParams.recipients ?? [],
237243
changeAddress: txParams.changeAddress,
238-
unifiedRecipientPreference: txParams.unifiedRecipientPreference,
244+
unifiedRecipientPreference: effectiveTxParams.unifiedRecipientPreference,
239245
},
240246
customChange,
241247
reqId,
@@ -252,7 +258,7 @@ export async function parseTransaction<TNumber extends bigint | number>(
252258
function toComparableOutputsWithExternal(outputs: Output[]): ComparableOutputWithExternal<bigint | 'max'>[] {
253259
return outputs.map((output) => ({
254260
script: fromExtendedAddressFormatToScript(output.address, coin.name, (address) =>
255-
coin.resolveOutputScript(address, txParams.unifiedRecipientPreference)
261+
coin.resolveOutputScript(address, effectiveTxParams.unifiedRecipientPreference)
256262
),
257263
value: output.amount === 'max' ? 'max' : (BigInt(output.amount) as bigint | 'max'),
258264
external: output.external,

modules/abstract-utxo/src/transaction/recipient.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { address } from '@bitgo/wasm-utxo';
1+
import { address, fixedScriptWallet } from '@bitgo/wasm-utxo';
22

3-
import { UtxoCoinName } from '../names';
3+
import { isZcashCoin, UtxoCoinName } from '../names';
44

55
const ScriptRecipientPrefix = 'scriptPubKey:';
66

@@ -58,16 +58,45 @@ export function toOutputScript(
5858

5959
const OP_RETURN = 0x6a;
6060

61+
/**
62+
* Encode raw Orchard/Ironwood shielded-receiver bytes as a single-receiver ZIP-316 Unified
63+
* Address, or return `undefined` when the bytes are not a valid receiver — `encodeOrchardReceiver`
64+
* throws for anything that is not one, so the try/catch doubles as the receiver-validity check.
65+
*/
66+
function encodeShieldedReceiver(script: Buffer, coinName: 'zec' | 'tzec'): string | undefined {
67+
try {
68+
return fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver(new Uint8Array(script), coinName);
69+
} catch {
70+
return undefined;
71+
}
72+
}
73+
74+
/**
75+
* Zcash extended-address format: a script is either a raw Orchard/Ironwood shielded receiver —
76+
* which is not a scriptPubKey at all and can only be represented as a Unified Address — or an
77+
* ordinary transparent scriptPubKey.
78+
*/
79+
function zcashToExtendedAddressFormat(script: Buffer, coinName: 'zec' | 'tzec'): string {
80+
return encodeShieldedReceiver(script, coinName) ?? address.fromOutputScriptWithCoin(script, coinName);
81+
}
82+
6183
/**
6284
* Convert a script or address to the extended address format.
6385
* @param script
6486
* @param coinName
65-
* @returns if the script is an OP_RETURN script, then it will be prefixed with `scriptPubKey:`, otherwise it will be converted to an address.
87+
* @returns if the script is an OP_RETURN script, then it will be prefixed with `scriptPubKey:`; if
88+
* it is a Zcash shielded receiver (a raw Orchard/Ironwood receiver, which is not a scriptPubKey at
89+
* all), it will be encoded as a single-receiver ZIP-316 Unified Address; otherwise it will be
90+
* converted to an address.
6691
*/
6792
export function toExtendedAddressFormat(script: Buffer, coinName: UtxoCoinName): string {
68-
return script[0] === OP_RETURN
69-
? `${ScriptRecipientPrefix}${script.toString('hex')}`
70-
: address.fromOutputScriptWithCoin(script, coinName);
93+
if (script[0] === OP_RETURN) {
94+
return `${ScriptRecipientPrefix}${script.toString('hex')}`;
95+
}
96+
if (isZcashCoin(coinName)) {
97+
return zcashToExtendedAddressFormat(script, coinName);
98+
}
99+
return address.fromOutputScriptWithCoin(script, coinName);
71100
}
72101

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

0 commit comments

Comments
 (0)