Skip to content

Commit 4466adf

Browse files
committed
merge(dashpay#4301): feat/shielded-two-note-invites-on-qa5 @1aefee642b into v41-keystore-qa5
Reconstruction of PR dashpay#4301 (feat/shielded-two-note-invites @d6610262b0) on top of qa5's tip. dashpay#4301 is based on a current v4.2-dev point; merging it directly would have dragged in 9 unrelated commits (dashpay#4287, dashpay#4266, dashpay#4279, dashpay#4276, dashpay#4278, dashpay#4277, and duplicate dashpay#4183/dashpay#4191/dashpay#4251), including dashpay#4277's competing encrypted-txMetadata implementation that collides with qa5's dashpay#4186. The reconstruction cherry-picks ONLY dashpay#4301's own commit; the resulting delta is byte-identical to the original (1256 insertions, 8 deletions across the same 11 files) — only hunk offsets differ. Verified: platform-wallet 774/774, platform-wallet-ffi 284/284, dpp shielded 216/216 (incl. both multi/single-output fee-vs-action-count tests), the four dashpay#4204 security tests, and dashpay#4301's three note-selection tests all pass. rustfmt clean; no new clippy warnings.
2 parents 33d9c40 + 1aefee6 commit 4466adf

11 files changed

Lines changed: 1256 additions & 8 deletions

File tree

packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,26 @@ internal object FundingNative {
222222
memoText: String?,
223223
)
224224

225+
/**
226+
* Multi-output shielded → shielded transfer, Type 16 (bridges
227+
* `platform_wallet_manager_shielded_transfer_multi`).
228+
*
229+
* [recipientsRaw43] holds `amounts.size` raw 43-byte Orchard addresses
230+
* laid out back to back (length must be `43 * amounts.size`), and
231+
* [amounts] the matching credit values. Each pair becomes its own note;
232+
* repeating the same address funds it with several independent notes.
233+
* [memoText] is attached to every recipient note.
234+
*/
235+
external fun shieldedTransferMulti(
236+
managerHandle: Long,
237+
walletId: ByteArray,
238+
resolverHandle: Long,
239+
account: Int,
240+
recipientsRaw43: ByteArray,
241+
amounts: LongArray,
242+
memoText: String?,
243+
)
244+
225245
/**
226246
* Shielded → Platform unshield, Type 17 (bridges
227247
* `platform_wallet_manager_shielded_unshield`). [toPlatformAddress] is a

packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1738,6 +1738,61 @@ class PlatformWalletManager(
17381738
}
17391739
}
17401740

1741+
/**
1742+
* Multi-output shielded → shielded transfer (Type 16). Spends notes from
1743+
* [account] on [walletId] and creates ONE note per entry of [outputs] in
1744+
* a single atomic transition.
1745+
*
1746+
* Repeating the same address across entries is allowed and is the point
1747+
* of this call: it funds one address with several independent notes, so
1748+
* a later spend of that address spends several REAL notes rather than
1749+
* one real note plus an Orchard padding dummy (whose nullifier is
1750+
* randomly generated and therefore not reproducible offline).
1751+
*
1752+
* The transition always emits a change note, so the spendable balance
1753+
* must strictly exceed the summed amounts plus the fee. The fee grows
1754+
* with the output count: the bundle publishes
1755+
* `max(spentNotes, outputs.size + 1, 2)` Orchard actions.
1756+
*
1757+
* @param walletId the 32-byte wallet id.
1758+
* @param outputs (raw 43-byte Orchard address, credits) pairs; must be
1759+
* non-empty and every amount must be positive.
1760+
* @param account the ZIP-32 shielded account to spend from (usually 0).
1761+
* @param memo optional UTF-8 memo attached to EVERY recipient note
1762+
* (null / empty = no memo; at most 32 UTF-8 bytes).
1763+
*/
1764+
suspend fun shieldedTransferMulti(
1765+
walletId: ByteArray,
1766+
outputs: List<Pair<ByteArray, Long>>,
1767+
account: Int = 0,
1768+
memo: String? = null,
1769+
): Unit = teardownGate.op {
1770+
require(outputs.isNotEmpty()) { "outputs must not be empty" }
1771+
require(account >= 0) { "account must be non-negative, got $account" }
1772+
outputs.forEachIndexed { index, (recipientRaw43, amount) ->
1773+
require(recipientRaw43.size == 43) {
1774+
"outputs[$index] address must be exactly 43 bytes, got ${recipientRaw43.size}"
1775+
}
1776+
require(amount > 0) { "outputs[$index] amount must be positive, got $amount" }
1777+
}
1778+
val recipientsRaw43 = ByteArray(outputs.size * 43)
1779+
outputs.forEachIndexed { index, (recipientRaw43, _) ->
1780+
recipientRaw43.copyInto(recipientsRaw43, index * 43)
1781+
}
1782+
val amounts = LongArray(outputs.size) { outputs[it].second }
1783+
mapNativeErrors {
1784+
FundingNative.shieldedTransferMulti(
1785+
managerHandle,
1786+
walletId,
1787+
mnemonicResolver.nativeHandle,
1788+
account,
1789+
recipientsRaw43,
1790+
amounts,
1791+
memo?.takeIf { it.isNotEmpty() },
1792+
)
1793+
}
1794+
}
1795+
17411796
/**
17421797
* Shielded → Platform unshield (Type 17) — port of Swift's
17431798
* `PlatformWalletManager.shieldedUnshield(walletId:account:toPlatformAddress:amount:)`

packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,13 @@ mod tests {
414414
identity_id_from_nullifiers(&[real_nullifier]),
415415
"the padding action's dummy nullifier must participate in the id derivation"
416416
);
417+
// …which is precisely what `shielded_identity_id_is_reproducible` reports: with one real
418+
// spend the published set contains fresh randomness, so the id cannot be re-derived
419+
// offline (a retry would build a different dummy and thus a different id).
420+
assert!(
421+
!crate::state_transition::identity_create_from_shielded_pool_transition::shielded_identity_id_is_reproducible(1),
422+
"a single-spend bundle is padded, so its id must be reported as NOT reproducible"
423+
);
417424
assert!(
418425
result.predicted_fee < DENOMINATION,
419426
"predicted fee must leave the new identity a positive balance"
@@ -497,5 +504,12 @@ mod tests {
497504
identity_id_from_nullifiers(&[nf_a, nf_b]),
498505
"with no padding, the published set is exactly the real spends' nullifiers"
499506
);
507+
// …which is precisely what `shielded_identity_id_is_reproducible` reports: with two real
508+
// spends no padding is added, so the id is a pure function of the spent notes and a retry
509+
// re-derives the SAME id. This is the property two-note funding buys.
510+
assert!(
511+
crate::state_transition::identity_create_from_shielded_pool_transition::shielded_identity_id_is_reproducible(2),
512+
"a two-spend bundle needs no padding, so its id must be reported as reproducible"
513+
);
500514
}
501515
}

packages/rs-dpp/src/shielded/builder/mod.rs

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ pub use identity_create_from_shielded_pool::{
4343
pub use shield_from_asset_lock::build_shield_from_asset_lock_transition;
4444
#[cfg(feature = "core_key_wallet")]
4545
pub use shield_from_asset_lock::build_shield_from_asset_lock_transition_with_signer;
46-
pub use shielded_transfer::build_shielded_transfer_transition;
46+
pub use shielded_transfer::{
47+
build_shielded_transfer_transition, build_shielded_transfer_transition_multi,
48+
ShieldedTransferOutput,
49+
};
4750
pub use shielded_withdrawal::build_shielded_withdrawal_transition;
4851
pub use unshield::build_unshield_transition;
4952

@@ -103,6 +106,36 @@ impl From<&OrchardAddress> for PaymentAddress {
103106
}
104107
}
105108

109+
/// The number of Orchard actions a `BundleType::DEFAULT` bundle built from `num_spends` spends
110+
/// and `num_outputs` outputs will publish **on the wire**.
111+
///
112+
/// Every shielded fee predictor MUST size its fee with this function, because consensus prices
113+
/// the fee off the on-wire `actions.len()` (see
114+
/// `StateTransitionShieldedMinimumFeeValidationV0::validate_minimum_shielded_fee`, which reads
115+
/// `v0.actions.len()`), and an Orchard action is a *joined* spend/output slot: the action count
116+
/// is `max(num_spends, num_outputs)`, then padded up to Orchard's `MIN_ACTIONS = 2`.
117+
///
118+
/// The output side matters. A predictor that looks only at the spend count is correct **only**
119+
/// while `num_outputs <= 2`, because `max(n, 1).max(2) == max(n, 2).max(2)`. As soon as a
120+
/// transition publishes three or more outputs (a multi-recipient transfer plus change), a
121+
/// spends-only predictor under-counts and carves a fee below the one consensus computes — fatal
122+
/// for `ShieldedTransfer`, whose `value_balance` must equal the minimum fee **exactly**.
123+
///
124+
/// This delegates to Orchard's own [`BundleType::num_actions`] rather than re-deriving the rule,
125+
/// so the predictor cannot drift from the builder that actually lays out the bundle.
126+
pub fn shielded_bundle_action_count(
127+
num_spends: usize,
128+
num_outputs: usize,
129+
) -> Result<usize, ProtocolError> {
130+
BundleType::DEFAULT
131+
.num_actions(num_spends, num_outputs)
132+
.map_err(|e| {
133+
ProtocolError::ShieldedBuildError(format!(
134+
"invalid Orchard bundle shape ({num_spends} spends, {num_outputs} outputs): {e}"
135+
))
136+
})
137+
}
138+
106139
/// Serializes an authorized Orchard bundle into the raw fields used by
107140
/// state transition constructors.
108141
pub fn serialize_authorized_bundle(bundle: &Bundle<Authorized, i64, DashMemo>) -> SerializedBundle {
@@ -781,4 +814,57 @@ mod mod_tests {
781814
other => panic!("expected the closure's error to propagate, got {:?}", other),
782815
}
783816
}
817+
818+
// ------------------------------------------------------------------
819+
// `shielded_bundle_action_count` — the shared fee-sizing predictor.
820+
// ------------------------------------------------------------------
821+
822+
/// The predictor must be `max(num_spends, num_outputs)` padded to Orchard's 2-action
823+
/// minimum — for the OUTPUT side as well as the spend side. The `num_outputs >= 3` rows are
824+
/// the ones a spends-only predictor gets wrong.
825+
#[test]
826+
fn shielded_bundle_action_count_is_max_spends_outputs_padded_to_two() {
827+
for (spends, outputs, expected) in [
828+
(0usize, 1usize, 2usize),
829+
(1, 1, 2),
830+
(1, 2, 2),
831+
(2, 2, 2),
832+
// Output-dominated shapes: the spend count no longer determines the fee.
833+
(1, 3, 3),
834+
(2, 3, 3),
835+
(1, 4, 4),
836+
(5, 3, 5),
837+
(3, 7, 7),
838+
] {
839+
let actual = shielded_bundle_action_count(spends, outputs)
840+
.expect("DEFAULT bundles accept any spend/output mix");
841+
assert_eq!(
842+
actual, expected,
843+
"action count for {spends} spends / {outputs} outputs"
844+
);
845+
}
846+
}
847+
848+
/// A real bundle's on-wire `actions.len()` — the number consensus prices the fee off — must
849+
/// equal what the predictor said. Exercised through the output-only builder because it is
850+
/// the cheapest real bundle to construct at several output counts.
851+
#[test]
852+
fn shielded_bundle_action_count_matches_a_real_bundle() {
853+
let recipient = test_orchard_address();
854+
// (dummy_outputs, total outputs = 1 real + dummies)
855+
for dummies in [0usize, 1, 4] {
856+
let num_outputs = 1 + dummies;
857+
let bundle =
858+
build_output_only_bundle(&recipient, 10_000, [0u8; 36], None, dummies, &TestProver)
859+
.expect("bundle should build");
860+
let predicted =
861+
shielded_bundle_action_count(0, num_outputs).expect("valid bundle shape");
862+
assert_eq!(
863+
bundle.actions().len(),
864+
predicted,
865+
"predicted action count must match the real bundle's on-wire count for \
866+
{num_outputs} outputs"
867+
);
868+
}
869+
}
784870
}

0 commit comments

Comments
 (0)