Skip to content

feat(abstract-utxo): support ZEC v6 psbt decoding - #9727

Open
veetragjain wants to merge 1 commit into
masterfrom
veetragjain/cshld-1640-decode-zcash-v6-ironwood-transaction-prebuilds
Open

veetragjain wants to merge 1 commit into
masterfrom
veetragjain/cshld-1640-decode-zcash-v6-ironwood-transaction-prebuilds

Conversation

@veetragjain

Copy link
Copy Markdown
Contributor

Ticket: CSHLD-1640

@linear-code

linear-code Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

CSHLD-1640

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

⚠️ Unit tests are failing on Node 26.x (Current release line, non-blocking). This is not an LTS version yet, so it does not block merge, but it signals an incompatibility to fix before Node 26.x becomes LTS.

View run

@veetragjain
veetragjain force-pushed the veetragjain/cshld-1640-decode-zcash-v6-ironwood-transaction-prebuilds branch from 9d329dc to ea0aced Compare September 14, 2026 12:09
@veetragjain
veetragjain marked this pull request as ready for review September 14, 2026 12:33
@veetragjain
veetragjain requested a review from a team as a code owner September 14, 2026 12:33

@OttoAllmendinger OttoAllmendinger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nits I can fix in a follow-up

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't need this, the caller can do the buffer conversion

* encoding) override this and may fall back to the output's original address.
*/
outputScriptToAddress(script: Buffer, address?: string): string {
return this.toExtendedAddressFormat(script);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather just add the address?: string arg to this.toExtendedAddressFormat ?

script: addressCodec.fromExtendedAddressFormatToScript(output.address),
// Change/custom-change outputs are always transparent wallet addresses.
script:
output.external === false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

output.external ? is more natural

and maybe we should have decodeChangeAddress and decodeExternalAddress

@OttoAllmendinger
OttoAllmendinger self-requested a review September 14, 2026 13:10

@OttoAllmendinger OttoAllmendinger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: the stored Unified Address is not authoritative for the recipient. It lives in PSBT proprietary metadata, while the raw Orchard receiver / transparent script is the transaction truth. The builder validates this relationship at construction time, but deserialization does not revalidate it.

This creates a security and display-integrity issue: a serialized v6 PSBT can contain raw receiver R2 and preserved UA U1(R1). explainPsbtWasm currently drops ParsedOutput.script and exposes the preserved address; then parseTransaction reconstructs the actual output script from that address in toComparableOutputsWithExternal. A forged metadata value can therefore be displayed as the recipient and can make recipient verification compare U1 against U1 instead of comparing the requested recipient against R2.

Minimal fix: validate before mapping ParsedOutput to the public explanation, while address, script, and isShielded are still available. For Zcash only, pass coinName into explainPsbtWasm and require:

const recipient = output.isShielded
  ? zcashAddress.toShieldedReceiverWithCoin(output.address, coinName)
  : zcashAddress.toTransparentReceiverWithCoin(output.address, coinName);
if (!Buffer.from(recipient).equals(Buffer.from(output.script))) {
  throw new Error(`Zcash output ${i} address does not match its raw recipient`);
}

Reject malformed/mismatched metadata rather than falling back to it. Also make outputScriptToAddress validate any preserved address against script (or remove the fallback), and add regression tests for mismatched v6 Orchard and v4 transparent metadata. The existing construction-time checks are not sufficient because the PSBT can be modified after serialization.

Until this raw-output check exists, the PR should not merge.

@veetragjain
veetragjain force-pushed the veetragjain/cshld-1640-decode-zcash-v6-ironwood-transaction-prebuilds branch from ea0aced to e714104 Compare September 15, 2026 13:43

@OttoAllmendinger OttoAllmendinger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The normal explainTransaction() / parseTransaction() flow now validates the raw output bytes. Thank you.

Blocking gap remains: Zec.resolveRecipientsFromPsbt() bypasses AddressCodec.isMatchingScript(). In impl/zec/zec.ts, it calls resolvePsbtRecipients(psbt, walletKeys) directly. impl/zec/recipients.ts then returns output.address / unifiedAddress from PSBT proprietary metadata alongside the raw output.script, without proving they match.

A serialized PSBT can therefore still carry raw recipient R2 and preserved UA U1(R1), and this public API will report U1 as the recipient. This applies to both v4 transparent proprietary UA metadata and v6 Orchard UA metadata. Builder-time checks do not protect this deserialization boundary.

Please pass the Zcash codec into resolvePsbtRecipients() and reject before returning any metadata:

if (!addressCodec.isMatchingScript(output)) {
  throw new Error(`Output ${i} address ${output.address} does not match its raw recipient`);
}

Add public-path regressions that mutate the proprietary UA only after serializing a valid v4 and v6 PSBT, then assert tzec.resolveRecipientsFromPsbt() rejects. The current isMatchingScript unit tests validate the decoder selection, but they do not cover the attacker-controlled PSBT metadata path.

Also, per the prior design discussion, keep isShielded out of the exported generic AddressCodecOutput contract. The generic codec API should only require address and raw bytes; Zcash-specific receiver selection belongs inside ZecAddressCodec.

@veetragjain
veetragjain force-pushed the veetragjain/cshld-1640-decode-zcash-v6-ironwood-transaction-prebuilds branch from e714104 to 3721431 Compare September 15, 2026 15:11

@OttoAllmendinger OttoAllmendinger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public Zec.resolveRecipientsFromPsbt() now passes this.addressCodec, and isShielded has moved into a Zcash-only type. Those are the right corrections.

The security invariant is still optional in the exported lower-level APIs:

  • resolvePsbtRecipients(psbt, walletKeys, addressCodec?: AddressCodec) accepts no codec and then returns proprietary address / unifiedAddress metadata without validation. This function is exported by impl/zec/index.ts.
  • explainTx(..., addressCodec?: AddressCodec) and explainPsbtWasm({ addressCodec?: AddressCodec }) likewise skip the check when invoked directly.

The comments describe this as compatibility for callers that only need decoded outputs, but those decoded outputs include the untrusted recipient metadata. A caller can therefore still display U1 while the committed output pays R2. Please make the validating codec required on these paths, or expose an explicitly named legacy/raw parser that cannot be mistaken for validated recipient output.

The added tests verify receiver selection and successful constructed PSBTs, but construction already enforces this relation. Add v4 transparent and v6 Orchard tests that mutate the proprietary Unified Address only after serialization, then assert both resolveRecipientsFromPsbt() and the direct resolver/explanation entry point reject. That is the deserialization boundary this change is intended to secure.

@veetragjain
veetragjain force-pushed the veetragjain/cshld-1640-decode-zcash-v6-ironwood-transaction-prebuilds branch from 3721431 to 2ece608 Compare September 15, 2026 16:41
BREAKING CHANGE: explainTx, explainPsbtWasm and zec's resolvePsbtRecipients now require address codec to decode and verify the psbt output
Ticket: CSHLD-1640
@veetragjain
veetragjain force-pushed the veetragjain/cshld-1640-decode-zcash-v6-ironwood-transaction-prebuilds branch from 2ece608 to c236121 Compare September 15, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants