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
6 changes: 3 additions & 3 deletions packages/wasm-utxo/cli/src/psbt/add_shielded_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,16 @@ pub fn handle_add_shielded_input_command(
.collect::<Result<Vec<_>>>()?;

let map = psbt
.add_shielded_spends(&spends, &outputs, &anchor, OsRng)
.add_shielded_spends(&spends, &outputs, None, &anchor, OsRng)
.map_err(|e| anyhow!("failed to add the shielded spends: {e}"))?;

// The actions are shuffled, so the later steps' --action-index arguments must come from
// this map. It goes to stderr, keeping stdout to the PSBT hex alone.
eprintln!(
"{}",
serde_json::json!({
"spendActionIndices": map.spend_action_indices(),
"outputActionIndices": map.output_action_indices(),
"spendActionIndices": map.actions.spend_action_indices(),
"outputActionIndices": map.actions.output_action_indices(),
})
);
print_zcash_psbt(&psbt)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,18 @@ export function zip302NoMemo(): Uint8Array {

/**
* The parsed shape of a Zcash output through the Ironwood parse: the Zcash output shape plus
* the PCZT action index (`null` for transparent outputs).
* the PCZT action index (`null` for transparent outputs) and, for shielded outputs, the
* change marker.
*/
export type ZcashIronwoodParsedOutput = ZcashParsedOutput & {
actionIndex: number | null;
/**
* For a shielded output: whether it pays back to a spent note's own address (see
* `verifyShieldedBundle`'s report); such outputs are excluded from the parse's
* `spendAmount`. Always `false` for transparent outputs, whose wallet ownership is the
* `scriptId` they carry.
*/
isChange: boolean;
};

/**
Expand Down Expand Up @@ -197,7 +205,7 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt<

/**
* Parse outputs through the Zcash parse: every output carries `isShielded` and, for
* shielded outputs, its `actionIndex`.
* shielded outputs, its `actionIndex` and `isChange`.
*/
override parseOutputsWithWalletKeys(
walletKeys: WalletKeysArg,
Expand Down Expand Up @@ -415,15 +423,30 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt<
* the ZIP-302 "no memo" encoding, as {@link addShieldedOutput}'s does
* @param options.anchor - the Ironwood note-commitment-tree root every spend's witness
* commits to (32 bytes)
* @returns the action index each requested spend/output landed at. The actions are shuffled,
* so request order does not equal action order: any caller correlating per-action data (a
* stored Unified Address, an out-ciphertext) must go through these indices.
* @param options.change - optional `{ amount, spendIndex }`: one more shielded output paying
* `amount` back to `spends[spendIndex]`'s own address — the only shielded change a keyless
* client can recognize (its address is derived from the wallet's spend-authorizing key, so a
* note back to it is spendable only by this wallet). Without it every shielded output is an
* external send. Only an exact address match counts: change to another diversified address
* of the same wallet is reported as an external send.
* @returns the action index each requested spend/output landed at, and the change output's
* action index when requested. With change, `outputActionIndices` has one more entry than
* `outputs`: its last entry is `changeActionIndex`. The actions are shuffled, so request
* order does not equal action order: any caller correlating per-action data (a stored
* Unified Address, an out-ciphertext) must go through these indices.
*/
addShieldedSpends(
spends: ZcashShieldedSpendRequest[],
outputs: ZcashShieldedOutputRequest[],
options: { anchor: Uint8Array },
): { spendActionIndices: number[]; outputActionIndices: number[] } {
options: {
anchor: Uint8Array;
change?: { amount: bigint; spendIndex: number };
},
): {
spendActionIndices: number[];
outputActionIndices: number[];
changeActionIndex: number | null;
} {
const map = this.wasm.add_ironwood_shielded_spends(
spends.map((spend) => ({
recipient: spend.recipient,
Expand All @@ -441,10 +464,18 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt<
unifiedAddress: output.unifiedAddress,
})),
options.anchor,
) as { spendActionIndices: number[]; outputActionIndices: number[] };
options.change
? { amount: options.change.amount, spendIndex: options.change.spendIndex }
: null,
) as {
spendActionIndices: number[];
outputActionIndices: number[];
changeActionIndex: number | null;
};
return {
spendActionIndices: Array.from(map.spendActionIndices),
outputActionIndices: Array.from(map.outputActionIndices),
changeActionIndex: map.changeActionIndex,
};
}

Expand Down
19 changes: 18 additions & 1 deletion packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,29 @@ export type ZcashShieldedSpend = {
signed: boolean;
};

/** One real shielded output, as surfaced by the verification report. */
export type ZcashShieldedOutput = {
/** The output's action index in the orchard bundle. */
actionIndex: number;
/** Output value in zatoshi. */
value: bigint;
/** Raw Orchard recipient (43 bytes). */
recipient: Uint8Array;
/** The memo the recipient will decrypt. */
memo: Uint8Array;
/**
* Whether this output pays back to a real spend's own address — the shielded change a
* keyless client can recognize. Every other output is an external send.
*/
isChange: boolean;
};

/** The client-side verification report of a shielded bundle. */
export type IronwoodVerificationReport = {
/** The bundle's real spends, as verified. */
spends: ZcashShieldedSpend[];
/** The bundle's real outputs. */
outputs: Array<{ actionIndex: number; value: bigint; recipient: Uint8Array }>;
outputs: ZcashShieldedOutput[];
/** The bundle's value balance (Σv_spend − Σv_out). */
valueBalance: bigint;
/** The anchor the spends' witnesses commit to. */
Expand Down
1 change: 1 addition & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export type {
IronwoodRandomizerState,
ZcashInputKind,
ZcashShieldedSpend,
ZcashShieldedOutput,
IronwoodVerificationReport,
IronwoodShieldedStatus,
} from "./ZcashIronwoodTypes.js";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ pub enum IronwoodPsbtError {
/// receiver, or its Orchard receiver is not the output's `recipient`; or a verified
/// spend's Orchard receiver could not be encoded as a Unified Address.
BadUnifiedAddress(String),
/// The requested shielded change names a spend index outside the request's spends.
BadChangeSpend {
/// The offending spend index.
spend_index: usize,
},
/// The PSBT carries no Ironwood PCZT, or its bundle is not a spend bundle (no real
/// spends), and the operation needs one.
NotSpendBundle,
Expand Down Expand Up @@ -116,6 +121,10 @@ impl std::fmt::Display for IronwoodPsbtError {
"an Ironwood PCZT is already present; only one call is supported"
),
IronwoodPsbtError::BadUnifiedAddress(e) => write!(f, "invalid unified address: {e}"),
IronwoodPsbtError::BadChangeSpend { spend_index } => write!(
f,
"change names spend {spend_index}, which is not among the requested spends"
),
IronwoodPsbtError::NotSpendBundle => {
write!(f, "this PSBT carries no Ironwood spend bundle")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,21 @@ impl ZcashBitGoPsbt {
/// - **one call per PSBT** — a second call would silently overwrite the first batch
/// ([`IronwoodPsbtError::AlreadyAdded`]).
///
/// `change`, when given, adds one more shielded output paying `change.amount` back to
/// `spends[change.spend_index]`'s own address — the only shielded change a keyless client
/// can recognize (see [`crate::zcash::ironwood_verify::VerifiedOutput::is_change`]). Its
/// action index is reported as [`IronwoodSpendActionMap::change_action_index`].
///
/// Also stored: each output's Unified Address, keyed by its action index, and the
/// all-clear `out_ciphertext` bitset (proprietary key `0x09`).
pub fn add_shielded_spends<R: rand::RngCore + rand::CryptoRng>(
&mut self,
spends: &[crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec],
outputs: &[IronwoodOutputRequest],
change: Option<IronwoodSpendChange>,
anchor: &crate::zcash::ironwood_build::AnchorBytes,
rng: R,
) -> Result<crate::zcash::ironwood_build::IronwoodActionMap, IronwoodPsbtError> {
) -> Result<IronwoodSpendActionMap, IronwoodPsbtError> {
use crate::zcash::{ironwood_build, ironwood_construct};

// One input kind: a spend PSBT has no transparent inputs — the shielded side funds itself.
Expand All @@ -74,6 +80,24 @@ impl ZcashBitGoPsbt {
.map_err(IronwoodPsbtError::BadUnifiedAddress)?;
}
}
// Change goes back to a spent note's own address: append it as the last request.
let mut requests = outputs.to_vec();
if let Some(change) = change {
let spend =
spends
.get(change.spend_index)
.ok_or(IronwoodPsbtError::BadChangeSpend {
spend_index: change.spend_index,
})?;
requests.push(IronwoodOutputRequest {
recipient: spend.recipient,
amount: change.amount,
ovk: None,
memo: crate::zcash::ironwood_build::ZIP302_NO_MEMO,
unified_address: None,
});
}
let outputs = requests.as_slice();

let output_specs: Vec<ironwood_build::IronwoodOutputSpec> = outputs
.iter()
Expand All @@ -96,7 +120,12 @@ impl ZcashBitGoPsbt {
&mut self.psbt,
vec![0u8; action_count.div_ceil(8)],
);
Ok(action_map)
let change_action_index =
change.and_then(|_| action_map.output_action_index(outputs.len() - 1));
Ok(IronwoodSpendActionMap {
actions: action_map,
change_action_index,
})
}

/// The one-input-kind rule on the transparent side: reject adding a transparent input to a PSBT
Expand Down Expand Up @@ -687,6 +716,28 @@ impl ZcashBitGoPsbt {
}
}

/// The change half of [`ZcashBitGoPsbt::add_shielded_spends`]: `amount` zatoshi back to
/// `spends[spend_index]`'s own address.
#[derive(Clone, Copy, Debug)]
pub struct IronwoodSpendChange {
/// Change value in zatoshi (> 0).
pub amount: u64,
/// Which requested spend's address receives the change.
pub spend_index: usize,
}

/// Where [`ZcashBitGoPsbt::add_shielded_spends`]' requests landed: the requested spends and
/// outputs (the change output, when requested, is the last output entry) plus the change's
/// action index.
#[derive(Clone, Debug)]
pub struct IronwoodSpendActionMap {
/// The action index of every requested spend and output; the change output, if any, is
/// the last entry of `output_action_indices`.
pub actions: crate::zcash::ironwood_build::IronwoodActionMap,
/// The change output's action index, when change was requested.
pub change_action_index: Option<usize>,
}

/// Which input kind a v6 PSBT carries.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IronwoodInputKind {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,18 @@ pub struct ZcashParsedInput {
}

/// A Zcash output: the transparent parse shape plus the shielded marker and, for shielded
/// outputs, the PCZT action index.
/// outputs, the PCZT action index and change marker.
#[derive(Clone, Debug)]
pub struct ZcashParsedOutput {
pub base: ParsedOutput,
pub is_shielded: bool,
/// The output's action index in the orchard bundle; `None` for transparent outputs.
pub action_index: Option<usize>,
/// For a shielded output: whether it pays back to a spent note's own address (see
/// [`crate::zcash::ironwood_verify::VerifiedOutput::is_change`]); such outputs are
/// excluded from `spend_amount`. Always `false` for transparent outputs, whose wallet
/// ownership is the base parse's `script_id`.
pub is_change: bool,
}

/// The inputs of a Zcash transaction: transparent, or the shielded spends of a deshield /
Expand Down Expand Up @@ -235,11 +240,13 @@ pub(crate) fn parse_transaction_with_wallet_keys(
index: output_index,
},
)?;
spend_amount = spend_amount.checked_add(output.base.value).ok_or(
ParseTransactionError::SpendAmountOverflow {
index: output_index,
},
)?;
if !output.is_change {
spend_amount = spend_amount.checked_add(output.base.value).ok_or(
ParseTransactionError::SpendAmountOverflow {
index: output_index,
},
)?;
}
outputs.push(output);
}

Expand Down Expand Up @@ -281,47 +288,49 @@ fn transparent_outputs(outputs: Vec<ParsedOutput>) -> Vec<ZcashParsedOutput> {
base,
is_shielded: false,
action_index: None,
is_change: false,
})
.collect()
}

/// Synthesize a [`ZcashParsedOutput`] for every real shielded output
/// `(action_index, value, recipient)`.
/// Synthesize a [`ZcashParsedOutput`] for every real shielded output the key-free verifier
/// reported.
fn shielded_parsed_outputs(
z: &ZcashBitGoPsbt,
shielded_outputs: &[(usize, u64, OrchardAddressBytes)],
shielded_outputs: &[crate::zcash::ironwood_verify::VerifiedOutput],
) -> Result<Vec<ZcashParsedOutput>, ZcashParseTransactionError> {
shielded_outputs
.iter()
.map(|(action_index, value, recipient)| {
.map(|output| {
// Prefer the caller's original Unified Address (if `add_ironwood_output` was
// given one for this action): it may carry a transparent/Sapling receiver
// alongside the Orchard one, which a single-receiver reconstruction from
// `recipient` alone cannot recover.
let address = match super::propkv::get_ironwood_unified_address(&z.psbt, *action_index)
{
Some(ua) => ua,
None => crate::zcash::unified_address::encode_orchard_receiver(
recipient,
z.network().to_coin_name(),
)
.map_err(|e| ZcashParseTransactionError::Shielded(e.to_string()))?,
};
let address =
match super::propkv::get_ironwood_unified_address(&z.psbt, output.action_index) {
Some(ua) => ua,
None => crate::zcash::unified_address::encode_orchard_receiver(
&output.recipient,
z.network().to_coin_name(),
)
.map_err(|e| ZcashParseTransactionError::Shielded(e.to_string()))?,
};
Ok(ZcashParsedOutput {
base: ParsedOutput {
address: Some(address),
// No scriptPubKey exists for a shielded output; the raw receiver is
// still available here (not a scriptPubKey, but the same "raw
// output-destination bytes" role this field plays for transparent
// outputs).
script: recipient.to_vec(),
value: *value,
script: output.recipient.to_vec(),
value: output.value,
script_id: None,
paygo: false,
derivation_path: None,
},
is_shielded: true,
action_index: Some(*action_index),
action_index: Some(output.action_index),
is_change: output.is_change,
})
})
.collect()
Expand All @@ -347,14 +356,7 @@ fn shielded_side(
.ironwood_pczt()
.map_err(ZcashParseTransactionError::Shielded)?;
let report = z.keyless_verify(&pczt)?;
let outputs = shielded_parsed_outputs(
z,
&report
.outputs
.iter()
.map(|output| (output.action_index, output.value, output.recipient))
.collect::<Vec<_>>(),
)?;
let outputs = shielded_parsed_outputs(z, &report.outputs)?;
let spends = report
.spends
.iter()
Expand Down
Loading
Loading