Skip to content

Commit b33f8ec

Browse files
feat(dpp): shielded state transitions and Orchard bundle types (Medusa) (#3177)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 95b0d5c commit b33f8ec

194 files changed

Lines changed: 6920 additions & 53 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 482 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,35 @@ key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "0bc6592b
5353
key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "0bc6592bd41037ffc532d813d4c0828bea7cf882" }
5454
dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "0bc6592bd41037ffc532d813d4c0828bea7cf882" }
5555

56+
# Optimize heavy crypto crates even in dev/test builds so that
57+
# Halo 2 proof generation and verification run at near-release speed.
58+
# Without this, ZK operations are 10-100x slower (debug field arithmetic).
59+
[profile.dev.package.halo2_proofs]
60+
opt-level = 3
61+
[profile.dev.package.halo2_gadgets]
62+
opt-level = 3
63+
[profile.dev.package.halo2_poseidon]
64+
opt-level = 3
65+
[profile.dev.package.orchard]
66+
opt-level = 3
67+
[profile.dev.package.pasta_curves]
68+
opt-level = 3
69+
[profile.dev.package.grovedb-commitment-tree]
70+
opt-level = 3
71+
72+
[profile.test.package.halo2_proofs]
73+
opt-level = 3
74+
[profile.test.package.halo2_gadgets]
75+
opt-level = 3
76+
[profile.test.package.halo2_poseidon]
77+
opt-level = 3
78+
[profile.test.package.orchard]
79+
opt-level = 3
80+
[profile.test.package.pasta_curves]
81+
opt-level = 3
82+
[profile.test.package.grovedb-commitment-tree]
83+
opt-level = 3
84+
5685
[workspace.package]
5786

5887
version = "3.1.0-dev.1"

packages/rs-dpp/Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ strum = { version = "0.26", features = ["derive"] }
7171
json-schema-compatibility-validator = { path = '../rs-json-schema-compatibility-validator', optional = true }
7272
once_cell = "1.19.0"
7373
tracing = { version = "0.1.41" }
74+
grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "7ecb8465fad750c7cddd5332adb6f97fcceb498b", optional = true }
7475

7576
[dev-dependencies]
7677
tokio = { version = "1.40", features = ["full"] }
@@ -327,5 +328,13 @@ extended-document = [
327328
]
328329
token-reward-explanations = ["dep:chrono-tz"]
329330

331+
# Gates client-side Orchard helpers (address encoding, bundle building, proving).
332+
# Clients that don't need to create shielded transactions can omit this to avoid
333+
# compiling the Orchard/Halo 2 dependency tree. The shielded state transition
334+
# types themselves are always available — only the client tooling is behind this gate.
335+
shielded-client = [
336+
"state-transition-signing",
337+
"dep:grovedb-commitment-tree",
338+
]
330339
factories = []
331340
client = ["factories", "state-transitions"]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
pub mod fee_strategy;
2+
#[cfg(feature = "shielded-client")]
3+
mod orchard_address;
24
mod platform_address;
35
mod witness;
46
mod witness_verification_operations;
57

68
pub use fee_strategy::*;
9+
#[cfg(feature = "shielded-client")]
10+
pub use orchard_address::*;
711
pub use platform_address::*;
812
pub use witness::*;
913
pub use witness_verification_operations::*;
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
use bech32::{Bech32m, Hrp};
2+
use dashcore::Network;
3+
4+
use crate::address_funds::platform_address::{PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET};
5+
use crate::address_funds::PlatformAddress;
6+
use crate::ProtocolError;
7+
8+
/// Size of the Orchard diversifier (11 bytes).
9+
pub const ORCHARD_DIVERSIFIER_SIZE: usize = 11;
10+
/// Size of the Orchard diversified transmission key pk_d (32 bytes, Pallas curve point).
11+
pub const ORCHARD_PKD_SIZE: usize = 32;
12+
/// Total size of a raw Orchard payment address (43 bytes = diversifier + pk_d).
13+
pub const ORCHARD_ADDRESS_SIZE: usize = ORCHARD_DIVERSIFIER_SIZE + ORCHARD_PKD_SIZE;
14+
15+
/// An Orchard shielded payment address.
16+
///
17+
/// Composed of a diversifier (11 bytes) and a diversified transmission key (32 bytes).
18+
/// The diversifier enables a single spending key to derive an unlimited number of
19+
/// unlinkable payment addresses. Only the holder of the corresponding FullViewingKey
20+
/// (or IncomingViewingKey) can link diversified addresses to the same wallet.
21+
///
22+
/// Bech32m encoding uses type byte `0x10`, producing addresses that start with `z`:
23+
/// - Mainnet: `dash1z...`
24+
/// - Testnet: `tdash1z...`
25+
///
26+
/// The raw Orchard address format matches Zcash Orchard (43 bytes), but the
27+
/// string encoding is Dash-specific (no F4Jumble, no Unified Address wrapper).
28+
///
29+
/// Wraps `grovedb_commitment_tree::PaymentAddress`. Use [`From<PaymentAddress>`]
30+
/// to convert from the orchard crate's native type, or [`inner()`](OrchardAddress::inner)
31+
/// / [`into_inner()`](OrchardAddress::into_inner) to access the wrapped address.
32+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33+
pub struct OrchardAddress(grovedb_commitment_tree::PaymentAddress);
34+
35+
impl OrchardAddress {
36+
/// Type byte for Orchard addresses in bech32m encoding (user-facing).
37+
/// Produces 'z' as the first bech32 character.
38+
pub const ORCHARD_TYPE: u8 = 0x10;
39+
40+
/// Returns the inner [`PaymentAddress`](grovedb_commitment_tree::PaymentAddress).
41+
pub fn inner(&self) -> &grovedb_commitment_tree::PaymentAddress {
42+
&self.0
43+
}
44+
45+
/// Consumes the wrapper and returns the inner `PaymentAddress`.
46+
pub fn into_inner(self) -> grovedb_commitment_tree::PaymentAddress {
47+
self.0
48+
}
49+
50+
/// Creates an OrchardAddress from a 43-byte raw address.
51+
///
52+
/// The first 11 bytes are the diversifier, the next 32 are pk_d.
53+
/// Returns an error if `pk_d` is not a valid Pallas curve point.
54+
pub fn from_raw_bytes(bytes: &[u8; ORCHARD_ADDRESS_SIZE]) -> Result<Self, ProtocolError> {
55+
let addr =
56+
Option::from(grovedb_commitment_tree::PaymentAddress::from_raw_address_bytes(bytes))
57+
.ok_or_else(|| {
58+
ProtocolError::DecodingError(
59+
"OrchardAddress pk_d is not a valid Pallas curve point".to_string(),
60+
)
61+
})?;
62+
Ok(Self(addr))
63+
}
64+
65+
/// Returns the raw 43-byte address (diversifier || pk_d).
66+
pub fn to_raw_bytes(&self) -> [u8; ORCHARD_ADDRESS_SIZE] {
67+
self.0.to_raw_address_bytes()
68+
}
69+
70+
/// Encodes the OrchardAddress as a bech32m string for the specified network.
71+
///
72+
/// Format: `<HRP>1<data-part>`
73+
/// - Data: type_byte (0x10) || diversifier (11 bytes) || pk_d (32 bytes)
74+
/// - Total payload: 44 bytes
75+
/// - Checksum: bech32m (BIP-350)
76+
pub fn to_bech32m_string(&self, network: Network) -> String {
77+
let hrp_str = PlatformAddress::hrp_for_network(network);
78+
let hrp = Hrp::parse(hrp_str).expect("HRP is valid");
79+
80+
let raw = self.to_raw_bytes();
81+
let mut payload = Vec::with_capacity(1 + ORCHARD_ADDRESS_SIZE);
82+
payload.push(Self::ORCHARD_TYPE);
83+
payload.extend_from_slice(&raw);
84+
85+
bech32::encode::<Bech32m>(hrp, &payload).expect("encoding should succeed")
86+
}
87+
88+
/// Decodes a bech32m-encoded Orchard address string.
89+
///
90+
/// # Returns
91+
/// - `Ok((OrchardAddress, Network))` - The decoded address and its network
92+
/// - `Err(ProtocolError)` - If the address is invalid
93+
pub fn from_bech32m_string(s: &str) -> Result<(Self, Network), ProtocolError> {
94+
let (hrp, data) =
95+
bech32::decode(s).map_err(|e| ProtocolError::DecodingError(format!("{}", e)))?;
96+
97+
let hrp_lower = hrp.as_str().to_ascii_lowercase();
98+
let network = match hrp_lower.as_str() {
99+
s if s == PLATFORM_HRP_MAINNET => Network::Dash,
100+
s if s == PLATFORM_HRP_TESTNET => Network::Testnet,
101+
_ => {
102+
return Err(ProtocolError::DecodingError(format!(
103+
"invalid HRP '{}': expected '{}' or '{}'",
104+
hrp, PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET
105+
)))
106+
}
107+
};
108+
109+
// Validate payload: 1 type byte + 11 diversifier + 32 pk_d = 44 bytes
110+
if data.len() != 1 + ORCHARD_ADDRESS_SIZE {
111+
return Err(ProtocolError::DecodingError(format!(
112+
"invalid Orchard address length: expected {} bytes, got {}",
113+
1 + ORCHARD_ADDRESS_SIZE,
114+
data.len()
115+
)));
116+
}
117+
118+
if data[0] != Self::ORCHARD_TYPE {
119+
return Err(ProtocolError::DecodingError(format!(
120+
"invalid Orchard address type byte: expected 0x{:02x}, got 0x{:02x}",
121+
Self::ORCHARD_TYPE,
122+
data[0]
123+
)));
124+
}
125+
126+
let mut raw = [0u8; ORCHARD_ADDRESS_SIZE];
127+
raw.copy_from_slice(&data[1..]);
128+
Self::from_raw_bytes(&raw).map(|addr| (addr, network))
129+
}
130+
}
131+
132+
/// Infallible conversion from the orchard crate's `PaymentAddress` to `OrchardAddress`.
133+
impl From<grovedb_commitment_tree::PaymentAddress> for OrchardAddress {
134+
fn from(addr: grovedb_commitment_tree::PaymentAddress) -> Self {
135+
Self(addr)
136+
}
137+
}
138+
139+
/// Infallible conversion from a reference to `PaymentAddress`.
140+
impl From<&grovedb_commitment_tree::PaymentAddress> for OrchardAddress {
141+
fn from(addr: &grovedb_commitment_tree::PaymentAddress) -> Self {
142+
Self(*addr)
143+
}
144+
}
145+
146+
impl std::fmt::Display for OrchardAddress {
147+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148+
let raw = self.to_raw_bytes();
149+
write!(
150+
f,
151+
"Orchard(d={}, pk_d={})",
152+
hex::encode(&raw[..ORCHARD_DIVERSIFIER_SIZE]),
153+
hex::encode(&raw[ORCHARD_DIVERSIFIER_SIZE..])
154+
)
155+
}
156+
}
157+
158+
#[cfg(test)]
159+
mod tests {
160+
use super::*;
161+
use bech32::Hrp;
162+
163+
fn test_orchard_address() -> OrchardAddress {
164+
use grovedb_commitment_tree::{FullViewingKey, Scope, SpendingKey};
165+
let sk = SpendingKey::from_bytes([42u8; 32]).unwrap();
166+
let fvk = FullViewingKey::from(&sk);
167+
let payment_address = fvk.address_at(0u32, Scope::External);
168+
OrchardAddress::from(payment_address)
169+
}
170+
171+
#[test]
172+
fn test_orchard_address_raw_bytes_roundtrip() {
173+
let address = test_orchard_address();
174+
let raw = address.to_raw_bytes();
175+
assert_eq!(raw.len(), 43);
176+
177+
let recovered = OrchardAddress::from_raw_bytes(&raw).unwrap();
178+
assert_eq!(recovered, address);
179+
}
180+
181+
#[test]
182+
fn test_orchard_bech32m_mainnet_roundtrip() {
183+
let address = test_orchard_address();
184+
185+
let encoded = address.to_bech32m_string(Network::Dash);
186+
assert!(
187+
encoded.starts_with("dash1z"),
188+
"Orchard mainnet address should start with 'dash1z', got: {}",
189+
encoded
190+
);
191+
192+
let (decoded, network) =
193+
OrchardAddress::from_bech32m_string(&encoded).expect("decoding should succeed");
194+
assert_eq!(decoded, address);
195+
assert_eq!(network, Network::Dash);
196+
}
197+
198+
#[test]
199+
fn test_orchard_bech32m_testnet_roundtrip() {
200+
let address = test_orchard_address();
201+
202+
let encoded = address.to_bech32m_string(Network::Testnet);
203+
assert!(
204+
encoded.starts_with("tdash1z"),
205+
"Orchard testnet address should start with 'tdash1z', got: {}",
206+
encoded
207+
);
208+
209+
let (decoded, network) =
210+
OrchardAddress::from_bech32m_string(&encoded).expect("decoding should succeed");
211+
assert_eq!(decoded, address);
212+
assert_eq!(network, Network::Testnet);
213+
}
214+
215+
#[test]
216+
fn test_orchard_bech32m_wrong_type_byte_fails() {
217+
// Manually construct an address with P2PKH type byte (0xb0) but 44-byte payload
218+
let hrp = Hrp::parse("dash").unwrap();
219+
let mut payload = vec![PlatformAddress::P2PKH_TYPE]; // Wrong type byte
220+
payload.extend_from_slice(&[0u8; 43]);
221+
let encoded = bech32::encode::<Bech32m>(hrp, &payload).unwrap();
222+
223+
let result = OrchardAddress::from_bech32m_string(&encoded);
224+
assert!(result.is_err());
225+
assert!(result
226+
.unwrap_err()
227+
.to_string()
228+
.contains("invalid Orchard address type byte"));
229+
}
230+
231+
#[test]
232+
fn test_orchard_bech32m_wrong_length_fails() {
233+
// Too short (only 20 bytes instead of 43)
234+
let hrp = Hrp::parse("dash").unwrap();
235+
let mut payload = vec![OrchardAddress::ORCHARD_TYPE];
236+
payload.extend_from_slice(&[0u8; 20]);
237+
let encoded = bech32::encode::<Bech32m>(hrp, &payload).unwrap();
238+
239+
let result = OrchardAddress::from_bech32m_string(&encoded);
240+
assert!(result.is_err());
241+
assert!(result
242+
.unwrap_err()
243+
.to_string()
244+
.contains("invalid Orchard address length"));
245+
}
246+
247+
#[test]
248+
fn test_orchard_and_platform_addresses_are_distinguishable() {
249+
let p2pkh = PlatformAddress::P2pkh([0xAB; 20]);
250+
let p2sh = PlatformAddress::P2sh([0xAB; 20]);
251+
let orchard = test_orchard_address();
252+
253+
let p2pkh_enc = p2pkh.to_bech32m_string(Network::Dash);
254+
let p2sh_enc = p2sh.to_bech32m_string(Network::Dash);
255+
let orchard_enc = orchard.to_bech32m_string(Network::Dash);
256+
257+
// All three start with "dash1" but have different type-byte characters
258+
assert!(p2pkh_enc.starts_with("dash1k"), "P2PKH: {}", p2pkh_enc);
259+
assert!(p2sh_enc.starts_with("dash1s"), "P2SH: {}", p2sh_enc);
260+
assert!(
261+
orchard_enc.starts_with("dash1z"),
262+
"Orchard: {}",
263+
orchard_enc
264+
);
265+
266+
// Cross-decoding should fail
267+
assert!(PlatformAddress::from_bech32m_string(&orchard_enc).is_err());
268+
assert!(OrchardAddress::from_bech32m_string(&p2pkh_enc).is_err());
269+
}
270+
271+
#[test]
272+
fn test_orchard_address_from_raw_bytes_invalid_pk_d() {
273+
// All zeros for pk_d is not a valid Pallas curve point
274+
let mut raw = [0u8; 43];
275+
raw[0] = 0x01; // non-zero diversifier
276+
assert!(OrchardAddress::from_raw_bytes(&raw).is_err());
277+
}
278+
279+
#[test]
280+
fn test_orchard_address_display() {
281+
let address = test_orchard_address();
282+
let display = format!("{}", address);
283+
assert!(display.starts_with("Orchard(d="));
284+
assert!(display.contains("pk_d="));
285+
}
286+
}

packages/rs-dpp/src/address_funds/platform_address.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1303,7 +1303,7 @@ mod tests {
13031303
assert_eq!(p2pkh.to_bytes()[0], 0x00);
13041304
assert_eq!(p2sh.to_bytes()[0], 0x01);
13051305

1306-
// Bech32m encoding uses 0xb0/0x80 (verified by successful roundtrip)
1306+
// Bech32m encoding uses 0xb0/0xb8 (verified by successful roundtrip)
13071307
let p2pkh_encoded = p2pkh.to_bech32m_string(Network::Dash);
13081308
let p2sh_encoded = p2sh.to_bech32m_string(Network::Dash);
13091309

packages/rs-dpp/src/asset_lock/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub type PastAssetLockStateTransitionHashes = Vec<Vec<u8>>;
66

77
/// An enumeration of the possible states when querying platform to get the stored state of an outpoint
88
/// representing if the asset lock was already used or not.
9+
#[derive(Debug, serde::Serialize, serde::Deserialize)]
910
pub enum StoredAssetLockInfo {
1011
/// The asset lock was fully consumed in the past
1112
FullyConsumed,

packages/rs-dpp/src/asset_lock/reduced_asset_lock_value/mod.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,18 @@ mod v0;
1111

1212
pub use v0::{AssetLockValueGettersV0, AssetLockValueSettersV0};
1313

14-
#[derive(Debug, Clone, Encode, Decode, PlatformSerialize, PlatformDeserialize, From, PartialEq)]
14+
#[derive(
15+
Debug,
16+
Clone,
17+
Encode,
18+
Decode,
19+
PlatformSerialize,
20+
PlatformDeserialize,
21+
From,
22+
PartialEq,
23+
serde::Serialize,
24+
serde::Deserialize,
25+
)]
1526
#[platform_serialize(unversioned)]
1627
pub enum AssetLockValue {
1728
V0(AssetLockValueV0),

0 commit comments

Comments
 (0)