[WIP] 0.3.0 mtp update

This commit is contained in:
Alex Emmet 2026-08-18 22:39:02 +02:00
commit e1dd86ec02
No known key found for this signature in database
42 changed files with 2422 additions and 1429 deletions

View file

@ -6,4 +6,7 @@ edition = "2024"
[dependencies]
iota-storage = { path = "../iota-storage" }
iota-util = { path = "../iota-util" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["crypto"] }
[dev-dependencies]
tokio = { version = "1.50.0", features = ["macros", "rt"] }

View file

@ -1,3 +1,4 @@
pub mod connection_handler;
pub mod message_common;
pub mod message_handlers;
pub mod relay;

View file

@ -2,6 +2,8 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::type_map::TypeMap;
use std::time::{SystemTime, UNIX_EPOCH};
pub use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt};
pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue {
use mtp::type_map::{DataTypeId, TypeMap};
let tm = TypeMap::latest();
@ -15,22 +17,34 @@ pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue {
pub fn data_string(cv: &CommunicationValue, dt: DataType) -> Option<String> {
cv.get_data(dt)
.as_str()
.and_then(DataValue::as_str)
.map(|s| s.to_string())
.or_else(|| cv.get_data(dt).as_number().map(|n| n.to_string()))
.or_else(|| cv.get_data(dt).as_signed_number().map(|n| n.to_string()))
.or_else(|| {
cv.get_data(dt)
.and_then(DataValue::as_number)
.map(|n| n.to_string())
})
.or_else(|| {
cv.get_data(dt)
.and_then(DataValue::as_signed_number)
.map(|n| n.to_string())
})
}
pub fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option<i64> {
cv.get_data(dt)
.as_number()
.and_then(DataValue::as_number)
.and_then(|n| i64::try_from(n).ok())
.or_else(|| {
cv.get_data(dt)
.as_signed_number()
.and_then(DataValue::as_signed_number)
.and_then(|n| i64::try_from(n).ok())
})
.or_else(|| cv.get_data(dt).as_str().and_then(|s| s.parse::<i64>().ok()))
.or_else(|| {
cv.get_data(dt)
.and_then(DataValue::as_str)
.and_then(|s| s.parse::<i64>().ok())
})
}
#[derive(Debug, Clone)]
@ -67,7 +81,7 @@ pub fn recipient_from_value(value: &DataValue) -> Option<ChatSecretRecipient> {
}
pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option<Vec<ChatSecretRecipient>> {
let recipients = cv.get_data(DataType::Recipients).as_array()?;
let recipients = cv.get_data(DataType::Recipients)?.as_array()?;
let parsed = recipients
.iter()
.map(recipient_from_value)
@ -80,49 +94,6 @@ pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option<Vec<ChatSecretR
}
}
pub fn set_chat_secret_cv_for_recipient(
source: &CommunicationValue,
recipient: &ChatSecretRecipient,
) -> CommunicationValue {
let recipient_value = typed_container(vec![
(DataType::UserId, DataValue::Str(recipient.user_id.clone())),
(
DataType::EncryptedSecret,
DataValue::Bytes(recipient.encrypted_secret.clone()),
),
(
DataType::KemCiphertext,
DataValue::Bytes(recipient.kem_ciphertext.clone()),
),
]);
CommunicationValue::new(CommunicationType::SetChatSecret)
.with_id(source.get_id())
.with_sender(source.get_sender())
.with_receiver(recipient.user_id.parse::<u64>().unwrap_or(0))
.add_typed_default(DataType::ChatId, source.get_data(DataType::ChatId).clone())
.add_typed_default(
DataType::SecretId,
source.get_data(DataType::SecretId).clone(),
)
.add_typed_default(
DataType::VersionNumber,
source.get_data(DataType::VersionNumber).clone(),
)
.add_typed_default(
DataType::WrappingScheme,
source.get_data(DataType::WrappingScheme).clone(),
)
.add_typed_default(
DataType::CreatedAt,
source.get_data(DataType::CreatedAt).clone(),
)
.add_typed_default(
DataType::Recipients,
DataValue::Array(vec![recipient_value]),
)
}
pub fn now_millis_i64() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@ -131,7 +102,9 @@ pub fn now_millis_i64() -> i64 {
}
pub fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue {
CommunicationValue::new(ty)
.with_id(request.get_id())
.with_receiver(request.get_sender())
let mut response = CommunicationValue::new(ty).with_id(request.id().unwrap_or_default());
if let Some(sender) = request.sender() {
response = response.with_receiver(sender);
}
response
}

View file

@ -4,7 +4,11 @@ use iota_storage::util::chats_util::{self, get_user, mod_user};
use iota_storage::util::communities_util::CommunitiesUtil;
use iota_storage::util::e2ee_storage::{self, ChatSecretQuery};
use iota_storage::util::settings;
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::codec::{
CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, VerifiedRelayContent,
};
use crate::relay::VerifiedRelayContext;
pub struct MessageMutation {
pub sender_id: i64,
@ -33,6 +37,170 @@ pub fn success_response(cv: &CommunicationValue) -> CommunicationValue {
error_response(cv, CommunicationType::Success)
}
fn relay_field<'a>(
payload: &'a DataValue,
data_type: DataType,
type_map: &TypeMap,
) -> Option<&'a DataValue> {
payload.get_field(data_type.try_to_id(type_map)?)
}
fn relay_string<'a>(
payload: &'a DataValue,
data_type: DataType,
type_map: &TypeMap,
) -> Option<&'a str> {
relay_field(payload, data_type, type_map)?.as_str()
}
fn relay_number(payload: &DataValue, data_type: DataType, type_map: &TypeMap) -> Option<i128> {
relay_field(payload, data_type, type_map)?.as_number()
}
fn relay_identity(
payload: &DataValue,
data_type: DataType,
type_map: &TypeMap,
) -> Result<Option<u64>, String> {
let Some(value) = relay_field(payload, data_type, type_map) else {
return Ok(None);
};
if let Some(number) = value.as_number() {
return u64::try_from(number)
.map(Some)
.map_err(|_| format!("Relay {data_type:?} is outside the user ID range"));
}
if let Some(text) = value.as_str() {
return text
.parse::<u64>()
.map(Some)
.map_err(|_| format!("Relay {data_type:?} is not a user ID"));
}
Err(format!("Relay {data_type:?} has an invalid user ID value"))
}
fn validate_relay_identity(
context: &VerifiedRelayContext,
payload: &DataValue,
) -> Result<(), String> {
if relay_identity(payload, DataType::SenderId, &context.type_map)?
.is_some_and(|sender_id| sender_id != context.signer_id)
{
return Err("Relay SenderId does not match the authenticated signer".into());
}
if relay_identity(payload, DataType::ReceiverId, &context.type_map)?
.is_some_and(|receiver_id| receiver_id != context.final_recipient_id)
{
return Err("Relay ReceiverId does not match the authenticated recipient".into());
}
Ok(())
}
/*
* Apply only operations whose actor and recipient can be taken from verified
* Relay metadata. The raw Relay frame never enters these handlers, so outer
* routing fields cannot become application identity.
*/
pub fn apply_verified_relay_content(
context: &VerifiedRelayContext,
content: &VerifiedRelayContent,
) -> Result<(), String> {
validate_relay_identity(context, &content.content)?;
let sender_id = i64::try_from(context.signer_id)
.map_err(|_| "Relay signer ID exceeds the local storage range".to_string())?;
let recipient_id = i64::try_from(context.final_recipient_id)
.map_err(|_| "Relay recipient ID exceeds the local storage range".to_string())?;
let created_at = i64::try_from(context.created_at)
.map_err(|_| "Relay creation time exceeds the local storage range".to_string())?;
match content.message_type.as_str() {
"MessageSend" => {
let message = relay_string(&content.content, DataType::Content, &context.type_map)
.ok_or_else(|| "Relay MessageSend is missing Content".to_string())?;
let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map)
.and_then(|value| i64::try_from(value).ok())
.unwrap_or(created_at);
let height = relay_number(&content.content, DataType::Height, &context.type_map)
.and_then(|value| i64::try_from(value).ok())
.unwrap_or_default();
let reply_to = relay_number(&content.content, DataType::ReplyId, &context.type_map)
.and_then(|value| i64::try_from(value).ok());
chat_files::add_message(
u128::try_from(send_time)
.map_err(|_| "Relay MessageSend has a negative SendTime".to_string())?,
false,
recipient_id,
sender_id,
message,
height,
reply_to,
);
Ok(())
}
"MessageEdit" => {
let message = relay_string(&content.content, DataType::Content, &context.type_map)
.ok_or_else(|| "Relay MessageEdit is missing Content".to_string())?;
let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map)
.and_then(|value| i64::try_from(value).ok())
.ok_or_else(|| "Relay MessageEdit is missing SendTime".to_string())?;
chat_files::apply_remote_edit(recipient_id, sender_id, send_time, sender_id, message)
.map_err(|error| error.to_string())
}
"MessageReactionAdd" | "MessageReactionRemove" => {
let reaction = relay_string(&content.content, DataType::Reaction, &context.type_map)
.filter(|value| !value.is_empty() && value.len() <= 64)
.ok_or_else(|| "Relay reaction is invalid".to_string())?;
let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map)
.and_then(|value| i64::try_from(value).ok())
.ok_or_else(|| "Relay reaction is missing SendTime".to_string())?;
let result = if content.message_type == "MessageReactionAdd" {
chat_files::add_reaction(recipient_id, sender_id, send_time, sender_id, reaction)
} else {
chat_files::remove_reaction(recipient_id, sender_id, send_time, sender_id, reaction)
};
result.map_err(|error| error.to_string())
}
"MessageDeleteLive" => {
let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map)
.and_then(|value| i64::try_from(value).ok())
.ok_or_else(|| "Relay MessageDeleteLive is missing SendTime".to_string())?;
chat_files::apply_remote_delete(recipient_id, sender_id, send_time, sender_id)
.map_err(|error| error.to_string())
}
"SetChatSecret" => {
let frame = CommunicationValue::new(CommunicationType::SetChatSecret)
.with_payload(content.content.clone());
let recipients = chat_secret_recipients(&frame)
.ok_or_else(|| "Relay SetChatSecret has no recipients".to_string())?;
let recipient = recipients
.into_iter()
.find(|value| value.user_id == context.final_recipient_id.to_string())
.ok_or_else(|| "Relay SetChatSecret recipient mismatch".to_string())?;
let chat_id = data_string(&frame, DataType::ChatId)
.ok_or_else(|| "Relay SetChatSecret is missing ChatId".to_string())?;
let secret_id = data_string(&frame, DataType::SecretId)
.ok_or_else(|| "Relay SetChatSecret is missing SecretId".to_string())?;
let version = data_i64(&frame, DataType::VersionNumber)
.ok_or_else(|| "Relay SetChatSecret is missing VersionNumber".to_string())?;
let wrapping_scheme = data_string(&frame, DataType::WrappingScheme)
.ok_or_else(|| "Relay SetChatSecret is missing WrappingScheme".to_string())?;
e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret {
user_id: context.final_recipient_id.to_string(),
chat_id,
secret_id,
version,
encrypted_secret: recipient.encrypted_secret,
kem_ciphertext: recipient.kem_ciphertext,
wrapping_scheme,
created_at,
updated_at: now_millis_i64(),
})
.map_err(|error| error.to_string())
}
_ => Ok(()),
}
}
pub fn handle_message_edit(cv: &CommunicationValue) -> CommunicationValue {
let mutation = match message_mutation(cv) {
Ok(mutation) => mutation,
@ -346,7 +514,9 @@ mod presence_tests {
fn sync_error(cv: &CommunicationValue) -> CommunicationValue {
error_response(cv, CommunicationType::ErrorInvalidData).add_typed_default(
DataType::SessionId,
cv.get_data(DataType::SessionId).clone(),
cv.get_data(DataType::SessionId)
.cloned()
.unwrap_or(DataValue::Null),
)
}

View file

@ -0,0 +1,350 @@
use iota_util::route_target::RouteTarget;
use mtp::codec::{
CommunicationValue, ProtectionPolicy, RelayError, SignaturePolicy, TypeMap,
VerifiedRelayContent, VerifiedRelayMetadata, forward_relay_frame,
open_relay_content_with_keyrings, open_relay_metadata_with, relay_metadata_claimed_signer_id,
};
use mtp::crypto::{Keyring, PublicKeyBundle};
use std::fmt;
pub const RELAY_PROTECTION_POLICY: ProtectionPolicy = ProtectionPolicy {
signature: SignaturePolicy::Dual,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageSecurityClass {
RelayOnly,
AuthenticatedPeerControl,
AuthenticatedLocalRequest,
}
pub fn message_security_class(frame: &CommunicationValue) -> MessageSecurityClass {
const RELAY_ONLY_TYPES: &[mtp::codec::CommunicationType] = &[
mtp::codec::CommunicationType::MessageSend,
mtp::codec::CommunicationType::MessageLive,
mtp::codec::CommunicationType::MessageState,
mtp::codec::CommunicationType::MessageEdit,
mtp::codec::CommunicationType::MessageEditLive,
mtp::codec::CommunicationType::MessageReactionAdd,
mtp::codec::CommunicationType::MessageReactionRemove,
mtp::codec::CommunicationType::MessageReactionLive,
mtp::codec::CommunicationType::MessageDelete,
mtp::codec::CommunicationType::MessageDeleteLive,
mtp::codec::CommunicationType::MessageOtherIota,
mtp::codec::CommunicationType::SetChatSecret,
mtp::codec::CommunicationType::SendChat,
mtp::codec::CommunicationType::SettingsSave,
mtp::codec::CommunicationType::GlobalSettingsSave,
mtp::codec::CommunicationType::AddConversation,
mtp::codec::CommunicationType::AddCommunity,
mtp::codec::CommunicationType::RemoveCommunity,
];
if RELAY_ONLY_TYPES.iter().any(|kind| frame.is_type(*kind)) {
MessageSecurityClass::RelayOnly
} else if frame.is_type(mtp::codec::CommunicationType::GetChatSecret)
|| frame.is_type(mtp::codec::CommunicationType::MessageGet)
|| frame.is_type(mtp::codec::CommunicationType::MessagesGet)
{
MessageSecurityClass::AuthenticatedPeerControl
} else {
MessageSecurityClass::AuthenticatedLocalRequest
}
}
#[derive(Debug, Clone)]
pub struct UserIdentity {
pub user_id: u64,
pub iota_id: u64,
pub signing_keys: Vec<PublicKeyBundle>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRelayContext {
pub signer_id: u64,
pub final_recipient_id: u64,
pub message_id: String,
pub created_at: u64,
pub type_map: TypeMap,
}
#[derive(Debug, Clone)]
pub struct VerifiedRelay {
pub metadata: VerifiedRelayMetadata,
pub context: VerifiedRelayContext,
pub signing_keys: Vec<PublicKeyBundle>,
}
#[derive(Debug)]
pub enum RelayValidationError {
WrongNextHop { expected: u64, actual: Option<u64> },
OuterSenderNotAllowed,
MissingSigningKeys(u64),
MissingTypeMap,
InvalidRouteTarget(u64),
KeyLookup(String),
Relay(RelayError),
}
impl fmt::Display for RelayValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WrongNextHop { expected, actual } => {
write!(
formatter,
"relay next hop {:?} does not match Iota {expected}",
actual
)
}
Self::OuterSenderNotAllowed => formatter.write_str("relay has an outer sender"),
Self::MissingSigningKeys(signer_id) => {
write!(formatter, "no trusted signing keys for user {signer_id}")
}
Self::MissingTypeMap => formatter.write_str("relay has no negotiated type map"),
Self::InvalidRouteTarget(target) => {
write!(formatter, "relay has invalid route target {target}")
}
Self::KeyLookup(error) => write!(formatter, "trusted signer lookup failed: {error}"),
Self::Relay(error) => error.fmt(formatter),
}
}
}
impl std::error::Error for RelayValidationError {}
impl From<RelayError> for RelayValidationError {
fn from(error: RelayError) -> Self {
Self::Relay(error)
}
}
/*
* Relay metadata is opened only after the claimed signer selects trusted key
* history. Replay reservation happens after verification and durable
* acceptance, so a failed delivery can be retried without losing the frame.
*/
pub async fn verify_relay_metadata<F, Fut>(
frame: &CommunicationValue,
local_iota_id: u64,
keyring: &Keyring,
resolve_signing_keys: F,
) -> Result<VerifiedRelay, RelayValidationError>
where
F: FnOnce(u64) -> Fut,
Fut: Future<Output = Result<Vec<PublicKeyBundle>, RelayValidationError>>,
{
let expected_next_hop = RouteTarget::Iota(local_iota_id)
.wire_id()
.ok_or(RelayValidationError::InvalidRouteTarget(local_iota_id))?;
if frame.receiver() != Some(expected_next_hop) {
return Err(RelayValidationError::WrongNextHop {
expected: expected_next_hop,
actual: frame.receiver(),
});
}
if frame.sender().is_some() {
return Err(RelayValidationError::OuterSenderNotAllowed);
}
let claimed_signer = relay_metadata_claimed_signer_id(frame, &[keyring])?;
let signing_keys = resolve_signing_keys(claimed_signer).await?;
if signing_keys.is_empty() {
return Err(RelayValidationError::MissingSigningKeys(claimed_signer));
}
let resolver_keys = signing_keys.clone();
let type_map = frame
.type_map()
.cloned()
.ok_or(RelayValidationError::MissingTypeMap)?;
let metadata = open_relay_metadata_with(
frame,
&[keyring],
Some(claimed_signer),
move |signer_id| (signer_id == claimed_signer).then(|| resolver_keys.clone()),
RELAY_PROTECTION_POLICY,
None,
)?;
let context = VerifiedRelayContext {
signer_id: metadata.signer_id(),
final_recipient_id: metadata.final_recipient_id(),
message_id: metadata.message_id().to_owned(),
created_at: metadata.created_at(),
type_map,
};
Ok(VerifiedRelay {
metadata,
context,
signing_keys,
})
}
pub fn open_verified_relay_content(
relay: &VerifiedRelay,
keyrings: &[&Keyring],
expected_recipient_id: u64,
) -> Result<VerifiedRelayContent, RelayValidationError> {
Ok(open_relay_content_with_keyrings(
&relay.metadata,
keyrings,
&relay.signing_keys,
Some(expected_recipient_id),
RELAY_PROTECTION_POLICY,
)?)
}
pub fn forward_verified_relay(
frame: &CommunicationValue,
target: RouteTarget,
) -> Result<CommunicationValue, RelayValidationError> {
let next_hop_id = target
.wire_id()
.ok_or(RelayValidationError::InvalidRouteTarget(target.id()))?;
Ok(forward_relay_frame(frame, next_hop_id)?)
}
#[cfg(test)]
mod tests {
use super::*;
use mtp::codec::SealedRelayBuilder;
use mtp::crypto::{DualSigner, Ed25519Signer, Keyring};
fn relay(message_id: &str) -> Result<(Keyring, Keyring, CommunicationValue), String> {
let signer_keyring = Keyring::generate();
let recipient_keyring = Keyring::generate();
let signer = DualSigner::new(
&signer_keyring.sig_cl_secret_key,
&signer_keyring.sig_pq_secret_key,
&signer_keyring.sig_pq_public_key,
)
.map_err(|error| error.to_string())?;
let frame = SealedRelayBuilder::new(
"MessageSend",
mtp::codec::DataValue::Str("payload".into()),
7,
42,
RouteTarget::Iota(99)
.wire_id()
.ok_or("invalid test target")?,
&signer,
)
.message_id(message_id)
.created_at(123)
.metadata_recipients(vec![recipient_keyring.public_key_bundle()])
.content_recipients(vec![recipient_keyring.public_key_bundle()])
.build()
.map_err(|error| error.to_string())?;
Ok((signer_keyring, recipient_keyring, frame))
}
#[tokio::test]
async fn verifies_metadata_with_trusted_signing_key() -> Result<(), String> {
let (signer, recipient, frame) = relay("accepted")?;
let trusted_key = signer.public_key_bundle();
let verified = verify_relay_metadata(&frame, 99, &recipient, move |signer_id| async move {
(signer_id == 7)
.then_some(vec![trusted_key])
.ok_or(RelayValidationError::MissingSigningKeys(signer_id))
})
.await
.map_err(|error| error.to_string())?;
assert_eq!(verified.context.signer_id, 7);
assert_eq!(verified.context.final_recipient_id, 42);
assert_eq!(verified.context.message_id, "accepted");
Ok(())
}
#[tokio::test]
async fn rejects_metadata_signed_by_untrusted_key() -> Result<(), String> {
let (_signer, recipient, frame) = relay("wrong-key")?;
let wrong_signer = Keyring::generate();
let trusted_key = wrong_signer.public_key_bundle();
let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move {
Ok(vec![trusted_key])
})
.await;
assert!(matches!(result, Err(RelayValidationError::Relay(_))));
Ok(())
}
#[tokio::test]
async fn rejects_classical_only_relay_under_dual_policy() -> Result<(), String> {
let signer_keyring = Keyring::generate();
let recipient_keyring = Keyring::generate();
let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key)
.map_err(|error| error.to_string())?;
let frame = SealedRelayBuilder::new(
"MessageSend",
mtp::codec::DataValue::Str("payload".into()),
7,
42,
RouteTarget::Iota(99)
.wire_id()
.ok_or("invalid test target")?,
&signer,
)
.message_id("classical-only")
.created_at(123)
.metadata_recipients(vec![recipient_keyring.public_key_bundle()])
.content_recipients(vec![recipient_keyring.public_key_bundle()])
.build()
.map_err(|error| error.to_string())?;
let trusted_key = signer_keyring.public_key_bundle();
let result = verify_relay_metadata(&frame, 99, &recipient_keyring, move |_| async move {
Ok(vec![trusted_key])
})
.await;
assert!(matches!(result, Err(RelayValidationError::Relay(_))));
Ok(())
}
#[tokio::test]
async fn rejects_outer_sender_before_key_lookup() -> Result<(), String> {
let (_signer, recipient, frame) = relay("outer-sender")?;
let frame = frame.with_sender(501);
let result = verify_relay_metadata(&frame, 99, &recipient, |_| async {
Err(RelayValidationError::MissingSigningKeys(7))
})
.await;
assert!(matches!(
result,
Err(RelayValidationError::OuterSenderNotAllowed)
));
Ok(())
}
#[tokio::test]
async fn verification_does_not_commit_replay_state() -> Result<(), String> {
let (signer, recipient, frame) = relay("duplicate")?;
let trusted_key = signer.public_key_bundle();
for _ in 0..2 {
let trusted_key = trusted_key.clone();
let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move {
Ok(vec![trusted_key])
})
.await;
let _ = result.map_err(|error| error.to_string())?;
}
Ok(())
}
#[test]
fn forwarding_preserves_sealed_payload() -> Result<(), String> {
let (_signer, _recipient, frame) = relay("forwarding")?;
let forwarded = forward_verified_relay(&frame, RouteTarget::User(100))
.map_err(|error| error.to_string())?;
assert_eq!(frame.sender(), None);
assert_eq!(forwarded.sender(), None);
assert_eq!(forwarded.receiver(), RouteTarget::User(100).wire_id());
assert_eq!(frame.payload(), forwarded.payload());
Ok(())
}
}