From 7c53921748c51ef0858faf1402249b267e46adf0 Mon Sep 17 00:00:00 2001 From: Depo-dev <24318346+Depo-dev@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:54:46 +0100 Subject: [PATCH] docs: add token transfer flow, testnet treasury tutorial, rest endpoint how-to and crypto module map (#636 #634 #617 #624) --- apps/backend/docs/howto-add-rest-endpoint.md | 329 +++++++++++++++++ apps/web/docs/crypto-module-map.md | 124 +++++++ contracts/docs/tutorial-testnet-treasury.md | 362 +++++++++++++++++++ docs/flows/token-transfer.md | 162 +++++++++ 4 files changed, 977 insertions(+) create mode 100644 apps/backend/docs/howto-add-rest-endpoint.md create mode 100644 apps/web/docs/crypto-module-map.md create mode 100644 contracts/docs/tutorial-testnet-treasury.md create mode 100644 docs/flows/token-transfer.md diff --git a/apps/backend/docs/howto-add-rest-endpoint.md b/apps/backend/docs/howto-add-rest-endpoint.md new file mode 100644 index 0000000..0e4d5ab --- /dev/null +++ b/apps/backend/docs/howto-add-rest-endpoint.md @@ -0,0 +1,329 @@ +# How to add a REST endpoint + +The conventions a new route is expected to follow, with one worked example from schema to test. Every pattern below is lifted from a route that already exists; where the codebase is inconsistent, this page says which behaviour to copy. + +## Checklist + +A new route is done when it has all of these: + +1. A router file under `src/routes/`, exporting a typed `Router`. +2. A mount line in `src/app.ts`. **Without this the route does not exist** (see [the cautionary example](#cautionary-example-auditlogsts)). +3. `requireAuth` unless the endpoint is deliberately public. +4. A Zod schema in `src/schemas/`, passed to `validate()`, for any request body. +5. A rate limiter, with its bucket declared in `src/config/rateLimits.ts`. +6. A `recordAuditEvent` call when the action is security-relevant. +7. Errors in the standard `{ error }` shape. +8. Tests in `src/__tests__/` that mock Redis and Postgres. +9. A row in the REST docs (`docs/api-*.md`, and `docs/contracts-error-catalog.md` for any new error string). + +## Worked example: rename a device + +`PUT /device-labels/:deviceId` lets a signed-in user rename one of their own devices. It is small, authenticated, takes a body, and is mildly security-relevant: an attacker with a stolen session could rename their own device to look harmless in the device list. + +This is an illustrative example, not a shipped endpoint. It uses real columns (`devices.deviceName`) and real helpers. + +### 1. Schema: `src/schemas/deviceLabel.schemas.ts` + +```ts +import { z } from 'zod'; + +export const RenameDeviceSchema = z.object({ + deviceName: z + .string() + .trim() + .min(1, 'deviceName is required') + .max(100, 'deviceName must be at most 100 characters'), +}); +``` + +Mirror the style of `DeviceSchema` in `src/schemas/auth.schemas.ts`: one message per rule, so the `issues[]` array tells the client exactly what to fix. + +`validate()` (`src/middleware/validate.ts`) parses **`req.body` only**. It replaces `req.body` with the parsed value, so handlers read the cleaned data. Route params and query strings are not validated by it; check them in the handler (see step 2). + +### 2. Router: `src/routes/deviceLabels.ts` + +```ts +import { Router, type Router as RouterType } from 'express'; +import { and, eq, isNull } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { devices } from '../db/schema.js'; +import { requireAuth, type AuthRequest } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { RenameDeviceSchema } from '../schemas/deviceLabel.schemas.js'; +import { actorFromRequest, recordAuditEvent } from '../services/auditLog.js'; + +export const deviceLabelsRouter: RouterType = Router(); + +deviceLabelsRouter.use(requireAuth); + +deviceLabelsRouter.put( + '/:deviceId', + rateLimit('device_rename'), + validate(RenameDeviceSchema), + async (req: AuthRequest, res) => { + const deviceId = req.params['deviceId'] as string; + const callerId = req.auth!.userId; + const { deviceName } = req.body as { deviceName: string }; + + try { + const device = await db.query.devices.findFirst({ + where: and( + eq(devices.id, deviceId), + eq(devices.userId, callerId), + isNull(devices.revokedAt), + ), + }); + + if (!device) { + res.status(404).json({ error: 'Device not found' }); + return; + } + + await db.update(devices).set({ deviceName }).where(eq(devices.id, deviceId)); + + void recordAuditEvent({ + action: 'device_renamed', + ...actorFromRequest(req), + targetType: 'device', + targetId: deviceId, + metadata: { previousName: device.deviceName, newName: deviceName }, + }); + + res.json({ id: deviceId, deviceName }); + } catch { + res.status(500).json({ error: 'Failed to rename device' }); + } + }, +); +``` + +Points that are conventions, not accidents: + +- **`requireAuth` once, at the top of the router** (`router.use`), so a route added later cannot forget it. `auditLogs.ts` and `files.ts` do the same. +- **Middleware order is auth, then rate limit, then validation.** `rateLimit` keys on `user:` once `req.auth` is set and falls back to the IP otherwise (`defaultIdentifier`), so it must run after `requireAuth` for a per-account limit. Validation goes last so a rejected body still costs the caller budget. +- **Scope every query to the caller.** The `userId` condition is the authorization check. A device that belongs to someone else returns the same `404` as one that does not exist, which avoids confirming that an id is real. +- **Wrap the handler body in `try`/`catch`.** There is no global Express error handler, so an uncaught exception returns Express's HTML 500 page instead of JSON. +- **`void recordAuditEvent(...)`** (see [Audit events](#audit-events)). + +### 3. Rate limit bucket: `src/config/rateLimits.ts` + +Add the bucket to `RATE_LIMIT_DEFAULTS`. `RateLimitBucket` is derived from its keys, so `rateLimit('device_rename')` only type-checks once this exists. + +```ts +device_rename: { + limit: 20, + windowSeconds: MINUTE, + description: 'Device label changes', +}, +``` + +Operators can override it with `RATE_LIMIT_DEVICE_RENAME=[/]`; the bucket name is upper-cased to form the variable. Choose generous defaults: limits exist to stop abuse, not to shape normal use. A rejected call returns `429` with `{ error: 'Too many requests', bucket, retryAfterSeconds }` and `RateLimit-*` / `Retry-After` headers. + +### 4. Mount it in `src/app.ts` + +```ts +import { deviceLabelsRouter } from './routes/deviceLabels.js'; +// ... +app.use('/device-labels', deviceLabelsRouter); +``` + +Put the line with the other `app.use('/…', …Router)` mounts, **after** `app.use(rateLimit('global_ip', …))` so the catch-all per-IP ceiling covers it. Do not put it after the `/local-storage` block or the `/metrics` handler. + +### 5. Audit action (only because this example records one) + +`recordAuditEvent` takes an `AuditAction`, which is the Postgres enum `audit_action` (`auditActionEnum` in `src/db/schema.ts`). Add the value and generate a migration: + +```ts +export const auditActionEnum = pgEnum('audit_action', [ + // ...existing values + 'device_renamed', +]); +``` + +Then follow [`migrations.md`](./migrations.md): `pnpm db:generate`, read the SQL, commit schema and generated files together. If the action is not in the enum, TypeScript rejects the call. + +### 6. Test: `src/__tests__/deviceLabels.test.ts` + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +const mockFindFirst = vi.fn(); +const mockWhere = vi.fn().mockResolvedValue(undefined); +const mockSet = vi.fn(() => ({ where: mockWhere })); +const mockUpdate = vi.fn(() => ({ set: mockSet })); +const mockRecordAuditEvent = vi.fn().mockResolvedValue(undefined); + +vi.mock('../db/index.js', () => ({ + db: { query: { devices: { findFirst: mockFindFirst } }, update: mockUpdate }, +})); + +vi.mock('../db/schema.js', () => ({ + devices: { id: 'id', userId: 'userId', revokedAt: 'revokedAt', deviceName: 'deviceName' }, +})); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), + isNull: vi.fn((col: unknown) => ({ isNull: col })), +})); + +vi.mock('../lib/redis.js', () => ({ + get redis() { + return null; + }, +})); + +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { + userId: 'user-1', + deviceId: 'device-1', + }; + next(); + }, +})); + +vi.mock('../services/auditLog.js', () => ({ + recordAuditEvent: mockRecordAuditEvent, + actorFromRequest: () => ({ actorUserId: 'user-1', actorDeviceId: 'device-1' }), +})); + +const { deviceLabelsRouter } = await import('../routes/deviceLabels.js'); +const { clearLocalRateLimitCounters } = await import('../services/rateLimiter.js'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/device-labels', deviceLabelsRouter); + return app; +} + +beforeEach(() => { + vi.clearAllMocks(); + clearLocalRateLimitCounters(); +}); + +describe('PUT /device-labels/:deviceId', () => { + it('renames an owned device and records an audit event', async () => { + mockFindFirst.mockResolvedValue({ id: 'dev-1', deviceName: 'Old' }); + + const res = await request(makeApp()).put('/device-labels/dev-1').send({ deviceName: 'Laptop' }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: 'dev-1', deviceName: 'Laptop' }); + expect(mockSet).toHaveBeenCalledWith({ deviceName: 'Laptop' }); + expect(mockRecordAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'device_renamed', + targetId: 'dev-1', + metadata: { previousName: 'Old', newName: 'Laptop' }, + }), + ); + }); + + it('rejects an empty name with the validation shape', async () => { + const res = await request(makeApp()).put('/device-labels/dev-1').send({ deviceName: ' ' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + expect(res.body.issues[0]).toEqual({ field: 'deviceName', message: 'deviceName is required' }); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('returns 404 for a device the caller does not own', async () => { + mockFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()).put('/device-labels/other').send({ deviceName: 'X' }); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Device not found' }); + expect(mockRecordAuditEvent).not.toHaveBeenCalled(); + }); +}); +``` + +Add a separate test file or `describe` that mounts the router **without** the `middleware/auth.js` mock and asserts a missing `Authorization` header returns `401`; the testing guide says auth behaviour is tested with the real middleware. + +Run it with `pnpm --filter backend test -- deviceLabels`. + +## Cautionary example: `auditLogs.ts` + +`src/routes/auditLogs.ts` defines `auditLogsRouter` with `GET /` (a user's own security history) and a passing test, `src/__tests__/auditLog.test.ts`. The test builds its own Express app and mounts the router itself: + +```ts +app.use('/audit-logs', auditLogsRouter); +``` + +`src/app.ts` never imports or mounts it, so `GET /audit-logs` returns `404` in a running server and the tests stay green. This is the failure the mount step prevents: a route test that mounts its own router cannot tell you the real app mounts it. + +It also shows two gaps against the checklist above: the route has no rate limiter, and it parses `limit`, `cursor` and `action` by hand instead of through a schema. + +How to avoid repeating it: + +- After adding a router, grep for it: `grep -n "deviceLabelsRouter" apps/backend/src/app.ts` must find an import **and** an `app.use`. +- For anything user-facing, add at least one test that imports `app` from `../app.js` and requests the real path (see `src/__tests__/health.test.ts` for the pattern). + +The unmounted route is already called out in the [error catalog](./contracts-error-catalog.md) (note 2 under "The common shape"). + +## Audit events + +Call `recordAuditEvent` (`src/services/auditLog.ts`) when an action changes who can access an account or what an incident responder would need to reconstruct a compromise. The existing actions are: + +| Action | Recorded when | +| -------------------------------------------- | ------------------------------------------------------ | +| `device_linked`, `device_revoked` | Device lifecycle changes | +| `logout_everywhere` | All sessions invalidated | +| `key_bundle_drained` | Someone fetched a prekey bundle for an account | +| `auth_failed` | A valid credential was replayed after losing authority | +| `file_access_denied` | A user was refused access to a file | +| `group_member_added`, `group_member_removed` | Group membership changed | + +Do not audit ordinary reads, message sends, or anything high volume: an attacker who can trigger an event for free can flood the table. `requireAuth` deliberately audits only a validly signed token whose device is revoked, not malformed or expired tokens, for this reason. + +Usage rules: + +- Spread `...actorFromRequest(req)` for routes behind `requireAuth`. It fills `actorUserId`, `actorDeviceId`, `ipAddress` and `userAgent`. Before authentication, use `requestContext(req)` instead. +- Call it **after** the action succeeds, and write `void recordAuditEvent(...)`. It never throws or rejects: a failed write is logged and swallowed so an audit outage cannot break the security control it observes. +- Set `targetType` / `targetId` to what was acted on, and `subjectUserId` when the event concerns a different account than the actor's (it defaults to the actor). + +### Which metadata is safe + +The log must never contain message content. `sanitizeMetadata` enforces a safety net, but do not rely on it: + +- **Safe:** ids, counts, booleans, enum-like labels, platform names, device names, whether something was a self-action (`selfRevocation`), remaining-device counts. +- **Never record:** plaintext, ciphertext, envelopes, request bodies, tokens, secrets, passwords, signatures, private keys, prekeys. Keys whose lowercased name (ignoring `_` and `-`) contains `ciphertext`, `plaintext`, `content`, `message`, `body`, `text`, `envelope`, `payload`, `token`, `secret`, `password`, `signature`, `privatekey` or `prekey` are replaced with `"[redacted]"`, which also means a legitimate key such as `messageCount` is redacted; pick another name. +- **Limits per row:** 20 keys, strings truncated at 256 characters, arrays at 20 entries, one level of nesting. Never spread `req.body` into `metadata`. + +The rows are append-only, enforced by a database trigger, and are readable by the account through `GET /audit-logs` once that route is mounted. + +## Error responses + +REST errors are `{ "error": string }` with the HTTP status carrying most of the meaning. Extra fields sit beside `error`, never nested under it. + +| Situation | Status | Body | +| --------------------- | ------ | -------------------------------------------------------------------------------- | +| Missing or bad token | `401` | `{ error: 'Missing or invalid Authorization header' }` (from `requireAuth`) | +| Body fails the schema | `400` | `{ error: 'Validation failed', issues: [{ field, message }] }` (from `validate`) | +| Not yours / not found | `404` | `{ error: 'Device not found' }` | +| Conflicts with state | `409` | `{ error: 'Cannot revoke your only active device' }` | +| Over the rate limit | `429` | `{ error: 'Too many requests', bucket, retryAfterSeconds }` | +| Unexpected failure | `500` | `{ error: 'Failed to …' }`, produced by your own `catch` | + +Match on status first and the string second. Any new error string must be added to the [error catalog](./contracts-error-catalog.md) in the same PR so clients can find it. Do not leak internal detail (SQL errors, stack traces) in `error`. + +## Test conventions + +These follow the [backend testing guide](./testing.md), which in turn follows the repo-wide [testing strategy](../../../docs/testing.md). The short version: + +- **Never a real service.** No test starts Postgres, Redis or S3. Mock `../db/index.js`, `../db/schema.js`, `drizzle-orm`, and `../lib/redis.js` (return `null` for the degraded path unless the test is about caching). +- **Import the router after the mocks** with a top-level `await import(...)`, never a static import. +- **Mount only the router under test** in a throwaway Express app, plus one test through `app` to prove the mount. +- **Mock `requireAuth`** to inject `req.auth = { userId, deviceId }` for tests about something else, and use the real middleware for auth tests. +- **Do not mock `validate()`.** Exercise the real `400` path. +- **Mock `../services/auditLog.js`** unless the test is about auditing, and assert on the call, including that forbidden keys are absent from `metadata`. +- **Reset rate-limit state** in `beforeEach` with `clearLocalRateLimitCounters()` (and `resetRateLimitBucket(bucket)` when Redis is real or mocked), or one test's calls count against the next. +- **Any new table or Drizzle operator** the route uses must be added to the `db/schema.js` and `drizzle-orm` mocks, or the failure appears as an import error far from the cause. +- Files live in `src/__tests__/*.test.ts`; lint and format cover tests too (`pnpm --filter backend lint`, `format:check`). diff --git a/apps/web/docs/crypto-module-map.md b/apps/web/docs/crypto-module-map.md new file mode 100644 index 0000000..dce531f --- /dev/null +++ b/apps/web/docs/crypto-module-map.md @@ -0,0 +1,124 @@ +# Client crypto module map + +Which crypto-related modules in `apps/web/src/lib/` and `apps/web/src/lib/crypto/` actually run in the app, and which do not. Statuses were checked by following imports from non-test code (`app/`, `components/`, `contexts/`, `hooks/`) on the `dev` branch. + +## Status legend + +| Status | Meaning | +| -------------------------- | ------------------------------------------------------------------------------------------------ | +| **live** | Reached from non-test application code on the message path or on another shipped path. | +| **stub** | Placeholder or adapter that is not functional on its own (needs configuration or another piece). | +| **implemented, not wired** | Working code with tests, but no non-test code imports it. | + +"Callers" lists non-test importers only. A module that only tests import is listed as "tests only". + +## Module table + +Paths are relative to `apps/web/src/lib/`. + +| Module | Status | Responsibility | Callers | +| --------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `crypto.ts` | **live** | Phase-1 sealed-box encryption (`sealedBoxEncrypt`), `fetchConversationDevices`, `buildEnvelopes`, `sendEncryptedMessage` with the `device_set_mismatch` retry. | `app/app/conversations/[id]/page.tsx` (`sendEncryptedMessage`, `fetchConversationDevices`), `fileEncryption.ts` (`buildEnvelopes`), `session.ts` | +| `identityTrust.ts` | **live** | Pins each peer's identity-key set and raises `IdentityKeyChangedError` when it changes. | `crypto.ts` (`assertDevicesTrusted`), `signalSession.ts` | +| `deviceIdentity.ts` | **live** | Creates the device's Ed25519 identity key and device id in `localStorage` at sign-in. | `contexts/AuthContext.tsx` | +| `fileEncryption.ts` | **live** | Encrypts file bytes and thumbnails, then wraps the payload via `buildEnvelopes`. | `app/app/conversations/[id]/page.tsx`, `thumbnail.ts`, `components/messaging/EncryptedThumbnail.tsx` | +| `cryptoStore.ts` | **live** (search only) | IndexedDB store for an identity key pair (falls back to an in-memory map without IndexedDB). | `search/db.ts` (identity key for the local search index), `prekeyStore.ts` | +| `sessionStore.ts` | **live** (clear only) | `SessionStore` class: ECDH session establishment against fetched prekey bundles. The app only calls `sessionStore.clear()`; nothing ever establishes a session through it. | `app/app/conversations/[id]/page.tsx` (`clear()` on key change) | +| `crypto/processEnvelope.ts` | **live** | Inbound pipeline step: plaintext cache, sender key lookup, decrypt, classify failures as `pre-link` / `verification-failed` / `undecryptable`. | `hooks/useInboundPipeline.ts` | +| `crypto/decrypt.ts` | **live** | Parses the JSON envelope (`{v, iv, ct, sig}`), verifies the optional Ed25519 signature, decrypts with the in-memory AES session key. | `crypto/processEnvelope.ts`, `crypto/messageCrypto.ts` | +| `crypto/messageCrypto.ts` | **live** | `decryptMessageText` wrapper used to feed the local search index. | `hooks/useMessageSearchIndex.ts` | +| `crypto/deviceKeys.ts` | **live** | Fetches and caches a sender device's public key (`GET /user-devices/:id/public-key`). | `crypto/processEnvelope.ts` | +| `crypto/plaintextCache.ts` | **live** | In-memory `messageId` to plaintext cache, cleared on `beforeunload`. | `crypto/processEnvelope.ts` | +| `crypto/sessionStore.ts` | **live** (read side) | In-memory map of AES keys by `senderDeviceId`. `getSessionKey` is read on every inbound decrypt; `clearSessionKeys` runs on key change. **No non-test code calls `setSessionKey` or `importSessionKey`**, so the map is always empty in the running app. | `crypto/decrypt.ts`, `app/app/conversations/[id]/page.tsx` (`clearSessionKeys`) | +| `crypto/types.ts` | **live** | Shared types and the `DecryptError` / `PreLinkError` / `VerificationFailedError` classes. | `crypto/*`, `hooks/useInboundPipeline.ts`, `components/messaging/*` | +| `session.ts` | **implemented, not wired** | `SessionCrypto` interface, `Phase1SessionCrypto`, `LibsignalSessionCrypto`, and the `defaultSession` export. Nothing imports `defaultSession`; `crypto.ts` calls its own `buildEnvelopes` directly. | none | +| `signalClient.ts` | **stub** | `SignalClient` facade for `LibsignalSessionCrypto`. Rejects with "Not configured" until `configureSignalClient` is called, and nothing calls it. | `session.ts` (dynamic import) | +| `signalSession.ts` | **implemented, not wired** | Per-device sessions built on X3DH and a symmetric chain (not libsignal yet). | `signalClient.ts` | +| `x3dh.ts` | **implemented, not wired** | X3DH initiator/responder key agreement on `@noble/curves`. | `signalSession.ts`, `signalClient.ts` (type and `toBase64` only) | +| `prekeyStore.ts` | **implemented, not wired** | Generates, signs, stores and uploads signed and one-time prekeys. | `sessionStore.ts` (consumes one-time prekeys) | +| `crypto/doubleRatchet.ts` | **implemented, not wired** | Pure Double Ratchet primitives with skipped-message-key support (`MAX_SKIP = 100`, `MAX_SKIPPED_KEYS = 1000`). | tests only | +| `crypto/ratchetSession.ts` | **implemented, not wired** | IndexedDB-backed Double Ratchet session store (database `driptide-crypto`). Rejects out-of-order messages. | tests only | +| `mls.ts` | **implemented, not wired** | Browser-only wrapper around `@openmls/wasm` (see `mls-integration.md`). | none | +| `crypto/mls.ts` | **implemented, not wired** | Client-side MLS group coordination (key packages, Welcome handling). Backend only sees opaque bytes. | none | + +`session.ts` imports from `crypto.ts`, and `sessionStore.ts` imports `prekeyStore.ts`, which imports `cryptoStore.ts`. Those internal imports do not make the importing module live. + +## Live send and receive paths + +Only modules that run today appear below. Dashed boxes are things the diagram does not show because they are not on the path. + +```mermaid +sequenceDiagram + autonumber + participant UI as conversations/[id]/page.tsx + participant C as lib/crypto.ts + participant T as lib/identityTrust.ts + participant API as Backend REST + participant WS as Socket / GET /sync + participant H as hooks/useInboundPipeline.ts + participant P as crypto/processEnvelope.ts + participant D as crypto/decrypt.ts + participant K as crypto/sessionStore.ts + + Note over UI,API: Send + UI->>C: sendEncryptedMessage(plaintext) + C->>API: GET /conversations/:id/devices + C->>T: assertDevicesTrusted(devices) + C->>C: sealedBoxEncrypt per device (ECDH P-256 + AES-GCM) + C->>API: POST /messages {envelopes} + API-->>C: 409 device_set_mismatch, refetch and retry once + + Note over WS,K: Receive + WS->>H: message_envelope / device_envelope / new_message / sync envelopes + H->>P: processInboundEnvelope + P->>P: plaintextCache hit returns early + P->>API: GET /user-devices/:id/public-key (deviceKeys.ts) + P->>D: decryptAndVerifyEnvelope + D->>K: getSessionKey(senderDeviceId) + K-->>D: undefined, so PreLinkError + D-->>P: plaintext, or error mapped to an unavailable reason +``` + +### Known gap on the live path + +The two live halves do not currently interoperate: + +- **Send** produces a binary sealed box: `ephemeralPub (65 bytes) | iv (12) | ciphertext+tag`, base64 encoded (`crypto.ts`, `sealedBoxEncrypt`). +- **Receive** expects a base64 JSON object `{v: 1, iv, ct, sig?}` decrypted with an AES key from `crypto/sessionStore.ts` (`decrypt.ts`, `parseEnvelopePayload`). +- Nothing outside tests ever calls `setSessionKey` or `importSessionKey`, so inbound envelopes resolve to `PreLinkError` and render as unavailable. + +Also relevant when reading `crypto.ts`: device identity keys are created as Ed25519 SPKI (`deviceIdentity.ts`), which `importRecipientPublicKey` does not treat as P-256, so it takes the HKDF fallback branch, deriving the AES key from the public key bytes alone. Treat that as a Phase-1 limitation to review, not as confidentiality against someone holding the public key. + +`page.tsx` also emits a `send_message` socket event with a `content` field after the REST call succeeds. This map does not cover how the backend treats that field. + +## The `SessionCrypto` switch in `session.ts` + +`session.ts` ends with: + +```ts +export const defaultSession: SessionCrypto = new Phase1SessionCrypto(); +``` + +`Phase1SessionCrypto` delegates to `crypto.ts`. The file comments describe activating Phase-2 as a one-line change to `LibsignalSessionCrypto`. Reading the code, that single line is necessary but not sufficient: + +1. Change the export to `new LibsignalSessionCrypto()`. +2. Call `configureSignalClient({ myIdentity, fetchKeyBundle })` once after `getOrCreateDeviceIdentity()` at sign-in. It has no caller today, and until it runs `SignalClient.encryptToDevice` and `buildEnvelopes` reject. +3. Route the senders through `defaultSession`. `sendEncryptedMessage` and `sendEncryptedFile` call `buildEnvelopes` from `crypto.ts` directly today, so changing `defaultSession` alone changes nothing. +4. Add a matching decrypt path. `processEnvelope.ts` and `decrypt.ts` only understand the JSON/AES envelope. +5. Replace the in-memory session state with persistent storage and, per the integration doc, swap the `signalSession.ts` internals for `@signalapp/libsignal-client`. + +Library choice, audit status and bundle impact live in [`docs/signal-integration.md`](../../../docs/signal-integration.md); this page does not repeat them. + +## Known duplication and inconsistencies + +| Topic | Detail | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Two `sessionStore.ts` files | `lib/sessionStore.ts` is a class (`SessionStore`) that holds ECDH sessions against prekey bundles. `lib/crypto/sessionStore.ts` is a flat in-memory `Map` of AES keys by `senderDeviceId`. Different shapes, different keys, no shared code. `page.tsx` clears both on a key change. | +| Two ratchet implementations | `crypto/ratchetSession.ts` throws "Out-of-order ratchet messages are not supported" in `decryptDm`. `crypto/doubleRatchet.ts` keeps skipped message keys (#360) and decrypts late messages. The first has persistence, the second has out-of-order support, and neither is wired. | +| Two session abstractions on Signal path | `signalSession.ts` (per-device chains over X3DH) and `sessionStore.ts` (ECDH shared secrets) both claim to be "the session layer". Neither is on the live path. | +| Two identity key stores | `deviceIdentity.ts` keeps an Ed25519 key in `localStorage` (the key the server knows). `cryptoStore.ts` keeps a separate identity key pair in IndexedDB (used by search and prekeys). | +| Two MLS entry points | `mls.ts` (WASM binding) and `crypto/mls.ts` (group coordination) are both unwired. | + +## Keeping this table honest + +When a module gains or loses a non-test importer, update its row. A quick check: `grep -rn "from '.*'" apps/web/src --include=*.ts --include=*.tsx` and ignore `*.test.*`. diff --git a/contracts/docs/tutorial-testnet-treasury.md b/contracts/docs/tutorial-testnet-treasury.md new file mode 100644 index 0000000..4c7aa6d --- /dev/null +++ b/contracts/docs/tutorial-testnet-treasury.md @@ -0,0 +1,362 @@ +# Tutorial: create and run a group treasury on testnet + +You will deploy the `group_treasury` contract to Stellar testnet, add three members with an approval threshold of two, deposit a test asset, propose a withdrawal, approve it, and see what happens when you do the wrong thing. You will finish by pointing the app at the deployed contract. + +Every command and output below was run on testnet on 2026-10-20 with `stellar 25.2.0`, `cargo 1.94.0` and `soroban-sdk 22.0.0`. Addresses, contract IDs and transaction hashes will differ in your run. Long transaction URLs and event payloads are shortened with `…`. + +## Read this first: what differs from a clean run + +Running this tutorial against the repository as it stands surfaced three problems in `scripts/deploy_group_treasury.sh` and one gap in the contract. Each is handled in the step where it appears, and each is a fix worth making in the repo: + +| # | Where | Problem | +| --- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | script, build step | `cargo build --target wasm32-unknown-unknown` with a current Rust toolchain emits WebAssembly that testnet rejects (`reference-types not enabled`). Build with `stellar contract build`. | +| 2 | script, initialise step | `initialize` takes a required `--threshold`; the script does not pass it, so it fails with `Missing required argument 'threshold'`. | +| 3 | script, admin address step | `stellar keys public-key --secret-key …` is not accepted by `stellar 25.2.0` (`unexpected argument '--secret-key'`). | +| 4 | contract | There is **no execute entrypoint**. Reaching the threshold marks a proposal `Passed`; nothing moves funds. See [Step 8](#step-8-see-what-passed-does-and-does-not-do). | + +The tutorial therefore runs the script's own steps by hand (upload, deploy, initialize) with the corrections above. Once the script is fixed, steps 3 to 5 collapse into one script call. + +## Prerequisites + +- `stellar` CLI 25 or newer: `stellar --version` +- Rust with the `wasm32v1-none` target: `rustup target add wasm32v1-none` +- A checkout of this repository, with commands run from `contracts/` + +```bash +cd contracts +``` + +## Step 1: create and fund four testnet identities + +You need an admin (deployer) and three members. `--fund` asks the testnet friendbot to give each account test XLM. + +```bash +for n in tut-admin tut-alice tut-bob tut-carol; do + stellar keys generate $n --network testnet --fund +done +``` + +``` +✅ Key saved with alias tut-admin in "…/stellar/identity/tut-admin.toml" +✅ Account tut-admin funded on "Test SDF Network ; September 2015" +… (same two lines for tut-alice, tut-bob, tut-carol) +``` + +Save the addresses in shell variables: + +```bash +ADMIN=$(stellar keys address tut-admin) +ALICE=$(stellar keys address tut-alice) +BOB=$(stellar keys address tut-bob) +CAROL=$(stellar keys address tut-carol) +echo $ALICE +``` + +``` +GAEH7OD6EIE6OJNM3W3PCXETE6U4CWNW7MATSCHB7BCLMQY7QN6BB3LL +``` + +These are throwaway testnet keys. Never reuse a key you have funded on mainnet. + +## Step 2: get a test token contract address + +The treasury holds any SEP-41 token. The easiest on testnet is the built-in contract for the native asset (XLM), which your funded accounts already hold: + +```bash +TOKEN=$(stellar contract id asset --asset native --network testnet) +echo $TOKEN +``` + +``` +CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +``` + +That ID is the same for everyone on testnet. Amounts are in stroops: 1 XLM = 10,000,000 stroops. + +If you would rather use your own token, issue a classic asset and wrap it with `stellar contract asset deploy --asset CODE:ISSUER --source-account tut-admin --network testnet`; the command prints the contract ID. + +## Step 3: build the contract + +The script's own build line (problem 1) produces a file testnet refuses, so build with the CLI: + +```bash +stellar contract build --package group_treasury +``` + +``` +… + • reject_withdraw + • remove_member + • withdraw +✅ Build Complete +``` + +The output is `target/wasm32v1-none/release/group_treasury.wasm`. + +To see problem 1 for yourself, run the script with the identity's secret: + +```bash +DEPLOYER_SECRET=$(stellar keys secret tut-admin) TOKEN_CONTRACT_ID=$TOKEN ./scripts/deploy_group_treasury.sh +``` + +``` +==> Uploading WASM to testnet... +ℹ️ Simulating transaction… +❌ error: transaction simulation failed: HostError: Error(WasmVm, InvalidAction) +…"reference-types not enabled: zero byte expected"… +``` + +## Step 4: upload and deploy + +These are the script's `upload` and `deploy` calls, pointed at the correct file. + +```bash +WASM_HASH=$(stellar contract upload --network testnet --source tut-admin \ + --wasm target/wasm32v1-none/release/group_treasury.wasm) +echo $WASM_HASH +``` + +``` +✅ Transaction submitted successfully! +🔗 https://stellar.expert/explorer/testnet/tx/8f4e4f91… +f9c5809d38f2ce24edb1e31d0be801535bf5c7cb46645f13aa7eb3950affcb1b +``` + +(The hash is the last line; the command prints the status lines to stderr.) + +```bash +CID=$(stellar contract deploy --network testnet --source tut-admin --wasm-hash $WASM_HASH) +echo $CID +``` + +``` +CDLFIQKSJLWHQPCQPATYYLUE67ZWM3CCK2V6ZBRHEIFSFZH56PK7LSEK +``` + +Define a short helper so the rest reads cleanly: + +```bash +inv() { local src=$1; shift; stellar contract invoke --network testnet --source $src --id $CID -- "$@"; } +``` + +## Step 5: initialize with a threshold of two + +First, the script's `initialize` call (problem 2): + +```bash +inv tut-admin initialize --admin $ADMIN --token $TOKEN +``` + +``` +❌ error: Missing required argument 'threshold' of type u32 (unsigned 32-bit integer) +``` + +Add the threshold: + +```bash +inv tut-admin initialize --admin $ADMIN --token $TOKEN --threshold 2 +inv tut-admin get_threshold +``` + +``` +✅ Transaction submitted successfully! +2 +``` + +`initialize` does not require the admin's signature, so deploy and initialize in the same sitting; anyone could initialize an uninitialized contract. See [api-group-treasury.md](./api-group-treasury.md#initialize). + +## Step 6: add three members + +Only the admin can add members. Note that the admin is not a member and cannot vote. + +```bash +for m in $ALICE $BOB $CAROL; do inv tut-admin add_member --member $m; done +inv tut-admin get_members +``` + +``` +✅ Transaction submitted successfully! +📅 CDLF…SEK - Success - Event: [{"symbol":"member_added"}] = {"map":[{"key":{"symbol":"added_by"},… +… (once per member) +["GAEH7OD6…QN6BB3LL","GBN4LAAQ…KMDLEVWQS","GBUUCHFH…GKOBXTMBW"] +``` + +## Step 7: deposit, propose, approve + +Alice deposits 100 XLM (1,000,000,000 stroops). Depositing does not require membership. + +```bash +inv tut-alice deposit --from $ALICE --token $TOKEN --amount 1000000000 +inv tut-alice balance --token $TOKEN +``` + +``` +✅ Transaction submitted successfully! +📅 CDLZ…YSC - Success - Event: [{"symbol":"transfer"},…] = {"i128":"1000000000"} +📅 CDLF…SEK - Success - Event: [{"symbol":"deposit"}] = {"map":[{"key":{"symbol":"amount"},"val":{"i128":"1000000000"}},… +"1000000000" +``` + +Alice proposes sending 10 XLM to the admin account. `ttl_ledgers` is how long the proposal stays open (about 5 seconds per ledger, so 17280 is about a day). + +```bash +inv tut-alice propose_withdraw --proposer $ALICE --to $ADMIN --token $TOKEN \ + --amount 100000000 --ttl_ledgers 17280 +inv tut-alice get_proposal --proposal_id 0 +``` + +``` +✅ Transaction submitted successfully! +📅 CDLF…SEK - Success - Event: [{"symbol":"proposal_created"}] = {…"id":{"u32":0}…} +0 +{"amount":"100000000","approvals":1,"expires_at":1792581837,"id":0,"proposer":"GAEH…","rejections":0,"status":"Active","to":"GATF…","token":"CDLZ…"} +``` + +The new proposal has `approvals: 1`. **The proposer's vote is counted automatically**, so with a threshold of two, one more member is enough, not two. This is the main thing to understand about the "approve it twice" step in the original design: Alice's proposal already counts as her approval. + +Bob approves: + +```bash +inv tut-bob approve_withdraw --approver $BOB --proposal_id 0 +inv tut-bob get_proposal --proposal_id 0 +``` + +``` +✅ Transaction submitted successfully! +📅 CDLF…SEK - Success - Event: [{"symbol":"proposal_approved"}] = {"map":[{"key":{"symbol":"approvals"},"val":{"u32":2}},{"key":{"symbol":"id"},"val":{"u32":0}},{"key":{"symbol":"threshold"},"val":{"u32":2}}]} +📅 CDLF…SEK - Success - Event: [{"symbol":"withdraw_vote"}] = {…"approve":{"bool":true}…} +{"amount":"100000000","approvals":2,"expires_at":1792581837,"id":0,…,"status":"Passed",…} +``` + +`proposal_approved` fired and the status is `Passed`. + +## Step 8: see what `Passed` does and does not do + +Check the treasury balance and the pending list: + +```bash +inv tut-bob balance --token $TOKEN +inv tut-bob get_pending_proposals +``` + +``` +"1000000000" +[] +``` + +The balance is unchanged: 100 XLM, not 90. `group_treasury` has no function that executes a passed proposal (the `execute` tests in `group_treasury/src/test.rs` are `#[ignore]`d for this reason). Two ways funds actually leave today: + +1. **Admin `withdraw`.** The admin can send any amount to any address, regardless of proposals or votes: + + ```bash + inv tut-admin withdraw --to $ADMIN --token $TOKEN --amount 100000000 + inv tut-admin balance --token $TOKEN + ``` + + ``` + ✅ Transaction submitted successfully! + 📅 CDLZ…YSC - Success - Event: [{"symbol":"transfer"},…] = {"i128":"100000000"} + 📅 CDLF…SEK - Success - Event: [{"symbol":"withdraw"}] = {"map":[{"key":{"symbol":"amount"},… + "900000000" + ``` + +2. **The `proposals` contract.** Its `execute_withdraw` calls the treasury's `withdraw`, which requires the treasury admin's authorization, and it runs its own create, vote and finalize flow. That is a separate tutorial (see [concepts-proposal-lifecycle.md](./concepts-proposal-lifecycle.md) and `scripts/deploy_proposals.sh`). + +The honest summary: the three-member vote records consent on-chain, but it is the admin key that moves the money. Read [concepts-treasury-multisig-model.md](./concepts-treasury-multisig-model.md) before trusting a treasury with real funds. + +## Step 9: failure cases to try + +These are the failures a learner should see once. Release builds strip panic text, so the CLI reports `UnreachableCodeReached` and names the function; the table below says which check fired, from [contracts-errors.md](./contracts-errors.md). The workspace also defines a `release-with-logs` profile for debugging; this tutorial did not use it. + +### Approving twice from the same member + +Alice proposed, so she has already voted: + +```bash +inv tut-alice approve_withdraw --approver $ALICE --proposal_id 0 +``` + +``` +❌ error: transaction simulation failed: HostError: Error(WasmVm, InvalidAction) +Event log (newest first): + 0: [Diagnostic Event] contract:CDLF…SEK, topics:[error, Error(WasmVm, InvalidAction)], data:["VM call trapped: UnreachableCodeReached", approve_withdraw] +``` + +On a proposal that is still `Active`, the check that fires is `"already voted"`. Here the proposal is already `Passed` (Step 7), and the status check runs first, so it is `"proposal is not pending"`. To see `"already voted"` itself, create a second proposal (Alice proposes, then Alice approves) before anyone else votes. + +### Voting after the threshold is reached + +Carol arrives late: + +```bash +inv tut-carol approve_withdraw --approver $CAROL --proposal_id 0 +``` + +``` +❌ error: transaction simulation failed: HostError: Error(WasmVm, InvalidAction) + 0: … data:["VM call trapped: UnreachableCodeReached", approve_withdraw] +``` + +Same trap, check `"proposal is not pending"`: a `Passed` proposal accepts no further votes. + +### "Executing" below the threshold + +There is nothing to call, because there is no execute function (Step 8). The closest real experiment is to try the admin path with a non-admin key: + +```bash +inv tut-alice withdraw --to $ALICE --token $TOKEN --amount 100000000 +``` + +``` +❌ error: Missing signing key for account GATFU5UCUXUCYT6W6SJUA3IWDQZVZRN3ATSE2EHAAS2FD2JT6DHKR5MC +``` + +The CLI refuses before submitting, because `withdraw` needs the admin's signature and `tut-alice` is not the admin. + +### Proposing more than the treasury holds + +```bash +inv tut-alice propose_withdraw --proposer $ALICE --to $ADMIN --token $TOKEN \ + --amount 99999999999 --ttl_ledgers 17280 +``` + +``` +❌ error: transaction simulation failed: HostError: Error(WasmVm, InvalidAction) + 0: … data:["VM call trapped: UnreachableCodeReached", propose_withdraw] +``` + +The check is `"insufficient funds"`. + +## Step 10: point the app at the deployed contract + +The treasury contract ID is read by the **backend**, not the web app: + +```bash +# apps/backend/.env +GROUP_TREASURY_CONTRACT_ID=CDLFIQKSJLWHQPCQPATYYLUE67ZWM3CCK2V6ZBRHEIFSFZH56PK7LSEK +STELLAR_RPC_URL=https://soroban-testnet.stellar.org +``` + +With both set, the Stellar listener watches the contract and syncs its events into the database (`apps/backend/src/index.ts`). Without `GROUP_TREASURY_CONTRACT_ID`, the treasury route reports contract `stub`. + +For the web app, set the network variables to match testnet (they are the defaults, so this is only needed if you changed them). All `NEXT_PUBLIC_*` values are compiled into the bundle and visible to every visitor; never put a secret in one: + +```bash +# apps/web/.env.local +NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +NEXT_PUBLIC_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +NEXT_PUBLIC_NETWORK=test +``` + +**There is currently no `NEXT_PUBLIC_*` variable for the group treasury contract.** The only contract ID the web app reads is `NEXT_PUBLIC_TOKEN_TRANSFER_CONTRACT` (`apps/web/src/lib/soroban.ts`), which is for the `token_transfer` contract, not this one. If a frontend variable for the treasury is added (the original request referenced #373), update this step. The full variable list and defaults are in [environment-variables.md](../../docs/environment-variables.md). + +Restart the backend and web dev servers after changing env files. + +## Clean up + +Testnet state is free to leave behind. To remove the local identities: + +```bash +for n in tut-admin tut-alice tut-bob tut-carol; do stellar keys rm $n; done +``` diff --git a/docs/flows/token-transfer.md b/docs/flows/token-transfer.md new file mode 100644 index 0000000..fa112f5 --- /dev/null +++ b/docs/flows/token-transfer.md @@ -0,0 +1,162 @@ +# Flow: in-chat token transfer, end to end + +One sequence from "the user chooses to send tokens in a chat" to "both users see a transfer card". It spans the web app, Freighter, Soroban RPC, the `token_transfer` contract, the backend REST API, Postgres, and the Stellar listener. + +This page describes what the code on `dev` does today. Where the intended flow and the code differ, it says so under [Gaps between the intended flow and the code](#gaps-between-the-intended-flow-and-the-code). Detail on each part lives in the linked pages and is not repeated here. + +| Part | Code | Reference | +| ------------------ | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Transfer card | `apps/web/src/components/chat/TransferCard.tsx`, rendered from `app/app/conversations/[id]/page.tsx` | [components-messaging.md](../../apps/web/docs/components-messaging.md) | +| Signing and submit | `apps/web/src/lib/soroban.ts` (`transferToken`) | [api-soroban-client.md](../../apps/web/docs/api-soroban-client.md) | +| Contract | `contracts/contracts/token_transfer/src/lib.rs` (`transfer`) | [api-token-transfer.md](../../contracts/docs/api-token-transfer.md), [concepts-token-transfer-flow.md](../../contracts/docs/concepts-token-transfer-flow.md) | +| REST | `POST` and `GET /conversations/:id/transfers` in `apps/backend/src/routes/conversations.ts` | [api-conversations.md](../../apps/backend/docs/api-conversations.md) | +| Table | `tokenTransfers` in `apps/backend/src/db/schema.ts` | [contracts-database-schema.md](../../apps/backend/docs/contracts-database-schema.md) | +| Listener | `apps/backend/src/services/stellarListener.ts` | [concepts-stellar-listener.md](../../apps/backend/docs/concepts-stellar-listener.md) | + +## Sequence + +Solid arrows are implemented. Steps marked **(not wired)** have working code on one side and no caller on the other. + +```mermaid +sequenceDiagram + autonumber + participant UI as Web client (chat) + participant FR as Freighter wallet + participant RPC as Soroban RPC + participant C as token_transfer contract + participant API as Backend REST + participant DB as Postgres (token_transfers) + participant L as Stellar listener + + UI->>UI: user enters amount and recipient (not wired: no UI calls transferToken) + UI->>FR: isConnected(), getAddress() + UI->>RPC: getAccount, simulateTransaction(transfer) + RPC-->>UI: fees and auth entries (or a simulation error) + UI->>FR: signTransaction(XDR) + FR-->>UI: signed XDR (or rejection) + UI->>RPC: sendTransaction(signed) + RPC->>C: transfer(from, to, amount, memo) + C->>C: from.require_auth(), call SEP-41 token.transfer + C-->>RPC: event "transfer" {from, to, amount, memo} + UI->>RPC: getTransaction(hash), poll up to 30 times, 2s apart + RPC-->>UI: SUCCESS + UI->>API: POST /conversations/:id/transfers (not wired) + API->>DB: INSERT token_transfers (unique tx_hash) + API-->>UI: 201 row + UI->>API: send chat message {type: transfer, amount, token, txHash} + API-->>UI: message delivered to both users + UI->>UI: parseTransferPayload renders TransferCard with explorer link + + loop every 5 seconds + L->>RPC: getEvents(contract, topic "transfer", cursor) + RPC-->>L: transfer events + L->>DB: INSERT ... ON CONFLICT (tx_hash) DO UPDATE SET created_at = now() + end +``` + +## Step by step + +1. **Start.** The flow begins when the user confirms a transfer in the chat. There is no such control on `dev` today (see [gaps](#gaps-between-the-intended-flow-and-the-code)), so step 1 is the caller's job. +2. **Check the wallet.** `transferToken(recipient, amount, memo)` imports `@stellar/freighter-api` lazily. `isConnected()` must be true and `getAddress()` must return a public key, otherwise it throws `Freighter not installed or not connected` or `Unable to read Freighter wallet address`. +3. **Build and simulate.** It builds a `transfer(from, to, amount, memo)` invocation against `NEXT_PUBLIC_TOKEN_TRANSFER_CONTRACT`, loads the account from `NEXT_PUBLIC_SOROBAN_RPC_URL`, and simulates. A simulation error is thrown with the RPC's message. Failing to set the contract variable leaves the placeholder `REPLACE_WITH_TOKEN_TRANSFER_CONTRACT_ID`, which makes every transfer fail here. +4. **Sign.** Freighter shows its approval popup. The private key never leaves the wallet. +5. **Submit.** The signed transaction goes to `sendTransaction`. Status `ERROR` throws `Transaction failed: …`. +6. **On-chain.** The contract requires `from`'s authorization, rejects `amount <= 0`, calls the underlying SEP-41 token's `transfer`, and publishes a `transfer` event containing `from`, `to`, `amount` and `memo` (bytes). Nothing is recorded if any step panics. +7. **Confirm.** The client polls `getTransaction(hash)` every 2 seconds, up to 30 times, and returns the hash on `SUCCESS`. +8. **Record.** The client is expected to `POST /conversations/:id/transfers` with `recipientAddress`, `amount`, `tokenContractId`, `txHash` and optional `memo` (snake_case names are also accepted). The backend checks membership, rejects a duplicate `txHash` with `409`, and inserts one row. +9. **Announce.** The client sends a chat message whose body is `{ type: 'transfer', amount, token, txHash }`. `page.tsx` calls `parseTransferPayload` on each message and renders a `TransferCard` for those that match. Both users see the same card because both receive the same message. +10. **Reconcile.** Independently of steps 8 and 9, the listener reads the contract's events and upserts on `tx_hash`. + +## Source of truth + +**The on-chain event is the source of truth. The backend row is a derived index.** + +- The ledger is final and public. A transaction that succeeded has moved the tokens whether or not any backend row exists. +- The `token_transfers` row exists so the app can list a conversation's transfers without scanning the chain. It carries no authority. +- The chat message carrying the card is neither of these. It is a user-authored claim that points at a `txHash`, and the card does not check the chain when it renders. + +### How the two are reconciled + +Reconciliation is a keyed upsert, not a comparison: + +| Case | Result | +| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Client `POST` first, listener second (normal) | The row already exists. The listener's upsert conflicts on `tx_hash` and **only sets `created_at = now()`**. Every other column keeps the client's values. | +| Listener first, client `POST` second | The listener inserts the row. The client's `POST` finds the hash and returns `409 Transaction hash already exists`. The client's `conversationId`, `senderId` and `tokenContractId` are not applied. | +| Client `POST` only, no chain event (forged or wrong hash) | The row stays. **The backend never reads the chain on `POST`**, so nothing disproves it. | +| Chain event only, client never `POST`s | The listener creates the row on its own, subject to the caveats below. | + +When the row and the chain **disagree** (different amount, different recipient, hash that never confirmed), no code detects it or corrects it. The existing row wins for every column except `created_at`. Treat the chain as authoritative when investigating, and do not rely on `token_transfers` for anything that must be correct. + +Caveats in the listener's insert path (`defaultPersistEvent`): + +- `conversation_id` and `sender_id` come from decoding the event's `memo` as a message UUID and finding that message. If the memo is absent or does not match, the writer **falls back to an arbitrary existing conversation and user** (`limit(1)` with no ordering), and drops the event only if the database has none. A transfer created without a message-UUID memo can therefore be attributed to an unrelated conversation. +- `token_contract_id` is written as the literal `placeholder_token_contract_id`, not the real token. +- `recipient_address` and `amount` come from the event; `memo` is stored as the raw hex string. +- The listener's cursor lives in memory. After a restart it re-reads whatever the RPC still retains. Events older than the RPC retention window are not backfilled. See [concepts-stellar-listener.md](../../apps/backend/docs/concepts-stellar-listener.md). +- The listener only runs when both `STELLAR_RPC_URL` and `TOKEN_TRANSFER_CONTRACT_ID` are set, and `TOKEN_TRANSFER_CONTRACT_ID` must equal the web app's `NEXT_PUBLIC_TOKEN_TRANSFER_CONTRACT`. + +## Failure points and what the user sees + +"Sees" is what the code produces. `transferToken` throws an `Error`; the code that called it decides how to show that, and no caller exists on `dev`. The messages below are what that caller would receive. + +| # | Failure | Where | Error thrown | On-chain state | Backend row | What the user should see / what happens | +| --- | ------------------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Freighter missing or locked | step 2 | `Freighter not installed or not connected` | none | none | Nothing was sent. Install or unlock the wallet, retry. | +| 2 | Wrong or unset contract ID, bad amount, insufficient balance | step 3 (simulation) | the RPC's simulation error text | none | none | Nothing was sent. Fix configuration or amount. | +| 3 | **User rejects in Freighter** | step 4 | `Unable to sign transaction with Freighter` | none | none | Nothing was sent and nothing is charged. The error message does not distinguish rejection from other signing errors. | +| 4 | Freighter on the wrong network | step 4 | `Unable to sign transaction with Freighter` | none | none | The extension refuses a transaction built for a different passphrase. Align `NEXT_PUBLIC_NETWORK_PASSPHRASE`. | +| 5 | RPC rejects the submission | step 5 | `Transaction failed: …` | none | none | Nothing moved. Retry. | +| 6 | **Transaction fails on-chain** (reverts) | step 7 | **see note below**: surfaces as `Transaction not confirmed after timeout: ` after about 60 seconds | none (reverted) | none | The tokens did not move, but the user waits a minute and is told it was "not confirmed", which suggests it might still land. | +| 7 | Confirmation poll times out | step 7 | `Transaction not confirmed after timeout: ` | **unknown**: it may still confirm after the 60 s | none (client stops) | The hash in the message lets the user check the explorer. If it later succeeds, the listener will insert a row (see failure 8), but no chat card is sent. | +| 8 | **Backend `POST` fails after on-chain success** | step 8 | `4xx`/`5xx` from the API, or a network error | **tokens moved** | none from the client; **the listener inserts one** within about 5 seconds (if enabled) | The money moved but the chat has no card unless the client still sends the message. The row appears later via the listener, possibly attributed to the wrong conversation (see caveats). A retried `POST` then gets `409`. | +| 9 | `POST` returns `403` or `400` | step 8 | `Not a member of this conversation`, or `recipientAddress, amount, tokenContractId, and txHash are required` | tokens moved | none from the client (listener may add it) | Same recovery as failure 8. | +| 10 | Chat message fails to send after steps 7 and 8 | step 9 | message-send error | tokens moved | row exists | Recipient has no card even though the transfer is real and recorded. The sender can resend a message with the same `txHash`. | +| 11 | Listener disabled or RPC outage | step 10 | log line only | unaffected | missing until the listener catches up | Rows from the client `POST` are unaffected. Chain-only transfers are not recorded while it is down. | + +### Note on failure 6 + +`soroban.ts` throws `Transaction reverted: ` when the polled status is `FAILED`, but that `throw` sits inside the same `try` whose `catch {}` is meant to swallow transient lookup errors. The revert error is caught and ignored, the loop keeps polling a transaction that will never succeed, and the function ends with the timeout error. The practical result is the behavior in the table. Fixing it means rethrowing after the catch, which is outside this document. + +### Making failures 7 to 10 safe to retry + +Everything after the on-chain step is idempotent on `tx_hash`. A client can safely repeat the `POST` and treat `409` as "already recorded". Because the chain, not the row, is the truth, a retry should key on the hash and never submit a second transaction. + +## What the backend can see + +The chat message body is end-to-end encrypted for messages sent through `POST /messages`: the server stores per-device ciphertext envelopes. **A token transfer is not hidden by that.** Two separate things are visible to the backend: + +**1. The `token_transfers` row (plaintext columns)** + +| Column | Visible | Notes | +| ------------------- | ------- | ------------------------------------------------------------------ | +| `conversation_id` | yes | Which conversation the transfer belongs to. | +| `sender_id` | yes | The authenticated user who called `POST`, or the listener's guess. | +| `recipient_address` | yes | Stellar address of the recipient. | +| `amount` | yes | Decimal string. | +| `token_contract_id` | yes | Client-supplied, or the placeholder from the listener. | +| `tx_hash` | yes | Unique key. | +| `memo` | yes | Whatever the client sent, or the raw hex of the on-chain memo. | +| `created_at` | yes | Row insert time, refreshed by each listener upsert. | + +**2. The chain itself.** The `transfer` event (`from`, `to`, `amount`, `memo`) is public on Stellar, so anyone, including the backend, can read it regardless of what the app stores. + +The consequences: + +- The server can see who paid whom, how much, in which token, and in which conversation. End-to-end encryption protects what people **say**, not the **transfers** they make. +- Do not put message text or other private information in the `memo`. It is written on-chain and into the `token_transfers` row in the clear. +- The card payload `{ type: 'transfer', amount, token, txHash }` travels as a chat message and would be encrypted like any other message if sent through the encrypted path (`POST /messages`). That does not conceal the same facts, because they are also in the row and on-chain. +- The listener links a chain event to a conversation only through a message-UUID memo. A transfer with no such memo has no honest link to a conversation in the backend's data. + +## Gaps between the intended flow and the code + +Verified against the code on `dev` when this page was written: + +1. **No UI triggers the flow.** `transferToken` has no caller in `apps/web/src`. [api-soroban-client.md](../../apps/web/docs/api-soroban-client.md) names `components/chat/MessageInput.tsx` (`handleConfirmTransfer`), but that component was removed in the commit that retired the legacy chat route. Only the rendering end (`TransferCard`) remains. +2. **No client code calls `POST` or `GET /conversations/:id/transfers`.** The endpoints exist and are tested, but the web app does not use them. +3. **The memo type does not match.** `transferToken` passes the memo as a Soroban `Symbol`, while the contract's `transfer` takes `memo: Bytes`. The listener also expects the memo to be a message UUID. Simulation is likely to reject the call until the types line up (not run against a live RPC). +4. **The card has no "confirmed" state.** `TransferCard` renders from the chat message and links to the explorer. It does not read `token_transfers` and has no pending, failed, or confirmed status. "Confirmed" today means only that the sender's client saw `SUCCESS` before sending the message. +5. **`POST` trusts the client.** It does not verify the hash on-chain, so a conversation member can record a transfer that never happened. +6. **The revert error is swallowed** (failure 6 above). + +When these are fixed, update the sequence diagram and delete the matching row.