From e5f59e18cc0623c6a75c6ff55a7073c34eefb492 Mon Sep 17 00:00:00 2001 From: Gregoire MOURO Date: Mon, 26 Oct 2026 06:51:30 +0000 Subject: [PATCH 1/8] keeper, front: harden and prepare the hosted deployment (Vercel + Railway) Write routes need a team access code, hosted mode refuses the fund key, the session is saved and reloaded, unconfirmed transactions are reconciled by hash before any new write, /config states the backend, the front asks for the access code. Adds Dockerfile, railway.json, vercel.json, docs/deploy.md and interfaces v1.4. Co-Authored-By: Claude Sonnet 5.5 --- .dockerignore | 8 ++ .env.example | 8 ++ Dockerfile | 27 ++++ README.md | 27 +++- app/src/App.tsx | 9 ++ app/src/api/access.ts | 22 +++ app/src/api/errors.ts | 1 + app/src/api/http.ts | 7 +- app/src/dashboard/AccessCodeModal.tsx | 43 ++++++ app/src/state/useKeystone.ts | 16 +++ app/test/access.test.ts | 46 ++++++ app/vite.config.ts | 18 ++- docs/deploy.md | 110 ++++++++++++++ docs/interfaces.md | 11 +- keeper/src/access.ts | 39 +++++ keeper/src/errors.ts | 10 +- keeper/src/fake-vault-client.ts | 9 +- keeper/src/index.ts | 42 ++++-- keeper/src/server.ts | 38 ++++- keeper/src/session.ts | 200 +++++++++++++++++++++++--- keeper/src/store.ts | 53 +++++++ keeper/src/testnet-vault-client.ts | 45 +++++- keeper/src/vault-client.ts | 7 + keeper/test/hosted.test.ts | 194 +++++++++++++++++++++++++ keeper/test/testnet-client.test.ts | 2 +- packages/shared/src/types.ts | 4 + railway.json | 10 ++ vercel.json | 18 +++ 28 files changed, 974 insertions(+), 50 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 app/src/api/access.ts create mode 100644 app/src/dashboard/AccessCodeModal.tsx create mode 100644 app/test/access.test.ts create mode 100644 docs/deploy.md create mode 100644 keeper/src/access.ts create mode 100644 keeper/src/store.ts create mode 100644 keeper/test/hosted.test.ts create mode 100644 railway.json create mode 100644 vercel.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dfc25ce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +**/node_modules +**/dist +.git +.env +.env.* +**/* 2.* +contracts/target diff --git a/.env.example b/.env.example index 550164f..8767e71 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,11 @@ VITE_USE_MOCKS=false # KEEPER_ALLOWED_ORIGINS : origines web supplémentaires autorisées (CORS), séparées par des virgules ; localhost est toujours autorisé. KEEPER_BACKEND= KEEPER_ALLOWED_ORIGINS= + +# --- Hosted keeper (Railway) : voir docs/deploy.md --- +# KEEPER_HOST=0.0.0.0 # défaut 127.0.0.1. Hors loopback, le keeper est public : les 4 variables ci-dessous deviennent obligatoires. +# KEEPER_ACCESS_TOKENS= # codes d'accès des routes POST, séparés par des virgules, 32 caractères minimum chacun (openssl rand -hex 24). Jamais dans Vercel. +# KEEPER_ALLOWED_ORIGINS= # origines HTTPS exactes du site (le caractère * remplace un seul segment de nom : https://keystone-*-equipe.vercel.app) +# KEEPER_PUBLIC_HOSTS= # nom d'hôte public du keeper, sans schéma (xxx.up.railway.app) +# KEEPER_DATA_DIR=/data # dossier durable (volume) : session, caches Euribor et marché +# KEEPER_TRUST_PROXY_HOPS=1 # nombre de proxys devant le keeper diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..37da84c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Keystone keeper (Fastify + SSE) for a Node host such as Railway. The web app is built and served by Vercel. +FROM node:24-slim +WORKDIR /app + +# Manifests first: the dependency layer is reused until a package.json or the lockfile changes. +COPY package.json package-lock.json ./ +COPY app/package.json app/ +COPY keeper/package.json keeper/ +COPY packages/shared/package.json packages/shared/ +COPY packages/vault-client/package.json packages/vault-client/ +COPY scripts/package.json scripts/ +RUN npm ci --workspace=@keystone/keeper --workspace=vault-client --workspace=@keystone/shared --include-workspace-root + +COPY packages packages +COPY keeper keeper +COPY deployments deployments +# The generated contract bindings are not versioned: compile them into packages/vault-client/dist. +RUN npm run build -w vault-client + +# Hosted defaults; the host sets PORT, KEEPER_SECRET, KEEPER_ACCESS_TOKENS and the origins (see README). +ENV NODE_ENV=production \ + KEEPER_HOST=0.0.0.0 \ + KEEPER_BACKEND=testnet \ + SIGNER=freighter \ + KEEPER_DATA_DIR=/data +EXPOSE 8787 +CMD ["node_modules/.bin/tsx", "keeper/src/index.ts"] diff --git a/README.md b/README.md index 765285a..a7250f4 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,11 @@ Verified on the keeper machine (macOS). Not yet verified on Windows. | `PORT` | keeper | API port, default 8787 | | `KEEPER_BACKEND` | keeper | `testnet` (real vault on Stellar Testnet, real transactions) or `fake` (in-memory vault, fake `FAKE…` hashes, no transaction; used by the tests). No default: the keeper refuses to start without it | | `KEEPER_ALLOWED_ORIGINS` | keeper | Extra browser origins allowed by CORS (comma separated). `localhost` and `127.0.0.1` are always allowed; the keeper listens on `127.0.0.1` only | -| `VITE_KEEPER_URL` | dashboard | Keeper API base URL | +| `KEEPER_HOST` | keeper | Listening address, default `127.0.0.1`. A hosted keeper sets `0.0.0.0`; it then requires `KEEPER_ACCESS_TOKENS`, `KEEPER_ALLOWED_ORIGINS` and `KEEPER_PUBLIC_HOSTS`, refuses `SIGNER=keeper` and a `FUND_SECRET`, and ignores localhost origins | +| `KEEPER_ACCESS_TOKENS` | hosted keeper | Access codes (32 characters or more, comma separated) required as `Authorization: Bearer` on every `POST` route | +| `KEEPER_PUBLIC_HOSTS` | hosted keeper | Public host name(s) of the keeper (Host header check) | +| `KEEPER_DATA_DIR` | keeper | Where the session file and the Euribor and market caches are kept (a mounted volume when hosted). Default `keeper/data` | +| `VITE_KEEPER_URL` | dashboard | Keeper API base URL. A Vercel build refuses anything but an HTTPS address | | `VITE_USE_MOCKS` | dashboard | `true` to run on mocks | ## 9. Commands @@ -225,7 +229,7 @@ Only commands that have been run successfully are listed. Run them from the repo | Command | What it does | Status | | --- | --- | --- | | `npm run typecheck` | Type-checks every workspace that defines a `typecheck` script (today: `app`, `packages/shared`, `scripts` and `keeper`) | Verified (macOS) | -| `npm test` | Runs the tests of every workspace that defines a `test` script (today: Vitest checks of the shared mocks, the script helpers, the margin model with reference values, the Euribor cache and the keeper API on the in-memory vault) | Verified (macOS): 64 keeper tests pass | +| `npm test` | Runs the tests of every workspace that defines a `test` script (today: Vitest checks of the shared mocks, the script helpers, the margin model with reference values, the Euribor cache and the keeper API on the in-memory vault) | Verified (macOS): 94 keeper tests and 29 app tests pass | | `npm run init-env` | Creates `.env` from `.env.example` if needed and generates the Testnet keys still empty (issuer, bank, keeper). Never generates `FUND_SECRET`, never overwrites a value, prints public keys only | Verified (macOS) | | `npm run bootstrap` | Idempotent Testnet bootstrap: issuer, bank and keeper accounts (friendbot), bank trustlines, KMMF and KBOND Stellar Asset Contracts, and, when `FUND_SECRET` is set, fund trustlines and issuance up to 1 000 units of each asset (the fund address is derived from `FUND_SECRET`). Writes `deployments/testnet.json` | Verified (macOS) over seven runs (the first stopped on a bug, since fixed); once everything exists, a run sends no transaction | | `KEEPER_BACKEND=testnet SIGNER=keeper npm run keeper` | Starts the keeper API on `http://127.0.0.1:8787/api` on the **real vault** (C2 build, from `deployments/testnet.json`): `POST /session` deploys a new vault from the recorded WASM hash, deposits and withdrawals are signed with the local demo fund key, `post_margin` with the keeper key, and every `chain` entry of the SSE feed carries the hash of a transaction confirmed by the RPC. Needs `KEEPER_SECRET` and `FUND_SECRET` in the local `.env` (checked against `deployments/testnet.json`). Routes: `/health`, `/config`, `/session` (POST, GET), `/session/deposit`, `/session/withdraw`, `/session/deposit/prepare`, `/session/withdraw/prepare`, `/session/submit`, `/session/rate-shock`, `/session/settle-default` (permissionless `settle_default`, submitted by the keeper account), `/events` (SSE) | Verified (macOS): boots, `/health`, `/session` without session → `NoSession`; the transactions behind it are proven by the integration run below. **Do not expose it publicly with `SIGNER=keeper`**: anyone reaching the API would spend the demo fund. The keeper listens on 127.0.0.1 and refuses requests whose Host or Origin is not loopback (CORS and DNS-rebinding protection, tested) | @@ -235,7 +239,21 @@ Only commands that have been run successfully are listed. Run them from the repo The keeper builds `packages/vault-client` (generated bindings, `dist/` not versioned) before `start`, `test` and `typecheck`. -Still to come, once they work: Freighter signing from the browser, a single e2e script chaining both scenarios, and the public deployment (decision A8). +Still to come, once they work: a single e2e script chaining both scenarios. + +### Hosted deployment (web app on Vercel, keeper on Railway) + +The web app is a static Vite build served by Vercel (`vercel.json`). The keeper is a long-lived Node process (Fastify, server-sent events, in-memory session) and runs in a container on Railway (`Dockerfile`, `railway.json`), not as serverless functions. Step-by-step instructions, variables and checks are in [`docs/deploy.md`](docs/deploy.md). + +What a hosted keeper does differently from a local one (all covered by tests in `keeper/test/hosted.test.ts`): + +- **Write routes need an access code.** Every `POST` returns `Unauthorized` (401) without `Authorization: Bearer `; codes live only in the host's `KEEPER_ACCESS_TOKENS`. The dashboard asks for the code once and keeps it in the browser. CORS and the Host/Origin checks are kept (exact origins only) but are not the authorization. +- **No fund key.** It refuses to start with `SIGNER=keeper` or a `FUND_SECRET`: deposits and withdrawals are always signed in Freighter. +- **The session survives a restart.** It is saved in `KEEPER_DATA_DIR` and checked against the vault on-chain when reloaded. +- **Uncertain transactions are reconciled by hash.** After a timeout the entry stays `pending` with its hash; no other transaction is sent until the hash is found confirmed, failed or expired. +- **`GET /config` states the backend** (`backend: "testnet" | "fake"`); the page labels a fake backend. + +Status: configuration and tests written; **not deployed yet** (see the progress table). **Keeper model** (`keeper/src/margin.ts`, parameters in `keeper/scenarios/default.json`): flat curve, 20 quarterly periods, `Q = ceil(V / (P × (1 − h)))` in the token's smallest unit, requirement posted only when it moved by at least the MTA (50 000 EUR). The starting rate is the last ECB Euribor 3M observation (`keeper/data/euribor-cache.json` is the versioned fallback seed, shown as `cache`; it is rewritten only when the ECB value changes, so a run may modify it). Reference values for 0, −50 and −80 bp (computed outside the code, in Python with 50-digit decimals) are checked in `keeper/test/margin.test.ts`. @@ -346,7 +364,7 @@ Only the status "verified" counts as done, and only with a proof link. | 7 | Separate default scenario with on-chain transfer to the bank | Verified (Testnet) | [`settle_default`, 300 KBOND to the bank](https://stellar.expert/explorer/testnet/tx/33793f423facfa6e2968da335558cc9bba2cc296b6ea6e2133b734829309112c); also from the browser: [`settle_default`, 50 KBOND to the bank](https://stellar.expert/explorer/testnet/tx/0c3bd3d7db474f950a6b4bf3119f78536c5ac89c4a6498c67b811a35589add43) | | 8 | Real Stellar Testnet transactions shown with proof | Verified (both scenarios from the browser) | Nine KMMF and four KBOND transactions with On-chain badges and Explorer links in section 6 | | 9 | Dashboard showing asset choice, steps and on-chain proof | Verified on the real keeper for both scenarios (Liquid Glass UI in a browser); Freighter not tested | [KMMF deposit](https://stellar.expert/explorer/testnet/tx/e00a4b80d5de4e6d6dc08baa06bc9e1c3fdb4fded1bdfb209a32e280c2a15286), [KMMF cure](https://stellar.expert/explorer/testnet/tx/b651b662f4200556cb26975b2243628db34305fe02908911a3969ca5e418723b), [KBOND settlement](https://stellar.expert/explorer/testnet/tx/0c3bd3d7db474f950a6b4bf3119f78536c5ac89c4a6498c67b811a35589add43) | -| 10 | End-to-end integration of the three poles | In progress | Browser → keeper → C2 vault proven for both scenarios; Freighter signing and the public URL pending | +| 10 | End-to-end integration of the three poles | In progress | Browser → keeper → C2 vault proven for both scenarios; Freighter signing and the public URL pending; hosted configuration (Vercel + Railway, access codes, durable session) written and tested locally, **not deployed** | | 11 | Submission: public repo, README, public URL, video, pitch deck | Not started | | | 12 | Continuous quality: review of every PR, tests, README updates | In place from phase zero | This repository's PR history | @@ -363,4 +381,5 @@ Only the status "verified" counts as done, and only with a proof link. - The default is a simplified rule (margin call not met before a short grace period), not a full CSA close-out. - KMMF and KBOND are fictitious; they carry no real economic rights. - **Etherfuse Stablebonds were tested and dropped:** on 25 October, an order of sandbox CETES on Etherfuse's API v2 was funded but never delivered to Stellar Testnet. The Etherfuse team confirmed this part of v2 was not finished before the submission deadline, so KBOND represents the bond class instead. +- Hosted keeper: one shared team access code per teammate, no per-user accounts; the read routes (`/session`, `/events`) are public Testnet data. Local accounts (PGlite) are per browser origin and are not an authorization. - Testnet only. No real money, no Mainnet. diff --git a/app/src/App.tsx b/app/src/App.tsx index b745a49..3abc3b3 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -5,6 +5,7 @@ import type { AssetId, KeystoneConfig, VaultStatus } from '@keystone/shared' import { LockScreen } from './auth/LockScreen.tsx' import { currentUser, rememberUser } from './auth/session.ts' import { KEEPER_URL } from './config.ts' +import { AccessCodeModal } from './dashboard/AccessCodeModal.tsx' import { ActionsCard } from './dashboard/ActionsCard.tsx' import { ActivityFeed } from './dashboard/ActivityFeed.tsx' import { assetOf } from './dashboard/assets.ts' @@ -131,9 +132,15 @@ function Main({ user, onLock }: { user: string; onLock(): void }) { const mocks = k.source === 'mocks' const asset = k.config && k.session ? assetOf(k.config, k.session) : null const [details, setDetails] = useState(false) + // Asked as soon as the keeper says it needs a code; "Not now" keeps the page readable until an action is refused. + const [codeDismissed, setCodeDismissed] = useState(false) + useEffect(() => { + if (k.error?.code === 'Unauthorized') setCodeDismissed(false) + }, [k.error]) return ( <> + setCodeDismissed(true)} /> {mocks ? Demo data · nothing sent to Stellar : Stellar Testnet} + {/* A keeper on the in-memory vault says so: its hashes are not Testnet transactions. */} + {!mocks && k.config?.backend === 'fake' && Demo backend · fake transactions} {/* §4: the public demo (SIGNER=keeper) must say that the keeper signs for the fund. */} {k.config?.signerMode === 'keeper' && Public demo · keeper signs} diff --git a/app/src/api/access.ts b/app/src/api/access.ts new file mode 100644 index 0000000..b4e1001 --- /dev/null +++ b/app/src/api/access.ts @@ -0,0 +1,22 @@ +// The team access code that authorizes writes on a hosted keeper. It is typed by the person, kept in +// this browser only, and sent as `Authorization: Bearer`. It is never part of the build. +const KEY = 'keystone.accessCode' +let memory: string | null = null + +export function getAccessCode(): string | null { + try { + return localStorage.getItem(KEY) ?? memory + } catch { + return memory + } +} + +export function setAccessCode(code: string | null): void { + memory = code + try { + if (code) localStorage.setItem(KEY, code) + else localStorage.removeItem(KEY) + } catch { + // Storage blocked: the code then lives until the tab is closed. + } +} diff --git a/app/src/api/errors.ts b/app/src/api/errors.ts index f146748..fa3f7f2 100644 --- a/app/src/api/errors.ts +++ b/app/src/api/errors.ts @@ -25,6 +25,7 @@ const MESSAGES: Record = { Defaulted: 'The vault has defaulted. Only the available collateral can still be withdrawn.', // Keeper (section 4) BadRequest: 'The keeper rejected the request.', + Unauthorized: 'The access code is missing or wrong. Enter the team access code to act on the keeper.', NoSession: 'No session is running. Start one first.', SignerModeDisabled: 'This action is not available in the current signing mode.', TxFailed: 'The transaction failed.', diff --git a/app/src/api/http.ts b/app/src/api/http.ts index d22e440..74cffb4 100644 --- a/app/src/api/http.ts +++ b/app/src/api/http.ts @@ -1,5 +1,6 @@ import type { ActivityEvent, ApiError, AssetId, KeystoneConfig, MarketSnapshot, SessionState } from '@keystone/shared' import type { KeeperClient, StreamListener } from './client.ts' +import { getAccessCode } from './access.ts' import { KeystoneError } from './errors.ts' /** Advisor deliberately uses only this GET, even while session actions run in mock mode. */ @@ -39,9 +40,13 @@ export function createHttpClient(baseUrl: string): KeeperClient { async function request(method: 'GET' | 'POST', path: string, body?: unknown): Promise { let response: Response try { + const code = method === 'POST' ? getAccessCode() : null response = await fetch(`${baseUrl}${path}`, { method, - headers: body === undefined ? undefined : { 'content-type': 'application/json' }, + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...(code ? { authorization: `Bearer ${code}` } : {}), + }, body: body === undefined ? undefined : JSON.stringify(body), }) } catch { diff --git a/app/src/dashboard/AccessCodeModal.tsx b/app/src/dashboard/AccessCodeModal.tsx new file mode 100644 index 0000000..cc22962 --- /dev/null +++ b/app/src/dashboard/AccessCodeModal.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react' +import { Button } from '../ui/index.ts' +import { Modal } from '../ui/Modal.tsx' + +/** Asks for the team access code that a hosted keeper requires before it deploys, posts or signs anything. */ +export function AccessCodeModal({ open, onSave, onClose }: { open: boolean; onSave(code: string): void; onClose(): void }) { + const [code, setCode] = useState('') + return ( + +
{ + event.preventDefault() + if (code.trim()) onSave(code) + setCode('') + }} + > +

+ This keeper acts on Stellar Testnet for the team. Enter the access code you were given to start sessions and move + collateral. Reading the dashboard needs no code. +

+ setCode(event.target.value)} + className="glass h-12 w-full rounded-full px-5 text-sm text-ink outline-none placeholder:text-ink-subtle focus-visible:outline-2 focus-visible:outline-chain" + /> +
+ + +
+
+
+ ) +} diff --git a/app/src/state/useKeystone.ts b/app/src/state/useKeystone.ts index 069c9b2..ce92c33 100644 --- a/app/src/state/useKeystone.ts +++ b/app/src/state/useKeystone.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ActivityEvent, AssetId, KeystoneConfig, SessionState } from '@keystone/shared' import { createBackend, type Backend } from '../api/index.ts' import type { ConnectionStatus } from '../api/client.ts' +import { getAccessCode, setAccessCode } from '../api/access.ts' import { describeError, KeystoneError } from '../api/errors.ts' import { formatUnits, parseUnits } from '../lib/format.ts' @@ -33,6 +34,14 @@ export function useKeystone() { const [step, setStep] = useState(null) const [error, setError] = useState(null) + // Hosted keeper: writes need the team access code (config.writeAuth), typed once and kept in this browser. + const [hasAccessCode, setHasAccessCode] = useState(() => getAccessCode() !== null) + const saveAccessCode = useCallback((code: string) => { + setAccessCode(code.trim() || null) + setHasAccessCode(getAccessCode() !== null) + setError(null) + }, []) + const inFlight = useRef(false) // When a deposit or withdrawal last ended without a confirmation (RPC timeout): kept here as well, // since the pending feed entry may not have reached this tab (stream reconnecting). @@ -93,6 +102,11 @@ export function useKeystone() { if ((action === 'deposit' || action === 'withdraw') && e instanceof KeystoneError && e.code === 'RpcUnavailable') { unconfirmedAt.current = Date.now() } + if (e instanceof KeystoneError && e.code === 'Unauthorized') { + // Wrong or revoked code: forget it, the page asks again. + setAccessCode(null) + setHasAccessCode(false) + } setError(describeError(e)) } finally { inFlight.current = false @@ -204,6 +218,8 @@ export function useKeystone() { busy, step, error, + needsAccessCode: source === 'keeper' && config?.writeAuth === 'token' && !hasAccessCode, + saveAccessCode, ...actions, } } diff --git a/app/test/access.test.ts b/app/test/access.test.ts new file mode 100644 index 0000000..4c9e991 --- /dev/null +++ b/app/test/access.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getAccessCode, setAccessCode } from '../src/api/access.ts' +import { createHttpClient } from '../src/api/http.ts' + +afterEach(() => { + setAccessCode(null) + vi.unstubAllGlobals() +}) + +function stubFetch(status = 200, body: unknown = { sessionId: 's' }) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status })) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +const headersOf = (call: unknown[]) => (call[1] as RequestInit).headers as Record + +describe('team access code', () => { + it('is sent on writes only, as a bearer token', async () => { + const fetchMock = stubFetch() + setAccessCode('team-code') + const client = createHttpClient('https://keeper.example/api') + await client.getConfig() + await client.startSession('KMMF') + expect(headersOf(fetchMock.mock.calls[0]!).authorization).toBeUndefined() + expect(headersOf(fetchMock.mock.calls[1]!).authorization).toBe('Bearer team-code') + }) + + it('sends nothing when no code is stored', async () => { + const fetchMock = stubFetch() + await createHttpClient('https://keeper.example/api').rateShock(10) + expect(headersOf(fetchMock.mock.calls[0]!).authorization).toBeUndefined() + }) + + it('surfaces a refused code as Unauthorized', async () => { + stubFetch(401, { error: { code: 'Unauthorized', message: 'A valid access code is required for this action' } }) + await expect(createHttpClient('https://keeper.example/api').settleDefault()).rejects.toMatchObject({ code: 'Unauthorized', status: 401 }) + }) + + it('stores and clears the code', () => { + setAccessCode('abc') + expect(getAccessCode()).toBe('abc') + setAccessCode(null) + expect(getAccessCode()).toBeNull() + }) +}) diff --git a/app/vite.config.ts b/app/vite.config.ts index a4d3c92..8638a3e 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -1,13 +1,29 @@ import { fileURLToPath } from 'node:url' import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' + +/** + * A build made on Vercel must not ship the localhost default: the hosted page would call the visitor's own + * machine. It must name an HTTPS keeper (an HTTP one would be blocked as mixed content) unless it is an + * explicit mock build, which labels itself "Demo data" on screen. + */ +function assertHostedBuild(mode: string) { + if (!process.env.VERCEL || mode === 'demo') return + const env = { ...loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), 'VITE_'), ...process.env } + if (env.VITE_USE_MOCKS === 'true') return + const url = env.VITE_KEEPER_URL ?? '' + if (!/^https:\/\/[^/]+/i.test(url) || /^https:\/\/(localhost|127\.|\[::1\])/i.test(url)) { + throw new Error('VITE_KEEPER_URL must be the HTTPS address of the hosted keeper (for example https:///api) for a Vercel build') + } +} // https://vite.dev/config/ export default defineConfig(({ mode }) => { // "demo" mode (npm run demo / dev:demo): the in-browser mock keeper, no keeper, wallet or network // needed. It overrides VITE_USE_MOCKS from .env; only a value set in the shell itself wins. if (mode === 'demo') process.env.VITE_USE_MOCKS ??= 'true' + assertHostedBuild(mode) return { plugins: [react(), tailwindcss()], // Read the repository's root .env (see .env.example), whatever the working directory. diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..2aca5fa --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,110 @@ +# Keystone — Déploiement (Vercel + Railway) + +Ce document est le mode d'emploi du déploiement en ligne. Le site (statique) va sur Vercel, le keeper (processus Node permanent) sur Railway. Tout reste sur Stellar **Testnet**. Aucun contrat n'est redéployé : le keeper lit les adresses et le hash WASM de `deployments/testnet.json`. + +## 1. Architecture + +```text +Navigateur ── HTTPS ──> Vercel (app/dist, fichiers statiques, PGlite local au navigateur) + │ + └──── HTTPS (REST + SSE) ──> Railway : keeper Fastify ──> RPC Stellar Testnet + │ ECB, Yahoo Finance (lecture) + └── volume /data : session.json, caches +Freighter (extension du navigateur) signe les dépôts et retraits du fonds. +``` + +Pourquoi le keeper n'est pas sur Vercel : il garde des connexions SSE ouvertes, une session en mémoire et des fichiers de cache. Des fonctions serverless n'offrent rien de cela. + +## 2. Ce qui protège le keeper public + +| Risque | Protection | Où | +| --- | --- | --- | +| Un visiteur déploie des vaults, poste une marge, règle un défaut | Chaque route `POST` exige `Authorization: Bearer `; les codes sont dans `KEEPER_ACCESS_TOKENS` (côté serveur, 32 caractères minimum, comparaison à temps constant) | `keeper/src/access.ts`, `server.ts` | +| Le code fuit dans le site | Le code n'est jamais une variable `VITE_*` : chaque personne le saisit dans la page, il reste dans son navigateur | `app/src/api/access.ts` | +| Le keeper signe pour le fonds | Au démarrage hébergé, `SIGNER=keeper` et `FUND_SECRET` sont refusés : tout dépôt ou retrait passe par Freighter | `keeper/src/index.ts` | +| Page tierce qui appelle l'API | Origines exactes (`KEEPER_ALLOWED_ORIGINS`), hôtes exacts (`KEEPER_PUBLIC_HOSTS`), `localhost` refusé en mode hébergé. Ce n'est **pas** une authentification | `server.ts` | +| Trop de flux SSE | 100 flux ouverts au plus | `server.ts` | + +Les routes `GET` (`/health`, `/config`, `/session`, `/events`, `/market-advisor`) restent ouvertes : elles ne montrent que des données publiques de Testnet. + +Limites assumées : un seul code partagé par personne (pas de comptes), pas de limitation de débit au-delà de la longueur du code (32 caractères aléatoires), et quiconque a un code peut dépenser les frais du compte keeper et les jetons de démonstration du fonds (le fonds ne signe que dans Freighter). + +## 3. Persistance et transactions incertaines + +- Le keeper écrit `session.json` dans `KEEPER_DATA_DIR` (volume Railway monté sur `/data`) à chaque changement, de façon atomique. Au redémarrage il recharge la session et relit le coffre sur la chaîne. Si le coffre n'existe plus (remise à zéro de Testnet), une copie `session.json.dropped-….bak` est gardée et le keeper repart sans session. +- **Sans volume, un redéploiement efface la session** : le coffre garde ses jetons sur la chaîne mais le keeper n'en connaît plus l'adresse. Le volume est donc obligatoire pour un usage au-delà d'un essai. +- Une transaction envoyée dont la confirmation n'arrive pas (délai RPC) reste `pending` avec son hash. Tant qu'elle n'est pas tranchée, **toute écriture est refusée** (`PendingTransaction`). Toutes les 15 s, et avant chaque écriture, le keeper interroge `getTransaction(hash)` : `SUCCESS` confirme l'entrée, `FAILED` la rejette, et un hash introuvable 200 s après l'envoi est déclaré non inclus (aucune transaction construite par le keeper n'est valable plus longtemps). La garde de 200 s du front reste, mais elle n'est qu'un confort. +- Cas non couvert : un arrêt du processus entre l'envoi du déploiement d'un vault et sa première trace. Le vault orphelin est vide et ne coûte que ses frais. + +## 4. Coûts et limites (vérifiés dans la documentation des hébergeurs le 26 octobre 2026) + +| | Plan | Prix | À retenir | +| --- | --- | --- | --- | +| Vercel | Hobby | 0 $ | Usage personnel et non commercial seulement (règle d'usage équitable). 100 déploiements par jour. « Vercel Authentication » (accès réservé aux membres du compte) est disponible pour les previews et la production : à garder activé sur les previews. Un projet d'équipe ou à usage commercial demande Pro (20 $ par personne et par mois) | +| Railway | Free | 0 $, 1 $ de crédit par mois | 0,5 Go de RAM, 0,5 Go de volume : à peine suffisant, et le service s'arrête quand le crédit est épuisé | +| Railway | Hobby | 5 $ par mois, 5 $ d'usage inclus | 5 Go de volume. **Recommandé** : le keeper consomme peu (un processus Node, ~150 Mo) | + +Aucun plan payant n'est activé par ce dépôt : c'est une décision de l'équipe. Railway ne met pas un service à dormir de lui-même ; l'option « Serverless / App Sleeping » existe dans les réglages du service et doit rester **désactivée** (elle couperait les flux SSE et vider la mémoire). + +## 5. Étapes (actions humaines marquées 👤) + +### A. Générer les secrets (sur ta machine, ne rien coller dans le chat) + +```bash +openssl rand -hex 24 # un code d'accès par personne : à donner à la personne, à mettre dans KEEPER_ACCESS_TOKENS +``` + +`KEEPER_SECRET` : la valeur déjà présente dans ton `.env` local (compte keeper de `deployments/testnet.json`). + +### B. Keeper sur Railway + +1. 👤 Créer le compte sur railway.com et se connecter avec GitHub. Choisir le plan Hobby (5 $) si l'équipe l'accepte. +2. 👤 *New Project → Deploy from GitHub repo → `Lagouze/keystone`*. Autoriser Railway sur ce seul dépôt. Branche : `deploy/vercel-railway` pour l'essai, puis la branche de production choisie. Railway lit `railway.json` et le `Dockerfile` à la racine. +3. 👤 *Settings → Networking → Generate Domain* : noter `xxx.up.railway.app`. +4. 👤 *Variables* (taper les valeurs dans l'interface de Railway, pas ailleurs) : + + | Variable | Valeur | + | --- | --- | + | `KEEPER_SECRET` | la clé du compte keeper (secret) | + | `KEEPER_ACCESS_TOKENS` | les codes générés, séparés par des virgules (secret) | + | `KEEPER_PUBLIC_HOSTS` | `xxx.up.railway.app` | + | `KEEPER_ALLOWED_ORIGINS` | l'origine HTTPS exacte du site, par exemple `https://keystone-xxx.vercel.app`; pour les previews, `https://keystone-*-<équipe>.vercel.app` | + | `KEEPER_DATA_DIR` | `/data` | + + Déjà fixées par le `Dockerfile` : `KEEPER_HOST=0.0.0.0`, `KEEPER_BACKEND=testnet`, `SIGNER=freighter`. Railway fournit `PORT`. Ne **jamais** définir `FUND_SECRET`, `ISSUER_SECRET` ni `BANK_SECRET`. +5. 👤 *Settings → Volumes → New Volume*, point de montage `/data`. Si le service ne peut pas y écrire, ajouter la variable `RAILWAY_RUN_UID=0`. +6. Vérifier : `curl https://xxx.up.railway.app/api/config` doit répondre `"backend":"testnet"`, `"signerMode":"freighter"`, `"writeAuth":"token"` et le compte fonds `GDQ6AW…MQOY`. + +### C. Site sur Vercel + +1. 👤 Créer le compte sur vercel.com (« Continue with GitHub »). Autoriser **uniquement** `Lagouze/keystone` (*Only select repositories*). +2. 👤 *Add New → Project → Import `Lagouze/keystone`*, nom `keystone`. Les réglages viennent de `vercel.json` (Vite, `npm ci`, `npm run build -w @keystone/app`, sortie `app/dist`), Root Directory = racine du dépôt. +3. 👤 *Environment Variables* (Production **et** Preview) : + - `VITE_USE_MOCKS` = `false` + - `VITE_KEEPER_URL` = `https://xxx.up.railway.app/api` + Ces valeurs sont publiques (elles finissent dans le JavaScript). Le build échoue volontairement si l'adresse n'est pas en HTTPS ou vise localhost. +4. 👤 *Settings → Git* : laisser la branche de production sur `main` tant que `main` n'est pas à jour. Le premier déploiement se fait sur la branche `deploy/vercel-railway` (un « Preview »). +5. Reporter l'URL du site dans `KEEPER_ALLOWED_ORIGINS` sur Railway. + +### D. Vérifications sans transaction + +```bash +curl -s https://xxx.up.railway.app/api/health +curl -s https://xxx.up.railway.app/api/config +curl -s -o /dev/null -w "%{http_code}\n" -X POST https://xxx.up.railway.app/api/session -H 'content-type: application/json' -d '{"asset":"KMMF"}' # 401 +curl -s https://xxx.up.railway.app/api/market-advisor +curl -N -m 5 https://xxx.up.railway.app/api/events # : connected, puis ping +``` + +Dans le navigateur : site chargé, logo et animations, écran de verrouillage (**les comptes de localhost ne sont pas transférés** : en créer un sur le nouveau domaine), « Stellar Testnet · Keeper connected », fenêtre « Team access code », aucune erreur dans la console, aucun appel vers `localhost`, Market Advisor, mobile. + +### E. Premier essai Freighter (après un GO écrit de l'équipe) + +Même parcours que le test local : session KMMF (signée par le keeper, un vault déployé), dépôt de 1 KMMF, retrait de 1 KMMF, signés dans Freighter (Testnet, compte `GDQ6AW…MQOY`). Coller les hashes dans la PR. + +## 6. Exploitation + +- **Changer ou révoquer un code** : modifier `KEEPER_ACCESS_TOKENS` sur Railway (redéploiement automatique). La session survit grâce au volume. +- **Retour arrière** : Railway et Vercel gardent les déploiements précédents (*Redeploy* / *Promote*). +- **Journaux** : Railway → *Deployments → View logs*. Les clés n'y sont jamais écrites. +- **Si Yahoo Finance bloque l'adresse du serveur** : `/api/market-advisor` marque le VIX comme indisponible (c'est le comportement prévu). À vérifier après le premier déploiement. diff --git a/docs/interfaces.md b/docs/interfaces.md index ff46f9f..c3c9827 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -1,6 +1,6 @@ # Keystone — Shared interfaces -**Version: v1.3** (v1.0 frozen at the end of the kickoff; later changes follow section 6). +**Version: v1.4** (v1.0 frozen at the end of the kickoff; later changes follow section 6). This file is the single source of truth for everything exchanged between the three poles: the Soroban contract, the keeper API and the shared TypeScript types. `README.md` and `AGENTS.md` refer to it. When they disagree, this file wins. @@ -120,6 +120,8 @@ Each event has a single topic: a symbol carrying the exact event name below. Dat Base URL: `http://localhost:8787/api` in local development; the public URL is set at deployment (decision A8). CORS is open to the local and public front. JSON everywhere. +**Write authorization (v1.4, hosted keeper).** A keeper listening beyond loopback requires `Authorization: Bearer ` on every `POST` route (the codes are listed in the keeper's `KEEPER_ACCESS_TOKENS`, never in the web bundle). A missing or wrong code returns `Unauthorized` (401) before anything is read or sent. `GET` routes stay open: they expose public Testnet data only. A local keeper (loopback, no code configured) behaves as before. `GET /config` tells which mode applies through `writeAuth`, and which vault backs the keeper through `backend`. + **Formats.** Amounts, rates and timestamps travel as strings, never as JSON numbers. The only other JSON types are numbers for `bp`, `epoch`, `tenorYears` and `decimals`, the boolean `ok` of `GET /health`, and `null` for `callDeadline` outside a margin call: - `*Units`: integer in the token's smallest unit (7 decimals: 300 shares = `"3000000000"`). The keeper converts the contract's `i128` straight to a decimal string, without going through a JS `number`. @@ -148,6 +150,8 @@ Base URL: `http://localhost:8787/api` in local development; the public URL is se **Write routes** (`POST /session`, `submit`, `rate-shock`, `settle-default`, `deposit`, `withdraw`) respond once the transaction is confirmed or failed (`getTransaction` SUCCESS or FAILED). The `pending` entry is sent on the SSE stream before that. Without confirmation after 60 s, the route returns `RpcUnavailable` and the entry stays `pending` until its outcome is known. +**Unconfirmed transactions (v1.4).** When a transaction was sent but not confirmed (`RpcUnavailable`), its `pending` entry carries its `txHash`. The keeper then refuses every write and every `prepare` with `PendingTransaction` until it has looked the hash up: `SUCCESS` turns the entry `confirmed` (with its link), `FAILED` turns it `failed`, and a hash still not found 200 s after it was sent (longer than the validity of any transaction the keeper builds) turns it `failed` ("never included"). The keeper repeats the lookup every 15 s by itself. A hosted keeper keeps its session in a JSON file (`KEEPER_DATA_DIR`) and reloads it after a restart, checking the vault on-chain; the unconfirmed transaction is saved with it. The front's own 200 s guard is only a convenience: the keeper decides. + **Rate shock.** `bp` is added to the current float rate (cumulative: −50 then −30 = −80 bp from the start); `floatRateSource` becomes `"scenario"` after the first shock. If the requirement moves by less than the MTA, no transaction is sent and only a `rate_shock` `sim` entry is added. After `defaulted`, `rate-shock` returns `Defaulted` (409) without changing anything: `margin` stays frozen, so `margin.requiredUnits` and `margin.requiredValueEur` remain the last computed requirement, shown as "last computed requirement (historical)" while `vault.requiredUnits` is `"0"`. **SSE.** Two named events: @@ -165,6 +169,8 @@ Base URL: `http://localhost:8787/api` in local development; the public URL is se | contract error name (section 3.6) | 409 | the vault rejected the call, for example `GracePeriodRunning` | | `TxFailed` | 502 | transaction rejected or failed outside the vault's business rules (authorization, SAC, simulation); details in `message` | | `RpcUnavailable` | 503 | RPC unreachable, or no confirmation after 60 s | +| `Unauthorized` | 401 | (v1.4) write route called without a valid access code on a hosted keeper | +| `PendingTransaction` | 409 | (v1.4) a transaction sent earlier has no known outcome yet; no new write starts until it is confirmed, failed or expired (see below) | `SIGNER=keeper` is used by the end-to-end script and by the **public demo URL**, because judges cannot sign with the fund key. The front shows a visible notice in that mode. @@ -207,6 +213,8 @@ export interface KeystoneConfig { accounts: { fund: string; bank: string; keeper: string }; assets: AssetInfo[]; signerMode: SignerMode; + backend?: "testnet" | "fake"; // v1.4: "fake" = in-memory vault, no real transaction + writeAuth?: "token" | "none"; // v1.4: "token" = POST routes need `Authorization: Bearer ` } export interface SessionState { @@ -333,6 +341,7 @@ export interface MarketSnapshot { | Version | Date | Change | | --- | --- | --- | +| v1.4 | 2026-10-26 | Compatible addition for the hosted keeper (consuming pole: front): optional `backend` and `writeAuth` in `KeystoneConfig`, `Authorization: Bearer` on write routes, error codes `Unauthorized` (401) and `PendingTransaction` (409), reconciliation of unconfirmed transactions by hash, durable session. Local behaviour unchanged. | | v1.3 | 2026-10-26 | Compatible addition for front: read-only `GET /market-advisor`, diagnostic types in `packages/shared/src/market.ts`, real-source/freshness/cache semantics and explainable class scoring. Existing session/contract types and behaviours unchanged. | | v1.0-rc | 2026-10-25 | Initial proposal for the kickoff. | | v1.0 | 2026-10-25 | Kickoff amendments (Issue #5): `AssetId` `BOND` → `KBOND`; full constructor signature; state after `settle_default` and no rebalancing after `Defaulted`; deadline rules; SAC balance invariant `>=`; named event data; error precisions; keeper formats, error codes, write-route timing, rate-shock rule (refused after `defaulted`, `margin` frozen), single session; SSE `activity` and `state` events; `ActivityEvent.sessionId`; `ActivityType` mapping; `deployments/testnet.json` written by the keeper. | diff --git a/keeper/src/access.ts b/keeper/src/access.ts new file mode 100644 index 0000000..43ad95d --- /dev/null +++ b/keeper/src/access.ts @@ -0,0 +1,39 @@ +// Server-side authorization of the routes that spend or move anything (hosted keeper). +// A token is a secret known to the team and set only on the host (KEEPER_ACCESS_TOKENS): it never +// reaches the web bundle. CORS and the Host/Origin checks stop other web pages, not other programs. +import { createHash, timingSafeEqual } from "node:crypto"; + +export const MIN_TOKEN_LENGTH = 32; + +const digest = (value: string): Buffer => createHash("sha256").update(value).digest(); + +/** Parses KEEPER_ACCESS_TOKENS (comma separated, one per teammate so that one can be revoked alone). */ +export function parseAccessTokens(raw: string | undefined): string[] { + const tokens = (raw ?? "").split(",").map((t) => t.trim()).filter(Boolean); + for (const t of tokens) { + if (t.length < MIN_TOKEN_LENGTH) throw new Error(`Each KEEPER_ACCESS_TOKENS entry must have at least ${MIN_TOKEN_LENGTH} characters (use \`openssl rand -hex 24\`)`); + } + return tokens; +} + +/** Constant-time check of an `Authorization: Bearer ` header against the accepted tokens. */ +export function makeAuthorizer(tokens: string[]): (header: string | undefined) => boolean { + const accepted = tokens.map(digest); + return (header) => { + const m = /^Bearer (\S+)$/.exec(header ?? ""); + if (!m) return false; + const given = digest(m[1]!); + // Every token is compared, whatever the outcome, so the time taken does not tell which one matched. + return accepted.map((a) => timingSafeEqual(a, given)).reduce((any, ok) => any || ok, false); + }; +} + +/** `https://keystone-*-team.vercel.app` matches any single DNS label in place of `*`; other entries are exact. */ +export function originMatcher(patterns: string[]): (origin: string) => boolean { + const rules = patterns.map((p) => { + if (!p.includes("*")) return (o: string) => o === p; + const re = new RegExp("^" + p.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[a-z0-9-]+") + "$"); + return (o: string) => re.test(o); + }); + return (origin) => rules.some((r) => r(origin)); +} diff --git a/keeper/src/errors.ts b/keeper/src/errors.ts index 862a43b..d08b189 100644 --- a/keeper/src/errors.ts +++ b/keeper/src/errors.ts @@ -8,16 +8,22 @@ export type ContractErrorName = (typeof CONTRACT_ERRORS)[number]; export type ApiErrorCode = | "BadRequest" | "NoSession" | "SignerModeDisabled" | "TxFailed" | "RpcUnavailable" - | "NotFound" | "InternalError" | ContractErrorName; + | "Unauthorized" | "PendingTransaction" | "NotFound" | "InternalError" | ContractErrorName; const STATUS: Record = { - BadRequest: 400, NoSession: 404, NotFound: 404, SignerModeDisabled: 409, + BadRequest: 400, Unauthorized: 401, NoSession: 404, NotFound: 404, SignerModeDisabled: 409, PendingTransaction: 409, TxFailed: 502, RpcUnavailable: 503, InternalError: 500, }; export class KeeperError extends Error { readonly code: ApiErrorCode; readonly httpStatus: number; + /** Set when the transaction was sent but its outcome is unknown (RpcUnavailable): the key to reconcile it later. */ + txHash?: string; + /** Deploy only: the vault the unconfirmed transaction creates. */ + vaultId?: string; + /** The network refused the transaction before including it: nothing to reconcile. */ + notSent?: boolean; constructor(code: ApiErrorCode, message: string) { super(message); this.name = "KeeperError"; diff --git a/keeper/src/fake-vault-client.ts b/keeper/src/fake-vault-client.ts index e7b87c8..8c7ddfc 100644 --- a/keeper/src/fake-vault-client.ts +++ b/keeper/src/fake-vault-client.ts @@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto"; import { KeeperError, VaultError } from "./errors.ts"; import type { - DeployParams, SignedTxInfo, TxResult, VaultClient, VaultEvent, VaultState, + DeployParams, SignedTxInfo, TxResult, TxStatus, VaultClient, VaultEvent, VaultState, } from "./vault-client.ts"; const I128_MAX = (1n << 127n) - 1n; @@ -30,6 +30,13 @@ export class FakeVaultClient implements VaultClient { this.fundInitial = opts.fundInitialUnits ?? 10_000_000_000n; // 1 000 units, like the bootstrap } + /** Test hook: hashes set here answer SUCCESS; every other hash is NOT_FOUND. */ + readonly known = new Map(); + + async txStatus(hash: string): Promise { + return this.known.get(hash) ?? "NOT_FOUND"; + } + async ledgerTime(): Promise { return this.now(); } diff --git a/keeper/src/index.ts b/keeper/src/index.ts index 7b7e255..fb88027 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Keypair } from "vault-client"; import type { SignerMode } from "@keystone/shared"; +import { originMatcher, parseAccessTokens } from "./access.ts"; import { buildConfig } from "./config.ts"; import { getEuribor } from "./euribor.ts"; import { createMarketProvider } from './market-data.ts'; @@ -12,6 +13,7 @@ import { FakeVaultClient } from "./fake-vault-client.ts"; import { loadScenarioFile } from "./scenarios.ts"; import { buildServer, isLocalOrigin } from "./server.ts"; import { SessionManager } from "./session.ts"; +import { SessionStore } from "./store.ts"; import { TestnetVaultClient } from "./testnet-vault-client.ts"; import type { VaultClient } from "./vault-client.ts"; @@ -24,6 +26,18 @@ const signer = process.env.SIGNER || "freighter"; if (signer !== "freighter" && signer !== "keeper") throw new Error(`SIGNER must be freighter or keeper, got "${signer}"`); const port = Number(process.env.PORT || 8787); if (!Number.isInteger(port) || port <= 0) throw new Error("PORT must be a positive integer"); +// Loopback by default (a developer's machine). A hosted keeper sets KEEPER_HOST=0.0.0.0 and is then public: +// it needs access codes, an explicit list of hosts and origins, and never signs with the fund key. +const host = process.env.KEEPER_HOST || "127.0.0.1"; +const hosted = !["127.0.0.1", "localhost", "::1"].includes(host); +const accessTokens = parseAccessTokens(process.env.KEEPER_ACCESS_TOKENS); +if (hosted) { + if (signer !== "freighter") throw new Error("A hosted keeper must run with SIGNER=freighter: it never holds the fund key"); + if (accessTokens.length === 0) throw new Error("A hosted keeper needs KEEPER_ACCESS_TOKENS: its write routes cost fees and move collateral"); + if (!process.env.KEEPER_ALLOWED_ORIGINS) throw new Error("A hosted keeper needs KEEPER_ALLOWED_ORIGINS (the exact origin of the web app)"); + if (!process.env.KEEPER_PUBLIC_HOSTS) throw new Error("A hosted keeper needs KEEPER_PUBLIC_HOSTS (its own public host name)"); + if (process.env.FUND_SECRET) throw new Error("FUND_SECRET must not be set on a hosted keeper"); +} const backend = process.env.KEEPER_BACKEND; if (backend !== "fake" && backend !== "testnet") { console.error("Set KEEPER_BACKEND=testnet (real vault on Stellar Testnet) or KEEPER_BACKEND=fake (in-memory, fake hashes)."); @@ -58,23 +72,35 @@ function makeClient(): VaultClient { }); } -const extraOrigins = (process.env.KEEPER_ALLOWED_ORIGINS || "").split(",").map((o) => o.trim()).filter(Boolean); +const list = (name: string) => (process.env[name] || "").split(",").map((o) => o.trim()).filter(Boolean); +const extraOrigins = list("KEEPER_ALLOWED_ORIGINS"); +const matchesOrigin = originMatcher(extraOrigins); +const publicHosts = list("KEEPER_PUBLIC_HOSTS"); +// Where the keeper keeps what must survive a restart: on a host, a mounted volume (KEEPER_DATA_DIR=/data). +const dataDir = process.env.KEEPER_DATA_DIR || join(keeperDir, "data"); +const store = backend === "testnet" ? new SessionStore(dataDir) : undefined; const sessions = new SessionManager({ client: makeClient(), config, scenario, - euribor: () => getEuribor({ cachePath: join(keeperDir, "data", "euribor-cache.json") }), + euribor: () => getEuribor({ cachePath: join(dataDir, "euribor-cache.json") }), + store, }); +await sessions.restore(); +sessions.startReconciler(); const app = buildServer(config, sessions, { market: createMarketProvider({ - cachePath: join(keeperDir, 'data', 'market-cache.local.json'), + cachePath: join(dataDir, 'market-cache.local.json'), }), - allowedOrigin: (o) => isLocalOrigin(o) || extraOrigins.includes(o), - // a hosted deployment adds its own host names here - allowedHost: (h) => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(h) || extraOrigins.some((o) => new URL(o).host === h), + // On a host, pages served from the visitor's own machine are not trusted: only the configured origins. + allowedOrigin: (o) => (!hosted && isLocalOrigin(o)) || matchesOrigin(o), + allowedHost: (h) => (!hosted && /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(h)) || publicHosts.includes(h), + accessTokens, + backend, + trustProxyHops: hosted ? Number(process.env.KEEPER_TRUST_PROXY_HOPS || 1) : undefined, }); -await app.listen({ port, host: "127.0.0.1" }); +await app.listen({ port, host }); console.log( - `Keeper listening on http://127.0.0.1:${port}/api (SIGNER=${signer}, backend: ` + + `Keeper listening on http://${host}:${port}/api (SIGNER=${signer}, ` + (accessTokens.length ? `write routes need an access code, ` : "no access code, ") + `backend: ` + (backend === "fake" ? "in-memory FAKE, no Testnet transaction)" : "Stellar Testnet, real transactions)"), ); diff --git a/keeper/src/server.ts b/keeper/src/server.ts index bcddb03..7e631dc 100644 --- a/keeper/src/server.ts +++ b/keeper/src/server.ts @@ -3,11 +3,15 @@ import cors from "@fastify/cors"; import Fastify, { type FastifyInstance, type FastifyReply } from "fastify"; import type { AssetId, KeystoneConfig, MarketSnapshot } from "@keystone/shared"; import { createMarketProvider, unavailableMarket } from './market-data.ts'; +import { makeAuthorizer } from "./access.ts"; import { KeeperError } from "./errors.ts"; import type { SessionManager, StreamMessage } from "./session.ts"; const I128_MAX = (1n << 127n) - 1n; const HEARTBEAT_MS = 15_000; +const MAX_STREAMS = 100; +/** Railway's health probe calls /api/health with this Host; it is let through for that route only. */ +const PROBE_HOST = "healthcheck.railway.app"; function parseUnits(body: unknown): bigint { const units = (body as { units?: unknown } | null)?.units; @@ -30,12 +34,24 @@ export interface ServerOptions { allowedOrigin?: (origin: string) => boolean; /** Which `Host` header values are served. Default: loopback names only, against DNS rebinding. */ allowedHost?: (host: string) => boolean; + /** Accepted access codes. When set, every POST route needs `Authorization: Bearer `. */ + accessTokens?: string[]; + /** Shown by GET /config so the interface never presents a fake vault as the real one. Default "fake". */ + backend?: "testnet" | "fake"; + /** Number of reverse proxies in front of the keeper (hosted), for the client address. */ + trustProxyHops?: number; } export function buildServer(config: KeystoneConfig, sessions: SessionManager, options: ServerOptions = {}): FastifyInstance { const allowed = options.allowedOrigin ?? isLocalOrigin; const allowedHost = options.allowedHost ?? ((h: string) => LOCAL_HOST.test(h)); - const app = Fastify({ logger: false, forceCloseConnections: true }); + const tokens = options.accessTokens ?? []; + const authorized = makeAuthorizer(tokens); + const app = Fastify({ + logger: false, forceCloseConnections: true, + // Trust only the `trustProxyHops` addresses closest to the socket: a client cannot forge its own address. + ...(options.trustProxyHops ? { trustProxy: (_address: string, hop: number) => hop < options.trustProxyHops! } : {}), + }); const streams = new Set(); const market = options.market ?? createMarketProvider(); @@ -43,11 +59,22 @@ export function buildServer(config: KeystoneConfig, sessions: SessionManager, op // refuse any request whose Host or Origin is not expected. app.addHook("onRequest", async (req, reply) => { const origin = req.headers.origin; - if (!allowedHost(req.headers.host ?? "") || (origin !== undefined && !allowed(origin))) { + const probe = req.headers.host === PROBE_HOST && req.method === "GET" && req.url === "/api/health"; + if (!probe && (!allowedHost(req.headers.host ?? "") || (origin !== undefined && !allowed(origin)))) { return reply.status(403).send({ error: { code: "BadRequest", message: "Host or Origin not allowed" } }); } }); - app.register(cors, { origin: (origin, cb) => cb(null, !origin || allowed(origin)) }); + // Every route that can deploy a vault, post margin, spend fees or move collateral is a POST. + if (tokens.length > 0) { + // preParsing: after the CORS headers are added (so the browser can read the 401), before any body is read. + app.addHook("preParsing", async (req) => { + if (req.method === "OPTIONS" || req.method === "GET" || req.method === "HEAD") return; + if (!authorized(req.headers.authorization)) { + throw new KeeperError("Unauthorized", "A valid access code is required for this action"); + } + }); + } + app.register(cors, { allowedHeaders: ["authorization", "content-type"], origin: (origin, cb) => cb(null, !origin || allowed(origin)) }); app.setErrorHandler((err, _req, reply) => { if (err instanceof KeeperError) { @@ -71,7 +98,7 @@ export function buildServer(config: KeystoneConfig, sessions: SessionManager, op }; api.get("/health", async () => ({ ok: true, network: "testnet" as const })); - api.get("/config", async () => config); + api.get("/config", async () => ({ ...config, backend: options.backend ?? "fake", writeAuth: tokens.length > 0 ? "token" : "none" })); api.get('/market-advisor', async (_req, reply) => { reply.header('Cache-Control', 'no-store'); try { return await market(); } @@ -119,6 +146,9 @@ export function buildServer(config: KeystoneConfig, sessions: SessionManager, op api.post("/session/settle-default", async () => sessions.settleDefault()); api.get("/events", (req, reply) => { + if (streams.size >= MAX_STREAMS) { + return reply.status(503).send({ error: { code: "RpcUnavailable", message: "Too many open streams: try again shortly" } }); + } reply.hijack(); const res = reply.raw; const origin = req.headers.origin; diff --git a/keeper/src/session.ts b/keeper/src/session.ts index b76aebb..c6e403a 100644 --- a/keeper/src/session.ts +++ b/keeper/src/session.ts @@ -10,6 +10,7 @@ import { type CollateralParams, type MarginResult, type SwapParams, } from "./margin.ts"; import type { ScenarioFile } from "./scenarios.ts"; +import type { SessionStore } from "./store.ts"; import type { OnChainStatus, TxResult, VaultClient, VaultEvent, VaultState } from "./vault-client.ts"; export type StreamMessage = @@ -22,8 +23,38 @@ export interface SessionDeps { scenario: ScenarioFile; euribor: () => Promise; now?: () => Date; + /** Durable copy of the session (hosted keeper). Without it the session lives in memory only. */ + store?: SessionStore; } +/** + * A transaction that was sent but whose outcome is unknown (RPC timeout, restart). No new write runs + * until it is looked up by hash and found confirmed, failed, or expired without being included. + */ +interface Unresolved { + hash: string; + entryId: string; + kind: "deploy" | "write"; + /** Epoch ms after which a transaction still NOT_FOUND can no longer be included in a ledger. */ + notAfter: number; + doneTitle: string; + doneDetail?: string; + vaultId?: string; // deploy: the vault created by the transaction + postedValueCents?: bigint; // margin: becomes lastPostedValueCents once confirmed +} + +/** What `chain` needs to remember an unconfirmed transaction. */ +interface Track { + kind?: "deploy"; + preHash?: string; // hash known before sending (wallet-signed transactions) + doneTitle: string; + doneDetail?: string; + postedValueCents?: bigint; +} + +/** Margin of safety over the 180 s validity of a prepared transaction and the 60 s of a keeper-signed one. */ +const EXPIRY_MS = 200_000; + const STATUS: Record = { Active: "active", MarginCall: "margin_call", Defaulted: "defaulted" }; const MAX_BP = 1000; @@ -43,6 +74,8 @@ interface Session { export class SessionManager { private session: Session | null = null; + private pendingDeploy: Session | null = null; // session whose vault deployment is unconfirmed + private unresolved: Unresolved | null = null; private readonly listeners = new Set<(m: StreamMessage) => void>(); private queue: Promise = Promise.resolve(); private seq = 0; @@ -54,6 +87,93 @@ export class SessionManager { this.now = deps.now ?? (() => new Date()); } + // ---- persistence and reconciliation ------------------------------------------------------ + + /** Reloads the saved session after a restart and settles any transaction left unconfirmed. */ + async restore(): Promise { + const store = this.deps.store; + const data = store?.load(); + if (!store || !data) return; + this.seq = typeof data.seq === "number" ? data.seq : 0; + this.session = (data.session as Session | null) ?? null; + this.pendingDeploy = (data.pendingDeploy as Session | null) ?? null; + this.unresolved = (data.unresolved as Unresolved | null) ?? null; + if (this.session) { + try { + await this.publishState(this.session); + } catch (e) { + // Unreachable RPC: keep the session, the state is rebuilt on the next read. Any other failure + // (the vault no longer exists, for example after a Testnet reset): keep a copy and start clean. + if (!(e instanceof KeeperError && e.code === "RpcUnavailable")) { + store.backup("dropped"); + console.error(`Saved session dropped (a copy was kept): ${e instanceof Error ? e.message : String(e)}`); + this.session = null; + this.persist(); + } + } + } + if (this.unresolved) await this.enqueue(() => this.reconcile()).catch(() => undefined); + } + + /** Looks up the unconfirmed transaction (if any) every `intervalMs`, so the feed settles by itself. */ + startReconciler(intervalMs = 15_000): () => void { + const timer = setInterval(() => { + if (this.unresolved) void this.enqueue(() => this.reconcile()).catch(() => undefined); + }, intervalMs); + timer.unref(); + return () => clearInterval(timer); + } + + private persist(): void { + const strip = (s: Session | null) => (s ? { ...s, lastState: null } : null); + this.deps.store?.save({ + seq: this.seq, session: strip(this.session), pendingDeploy: strip(this.pendingDeploy), unresolved: this.unresolved, + }); + } + + /** + * Settles the unconfirmed transaction by hash. Throws PendingTransaction while it may still be + * included, and RpcUnavailable when the network cannot be asked: a write never starts in that case. + */ + private async reconcile(): Promise { + const u = this.unresolved; + if (!u) return; + const s = u.kind === "deploy" ? this.pendingDeploy : this.session; + const entry = s?.feed.find((e) => e.id === u.entryId); + const status = await this.deps.client.txStatus(u.hash); + if (status === "NOT_FOUND" && this.now().getTime() <= u.notAfter) { + throw new KeeperError("PendingTransaction", + `Transaction ${u.hash} was sent but is not confirmed yet. No new transaction starts until its outcome is known.`); + } + this.unresolved = null; + if (status === "SUCCESS") { + if (entry) { + this.update(entry, { + status: "confirmed", title: u.doneTitle, detail: u.doneDetail ?? "Confirmed after the keeper stopped waiting.", + txHash: u.hash, explorerUrl: `${this.deps.config.explorerTxBase}${u.hash}`, + }); + } + if (s && u.kind === "deploy" && u.vaultId) { + s.vaultId = u.vaultId; + this.pendingDeploy = null; + this.session = s; + } else if (s && u.postedValueCents !== undefined) { + s.lastPostedValueCents = u.postedValueCents; + } + this.persist(); + if (s?.vaultId) await this.publishState(s).catch(() => undefined); + return; + } + if (entry) { + this.update(entry, { + status: "failed", + detail: status === "FAILED" ? `Transaction ${u.hash} failed on-chain.` : `Transaction ${u.hash} was never included in a ledger.`, + }); + } + if (u.kind === "deploy") this.pendingDeploy = null; + this.persist(); + } + // ---- streaming ------------------------------------------------------------------------- /** Messages to replay on a new SSE connection: the whole history, then the current state. */ @@ -78,7 +198,7 @@ export class SessionManager { // ---- writes ---------------------------------------------------------------------------- start(asset: AssetId): Promise { - return this.enqueue(async () => { + return this.exclusive(async () => { const info = this.deps.config.assets.find((a) => a.id === asset); if (!info) throw new KeeperError("BadRequest", `Unknown asset ${asset}`); const rate = await this.deps.euribor().catch((e: Error) => { @@ -100,50 +220,62 @@ export class SessionManager { }; // The new session replaces the current one only once its vault is deployed. const { accounts } = this.deps.config; - const deployed = await this.chain(s, "session_started", "Deploying a new vault", async () => { - const r = await this.deps.client.deploy({ - fund: accounts.fund, bank: accounts.bank, keeper: accounts.keeper, assetSac: info.sac, - maxRequired: BigInt(scenario.csa.maxRequiredUnits), gracePeriodSecs: BigInt(scenario.csa.gracePeriodSecs), - }); - return { ...r, events: [] as VaultEvent[] }; - }, (r) => ({ title: `Vault deployed for ${info.code}`, detail: `Vault ${r.vaultId}` })); + this.pendingDeploy = s; + let deployed; + try { + deployed = await this.chain(s, "session_started", "Deploying a new vault", async () => { + const r = await this.deps.client.deploy({ + fund: accounts.fund, bank: accounts.bank, keeper: accounts.keeper, assetSac: info.sac, + maxRequired: BigInt(scenario.csa.maxRequiredUnits), gracePeriodSecs: BigInt(scenario.csa.gracePeriodSecs), + }); + return { ...r, events: [] as VaultEvent[] }; + }, (r) => ({ title: `Vault deployed for ${info.code}`, detail: `Vault ${r.vaultId}` }), undefined, + { kind: "deploy", doneTitle: `Vault deployed for ${info.code}` }); + } catch (e) { + if (!this.unresolved) this.pendingDeploy = null; // a deployment that may still land stays tracked + this.persist(); + throw e; + } + this.pendingDeploy = null; s.vaultId = deployed.vaultId; this.session = s; + this.persist(); for (const e of s.feed) this.emit({ event: "activity", data: { ...e } }); // clients now know this session id return this.publishState(s); }); } deposit(units: bigint): Promise { - return this.enqueue(async () => { + return this.exclusive(async () => { const s = this.require(); await this.chain(s, "deposit", `Depositing ${this.amount(s, units)}`, () => this.deps.client.deposit(s.vaultId!, units), - () => ({ title: `Deposit of ${this.amount(s, units)}` })); + () => ({ title: `Deposit of ${this.amount(s, units)}` }), undefined, { doneTitle: `Deposit of ${this.amount(s, units)}` }); return this.publishState(s); }); } withdraw(units: bigint): Promise { - return this.enqueue(async () => { + return this.exclusive(async () => { const s = this.require(); await this.chain(s, "withdraw", `Withdrawing ${this.amount(s, units)}`, () => this.deps.client.withdraw(s.vaultId!, units), - () => ({ title: `Withdrawal of ${this.amount(s, units)}` })); + () => ({ title: `Withdrawal of ${this.amount(s, units)}` }), undefined, { doneTitle: `Withdrawal of ${this.amount(s, units)}` }); return this.publishState(s); }); } prepare(action: "deposit" | "withdraw", units: bigint): Promise { - return this.enqueue(async () => this.deps.client.prepare(this.require().vaultId!, action, units)); + return this.exclusive(async () => this.deps.client.prepare(this.require().vaultId!, action, units)); } submit(signedTxXdr: string): Promise { - return this.enqueue(async () => { + return this.exclusive(async () => { const s = this.require(); const info = this.deps.client.inspectSigned(signedTxXdr); const label = info.action === "deposit" ? "Deposit" : "Withdrawal"; await this.chain(s, info.action, `${label} of ${this.amount(s, info.units)} signed in the wallet`, () => this.deps.client.submitSigned(s.vaultId!, signedTxXdr), - () => ({ title: `${label} of ${this.amount(s, info.units)}`, detail: "Signed by the fund in the wallet" })); + () => ({ title: `${label} of ${this.amount(s, info.units)}`, detail: "Signed by the fund in the wallet" }), undefined, + { preHash: info.txHash, doneTitle: `${label} of ${this.amount(s, info.units)}`, doneDetail: "Signed by the fund in the wallet" }); return this.publishState(s); }); } @@ -153,7 +285,7 @@ export class SessionManager { * the MTA. The rate change is kept even if the posting fails: a shock of 0 bp retries the posting. */ rateShock(bp: number): Promise { - return this.enqueue(async () => { + return this.exclusive(async () => { const s = this.require(); if (!Number.isInteger(bp) || Math.abs(bp) > MAX_BP) { throw new KeeperError("BadRequest", `bp must be an integer between -${MAX_BP} and ${MAX_BP}`); @@ -193,8 +325,10 @@ export class SessionManager { detail: `Locked ${formatUnits(r.state.locked)}, shortfall ${formatUnits(r.state.shortfall)} ${s.asset}`, }), before.locked, + { doneTitle: `Margin requirement posted (epoch ${epoch}): ${formatUnits(margin.requiredUnits)} ${s.asset} required`, postedValueCents: margin.requiredValueCents }, ); s.lastPostedValueCents = margin.requiredValueCents; + this.persist(); } } catch (e) { // The new rate and requirement are kept: tell the stream, a 0 bp shock retries the posting. @@ -206,13 +340,13 @@ export class SessionManager { } settleDefault(): Promise { - return this.enqueue(async () => { + return this.exclusive(async () => { const s = this.require(); await this.chain(s, "defaulted", "Settling the default", () => this.deps.client.settleDefault(s.vaultId!), (r) => { const paid = r.events.find((e) => e.type === "defaulted"); return { title: `Default settled: ${formatUnits(paid?.type === "defaulted" ? paid.amount : 0n)} ${s.asset} transferred to the bank` }; - }); + }, undefined, { doneTitle: "Default settled" }); return this.publishState(s); }); } @@ -231,14 +365,30 @@ export class SessionManager { run: () => Promise, done: (r: T) => { title: string; detail?: string }, lockedBefore?: bigint, + track?: Track, ): Promise { - const entry = this.add(s, { kind: "chain", type, title: pendingTitle, status: "pending" }); + const entry = this.add(s, { + kind: "chain", type, title: pendingTitle, status: "pending", + ...(track?.preHash ? { txHash: track.preHash, explorerUrl: `${this.deps.config.explorerTxBase}${track.preHash}` } : {}), + }); let result: T; try { result = await run(); } catch (e) { if (e instanceof KeeperError && e.code === "RpcUnavailable") { - this.update(entry, { detail: `No confirmation yet: ${e.message}` }); + const hash = e.notSent ? undefined : (e.txHash ?? track?.preHash); + this.update(entry, { + detail: `No confirmation yet: ${e.message}`, + ...(hash ? { txHash: hash, explorerUrl: `${this.deps.config.explorerTxBase}${hash}` } : {}), + }); + // The transaction may still land: remember it so that nothing else is sent before its outcome is known. + if (hash && track) { + this.unresolved = { + hash, entryId: entry.id, kind: track.kind ?? "write", notAfter: this.now().getTime() + EXPIRY_MS, + doneTitle: track.doneTitle, doneDetail: track.doneDetail, vaultId: e.vaultId, postedValueCents: track.postedValueCents, + }; + this.persist(); + } } else { this.update(entry, { status: "failed", detail: e instanceof Error ? e.message : String(e) }); } @@ -265,12 +415,14 @@ export class SessionManager { const entry: ActivityEvent = { id: `${s.id}-${++this.seq}`, sessionId: s.id, ts: this.now().toISOString(), ...e }; s.feed.push(entry); this.emit({ event: "activity", data: { ...entry } }); + this.persist(); return entry; } private update(entry: ActivityEvent, patch: Partial): void { Object.assign(entry, patch); this.emit({ event: "activity", data: { ...entry } }); + this.persist(); } private emit(m: StreamMessage): void { @@ -337,6 +489,14 @@ export class SessionManager { return this.session; } + /** A write: runs in the queue, after the previous unconfirmed transaction (if any) has been settled. */ + private exclusive(job: () => Promise): Promise { + return this.enqueue(async () => { + await this.reconcile(); + return job(); + }); + } + private enqueue(job: () => Promise): Promise { const run = this.queue.then(job); this.queue = run.catch(() => undefined); diff --git a/keeper/src/store.ts b/keeper/src/store.ts new file mode 100644 index 0000000..93fa11f --- /dev/null +++ b/keeper/src/store.ts @@ -0,0 +1,53 @@ +// Durable copy of the keeper's session, so a restart or a new deployment does not forget a vault that +// already holds tokens. One JSON file, written atomically; bigint values are tagged to survive JSON. +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const VERSION = 1; + +const replacer = (_k: string, v: unknown) => (typeof v === "bigint" ? { $big: v.toString() } : v); +const reviver = (_k: string, v: unknown) => + v && typeof v === "object" && "$big" in v && typeof (v as { $big: unknown }).$big === "string" + ? BigInt((v as { $big: string }).$big) + : v; + +export type PersistedState = Record & { version: typeof VERSION }; + +export class SessionStore { + private readonly file: string; + + constructor(dir: string) { + mkdirSync(dir, { recursive: true }); + this.file = join(dir, "session.json"); + } + + /** The saved state, or null when there is none or it is unreadable (an unreadable file is kept as a backup). */ + load(): PersistedState | null { + if (!existsSync(this.file)) return null; + try { + const data = JSON.parse(readFileSync(this.file, "utf8"), reviver) as PersistedState; + if (data?.version === VERSION) return data; + } catch { /* falls through to the backup */ } + this.backup("unreadable"); + return null; + } + + save(data: Omit): void { + try { + const tmp = `${this.file}.tmp`; + writeFileSync(tmp, JSON.stringify({ version: VERSION, ...data }, replacer), { mode: 0o600 }); + renameSync(tmp, this.file); + chmodSync(this.file, 0o600); + } catch (e) { + // Never break an operation because the disk is unavailable, but never stay silent about it. + console.error(`Keeper could not save its session: ${e instanceof Error ? e.message : String(e)}`); + } + } + + /** Keeps a copy of the current file next to it (used before a saved session is dropped). */ + backup(reason: string): void { + try { + if (existsSync(this.file)) copyFileSync(this.file, `${this.file}.${reason}-${Date.now()}.bak`); + } catch { /* best effort */ } + } +} diff --git a/keeper/src/testnet-vault-client.ts b/keeper/src/testnet-vault-client.ts index cfb6570..d029215 100644 --- a/keeper/src/testnet-vault-client.ts +++ b/keeper/src/testnet-vault-client.ts @@ -6,7 +6,7 @@ import { } from "vault-client"; import { CONTRACT_ERRORS, KeeperError, VaultError } from "./errors.ts"; import type { - DeployParams, OnChainStatus, SignedTxInfo, TxResult, VaultClient, VaultEvent, VaultState, + DeployParams, OnChainStatus, SignedTxInfo, TxResult, TxStatus, VaultClient, VaultEvent, VaultState, } from "./vault-client.ts"; export interface TestnetOptions { @@ -83,14 +83,22 @@ export class TestnetVaultClient implements VaultClient { }); } + async txStatus(hash: string): Promise { + return this.guard(async () => { + const res = await this.server.getTransaction(hash); + return res.status === "SUCCESS" ? "SUCCESS" : res.status === "FAILED" ? "FAILED" : "NOT_FOUND"; + }); + } + async deploy(p: DeployParams): Promise<{ vaultId: string; txHash: string }> { return this.guard(async () => { const tx = await VaultBindings.deploy( { fund: p.fund, bank: p.bank, keeper: p.keeper, asset: p.assetSac, max_required: p.maxRequired, grace_period_secs: p.gracePeriodSecs }, { ...this.base(this.o.keeper), timeoutInSeconds: this.timeout, wasmHash: this.o.wasmHash, format: "hex" }, ); - const sent = await tx.signAndSend(); - const vaultId = (sent.result as VaultBindings).options.contractId; + // The contract address is known from the simulation: keep it if the confirmation times out. + const vaultId = (tx.result as VaultBindings).options.contractId; + const sent = await this.send(tx, vaultId); return { vaultId, txHash: this.hashOf(sent) }; }); } @@ -158,7 +166,10 @@ export class TestnetVaultClient implements VaultClient { if ((action !== "deposit" && action !== "withdraw") || args.length !== 1 || args[0]!.switch().name !== "scvI128") { throw new Error("not a deposit or withdraw of an amount"); } - return { action, units: BigInt(scValToNative(args[0]!) as bigint), vaultId: Address.fromScAddress(call.contractAddress()).toString() }; + return { + action, units: BigInt(scValToNative(args[0]!) as bigint), vaultId: Address.fromScAddress(call.contractAddress()).toString(), + txHash: tx.hash().toString("hex"), + }; } catch { throw new KeeperError("BadRequest", "Only a deposit or withdraw of an i128 amount on the vault may be submitted"); } @@ -171,7 +182,9 @@ export class TestnetVaultClient implements VaultClient { const tx = TransactionBuilder.fromXDR(signedTxXdr, this.o.networkPassphrase); const sent = await this.server.sendTransaction(tx); if (sent.status === "ERROR" || sent.status === "TRY_AGAIN_LATER") { - throw new KeeperError(sent.status === "ERROR" ? "TxFailed" : "RpcUnavailable", `The network refused the transaction (${sent.status})`); + const refused = new KeeperError(sent.status === "ERROR" ? "TxFailed" : "RpcUnavailable", `The network refused the transaction (${sent.status})`); + refused.notSent = true; + throw refused; } const res = await this.confirm(sent.hash); return { txHash: sent.hash, events: await this.eventsOf(vaultId, sent.hash, res.ledger), state: await this.getState(vaultId) }; @@ -202,7 +215,7 @@ export class TestnetVaultClient implements VaultClient { ): Promise { return this.guard(vaultId, async () => { const tx = await call(this.bindings(vaultId, signer), { timeoutInSeconds: this.timeout }); - const sent = await tx.signAndSend(); + const sent = await this.send(tx, vaultId); const txHash = this.hashOf(sent); const res = await this.confirm(txHash); // The transaction is confirmed: a failing event read must not turn it into an error. @@ -211,6 +224,22 @@ export class TestnetVaultClient implements VaultClient { }); } + /** signAndSend, tagging a lost confirmation with the hash of the transaction that was already sent. */ + private async send; sendTransactionResponse?: { hash: string } }>( + tx: T, vaultId?: string, + ): Promise>> { + try { + return (await tx.signAndSend()) as Awaited>; + } catch (e) { + const mapped = mapChainError(e, vaultId); + if (mapped.code === "RpcUnavailable" && tx.sendTransactionResponse?.hash) { + mapped.txHash = tx.sendTransactionResponse.hash; + if (vaultId) mapped.vaultId = vaultId; + } + throw mapped; + } + } + private hashOf(sent: { sendTransactionResponse?: { hash: string } }): string { const hash = sent.sendTransactionResponse?.hash; if (!hash) throw new KeeperError("TxFailed", "The transaction was not submitted"); @@ -222,7 +251,9 @@ export class TestnetVaultClient implements VaultClient { const res = await this.server.pollTransaction(hash, { attempts: Math.ceil(this.timeout / 2), sleepStrategy: () => 2000 }); if (res.status === "SUCCESS") return { ledger: res.ledger }; if (res.status === "FAILED") throw new KeeperError("TxFailed", `Transaction ${hash} failed on-chain`); - throw new KeeperError("RpcUnavailable", `No confirmation for ${hash} after ${this.timeout}s`); + const pending = new KeeperError("RpcUnavailable", `No confirmation for ${hash} after ${this.timeout}s`); + pending.txHash = hash; + throw pending; } private async eventsOf(vaultId: string, hash: string, ledger: number): Promise { diff --git a/keeper/src/vault-client.ts b/keeper/src/vault-client.ts index 78cfc01..fea4cef 100644 --- a/keeper/src/vault-client.ts +++ b/keeper/src/vault-client.ts @@ -49,8 +49,13 @@ export interface DeployParams { export interface SignedTxInfo { action: "deposit" | "withdraw"; units: bigint; + /** Hash of the signed transaction, known before it is sent (absent on the in-memory vault). */ + txHash?: string; } +/** Outcome of a transaction looked up by hash. NOT_FOUND: not (yet) included in a ledger. */ +export type TxStatus = "SUCCESS" | "FAILED" | "NOT_FOUND"; + /** * Failures: a business rejection throws `VaultError` (409, section 3.6); a rejection outside the * vault's rules throws `KeeperError("TxFailed")`; an unreachable RPC or a missing confirmation @@ -58,6 +63,8 @@ export interface SignedTxInfo { * confirmed. */ export interface VaultClient { + /** Looks a transaction up by hash; used to settle entries left pending by a timeout. */ + txStatus(hash: string): Promise; /** Ledger time in seconds: the authority for deadlines. */ ledgerTime(): Promise; deploy(params: DeployParams): Promise<{ vaultId: string; txHash: string }>; diff --git a/keeper/test/hosted.test.ts b/keeper/test/hosted.test.ts new file mode 100644 index 0000000..1d433dc --- /dev/null +++ b/keeper/test/hosted.test.ts @@ -0,0 +1,194 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { makeAuthorizer, originMatcher, parseAccessTokens } from "../src/access.ts"; +import { KeeperError } from "../src/errors.ts"; +import { FakeVaultClient } from "../src/fake-vault-client.ts"; +import { buildServer } from "../src/server.ts"; +import { SessionManager } from "../src/session.ts"; +import { SessionStore } from "../src/store.ts"; +import type { TxResult } from "../src/vault-client.ts"; +import { config, scenario } from "./helpers.ts"; + +const TOKEN = "t".repeat(40); +const WEB = "https://keystone-web.vercel.app"; +const euribor = async () => ({ period: "2026-09", rateMilli: 2034, fetchedAt: "2026-10-25T00:00:00.000Z", source: "ecb" as const }); + +describe("access codes", () => { + it("refuses short tokens and accepts a list", () => { + expect(() => parseAccessTokens("short")).toThrow(/at least 32/); + expect(parseAccessTokens(` ${TOKEN} , ${"u".repeat(32)} `)).toHaveLength(2); + expect(parseAccessTokens(undefined)).toEqual([]); + }); + + it("checks the bearer header against every token", () => { + const ok = makeAuthorizer([TOKEN, "u".repeat(32)]); + expect(ok(`Bearer ${TOKEN}`)).toBe(true); + expect(ok(`Bearer ${"u".repeat(32)}`)).toBe(true); + expect(ok(`Bearer ${TOKEN}x`)).toBe(false); + expect(ok(TOKEN)).toBe(false); + expect(ok(undefined)).toBe(false); + expect(makeAuthorizer([])("Bearer x")).toBe(false); + }); + + it("matches exact origins and single-label wildcards only", () => { + const m = originMatcher([WEB, "https://keystone-*-team.vercel.app"]); + expect(m(WEB)).toBe(true); + expect(m("https://keystone-abc123-team.vercel.app")).toBe(true); + expect(m("https://keystone-a.b-team.vercel.app")).toBe(false); + expect(m("https://keystone-abc-team.vercel.app.evil.com")).toBe(false); + expect(m("http://keystone-web.vercel.app")).toBe(false); + }); +}); + +describe("hosted API", () => { + const make = () => { + const client = new FakeVaultClient(); + const sessions = new SessionManager({ client, config: config("freighter"), scenario, euribor }); + const app = buildServer(config("freighter"), sessions, { + accessTokens: [TOKEN], backend: "testnet", + allowedOrigin: (o) => o === WEB, allowedHost: (h) => h === "keeper.example.com", + }); + const call = (method: "GET" | "POST" | "OPTIONS", url: string, headers: Record = {}, payload?: object) => + app.inject({ method, url: `/api${url}`, headers: { host: "keeper.example.com", origin: WEB, ...headers }, payload }); + return { app, call }; + }; + + it("lets anyone read but nobody write without the code", async () => { + const { call } = make(); + expect((await call("GET", "/health")).statusCode).toBe(200); + const cfg = (await call("GET", "/config")).json(); + expect(cfg).toMatchObject({ backend: "testnet", writeAuth: "token", signerMode: "freighter" }); + for (const [url, body] of [ + ["/session", { asset: "KMMF" }], ["/session/rate-shock", { bp: 10 }], ["/session/settle-default", {}], + ["/session/deposit/prepare", { units: "1" }], ["/session/withdraw/prepare", { units: "1" }], + ["/session/submit", { signedTxXdr: "x" }], ["/session/deposit", { units: "1" }], ["/session/withdraw", { units: "1" }], + ] as const) { + const none = await call("POST", url, {}, body); + expect(none.statusCode, url).toBe(401); + expect(none.json().error.code).toBe("Unauthorized"); + expect((await call("POST", url, { authorization: "Bearer wrong" }, body)).statusCode, url).toBe(401); + } + }); + + it("answers 401 with CORS headers so the page can read it, and serves preflights without a code", async () => { + const { call } = make(); + const denied = await call("POST", "/session", {}, { asset: "KMMF" }); + expect(denied.headers["access-control-allow-origin"]).toBe(WEB); + const pre = await call("OPTIONS", "/session", { "access-control-request-method": "POST", "access-control-request-headers": "authorization,content-type" }); + expect(pre.statusCode).toBe(204); + expect(String(pre.headers["access-control-allow-headers"]).toLowerCase()).toContain("authorization"); + }); + + it("serves the writes with the code and keeps the Host and Origin checks", async () => { + const { call } = make(); + const auth = { authorization: `Bearer ${TOKEN}` }; + expect((await call("POST", "/session", auth, { asset: "KMMF" })).statusCode).toBe(200); + expect((await call("POST", "/session/deposit", auth, { units: "1" })).json().error.code).toBe("SignerModeDisabled"); + expect((await call("GET", "/session", { host: "evil.example.com" })).statusCode).toBe(403); + expect((await call("GET", "/session", { origin: "https://evil.example.com" })).statusCode).toBe(403); + }); + + it("lets the platform probe reach /api/health only", async () => { + const { call } = make(); + expect((await call("GET", "/health", { host: "healthcheck.railway.app", origin: "" })).statusCode).toBe(200); + expect((await call("GET", "/config", { host: "healthcheck.railway.app" })).statusCode).toBe(403); + }); +}); + +describe("durable session and uncertain transactions", () => { + let dir = ""; + afterEach(() => dir && rmSync(dir, { recursive: true, force: true })); + + /** A vault whose next deposit is sent but never confirmed (RPC timeout). */ + class Flaky extends FakeVaultClient { + lose: string | null = null; + override async deposit(vaultId: string, units: bigint): Promise { + if (this.lose) { + const e = new KeeperError("RpcUnavailable", "No confirmation"); + e.txHash = this.lose; + throw e; + } + return super.deposit(vaultId, units); + } + } + + const make = (client = new Flaky(), clock = { ms: 1_800_000_000_000 }) => { + dir = dir || mkdtempSync(join(tmpdir(), "keystone-store-")); + const sessions = new SessionManager({ + client, config: config("keeper"), scenario, euribor, store: new SessionStore(dir), now: () => new Date(clock.ms), + }); + return { client, clock, sessions }; + }; + + it("restores the session and its feed after a restart", async () => { + const a = make(); + const started = await a.sessions.start("KMMF"); + await a.sessions.deposit(5_000_000n); + const b = make(a.client, a.clock); + await b.sessions.restore(); + const state = await b.sessions.getState(); + expect(state.vaultId).toBe(started.vaultId); + expect(state.sessionId).toBe(started.sessionId); + expect(state.vault.availableUnits).toBe("5000000"); + const feed = b.sessions.replay().filter((m) => m.event === "activity"); + expect(feed.map((m) => (m.data as { type: string }).type)).toEqual(["session_started", "deposit"]); + // bigint fields survived: a rate shock still computes against the restored swap + await expect(b.sessions.rateShock(50)).resolves.toBeTruthy(); + }); + + it("starts clean when the saved vault no longer exists, keeping a copy", async () => { + const a = make(); + await a.sessions.start("KMMF"); + const b = make(new Flaky(), a.clock); // a fresh chain: the saved vault is unknown to it + await b.sessions.restore(); + await expect(b.sessions.getState()).rejects.toMatchObject({ code: "NoSession" }); + }); + + it("blocks every write while a sent transaction is unconfirmed, then settles it by hash", async () => { + const { client, sessions } = make(); + await sessions.start("KMMF"); + client.lose = "ab".repeat(32); + await expect(sessions.deposit(1_000_000n)).rejects.toMatchObject({ code: "RpcUnavailable" }); + client.lose = null; + + // Not found yet and still inside its validity window: nothing else may be sent. + for (const attempt of [() => sessions.deposit(1_000_000n), () => sessions.rateShock(10), () => sessions.start("KBOND"), () => sessions.prepare("deposit", 1n)]) { + await expect(attempt()).rejects.toMatchObject({ code: "PendingTransaction" }); + } + const pending = sessions.replay().map((m) => m.data as { status: string; txHash?: string }).find((e) => e.status === "pending"); + expect(pending?.txHash).toBe("ab".repeat(32)); + + // The network reports it confirmed: the entry settles and the next write goes through. + client.known.set("ab".repeat(32), "SUCCESS"); + await sessions.deposit(2_000_000n); + const types = sessions.replay().filter((m) => m.event === "activity").map((m) => m.data as { status: string; txHash?: string }); + expect(types.filter((e) => e.status === "pending")).toHaveLength(0); + expect(types.find((e) => e.txHash === "ab".repeat(32))?.status).toBe("confirmed"); + }); + + it("declares a transaction that never landed failed only after it has expired", async () => { + const { client, clock, sessions } = make(); + await sessions.start("KMMF"); + client.lose = "cd".repeat(32); + await expect(sessions.deposit(1_000_000n)).rejects.toMatchObject({ code: "RpcUnavailable" }); + client.lose = null; + await expect(sessions.deposit(1_000_000n)).rejects.toMatchObject({ code: "PendingTransaction" }); + clock.ms += 300_000; + await expect(sessions.deposit(1_000_000n)).resolves.toBeTruthy(); + const lost = sessions.replay().map((m) => m.data as { txHash?: string; status: string }).find((e) => e.txHash === "cd".repeat(32)); + expect(lost?.status).toBe("failed"); + }); + + it("remembers an unconfirmed transaction across a restart", async () => { + const a = make(); + await a.sessions.start("KMMF"); + a.client.lose = "ef".repeat(32); + await expect(a.sessions.deposit(1_000_000n)).rejects.toMatchObject({ code: "RpcUnavailable" }); + a.client.lose = null; + const b = make(a.client, a.clock); + await b.sessions.restore(); + await expect(b.sessions.deposit(1_000_000n)).rejects.toMatchObject({ code: "PendingTransaction" }); + }); +}); diff --git a/keeper/test/testnet-client.test.ts b/keeper/test/testnet-client.test.ts index 8f77ca1..a705158 100644 --- a/keeper/test/testnet-client.test.ts +++ b/keeper/test/testnet-client.test.ts @@ -79,7 +79,7 @@ describe("inspectSigned only accepts a signed deposit or withdraw on the vault", const code = (xdrString: string) => { try { client.inspectSigned(xdrString); } catch (e) { return (e as KeeperError).code; } return null; }; it("accepts a signed deposit and a withdraw", () => { - expect(client.inspectSigned(build([call("deposit", amount)]))).toEqual({ action: "deposit", units: 10000000n, vaultId: VAULT }); + expect(client.inspectSigned(build([call("deposit", amount)]))).toEqual({ action: "deposit", units: 10000000n, vaultId: VAULT, txHash: expect.stringMatching(/^[0-9a-f]{64}$/) }); expect(client.inspectSigned(build([call("withdraw", amount)])).action).toBe("withdraw"); }); it("refuses everything else with BadRequest", () => { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index d9f2a71..b2a3a47 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -23,6 +23,10 @@ export interface KeystoneConfig { accounts: { fund: string; bank: string; keeper: string }; assets: AssetInfo[]; signerMode: SignerMode; + /** Optional (v1.3): which vault backs the keeper. "fake" means no real transaction. */ + backend?: "testnet" | "fake"; + /** Optional (v1.3): "token" when the write routes need `Authorization: Bearer `. */ + writeAuth?: "token" | "none"; } export interface SessionState { diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..979d150 --- /dev/null +++ b/railway.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { "builder": "DOCKERFILE", "dockerfilePath": "Dockerfile" }, + "deploy": { + "healthcheckPath": "/api/health", + "healthcheckTimeout": 60, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 5 + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..0dfea38 --- /dev/null +++ b/vercel.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "installCommand": "npm ci", + "buildCommand": "npm run build -w @keystone/app", + "outputDirectory": "app/dist", + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" } + ] + } + ] +} From e70957ecc283286ae3c3c83181fd3c1d4b0579b2 Mon Sep 17 00:00:00 2001 From: Gregoire MOURO Date: Mon, 26 Oct 2026 07:02:01 +0000 Subject: [PATCH 2/8] keeper: record hashes before sending, guard restarts, keep sessions on network errors Review fixes: the hash of a keeper-signed transaction is captured at signing through the wallet signer and saved before the send; a pending entry found after a restart blocks writes; a session is dropped only when the vault is gone; SIGTERM waits for the write in progress; the data directory is probed at start-up; streams are capped per address; hosted start-up rules moved to hosted.ts with tests. Co-Authored-By: Claude Sonnet 5.5 --- .dockerignore | 2 + README.md | 2 +- docs/deploy.md | 14 +++-- keeper/src/errors.ts | 5 ++ keeper/src/fake-vault-client.ts | 3 + keeper/src/hosted.ts | 23 ++++++++ keeper/src/index.ts | 27 +++++---- keeper/src/server.ts | 8 ++- keeper/src/session.ts | 83 +++++++++++++++++++++------ keeper/src/store.ts | 10 +++- keeper/src/testnet-vault-client.ts | 50 ++++++++++++----- keeper/src/vault-client.ts | 5 ++ keeper/test/hosted.test.ts | 90 +++++++++++++++++++++++++++++- keeper/test/testnet-client.test.ts | 32 ++++++++++- packages/shared/src/types.ts | 4 +- 15 files changed, 305 insertions(+), 53 deletions(-) create mode 100644 keeper/src/hosted.ts diff --git a/.dockerignore b/.dockerignore index dfc25ce..31d2ed7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,5 +4,7 @@ node_modules .git .env .env.* +**/.env +**/.env.* **/* 2.* contracts/target diff --git a/README.md b/README.md index a7250f4..506fef3 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ What a hosted keeper does differently from a local one (all covered by tests in - **Write routes need an access code.** Every `POST` returns `Unauthorized` (401) without `Authorization: Bearer `; codes live only in the host's `KEEPER_ACCESS_TOKENS`. The dashboard asks for the code once and keeps it in the browser. CORS and the Host/Origin checks are kept (exact origins only) but are not the authorization. - **No fund key.** It refuses to start with `SIGNER=keeper` or a `FUND_SECRET`: deposits and withdrawals are always signed in Freighter. - **The session survives a restart.** It is saved in `KEEPER_DATA_DIR` and checked against the vault on-chain when reloaded. -- **Uncertain transactions are reconciled by hash.** After a timeout the entry stays `pending` with its hash; no other transaction is sent until the hash is found confirmed, failed or expired. +- **Uncertain transactions are reconciled by hash.** The hash is recorded before a keeper-signed transaction is sent; after a timeout (or a restart during the wait) the entry stays `pending`, and no other transaction is sent until the hash is found confirmed, failed or expired. - **`GET /config` states the backend** (`backend: "testnet" | "fake"`); the page labels a fake backend. Status: configuration and tests written; **not deployed yet** (see the progress table). diff --git a/docs/deploy.md b/docs/deploy.md index 2aca5fa..8eea50d 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -22,8 +22,8 @@ Pourquoi le keeper n'est pas sur Vercel : il garde des connexions SSE ouvertes, | Un visiteur déploie des vaults, poste une marge, règle un défaut | Chaque route `POST` exige `Authorization: Bearer `; les codes sont dans `KEEPER_ACCESS_TOKENS` (côté serveur, 32 caractères minimum, comparaison à temps constant) | `keeper/src/access.ts`, `server.ts` | | Le code fuit dans le site | Le code n'est jamais une variable `VITE_*` : chaque personne le saisit dans la page, il reste dans son navigateur | `app/src/api/access.ts` | | Le keeper signe pour le fonds | Au démarrage hébergé, `SIGNER=keeper` et `FUND_SECRET` sont refusés : tout dépôt ou retrait passe par Freighter | `keeper/src/index.ts` | -| Page tierce qui appelle l'API | Origines exactes (`KEEPER_ALLOWED_ORIGINS`), hôtes exacts (`KEEPER_PUBLIC_HOSTS`), `localhost` refusé en mode hébergé. Ce n'est **pas** une authentification | `server.ts` | -| Trop de flux SSE | 100 flux ouverts au plus | `server.ts` | +| Page tierce qui appelle l'API | Origines (`KEEPER_ALLOWED_ORIGINS`), hôtes exacts (`KEEPER_PUBLIC_HOSTS`), `localhost` refusé en mode hébergé. Ce n'est **pas** une authentification. Le joker `*` des origines (previews Vercel) accepte aussi des noms d'autres équipes qui se terminent pareil : préférer des origines exactes quand c'est possible | `server.ts` | +| Trop de flux SSE | 100 flux ouverts au plus, 10 par adresse | `server.ts` | Les routes `GET` (`/health`, `/config`, `/session`, `/events`, `/market-advisor`) restent ouvertes : elles ne montrent que des données publiques de Testnet. @@ -33,8 +33,12 @@ Limites assumées : un seul code partagé par personne (pas de comptes), pas de - Le keeper écrit `session.json` dans `KEEPER_DATA_DIR` (volume Railway monté sur `/data`) à chaque changement, de façon atomique. Au redémarrage il recharge la session et relit le coffre sur la chaîne. Si le coffre n'existe plus (remise à zéro de Testnet), une copie `session.json.dropped-….bak` est gardée et le keeper repart sans session. - **Sans volume, un redéploiement efface la session** : le coffre garde ses jetons sur la chaîne mais le keeper n'en connaît plus l'adresse. Le volume est donc obligatoire pour un usage au-delà d'un essai. -- Une transaction envoyée dont la confirmation n'arrive pas (délai RPC) reste `pending` avec son hash. Tant qu'elle n'est pas tranchée, **toute écriture est refusée** (`PendingTransaction`). Toutes les 15 s, et avant chaque écriture, le keeper interroge `getTransaction(hash)` : `SUCCESS` confirme l'entrée, `FAILED` la rejette, et un hash introuvable 200 s après l'envoi est déclaré non inclus (aucune transaction construite par le keeper n'est valable plus longtemps). La garde de 200 s du front reste, mais elle n'est qu'un confort. -- Cas non couvert : un arrêt du processus entre l'envoi du déploiement d'un vault et sa première trace. Le vault orphelin est vide et ne coûte que ses frais. +- Le hash d'une transaction signée par le keeper est enregistré **avant** son envoi (et celui d'une transaction signée dans Freighter dès sa réception), puis sauvegardé. Un redémarrage pendant l'attente de confirmation garde donc la garde. Une entrée `pending` retrouvée au redémarrage sans hash bloque les écritures jusqu'à la fin de la fenêtre de 200 s. +- Une transaction dont la confirmation n'arrive pas (délai RPC, coupure réseau) reste `pending` avec son hash. Tant qu'elle n'est pas tranchée, **toute écriture et toute préparation de signature sont refusées** (`PendingTransaction`). Toutes les 15 s, et avant chaque écriture, le keeper interroge `getTransaction(hash)` : `SUCCESS` confirme l'entrée, `FAILED` la rejette, et un hash introuvable 200 s après l'enregistrement est déclaré non inclus (aucune transaction construite par le keeper n'est valable plus longtemps). Une transaction refusée par le réseau, ou qui n'a jamais été signée, est marquée `failed` tout de suite. La garde de 200 s du front reste, mais elle n'est qu'un confort. +- Après un arrêt, le keeper ne jette la session que si la chaîne dit que le coffre n'existe plus ; une panne réseau au démarrage la conserve. +- Sur SIGTERM (redéploiement), le keeper cesse d'accepter des requêtes et laisse finir l'écriture en cours avant de s'arrêter (25 s au plus). +- Au démarrage hébergé, le keeper vérifie qu'il peut écrire dans `KEEPER_DATA_DIR` et refuse de démarrer sinon. +- Limites connues : la fenêtre de 200 s se mesure à l'horloge du serveur ; un hash introuvable après un très long arrêt (au-delà de la mémoire du RPC) est lu comme non inclus, d'où le message qui renvoie à l'état du coffre lu sur la chaîne. Une confirmation tardive met à jour l'entrée et l'état, mais ne recrée pas les entrées `margin_call`, `cured` et `released` de cette transaction. Un déploiement confirmé après un redémarrage sans adresse de coffre enregistrée n'est pas adopté : lancer une nouvelle session. ## 4. Coûts et limites (vérifiés dans la documentation des hébergeurs le 26 octobre 2026) @@ -72,7 +76,7 @@ openssl rand -hex 24 # un code d'accès par personne : à donner à la personn | `KEEPER_DATA_DIR` | `/data` | Déjà fixées par le `Dockerfile` : `KEEPER_HOST=0.0.0.0`, `KEEPER_BACKEND=testnet`, `SIGNER=freighter`. Railway fournit `PORT`. Ne **jamais** définir `FUND_SECRET`, `ISSUER_SECRET` ni `BANK_SECRET`. -5. 👤 *Settings → Volumes → New Volume*, point de montage `/data`. Si le service ne peut pas y écrire, ajouter la variable `RAILWAY_RUN_UID=0`. +5. 👤 *Settings → Volumes → New Volume*, point de montage `/data`. Le conteneur tourne en root (compatible avec les volumes de Railway) ; si le service ne peut pas écrire dans `/data`, le keeper refuse de démarrer, et ajouter `RAILWAY_RUN_UID=0` règle le cas. 6. Vérifier : `curl https://xxx.up.railway.app/api/config` doit répondre `"backend":"testnet"`, `"signerMode":"freighter"`, `"writeAuth":"token"` et le compte fonds `GDQ6AW…MQOY`. ### C. Site sur Vercel diff --git a/keeper/src/errors.ts b/keeper/src/errors.ts index d08b189..66aaa6d 100644 --- a/keeper/src/errors.ts +++ b/keeper/src/errors.ts @@ -38,3 +38,8 @@ export class VaultError extends KeeperError { super(code, message ?? `The vault rejected the call: ${code}`); } } + +/** True only when the chain says the vault does not exist (after a Testnet reset, for example): never for a network trouble. */ +export function isVaultGone(e: unknown): boolean { + return e instanceof KeeperError && e.code === "TxFailed" && /MissingValue|not found|does not exist|Unknown vault/i.test(e.message); +} diff --git a/keeper/src/fake-vault-client.ts b/keeper/src/fake-vault-client.ts index 8c7ddfc..280dfe7 100644 --- a/keeper/src/fake-vault-client.ts +++ b/keeper/src/fake-vault-client.ts @@ -30,6 +30,9 @@ export class FakeVaultClient implements VaultClient { this.fundInitial = opts.fundInitialUnits ?? 10_000_000_000n; // 1 000 units, like the bootstrap } + /** Part of the VaultClient contract; the fake never signs, so it never calls it. */ + onSigned?: (txHash: string, vaultId?: string) => void; + /** Test hook: hashes set here answer SUCCESS; every other hash is NOT_FOUND. */ readonly known = new Map(); diff --git a/keeper/src/hosted.ts b/keeper/src/hosted.ts new file mode 100644 index 0000000..3db608b --- /dev/null +++ b/keeper/src/hosted.ts @@ -0,0 +1,23 @@ +// Start-up rules of a keeper that listens beyond loopback (and is therefore reachable by anyone). +import { parseAccessTokens } from "./access.ts"; + +export interface Exposure { + host: string; + hosted: boolean; + accessTokens: string[]; +} + +/** Reads KEEPER_HOST and the hosted-only variables; throws when a public keeper is not locked down. */ +export function readExposure(env: Record, signer: string): Exposure { + const host = env.KEEPER_HOST || "127.0.0.1"; + const hosted = !["127.0.0.1", "localhost", "::1"].includes(host); + const accessTokens = parseAccessTokens(env.KEEPER_ACCESS_TOKENS); + if (hosted) { + if (signer !== "freighter") throw new Error("A hosted keeper must run with SIGNER=freighter: it never holds the fund key"); + if (accessTokens.length === 0) throw new Error("A hosted keeper needs KEEPER_ACCESS_TOKENS: its write routes cost fees and move collateral"); + if (!env.KEEPER_ALLOWED_ORIGINS) throw new Error("A hosted keeper needs KEEPER_ALLOWED_ORIGINS (the exact origin of the web app)"); + if (!env.KEEPER_PUBLIC_HOSTS) throw new Error("A hosted keeper needs KEEPER_PUBLIC_HOSTS (its own public host name)"); + if (env.FUND_SECRET) throw new Error("FUND_SECRET must not be set on a hosted keeper"); + } + return { host, hosted, accessTokens }; +} diff --git a/keeper/src/index.ts b/keeper/src/index.ts index fb88027..bde8511 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -5,8 +5,9 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Keypair } from "vault-client"; import type { SignerMode } from "@keystone/shared"; -import { originMatcher, parseAccessTokens } from "./access.ts"; +import { originMatcher } from "./access.ts"; import { buildConfig } from "./config.ts"; +import { readExposure } from "./hosted.ts"; import { getEuribor } from "./euribor.ts"; import { createMarketProvider } from './market-data.ts'; import { FakeVaultClient } from "./fake-vault-client.ts"; @@ -28,16 +29,7 @@ const port = Number(process.env.PORT || 8787); if (!Number.isInteger(port) || port <= 0) throw new Error("PORT must be a positive integer"); // Loopback by default (a developer's machine). A hosted keeper sets KEEPER_HOST=0.0.0.0 and is then public: // it needs access codes, an explicit list of hosts and origins, and never signs with the fund key. -const host = process.env.KEEPER_HOST || "127.0.0.1"; -const hosted = !["127.0.0.1", "localhost", "::1"].includes(host); -const accessTokens = parseAccessTokens(process.env.KEEPER_ACCESS_TOKENS); -if (hosted) { - if (signer !== "freighter") throw new Error("A hosted keeper must run with SIGNER=freighter: it never holds the fund key"); - if (accessTokens.length === 0) throw new Error("A hosted keeper needs KEEPER_ACCESS_TOKENS: its write routes cost fees and move collateral"); - if (!process.env.KEEPER_ALLOWED_ORIGINS) throw new Error("A hosted keeper needs KEEPER_ALLOWED_ORIGINS (the exact origin of the web app)"); - if (!process.env.KEEPER_PUBLIC_HOSTS) throw new Error("A hosted keeper needs KEEPER_PUBLIC_HOSTS (its own public host name)"); - if (process.env.FUND_SECRET) throw new Error("FUND_SECRET must not be set on a hosted keeper"); -} +const { host, hosted, accessTokens } = readExposure(process.env, signer); const backend = process.env.KEEPER_BACKEND; if (backend !== "fake" && backend !== "testnet") { console.error("Set KEEPER_BACKEND=testnet (real vault on Stellar Testnet) or KEEPER_BACKEND=fake (in-memory, fake hashes)."); @@ -79,6 +71,8 @@ const publicHosts = list("KEEPER_PUBLIC_HOSTS"); // Where the keeper keeps what must survive a restart: on a host, a mounted volume (KEEPER_DATA_DIR=/data). const dataDir = process.env.KEEPER_DATA_DIR || join(keeperDir, "data"); const store = backend === "testnet" ? new SessionStore(dataDir) : undefined; +// A keeper that cannot save its session would forget a funded vault on the next restart: refuse to run. +if (store && hosted) store.assertWritable(); const sessions = new SessionManager({ client: makeClient(), config, @@ -100,6 +94,17 @@ const app = buildServer(config, sessions, { trustProxyHops: hosted ? Number(process.env.KEEPER_TRUST_PROXY_HOPS || 1) : undefined, }); await app.listen({ port, host }); +// A redeploy sends SIGTERM: stop taking requests, let the write in progress finish (its hash is saved), then exit. +let stopping = false; +for (const sig of ["SIGTERM", "SIGINT"] as const) { + process.on(sig, () => { + if (stopping) return; + stopping = true; + const force = setTimeout(() => process.exit(1), 25_000); + force.unref(); + void app.close().then(() => sessions.idle()).then(() => process.exit(0)); + }); +} console.log( `Keeper listening on http://${host}:${port}/api (SIGNER=${signer}, ` + (accessTokens.length ? `write routes need an access code, ` : "no access code, ") + `backend: ` + (backend === "fake" ? "in-memory FAKE, no Testnet transaction)" : "Stellar Testnet, real transactions)"), diff --git a/keeper/src/server.ts b/keeper/src/server.ts index 7e631dc..bf2da06 100644 --- a/keeper/src/server.ts +++ b/keeper/src/server.ts @@ -10,6 +10,7 @@ import type { SessionManager, StreamMessage } from "./session.ts"; const I128_MAX = (1n << 127n) - 1n; const HEARTBEAT_MS = 15_000; const MAX_STREAMS = 100; +const MAX_STREAMS_PER_IP = 10; /** Railway's health probe calls /api/health with this Host; it is let through for that route only. */ const PROBE_HOST = "healthcheck.railway.app"; @@ -53,6 +54,7 @@ export function buildServer(config: KeystoneConfig, sessions: SessionManager, op ...(options.trustProxyHops ? { trustProxy: (_address: string, hop: number) => hop < options.trustProxyHops! } : {}), }); const streams = new Set(); + const streamsByIp = new Map(); const market = options.market ?? createMarketProvider(); // A page that rebinds its DNS name to 127.0.0.1 is same-origin with the keeper and bypasses CORS: @@ -146,7 +148,8 @@ export function buildServer(config: KeystoneConfig, sessions: SessionManager, op api.post("/session/settle-default", async () => sessions.settleDefault()); api.get("/events", (req, reply) => { - if (streams.size >= MAX_STREAMS) { + const ip = req.ip; + if (streams.size >= MAX_STREAMS || (streamsByIp.get(ip) ?? 0) >= MAX_STREAMS_PER_IP) { return reply.status(503).send({ error: { code: "RpcUnavailable", message: "Too many open streams: try again shortly" } }); } reply.hijack(); @@ -166,7 +169,10 @@ export function buildServer(config: KeystoneConfig, sessions: SessionManager, op for (const m of sessions.replay()) write(m); const heartbeat = setInterval(() => res.write(": ping\n\n"), HEARTBEAT_MS); streams.add(reply); + streamsByIp.set(ip, (streamsByIp.get(ip) ?? 0) + 1); req.raw.on("close", () => { + const left = (streamsByIp.get(ip) ?? 1) - 1; + if (left > 0) streamsByIp.set(ip, left); else streamsByIp.delete(ip); clearInterval(heartbeat); unsubscribe(); streams.delete(reply); diff --git a/keeper/src/session.ts b/keeper/src/session.ts index c6e403a..2256369 100644 --- a/keeper/src/session.ts +++ b/keeper/src/session.ts @@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto"; import type { ActivityEvent, ActivityType, AssetId, KeystoneConfig, SessionState, VaultStatus } from "@keystone/shared"; import { formatCents, formatMilli, formatUnits, parseScaled } from "./amounts.ts"; import type { EuriborResult } from "./euribor.ts"; -import { KeeperError, VaultError } from "./errors.ts"; +import { isVaultGone, KeeperError, VaultError } from "./errors.ts"; import { collateralParams, computeMargin, fixedRateFromStart, MILLI_PER_BP, shouldPost, type CollateralParams, type MarginResult, type SwapParams, @@ -76,6 +76,8 @@ export class SessionManager { private session: Session | null = null; private pendingDeploy: Session | null = null; // session whose vault deployment is unconfirmed private unresolved: Unresolved | null = null; + /** The write in progress: lets a hash reported by the client be recorded before the transaction is sent. */ + private active: { entry: ActivityEvent; track: Track } | null = null; private readonly listeners = new Set<(m: StreamMessage) => void>(); private queue: Promise = Promise.resolve(); private seq = 0; @@ -85,6 +87,12 @@ export class SessionManager { constructor(deps: SessionDeps) { this.deps = deps; this.now = deps.now ?? (() => new Date()); + deps.client.onSigned = (hash, vaultId) => this.noteSigned(hash, vaultId); + } + + /** Resolves once the queued writes are done (used to stop cleanly on SIGTERM). */ + idle(): Promise { + return this.queue.then(() => undefined, () => undefined); } // ---- persistence and reconciliation ------------------------------------------------------ @@ -98,13 +106,26 @@ export class SessionManager { this.session = (data.session as Session | null) ?? null; this.pendingDeploy = (data.pendingDeploy as Session | null) ?? null; this.unresolved = (data.unresolved as Unresolved | null) ?? null; + // A restart can interrupt a write between its signature and its confirmation. Every persisted pending + // chain entry is therefore treated as unconfirmed (by hash when it has one) until its window has passed. + if (!this.unresolved) { + const owner = this.pendingDeploy ?? this.session; + const entry = owner?.feed.find((e) => e.kind === "chain" && e.status === "pending"); + if (owner && entry) { + this.unresolved = { + hash: entry.txHash ?? "", entryId: entry.id, kind: owner === this.pendingDeploy ? "deploy" : "write", + notAfter: this.now().getTime() + EXPIRY_MS, doneTitle: entry.title, + }; + this.persist(); + } + } if (this.session) { try { await this.publishState(this.session); } catch (e) { - // Unreachable RPC: keep the session, the state is rebuilt on the next read. Any other failure - // (the vault no longer exists, for example after a Testnet reset): keep a copy and start clean. - if (!(e instanceof KeeperError && e.code === "RpcUnavailable")) { + // Only proof that the vault is gone (a Testnet reset) drops the session, keeping a copy. A network + // trouble at start-up keeps it: the state is rebuilt on the next read. + if (isVaultGone(e)) { store.backup("dropped"); console.error(`Saved session dropped (a copy was kept): ${e instanceof Error ? e.message : String(e)}`); this.session = null; @@ -140,7 +161,8 @@ export class SessionManager { if (!u) return; const s = u.kind === "deploy" ? this.pendingDeploy : this.session; const entry = s?.feed.find((e) => e.id === u.entryId); - const status = await this.deps.client.txStatus(u.hash); + // No hash: the restart hit before it was recorded. Nothing can be looked up, so wait out the window. + const status = u.hash ? await this.deps.client.txStatus(u.hash) : "NOT_FOUND"; if (status === "NOT_FOUND" && this.now().getTime() <= u.notAfter) { throw new KeeperError("PendingTransaction", `Transaction ${u.hash} was sent but is not confirmed yet. No new transaction starts until its outcome is known.`); @@ -153,10 +175,15 @@ export class SessionManager { txHash: u.hash, explorerUrl: `${this.deps.config.explorerTxBase}${u.hash}`, }); } - if (s && u.kind === "deploy" && u.vaultId) { + if (s && u.kind === "deploy" && !u.vaultId) { + // Confirmed, but the vault address was not recorded: it cannot be adopted, say so rather than guess. + if (entry) this.update(entry, { detail: "Confirmed after a restart; the vault address was not recorded, start a new session." }); + this.pendingDeploy = null; + } else if (s && u.kind === "deploy" && u.vaultId) { s.vaultId = u.vaultId; this.pendingDeploy = null; this.session = s; + for (const e of s.feed) this.emit({ event: "activity", data: { ...e } }); // clients now know this session id } else if (s && u.postedValueCents !== undefined) { s.lastPostedValueCents = u.postedValueCents; } @@ -167,7 +194,11 @@ export class SessionManager { if (entry) { this.update(entry, { status: "failed", - detail: status === "FAILED" ? `Transaction ${u.hash} failed on-chain.` : `Transaction ${u.hash} was never included in a ledger.`, + detail: status === "FAILED" + ? `Transaction ${u.hash} failed on-chain.` + : u.hash + ? `Transaction ${u.hash} was not found by the network after its validity window: treated as not included. The vault state shown is read from the chain.` + : "The keeper restarted before recording the outcome of this transaction. The vault state shown is read from the chain.", }); } if (u.kind === "deploy") this.pendingDeploy = null; @@ -371,29 +402,32 @@ export class SessionManager { kind: "chain", type, title: pendingTitle, status: "pending", ...(track?.preHash ? { txHash: track.preHash, explorerUrl: `${this.deps.config.explorerTxBase}${track.preHash}` } : {}), }); + this.active = track ? { entry, track } : null; + // A hash known before sending (wallet-signed) is recorded before the network can lose track of the transaction. + if (track?.preHash) this.markInFlight(entry, track, track.preHash); let result: T; try { result = await run(); } catch (e) { - if (e instanceof KeeperError && e.code === "RpcUnavailable") { - const hash = e.notSent ? undefined : (e.txHash ?? track?.preHash); + // Pending only when something may be in flight: a hash is known. Without one, nothing was signed or sent. + if (e instanceof KeeperError && e.code === "RpcUnavailable" && !e.notSent && (e.txHash || this.unresolved)) { + const hash = e.txHash ?? this.unresolved?.hash; this.update(entry, { detail: `No confirmation yet: ${e.message}`, ...(hash ? { txHash: hash, explorerUrl: `${this.deps.config.explorerTxBase}${hash}` } : {}), }); - // The transaction may still land: remember it so that nothing else is sent before its outcome is known. - if (hash && track) { - this.unresolved = { - hash, entryId: entry.id, kind: track.kind ?? "write", notAfter: this.now().getTime() + EXPIRY_MS, - doneTitle: track.doneTitle, doneDetail: track.doneDetail, vaultId: e.vaultId, postedValueCents: track.postedValueCents, - }; - this.persist(); - } + // The transaction may still land: it stays recorded so that nothing else is sent before its outcome is known. + if (hash && track && !this.unresolved) this.markInFlight(entry, track, hash, e.vaultId); } else { + // A definite outcome (rejected, failed on-chain, or refused by the network): nothing is in flight. + this.unresolved = null; this.update(entry, { status: "failed", detail: e instanceof Error ? e.message : String(e) }); } throw e; + } finally { + this.active = null; } + this.unresolved = null; const link = { txHash: result.txHash, explorerUrl: `${this.deps.config.explorerTxBase}${result.txHash}` }; this.update(entry, { status: "confirmed", ...done(result), ...link }); const events = result.events ?? []; @@ -411,6 +445,21 @@ export class SessionManager { return result; } + /** Records a transaction as possibly in flight (and saves it). */ + private markInFlight(entry: ActivityEvent, track: Track, hash: string, vaultId?: string): void { + this.unresolved = { + hash, entryId: entry.id, kind: track.kind ?? "write", notAfter: this.now().getTime() + EXPIRY_MS, + doneTitle: track.doneTitle, doneDetail: track.doneDetail, vaultId, postedValueCents: track.postedValueCents, + }; + this.persist(); + } + + /** The client signed a transaction and is about to send it: record its hash first. */ + private noteSigned(hash: string, vaultId?: string): void { + if (!this.active || this.unresolved?.hash === hash) return; + this.markInFlight(this.active.entry, this.active.track, hash, vaultId); + } + private add(s: Session, e: Omit): ActivityEvent { const entry: ActivityEvent = { id: `${s.id}-${++this.seq}`, sessionId: s.id, ts: this.now().toISOString(), ...e }; s.feed.push(entry); diff --git a/keeper/src/store.ts b/keeper/src/store.ts index 93fa11f..0f62486 100644 --- a/keeper/src/store.ts +++ b/keeper/src/store.ts @@ -1,6 +1,6 @@ // Durable copy of the keeper's session, so a restart or a new deployment does not forget a vault that // already holds tokens. One JSON file, written atomically; bigint values are tagged to survive JSON. -import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const VERSION = 1; @@ -21,6 +21,14 @@ export class SessionStore { this.file = join(dir, "session.json"); } + /** Throws when the data directory cannot be written and read back (volume missing or read-only). */ + assertWritable(): void { + const probe = `${this.file}.probe`; + writeFileSync(probe, "ok", { mode: 0o600 }); + if (readFileSync(probe, "utf8") !== "ok") throw new Error("KEEPER_DATA_DIR cannot be read back"); + unlinkSync(probe); + } + /** The saved state, or null when there is none or it is unreadable (an unreadable file is kept as a backup). */ load(): PersistedState | null { if (!existsSync(this.file)) return null; diff --git a/keeper/src/testnet-vault-client.ts b/keeper/src/testnet-vault-client.ts index d029215..d364419 100644 --- a/keeper/src/testnet-vault-client.ts +++ b/keeper/src/testnet-vault-client.ts @@ -9,6 +9,8 @@ import type { DeployParams, OnChainStatus, SignedTxInfo, TxResult, TxStatus, VaultClient, VaultEvent, VaultState, } from "./vault-client.ts"; +type NodeSigner = ReturnType; + export interface TestnetOptions { rpcUrl: string; networkPassphrase: string; @@ -32,12 +34,30 @@ export function mapChainError(e: unknown, vaultId?: string): KeeperError { const contracts: string[] = msg.match(/\bC[A-Z2-7]{55}\b/g) ?? []; const foreign = vaultId !== undefined && contracts.length > 0 && !contracts.includes(vaultId); if (name && !foreign) return new VaultError(name); - if (/still pending|timed? ?out|timeout|ECONN|ENOTFOUND|fetch failed|socket hang up|\bHTTP (429|5\d\d)\b|\b(429|502|503|504)\b/i.test(msg)) { + if (/still pending|timed? ?out|timeout|ECONN|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|EHOSTUNREACH|network ?error|fetch failed|aborted|socket hang up|\bHTTP (429|5\d\d)\b|\b(429|502|503|504)\b/i.test(msg)) { return new KeeperError("RpcUnavailable", `RPC unavailable or no confirmation: ${msg.slice(0, 200)}`); } return new KeeperError("TxFailed", msg.slice(0, 400)); } +/** + * Error of a keeper-signed send. Before signing nothing was sent: the plain mapping applies. After signing, only a + * refusal by the network (the SDK's SendFailedError) proves the transaction was not included; any other failure + * leaves it possibly in flight and becomes RpcUnavailable carrying the hash to reconcile. + */ +export function classifySendError(e: unknown, signedHash: string | undefined, vaultId?: string): KeeperError { + const mapped = mapChainError(e, vaultId); + if (!signedHash) return mapped; + if ((e as { constructor?: { name?: string } } | null)?.constructor?.name === "SendFailedError") { + mapped.notSent = true; + return mapped; + } + const uncertain = mapped.code === "RpcUnavailable" ? mapped : new KeeperError("RpcUnavailable", `No confirmation: ${mapped.message}`.slice(0, 300)); + uncertain.txHash = signedHash; + if (vaultId) uncertain.vaultId = vaultId; + return uncertain; +} + /** Decodes one contract event (docs/interfaces.md section 3.5); unknown events are ignored. */ export function decodeEvent(topics: xdr.ScVal[], value: xdr.ScVal): VaultEvent | null { const name = topics.length === 1 ? scValToNative(topics[0]!) : null; @@ -68,6 +88,7 @@ export class TestnetVaultClient implements VaultClient { private readonly o: TestnetOptions; private readonly server: rpc.Server; private readonly timeout: number; + onSigned?: (txHash: string, vaultId?: string) => void; constructor(options: TestnetOptions) { this.o = options; @@ -98,7 +119,7 @@ export class TestnetVaultClient implements VaultClient { ); // The contract address is known from the simulation: keep it if the confirmation times out. const vaultId = (tx.result as VaultBindings).options.contractId; - const sent = await this.send(tx, vaultId); + const sent = await this.send(tx, this.o.keeper, vaultId); return { vaultId, txHash: this.hashOf(sent) }; }); } @@ -215,7 +236,7 @@ export class TestnetVaultClient implements VaultClient { ): Promise { return this.guard(vaultId, async () => { const tx = await call(this.bindings(vaultId, signer), { timeoutInSeconds: this.timeout }); - const sent = await this.send(tx, vaultId); + const sent = await this.send(tx, signer, vaultId); const txHash = this.hashOf(sent); const res = await this.confirm(txHash); // The transaction is confirmed: a failing event read must not turn it into an error. @@ -224,19 +245,22 @@ export class TestnetVaultClient implements VaultClient { }); } - /** signAndSend, tagging a lost confirmation with the hash of the transaction that was already sent. */ - private async send; sendTransactionResponse?: { hash: string } }>( - tx: T, vaultId?: string, + /** Signs with the given key, reports the hash before sending (`onSigned`), then sends and waits. */ + private async send }>( + tx: T, signer: Keypair, vaultId?: string, ): Promise>> { + const node = contract.basicNodeSigner(signer, this.o.networkPassphrase); + let signedHash: string | undefined; + const signTransaction: NodeSigner["signTransaction"] = async (xdrString, opts) => { + const out = await node.signTransaction(xdrString, opts); + signedHash = TransactionBuilder.fromXDR(out.signedTxXdr, this.o.networkPassphrase).hash().toString("hex"); + this.onSigned?.(signedHash, vaultId); + return out; + }; try { - return (await tx.signAndSend()) as Awaited>; + return (await tx.signAndSend({ signTransaction })) as Awaited>; } catch (e) { - const mapped = mapChainError(e, vaultId); - if (mapped.code === "RpcUnavailable" && tx.sendTransactionResponse?.hash) { - mapped.txHash = tx.sendTransactionResponse.hash; - if (vaultId) mapped.vaultId = vaultId; - } - throw mapped; + throw classifySendError(e, signedHash, vaultId); } } diff --git a/keeper/src/vault-client.ts b/keeper/src/vault-client.ts index fea4cef..e70183a 100644 --- a/keeper/src/vault-client.ts +++ b/keeper/src/vault-client.ts @@ -63,6 +63,11 @@ export type TxStatus = "SUCCESS" | "FAILED" | "NOT_FOUND"; * confirmed. */ export interface VaultClient { + /** + * Called with the hash of a keeper-signed transaction after it is signed and before it is sent + * (`vaultId` for a deployment), so the keeper can record it before the network can lose track of it. + */ + onSigned?: (txHash: string, vaultId?: string) => void; /** Looks a transaction up by hash; used to settle entries left pending by a timeout. */ txStatus(hash: string): Promise; /** Ledger time in seconds: the authority for deadlines. */ diff --git a/keeper/test/hosted.test.ts b/keeper/test/hosted.test.ts index 1d433dc..d9d0318 100644 --- a/keeper/test/hosted.test.ts +++ b/keeper/test/hosted.test.ts @@ -4,11 +4,12 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { makeAuthorizer, originMatcher, parseAccessTokens } from "../src/access.ts"; import { KeeperError } from "../src/errors.ts"; +import { readExposure } from "../src/hosted.ts"; import { FakeVaultClient } from "../src/fake-vault-client.ts"; import { buildServer } from "../src/server.ts"; import { SessionManager } from "../src/session.ts"; import { SessionStore } from "../src/store.ts"; -import type { TxResult } from "../src/vault-client.ts"; +import type { TxResult, VaultState } from "../src/vault-client.ts"; import { config, scenario } from "./helpers.ts"; const TOKEN = "t".repeat(40); @@ -191,4 +192,91 @@ describe("durable session and uncertain transactions", () => { await b.sessions.restore(); await expect(b.sessions.deposit(1_000_000n)).rejects.toMatchObject({ code: "PendingTransaction" }); }); + + it("records a keeper-signed hash before it is sent, so a restart in the confirmation wait is still guarded", async () => { + const a = make(); + await a.sessions.start("KMMF"); + const hash = "12".repeat(32); + let during: Promise | undefined; + const hook = a.client.onSigned; // each manager installs its own hook on the shared client + a.client.deposit = async () => { + a.client.deposit = FakeVaultClient.prototype.deposit.bind(a.client); + hook?.(hash); // the real client reports the hash between signing and sending + const b = make(a.client, a.clock); + await b.sessions.restore(); // the process "restarts" while the confirmation is awaited + during = b.sessions.deposit(1_000_000n).catch((e: KeeperError) => e.code); + throw new Error("process died"); + }; + await expect(a.sessions.deposit(1_000_000n)).rejects.toThrow("process died"); + await expect(during).resolves.toBe("PendingTransaction"); + }); + + it("guards a pending entry saved without any hash until its window has passed", async () => { + const a = make(); + await a.sessions.start("KMMF"); + a.client.deposit = async () => { + a.client.deposit = FakeVaultClient.prototype.deposit.bind(a.client); + // crash after the entry was saved as pending, before any hash was recorded + const b = make(a.client, a.clock); + await b.sessions.restore(); + await expect(b.sessions.deposit(1_000_000n)).rejects.toMatchObject({ code: "PendingTransaction" }); + a.clock.ms += 300_000; + await expect(b.sessions.deposit(1_000_000n)).resolves.toBeTruthy(); + throw new Error("process died"); + }; + await expect(a.sessions.deposit(1_000_000n)).rejects.toThrow("process died"); + }); + + it("keeps the saved session when the chain cannot be reached at start-up, and drops it only if the vault is gone", async () => { + const a = make(); + const started = await a.sessions.start("KMMF"); + const down = make(a.client, a.clock); + a.client.getState = async () => { throw new KeeperError("TxFailed", "getaddrinfo EAI_AGAIN soroban-testnet.stellar.org"); }; + await down.sessions.restore(); + a.client.getState = FakeVaultClient.prototype.getState.bind(a.client); + expect((await down.sessions.getState()).vaultId).toBe(started.vaultId); + }); + + it("marks a transaction refused by the network failed at once, and one that never left (no hash) too", async () => { + const { client, sessions } = make(); + await sessions.start("KMMF"); + client.deposit = async () => { const e = new KeeperError("RpcUnavailable", "refused"); e.notSent = true; e.txHash = "ab".repeat(32); throw e; }; + await expect(sessions.deposit(1n)).rejects.toMatchObject({ code: "RpcUnavailable" }); + client.deposit = async () => { throw new KeeperError("RpcUnavailable", "RPC down before signing"); }; + await expect(sessions.deposit(1n)).rejects.toMatchObject({ code: "RpcUnavailable" }); + const entries = sessions.replay().map((m) => m.data as { type: string; status: string }); + expect(entries.filter((e) => e.type === "deposit").map((e) => e.status)).toEqual(["failed", "failed"]); + // nothing is in flight: the next write is not blocked + client.deposit = FakeVaultClient.prototype.deposit.bind(client); + await expect(sessions.deposit(1_000_000n)).resolves.toBeTruthy(); + }); +}); + +describe("hosted start-up rules", () => { + const ok = { KEEPER_HOST: "0.0.0.0", KEEPER_ACCESS_TOKENS: TOKEN, KEEPER_ALLOWED_ORIGINS: WEB, KEEPER_PUBLIC_HOSTS: "keeper.example.com" }; + + it("leaves a loopback keeper as it was", () => { + expect(readExposure({}, "keeper")).toEqual({ host: "127.0.0.1", hosted: false, accessTokens: [] }); + expect(readExposure({ KEEPER_HOST: "localhost" }, "freighter").hosted).toBe(false); + }); + + it("accepts a fully configured public keeper", () => { + expect(readExposure(ok, "freighter")).toMatchObject({ hosted: true, accessTokens: [TOKEN] }); + }); + + it("refuses a public keeper that is not locked down", () => { + expect(() => readExposure(ok, "keeper")).toThrow(/SIGNER=freighter/); + expect(() => readExposure({ ...ok, KEEPER_ACCESS_TOKENS: "" }, "freighter")).toThrow(/KEEPER_ACCESS_TOKENS/); + expect(() => readExposure({ ...ok, KEEPER_ACCESS_TOKENS: "short" }, "freighter")).toThrow(/at least 32/); + expect(() => readExposure({ ...ok, KEEPER_ALLOWED_ORIGINS: "" }, "freighter")).toThrow(/KEEPER_ALLOWED_ORIGINS/); + expect(() => readExposure({ ...ok, KEEPER_PUBLIC_HOSTS: "" }, "freighter")).toThrow(/KEEPER_PUBLIC_HOSTS/); + expect(() => readExposure({ ...ok, FUND_SECRET: "S" }, "freighter")).toThrow(/FUND_SECRET/); + }); + + it("refuses a data directory it cannot write", () => { + expect(() => new SessionStore("/proc/definitely/not/writable").assertWritable()).toThrow(); + const dir = mkdtempSync(join(tmpdir(), "keystone-probe-")); + expect(() => new SessionStore(dir).assertWritable()).not.toThrow(); + rmSync(dir, { recursive: true, force: true }); + }); }); diff --git a/keeper/test/testnet-client.test.ts b/keeper/test/testnet-client.test.ts index a705158..9700f28 100644 --- a/keeper/test/testnet-client.test.ts +++ b/keeper/test/testnet-client.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { Account, Contract, Keypair, Networks, nativeToScVal, Operation, TransactionBuilder, xdr } from "vault-client"; import { KeeperError, VaultError } from "../src/errors.ts"; -import { TestnetVaultClient, decodeEvent, mapChainError } from "../src/testnet-vault-client.ts"; +import { TestnetVaultClient, classifySendError, decodeEvent, mapChainError } from "../src/testnet-vault-client.ts"; import { isLocalOrigin } from "../src/server.ts"; const sym = (s: string) => xdr.ScVal.scvSymbol(s); @@ -102,3 +102,33 @@ describe("inspectSigned only accepts a signed deposit or withdraw on the vault", expect(client.inspectSigned(build([other])).vaultId).not.toBe(VAULT); }); }); + +describe("classifySendError (keeper-signed sends)", () => { + const HASH = "ab".repeat(32); + class SendFailedError extends Error {} + class TransactionStillPendingError extends Error {} + + it("keeps the plain mapping when nothing was signed", () => { + const e = classifySendError(new Error("Error(Contract, #2)"), undefined); + expect(e.code).toBe("InsufficientAvailable"); + expect(e.txHash).toBeUndefined(); + }); + + it("marks a refusal by the network as not sent", () => { + const e = classifySendError(new SendFailedError("Sending transaction failed"), HASH); + expect(e.notSent).toBe(true); + expect(e.txHash).toBeUndefined(); + }); + + it("keeps the hash of a transaction whose confirmation was lost", () => { + const e = classifySendError(new TransactionStillPendingError("Transaction still pending"), HASH, "CVAULT"); + expect([e.code, e.txHash, e.vaultId, e.notSent]).toEqual(["RpcUnavailable", HASH, "CVAULT", undefined]); + }); + + it("treats any other failure after signing as possibly in flight, even an unrecognised one", () => { + for (const msg of ["getaddrinfo EAI_AGAIN soroban-testnet.stellar.org", "Network Error", "The operation was aborted", "weird failure"]) { + const e = classifySendError(new Error(msg), HASH); + expect([e.code, e.txHash], msg).toEqual(["RpcUnavailable", HASH]); + } + }); +}); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index b2a3a47..6d4d29e 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -23,9 +23,9 @@ export interface KeystoneConfig { accounts: { fund: string; bank: string; keeper: string }; assets: AssetInfo[]; signerMode: SignerMode; - /** Optional (v1.3): which vault backs the keeper. "fake" means no real transaction. */ + /** Optional (v1.4): which vault backs the keeper. "fake" means no real transaction. */ backend?: "testnet" | "fake"; - /** Optional (v1.3): "token" when the write routes need `Authorization: Bearer `. */ + /** Optional (v1.4): "token" when the write routes need `Authorization: Bearer `. */ writeAuth?: "token" | "none"; } From dbfb484e2850a9325d170d61169d5400d1161c30 Mon Sep 17 00:00:00 2001 From: Gregoire MOURO Date: Mon, 26 Oct 2026 07:11:52 +0000 Subject: [PATCH 3/8] keeper: keep the hash guard through any error after signing, save before send, tighten hosted rules Second review fixes: chain() releases the guard only on a definite outcome; a hash that cannot be saved stops the send; DUPLICATE is not a refusal; wallet-signed transactions must expire within 200 s; localhost origins are refused on a hosted keeper; restore no longer holds the listening socket; the Euribor seed is copied into a fresh data directory; send() is tested with the real signer. Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 2 +- .gitignore | 4 ++ README.md | 4 +- docs/deploy.md | 6 ++- keeper/src/errors.ts | 4 +- keeper/src/hosted.ts | 4 ++ keeper/src/index.ts | 11 ++++- keeper/src/session.ts | 41 ++++++++++-------- keeper/src/store.ts | 6 ++- keeper/src/testnet-vault-client.ts | 23 ++++++++-- keeper/test/hosted.test.ts | 44 ++++++++++++++++++- keeper/test/testnet-client.test.ts | 68 +++++++++++++++++++++++++++++- 12 files changed, 184 insertions(+), 33 deletions(-) diff --git a/.env.example b/.env.example index 8767e71..3129c8b 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,7 @@ VITE_USE_MOCKS=false # --- Keeper backend --- # testnet = vrai vault Soroban sur Stellar Testnet (transactions réelles) ; fake = vault en mémoire, hashes factices. -# KEEPER_ALLOWED_ORIGINS : origines web supplémentaires autorisées (CORS), séparées par des virgules ; localhost est toujours autorisé. +# KEEPER_ALLOWED_ORIGINS : origines web supplémentaires autorisées (CORS), séparées par des virgules ; localhost est autorisé en local seulement : un keeper hébergé le refuse. KEEPER_BACKEND= KEEPER_ALLOWED_ORIGINS= diff --git a/.gitignore b/.gitignore index 6ae2019..680fd27 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,7 @@ keeper/data/market-cache.local.json Thumbs.db .vscode/ .idea/ + +# Keeper runtime state (hosted or local run) +keeper/data/session.json* +keeper/data/*.probe diff --git a/README.md b/README.md index 506fef3..43d21b0 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ Verified on the keeper machine (macOS). Not yet verified on Windows. | `PORT` | keeper | API port, default 8787 | | `KEEPER_BACKEND` | keeper | `testnet` (real vault on Stellar Testnet, real transactions) or `fake` (in-memory vault, fake `FAKE…` hashes, no transaction; used by the tests). No default: the keeper refuses to start without it | | `KEEPER_ALLOWED_ORIGINS` | keeper | Extra browser origins allowed by CORS (comma separated). `localhost` and `127.0.0.1` are always allowed; the keeper listens on `127.0.0.1` only | -| `KEEPER_HOST` | keeper | Listening address, default `127.0.0.1`. A hosted keeper sets `0.0.0.0`; it then requires `KEEPER_ACCESS_TOKENS`, `KEEPER_ALLOWED_ORIGINS` and `KEEPER_PUBLIC_HOSTS`, refuses `SIGNER=keeper` and a `FUND_SECRET`, and ignores localhost origins | +| `KEEPER_HOST` | keeper | Listening address, default `127.0.0.1`. A hosted keeper sets `0.0.0.0`; it then requires `KEEPER_ACCESS_TOKENS`, `KEEPER_ALLOWED_ORIGINS` and `KEEPER_PUBLIC_HOSTS`, refuses `SIGNER=keeper`, a `FUND_SECRET` and localhost origins | | `KEEPER_ACCESS_TOKENS` | hosted keeper | Access codes (32 characters or more, comma separated) required as `Authorization: Bearer` on every `POST` route | | `KEEPER_PUBLIC_HOSTS` | hosted keeper | Public host name(s) of the keeper (Host header check) | | `KEEPER_DATA_DIR` | keeper | Where the session file and the Euribor and market caches are kept (a mounted volume when hosted). Default `keeper/data` | @@ -229,7 +229,7 @@ Only commands that have been run successfully are listed. Run them from the repo | Command | What it does | Status | | --- | --- | --- | | `npm run typecheck` | Type-checks every workspace that defines a `typecheck` script (today: `app`, `packages/shared`, `scripts` and `keeper`) | Verified (macOS) | -| `npm test` | Runs the tests of every workspace that defines a `test` script (today: Vitest checks of the shared mocks, the script helpers, the margin model with reference values, the Euribor cache and the keeper API on the in-memory vault) | Verified (macOS): 94 keeper tests and 29 app tests pass | +| `npm test` | Runs the tests of every workspace that defines a `test` script (today: Vitest checks of the shared mocks, the script helpers, the margin model with reference values, the Euribor cache and the keeper API on the in-memory vault) | Verified (macOS): 114 keeper tests and 29 app tests pass | | `npm run init-env` | Creates `.env` from `.env.example` if needed and generates the Testnet keys still empty (issuer, bank, keeper). Never generates `FUND_SECRET`, never overwrites a value, prints public keys only | Verified (macOS) | | `npm run bootstrap` | Idempotent Testnet bootstrap: issuer, bank and keeper accounts (friendbot), bank trustlines, KMMF and KBOND Stellar Asset Contracts, and, when `FUND_SECRET` is set, fund trustlines and issuance up to 1 000 units of each asset (the fund address is derived from `FUND_SECRET`). Writes `deployments/testnet.json` | Verified (macOS) over seven runs (the first stopped on a bug, since fixed); once everything exists, a run sends no transaction | | `KEEPER_BACKEND=testnet SIGNER=keeper npm run keeper` | Starts the keeper API on `http://127.0.0.1:8787/api` on the **real vault** (C2 build, from `deployments/testnet.json`): `POST /session` deploys a new vault from the recorded WASM hash, deposits and withdrawals are signed with the local demo fund key, `post_margin` with the keeper key, and every `chain` entry of the SSE feed carries the hash of a transaction confirmed by the RPC. Needs `KEEPER_SECRET` and `FUND_SECRET` in the local `.env` (checked against `deployments/testnet.json`). Routes: `/health`, `/config`, `/session` (POST, GET), `/session/deposit`, `/session/withdraw`, `/session/deposit/prepare`, `/session/withdraw/prepare`, `/session/submit`, `/session/rate-shock`, `/session/settle-default` (permissionless `settle_default`, submitted by the keeper account), `/events` (SSE) | Verified (macOS): boots, `/health`, `/session` without session → `NoSession`; the transactions behind it are proven by the integration run below. **Do not expose it publicly with `SIGNER=keeper`**: anyone reaching the API would spend the demo fund. The keeper listens on 127.0.0.1 and refuses requests whose Host or Origin is not loopback (CORS and DNS-rebinding protection, tested) | diff --git a/docs/deploy.md b/docs/deploy.md index 8eea50d..6578a25 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -21,7 +21,7 @@ Pourquoi le keeper n'est pas sur Vercel : il garde des connexions SSE ouvertes, | --- | --- | --- | | Un visiteur déploie des vaults, poste une marge, règle un défaut | Chaque route `POST` exige `Authorization: Bearer `; les codes sont dans `KEEPER_ACCESS_TOKENS` (côté serveur, 32 caractères minimum, comparaison à temps constant) | `keeper/src/access.ts`, `server.ts` | | Le code fuit dans le site | Le code n'est jamais une variable `VITE_*` : chaque personne le saisit dans la page, il reste dans son navigateur | `app/src/api/access.ts` | -| Le keeper signe pour le fonds | Au démarrage hébergé, `SIGNER=keeper` et `FUND_SECRET` sont refusés : tout dépôt ou retrait passe par Freighter | `keeper/src/index.ts` | +| Le keeper signe pour le fonds | Au démarrage hébergé, `SIGNER=keeper` et `FUND_SECRET` sont refusés : tout dépôt ou retrait passe par Freighter | `keeper/src/hosted.ts` | | Page tierce qui appelle l'API | Origines (`KEEPER_ALLOWED_ORIGINS`), hôtes exacts (`KEEPER_PUBLIC_HOSTS`), `localhost` refusé en mode hébergé. Ce n'est **pas** une authentification. Le joker `*` des origines (previews Vercel) accepte aussi des noms d'autres équipes qui se terminent pareil : préférer des origines exactes quand c'est possible | `server.ts` | | Trop de flux SSE | 100 flux ouverts au plus, 10 par adresse | `server.ts` | @@ -36,8 +36,9 @@ Limites assumées : un seul code partagé par personne (pas de comptes), pas de - Le hash d'une transaction signée par le keeper est enregistré **avant** son envoi (et celui d'une transaction signée dans Freighter dès sa réception), puis sauvegardé. Un redémarrage pendant l'attente de confirmation garde donc la garde. Une entrée `pending` retrouvée au redémarrage sans hash bloque les écritures jusqu'à la fin de la fenêtre de 200 s. - Une transaction dont la confirmation n'arrive pas (délai RPC, coupure réseau) reste `pending` avec son hash. Tant qu'elle n'est pas tranchée, **toute écriture et toute préparation de signature sont refusées** (`PendingTransaction`). Toutes les 15 s, et avant chaque écriture, le keeper interroge `getTransaction(hash)` : `SUCCESS` confirme l'entrée, `FAILED` la rejette, et un hash introuvable 200 s après l'enregistrement est déclaré non inclus (aucune transaction construite par le keeper n'est valable plus longtemps). Une transaction refusée par le réseau, ou qui n'a jamais été signée, est marquée `failed` tout de suite. La garde de 200 s du front reste, mais elle n'est qu'un confort. - Après un arrêt, le keeper ne jette la session que si la chaîne dit que le coffre n'existe plus ; une panne réseau au démarrage la conserve. -- Sur SIGTERM (redéploiement), le keeper cesse d'accepter des requêtes et laisse finir l'écriture en cours avant de s'arrêter (25 s au plus). +- Sur SIGTERM (redéploiement), le keeper cesse d'accepter des requêtes et laisse finir l'écriture en cours avant de s'arrêter (25 s au plus). Railway ne laisse ce délai que si la variable `RAILWAY_DEPLOYMENT_DRAINING_SECONDS=30` est définie sur le service : à ajouter. - Au démarrage hébergé, le keeper vérifie qu'il peut écrire dans `KEEPER_DATA_DIR` et refuse de démarrer sinon. +- Un fichier de session qui ne peut pas être écrit empêche d'envoyer la transaction (rien ne part sans trace durable). Une transaction d'un fonds signée dans Freighter doit expirer dans les 200 s qui suivent sa soumission, sinon elle est refusée (`BadRequest`). - Limites connues : la fenêtre de 200 s se mesure à l'horloge du serveur ; un hash introuvable après un très long arrêt (au-delà de la mémoire du RPC) est lu comme non inclus, d'où le message qui renvoie à l'état du coffre lu sur la chaîne. Une confirmation tardive met à jour l'entrée et l'état, mais ne recrée pas les entrées `margin_call`, `cured` et `released` de cette transaction. Un déploiement confirmé après un redémarrage sans adresse de coffre enregistrée n'est pas adopté : lancer une nouvelle session. ## 4. Coûts et limites (vérifiés dans la documentation des hébergeurs le 26 octobre 2026) @@ -74,6 +75,7 @@ openssl rand -hex 24 # un code d'accès par personne : à donner à la personn | `KEEPER_PUBLIC_HOSTS` | `xxx.up.railway.app` | | `KEEPER_ALLOWED_ORIGINS` | l'origine HTTPS exacte du site, par exemple `https://keystone-xxx.vercel.app`; pour les previews, `https://keystone-*-<équipe>.vercel.app` | | `KEEPER_DATA_DIR` | `/data` | + | `RAILWAY_DEPLOYMENT_DRAINING_SECONDS` | `30` | Déjà fixées par le `Dockerfile` : `KEEPER_HOST=0.0.0.0`, `KEEPER_BACKEND=testnet`, `SIGNER=freighter`. Railway fournit `PORT`. Ne **jamais** définir `FUND_SECRET`, `ISSUER_SECRET` ni `BANK_SECRET`. 5. 👤 *Settings → Volumes → New Volume*, point de montage `/data`. Le conteneur tourne en root (compatible avec les volumes de Railway) ; si le service ne peut pas écrire dans `/data`, le keeper refuse de démarrer, et ajouter `RAILWAY_RUN_UID=0` règle le cas. diff --git a/keeper/src/errors.ts b/keeper/src/errors.ts index 66aaa6d..984d72e 100644 --- a/keeper/src/errors.ts +++ b/keeper/src/errors.ts @@ -24,6 +24,8 @@ export class KeeperError extends Error { vaultId?: string; /** The network refused the transaction before including it: nothing to reconcile. */ notSent?: boolean; + /** The transaction was included and failed on-chain: a definite outcome. */ + onChain?: boolean; constructor(code: ApiErrorCode, message: string) { super(message); this.name = "KeeperError"; @@ -41,5 +43,5 @@ export class VaultError extends KeeperError { /** True only when the chain says the vault does not exist (after a Testnet reset, for example): never for a network trouble. */ export function isVaultGone(e: unknown): boolean { - return e instanceof KeeperError && e.code === "TxFailed" && /MissingValue|not found|does not exist|Unknown vault/i.test(e.message); + return e instanceof KeeperError && e.code === "TxFailed" && /MissingValue|non-existing value for contract instance|Unknown vault/i.test(e.message); } diff --git a/keeper/src/hosted.ts b/keeper/src/hosted.ts index 3db608b..0cee1c8 100644 --- a/keeper/src/hosted.ts +++ b/keeper/src/hosted.ts @@ -17,6 +17,10 @@ export function readExposure(env: Record, signer: st if (accessTokens.length === 0) throw new Error("A hosted keeper needs KEEPER_ACCESS_TOKENS: its write routes cost fees and move collateral"); if (!env.KEEPER_ALLOWED_ORIGINS) throw new Error("A hosted keeper needs KEEPER_ALLOWED_ORIGINS (the exact origin of the web app)"); if (!env.KEEPER_PUBLIC_HOSTS) throw new Error("A hosted keeper needs KEEPER_PUBLIC_HOSTS (its own public host name)"); + const local = /^https?:\/\/(localhost|127\.|\[::1\])/i; + if ((env.KEEPER_ALLOWED_ORIGINS ?? "").split(",").some((o) => local.test(o.trim()))) { + throw new Error("KEEPER_ALLOWED_ORIGINS must not list localhost on a hosted keeper: any page on a visitor's machine could then call it"); + } if (env.FUND_SECRET) throw new Error("FUND_SECRET must not be set on a hosted keeper"); } return { host, hosted, accessTokens }; diff --git a/keeper/src/index.ts b/keeper/src/index.ts index bde8511..2e2eb5c 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -1,6 +1,6 @@ // Keeper entry point. KEEPER_BACKEND=testnet uses the real vault on Stellar Testnet, // KEEPER_BACKEND=fake an in-memory vault (fake hashes, no transaction). -import { existsSync, readFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Keypair } from "vault-client"; @@ -70,6 +70,12 @@ const matchesOrigin = originMatcher(extraOrigins); const publicHosts = list("KEEPER_PUBLIC_HOSTS"); // Where the keeper keeps what must survive a restart: on a host, a mounted volume (KEEPER_DATA_DIR=/data). const dataDir = process.env.KEEPER_DATA_DIR || join(keeperDir, "data"); +// A fresh data directory starts from the versioned Euribor seed, as a local run does. +const seed = join(keeperDir, "data", "euribor-cache.json"); +if (dataDir !== join(keeperDir, "data") && existsSync(seed) && !existsSync(join(dataDir, "euribor-cache.json"))) { + mkdirSync(dataDir, { recursive: true }); + copyFileSync(seed, join(dataDir, "euribor-cache.json")); +} const store = backend === "testnet" ? new SessionStore(dataDir) : undefined; // A keeper that cannot save its session would forget a funded vault on the next restart: refuse to run. if (store && hosted) store.assertWritable(); @@ -80,7 +86,8 @@ const sessions = new SessionManager({ euribor: () => getEuribor({ cachePath: join(dataDir, "euribor-cache.json") }), store, }); -await sessions.restore(); +// The saved session is read at once; the on-chain checks that follow must not hold up the listening socket. +void sessions.restore().catch((e: Error) => console.error(`Could not restore the saved session: ${e.message}`)); sessions.startReconciler(); const app = buildServer(config, sessions, { market: createMarketProvider({ diff --git a/keeper/src/session.ts b/keeper/src/session.ts index 2256369..1dd1de2 100644 --- a/keeper/src/session.ts +++ b/keeper/src/session.ts @@ -145,11 +145,12 @@ export class SessionManager { return () => clearInterval(timer); } - private persist(): void { + /** Saves the session; false when a store is configured and the write failed. */ + private persist(): boolean { const strip = (s: Session | null) => (s ? { ...s, lastState: null } : null); - this.deps.store?.save({ + return this.deps.store?.save({ seq: this.seq, session: strip(this.session), pendingDeploy: strip(this.pendingDeploy), unresolved: this.unresolved, - }); + }) ?? true; } /** @@ -403,27 +404,27 @@ export class SessionManager { ...(track?.preHash ? { txHash: track.preHash, explorerUrl: `${this.deps.config.explorerTxBase}${track.preHash}` } : {}), }); this.active = track ? { entry, track } : null; - // A hash known before sending (wallet-signed) is recorded before the network can lose track of the transaction. - if (track?.preHash) this.markInFlight(entry, track, track.preHash); let result: T; try { + // A hash known before sending (wallet-signed) is recorded before the network can lose track of the transaction. + if (track?.preHash) this.markInFlight(entry, track, track.preHash); result = await run(); } catch (e) { - // Pending only when something may be in flight: a hash is known. Without one, nothing was signed or sent. - if (e instanceof KeeperError && e.code === "RpcUnavailable" && !e.notSent && (e.txHash || this.unresolved)) { - const hash = e.txHash ?? this.unresolved?.hash; - this.update(entry, { - detail: `No confirmation yet: ${e.message}`, - ...(hash ? { txHash: hash, explorerUrl: `${this.deps.config.explorerTxBase}${hash}` } : {}), - }); - // The transaction may still land: it stays recorded so that nothing else is sent before its outcome is known. - if (hash && track && !this.unresolved) this.markInFlight(entry, track, hash, e.vaultId); - } else { - // A definite outcome (rejected, failed on-chain, or refused by the network): nothing is in flight. + const known = e instanceof KeeperError ? e : undefined; + if (!this.unresolved && known?.txHash && track && !known.notSent) this.markInFlight(entry, track, known.txHash, known.vaultId); + // Definite outcomes only: refused by the network, failed on-chain, or rejected before anything was sent. + const definite = !this.unresolved || known?.notSent || known?.onChain || known?.code === "BadRequest"; + if (definite) { this.unresolved = null; this.update(entry, { status: "failed", detail: e instanceof Error ? e.message : String(e) }); + throw e; } - throw e; + // Anything else after the hash was recorded leaves the transaction possibly in flight: it stays pending and + // guarded until looked up by hash, whatever the error looks like. + const hash = this.unresolved!.hash; + const message = e instanceof Error ? e.message : String(e); + this.update(entry, { detail: `No confirmation yet: ${message}`, txHash: hash, explorerUrl: `${this.deps.config.explorerTxBase}${hash}` }); + throw known?.code === "RpcUnavailable" ? e : new KeeperError("RpcUnavailable", `No confirmation yet: ${message}`.slice(0, 300)); } finally { this.active = null; } @@ -451,7 +452,11 @@ export class SessionManager { hash, entryId: entry.id, kind: track.kind ?? "write", notAfter: this.now().getTime() + EXPIRY_MS, doneTitle: track.doneTitle, doneDetail: track.doneDetail, vaultId, postedValueCents: track.postedValueCents, }; - this.persist(); + // The record must be durable before the transaction leaves: otherwise a restart would forget it. + if (!this.persist()) { + this.unresolved = null; + throw new KeeperError("InternalError", "The keeper cannot save its state: nothing was sent"); + } } /** The client signed a transaction and is about to send it: record its hash first. */ diff --git a/keeper/src/store.ts b/keeper/src/store.ts index 0f62486..c3caa09 100644 --- a/keeper/src/store.ts +++ b/keeper/src/store.ts @@ -40,15 +40,17 @@ export class SessionStore { return null; } - save(data: Omit): void { + /** Returns false (after logging) when the file could not be written. */ + save(data: Omit): boolean { try { const tmp = `${this.file}.tmp`; writeFileSync(tmp, JSON.stringify({ version: VERSION, ...data }, replacer), { mode: 0o600 }); renameSync(tmp, this.file); chmodSync(this.file, 0o600); + return true; } catch (e) { - // Never break an operation because the disk is unavailable, but never stay silent about it. console.error(`Keeper could not save its session: ${e instanceof Error ? e.message : String(e)}`); + return false; } } diff --git a/keeper/src/testnet-vault-client.ts b/keeper/src/testnet-vault-client.ts index d364419..eb94fbd 100644 --- a/keeper/src/testnet-vault-client.ts +++ b/keeper/src/testnet-vault-client.ts @@ -9,6 +9,9 @@ import type { DeployParams, OnChainStatus, SignedTxInfo, TxResult, TxStatus, VaultClient, VaultEvent, VaultState, } from "./vault-client.ts"; +/** Longest validity accepted for a wallet-signed transaction (the keeper builds them for 180 s). */ +export const MAX_VALIDITY_SECS = 200; + type NodeSigner = ReturnType; export interface TestnetOptions { @@ -48,7 +51,10 @@ export function mapChainError(e: unknown, vaultId?: string): KeeperError { export function classifySendError(e: unknown, signedHash: string | undefined, vaultId?: string): KeeperError { const mapped = mapChainError(e, vaultId); if (!signedHash) return mapped; - if ((e as { constructor?: { name?: string } } | null)?.constructor?.name === "SendFailedError") { + // The SDK raises SendFailedError for every status but PENDING. Only ERROR and TRY_AGAIN_LATER prove the transaction was + // not taken (DUPLICATE means the network already has it). + if ((e as { constructor?: { name?: string } } | null)?.constructor?.name === "SendFailedError" + && /"status":\s*"(ERROR|TRY_AGAIN_LATER)"/.test((e as Error).message)) { mapped.notSent = true; return mapped; } @@ -179,6 +185,12 @@ export class TestnetVaultClient implements VaultClient { } if (tx.source !== this.o.fundAddress) throw new KeeperError("BadRequest", "The transaction source must be the fund account"); if (tx.signatures.length === 0) throw new KeeperError("BadRequest", "The transaction is not signed"); + // The keeper decides a lost transaction can no longer land once MAX_VALIDITY_SECS have passed: refuse any that outlives it. + const maxTime = Number(tx.timeBounds?.maxTime ?? 0); + const nowSecs = Math.floor(Date.now() / 1000); + if (!maxTime || maxTime < nowSecs || maxTime > nowSecs + MAX_VALIDITY_SECS) { + throw new KeeperError("BadRequest", "The transaction must expire within a few minutes: prepare it again"); + } try { if (op.func.switch().name !== "hostFunctionTypeInvokeContract") throw new Error("not an invocation"); const call = op.func.invokeContract(); @@ -208,7 +220,8 @@ export class TestnetVaultClient implements VaultClient { throw refused; } const res = await this.confirm(sent.hash); - return { txHash: sent.hash, events: await this.eventsOf(vaultId, sent.hash, res.ledger), state: await this.getState(vaultId) }; + // The transaction is confirmed: a failing event read must not turn it into an error. + return { txHash: sent.hash, events: await this.eventsOf(vaultId, sent.hash, res.ledger).catch(() => []), state: await this.getState(vaultId) }; }); } @@ -274,7 +287,11 @@ export class TestnetVaultClient implements VaultClient { private async confirm(hash: string): Promise<{ ledger: number }> { const res = await this.server.pollTransaction(hash, { attempts: Math.ceil(this.timeout / 2), sleepStrategy: () => 2000 }); if (res.status === "SUCCESS") return { ledger: res.ledger }; - if (res.status === "FAILED") throw new KeeperError("TxFailed", `Transaction ${hash} failed on-chain`); + if (res.status === "FAILED") { + const failed = new KeeperError("TxFailed", `Transaction ${hash} failed on-chain`); + failed.onChain = true; + throw failed; + } const pending = new KeeperError("RpcUnavailable", `No confirmation for ${hash} after ${this.timeout}s`); pending.txHash = hash; throw pending; diff --git a/keeper/test/hosted.test.ts b/keeper/test/hosted.test.ts index d9d0318..aca82aa 100644 --- a/keeper/test/hosted.test.ts +++ b/keeper/test/hosted.test.ts @@ -207,7 +207,7 @@ describe("durable session and uncertain transactions", () => { during = b.sessions.deposit(1_000_000n).catch((e: KeeperError) => e.code); throw new Error("process died"); }; - await expect(a.sessions.deposit(1_000_000n)).rejects.toThrow("process died"); + await expect(a.sessions.deposit(1_000_000n)).rejects.toMatchObject({ code: "RpcUnavailable" }); await expect(during).resolves.toBe("PendingTransaction"); }); @@ -250,6 +250,42 @@ describe("durable session and uncertain transactions", () => { client.deposit = FakeVaultClient.prototype.deposit.bind(client); await expect(sessions.deposit(1_000_000n)).resolves.toBeTruthy(); }); + + it("keeps the guard whatever error follows a recorded hash (TxFailed or plain), and releases it on a definite one", async () => { + for (const thrown of [() => new KeeperError("TxFailed", "Request failed with status code 500"), () => new Error("socket closed")]) { + const { client, sessions } = make(); + await sessions.start("KMMF"); + const hook = client.onSigned; + client.deposit = async () => { hook?.("77".repeat(32)); throw thrown(); }; + await expect(sessions.deposit(1n)).rejects.toMatchObject({ code: "RpcUnavailable" }); + client.deposit = FakeVaultClient.prototype.deposit.bind(client); + await expect(sessions.deposit(1n)).rejects.toMatchObject({ code: "PendingTransaction" }); + } + // failed on-chain, refused by the network, or rejected as a bad request: nothing in flight + for (const mark of [(e: KeeperError) => { e.onChain = true; }, (e: KeeperError) => { e.notSent = true; }, () => undefined]) { + const { client, sessions } = make(); + await sessions.start("KMMF"); + const hook = client.onSigned; + client.deposit = async () => { hook?.("88".repeat(32)); const e = new KeeperError(mark.length === 0 ? "BadRequest" : "TxFailed", "definite"); mark(e); throw e; }; + await expect(sessions.deposit(1n)).rejects.toMatchObject({ message: "definite" }); + client.deposit = FakeVaultClient.prototype.deposit.bind(client); + await expect(sessions.deposit(1n)).resolves.toBeTruthy(); + } + }); + + it("sends nothing when the hash cannot be saved first", async () => { + const { client, sessions } = make(); + await sessions.start("KMMF"); + let sent = false; + const hook = client.onSigned; + const store = (sessions as unknown as { deps: { store: SessionStore } }).deps.store; + client.deposit = async () => { hook?.("99".repeat(32)); sent = true; return FakeVaultClient.prototype.deposit.call(client, "x", 1n); }; + const realSave = store.save.bind(store); + store.save = () => false; + await expect(sessions.deposit(1n)).rejects.toMatchObject({ code: "InternalError" }); + store.save = realSave; + expect(sent).toBe(false); + }); }); describe("hosted start-up rules", () => { @@ -273,6 +309,12 @@ describe("hosted start-up rules", () => { expect(() => readExposure({ ...ok, FUND_SECRET: "S" }, "freighter")).toThrow(/FUND_SECRET/); }); + it("refuses localhost among the allowed origins of a public keeper", () => { + for (const o of ["http://localhost:5173", "http://127.0.0.1:5173", `${WEB},http://[::1]:5173`]) { + expect(() => readExposure({ ...ok, KEEPER_ALLOWED_ORIGINS: o }, "freighter")).toThrow(/localhost/); + } + }); + it("refuses a data directory it cannot write", () => { expect(() => new SessionStore("/proc/definitely/not/writable").assertWritable()).toThrow(); const dir = mkdtempSync(join(tmpdir(), "keystone-probe-")); diff --git a/keeper/test/testnet-client.test.ts b/keeper/test/testnet-client.test.ts index 9700f28..d17cb85 100644 --- a/keeper/test/testnet-client.test.ts +++ b/keeper/test/testnet-client.test.ts @@ -78,6 +78,19 @@ describe("inspectSigned only accepts a signed deposit or withdraw on the vault", const refused = (xdrString: string) => expect(() => client.inspectSigned(xdrString)).toThrow(KeeperError); const code = (xdrString: string) => { try { client.inspectSigned(xdrString); } catch (e) { return (e as KeeperError).code; } return null; }; + it("refuses a transaction that does not expire within the keeper's window", () => { + const longLived = (secs: number | null) => { + const b = new TransactionBuilder(new Account(fund.publicKey(), "1"), { fee: "100", networkPassphrase: Networks.TESTNET }); + b.addOperation(call("deposit", amount)); + const tx = secs === null ? b.setTimeout(0).build() : b.setTimeout(secs).build(); + tx.sign(fund); + return tx.toXDR(); + }; + expect(code(longLived(3600))).toBe("BadRequest"); + expect(code(longLived(null))).toBe("BadRequest"); + expect(code(longLived(180))).toBeNull(); + }); + it("accepts a signed deposit and a withdraw", () => { expect(client.inspectSigned(build([call("deposit", amount)]))).toEqual({ action: "deposit", units: 10000000n, vaultId: VAULT, txHash: expect.stringMatching(/^[0-9a-f]{64}$/) }); expect(client.inspectSigned(build([call("withdraw", amount)])).action).toBe("withdraw"); @@ -108,6 +121,14 @@ describe("classifySendError (keeper-signed sends)", () => { class SendFailedError extends Error {} class TransactionStillPendingError extends Error {} + it("does not treat DUPLICATE as a refusal", () => { + const duplicate = new SendFailedError('Sending the transaction failed {\n "status": "DUPLICATE" }'); + const refused = new SendFailedError('Sending the transaction failed {\n "status": "TRY_AGAIN_LATER" }'); + expect(classifySendError(duplicate, HASH).notSent).toBeUndefined(); + expect(classifySendError(duplicate, HASH).txHash).toBe(HASH); + expect(classifySendError(refused, HASH).notSent).toBe(true); + }); + it("keeps the plain mapping when nothing was signed", () => { const e = classifySendError(new Error("Error(Contract, #2)"), undefined); expect(e.code).toBe("InsufficientAvailable"); @@ -115,7 +136,7 @@ describe("classifySendError (keeper-signed sends)", () => { }); it("marks a refusal by the network as not sent", () => { - const e = classifySendError(new SendFailedError("Sending transaction failed"), HASH); + const e = classifySendError(new SendFailedError('failed {"status": "ERROR"}'), HASH); expect(e.notSent).toBe(true); expect(e.txHash).toBeUndefined(); }); @@ -132,3 +153,48 @@ describe("classifySendError (keeper-signed sends)", () => { } }); }); + +describe("send() reports the hash of what it sends, before sending", () => { + const keeper = Keypair.random(); + const VAULT = "CDL5GRMUQGDTCFVRKJPXKUUIXYLICZOPQP54WYODPGN2XLIVHKWGTQA2"; + const client = new TestnetVaultClient({ + rpcUrl: "https://rpc.invalid", networkPassphrase: Networks.TESTNET, wasmHash: "00", keeper, fundAddress: Keypair.random().publicKey(), + }); + const unsigned = () => + new TransactionBuilder(new Account(keeper.publicKey(), "1"), { fee: "100", networkPassphrase: Networks.TESTNET }) + .addOperation(new Contract(VAULT).call("settle_default")).setTimeout(60).build().toXDR(); + type Signer = (xdrString: string) => Promise<{ signedTxXdr: string }>; + const send = (tx: unknown) => (client as unknown as { send(t: unknown, k: Keypair, v?: string): Promise }).send(tx, keeper, VAULT); + + it("calls onSigned with the hash of the signed envelope, then lets the network step run", async () => { + const order: string[] = []; + let signedXdr = ""; + client.onSigned = (hash, vaultId) => { order.push(`hook:${hash}:${vaultId}`); }; + const tx = { + signAndSend: async ({ signTransaction }: { signTransaction: Signer }) => { + signedXdr = (await signTransaction(unsigned())).signedTxXdr; + order.push("network"); + return "sent"; + }, + }; + await expect(send(tx)).resolves.toBe("sent"); + const expected = TransactionBuilder.fromXDR(signedXdr, Networks.TESTNET).hash().toString("hex"); + expect(order).toEqual([`hook:${expected}:${VAULT}`, "network"]); + }); + + it("turns a failure after signing into RpcUnavailable carrying that hash", async () => { + let hook = ""; + client.onSigned = (h) => { hook = h; }; + const tx = { signAndSend: async ({ signTransaction }: { signTransaction: Signer }) => { await signTransaction(unsigned()); throw new Error("Request failed with status code 500"); } }; + const err = (await send(tx).catch((e: unknown) => e)) as { txHash: string }; + expect(err).toMatchObject({ code: "RpcUnavailable", vaultId: VAULT }); + expect(err.txHash).toMatch(/^[0-9a-f]{64}$/); + expect(err.txHash).toBe(hook); + }); + + it("leaves a failure before signing as a plain error with no hash", async () => { + client.onSigned = () => { throw new Error("must not be called"); }; + const tx = { signAndSend: async () => { throw new Error("Error(Contract, #2)"); } }; + await expect(send(tx)).rejects.toMatchObject({ code: "InsufficientAvailable", txHash: undefined }); + }); +}); From fa5add25dbff2319f2e20d2034eea684c34596e2 Mon Sep 17 00:00:00 2001 From: Gregoire MOURO Date: Mon, 26 Oct 2026 07:38:27 +0000 Subject: [PATCH 4/8] keeper, docs: let uptime monitors and platform probes reach /api/health on a hosted keeper, add the Render variant GET /api/health skips the Host check on a hosted keeper only (nothing else does); deploy guide gains a Render + UptimeRobot variant and the cost/limits of the free tier. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- docs/deploy.md | 25 +++++++++++++++++++++++-- keeper/src/index.ts | 1 + keeper/src/server.ts | 10 ++++++---- keeper/test/hosted.test.ts | 17 ++++++++++++----- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 43d21b0..96f819b 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ Still to come, once they work: a single e2e script chaining both scenarios. ### Hosted deployment (web app on Vercel, keeper on Railway) -The web app is a static Vite build served by Vercel (`vercel.json`). The keeper is a long-lived Node process (Fastify, server-sent events, in-memory session) and runs in a container on Railway (`Dockerfile`, `railway.json`), not as serverless functions. Step-by-step instructions, variables and checks are in [`docs/deploy.md`](docs/deploy.md). +The web app is a static Vite build served by Vercel (`vercel.json`). The keeper is a long-lived Node process (Fastify, server-sent events, in-memory session) and runs in a container (`Dockerfile`; Railway via `railway.json`, or Render), not as serverless functions. Step-by-step instructions, variables and checks are in [`docs/deploy.md`](docs/deploy.md). What a hosted keeper does differently from a local one (all covered by tests in `keeper/test/hosted.test.ts`): diff --git a/docs/deploy.md b/docs/deploy.md index 6578a25..f93060f 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ -# Keystone — Déploiement (Vercel + Railway) +# Keystone — Déploiement (Vercel + Railway ou Render) -Ce document est le mode d'emploi du déploiement en ligne. Le site (statique) va sur Vercel, le keeper (processus Node permanent) sur Railway. Tout reste sur Stellar **Testnet**. Aucun contrat n'est redéployé : le keeper lit les adresses et le hash WASM de `deployments/testnet.json`. +Ce document est le mode d'emploi du déploiement en ligne. Le site (statique) va sur Vercel, le keeper (processus Node permanent) sur Railway ou sur Render (voir l'état de Railway au §4 et la variante Render au §5bis). Tout reste sur Stellar **Testnet**. Aucun contrat n'est redéployé : le keeper lit les adresses et le hash WASM de `deployments/testnet.json`. ## 1. Architecture @@ -49,6 +49,12 @@ Limites assumées : un seul code partagé par personne (pas de comptes), pas de | Railway | Free | 0 $, 1 $ de crédit par mois | 0,5 Go de RAM, 0,5 Go de volume : à peine suffisant, et le service s'arrête quand le crédit est épuisé | | Railway | Hobby | 5 $ par mois, 5 $ d'usage inclus | 5 Go de volume. **Recommandé** : le keeper consomme peu (un processus Node, ~150 Mo) | +| Render | Free | 0 $ | Le service s'endort après 15 min sans requête entrante ; **pas de disque persistant** (la session est perdue à chaque redémarrage ou redéploiement). Convient à un essai ou à une démo courte, avec un moniteur qui l'interroge toutes les 5 min (§5bis) | +| Render | Payant | non vérifié | Seul le payant permet un disque persistant (montage `/data`) ; un disque interdit les déploiements sans coupure et la duplication du service | +| UptimeRobot | Free | 0 $, sans carte | 50 moniteurs HTTP(s), intervalle minimal de 5 min | + +**État de Railway au 26 octobre 2026 :** la création de nouveaux comptes est suspendue (page de statut de Railway, notice du 23 octobre). Tant qu'elle l'est, utiliser Render (§5bis). + Aucun plan payant n'est activé par ce dépôt : c'est une décision de l'équipe. Railway ne met pas un service à dormir de lui-même ; l'option « Serverless / App Sleeping » existe dans les réglages du service et doit rester **désactivée** (elle couperait les flux SSE et vider la mémoire). ## 5. Étapes (actions humaines marquées 👤) @@ -108,6 +114,21 @@ Dans le navigateur : site chargé, logo et animations, écran de verrouillage (* Même parcours que le test local : session KMMF (signée par le keeper, un vault déployé), dépôt de 1 KMMF, retrait de 1 KMMF, signés dans Freighter (Testnet, compte `GDQ6AW…MQOY`). Coller les hashes dans la PR. +## 5bis. Variante Render + UptimeRobot (offre gratuite pour un essai) + +Les variables d'environnement, les codes d'accès, le `Dockerfile` et la partie Vercel (§5.C) sont identiques. Seul l'hébergeur du keeper change. + +1. 👤 render.com → *Get Started* → se connecter avec GitHub. +2. 👤 *New +* → *Web Service* → *Build and deploy from a Git repository* → autoriser Render sur le seul dépôt `Lagouze/keystone` → le choisir. +3. 👤 Réglages : *Name* `keystone-keeper` ; *Language* **Docker** ; *Branch* `deploy/vercel-railway` ; *Region* proche (Frankfurt) ; *Instance Type* **Free** pour l'essai ; laisser *Root Directory* et *Dockerfile Path* par défaut. +4. 👤 *Environment Variables* (mêmes valeurs qu'au §5.B, sans `RAILWAY_*`) : `KEEPER_SECRET`, `KEEPER_ACCESS_TOKENS`, `KEEPER_DATA_DIR=/data`, `KEEPER_ALLOWED_ORIGINS` (provisoire), `KEEPER_PUBLIC_HOSTS` (provisoire : `keystone-keeper.onrender.com`). Render fournit `PORT` (10000 par défaut) : le keeper l'utilise. +5. 👤 *Advanced* → *Health Check Path* : `/api/health`. +6. 👤 *Create Web Service*. Le premier build prend quelques minutes. L'adresse est `https://.onrender.com` ; mettre à jour `KEEPER_PUBLIC_HOSTS` avec ce nom exact (sans `https://`). +7. Vérifier : `curl https://.onrender.com/api/config` (mêmes attendus qu'au §5.B.6). +8. 👤 UptimeRobot : uptimerobot.com → créer un compte → *Add New Monitor* → type **HTTP(s)**, URL `https://.onrender.com/api/health`, intervalle **5 minutes**. + +Limites de cette variante (offre gratuite) : réveil lent après une veille, session perdue à chaque redémarrage ou redéploiement, garde « pas de double transaction » perdue avec elle. Pour garder une session ou montrer la démo au jury : passer le service à un plan payant et lui attacher un disque monté sur `/data` (*Disks* → *Add Disk*). + ## 6. Exploitation - **Changer ou révoquer un code** : modifier `KEEPER_ACCESS_TOKENS` sur Railway (redéploiement automatique). La session survit grâce au volume. diff --git a/keeper/src/index.ts b/keeper/src/index.ts index 2e2eb5c..5200159 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -98,6 +98,7 @@ const app = buildServer(config, sessions, { allowedHost: (h) => (!hosted && /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(h)) || publicHosts.includes(h), accessTokens, backend, + healthProbeAnyHost: hosted, trustProxyHops: hosted ? Number(process.env.KEEPER_TRUST_PROXY_HOPS || 1) : undefined, }); await app.listen({ port, host }); diff --git a/keeper/src/server.ts b/keeper/src/server.ts index bf2da06..38b52ad 100644 --- a/keeper/src/server.ts +++ b/keeper/src/server.ts @@ -11,8 +11,8 @@ const I128_MAX = (1n << 127n) - 1n; const HEARTBEAT_MS = 15_000; const MAX_STREAMS = 100; const MAX_STREAMS_PER_IP = 10; -/** Railway's health probe calls /api/health with this Host; it is let through for that route only. */ -const PROBE_HOST = "healthcheck.railway.app"; +/** Platform probes and uptime monitors call GET /api/health with a Host of their own. */ +const isHealthProbe = (method: string, url: string) => method === "GET" && url === "/api/health"; function parseUnits(body: unknown): bigint { const units = (body as { units?: unknown } | null)?.units; @@ -39,6 +39,8 @@ export interface ServerOptions { accessTokens?: string[]; /** Shown by GET /config so the interface never presents a fake vault as the real one. Default "fake". */ backend?: "testnet" | "fake"; + /** Hosted keeper: GET /api/health skips the Host check (health probes, uptime monitors). Off locally, against DNS rebinding. */ + healthProbeAnyHost?: boolean; /** Number of reverse proxies in front of the keeper (hosted), for the client address. */ trustProxyHops?: number; } @@ -61,8 +63,8 @@ export function buildServer(config: KeystoneConfig, sessions: SessionManager, op // refuse any request whose Host or Origin is not expected. app.addHook("onRequest", async (req, reply) => { const origin = req.headers.origin; - const probe = req.headers.host === PROBE_HOST && req.method === "GET" && req.url === "/api/health"; - if (!probe && (!allowedHost(req.headers.host ?? "") || (origin !== undefined && !allowed(origin)))) { + const hostOk = (options.healthProbeAnyHost && isHealthProbe(req.method, req.url)) || allowedHost(req.headers.host ?? ""); + if (!hostOk || (origin !== undefined && !allowed(origin))) { return reply.status(403).send({ error: { code: "BadRequest", message: "Host or Origin not allowed" } }); } }); diff --git a/keeper/test/hosted.test.ts b/keeper/test/hosted.test.ts index aca82aa..d7e63bd 100644 --- a/keeper/test/hosted.test.ts +++ b/keeper/test/hosted.test.ts @@ -48,7 +48,7 @@ describe("hosted API", () => { const client = new FakeVaultClient(); const sessions = new SessionManager({ client, config: config("freighter"), scenario, euribor }); const app = buildServer(config("freighter"), sessions, { - accessTokens: [TOKEN], backend: "testnet", + accessTokens: [TOKEN], backend: "testnet", healthProbeAnyHost: true, allowedOrigin: (o) => o === WEB, allowedHost: (h) => h === "keeper.example.com", }); const call = (method: "GET" | "POST" | "OPTIONS", url: string, headers: Record = {}, payload?: object) => @@ -91,10 +91,17 @@ describe("hosted API", () => { expect((await call("GET", "/session", { origin: "https://evil.example.com" })).statusCode).toBe(403); }); - it("lets the platform probe reach /api/health only", async () => { - const { call } = make(); - expect((await call("GET", "/health", { host: "healthcheck.railway.app", origin: "" })).statusCode).toBe(200); - expect((await call("GET", "/config", { host: "healthcheck.railway.app" })).statusCode).toBe(403); + it("lets a platform probe or uptime monitor reach /api/health only, whatever its Host", async () => { + const { app } = make(); + const get = (url: string, host: string, origin?: string) => + app.inject({ method: "GET", url: `/api${url}`, headers: { host, ...(origin ? { origin } : {}) } }); + for (const host of ["healthcheck.railway.app", "keystone-keeper.onrender.com", "10.0.0.5:10000"]) { + expect((await get("/health", host)).statusCode, host).toBe(200); + expect((await get("/config", host)).statusCode, host).toBe(403); + expect((await get("/session", host)).statusCode, host).toBe(403); + } + // a page of another origin is still refused, even on the health route + expect((await get("/health", "x.onrender.com", "https://evil.example.com")).statusCode).toBe(403); }); }); From 61b85d7bd9c34a60f0f52a776bff1bab7e1e5e1e Mon Sep 17 00:00:00 2001 From: Gregoire MOURO Date: Mon, 26 Oct 2026 08:05:21 +0000 Subject: [PATCH 5/8] ci: trigger the Vercel preview of this branch Co-Authored-By: Claude Sonnet 5.5 From 730e764d911da229fa1d021c1fc1987585e63600 Mon Sep 17 00:00:00 2001 From: Gregoire MOURO Date: Mon, 26 Oct 2026 08:23:20 +0000 Subject: [PATCH 6/8] front: open the market advisor from the dashboard top bar, not only from the landing page Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- app/src/App.tsx | 3 +++ app/src/dashboard/MarketAdvisor.tsx | 18 ++++++++++++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 96f819b..fbae62c 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ Verified in a browser (integration branch, 2026-10-26): both scenarios on the mo ### Collateral Market Advisor -On Landing, **Compare market conditions** opens a read-only assistant. It shows five indicator slots, observation/retrieval dates, source links, availability, publication frequency, cache/freshness labels, indicative class scores and assumptions. Closing it leaves the manual collateral selection unchanged. Only the separate Start button creates a vault. No wallet is needed for the assistant, and its only request is `GET /api/market-advisor`; it never calls a Stellar write route. With `VITE_USE_MOCKS=true`, session scenarios remain mocked while the advisor still requests real market observations from the configured keeper (or shows insufficient data if it is unavailable). +On Landing, **Compare market conditions** opens a read-only assistant; once a session is running, the **Market** button in the dashboard's top bar opens the same assistant, so it can be shown at any time without ending the session. It shows five indicator slots, observation/retrieval dates, source links, availability, publication frequency, cache/freshness labels, indicative class scores and assumptions. Closing it leaves the manual collateral selection unchanged. Only the separate Start button creates a vault. No wallet is needed for the assistant, and its only request is `GET /api/market-advisor`; it never calls a Stellar write route. With `VITE_USE_MOCKS=true`, session scenarios remain mocked while the advisor still requests real market observations from the configured keeper (or shows insufficient data if it is unavailable). | Indicator | Actual source / interpretation | | --- | --- | diff --git a/app/src/App.tsx b/app/src/App.tsx index 3abc3b3..7332b61 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -11,6 +11,7 @@ import { ActivityFeed } from './dashboard/ActivityFeed.tsx' import { assetOf } from './dashboard/assets.ts' import { DetailsModal } from './dashboard/DetailsModal.tsx' import { Landing } from './dashboard/Landing.tsx' +import { MarketAdvisor } from './dashboard/MarketAdvisor.tsx' import { MarketCard } from './dashboard/MarketCard.tsx' import { StatusCard } from './dashboard/StatusCard.tsx' import { VaultCard } from './dashboard/VaultCard.tsx' @@ -271,6 +272,8 @@ function TopBar({ : 'Reconnecting…'} + {/* The advisor is read-only: with a session running it stays one click away (it never starts or changes anything). */} + {k.session && } {onDetails && ( + setOpen(false)}>

Indicative class comparison. Real published market observations, separate from simulated token prices. No wallet needed.

{pending &&

Loading market observations…

} @@ -74,9 +80,13 @@ export function MarketAdvisor({ className }: { className?: string }) { }
- +
-

Closing this assistant leaves your selection unchanged. Starting a session requires the separate Start button.

+

+ {inSession + ? 'Closing this assistant changes nothing: it is read-only and never touches the running session.' + : 'Closing this assistant leaves your selection unchanged. Starting a session requires the separate Start button.'} +

} From 5d24e03e05edec47bf2bf6aba745365d9d3d0403 Mon Sep 17 00:00:00 2001 From: Gregoire MOURO Date: Mon, 26 Oct 2026 08:41:26 +0000 Subject: [PATCH 7/8] docs, front: public README (live URL, simulated demo, Freighter and hosted proofs), ?demo mode README: project status, live demo section, architecture diagram, demo instructions, Freighter and hosted-keeper proofs (read back from Horizon), progress table, known limits. App: ?demo plays the flow on the in-browser mock on any build, with a link from the access-code window. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 52 ++++++++++++++++++++------- app/src/config.ts | 11 ++++-- app/src/dashboard/AccessCodeModal.tsx | 4 +++ app/test/demo-mode.test.ts | 25 +++++++++++++ 4 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 app/test/demo-mode.test.ts diff --git a/README.md b/README.md index fbae62c..92fde35 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Keystone is a programmable collateral engine for an OTC interest rate swap betwe Built during HackMeridian 2026 (Lisbon, 25–26 October 2026), track Genesis. -> **Project status: backend MVP demonstrated on Testnet.** The shared types, the Testnet bootstrap, the margin model, the Soroban vault (C1 deposit/withdraw/margin and C2 default settlement) and a keeper API that drives it exist. Both scenarios were played on Stellar Testnet through the keeper's session code (section 6). The dashboard has not been run against the real keeper yet, the public URL, the video and the deck are missing. Every section below marked *not yet available* or *not tested* will be filled with real, verified values as the build progresses. The progress table (section 12) is the reference. +> **Project status: working MVP on Stellar Testnet, deployed.** The Soroban vault (C1 deposit/withdraw/margin and C2 default settlement), the keeper API and the dashboard work together: both scenarios were played on Stellar Testnet from a browser, and a deposit and a withdrawal were signed in the Freighter wallet (section 6). The dashboard is live (section 4). The demo video link is added to section 4 at submission. Not done: the guarantees of a production system (section 14). The progress table (section 12) is the reference, and a line only says *verified* with a proof. ## 1. Summary @@ -36,17 +36,29 @@ Keystone does not promise a return, does not remove the need for cash entirely, ## 4. Live demo -- **Public URL:** *not yet available* (hosting decided at milestone J2, see `docs/decisions.md`, A8). -- The public demo runs in `SIGNER=keeper` mode: judges cannot sign with the demo fund key, so the keeper signs on behalf of the demo fund account. The UI says so. The demo video shows the same flow signed in the Freighter wallet. -- **Demo video:** *not yet available*. +| What | Where | +| --- | --- | +| **Live web app** (real mode: it talks to the hosted keeper on Stellar Testnet) | | +| **Simulated demo** (the whole flow on simulated data, nothing sent to Stellar, no wallet, no code) | | +| **Demo video** | *link added at submission* | +| Hosted keeper API (read routes are open) | `https://keystone-keeper.onrender.com/api` (`/health`, `/config`, `/market-advisor`, `/session`, `/events`) | + +**What a judge can do.** + +- Open the live app, create a local account (accounts live in the browser, see section 10) and see the landing page, the **Collateral Market Advisor** (real ECB and market data, read-only) and, when a session is running, its dashboard updating live over server-sent events. +- Open the `?demo` link for the full hands-on flow: pick KMMF or KBOND, deposit, move rates, margin call, cure, release, default. Every transaction there is labelled **Demo tx**: nothing is sent to Stellar. +- Check the real transactions in section 6: every on-chain step has a stellar.expert link. + +**Why the live app cannot be driven by anyone.** In real mode, deposits and withdrawals are signed in the Freighter wallet by the demo fund account, whose key stays with the team, and every action that writes (starting a session deploys a vault) needs a team access code, because the keeper is public and pays Testnet fees. This is deliberate (section 9, *Hosted deployment*). The demo video shows the real flow with Freighter. The hosted keeper runs on a free plan: its first answer after a long idle period can take up to a minute, and a restart clears its session (an empty vault stays on-chain). ## 5. Architecture and use of Stellar ``` -Dashboard (Vite/React + Freighter) --REST--> Keeper (Node/Fastify) --signed tx--> keystone_vault (Soroban, Testnet) -Dashboard <--SSE: state + activity feed-- Keeper | transfers through the SAC -Keeper <-- ECB API (Euribor 3M) v -Bootstrap script --issues KMMF and KBOND--> fund account Fund and bank accounts (trustlines) +Browser: Dashboard (Vite/React, Freighter) --REST + SSE--> Keeper (Node/Fastify) --signed tx--> keystone_vault (Soroban, Testnet) + served by Vercel | | | transfers through the SAC + Freighter signs the fund's deposits | +-- ECB, Yahoo Finance (read-only) v + and withdrawals +-- hosted on Render Fund and bank accounts (trustlines) +Bootstrap script --issues KMMF and KBOND--> fund account ``` **Stellar features used** @@ -161,6 +173,16 @@ After a reload, the dashboard shows this KBOND session: "Defaulted – Collatera Scenario 1 was also replayed on a C2 vault (`CD3MAB27BM57BM5SVGAIM5CTBDJPAVSUHFCKDHNZHBHBMNGI6KX7QAC7`) with all script checks passing; its hashes were not recorded, so that replay is not a proof and the C1-build hashes above remain the reference table. An independent settlement fixture of the contract pole (fictitious KSC1 asset) is in `contracts/keystone_vault/testnet-c2.json`. +**Freighter** (2026-10-26, Testnet): a deposit and a withdrawal of 1 KMMF signed in the Freighter wallet from the dashboard, against a keeper running with `SIGNER=freighter` (the keeper prepares the transaction, the fund signs in the wallet, the keeper submits it). Each transaction was read back from Horizon: `successful`, and for the last two the source account is the demo fund account `GDQ6AW…MQOY`, the invoked function is `deposit` or `withdraw` with `i128` `10000000` (1 KMMF, 7 decimals) on the vault [`CAPZEIRJ…MFX2`](https://stellar.expert/explorer/testnet/contract/CAPZEIRJMFSSXN34DG3XV35H6STUEQZOFAH3MCXYJQ3VSRI4EVGDMFX2): + +| Step | Signed by | Ledger | Transaction | +| --- | --- | --- | --- | +| Vault deployed by *Start* (KMMF) | keeper account `GDGUJH…G4TO` | 5394522 | [`044ac4fd…6c64`](https://stellar.expert/explorer/testnet/tx/044ac4fd7d88f2706a99960abdfedd25d0f65620376aa7f74b03ec4fab466c64) | +| `deposit` of 1 KMMF | demo fund account, in Freighter | 5394549 | [`4e423a39…7dc1`](https://stellar.expert/explorer/testnet/tx/4e423a39e4021d3c40a85a1e8ffba09df1ca6cb72c91f6744005ecf3fc3c7dc1) | +| `withdraw` of 1 KMMF | demo fund account, in Freighter | 5394558 | [`30464b3c…8523`](https://stellar.expert/explorer/testnet/tx/30464b3c99c8f87d22835988a3368c56ae1804d5e8ce2b0579df234169598523) | + +**Hosted keeper** (2026-10-26): starting a KMMF session from the live web app made the keeper on Render deploy a vault on Testnet, vault [`CA6OA3I4…JV7D`](https://stellar.expert/explorer/testnet/contract/CA6OA3I4EPINUCZAVFTPHCQCBH4IQLCYZNHGNJBDWFEQP7IL23PSJV7D), transaction [`e192680a…afaf`](https://stellar.expert/explorer/testnet/tx/e192680a8e2a0f6dd61da64300ccbf255618e4c188f3d4cd4102c398c48afdaf) (ledger 5395810, signed by the keeper account, status `successful`). The vault holds nothing. + ## 7. Repository structure and poles The project is built by three poles working in parallel, each on its own branch, integrated through pull requests into `staging`. @@ -280,11 +302,12 @@ The root `npm run typecheck` also covers the dashboard. | --- | --- | | `VITE_KEEPER_URL` | Keeper API base URL, default `http://localhost:8787/api` | | `VITE_USE_MOCKS` | Exactly `true` to use the in-browser mock keeper (mock data, nothing sent to Stellar, every hash starts with `MOCK`) | +| `?demo` in the page address | Not a variable: plays the whole flow on the in-browser mock for that visit, on any build (used for the public simulated demo) | | `VITE_MOCK_SIGNER` | Mocks only: `keeper` plays the `SIGNER=keeper` fallback; otherwise the Freighter flow, with the signature simulated | **What the dashboard does.** After the landing page, the user picks KMMF or KBOND and starts a session (a new vault). The screen shows the vault status with its margin-call countdown and actions, the vault (coverage bar, free / locked / missing, the fund's token balance and, next to it, what the bank owes the fund in cash when the swap moves in the fund's favour, simulated because only the fund's side is collateralized on-chain; after a default, the tokens the bank received instead), the market figures (fund P&L, Euribor, cash kept invested, all simulated), rate shocks, deposit and withdraw, and the latest activity (`GET /events`). *Sim* entries are computations and never carry a hash; an *On-chain* entry shows its hash and stellar.expert link only once confirmed. Deposits and withdrawals are prepared by the keeper and signed by the fund in Freighter (Testnet, demo fund account checked before signing); when the keeper runs with `SIGNER=keeper` (public demo URL), the dashboard calls the direct routes and says so in the header. The header shows **Stellar Testnet** and the keeper's real state (*Connecting…*, *Keeper connected* while the event stream is open, *Reconnecting…*, *Keeper unreachable*); in mocks mode it shows *Demo data · nothing sent to Stellar* and *Mock keeper* instead, and the hashes are fake (`MOCK…`). Before asking Freighter for a signature, the dashboard refuses a deposit above the fund's balance or a withdrawal above the available collateral, and refuses a new deposit or withdrawal for about three minutes after a previous one ended without confirmation (an RPC timeout): a prepared transaction stays valid 180 s, so it may still land in that window. -Verified in a browser (integration branch, 2026-10-26): both scenarios on the mock keeper (KMMF deposit, margin call, cure, release, withdrawal; KBOND margin call, deadline, settlement); against a running keeper (`KEEPER_BACKEND=fake`, read-only: config, session, event stream, Market Advisor, loss and return of the keeper), with no POST sent. **Not tested yet from this front:** the keeper on the real vault (`KEEPER_BACKEND=testnet`) and a real Freighter signature. +Verified in a browser (integration branch, 2026-10-26): both scenarios on the mock keeper (KMMF deposit, margin call, cure, release, withdrawal; KBOND margin call, deadline, settlement); against a running keeper (`KEEPER_BACKEND=fake`, read-only: config, session, event stream, Market Advisor, loss and return of the keeper), with no POST sent. Since then, both scenarios were played from this front on the real vault (section 6), and a deposit and a withdrawal were signed with the Freighter wallet (section 6). **Not tested:** the Freighter flow from the hosted web app (the hosted keeper deployed a vault from the live app, section 6), and a phone-sized screen. **Lock screen.** The dashboard opens behind a lock screen in the style of the Mac login (`app/src/auth/`). Demo account: `demo@keystone.app` / `stellar`. *Create an account* adds an email and password; an email already registered (case-insensitive) is refused with *An account already exists for this email.* *Forgot password?* runs the local recovery described below (demo code shown on screen, no email). Accounts live in [PGlite](https://pglite.dev) (PostgreSQL compiled to WebAssembly, Apache-2.0) stored in the browser's IndexedDB (`keystone-auth`); passwords are hashed with PBKDF2-SHA256 (210 000 iterations, random salt). The unlocked user is kept in `sessionStorage`, so closing the tab locks again; the lock button in the header locks at once. This is a demo gate, not a security boundary: accounts exist only in that browser, and the keeper API itself has no authentication. @@ -318,7 +341,9 @@ Validation commands: `npm ci`, `npm run typecheck`, `npm test`, `npm run build - ## 10. Demo instructions -*To be written once the end-to-end flow works.* Two scenarios are planned: +**Try it online, no install:** (simulated, nothing sent to Stellar). Create a local account on the lock screen, or use `demo@keystone.app` / `stellar`. **Try it on your machine in two commands:** `npm install`, then `npm run demo -w @keystone/app` (builds and opens the same simulated demo on `http://localhost:4173`). The real, on-chain setup needs a Testnet keeper and a Freighter wallet and is described in section 9. + +Two scenarios are played, simulated in the demo and real on Stellar Testnet (section 6 has the hashes): 1. **Margin and release (KMMF).** Deposit, rate shock of −50 bp (collateral locked), further −30 bp (margin call, top-up, cured), +60 bp (collateral released and withdrawn). 2. **Default (KBOND).** Deposit, rate shock of −100 bp (margin call), no top-up, 90-second grace period, settlement: the locked collateral is transferred to the bank. @@ -357,15 +382,15 @@ Only the status "verified" counts as done, and only with a proof link. | --- | --- | --- | --- | | 1 | Swap + CSA scenario and margin model | Verified (reference-value tests; scenario 1 on the C1 build and scenario 2 on the C2 build, Testnet) | `keeper/test/margin.test.ts`, section 6 | | 2 | KMMF and KBOND issued on Testnet | Verified | [KMMF issued](https://stellar.expert/explorer/testnet/tx/ec2b3c50209bbdfa0851274c03b6cd339b20fdc86774f170cf76d4a65315a532), [KBOND issued](https://stellar.expert/explorer/testnet/tx/637fa5e953241c7a7a2f474b4239e44871d67e60af667419aff8f16a0f8ff463), [KMMF SAC](https://stellar.expert/explorer/testnet/tx/bc7d9d9a2a15473b5bae6b81461cac9a68003235dea60056605c8b2631df0e11), [KBOND SAC](https://stellar.expert/explorer/testnet/tx/08089cde2ab558289d0a18c8f85817e18297bb40b41c6506c2d109462b47dcd3) | -| 3 | Manual choice of collateral class in the UI | Not started | | +| 3 | Manual choice of collateral class in the UI | Verified (the choice KMMF or KBOND starts the matching vault, in a browser on Testnet) | KMMF session [`0e7604e2…3f2d`](https://stellar.expert/explorer/testnet/tx/0e7604e28c10da0a4ec3e2ea138a968a00ca805a71cef2fafefb414c45ef3f2d), KBOND session [`63d08b12…b3b4`](https://stellar.expert/explorer/testnet/tx/63d08b125bfe0aaf58e67a051c62122fea6393ccd5f46773ec026473e6c2b3b4) | | 4 | Soroban vault: authorized deposit, available / locked accounting | Verified (C1 and C2) | Section 6 tables; 18 Rust tests (contract pole) | | 5 | Keeper: exposure and margin computation, posting to the vault | Verified (both scenarios, Testnet) | `post_margin` hashes in section 6 | | 6 | Deposit, lock and release with verifiable results | Verified (Testnet) | [Deposit](https://stellar.expert/explorer/testnet/tx/eaeeb3da25b832b9e7019099fb74e8396f9d07a3bd6e5b638db152a3757cdec2), [lock](https://stellar.expert/explorer/testnet/tx/d575193f3c49bed24541a0f03dde70129c0242b570305440e6bdb2ca2c75a5f0), [release](https://stellar.expert/explorer/testnet/tx/91527a412eae01b080f3fe7b5b076904649613688cfe3964630034cd385d0dbb), [withdrawal](https://stellar.expert/explorer/testnet/tx/7de52778115187c6ecaadcef3eb4cef294ad045b9713e9b13e63012034bd7c4a) | | 7 | Separate default scenario with on-chain transfer to the bank | Verified (Testnet) | [`settle_default`, 300 KBOND to the bank](https://stellar.expert/explorer/testnet/tx/33793f423facfa6e2968da335558cc9bba2cc296b6ea6e2133b734829309112c); also from the browser: [`settle_default`, 50 KBOND to the bank](https://stellar.expert/explorer/testnet/tx/0c3bd3d7db474f950a6b4bf3119f78536c5ac89c4a6498c67b811a35589add43) | | 8 | Real Stellar Testnet transactions shown with proof | Verified (both scenarios from the browser) | Nine KMMF and four KBOND transactions with On-chain badges and Explorer links in section 6 | -| 9 | Dashboard showing asset choice, steps and on-chain proof | Verified on the real keeper for both scenarios (Liquid Glass UI in a browser); Freighter not tested | [KMMF deposit](https://stellar.expert/explorer/testnet/tx/e00a4b80d5de4e6d6dc08baa06bc9e1c3fdb4fded1bdfb209a32e280c2a15286), [KMMF cure](https://stellar.expert/explorer/testnet/tx/b651b662f4200556cb26975b2243628db34305fe02908911a3969ca5e418723b), [KBOND settlement](https://stellar.expert/explorer/testnet/tx/0c3bd3d7db474f950a6b4bf3119f78536c5ac89c4a6498c67b811a35589add43) | +| 9 | Dashboard showing asset choice, steps and on-chain proof | Verified on the real keeper for both scenarios (Liquid Glass UI in a browser); Freighter deposit and withdrawal verified (section 6) | [KMMF deposit](https://stellar.expert/explorer/testnet/tx/e00a4b80d5de4e6d6dc08baa06bc9e1c3fdb4fded1bdfb209a32e280c2a15286), [KMMF cure](https://stellar.expert/explorer/testnet/tx/b651b662f4200556cb26975b2243628db34305fe02908911a3969ca5e418723b), [KBOND settlement](https://stellar.expert/explorer/testnet/tx/0c3bd3d7db474f950a6b4bf3119f78536c5ac89c4a6498c67b811a35589add43) | | 10 | End-to-end integration of the three poles | In progress | Browser → keeper → C2 vault proven for both scenarios; Freighter signing and the public URL pending; hosted configuration (Vercel + Railway, access codes, durable session) written and tested locally, **not deployed** | -| 11 | Submission: public repo, README, public URL, video, pitch deck | Not started | | +| 11 | Submission: public repo, README, public URL, video, pitch deck | In progress: public repo, README and live URL done; video and deck by the team | Section 4 | | 12 | Continuous quality: review of every PR, tests, README updates | In place from phase zero | This repository's PR history | ## 13. Prior work and work done during the event @@ -382,4 +407,5 @@ Only the status "verified" counts as done, and only with a proof link. - KMMF and KBOND are fictitious; they carry no real economic rights. - **Etherfuse Stablebonds were tested and dropped:** on 25 October, an order of sandbox CETES on Etherfuse's API v2 was funded but never delivered to Stellar Testnet. The Etherfuse team confirmed this part of v2 was not finished before the submission deadline, so KBOND represents the bond class instead. - Hosted keeper: one shared team access code per teammate, no per-user accounts; the read routes (`/session`, `/events`) are public Testnet data. Local accounts (PGlite) are per browser origin and are not an authorization. +- The public demo is not a production system: the hosted keeper is a single instance on a free plan (idle wake-up delay, session lost on restart), actions need a shared team access code, and read routes are public Testnet data. Local accounts live in each browser and are not an authorization. - Testnet only. No real money, no Mainnet. diff --git a/app/src/config.ts b/app/src/config.ts index 0656310..d63c953 100644 --- a/app/src/config.ts +++ b/app/src/config.ts @@ -4,8 +4,15 @@ export const KEEPER_URL: string = ( import.meta.env.VITE_KEEPER_URL || 'http://localhost:8787/api' ).replace(/\/+$/, '') -/** Exactly "true" turns the in-browser mock keeper on. */ -export const USE_MOCKS: boolean = import.meta.env.VITE_USE_MOCKS === 'true' +/** + * `?demo` in the page address plays the whole flow on the in-browser mock, on any build: a visitor who has no access + * code (or no Freighter fund account) can still explore. The page says "Demo data · nothing sent to Stellar". + */ +export const DEMO_REQUESTED: boolean = + typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('demo') + +/** Exactly "true" turns the in-browser mock keeper on, as does `?demo`. */ +export const USE_MOCKS: boolean = import.meta.env.VITE_USE_MOCKS === 'true' || DEMO_REQUESTED /** Mocks only: "keeper" plays the SIGNER=keeper fallback, anything else the Freighter flow. */ export const MOCK_SIGNER_MODE: 'freighter' | 'keeper' = diff --git a/app/src/dashboard/AccessCodeModal.tsx b/app/src/dashboard/AccessCodeModal.tsx index cc22962..9830d5b 100644 --- a/app/src/dashboard/AccessCodeModal.tsx +++ b/app/src/dashboard/AccessCodeModal.tsx @@ -29,6 +29,10 @@ export function AccessCodeModal({ open, onSave, onClose }: { open: boolean; onSa onChange={(event) => setCode(event.target.value)} className="glass h-12 w-full rounded-full px-5 text-sm text-ink outline-none placeholder:text-ink-subtle focus-visible:outline-2 focus-visible:outline-chain" /> +

+ No code? Explore the simulated demo: the full flow on + simulated data, nothing is sent to Stellar. +