feat(wallet): bind prepared transaction quotes to originating chat session#2708
feat(wallet): bind prepared transaction quotes to originating chat session#2708oxoxDev wants to merge 4 commits into
Conversation
Adds private QuoteOwner type and a current_owner() helper that reads the APPROVAL_CHAT_CONTEXT task-local set by the web chat channel around the agent tool loop. No behavior change yet — the helper is unused; the next commits wire it into the prepared-quote lifecycle so a quote prepared in one chat thread cannot be executed from another.
Add a private `owner: Option<QuoteOwner>` field to PreparedTransaction
(serialised only when set, so the no-context wire shape stays stable) and
populate it from `current_owner()` in all three prepare entry points —
`prepare_transfer`, `prepare_swap`, `prepare_contract_call`.
Chain-test literal constructions in `wallet/chains/{btc,solana,tron}.rs`
get `owner: None` to keep their fixtures compiling (these are direct
struct literals in the same crate, not external constructors).
Pure data plumbing in this commit — execute_prepared still ignores the
owner field. The gate is wired in the next commit.
…ote_for Replaces `take_quote(id)` with `take_quote_for(id, caller_owner)`. Read the caller's chat-thread owner from APPROVAL_CHAT_CONTEXT once at execute_prepared entry, then require the stored quote's owner to match. Mismatch returns the exact same "quote '…' not found" string the missing-id branch returns, so a co-channel attacker who has scraped a quote_id from the prompt broadcast cannot distinguish wrong-owner from no-such-quote — no enumeration oracle. Owner check runs *before* removal, so a mismatched caller cannot poison the store by consuming someone else's quote. The existing retry-restore path already does `quote.clone()` so the owner survives broadcast failure and retry naturally. Callers with no chat context (CLI, direct JSON-RPC, background turns) can only execute quotes also prepared with no chat context — intentional, since those flows have no shared channel from which a quote_id could leak. Tests added in the next commit.
…hape invariant Five new tests pinning down the prepare/execute owner gate: 1. `execute_prepared_rejects_cross_owner_execution` — Alice prepares, Bob tries to execute → error returned, quote remains in the store so Alice can still execute. Mismatched callers can't poison the store. 2. `execute_prepared_allows_same_owner_execution` — Alice prepares + Alice executes inside the same APPROVAL_CHAT_CONTEXT scope → past the owner gate (chain code may error later for unrelated mock reasons, but the failure is asserted *not* to be the not-found oracle). 3. `execute_prepared_allows_no_context_flows` — prepare + execute outside any scope → success. Keeps CLI / direct JSON-RPC usable. 4. `execute_prepared_rejects_chat_quote_from_no_context_caller` — prepare under owner A, execute with no scope → reject. Prevents privilege-drop into background / triage / cron flows that wouldn't surface UI confirmation. 5. `execute_prepared_owner_mismatch_error_matches_not_found_shape` — explicit byte-equality assertion locking the no-enumeration-oracle invariant. Regressions here would re-open the leak. Owner-stamped fixtures are built via insert_owned_quote() to avoid needing the full wallet-setup + mock-RPC stack for gate-only assertions.
📝 WalkthroughWalkthroughChat-scoped quote ownership prevents cross-thread quote reuse. PreparedTransaction now carries optional owner identity derived from chat context at prepare time; quote consumption gates removal to matching owners, returning not-found on mismatch to prevent quote-store poisoning and hijacking. ChangesQuote Owner Gating
Sequence DiagramsequenceDiagram
participant Caller
participant PrepareEndpoint
participant CurrentOwner
participant QuoteStore
participant ExecutePrepared
Caller->>PrepareEndpoint: prepare_transfer(params)
PrepareEndpoint->>CurrentOwner: current_owner()
CurrentOwner-->>PrepareEndpoint: Some(QuoteOwner{thread_id, client_id})
PrepareEndpoint->>QuoteStore: store quote with owner
QuoteStore-->>PrepareEndpoint: quote_id
PrepareEndpoint-->>Caller: PreparedTransaction{quote_id, owner: Some(...)}
Caller->>ExecutePrepared: execute_prepared(quote_id, ...)
ExecutePrepared->>CurrentOwner: current_owner()
CurrentOwner-->>ExecutePrepared: Some(QuoteOwner{same_thread, same_client})
ExecutePrepared->>QuoteStore: take_quote_for(quote_id, caller_owner)
QuoteStore-->>ExecutePrepared: quote (owner matches)
ExecutePrepared-->>Caller: execution result
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly Related PRs
Suggested Labels
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/openhuman/wallet/execution.rs (1)
1289-1316: ⚡ Quick winAdd one owner-gating test that goes through a real
prepare_*path.These tests inject
ownerwithinsert_owned_quote(...), so they never verify thatcurrent_owner()is actually stamped during prepare. If task-local propagation regresses, the gate tests still stay green while chat-scoped quotes quietly fall back toowner: None. Please cover at least oneprepare_* -> execute_preparedflow insideAPPROVAL_CHAT_CONTEXT.scope(...).Also applies to: 1339-1484
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/openhuman/wallet/execution.rs` around lines 1289 - 1316, Add a test that exercises a real prepare -> execute_prepared flow inside APPROVAL_CHAT_CONTEXT.scope(...) instead of injecting owner via insert_owned_quote, so the owner is stamped by task-local context; specifically, create a test that calls one of the real prepare_* functions (e.g., the prepare for the transfer path used in your suite) within APPROVAL_CHAT_CONTEXT.scope(...) and then calls execute_prepared (or execute_prepared_transaction) on the produced PreparedTransaction and assert that PreparedTransaction.owner (or current_owner() on the executed result) is Some(expected_owner); this ensures task-local propagation is exercised instead of bypassing prepare_* with insert_owned_quote.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/openhuman/wallet/execution.rs`:
- Around line 128-135: PreparedTransaction.owner (type QuoteOwner) currently
gets serialized when Some(...) and leaks chat thread/client IDs; update the
field to never be included in RPC responses by annotating it to be skipped by
Serde (e.g., replace or add #[serde(skip)] / #[serde(skip_serializing)] on the
owner field in the PreparedTransaction struct), leaving internal usages
(prepare_transfer/prepare_swap/prepare_contract_call and
execute_prepared/ExecutionResult.transaction) unchanged so the field remains
available in-process but is never emitted over the wire; reference the
PreparedTransaction.owner, QuoteOwner, current_owner(),
prepare_transfer/prepare_swap/prepare_contract_call and execute_prepared symbols
when making the change.
---
Nitpick comments:
In `@src/openhuman/wallet/execution.rs`:
- Around line 1289-1316: Add a test that exercises a real prepare ->
execute_prepared flow inside APPROVAL_CHAT_CONTEXT.scope(...) instead of
injecting owner via insert_owned_quote, so the owner is stamped by task-local
context; specifically, create a test that calls one of the real prepare_*
functions (e.g., the prepare for the transfer path used in your suite) within
APPROVAL_CHAT_CONTEXT.scope(...) and then calls execute_prepared (or
execute_prepared_transaction) on the produced PreparedTransaction and assert
that PreparedTransaction.owner (or current_owner() on the executed result) is
Some(expected_owner); this ensures task-local propagation is exercised instead
of bypassing prepare_* with insert_owned_quote.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 6c547791-77d7-4c9c-b267-4088301c7853
📒 Files selected for processing (4)
src/openhuman/wallet/chains/btc.rssrc/openhuman/wallet/chains/solana.rssrc/openhuman/wallet/chains/tron.rssrc/openhuman/wallet/execution.rs
| /// Chat-thread owner stamped at prepare time. Present when the quote | ||
| /// was prepared from inside an interactive chat turn (web channel sets | ||
| /// `APPROVAL_CHAT_CONTEXT`); `None` for CLI / direct-RPC / background | ||
| /// callers. Serialised only when set so the wire shape stays stable | ||
| /// for the no-context case. Used by `execute_prepared` to gate against | ||
| /// cross-thread execution of leaked `quote_id`s. | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| pub(crate) owner: Option<QuoteOwner>, |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Rust serde derive, are pub(crate)or private struct fields serialized by default unless#[serde(skip)]or#[serde(skip_serializing)] is used?
💡 Result:
Yes, in Rust, when you use the Serde derive macro on a struct, all fields within that struct are serialized by default, regardless of their visibility (including private or pub(crate) fields) [1][2]. The derive macro has access to all fields defined within the same module scope, allowing it to generate the necessary code to include them in the serialization process [3][4]. If you wish to exclude a field from serialization, you must explicitly use the #[serde(skip)] or #[serde(skip_serializing)] attribute [2][5]. A key exception to this behavior involves serializing structs from external crates ("remote" types) [6][7]. If a struct from another crate contains private fields, Serde's derive macro cannot access those fields directly [6][7]. In such cases, you cannot simply derive Serialize on the original type; instead, you must use the #[serde(remote = "...")] feature, which requires you to provide a definition of the type with explicit getters for each private field [6][7].
Citations:
- 1: https://github.com/serde-rs/serde/blob/4e54aaf7963c3580cc50b56842949b0ce6b3a997/serde_derive/src/ser.rs
- 2: https://dev.to/mihir_mohapatra/rust-concepts-serde-error-handling-benchmarking-workspaces-part-6-1l75
- 3: https://dev.to/andreimerlescu/rust-decorators-for-go-developers-4k7
- 4: https://serde.rs/impl-serialize.html
- 5: https://serde.rs/field-attrs.html
- 6: https://serde.rs/remote-derive.html
- 7: https://github.com/serde-rs/serde-rs.github.io/blob/master/_src/remote-derive.md
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate relevant struct/field definitions and serialization attributes
rg -n "struct PreparedTransaction|PreparedTransaction\s*\{" src/openhuman/wallet/execution.rs
rg -n "owner:\s*Option<QuoteOwner>|skip_serializing_if|skip_serializing" src/openhuman/wallet/execution.rs
rg -n "struct QuoteOwner|derive\(Serialize\)|thread|client" src/openhuman/wallet/execution.rs
# Find where ExecutionResult is serialized / returned (RPC boundary hints)
rg -n "struct ExecutionResult|ExecutionResult\s*\{" src/openhuman/wallet/execution.rs
rg -n "return\s+ExecutionResult|ExecutionResult\s*\{" src/openhuman/wallet/execution.rs
rg -n "execute_prepared|prepare_transfer|prepare_swap|prepare_contract_call|take_quote_for|current_owner\(" src/openhuman/wallet/execution.rs
# Check if PreparedTransaction is used in outward/public types (serde derives on RPC result types)
rg -n "Serialize.*PreparedTransaction|PreparedTransaction.*Serialize|serde\(.*PreparedTransaction" src/openhuman/wallet/execution.rs src/openhuman/**/*.rsRepository: tinyhumansai/openhuman
Length of output: 6193
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file="src/openhuman/wallet/execution.rs"
sed -n '90,170p' "$file" # owner field + context
sed -n '200,280p' "$file" # QuoteOwner + current_owner
sed -n '104,180p' "$file" # PreparedTransaction header
sed -n '180,270p' "$file" # more around PreparedTransaction/QuoteOwner boundary
sed -n '330,460p' "$file" # take_quote_for + caller_owner types + store_quote maybe
sed -n '1360,1520p' "$file" # tests about execute_prepared owner mismatch and no context
sed -n '600,900p' "$file" # prepare_transfer/swap/contract_call and execute_prepared logic
rg -n "pub\s+struct\s+ExecutionResult|derive\(.*Serialize.*\)" "$file"Repository: tinyhumansai/openhuman
Length of output: 34856
Do not expose PreparedTransaction.owner (chat thread/client IDs) in RPC responses.
PreparedTransaction is returned from prepare_transfer/prepare_swap/prepare_contract_call and is embedded in ExecutionResult.transaction for execute_prepared; owner is only suppressed when None (#[serde(skip_serializing_if = "Option::is_none")]). When current_owner() returns Some, Serde will serialize QuoteOwner (including thread_id/client_id, as threadId/clientId), leaking internal chat identifiers over the wire.
🔒 Suggested fix
- #[serde(skip_serializing_if = "Option::is_none")]
+ #[serde(skip_serializing)]
pub(crate) owner: Option<QuoteOwner>,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /// Chat-thread owner stamped at prepare time. Present when the quote | |
| /// was prepared from inside an interactive chat turn (web channel sets | |
| /// `APPROVAL_CHAT_CONTEXT`); `None` for CLI / direct-RPC / background | |
| /// callers. Serialised only when set so the wire shape stays stable | |
| /// for the no-context case. Used by `execute_prepared` to gate against | |
| /// cross-thread execution of leaked `quote_id`s. | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub(crate) owner: Option<QuoteOwner>, | |
| /// Chat-thread owner stamped at prepare time. Present when the quote | |
| /// was prepared from inside an interactive chat turn (web channel sets | |
| /// `APPROVAL_CHAT_CONTEXT`); `None` for CLI / direct-RPC / background | |
| /// callers. Serialised only when set so the wire shape stays stable | |
| /// for the no-context case. Used by `execute_prepared` to gate against | |
| /// cross-thread execution of leaked `quote_id`s. | |
| #[serde(skip_serializing)] | |
| pub(crate) owner: Option<QuoteOwner>, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/openhuman/wallet/execution.rs` around lines 128 - 135,
PreparedTransaction.owner (type QuoteOwner) currently gets serialized when
Some(...) and leaks chat thread/client IDs; update the field to never be
included in RPC responses by annotating it to be skipped by Serde (e.g., replace
or add #[serde(skip)] / #[serde(skip_serializing)] on the owner field in the
PreparedTransaction struct), leaving internal usages
(prepare_transfer/prepare_swap/prepare_contract_call and
execute_prepared/ExecutionResult.transaction) unchanged so the field remains
available in-process but is never emitted over the wire; reference the
PreparedTransaction.owner, QuoteOwner, current_owner(),
prepare_transfer/prepare_swap/prepare_contract_call and execute_prepared symbols
when making the change.
graycyrus
left a comment
There was a problem hiding this comment.
@oxoxDev nice security work here — the two-step owner-binding approach is clean, the error-shape invariant (mismatch returns byte-equal "not found") is properly locked down in tests, and the None == None round-trip for non-chat callers is exactly right.
CI is failing on Rust Core Tests and Frontend Unit Tests, so I'm holding off on a full sign-off until those are green. That said, a couple things to address while you're at it:
CodeRabbit flagged the main one: the #[serde(skip_serializing_if = "Option::is_none")] annotation on PreparedTransaction.owner leaks thread_id/client_id as threadId/clientId in any RPC response where a chat context is present. That field should be #[serde(skip_serializing)] — internal gate data, never wire-visible.
One thing they didn't catch: QuoteOwner itself derives Serialize with #[serde(rename_all = "camelCase")]. After the field annotation is fixed, that derive is dead weight — and a latent trap. Any future struct that embeds QuoteOwner without an explicit skip on the field would re-expose session identifiers without warning. Since QuoteOwner is purely an internal gate type with no serialization use case, drop Serialize from its derive list entirely.
Fix the CI, apply CodeRabbit's suggestion, drop Serialize from QuoteOwner, and this is good to go.
|
@oxoxDev unresolved review feedback — please address before we review. |
|
Unresolved review feedback from coderabbitai[bot] — please address before we review. |
Summary
PreparedTransactionatwallet_prepare_*time.wallet_execute_preparedso the caller's session must match the prepared quote's stamped owner."quote '<id>' not found"error verbatim (no enumeration oracle).Problem
#2331 hardened the channel inbound path so two distinct senders in the same shared channel no longer collapse into one cached agent session. The wallet broadcast path was not aware of that session boundary:
QUOTE_STOREis process-global keyed only byquote_id, andexecute_preparedonly checksconfirmed: true.In a shared multi-member channel where the agent's confirmation prompt is broadcast back to every channel member, a co-channel observer can read the prepared
quote_idand then pass it intowallet_execute_preparedfrom their own (now session-isolated) agent run, triggering the original sender's prepared transaction.Solution
QuoteOwner { thread_id, client_id }derived from theAPPROVAL_CHAT_CONTEXTtask-local that already scopes the agent tool loop (channels/providers/web.rs::run_chat_task).Option<QuoteOwner>onto eachPreparedTransactionat prepare time —prepare_transfer,prepare_swap,prepare_contract_call.execute_preparedreadscurrent_owner()once on entry and gates retrieval throughtake_quote_for(id, caller_owner). Owner mismatch returns the same not-found error string the genuine not-found branch returns, so error-string diffing cannot enumerate other sessions' quote ids.Option<QuoteOwner>preserves single-session flows:None == Noneround-trips successfully so CLI, direct JSON-RPC, single-DM Telegram, single-user Discord channels are unchanged.A
// SAFETY:doc oncurrent_owner()flags reliance on the inline.awaitchain inrun_chat_task. A future refactor that detaches the wallet handler onto a freshly spawned task would landcaller_owner = Noneagainst aSome(owner)quote → reject. The safe default.Submission Checklist
wallet/execution.rs:execute_prepared_rejects_cross_owner_executionexecute_prepared_allows_same_owner_executionexecute_prepared_allows_no_context_flowsexecute_prepared_rejects_chat_quote_from_no_context_callerexecute_prepared_owner_mismatch_error_matches_not_found_shapecargo fmt --all -- --checkcleancargo check --libcleancargo clippy --lib— zero new warnings on changed linescargo test --lib openhuman::wallet::execution— 18 passed / 0 failed (13 pre-existing + 5 new)Impact
owner = None(noAPPROVAL_CHAT_CONTEXT), so the equality check isNone == Noneand the existing flow keeps working.owneris#[serde(skip_serializing_if = "Option::is_none")]. LegacyNonequotes serialize byte-identically to today; only quotes prepared inside a chat scope add the new field.owner.thread_idderives from the sameAPPROVAL_CHAT_CONTEXT.thread_idthatderive_inbound_thread_idalready special-cases viachannel_is_telegram. No separate plumbing.Related
send_channel_reply) — privacy-only residual after the execution path is gated; consider sensitive-reply DM redirect on multi-member shared channels.current_owner()from a task-local read to explicit-argument plumbing so a future refactor cannot silently no-op the gate by detaching the wallet handler onto a fresh task.AI Authored PR Metadata
Summary by CodeRabbit