Skip to content

fix(send): hoist messageContextInfo to outer in DeviceSentMessage (WA Web parity) - #696

Merged
jlucaso1 merged 2 commits into
mainfrom
fix/dsm-context-hoist
Jun 3, 2026
Merged

fix(send): hoist messageContextInfo to outer in DeviceSentMessage (WA Web parity)#696
jlucaso1 merged 2 commits into
mainfrom
fix/dsm-context-hoist

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

When a sent DM is synced to our own other devices, the message is wrapped in a DeviceSentMessage. We were placing messageContextInfo — which carries the messageSecret used to decrypt later add-ons (reactions, edits, poll votes) against the original message — on the inner wrapped message, leaving the outer message empty.

WhatsApp Web does the opposite. WAWebDeviceSentMessageProtoUtils.wrapDeviceSentMessage (used by MsgCreateDeviceStanza on the own-device path) hoists messageContextInfo onto the outer message and clears it from the inner copy:

return {
  messageContextInfo: { ...rootContext },                         // outer gets the context
  deviceSentMessage: { destinationJid, message: { ...e, messageContextInfo: void 0 } } // inner cleared
};

Our receive path already mirrors WA Web's unwrapDeviceSentMessage: unwrap_device_sent + merge_dsm_context read the secret inner-first and fall back to the outer message. So our reader already assumed the WA Web shape, while our writer never produced it — an internal inconsistency, and a wire-format divergence from WA Web on the own-device sync stanza.

Fix

Add wrap_device_sent (the inverse of the existing unwrap_device_sent) in wacore::messages, which hoists message_context_info onto the outer message and clears the inner copy, matching WA Web. prepare_dm_stanza now builds the own-device DSM through it.

The recipient and group paths are unchanged: the recipient message keeps its messageContextInfo inline, exactly as WA Web sends it. Only the own-device DSM envelope changes shape.

Tests

New unit tests in wacore (messages::device_sent_tests):

  • wrap_hoists_context_to_outer_on_wire — encodes then decodes the wrapped message and asserts the secret lands on the outer message and the inner copy carries no messageContextInfo.
  • wrap_without_context_leaves_outer_empty — no context in, no context hoisted.
  • wrap_then_unwrap_round_trips_secretwrap_device_sentunwrap_device_sent recovers the original content and the secret.
cargo fmt --all
cargo clippy --all-targets -- -D warnings   # clean
cargo test -p wacore                         # 861 pass (incl. 3 new)
cargo test -p whatsapp-rust --lib -- message send   # 280 pass

Breaking

None. prepare_dm_stanza's signature is unchanged; wrap_device_sent is a new additive helper. The DSM wire shape changes to match WA Web; our own reader already handled it (and still does, via the round-trip test).

… Web parity)

When syncing a sent DM to our own other devices we wrap it in a
DeviceSentMessage. We were placing `messageContextInfo` (which carries the
`messageSecret` used to decrypt later add-ons — reactions/edits/polls) on the
INNER wrapped message and leaving the outer message empty.

WhatsApp Web's `WAWebDeviceSentMessageProtoUtils.wrapDeviceSentMessage` (used by
`MsgCreateDeviceStanza`) does the opposite: it hoists `messageContextInfo` onto
the OUTER message and clears it from the inner copy. Our receive path
(`unwrap_device_sent` + `merge_dsm_context`) already mirrors WA Web's
`unwrapDeviceSentMessage` (inner-first, outer fallback), so our reader assumed
the WA Web shape while our writer never produced it.

Add `wrap_device_sent` (inverse of `unwrap_device_sent`) that hoists the context
to the outer message and clears the inner copy; `prepare_dm_stanza` builds the
own-device DSM through it. The recipient and group paths are unchanged (the
recipient message keeps its context inline, exactly as WA Web sends it).
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 99bb29e8-0455-4d72-b68c-305f885de287

📥 Commits

Reviewing files that changed from the base of the PR and between 0d8931d and 845b114.

📒 Files selected for processing (2)
  • wacore/src/messages.rs
  • wacore/src/proto_helpers.rs

📝 Walkthrough

Summary by CodeRabbit

  • Refactor

    • Centralized device-sent message wrapping and updated DM sending flow to use the shared wrapper.
  • Bug Fixes

    • Preserve message context fields (including add-on duration and secrets) when wrapping/unwrapping device-sent messages and when merging contexts.
  • Tests

    • Added tests covering wrapping behavior, context hoisting, and round-trip preservation of secrets and metadata.

Walkthrough

Adds wrap_device_sent to wrap a wa::Message into a device_sent_message wrapper while hoisting message_context_info to the outer message. Updates DM stanza preparation to call this helper and fixes merge_dsm_context to preserve full outer context when inner is absent; tests added.

Changes

Device-sent Message Wrapper

Layer / File(s) Summary
wrap_device_sent function and comprehensive test coverage
wacore/src/messages.rs
Adds pub fn wrap_device_sent(mut message: wa::Message, destination_jid: String) -> wa::Message that hoists message_context_info from inner to outer and leaves inner context unset. Includes device_sent_tests validating hoisting, missing-context behavior, and round-trip with unwrap_device_sent.
DM stanza preparation integration
wacore/src/send.rs
Replaces an inline DeviceSentMessage construction in prepare_dm_stanza with crate::messages::wrap_device_sent(...), removing the direct DeviceSentMessage usage in this flow.
merge_dsm_context hoist-preserve fix and test
wacore/src/proto_helpers.rs
Change (None, Some(outer)) branch to Some(outer.clone()) to preserve non-subset fields hoisted onto the outer context; add unit test asserting preservation of message_add_on_duration_in_secs.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

api-design

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title directly matches the core fix: hoisting messageContextInfo to outer in DeviceSentMessage for WA Web parity, which is precisely what the changeset implements.
Description check ✅ Passed The description comprehensively explains the problem, solution, tests, and breaking changes, all directly aligned with the actual code changes across three modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dsm-context-hoist

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d8931d74f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/src/messages.rs
Comment on lines +104 to +106
let context = message.message_context_info.take();
wa::Message {
message_context_info: context,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-secret DSM context fields

When an own-device DM already carries MessageContextInfo fields other than the WA-Web-hoisted subset, moving the entire context to the outer DSM drops those fields after receive: unwrap_device_sent delegates to merge_dsm_context, whose outer-only path copies only message_secret, association, limit sharing, thread IDs, and bot metadata. For example, src/send.rs builds pin/unpin messages with message_add_on_duration_in_secs; after this wrapper change, our other devices will unwrap the DSM without that duration, whereas the previous inner-context shape preserved it. Either keep unsupported fields on the inner copy or extend the DSM merge to round-trip the fields this sender can place in MessageContextInfo.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

Benchmark Results

67 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 2,838 2,838 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,272 8,272 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 31,317 31,317 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 13,827 13,827 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 49,398 49,398 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 54,827 54,827 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 1,592 1,592 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 4,219 4,219 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 112,823 112,830 -0.0%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,121 1,656,128 -0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 650,668 650,434 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 876,035 876,216 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,081,938 2,081,667 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 747,680 747,057 +0.1%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,331,091 1,330,829 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,378,605 4,393,654 -0.3%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 515,392 519,024 -0.7%
binary_benchmark::marshal_group::bench_marshal_allocating 45,401 45,401 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 45,451 45,451 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 66,354 66,354 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 43,512 43,512 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 45,507 45,507 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 4,930 4,930 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 4,961 4,961 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,732 6,732 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,529 528,529 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 528,150 528,150 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,396 529,396 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 5,417,786 5,417,786 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 5,362,043 5,362,043 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 13,276,336 13,276,336 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 1,850 1,850 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 29,217 29,217 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,890 672,890 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 3,736 3,736 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 3,840 3,840 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 48,283 48,283 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,866 3,866 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 48,344 48,344 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 5,206 5,206 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 66,668 66,668 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 310,312 310,312 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,286 8,286 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 254 254 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 91 91 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 292 292 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 137 137 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 317 317 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 145 145 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 27,425 27,425 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 10,725 10,725 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 4,139,359 4,137,447 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,133 100,131 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 4,263,702 4,263,702 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 99,803 99,803 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 210,262 210,262 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 496,921 496,897 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 509,486 507,681 +0.4%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,978,892 11,977,164 +0.0%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 2,466,138 2,466,138 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 4,873,192 4,937,922 -1.3%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,397 2,043,397 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,950 37,950 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 3,616,076 3,616,076 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 223,150 223,150 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 9,980,959 9,980,959 +0.0%
No significant changes detected.

The hoist in `wrap_device_sent` moves the whole `message_context_info` onto the
outer DSM message and clears the inner copy. The receive path's outer-only merge
branch (`merge_dsm_context`, inner None) rebuilt only the subset it explicitly
merges (message_secret, association, limit_sharing_v2, thread_id, bot_metadata),
dropping every other field — e.g. the `message_add_on_duration_in_secs` that pin
messages place in MessageContextInfo. Our other devices would then unwrap a pin
without its duration. The previous inner-context shape happened to preserve it.

Restore the full hoisted context on the outer-only unwrap branch (the sender
moved the entire context there, so we move all of it back). The wire shape on
send is unchanged (still WA Web's hoist-to-outer + clear-inner).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant