Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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, shielded_bundle_action_count,
OrchardProver, SpendableNote,
};

/// 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,11 @@ 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.
let num_actions = shielded_bundle_action_count(spends.len(), 1, platform_version)?;
let fee =
compute_shielded_identity_create_fee(num_actions, public_keys.len(), platform_version)?;

Expand Down Expand Up @@ -414,6 +421,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 +511,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