Skip to content

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

Open
oxoxDev wants to merge 4 commits into
tinyhumansai:mainfrom
oxoxDev:feat/wallet-quote-owner-binding
Open

feat(wallet): bind prepared transaction quotes to originating chat session#2708
oxoxDev wants to merge 4 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
    • Enhanced quote validation to prevent unauthorized usage: Quotes are now session-specific and can only be executed by the user who originally requested them, preventing accidental or malicious misuse across different contexts.

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

📝 Walkthrough

Walkthrough

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

Changes

Quote Owner Gating

Layer / File(s) Summary
Quote owner types and context reading
src/openhuman/wallet/execution.rs
PreparedTransaction struct adds optional owner: Option<QuoteOwner> field. New QuoteOwner type captures thread and client identity; current_owner() derives it from APPROVAL_CHAT_CONTEXT task-local, returning None for non-chat callers.
Owner-gated quote consumption
src/openhuman/wallet/execution.rs
take_quote_for implementation gates quote removal: only succeeds when caller's owner matches prepare-time owner; mismatch returns identical "quote not found" error shape as missing quotes. execute_prepared now consumes quotes via take_quote_for(quote_id, caller) binding execution to caller identity.
Owner stamping at prepare time
src/openhuman/wallet/execution.rs
prepare_transfer, prepare_swap, and prepare_contract_call each stamp the created PreparedTransaction with owner: current_owner() to associate the quote with the preparing caller's thread and client context.
Owner gating test coverage
src/openhuman/wallet/execution.rs
Quote-store round-trip test updated to include owner: None and consume via take_quote_for. New async tests cover cross-owner rejection, same-owner gate pass, no-context execution allowance, chat-quote rejection from no-context callers, and error-shape invariant between owner-mismatch and genuine not-found paths.
Chain-specific test fixture updates
src/openhuman/wallet/chains/btc.rs, src/openhuman/wallet/chains/solana.rs, src/openhuman/wallet/chains/tron.rs
BTC, Solana, and Tron wallet execution tests updated to include owner: None when constructing PreparedTransaction struct literals in test fixtures, matching the updated 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: 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
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly Related PRs

  • tinyhumansai/openhuman#2519: Both PRs modify quote handling in execute_prepared and PreparedTransaction struct; #2519 reworks multi-chain execution while this PR adds owner-gated quote consumption to the same area.

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 makes quotes align,
Safe trades bloom where contexts twine! 🌿

🚥 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 concisely describes the main change: binding prepared transaction quotes to their originating chat session to prevent cross-session hijacking.
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 on lines +128 to +135
/// 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>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 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:


🏁 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/**/*.rs

Repository: 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.

Suggested change
/// 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.

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.

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.

2 participants