From fbe15bebd53959644520525774e28848f3db3b5f Mon Sep 17 00:00:00 2001 From: G-ELM Date: Thu, 22 Oct 2026 07:56:10 +0100 Subject: [PATCH 1/4] docs(contracts): add proposals storage layout and treasury client guide Co-Authored-By: Claude Sonnet 5.5 --- contracts/docs/contracts-proposals-storage.md | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 contracts/docs/contracts-proposals-storage.md diff --git a/contracts/docs/contracts-proposals-storage.md b/contracts/docs/contracts-proposals-storage.md new file mode 100644 index 0000000..e5a7232 --- /dev/null +++ b/contracts/docs/contracts-proposals-storage.md @@ -0,0 +1,268 @@ +# `proposals` Storage Layout & Treasury Client + +Source: [`contracts/contracts/proposals/src/storage.rs`](../contracts/proposals/src/storage.rs), +[`contracts/contracts/proposals/src/treasury_interface.rs`](../contracts/proposals/src/treasury_interface.rs), +[`contracts/contracts/proposals/src/treasury_interface_client.rs`](../contracts/proposals/src/treasury_interface_client.rs), +[`contracts/contracts/proposals/src/lib.rs`](../contracts/proposals/src/lib.rs). + +For function signatures see [`api-proposals.md`](api-proposals.md); for the lifecycle narrative +see [`concepts-proposal-lifecycle.md`](concepts-proposal-lifecycle.md). For how this contract's +withdrawal path relates to the treasury's own multisig, see +[`concepts-cross-contract-interfaces.md`](concepts-cross-contract-interfaces.md). The sibling +document for the treasury is [`contracts-group-treasury-storage.md`](contracts-group-treasury-storage.md). + +## Storage tier: everything is instance storage + +Every read and write in `proposals` goes through `env.storage().instance()`. The contract never +calls `.persistent()`, `.temporary()`, `.extend_ttl()` or `bump()` (verified across `lib.rs` and +`storage.rs`). + +**TTL / bump behaviour:** + +- All keys below share the **single TTL of the contract's instance entry**. There is no per-key + TTL, no TTL constant, and nothing in this contract extends the TTL — not on `create_proposal`, + not on `vote`, not on any read. +- Keeping the instance alive is therefore a platform/operator concern (an out-of-band + `extend` / restore of the contract instance). If the instance entry archives, **every** key + (admin, id counter, all proposals, all votes) archives together and calls fail until it is + restored. See [`concepts-state-archival.md`](concepts-state-archival.md). +- Because votes and proposals live in instance storage, the instance entry grows with each + proposal and each vote. Large numbers of voters make the single entry bigger and every + invocation costlier; see [`concepts-resource-budget.md`](concepts-resource-budget.md). +- `initialize` cannot be used to revive state: it panics (`"already initialized"`) if + `DataKey::Admin` is present. + +## `DataKey` — storage key inventory + +```rust +#[contracttype] +pub enum DataKey { + Admin, + NextProposalId, + Proposal(u64), + Vote(u64, Address), // (proposal_id, voter) -> bool (true = yes, false = no) +} +``` + +| Key | Value type | Tier | Meaning | Lifecycle | +|---|---|---|---|---| +| `DataKey::Admin` | `Address` | instance | Reserved admin slot. No function currently reads it for authorization. | Set once by `initialize` (requires the admin's auth). Never updated. Its presence is the "already initialized" guard. | +| `DataKey::NextProposalId` | `u64` | instance | Id that will be assigned to the next proposal; equals the count of proposals ever created. | Set to `0` by `initialize`. Read and incremented by `create_proposal` (ids are `0`-based, monotonically increasing, never reused). | +| `DataKey::Proposal(u64)` | `Proposal` | instance | Full record for proposal `id`. | Created by `create_proposal`. Rewritten whole by `vote`, `finalize_proposal`, `finalize_expired_proposal`, `execute_proposal` and `execute_withdraw`. Read by every function through `load_proposal`, which panics with `"proposal not found"` for unknown ids. Never deleted. | +| `DataKey::Vote(u64, Address)` | `bool` | instance | The vote `voter` cast on proposal `proposal_id`: `true` = yes, `false` = no. | Written once by `vote`. Its mere existence (`has`) is the duplicate-vote guard (`"voter has already voted"`). Never changed or cleared. | + +There is no index of voters per proposal and no list of proposals: the only way to enumerate is +to walk ids `0..NextProposalId` and look up `Vote(id, address)` for a known address. Only the +yes/no *tallies* are kept on the `Proposal` itself. + +## `Proposal` + +```rust +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + pub id: u64, + pub proposer: Address, + pub description: String, + pub created_at: u64, + pub expires_at: u64, + pub yes_votes: u32, + pub no_votes: u32, + pub status: ProposalStatus, + + // Withdrawal execution parameters. + pub treasury: Address, + pub token: Address, + pub to: Address, + pub amount: i128, +} +``` + +| Field | Type | Set by | Notes | +|---|---|---|---| +| `id` | `u64` | `create_proposal` | Copy of the `NextProposalId` value at creation. | +| `proposer` | `Address` | `create_proposal` | Must authorize the creation call. Not checked against treasury membership. | +| `description` | `String` | `create_proposal` | Free text; stored as given. | +| `created_at` | `u64` | `create_proposal` | Ledger timestamp (unix seconds) at creation. | +| `expires_at` | `u64` | `create_proposal` | Unix seconds; must be strictly greater than the creation time. Voting is allowed while `now < expires_at`. | +| `yes_votes` | `u32` | `vote` | Incremented per `support = true` vote. | +| `no_votes` | `u32` | `vote` | Incremented per `support = false` vote. | +| `status` | `ProposalStatus` | all state-changing calls | See below. Starts as `Active`. | +| `treasury` | `Address` | `create_proposal` | `group_treasury` contract to withdraw from on execution. | +| `token` | `Address` | `create_proposal` | Token contract to withdraw. | +| `to` | `Address` | `create_proposal` | Withdrawal recipient. | +| `amount` | `i128` | `create_proposal` | Must be `> 0`. | + +The four withdrawal parameters are fixed at creation. They are never updated, and they are what +`execute_withdraw` hands to the treasury client. + +## `ProposalStatus` + +```rust +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProposalStatus { + Active, + Approved, + Passed, + Rejected, + Executed, + Expired, +} +``` + +| Value | Meaning | Reached by | +|---|---|---| +| `Active` | Open for voting; the initial state. | `create_proposal` | +| `Passed` | Voting closed with `yes_votes > no_votes`. The only state from which execution is allowed. | `finalize_proposal` | +| `Rejected` | Voting closed with `yes_votes <= no_votes` (ties reject). Terminal. | `finalize_proposal` | +| `Expired` | Voting window passed and the proposal was closed without a tally. Terminal. | `finalize_expired_proposal` | +| `Executed` | Execution has run. Terminal. | `execute_proposal` or `execute_withdraw` | +| `Approved` | **Declared but never assigned.** No function in `lib.rs` writes it. | — (see note) | + +> **Note on `Approved`.** The doc comment on `execute_withdraw` says the proposal "must be +> Approved", but the code checks for `Passed`, and nothing ever sets `Approved`. Treat +> `Approved` as a reserved, currently unreachable value. Clients should not wait for it. + +### Transitions + +``` + finalize_proposal execute_proposal + (now >= expires_at, or execute_withdraw + yes > no) (status == Passed) + create_proposal ┌────────────────────▶ Passed ──────────────────────────▶ Executed + ─────────────────▶ Active + │ finalize_proposal (now >= expires_at, yes <= no) + ├────────────────────▶ Rejected + │ finalize_expired_proposal (now > expires_at) + └────────────────────▶ Expired +``` + +| From | To | Function | Guard | +|---|---|---|---| +| (none) | `Active` | `create_proposal` | `expires_at > now`, `amount > 0`, proposer auth | +| `Active` | `Passed` | `finalize_proposal` | `now >= expires_at` and `yes_votes > no_votes` | +| `Active` | `Rejected` | `finalize_proposal` | `now >= expires_at` and `yes_votes <= no_votes` | +| `Active` | `Expired` | `finalize_expired_proposal` | `now > expires_at` | +| `Passed` | `Executed` | `execute_proposal` | executor auth; status must be `Passed` | +| `Passed` | `Executed` | `execute_withdraw` | caller auth; treasury membership, balance, and successful treasury `withdraw` | + +Points worth knowing: + +- Both `finalize_proposal` and `finalize_expired_proposal` accept only `Active` proposals. For + any `now > expires_at` **both** are valid, and whichever is called first decides the outcome + (`Passed`/`Rejected` vs `Expired`). At exactly `now == expires_at` only `finalize_proposal` + works. Both are callable by anyone and require no auth. +- `vote` is only valid while `status == Active` **and** `now < expires_at`. +- `Rejected`, `Expired` and `Executed` have no outgoing transitions. +- `execute_proposal` only flips the status and emits an event; it moves no funds. Only + `execute_withdraw` calls the treasury. +- The status check in `execute_withdraw` panics `"proposal already executed"` for `Executed` + and `"proposal not approved"` for anything other than `Passed`. Because the status is only + written after the treasury call succeeds, and a panic reverts the whole invocation, a failed + withdrawal leaves the proposal `Passed` and retryable. + +## Vote tracking + +1. `vote(voter, proposal_id, support)` calls `voter.require_auth()` and loads the proposal. +2. It requires `Active` status and `now < expires_at`. +3. It builds `DataKey::Vote(proposal_id, voter)` and panics if that key already exists. +4. It stores `support` (`bool`) under that key, bumps `yes_votes` or `no_votes` on the + `Proposal`, and writes the proposal back. +5. It emits `vote_cast`. + +The `Vote` entry is the source of truth for "has this address voted" and for *how* it voted. +The counters on `Proposal` are the source of truth for the tally used by `finalize_proposal`. +Note that **`vote` does not check treasury membership** — any authorized address can vote, and +each address counts once. Membership is only checked at execution time, for the `caller` of +`execute_withdraw`. + +## Event payload structs + +Also defined in `storage.rs` (not stored, only published): + +| Struct | Fields | Published as | By | +|---|---|---|---| +| `ProposalCreatedEvent` | `id`, `proposer`, `expires_at`, `treasury`, `token`, `to`, `amount` | `proposal_created` | `create_proposal` | +| `VoteCastEvent` | `id`, `voter`, `support` | `vote_cast` | `vote` | +| `ProposalFinalizedEvent` | `id`, `status`, `yes_votes`, `no_votes` | `proposal_finalized` | `finalize_proposal` | +| `ProposalExpiredEvent` | `id` | `proposal_expired` | `finalize_expired_proposal` | +| `ProposalExecutedEvent` | `id`, `executor` | `executed` (`execute_proposal`) / `execut` (`execute_withdraw`) | execution | + +The two execution paths use different topic symbols (`executed` vs `execut`); see +[`contracts-events.md`](contracts-events.md) for the full event catalogue. + +## Typed cross-contract client for `group_treasury` + +### What is in the source + +- [`treasury_interface.rs`](../contracts/proposals/src/treasury_interface.rs) is the real + mechanism. It declares a trait annotated with `#[contractclient(name = "TreasuryClient")]`: + + ```rust + #[contractclient(name = "TreasuryClient")] + pub trait TreasuryInterface { + fn is_member(env: Env, member: Address) -> bool; + fn balance(env: Env, token: Address) -> i128; + fn withdraw(env: Env, to: Address, token: Address, amount: i128); + } + ``` + + The `soroban-sdk` macro generates a `TreasuryClient` struct from it. The trait is never + implemented; it exists only to describe the callee's function names and signatures. +- [`treasury_interface_client.rs`](../contracts/proposals/src/treasury_interface_client.rs) is + **an empty placeholder** whose entire content is the comment + `// Intentionally left empty - this file was generated automatically.` It is not declared as a + module in `lib.rs` (`lib.rs` has `mod treasury_interface;` only), so it is not compiled and + nothing is imported from it. The client lives in the macro expansion of + `treasury_interface.rs`, not in this file. + +The interface is hand-maintained: it is a minimal subset of `group_treasury`'s public functions +(`is_member`, `balance`, `withdraw`) and is **not** checked against the real contract at compile +time. If `group_treasury` renames or re-types one of these functions, `proposals` still +compiles and the mismatch only surfaces at runtime as a failed invocation. + +### How the contract ID is resolved and invoked + +There is no registry or lookup. The target contract is whatever `Address` is stored in +`Proposal.treasury`, supplied by the proposer at `create_proposal`. In `execute_withdraw`: + +1. `proposal.treasury` is loaded from instance storage. +2. `TreasuryClient::new(&env, &proposal.treasury)` wraps that address. Constructing a client + does no I/O and does not check that the address is a deployed treasury. +3. Each method call (`is_member`, `balance`, `withdraw`) compiles to + `env.invoke_contract(&address, &Symbol::new(""), args)`. The client converts the + Rust arguments to `Val`s, invokes the function by name on that contract, and converts the + return value back (e.g. to `bool` or `i128`). The call is synchronous and runs inside the + caller's transaction. +4. If the callee panics, errors, or the address is not a contract exporting that function, the + panic propagates up the host stack and aborts the entire `execute_withdraw` invocation. All + state changes revert, including the status update, and no event is emitted. + +The call sequence in `execute_withdraw(caller, proposal_id)`: + +| Step | Call | Check | +|---|---|---| +| 1 | `caller.require_auth()` | Caller authorized the `proposals` call | +| 2 | local | Status is `Passed` (`Executed` panics `"proposal already executed"`) | +| 3 | `TreasuryClient::is_member(caller)` | else `"caller is not a treasury member"` | +| 4 | `TreasuryClient::balance(token)` | `>= amount`, else `"insufficient funds"` | +| 5 | `TreasuryClient::withdraw(to, token, amount)` | Treasury moves funds | +| 6 | local | Status set to `Executed`, stored | +| 7 | event | `execut` / `ProposalExecutedEvent` | + +### Authorization of the nested `withdraw` call + +`group_treasury::withdraw` is admin-gated: it calls `admin.require_auth()` on the treasury's +stored admin. In step 5 that is a **non-root** invocation (the root is `proposals`' +`execute_withdraw`). The treasury admin's authorization must therefore be present for that +sub-invocation, for example by having the admin sign an authorization entry covering +`treasury.withdraw(to, token, amount)`, or by making the admin a contract that authorizes the +call. The tests use `env.mock_all_auths_allowing_non_root_auth()` for exactly this reason (see +the comment in [`proposals/src/test.rs`](../contracts/proposals/src/test.rs)). Holding a `Passed` +proposal is not by itself sufficient to move treasury funds. + +The `Proposal.treasury` address is also not validated against any allow-list, so a proposal can +point at any contract. The membership and balance checks protect against a *real* treasury being +drained, not against a malicious one; voters should verify the `treasury` field before voting. +See [`security-checklist.md`](security-checklist.md). From f1326ffc52888580a3db75c0cf211aee89f29a03 Mon Sep 17 00:00:00 2001 From: G-ELM Date: Thu, 22 Oct 2026 07:57:12 +0100 Subject: [PATCH 2/4] docs(web): add app routing and conversation UI architecture guide Co-Authored-By: Claude Sonnet 5.5 --- apps/web/docs/concepts-app-routing.md | 167 ++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 apps/web/docs/concepts-app-routing.md diff --git a/apps/web/docs/concepts-app-routing.md b/apps/web/docs/concepts-app-routing.md new file mode 100644 index 0000000..bb753d2 --- /dev/null +++ b/apps/web/docs/concepts-app-routing.md @@ -0,0 +1,167 @@ +# App Routing & Conversation UI Architecture + +Source: [`src/app/`](../src/app/), in particular +[`layout.tsx`](../src/app/layout.tsx), [`page.tsx`](../src/app/page.tsx), +[`app/layout.tsx`](../src/app/app/layout.tsx) and +[`app/conversations/[id]/page.tsx`](../src/app/app/conversations/%5Bid%5D/page.tsx). + +This describes the routing structure after the route-consolidation work (commit `f6a40ef`, +"retire duplicate conversation route and legacy chat demo"): there is now a single conversation +route, `/app/conversations/[id]`. For auth state see +[`concepts-auth-device-lifecycle.md`](concepts-auth-device-lifecycle.md); for the message +pipeline behind the conversation view see +[`concepts-message-pipeline.md`](concepts-message-pipeline.md) and +[`concepts-e2ee-architecture.md`](concepts-e2ee-architecture.md). + +## Next.js App Router in one paragraph + +The web app uses the App Router (`src/app/`). Each folder containing a `page.tsx` is a URL +segment; a `layout.tsx` wraps every page beneath it and persists across navigation between those +pages; a folder named `[id]` is a dynamic segment read with `useParams()`. Nearly every page here +is a client component (`'use client'`) because it depends on wallet, auth token or socket state. + +## Route tree + +``` +src/app/ +├── layout.tsx Root layout (all routes) +├── page.tsx / Landing page +├── globals.css, favicon.ico +├── providers.tsx (not mounted — see "Leftovers") +├── routes/conversations.js (not a route — see "Leftovers") +└── app/ /app/* Authenticated shell + ├── layout.tsx Sidebar, wallet button, push prompt + ├── page.tsx /app Demo messages view + ├── messages/page.tsx /app/messages Re-exports /app page + ├── conversations/[id]/page.tsx /app/conversations/:id Real conversation view + ├── devices/page.tsx /app/devices + ├── profile/page.tsx /app/profile + ├── proposals/page.tsx /app/proposals + ├── search/page.tsx /app/search + └── treasury/page.tsx /app/treasury +``` + +### Root layout and landing page + +- [`app/layout.tsx`](../src/app/layout.tsx) sets metadata and fonts and nests the global + providers, outermost first: `WalletContext` → `AuthProvider` → `ToastProvider`. Every route, + including `/app/*`, can therefore call `useWallet()`, `useAuth()` and the toast API. +- [`app/page.tsx`](../src/app/page.tsx) (`/`) is the marketing page, wrapped in + ``: a visitor who already has a token is redirected to + `/app`. + +### The `/app` shell + +[`app/app/layout.tsx`](../src/app/app/layout.tsx) is the shell shared by every route below. It +renders a fixed left sidebar (nav links for Messages, Treasury, Proposals and Devices, plus a +Connect/Disconnect wallet button using `useWallet()`) and puts the active page in `
`. It +also mounts `PushPermissionPrompt` and listens for `sw:sync` messages from the service worker +(see [`concepts-service-worker.md`](concepts-service-worker.md)): a notification click navigates +to `/app/conversations/:id`, or to `/app/messages` if no conversation id was sent. + +The shell does **not** wrap its children in `ProtectedRoute mode="authenticated"`. Pages that +need a token handle a missing one themselves (for instance the conversation page shows "Sign in +to view this conversation."). + +### Top-level routes under `app/app/` + +| Route | Purpose | +|---|---| +| `/app` | Default landing inside the shell; currently a **demo** chat view with hard-coded messages and a local search panel, not wired to the backend. The sidebar highlights "Messages" for this path. | +| `/app/messages` | Alias of `/app` (`export { default } from '../page'`), and the sidebar's "Messages" target. | +| `/app/conversations/[id]` | The real, end-to-end encrypted conversation view for one direct or group conversation. Detailed below. | +| `/app/devices` | Lists the signed-in user's registered devices and lets them be managed/revoked, via `apiFetch` and modals. | +| `/app/profile` | Shows and edits the signed-in user's profile and linked wallets, via `apiFetch`. | +| `/app/proposals` | Community funding proposals list with voting UI; currently driven by local sample state. | +| `/app/search` | Full-page local message search (`MessageSearch` over the on-device index) with an indexed-message count. | +| `/app/treasury` | Group treasury view: fetches withdraw proposals, shows `ProposalCard`s, and opens `ProposeWithdrawalModal`; updates live through `useSocket`. See [`components-treasury-wallet.md`](components-treasury-wallet.md). | + +`/app/profile` is routed but has no sidebar entry; it is reached by direct link. + +## Conversation view (`/app/conversations/[id]`) + +`ConversationPage` is a single client component (~900 lines) that owns data loading, send +handlers, and rendering. It does **not** delegate to the components in `components/messaging/` +(`MessageThread`, `InboundMessageRow`) or `components/chat/` (`ConversationHeader`, +`NewConversationModal`), nor to `ConversationListSidebar`; none of those is imported by any +route. Orient yourself in the page file by these parts. + +### Hooks and libraries it pulls together + +| Concern | What it uses | +|---|---| +| Route param | `useParams<{ id }>()` gives the conversation id. | +| Auth | `useAuth()` supplies `token` and `loading`; no token → error state. | +| Realtime | `useSocket(token)`; on mount emits `join_room` via `emitSocketEnvelope`. | +| Incoming messages | `useInboundPipeline({ socket, token, conversationId })` receives, decrypts and orders envelopes, returning `messages` with `decrypted` / `unavailable` status. | +| Sending text | `sendEncryptedMessage` from `lib/crypto`, then `socket.emit('send_message')`. | +| Sending files | `fetchConversationDevices`, `generateEncryptedThumbnail`, `sendEncryptedFile`, then `socket.emit('send_file_message')`; download via `downloadAndDecryptFile`. | +| Key-change reset | `clearSessionKeys()` and `sessionStore.clear()` when a key-change socket event arrives. | +| Presentation | `Avatar`, `EmptyState`, `TransferCard`, `EncryptedThumbnail`, `UnavailableMessagePlaceholder`. | + +### Data flow + +1. **Initial load.** An effect (waits for auth) fetches in parallel `GET /users/me`, + `GET /conversations/:id` and `GET /conversations/:id/messages` into `currentUser`, + `conversation` and `messages`. +2. **Merge.** The `allMessages` memo overlays pipeline results on the REST history by message + id: a decrypted inbound entry supplies `content` (and a parsed `filePayload` for + `file`/`image`/`video` types), an `unavailable` one marks the row undecryptable, and inbound + messages not yet in REST history are appended. The result is sorted by `createdAt`. +3. **Derive.** `contacts` (members other than the current user), `title` and + `hasChangedSafetyNumber` are computed from state. + +### Layout and the message list + +The component renders a flex row of two panes: + +- **Main column (`
`)**: a header (avatar, title, member count or primary wallet, and the + safety-numbers toggle button), an optional amber "Safety number changed" banner, the scrolling + **message list**, and the **input bar**. +- **Safety panel (`