Skip to content
Open
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
9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ tokio-metrics = "0.5"
# Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which
# otherwise ships huge. Inherits `release` and is ONLY used by the iOS
# build (`build_ios.sh --profile release`)
#
# NOTE: `panic = "abort"` (here and in dev-ios) also disables the FFI
# panic guards (`catch_panic_to_code` in platform-wallet-ffi's
# shielded_send.rs) — on iOS a panic aborts the process before any
# `catch_unwind` runs; the guards are effective on Android and host
# builds, which keep `panic = "unwind"`. Flipping iOS to "unwind" would
# activate them at a binary-size cost (unwind tables + landing pads
# under fat LTO) that must be measured against this profile's size
# budget before shipping.
[profile.release-ios]
inherits = "release"
panic = "abort"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,26 @@ internal object FundingNative {
memoText: String?,
)

/**
* Multi-output shielded → shielded transfer, Type 16 (bridges
* `platform_wallet_manager_shielded_transfer_multi`).
*
* [recipientsRaw43] holds `amounts.size` raw 43-byte Orchard addresses
* laid out back to back (length must be `43 * amounts.size`), and
* [amounts] the matching credit values. Each pair becomes its own note;
* repeating the same address funds it with several independent notes.
* [memoText] is attached to every recipient note.
*/
external fun shieldedTransferMulti(
managerHandle: Long,
walletId: ByteArray,
resolverHandle: Long,
account: Int,
recipientsRaw43: ByteArray,
amounts: LongArray,
memoText: String?,
)

/**
* Shielded → Platform unshield, Type 17 (bridges
* `platform_wallet_manager_shielded_unshield`). [toPlatformAddress] is a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1644,6 +1644,69 @@ class PlatformWalletManager(
}
}

/**
* Multi-output shielded → shielded transfer (Type 16). Spends notes from
* [account] on [walletId] and creates ONE note per entry of [outputs] in
* a single atomic transition.
*
* Repeating the same address across entries is allowed and is the point
* of this call: it funds one address with several independent notes, so
* a later spend of that address spends several REAL notes rather than
* one real note plus an Orchard padding dummy (whose nullifier is
* randomly generated and therefore not reproducible offline).
*
* The transition always emits a change note, so the spendable balance
* must strictly exceed the summed amounts plus the fee. The fee grows
* with the output count: the bundle publishes
* `max(spentNotes, outputs.size + 1, 2)` Orchard actions.
*
* @param walletId the 32-byte wallet id.
* @param outputs (raw 43-byte Orchard address, credits) pairs; must be
* non-empty, hold at most [MAX_SHIELDED_TRANSFER_RECIPIENTS] entries
* (the native ceiling — 5, bound by the 20 KiB transition-size limit),
* and every amount must be positive.
* @param account the ZIP-32 shielded account to spend from (usually 0).
* @param memo optional UTF-8 memo attached to EVERY recipient note
* (null / empty = no memo; at most 32 UTF-8 bytes).
*/
suspend fun shieldedTransferMulti(
walletId: ByteArray,
outputs: List<Pair<ByteArray, Long>>,
account: Int = 0,
memo: String? = null,
): Unit = teardownGate.op {
require(outputs.isNotEmpty()) { "outputs must not be empty" }
// Mirror the native ceiling BEFORE flattening: the arrays built below are sized by
// `outputs.size`, and the native layer would reject an oversized call anyway — after
// this side had already allocated for it.
require(outputs.size <= MAX_SHIELDED_TRANSFER_RECIPIENTS) {
"outputs must hold at most $MAX_SHIELDED_TRANSFER_RECIPIENTS entries, got ${outputs.size}"
}
require(account >= 0) { "account must be non-negative, got $account" }
outputs.forEachIndexed { index, (recipientRaw43, amount) ->
require(recipientRaw43.size == 43) {
"outputs[$index] address must be exactly 43 bytes, got ${recipientRaw43.size}"
}
require(amount > 0) { "outputs[$index] amount must be positive, got $amount" }
}
val recipientsRaw43 = ByteArray(outputs.size * 43)
outputs.forEachIndexed { index, (recipientRaw43, _) ->
recipientRaw43.copyInto(recipientsRaw43, index * 43)
}
val amounts = LongArray(outputs.size) { outputs[it].second }
mapNativeErrors {
FundingNative.shieldedTransferMulti(
managerHandle,
walletId,
mnemonicResolver.nativeHandle,
account,
recipientsRaw43,
amounts,
memo?.takeIf { it.isNotEmpty() },
)
}
}

/**
* Shielded → Platform unshield (Type 17) — port of Swift's
* `PlatformWalletManager.shieldedUnshield(walletId:account:toPlatformAddress:amount:)`
Expand Down Expand Up @@ -2222,6 +2285,20 @@ class PlatformWalletManager(
/** SPV progress poll cadence — matches Swift's 1 Hz `startProgressPolling`. */
const val POLL_INTERVAL_MS = 1_000L

/**
* Recipient ceiling of [shieldedTransferMulti] — mirrors
* `MAX_SHIELDED_TRANSFER_RECIPIENTS` in
* `packages/rs-platform-wallet-ffi/src/shielded_send.rs`, which the JNI adapter enforces
* from the array lengths before allocating. Checked here too so an oversized call is
* refused before this side flattens caller-sized buffers.
*
* 5 = the effective per-transition Orchard action ceiling (6, bound by the 20 KiB
* `max_state_transition_size` — a 7-action transition serializes to ~21.7 KiB) minus
* the unconditional change output. The native constant is pinned to the dpp derivation
* by a Rust test; raise this only in lockstep with it.
*/
const val MAX_SHIELDED_TRANSFER_RECIPIENTS = 5

/** De-offset `PlatformWalletFFIResultCode::ErrorInvalidParameter`. */
const val PWFFI_INVALID_PARAMETER = 2
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ use crate::ProtocolError;
use platform_value::Identifier;
use platform_version::version::PlatformVersion;

use super::{build_spend_bundle_with, serialize_authorized_bundle, OrchardProver, SpendableNote};
use super::{
build_spend_bundle_with, serialize_authorized_bundle, serialized_envelope_bytes,
shielded_bundle_action_count, OrchardProver, SpendableNote, PER_KEY_SIGNATURE_ALLOWANCE_BYTES,
};

/// Output of [`build_identity_create_from_shielded_pool_transition`]: everything the SDK's
/// `IdentityCreateFromShieldedPool::identity_create_from_shielded_pool` broadcast helper needs.
Expand Down Expand Up @@ -158,7 +161,24 @@ where
// Orchard's BundleType::DEFAULT pads single-spend bundles to a 2-action minimum, matching the
// other spend-side builders. The fee predictor is only informational here (the metered fee at
// execution is authoritative); we report it so the caller's reservation math lines up.
let num_actions = spends.len().max(2);
//
// Routed through the shared predictor (1 shielded output — the change note), which is
// numerically `spends.len().max(2)` AND enforces both consensus ceilings (the structural
// action cap and the transition-size-derived one) BEFORE the ~30 s Halo 2 proof. The size
// side must price THIS transition's variable key set: up to six identity keys ride the
// envelope, and at gate time their PoP `signature` fields are still empty — so add a
// per-key allowance for the largest signature a key type can carry (BLS, 96 bytes, plus
// its length prefix), keeping the estimate conservative rather than optimistic.
let key_set_envelope_bytes = serialized_envelope_bytes(
&public_keys
.iter()
.map(|(_, c)| c.clone())
.collect::<Vec<IdentityPublicKeyInCreation>>(),
"the identity key set",
)?
.saturating_add(public_keys.len() as u64 * PER_KEY_SIGNATURE_ALLOWANCE_BYTES);
let num_actions =
shielded_bundle_action_count(spends.len(), 1, key_set_envelope_bytes, platform_version)?;
let fee =
compute_shielded_identity_create_fee(num_actions, public_keys.len(), platform_version)?;

Expand Down Expand Up @@ -351,6 +371,64 @@ mod tests {
/// 0.1 DASH in credits — the smallest member of the versioned exit-denomination set.
const DENOMINATION: u64 = 10_000_000_000;

/// The identity-create gate must price its variable key set into the size
/// budget (#4312 review finding e90e9cf15f52): a maximal six-key set —
/// measured pre-PoP-signing plus the per-key signature allowance — is a
/// real envelope cost, and at current constants the padded two-action
/// claim shape must still clear the gate with it (a maximal key set must
/// not brick identity creation; it only tightens how many spends fit).
#[test]
fn identity_key_set_envelope_is_priced_into_the_gate() {
use crate::shielded::builder::{
serialized_envelope_bytes, shielded_bundle_action_count,
PER_KEY_SIGNATURE_ALLOWANCE_BYTES,
};
use crate::shielded::{
max_shielded_actions_for_envelope, max_shielded_actions_per_transition,
};

let platform_version = PlatformVersion::latest();
let baseline = max_shielded_actions_per_transition(platform_version);

// Six keys — the identity-create maximum the finding names.
let keys: Vec<IdentityPublicKeyInCreation> = (0..6u32).map(|id| key_pair(id).1).collect();
let measured =
serialized_envelope_bytes(&keys, "the identity key set").expect("measurable key set");
let envelope = measured + keys.len() as u64 * PER_KEY_SIGNATURE_ALLOWANCE_BYTES;
assert!(
measured > 0,
"a six-key set must have a nonzero serialized envelope"
);

let ceiling = max_shielded_actions_for_envelope(platform_version, envelope);
assert!(
(2..=baseline).contains(&ceiling),
"a six-key envelope ({envelope} bytes) must leave at least the padded 2-action \
claim shape and never exceed the baseline ceiling {baseline}, got {ceiling}"
);

// The padded single-spend claim (2 actions on the wire) must pass the
// gate under the maximal key set.
let num_actions = shielded_bundle_action_count(1, 1, envelope, platform_version)
.expect("the padded 2-action identity create must clear the gate with six keys");
assert_eq!(num_actions, 2);

// A spend-fragmented claim at the BASELINE ceiling must be rejected
// once the key envelope eats the slack — or accepted if the envelope
// still fits; either way the gate's verdict must match the derived
// ceiling exactly (no drift between the gate and the derivation).
match shielded_bundle_action_count(baseline, 1, envelope, platform_version) {
Ok(n) => {
assert_eq!(n, baseline);
assert_eq!(ceiling, baseline);
}
Err(e) => {
assert!(ceiling < baseline, "rejection requires a tightened ceiling");
assert!(e.to_string().contains("max_state_transition_size"));
}
}
}

/// The padded-bundle regression test for the dummy-nullifier bug: a SINGLE-spend bundle is
/// padded by `BundleType::DEFAULT` to the 2-action minimum, and the padding action's random
/// dummy nullifier is published on the wire. The identity id MUST be derived from the FULL
Expand Down Expand Up @@ -414,6 +492,13 @@ mod tests {
identity_id_from_nullifiers(&[real_nullifier]),
"the padding action's dummy nullifier must participate in the id derivation"
);
// …which is precisely what `shielded_identity_id_is_reproducible` reports: with one real
// spend the published set contains fresh randomness, so the id cannot be re-derived
// offline (a retry would build a different dummy and thus a different id).
assert!(
!crate::state_transition::identity_create_from_shielded_pool_transition::shielded_identity_id_is_reproducible(1),
"a single-spend bundle is padded, so its id must be reported as NOT reproducible"
);
assert!(
result.predicted_fee < DENOMINATION,
"predicted fee must leave the new identity a positive balance"
Expand Down Expand Up @@ -497,5 +582,12 @@ mod tests {
identity_id_from_nullifiers(&[nf_a, nf_b]),
"with no padding, the published set is exactly the real spends' nullifiers"
);
// …which is precisely what `shielded_identity_id_is_reproducible` reports: with two real
// spends no padding is added, so the id is a pure function of the spent notes and a retry
// re-derives the SAME id. This is the property two-note funding buys.
assert!(
crate::state_transition::identity_create_from_shielded_pool_transition::shielded_identity_id_is_reproducible(2),
"a two-spend bundle needs no padding, so its id must be reported as reproducible"
);
}
}
Loading
Loading