Skip to content

feat(wallet): bind prepared transaction quotes to originating chat session#2708

Merged
M3gA-Mind merged 6 commits into
tinyhumansai:mainfrom
oxoxDev:feat/wallet-quote-owner-binding
May 28, 2026
Merged

feat(wallet): bind prepared transaction quotes to originating chat session#2708
M3gA-Mind merged 6 commits into
tinyhumansai:mainfrom
oxoxDev:feat/wallet-quote-owner-binding

Conversation

@oxoxDev
Copy link
Copy Markdown
Contributor

@oxoxDev oxoxDev commented May 26, 2026

Summary

  • Stamp the originating chat session onto every PreparedTransaction at wallet_prepare_* time.
  • Gate wallet_execute_prepared so the caller's session must match the prepared quote's stamped owner.
  • Owner mismatch returns the existing "quote '<id>' not found" error verbatim (no enumeration oracle).
  • Defense-in-depth follow-up to feat: tighten runtime policy + transport guards #2331.

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_STORE is process-global keyed only by quote_id, and execute_prepared only checks confirmed: 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_id and then pass it into wallet_execute_prepared from their own (now session-isolated) agent run, triggering the original sender's prepared transaction.

Solution

  1. Introduce a private QuoteOwner { thread_id, client_id } derived from the APPROVAL_CHAT_CONTEXT task-local that already scopes the agent tool loop (channels/providers/web.rs::run_chat_task).
  2. Stamp Option<QuoteOwner> onto each PreparedTransaction at prepare time — prepare_transfer, prepare_swap, prepare_contract_call.
  3. execute_prepared reads current_owner() once on entry and gates retrieval through take_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.
  4. Option<QuoteOwner> preserves single-session flows: None == None round-trips successfully so CLI, direct JSON-RPC, single-DM Telegram, single-user Discord channels are unchanged.

A // SAFETY: doc on current_owner() flags reliance on the inline .await chain in run_chat_task. A future refactor that detaches the wallet handler onto a freshly spawned task would land caller_owner = None against a Some(owner) quote → reject. The safe default.

Submission Checklist

  • Added unit tests covering the new gate semantics — 5 new cases in wallet/execution.rs:
    • execute_prepared_rejects_cross_owner_execution
    • execute_prepared_allows_same_owner_execution
    • execute_prepared_allows_no_context_flows
    • execute_prepared_rejects_chat_quote_from_no_context_caller
    • execute_prepared_owner_mismatch_error_matches_not_found_shape
  • cargo fmt --all -- --check clean
  • cargo check --lib clean
  • cargo clippy --lib — zero new warnings on changed lines
  • cargo test --lib openhuman::wallet::execution — 18 passed / 0 failed (13 pre-existing + 5 new)
  • N/A: i18n strings — no UI surface touched
  • N/A: frontend typecheck / lint / vitest — no TS surface touched
  • N/A: docs / changelog — internal defense-in-depth, no observable behavior change for legitimate single-session callers

Impact

  • Legitimate single-session callers (unchanged): CLI, direct JSON-RPC, single-DM Telegram, single-user Discord channels. Both prepare and execute run with owner = None (no APPROVAL_CHAT_CONTEXT), so the equality check is None == None and the existing flow keeps working.
  • Shared multi-member channels (new behavior): a quote prepared by user A in their agent session cannot be executed from user B's session in the same channel, even if user B observed the broadcast confirmation prompt.
  • Wire-compat: owner is #[serde(skip_serializing_if = "Option::is_none")]. Legacy None quotes serialize byte-identically to today; only quotes prepared inside a chat scope add the new field.
  • Telegram carve-out preserved: owner.thread_id derives from the same APPROVAL_CHAT_CONTEXT.thread_id that derive_inbound_thread_id already special-cases via channel_is_telegram. No separate plumbing.

Related

  • Builds on feat: tighten runtime policy + transport guards #2331 (channel inbound per-sender session isolation).
  • Follow-up hardening identified during this work (out of scope here, may file as a separate issue):
    • Confirmation-prompt broadcast to shared channels is still channel-wide (send_channel_reply) — privacy-only residual after the execution path is gated; consider sensitive-reply DM redirect on multi-member shared channels.
    • Convert 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

  • AI authored: yes — implementation produced by a Claude-driven worktree-dev agent following a plan derived via sequential-thinking MCP.
  • Human review: all four commits manually reviewed; tests run locally.

Summary by CodeRabbit

  • Bug Fixes

    • Quotes are now bound to the session that created them, preventing cross-session execution or enumeration and reducing accidental/malicious reuse.
  • Tests

    • Added and updated tests to validate session-scoped quote behavior, same-session allowance, cross-session rejection, and no-session flows.

Review Change Stack

oxoxDev added 4 commits May 26, 2026 23:30
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.
@oxoxDev oxoxDev requested a review from a team May 26, 2026 18:37
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 71a182a7-9aa5-4291-8598-8fbb4bf4b2c9

📥 Commits

Reviewing files that changed from the base of the PR and between a8d9a54 and c63ac3f.

📒 Files selected for processing (1)
  • src/openhuman/wallet/execution.rs
💤 Files with no reviewable changes (1)
  • src/openhuman/wallet/execution.rs

📝 Walkthrough

Walkthrough

PreparedTransaction gains an optional owner derived from task-local chat context. Quotes are stamped with that owner at prepare time and consumed only via take_quote_for when the caller's owner matches; mismatches return the same "quote '' not found" error. Tests and chain fixtures updated accordingly.

Changes

Quote Owner Gating

Layer / File(s) Summary
PreparedTransaction owner field and context reading
src/openhuman/wallet/execution.rs
Adds owner: Option<QuoteOwner> to PreparedTransaction. Introduces QuoteOwner and current_owner() which reads identity from task-local APPROVAL_CHAT_CONTEXT.
Owner-gated quote consumption
src/openhuman/wallet/execution.rs
Implements take_quote_for and updates consumption semantics so quote removal only occurs when caller owner matches prepare-time owner; owner mismatch yields identical "quote '' not found" error shape.
Owner stamping at prepare time
src/openhuman/wallet/execution.rs
prepare_transfer, prepare_swap, and prepare_contract_call stamp prepared quotes with owner: current_owner().
Execute prepared uses owner gate
src/openhuman/wallet/execution.rs
execute_prepared now computes caller = current_owner() and consumes quotes with take_quote_for(&params.quote_id, caller).
Test utilities and owner-gating tests
src/openhuman/wallet/execution.rs
Adds helpers to insert owner-stamped quotes and create chat contexts; new tests for cross-owner rejection, same-owner pass, no-context flows, chat-quote rejection from no-context callers, invariant error shape, and verifies prepare stamps owner.
Chain-specific test fixture updates
src/openhuman/wallet/chains/btc.rs, src/openhuman/wallet/chains/solana.rs, src/openhuman/wallet/chains/tron.rs
Updated tests include owner: None in PreparedTransaction fixtures to match the new struct shape.

Sequence Diagram

sequenceDiagram
  participant Caller
  participant PrepareEndpoint
  participant CurrentOwner
  participant QuoteStore
  participant ExecutePrepared

  Caller->>PrepareEndpoint: prepare_transfer(params)
  PrepareEndpoint->>CurrentOwner: current_owner()
  CurrentOwner-->>PrepareEndpoint: Option<QuoteOwner>
  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: Option<QuoteOwner>
  ExecutePrepared->>QuoteStore: take_quote_for(quote_id, caller_owner)
  QuoteStore-->>ExecutePrepared: quote (owner matches) / not found
  ExecutePrepared-->>Caller: execution result / error
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly Related PRs

  • tinyhumansai/openhuman#2519: Modifies quote handling and execute_prepared flow; directly related to quote consumption and execution path changes.

Suggested Labels

feature

🐰 A rabbit hops through threads with care,
Each quote now knows whose hands it'll share,
No thief can steal across the line—
The owner's seal keeps quotes benign,
Safe trades bloom where contexts pair. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main change: binding prepared transaction quotes to the originating chat session with ownership tracking.
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.


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

@coderabbitai coderabbitai Bot added the feature Net-new user-facing capability or product behavior. label May 26, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/openhuman/wallet/execution.rs (1)

1289-1316: ⚡ Quick win

Add one owner-gating test that goes through a real prepare_* path.

These tests inject owner with insert_owned_quote(...), so they never verify that current_owner() is actually stamped during prepare. If task-local propagation regresses, the gate tests still stay green while chat-scoped quotes quietly fall back to owner: None. Please cover at least one prepare_* -> execute_prepared flow inside APPROVAL_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

📥 Commits

Reviewing files that changed from the base of the PR and between 87f8ef4 and a8d9a54.

📒 Files selected for processing (4)
  • src/openhuman/wallet/chains/btc.rs
  • src/openhuman/wallet/chains/solana.rs
  • src/openhuman/wallet/chains/tron.rs
  • src/openhuman/wallet/execution.rs

Comment thread src/openhuman/wallet/execution.rs
Copy link
Copy Markdown
Contributor

@graycyrus graycyrus left a comment

Choose a reason for hiding this comment

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

@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.

@graycyrus
Copy link
Copy Markdown
Contributor

@oxoxDev unresolved review feedback — please address before we review.

@graycyrus
Copy link
Copy Markdown
Contributor

Unresolved review feedback from coderabbitai[bot] — please address before we review.

@senamakel senamakel self-assigned this May 28, 2026
…e-path integration test

- Change `PreparedTransaction.owner` from `skip_serializing_if = "Option::is_none"`
  to `skip_serializing` so chat thread/client IDs are never leaked in RPC
  responses (addresses @coderabbitai on execution.rs:135 and @graycyrus review).
- Drop `Serialize` + `serde(rename_all)` from `QuoteOwner` — purely internal
  gate type with no serialization use case (addresses @graycyrus review).
- Add `prepare_stamps_owner_via_task_local` integration test that exercises a
  real `prepare_transfer` inside `APPROVAL_CHAT_CONTEXT.scope(...)` to verify
  task-local propagation (addresses @coderabbitai nitpick on lines 1289-1316).
@oxoxDev
Copy link
Copy Markdown
Contributor Author

oxoxDev commented May 28, 2026

@graycyrus — CodeRabbit's serde concern on the owner field is resolved by c63ac3f (which Steven pushed directly): #[serde(skip_serializing)] instead of skip_serializing_if = Option::is_none, and the Serialize derive on QuoteOwner itself was dropped since it's internal gate data only. That commit also adds a prepare_stamps_owner_via_task_local integration test that exercises a real prepare_transfer inside an APPROVAL_CHAT_CONTEXT.scope(...). CI is 26 pass / 4 skip / 0 fail. Ready for re-review when you have a slot.

@oxoxDev oxoxDev requested a review from graycyrus May 28, 2026 05:52
Copy link
Copy Markdown
Contributor

@graycyrus graycyrus left a comment

Choose a reason for hiding this comment

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

Continuation review — two new commits since the last pass.

c63ac3f addresses both the CodeRabbit thread and my earlier note: #[serde(skip_serializing)] replaces the conditional skip, and Serialize / rename_all are gone from QuoteOwner entirely. Session identifiers no longer reach the wire under any code path. The new prepare_stamps_owner_via_task_local test exercises the full task-local propagation end-to-end, which is the right level of confidence for that // SAFETY: invariant.

CI is green across all 30 checks. Code is clean. Approving.

Copy link
Copy Markdown
Contributor

@M3gA-Mind M3gA-Mind left a comment

Choose a reason for hiding this comment

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

PR #2708 — feat(wallet): bind prepared transaction quotes to originating chat session

Walkthrough

Defense-in-depth follow-up to #2331. After per-sender session isolation landed, the QUOTE_STORE remained keyed only by quote_id with no ownership gate. Since the prepared-tx summary is broadcast to the full channel, a co-member could read the quote_id and call wallet_execute_prepared from their own (now isolated) agent session to trigger the original sender's transaction. This PR closes that by stamping a QuoteOwner {thread_id, client_id} on every quote at prepare time, then enforcing equality at execute time — with the mismatch error deliberately identical to the genuine not-found error to avoid an enumeration oracle.

Changes

File Summary
src/openhuman/wallet/execution.rs QuoteOwner struct + current_owner() task-local reader; owner field on PreparedTransaction; take_quotetake_quote_for; three prepare_* fns stamped; 6 new tests
src/openhuman/wallet/chains/btc.rs Test struct literals updated with owner: None
src/openhuman/wallet/chains/solana.rs Test struct literals updated with owner: None
src/openhuman/wallet/chains/tron.rs Test struct literals updated with owner: None

Actionable comments (0)

None — the implementation is correct across all axes.

Verified / looks good

  • No TOCTOU in take_quote_for: the mutex is held for the full function — position lookup, owner check, and store.remove are atomic with respect to other callers. ✓
  • Store not poisoned on mismatch: owner check runs before store.remove(pos), so a rejected caller cannot consume someone else's quote. Explicitly commented and covered by execute_prepared_rejects_cross_owner_execution (which asserts the quote is still present after rejection). ✓
  • Error indistinguishability: both genuine not-found and owner-mismatch use the same not_found() closure → byte-equal strings. Locked by execute_prepared_owner_mismatch_error_matches_not_found_shape. ✓
  • #[serde(skip_serializing)] is load-bearing: QuoteOwner does not derive Serialize, so without this attribute the PreparedTransaction derive would fail to compile. The attribute is not merely documenting intent — it is required. ✓
  • Owner preserved through restore path: restorable = quote.clone() carries the owner field, so a quote restored after a chain-layer failure (store_quote(refreshed)) retains the original owner and remains executable only by its original session. ✓
  • None == None round-trip: CLI / direct-RPC callers have no APPROVAL_CHAT_CONTEXT, so current_owner() returns None on both prepare and execute. Option::PartialEq makes None == None pass the gate. Covered by execute_prepared_allows_no_context_flows. ✓
  • Privilege-drop blocked: a context-scoped quote (Some(owner)) cannot be executed by a no-context caller (None). Covered by execute_prepared_rejects_chat_quote_from_no_context_caller. ✓
  • Task-local wiring tested end-to-end: prepare_stamps_owner_via_task_local goes through the full prepare_transfer code path inside an APPROVAL_CHAT_CONTEXT scope and asserts the stamped owner matches. ✓
  • All 25 CI checks pass, including Rust coverage gate (≥ 80% on changed lines). ✓
  • No merge conflicts with current main. ✓

@M3gA-Mind M3gA-Mind merged commit 4657d16 into tinyhumansai:main May 28, 2026
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Net-new user-facing capability or product behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants