Skip to content

Commit c0b5ca0

Browse files
committed
feat(abstract-utxo): add zec shielded psbt and recipient resolution support
Ticket: CSHLD-1640
1 parent 689a825 commit c0b5ca0

5 files changed

Lines changed: 378 additions & 3 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './zec';
2+
export * from './recipients';
23
export * from './tzec';
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* @prettier
3+
*/
4+
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
5+
import { Triple } from '@bitgo/sdk-core';
6+
7+
import { getReplayProtectionPubkeys } from '../../transaction/fixedScript/replayProtection';
8+
9+
/**
10+
* How a recipient parsed from a Zcash PSBT is spent.
11+
*
12+
* The decode-side counterpart of utxo-core's `buildTransaction/zcash.ts` `ZcashDestination` on
13+
* the build side: a shielded recipient is an Orchard/Ironwood output stored in the v6 (Ironwood)
14+
* PSBT's orchard PCZT, and everything else is an ordinary transparent output. A transparent
15+
* output resolved from a Unified Address carries that original UA (`zcashUnifiedTransparent`), a
16+
* plain address does not.
17+
*/
18+
export type PsbtRecipientDestination =
19+
| {
20+
kind: 'zcashShielded';
21+
/**
22+
* The Unified Address the output was addressed to — the original multi-receiver UA the
23+
* client passed when the PSBT stores one verbatim, otherwise a re-encoded single-receiver
24+
* Orchard UA.
25+
*/
26+
unifiedAddress: string;
27+
}
28+
| {
29+
kind: 'zcashUnifiedTransparent';
30+
/** The original Unified Address the transparent receiver was resolved from. */
31+
unifiedAddress: string;
32+
}
33+
| { kind: 'transparent' };
34+
35+
/** A recipient resolved from a decoded Zcash PSBT's external outputs. */
36+
export interface PsbtRecipient {
37+
/** Amount in satoshis. */
38+
amount: bigint;
39+
/**
40+
* The recipient address. For a shielded output this is the Unified Address the output was
41+
* addressed to — the original multi-receiver UA when the PSBT stores one verbatim, otherwise a
42+
* re-encoded single-receiver Orchard UA. For a transparent output it is the original Unified
43+
* Address when one was stored, else the decoded transparent address.
44+
*/
45+
address: string;
46+
/**
47+
* Raw receiver bytes: the 43-byte Orchard/Ironwood receiver for a shielded output, the
48+
* scriptPubKey for a transparent one.
49+
*/
50+
script: Uint8Array;
51+
/**
52+
* The original Unified Address the client supplied for this recipient, when the PSBT stores
53+
* one: the v6 (Ironwood) PCZT for a shielded output, the transparent-output proprietary
54+
* key-value map for a v4 transparent output. `undefined` when the recipient was built from a
55+
* plain address (or the single-receiver UA re-encoding is byte-identical for a shielded
56+
* output).
57+
*/
58+
unifiedAddress?: string;
59+
destination: PsbtRecipientDestination;
60+
}
61+
62+
export type ResolvePsbtRecipientsOptions = {
63+
/**
64+
* Custom change wallet xpubs, when the transaction spends to a custom change wallet. Outputs
65+
* matching these keys are classified as change, not recipients — matching how
66+
* `explainPsbtWasm` treats them.
67+
*/
68+
customChangeXpubs?: Triple<string>;
69+
};
70+
71+
/**
72+
* Resolve the recipient list of a decoded Zcash PSBT (v4 Sapling-shaped or v6 Ironwood).
73+
*
74+
* Mirrors the recipient resolution of wallet-platform's utxo-core `buildTransaction` in the
75+
* decode direction: every non-wallet, non-custom-change output with a resolvable address is a
76+
* recipient. A shielded output parses with `isShielded: true`, its `script` being the raw
77+
* 43-byte receiver; when the build stored the client's original Unified Address (the v6 PCZT for
78+
* shielded outputs, the transparent-output proprietary key-value map for v4), both the parsed
79+
* address and `unifiedAddress` report it verbatim. Opaque outputs with no address (e.g.
80+
* OP_RETURN) are skipped, as they carry no recipient.
81+
*/
82+
export function resolvePsbtRecipients(
83+
psbt: fixedScriptWallet.ZcashBitGoPsbt,
84+
walletKeys: fixedScriptWallet.RootWalletKeys,
85+
opts: ResolvePsbtRecipientsOptions = {}
86+
): PsbtRecipient[] {
87+
const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, {
88+
replayProtection: { publicKeys: getReplayProtectionPubkeys('zec') },
89+
});
90+
const customChangeOutputs = opts.customChangeXpubs
91+
? psbt.parseOutputsWithWalletKeys(opts.customChangeXpubs)
92+
: undefined;
93+
94+
const recipients: PsbtRecipient[] = [];
95+
parsed.outputs.forEach((output, i) => {
96+
// Wallet-owned (change) outputs.
97+
if (output.scriptId !== null) {
98+
return;
99+
}
100+
// Outputs owned by the custom change wallet, if one was supplied.
101+
if (customChangeOutputs?.[i]?.scriptId != null) {
102+
return;
103+
}
104+
// Opaque outputs (e.g. OP_RETURN) carry no recipient address.
105+
if (output.address === null) {
106+
return;
107+
}
108+
// The original client-passed Unified Address, stored verbatim in the PSBT's key-value
109+
// pairs: the orchard PCZT for a shielded output (parsed `address` reports it in full), the
110+
// transparent-output proprietary map for a v4 transparent output.
111+
const unifiedAddress = output.isShielded ? output.address : psbt.transparentOutputUnifiedAddress(i) ?? undefined;
112+
recipients.push({
113+
amount: output.value,
114+
address: output.address,
115+
script: output.script,
116+
unifiedAddress,
117+
destination: output.isShielded
118+
? { kind: 'zcashShielded', unifiedAddress: output.address }
119+
: unifiedAddress
120+
? { kind: 'zcashUnifiedTransparent', unifiedAddress }
121+
: { kind: 'transparent' },
122+
});
123+
});
124+
return recipients;
125+
}

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

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

713
import { AbstractUtxoCoin } from '../../abstractUtxoCoin';
814
import { stringToBufferTryFormats } from '../../transaction/decode';
915
import { UtxoCoinName } from '../../names';
1016

17+
import { resolvePsbtRecipients, ResolvePsbtRecipientsOptions, PsbtRecipient } from './recipients';
18+
1119
/**
1220
* Parse `address` as a ZIP-316 Unified Address for `network`, or return `undefined` if it isn't
1321
* one (malformed, wrong network, or not bech32m-shaped at all).
@@ -74,7 +82,10 @@ export class Zec extends AbstractUtxoCoin {
7482
* implementation would.
7583
*/
7684
override resolveOutputScript(address: string, unifiedRecipientPreference?: string): Uint8Array {
77-
return wasmAddress.toOutputScriptWithCoin(address, this.name, unifiedRecipientPreference === 'shielded');
85+
if (unifiedRecipientPreference === 'shielded') {
86+
return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.name);
87+
}
88+
return wasmAddress.toOutputScriptWithCoin(address, this.name);
7889
}
7990

8091
/**
@@ -90,7 +101,10 @@ export class Zec extends AbstractUtxoCoin {
90101
try {
91102
return fixedScriptWallet.ZcashBitGoPsbt.fromBytes(buffer, this.name as 'zec' | 'tzec');
92103
} catch (e) {
93-
if (isWasmUtxoError(e)) {
104+
// `ZcashBitGoPsbt.fromBytes` signals v6 (Ironwood) bytes with a plain Error (not a
105+
// WasmUtxoError) telling the caller to use `ZcashIronwoodBitGoPsbt.fromBytes` instead —
106+
// see its doc comment. Fall back for that message as well as wasm-layer errors.
107+
if (isWasmUtxoError(e) || (e instanceof Error && e.message.includes('v6 (Ironwood)'))) {
94108
return fixedScriptWallet.ZcashIronwoodBitGoPsbt.fromBytes(buffer, this.name as 'zec' | 'tzec');
95109
}
96110
throw e;
@@ -108,4 +122,22 @@ export class Zec extends AbstractUtxoCoin {
108122
}
109123
return this.decodeTransaction(string);
110124
}
125+
126+
/**
127+
* Decode a Zcash PSBT (v4 Sapling-shaped or v6 Ironwood) and resolve its recipient list.
128+
* The decode-side counterpart of the wallet-platform build path's recipient resolution:
129+
* shielded outputs resolve to their single-receiver Orchard Unified Address, transparent
130+
* outputs to their transparent address. Change and custom-change outputs are excluded.
131+
*/
132+
resolveRecipientsFromPsbt(
133+
input: Buffer | string,
134+
walletKeys: fixedScriptWallet.RootWalletKeys,
135+
opts: ResolvePsbtRecipientsOptions = {}
136+
): PsbtRecipient[] {
137+
const psbt = this.decodeTransaction(input);
138+
if (!(psbt instanceof fixedScriptWallet.ZcashBitGoPsbt)) {
139+
throw new Error('expected a Zcash PSBT');
140+
}
141+
return resolvePsbtRecipients(psbt, walletKeys, opts);
142+
}
111143
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import * as assert from 'assert';
2+
3+
import nock = require('nock');
4+
import { common } from '@bitgo/sdk-core';
5+
import { getSeed } from '@bitgo/sdk-test';
6+
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
7+
8+
import { getUtxoCoin, defaultBitGo } from '../../util';
9+
import { getDefaultWasmWalletKeys, keychainsBase58 } from '../../util/keychains';
10+
import { Zec } from '../../../../src/impl/zec';
11+
/**
12+
* Exercises every client-side flow that runs BEFORE verifyTransaction/signTransaction on a
13+
* shielded (v6 Ironwood) prebuild: prebuild post-processing, explanation, and recipient
14+
* resolution. Each of them must decode the v6 PSBT and resolve the shielded recipient without
15+
* error.
16+
*/
17+
describe('Zec shielded pre-verify flows (v6 Ironwood PSBT)', function () {
18+
const zec = getUtxoCoin('tzec');
19+
const bgUrl = common.Environments[defaultBitGo.getEnv()].uri;
20+
const { walletKeys } = getDefaultWasmWalletKeys();
21+
22+
const keyDocumentObjects = keychainsBase58.map((keychain, keyIdx) => {
23+
return {
24+
id: getSeed(keychain.pub).toString('hex'),
25+
pub: keychain.pub,
26+
source: ['user', 'backup', 'bitgo'][keyIdx],
27+
coinSpecific: {},
28+
};
29+
});
30+
const IRONWOOD_RECEIVER = Buffer.from(
31+
'd632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5',
32+
'hex'
33+
);
34+
let unifiedAddress: string;
35+
36+
before(function () {
37+
unifiedAddress = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver(
38+
new Uint8Array(IRONWOOD_RECEIVER),
39+
'tzec'
40+
);
41+
});
42+
43+
function buildShieldedV6PrebuildHex(): string {
44+
const psbt = fixedScriptWallet.ZcashIronwoodBitGoPsbt.createEmpty('tzec', walletKeys, { blockHeight: 4200000 });
45+
psbt.addWalletInput({ txid: '11'.repeat(32), vout: 0, value: 100000n }, walletKeys, {
46+
scriptId: { chain: 0, index: 0 },
47+
});
48+
psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 90000n });
49+
psbt.addShieldedOutputs(
50+
[{ recipient: new Uint8Array(IRONWOOD_RECEIVER), amount: 5000n, unifiedAddress }],
51+
new Uint8Array(32)
52+
);
53+
return Buffer.from(psbt.serialize()).toString('hex');
54+
}
55+
56+
afterEach(function () {
57+
nock.cleanAll();
58+
});
59+
60+
it('sendMany recipient validation accepts the unified address', function () {
61+
zec.checkRecipient({ address: unifiedAddress, amount: '5000' });
62+
});
63+
64+
it('postProcessPrebuild decodes the v6 psbt and re-encodes it unchanged', async function () {
65+
const prebuildHex = buildShieldedV6PrebuildHex();
66+
nock(bgUrl).get('/api/v2/tzec/public/block/latest').reply(200, { height: 4200000 });
67+
const prebuild = await zec.postProcessPrebuild({ txHex: prebuildHex, txInfo: {} });
68+
assert.match(prebuild.txHex as string, /^70736274/); // PSBT magic preserved
69+
const decoded = zec.decodeTransaction(prebuild.txHex as string);
70+
assert.ok(decoded instanceof fixedScriptWallet.ZcashIronwoodBitGoPsbt);
71+
});
72+
73+
it('explainTransaction decodes the v6 psbt and resolves the shielded recipient', async function () {
74+
const explained = await zec.explainTransaction({
75+
txHex: buildShieldedV6PrebuildHex(),
76+
pubs: [keyDocumentObjects[0].pub, keyDocumentObjects[1].pub, keyDocumentObjects[2].pub],
77+
});
78+
assert.strictEqual(explained.outputs.length, 1);
79+
assert.strictEqual(explained.outputs[0].address, unifiedAddress);
80+
assert.strictEqual(explained.outputs[0].amount.toString(), '5000');
81+
assert.strictEqual(explained.changeOutputs.length, 1);
82+
});
83+
84+
it('resolveRecipientsFromPsbt resolves the shielded recipient with its original UA', function () {
85+
const recipients = (zec as Zec).resolveRecipientsFromPsbt(buildShieldedV6PrebuildHex(), walletKeys);
86+
assert.strictEqual(recipients.length, 1);
87+
assert.strictEqual(recipients[0].destination.kind, 'zcashShielded');
88+
assert.strictEqual(recipients[0].unifiedAddress, unifiedAddress);
89+
assert.strictEqual(Buffer.from(recipients[0].script).toString('hex'), IRONWOOD_RECEIVER.toString('hex'));
90+
});
91+
});

0 commit comments

Comments
 (0)