-
Notifications
You must be signed in to change notification settings - Fork 306
feat(abstract-utxo): add zec shielded psbt decode and recipient resolution support #9642
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| export * from './zec'; | ||
| export * from './recipients'; | ||
| export * from './tzec'; | ||
| export * from './address'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| /** | ||
| * @prettier | ||
| */ | ||
| import { fixedScriptWallet } from '@bitgo/wasm-utxo'; | ||
|
|
||
| import { getReplayProtectionPubkeys } from '../../transaction/fixedScript/replayProtection'; | ||
|
|
||
| /** | ||
| * 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 (or the single-receiver UA re-encoding is byte-identical for a shielded | ||
| * output). | ||
| */ | ||
| 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; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,13 +1,14 @@ | ||||||
| /** | ||||||
| * @prettier | ||||||
| */ | ||||||
| import { BitGoBase } from '@bitgo/sdk-core'; | ||||||
| import { fixedScriptWallet } from '@bitgo/wasm-utxo'; | ||||||
| import { fixedScriptWallet, hasPsbtMagic, zcashAddress as wasmZcashAddress } from '@bitgo/wasm-utxo'; | ||||||
| import { BitGoBase, ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core'; | ||||||
|
|
||||||
| import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; | ||||||
| import { stringToBufferTryFormats } from '../../transaction/decode'; | ||||||
| import { UtxoCoinName } from '../../names'; | ||||||
|
|
||||||
| import { isShieldedZcashAddress } from './address'; | ||||||
| import { resolvePsbtRecipients, PsbtRecipient } from './recipients'; | ||||||
|
|
||||||
| export class Zec extends AbstractUtxoCoin { | ||||||
| readonly name: UtxoCoinName = 'zec'; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
this should get rid of the |
||||||
|
|
@@ -20,10 +21,139 @@ export class Zec extends AbstractUtxoCoin { | |||||
| return new Zec(bitgo); | ||||||
| } | ||||||
|
|
||||||
| isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean { | ||||||
| if (super.isValidAddress(address, param)) { | ||||||
| return true; | ||||||
| /** | ||||||
| * Forward `unifiedRecipientPreference` alongside the standard extra build params. Zcash builds | ||||||
| * that carry this preference always go through the wasm-utxo (Ironwood/v6-capable) build path | ||||||
| * on Wallet Platform rather than the legacy utxolib path, since utxolib has no notion of | ||||||
| * Unified Addresses or shielded outputs. | ||||||
| */ | ||||||
| override async getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet }) { | ||||||
| const extraParams = await super.getExtraPrebuildParams(buildParams); | ||||||
| const unifiedRecipientPreference = buildParams.unifiedRecipientPreference as string | undefined; | ||||||
| if (unifiedRecipientPreference === undefined) { | ||||||
| return extraParams; | ||||||
| } | ||||||
| return isShieldedZcashAddress(address, this.name as fixedScriptWallet.ZcashNetworkName); | ||||||
| return { ...extraParams, unifiedRecipientPreference }; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * In addition to ordinary transparent addresses, Zcash accepts ZIP-316 Unified Addresses that | ||||||
| * carry a transparent receiver, an Orchard/Ironwood receiver, or both. `unifiedRecipientPreference` | ||||||
| * (which of those receivers a build should spend to) is not this method's concern — it only | ||||||
| * answers whether `address` is a spendable address at all. | ||||||
| */ | ||||||
| override isValidAddress( | ||||||
| address: string, | ||||||
| param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean | ||||||
| ): boolean { | ||||||
| try { | ||||||
| const unifiedAddress = fixedScriptWallet.ZcashUnifiedAddress.parse(address, this.name as 'zec' | 'tzec'); | ||||||
| return unifiedAddress.transparentScript !== undefined || unifiedAddress.orchardReceiver !== undefined; | ||||||
| } catch (e) { | ||||||
| // Not a unified address for this network — defer to the base transparent-address | ||||||
| // validation. | ||||||
| return super.isValidAddress(address, param); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Resolve `address` to an output script. For a Unified Address, `unifiedRecipientPreference === | ||||||
| * 'shielded'` resolves to the raw 43-byte Orchard/Ironwood receiver (a shielded output, no | ||||||
| * scriptPubKey); any other value resolves the Unified Address's transparent receiver (a plain | ||||||
| * transparent address decodes exactly as the base implementation would). A Unified Address | ||||||
| * without a transparent receiver cannot resolve transparently and throws. | ||||||
| */ | ||||||
| override resolveOutputScript(address: string, unifiedRecipientPreference?: string): Uint8Array { | ||||||
| if (unifiedRecipientPreference === 'shielded') { | ||||||
| return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.name); | ||||||
| } | ||||||
| return wasmZcashAddress.toTransparentReceiverWithCoin(address, this.name); | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Infer the Unified-Address recipient preference from the recipients when the caller did not | ||||||
| * pass one — mirroring wallet-platform's utxo-core `buildTransaction` (`inferIsShielded` + | ||||||
| * `classifyRecipientShieldedness`): a Unified Address carrying only an Orchard receiver can | ||||||
| * only be spent shielded, one carrying only a transparent receiver only transparently, one | ||||||
| * carrying both is ambiguous, and a mix of shielded and transparent recipients is rejected. | ||||||
| */ | ||||||
| getUnifiedRecipientPreference(txParams: { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't see any tests move this to a standalone func to you can also clean up the signature |
||||||
| recipients?: { address?: string; amount: number | bigint | string }[]; | ||||||
| unifiedRecipientPreference?: string; | ||||||
| }): string | undefined { | ||||||
| if (txParams.unifiedRecipientPreference !== undefined) { | ||||||
| return txParams.unifiedRecipientPreference; | ||||||
| } | ||||||
| const shieldedness = (txParams.recipients ?? []).map((recipient) => { | ||||||
| if (recipient.address === undefined) { | ||||||
| // Raw script inherently transparent. | ||||||
| return 'transparent' as const; | ||||||
|
Comment on lines
+89
to
+90
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok |
||||||
| } | ||||||
| let unified: fixedScriptWallet.ZcashUnifiedAddress | undefined; | ||||||
| try { | ||||||
| unified = fixedScriptWallet.ZcashUnifiedAddress.parse(recipient.address, this.name as 'zec' | 'tzec'); | ||||||
| } catch (e) { | ||||||
| // Not a unified address: the ordinary transparent address-decoding path handles it. | ||||||
| return 'transparent' as const; | ||||||
|
Comment on lines
+96
to
+97
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't agree with that
we should try to parse it as a transparent address first instead, return |
||||||
| } | ||||||
| if (unified.hasOrchardReceiver && unified.hasTransparentReceiver) { | ||||||
| throw new Error( | ||||||
| `Unified address ${recipient.address} carries both transparent and Orchard receivers; specify unifiedRecipientPreference: "shielded" or "transparent"` | ||||||
| ); | ||||||
| } | ||||||
| if (unified.hasTransparentReceiver) { | ||||||
| return 'transparent' as const; | ||||||
| } | ||||||
| if (unified.hasOrchardReceiver) { | ||||||
| return 'shielded' as const; | ||||||
| } | ||||||
| throw new Error(`Unified 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; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Deserialize a Zcash PSBT (v4 Sapling-shaped or v6 Ironwood). `ZcashPsbt.fromBytes` reads | ||||||
| * the Zcash transaction version from the parsed metadata and returns the format-specific | ||||||
| * implementation — `ZcashBitGoPsbt` for v4, `ZcashIronwoodBitGoPsbt` for v6 — so no | ||||||
| * byte-level sniffing or fallback dispatch is needed here. | ||||||
| */ | ||||||
| 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, this.name as 'zec' | 'tzec'); | ||||||
| } | ||||||
|
|
||||||
| override decodeTransactionFromPrebuild(prebuild: { | ||||||
| txHex?: string; | ||||||
| txBase64?: string; | ||||||
| txHexPsbt?: string; | ||||||
| }): fixedScriptWallet.BitGoPsbt { | ||||||
| const string = prebuild.txHexPsbt ?? prebuild.txHex ?? prebuild.txBase64; | ||||||
| if (!string) { | ||||||
| throw new Error('missing required txHex or txBase64 property'); | ||||||
| } | ||||||
| return this.decodeTransaction(string); | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Decode a Zcash PSBT (v4 Sapling-shaped or v6 Ironwood) and resolve its recipient list. | ||||||
| * The decode-side counterpart of the wallet-platform build path's recipient resolution: | ||||||
| * shielded outputs resolve to their single-receiver Orchard Unified Address, transparent | ||||||
| * outputs to their transparent address. Change outputs are excluded. | ||||||
| */ | ||||||
| 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); | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
weak string type
the optional argument here is only useful for zcash and leaks into general AbstractUtxo, we should look for a better solution