diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..31d2ed7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +**/node_modules +**/dist +.git +.env +.env.* +**/.env +**/.env.* +**/* 2.* +contracts/target diff --git a/.env.example b/.env.example index 550164f..8a764be 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,14 @@ 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= + +# --- Hosted keeper (Render ou 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. +# (sur un keeper hébergé, KEEPER_ALLOWED_ORIGINS ci-dessus liste les origines HTTPS exactes du site ; * remplace un seul segment de nom) +# KEEPER_PUBLIC_HOSTS= # nom d'hôte public du keeper, sans schéma (xxx.onrender.com ou 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/.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/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..ba4907d 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 @@ -12,7 +12,7 @@ Built during HackMeridian 2026 (Lisbon, 25–26 October 2026), track Genesis. - The user picks one collateral class: **KMMF** (tokenized money market fund shares) or **KBOND** (tokenized sovereign bond). Both are fictitious assets issued by the team on Stellar Testnet. - A keeper computes the swap exposure and the required collateral, then posts it to the vault. - The vault locks what is required, opens a margin call when collateral is missing, releases collateral when exposure falls, and transfers the locked collateral to the bank if a margin call is not met before its deadline. -- Every token movement is a real Stellar Testnet transaction, shown in the dashboard with a link to the explorer. +- Every token movement is a real Stellar Testnet transaction, shown in the dashboard with a link to the explorer (in real mode; the `?demo` simulation sends nothing). ## 2. Problem, target user and value proposition @@ -31,22 +31,34 @@ Keystone does not promise a return, does not remove the need for cash entirely, | Swap, CSA, notional, rates, prices, haircuts | Simulated, labelled as such in the UI | | Euribor 3M starting value | Last monthly value published by the ECB (cached fallback) | | KMMF and KBOND | Fictitious classic Stellar assets issued by the team on Testnet | -| Deposits, withdrawals, margin postings, default settlement | **Real Stellar Testnet transactions** executed by the Soroban vault | +| Deposits, withdrawals, margin postings, default settlement | **Real Stellar Testnet transactions** executed by the Soroban vault (in real mode; simulated in the `?demo` and `--mode demo` builds) | | Proof | Transaction hash and stellar.expert link for every on-chain step | ## 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** @@ -99,7 +111,7 @@ Contract C1 (deployed by the contract pole, recorded in `deployments/testnet.jso | Vault deployment (ledger 5388742) | [`66af4894…bdec`](https://stellar.expert/explorer/testnet/tx/66af48946f18f1a16673c78eb37a76ace759c1ba0b9ab35ec825df65c9ecbdec) | | Deposit of 1 KMMF signed by the demo fund account (ledger 5388931) | [`17868920…7202`](https://stellar.expert/explorer/testnet/tx/178689208be148899ac567ac6c68da6526e040b39962d313d9594a4eeca07202) | -All three were read back from the Soroban RPC with status `SUCCESS`. After the deposit, the vault reports `available = 10000000` (1 KMMF), `locked = 0`, status `Active`; the vault's KMMF balance went from 0 to `10000000` and the fund's from `10000000000` to `9990000000` (balances read from the SAC by the throwaway script; the transaction itself carries one SAC `transfer` of `10000000` from the fund to the vault), and the transaction emitted one `deposited` event with `{amount: 10000000}`. The deposit was signed with the fund key from a throwaway script (not Freighter, which is untested). +All three were read back from the Soroban RPC with status `SUCCESS`. After the deposit, the vault reports `available = 10000000` (1 KMMF), `locked = 0`, status `Active`; the vault's KMMF balance went from 0 to `10000000` and the fund's from `10000000000` to `9990000000` (balances read from the SAC by the throwaway script; the transaction itself carries one SAC `transfer` of `10000000` from the fund to the vault), and the transaction emitted one `deposited` event with `{amount: 10000000}`. The deposit was signed with the fund key from a throwaway script (not Freighter, which was exercised later: see the Freighter table below). Scenario 1 on a fresh C1 vault [`CDL5GRMUQGDTCFVRKJPXKUUIXYLICZOPQP54WYODPGN2XLIVHKWGTQA2`](https://stellar.expert/explorer/testnet/contract/CDL5GRMUQGDTCFVRKJPXKUUIXYLICZOPQP54WYODPGN2XLIVHKWGTQA2), run by the keeper (`npm run integration:c1 -w @keystone/keeper`, start rate 2.635 % from the ECB, K = 2.640 %). Each step was checked against `get_state` and the SAC balances: @@ -115,7 +127,7 @@ Scenario 1 on a fresh C1 vault [`CDL5GRMUQGDTCFVRKJPXKUUIXYLICZOPQP54WYODPGN2XLI | Rate shock +30 bp, epoch 4 | required 0, 98.1948 released | [`33cf9729…3b3a`](https://stellar.expert/explorer/testnet/tx/33cf9729cf357e09adfa72fcf32bd00798ab450a5929a8ac999ef6c4cfa63b3a) | | Final withdrawal of 98.1948 | vault empty, fund back to 999 KMMF | [`1b9f9f00…a801`](https://stellar.expert/explorer/testnet/tx/1b9f9f00e58eb1a82572e9e7dd6dbad5270244ba0c93f866bfa562823283a801) | -The `prepare` / `submit` path (wallet signing) was exercised on another vault with the fund key standing in for Freighter (one-off script, not kept in the repository, so not reproducible): deposit [`85d0f132…2eee`](https://stellar.expert/explorer/testnet/tx/85d0f132bb236da6352ca35718ad9302c26aead25b3b73dab2ab95a4eb452eee) and withdrawal [`a6401141…8d91`](https://stellar.expert/explorer/testnet/tx/a640114144b9141cfaa78c76e5daadf30af776245b453cbab433a99f7ebf8d91); an unsigned transaction is refused with `BadRequest`. Freighter itself is not tested. +The `prepare` / `submit` path (wallet signing) was exercised on another vault with the fund key standing in for Freighter (one-off script, not kept in the repository, so not reproducible): deposit [`85d0f132…2eee`](https://stellar.expert/explorer/testnet/tx/85d0f132bb236da6352ca35718ad9302c26aead25b3b73dab2ab95a4eb452eee) and withdrawal [`a6401141…8d91`](https://stellar.expert/explorer/testnet/tx/a640114144b9141cfaa78c76e5daadf30af776245b453cbab433a99f7ebf8d91); an unsigned transaction is refused with `BadRequest`. Freighter itself was exercised later (see the Freighter table below). Scenario 2 (default, KBOND) on a fresh C2 vault [`CALYRUMGT3J3P32NHBDTRDWJD3JU2AEIXEKRCFDNZ7I6GHKU5DBBKFWA`](https://stellar.expert/explorer/testnet/contract/CALYRUMGT3J3P32NHBDTRDWJD3JU2AEIXEKRCFDNZ7I6GHKU5DBBKFWA), with the team accounts (`npm run e2e:scenario2 -w @keystone/keeper`, 2026-10-26). Every transaction was read back from the RPC as `SUCCESS`: @@ -144,7 +156,7 @@ Scenario 1 played **from the browser** (2026-10-26): the Liquid Glass dashboard | 8 | Rate shock +60 bp again (epoch 4) | [`27ad4b36…5a6c`](https://stellar.expert/explorer/testnet/tx/27ad4b360b6456d229a16e539e6910f2555cf7142913dbd30cbd5ba540e65a6c) | required 0, 98.1948 released | | 9 | Withdrawal of 98.1948 | [`0c49c0b2…ac78`](https://stellar.expert/explorer/testnet/tx/0c49c0b2d596ca69aafa7201a60b574e13dbc71728d665cb1bf967b48960ac78) | vault empty, fund back to 999 KMMF | -**Deviation from the planned scenario:** the dashboard has no +30 bp button, so step 8 repeats +60 bp (float rate 3.035 %) instead of +30 bp; the effect is the same (requirement back to 0, remaining collateral released). The margin call was covered, with the cure confirmed about 9 s after the call opened, well inside the 90 s grace period; no `settle_default` was sent. Freighter signing and the default scenario from the browser were not run. +**Deviation from the planned scenario:** the dashboard has no +30 bp button, so step 8 repeats +60 bp (float rate 3.035 %) instead of +30 bp; the effect is the same (requirement back to 0, remaining collateral released). The margin call was covered, with the cure confirmed about 9 s after the call opened, well inside the 90 s grace period; no `settle_default` was sent. At that point Freighter signing and the default scenario from the browser had not been run; both were run later, in the sections below. Scenario 2 (default) played **from the browser** (2026-10-26), with at most 50 KBOND engaged, on a fresh C2 vault [`CATMOIWU…DC5B`](https://stellar.expert/explorer/testnet/contract/CATMOIWU2FMO43TGLWH4A6SHCIMVHQ5YYXCV2VUN6ZSQMHHKUJRXDC5B) (keeper `KEEPER_BACKEND=testnet SIGNER=keeper`, dashboard `VITE_USE_MOCKS=false`). The four transactions were read back from the RPC as `SUCCESS`: @@ -157,10 +169,20 @@ Scenario 2 (default) played **from the browser** (2026-10-26), with at most 50 K The settlement was confirmed after the real 90 s ledger deadline (the keeper account only paid the fee). Its transaction carries one SAC `transfer` of `500000000` (50 KBOND) from the vault to the bank account, and one `defaulted` event `{amount: 500000000}`. Final `get_state`: `Defaulted`, `available`, `locked`, `required`, `shortfall` and `call_deadline` at 0, `epoch` 1. Balances: fund 700 → 650 KBOND, bank 300 → 350 KBOND. -After a reload, the dashboard shows this KBOND session: "Defaulted – Collateral sent to the bank", fund 650 and bank 350 KBOND, and six activity entries (five **On-chain** with an Explorer link, covering the four distinct transactions because the margin posting and the margin call share one hash; one **Sim**), no Demo entry. **Limits:** the clicks were driven through the browser console and the long press by keyboard events on the real button (the tool cannot hold a pointer); the browser pane could not render screenshots, so the visual check comes from the page content, not from images. Freighter signing was not tested. +After a reload, the dashboard shows this KBOND session: "Defaulted – Collateral sent to the bank", fund 650 and bank 350 KBOND, and six activity entries (five **On-chain** with an Explorer link, covering the four distinct transactions because the margin posting and the margin call share one hash; one **Sim**), no Demo entry. **Limits:** the clicks were driven through the browser console and the long press by keyboard events on the real button (the tool cannot hold a pointer); the browser pane could not render screenshots, so the visual check comes from the page content, not from images. Freighter signing was tested separately (see the Freighter table below). 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 Vercel deployment of this branch made the keeper on Render deploy a vault on Testnet, vault [`CA6OA3I4…JV7D`](https://stellar.expert/explorer/testnet/contract/CA6OA3I4EPINUCZAVFTPHCQCBH4IQLCYZNHGNJBDWFEQP7IL23PSJV7D), transaction [`e192680a…fdaf`](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`. @@ -189,7 +211,7 @@ deployments/testnet.json public on-chain identifiers ## 8. Prerequisites, installation and environment variables -**Prerequisites** (versions to be confirmed on each machine during phase zero) +**Prerequisites** - Node.js ≥ 22 and npm (verified on the keeper machine with Node 24.22.1 and npm 11.19.1) - Rust ≥ 1.84 with the `wasm32v1-none` target, and stellar-cli ≥ 25.2.0 (contract pole only) @@ -215,7 +237,12 @@ 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`, 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_TRUST_PROXY_HOPS` | hosted keeper | Number of reverse proxies in front of the keeper (default 1), so that a client cannot forge its address | +| `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 +252,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): 114 keeper tests and 31 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 +262,31 @@ 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. + +### Run your own Testnet instance (not tested from scratch) + +The real mode of this repository uses the team's Testnet accounts and assets (`deployments/testnet.json`), whose keys are not published. To run the same code against your own accounts, which we have **not** done from a clean checkout: + +1. `npm install`, then `npm run init-env`: creates `.env` and generates the issuer, bank and keeper keys. +2. Put the secret key of a **Testnet-only** Freighter account in `FUND_SECRET` (never a real account), fund it with friendbot, and add it to Freighter. +3. `npm run bootstrap`: creates the KMMF and KBOND assets and their contracts, the trustlines and 1 000 of each asset for the fund, and rewrites `deployments/testnet.json` with your IDs. +4. Build and upload the vault contract (`contracts/keystone_vault`, `stellar contract build` then `stellar contract upload`) and record its WASM hash in `deployments/testnet.json` under `vault.wasmHash`. +5. `KEEPER_BACKEND=testnet npm run keeper` and `npm run dev -w @keystone/app` on the same machine, with `VITE_USE_MOCKS=false`. + +### Hosted deployment (web app on Vercel, keeper on Render or 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 (`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`): + +- **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 when a persistent volume is mounted.** It is saved in `KEEPER_DATA_DIR` and checked against the vault on-chain when reloaded. On a host without a persistent disk (the free Render plan used for the public demo) it is lost on every restart. +- **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: deployed (keeper on Render, web app on Vercel, section 4). `railway.json` is kept as an alternative host: Railway had paused new sign-ups when the demo was deployed, so it was not used. **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`. @@ -251,7 +302,7 @@ The dashboard is the `@keystone/app` workspace; it imports the shared types from | `VITE_USE_MOCKS=true npm run dev -w @keystone/app` | Same, on an in-browser mock keeper: both scenarios play without keeper, wallet or network | | `npm run demo -w @keystone/app` | **The quickest way to try the site.** Builds the production bundle on the in-browser mock keeper (`--mode demo`, no keeper, wallet, network or `.env` needed) into `app/dist-demo/` (apart from the production build in `app/dist/`), serves it on `http://localhost:4173` by default and opens the browser. Sign in with `demo@keystone.app` / `stellar`. Production build, so this is the fluid version (verified on macOS) | | `npm run dev:demo -w @keystone/app` | The same demo on the dev server (`http://localhost:5173` by default, hot reload): for editing the front, slower than the build above. Works on Windows too, unlike the `VITE_USE_MOCKS=true …` form | -| `npm run keeper` then `npm run dev -w @keystone/app`, in two terminals on **the same Mac** | The real setup: the root `.env` sets `KEEPER_BACKEND=testnet`, `SIGNER=freighter`, `PORT=8787`, `VITE_USE_MOCKS=false`, `VITE_KEEPER_URL=http://127.0.0.1:8787/api` and the local `KEEPER_SECRET` (never committed). `127.0.0.1` is the machine running the browser, so the keeper and the browser must run on the same computer. The front opens on `http://localhost:5173`. Not tested from the integration branch with `KEEPER_BACKEND=testnet` (the read-only checks used `KEEPER_BACKEND=fake`) | +| `npm run keeper` then `npm run dev -w @keystone/app`, in two terminals on **the same Mac** | The real setup: the root `.env` sets `KEEPER_BACKEND=testnet`, `SIGNER=freighter`, `PORT=8787`, `VITE_USE_MOCKS=false`, `VITE_KEEPER_URL=http://127.0.0.1:8787/api` and the local `KEEPER_SECRET` (never committed). `127.0.0.1` is the machine running the browser, so the keeper and the browser must run on the same computer. The front opens on `http://localhost:5173`. Verified from this front on the real vault for both scenarios and for a Freighter deposit and withdrawal (section 6); the read-only checks of the integration branch also ran with `KEEPER_BACKEND=fake` | | `npm run build -w @keystone/app` | Type-checks, then builds the static site into `app/dist/` | The root `npm run typecheck` also covers the dashboard. @@ -262,11 +313,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. @@ -278,7 +330,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 | | --- | --- | @@ -300,7 +352,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. @@ -339,15 +393,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) | -| 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 | -| 11 | Submission: public repo, README, public URL, video, pitch deck | Not started | | +| 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 | Verified (the Freighter flow from the hosted web app is not tested) | Browser → keeper → C2 vault proven for both scenarios, Freighter deposit and withdrawal signed in the wallet, hosted keeper (Render) deploying a vault from the Vercel app (section 6) | +| 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 @@ -356,6 +410,10 @@ Only the status "verified" counts as done, and only with a proof link. **During the event (25–26 October 2026):** the documentation and interfaces of phase zero, and all application code (contract, keeper, dashboard, scripts). See the commit and pull request history of this repository. +**AI assistance.** The code was written by the team with AI coding agents (Claude Code on two machines, Codex on the third), under the rules in [`AGENTS.md`](AGENTS.md), and reviewed with fresh AI sub-agents (section 11). + +**Third-party code.** [React Bits](https://reactbits.dev) components under `app/src/ui/bits/` (MIT with the Commons Clause, licence notice kept in the files), [PGlite](https://pglite.dev) (Apache-2.0), Stellar SDK, Fastify, React, Vite and Tailwind CSS, each under its own licence (see `package-lock.json`). + ## 14. Known limits - The swap model uses a flat curve, no coupon cash flows, one-way variation margin (fund to bank), no initial margin. @@ -363,4 +421,6 @@ 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. +- **The contract is not audited.** The C2 settlement change was reviewed by automated AI reviewers (decision A11 in `docs/decisions.md`), not by an independent human auditor, and two reserves remain open: a token trustline or freeze can block `withdraw` and `settle_default` (`docs/interfaces.md` section 3.7), and the contract instance TTL must be extended on a long-lived deployment. +- 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 team access code (one per person, no per-user accounts), and the read routes (`/session`, `/events`) are public Testnet data. Local accounts live in each browser (PGlite) 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..7332b61 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -5,11 +5,13 @@ 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' 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' @@ -131,9 +133,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} @@ -262,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 && ( + + + + + ) +} diff --git a/app/src/dashboard/MarketAdvisor.tsx b/app/src/dashboard/MarketAdvisor.tsx index f04c7e0..920eaa3 100644 --- a/app/src/dashboard/MarketAdvisor.tsx +++ b/app/src/dashboard/MarketAdvisor.tsx @@ -12,7 +12,13 @@ function Source({ metric }: { metric: MarketMetric }) { } /** Pure display: choosing an asset and starting a vault remain separate, explicit Landing actions. */ -export function MarketAdvisor({ className }: { className?: string }) { +export function MarketAdvisor({ className, label = 'Compare market conditions', closeLabel = 'Back to manual selection', inSession = false }: { + className?: string + label?: string + closeLabel?: string + /** Opened from the dashboard (a session is running): there is no selection or Start button to mention. */ + inSession?: boolean +}) { const [open, setOpen] = useState(false) const [attempt, setAttempt] = useState(0) const [snapshot, setSnapshot] = useState(null) @@ -36,7 +42,7 @@ export function MarketAdvisor({ className }: { className?: string }) { }, [open, attempt]) return <> - + 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.'} +

} 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/test/demo-mode.test.ts b/app/test/demo-mode.test.ts new file mode 100644 index 0000000..7b592f6 --- /dev/null +++ b/app/test/demo-mode.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +afterEach(() => { + vi.unstubAllGlobals() + vi.resetModules() +}) + +async function configFor(search: string | null) { + vi.resetModules() + if (search !== null) vi.stubGlobal('window', { location: { search } }) + return import('../src/config.ts') +} + +describe('?demo', () => { + it('turns the mock keeper on for that visit only', async () => { + expect((await configFor('?demo')).USE_MOCKS).toBe(true) + expect((await configFor('?foo=1&demo')).USE_MOCKS).toBe(true) + }) + + it('leaves the real keeper alone otherwise', async () => { + expect((await configFor('')).USE_MOCKS).toBe(false) + expect((await configFor('?demonstration=1')).DEMO_REQUESTED).toBe(false) + expect((await configFor(null)).USE_MOCKS).toBe(false) // no window at all (tests, server) + }) +}) 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/brief.md b/docs/brief.md index ca4c0a4..d13feeb 100644 --- a/docs/brief.md +++ b/docs/brief.md @@ -1,3 +1,5 @@ +> **Document historique : brief initial (25 octobre 2026), partiellement dépassé.** Il contient des consignes données à l'agent au lancement du projet (dépôt alors privé, répartition des pôles non fixée). Les références à jour sont `README.md`, `docs/interfaces.md` et `docs/decisions.md`. + # KEYSTONE — Brief complet actualisé pour Claude **HackMeridian 2026 · Lisbonne · 25 octobre 2026** diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..700c48e --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,136 @@ +# 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 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 + +```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/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` | + +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. +- 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). 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) + +| | 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 activée par défaut sur les previews : **elle est désactivée sur ce projet** (Settings → Deployment Protection) pour que le site soit public ; le keeper reste protégé par ses codes. 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) | +| 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 👤) + +### 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` | + | `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. +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. + +## 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. +- **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..7735581 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,8 +169,10 @@ 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. +`SIGNER=keeper` is used by the end-to-end script and local runs. A hosted keeper refuses it (v1.4): the public live app runs with `SIGNER=freighter` and offers judges the simulated `?demo` mode instead, because they cannot sign with the fund key. The front shows a visible notice in keeper mode. ### 4.1 Read-only market advisor (v1.3, consuming pole: front) @@ -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. Wording: the public demo no longer uses `SIGNER=keeper`. | | 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/docs/plan.md b/docs/plan.md index d4bc022..5ca1b15 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,3 +1,5 @@ +> **Document historique (phase zéro, 25 octobre 2026).** Écrit avant le code ; le plan a évolué. Les références à jour sont `README.md`, `docs/interfaces.md` et `docs/decisions.md`. + # Keystone — Plan de construction du MVP Ce plan décrit les décisions, l'architecture, le modèle financier, le travail de chaque pôle, le planning et les risques. 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..984d72e 100644 --- a/keeper/src/errors.ts +++ b/keeper/src/errors.ts @@ -8,16 +8,24 @@ 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; + /** The transaction was included and failed on-chain: a definite outcome. */ + onChain?: boolean; constructor(code: ApiErrorCode, message: string) { super(message); this.name = "KeeperError"; @@ -32,3 +40,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|non-existing value for contract instance|Unknown vault/i.test(e.message); +} diff --git a/keeper/src/fake-vault-client.ts b/keeper/src/fake-vault-client.ts index e7b87c8..280dfe7 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,16 @@ 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(); + + async txStatus(hash: string): Promise { + return this.known.get(hash) ?? "NOT_FOUND"; + } + async ledgerTime(): Promise { return this.now(); } diff --git a/keeper/src/hosted.ts b/keeper/src/hosted.ts new file mode 100644 index 0000000..0cee1c8 --- /dev/null +++ b/keeper/src/hosted.ts @@ -0,0 +1,27 @@ +// 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)"); + 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 7b7e255..5200159 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -1,17 +1,20 @@ // 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"; import type { SignerMode } from "@keystone/shared"; +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"; 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 +27,9 @@ 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, 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)."); @@ -58,23 +64,56 @@ 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"); +// 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(); 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, }); +// 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({ - 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, + healthProbeAnyHost: hosted, + 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 }); +// 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://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..38b52ad 100644 --- a/keeper/src/server.ts +++ b/keeper/src/server.ts @@ -3,11 +3,16 @@ 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; +const MAX_STREAMS_PER_IP = 10; +/** 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; @@ -30,24 +35,50 @@ 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"; + /** 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; } 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 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: // 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 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" } }); } }); - 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 +102,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 +150,10 @@ export function buildServer(config: KeystoneConfig, sessions: SessionManager, op api.post("/session/settle-default", async () => sessions.settleDefault()); api.get("/events", (req, reply) => { + 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(); const res = reply.raw; const origin = req.headers.origin; @@ -136,7 +171,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 b76aebb..1dd1de2 100644 --- a/keeper/src/session.ts +++ b/keeper/src/session.ts @@ -4,12 +4,13 @@ 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, } 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,10 @@ 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; + /** 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; @@ -52,6 +87,123 @@ 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 ------------------------------------------------------ + + /** 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; + // 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) { + // 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; + 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); + } + + /** 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); + return this.deps.store?.save({ + seq: this.seq, session: strip(this.session), pendingDeploy: strip(this.pendingDeploy), unresolved: this.unresolved, + }) ?? true; + } + + /** + * 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); + // 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.`); + } + 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) { + // 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; + } + 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.` + : 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; + this.persist(); } // ---- streaming ------------------------------------------------------------------------- @@ -78,7 +230,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 +252,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 +317,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 +357,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 +372,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,19 +397,38 @@ 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}` } : {}), + }); + this.active = track ? { entry, track } : null; 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) { - if (e instanceof KeeperError && e.code === "RpcUnavailable") { - this.update(entry, { detail: `No confirmation yet: ${e.message}` }); - } else { + 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; } + 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 ?? []; @@ -261,16 +446,37 @@ 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, + }; + // 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. */ + 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); 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 +543,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..c3caa09 --- /dev/null +++ b/keeper/src/store.ts @@ -0,0 +1,63 @@ +// 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, unlinkSync, 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"); + } + + /** 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; + 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; + } + + /** 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) { + console.error(`Keeper could not save its session: ${e instanceof Error ? e.message : String(e)}`); + return false; + } + } + + /** 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..eb94fbd 100644 --- a/keeper/src/testnet-vault-client.ts +++ b/keeper/src/testnet-vault-client.ts @@ -6,9 +6,14 @@ 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"; +/** 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 { rpcUrl: string; networkPassphrase: string; @@ -32,12 +37,33 @@ 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; + // 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; + } + 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 +94,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; @@ -83,14 +110,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, this.o.keeper, vaultId); return { vaultId, txHash: this.hashOf(sent) }; }); } @@ -150,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(); @@ -158,7 +199,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,10 +215,13 @@ 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) }; + // 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) }; }); } @@ -202,7 +249,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, 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. @@ -211,6 +258,25 @@ export class TestnetVaultClient implements VaultClient { }); } + /** 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({ signTransaction })) as Awaited>; + } catch (e) { + throw classifySendError(e, signedHash, vaultId); + } + } + private hashOf(sent: { sendTransactionResponse?: { hash: string } }): string { const hash = sent.sendTransactionResponse?.hash; if (!hash) throw new KeeperError("TxFailed", "The transaction was not submitted"); @@ -221,8 +287,14 @@ 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`); - throw new KeeperError("RpcUnavailable", `No confirmation for ${hash} after ${this.timeout}s`); + 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; } 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..e70183a 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,13 @@ export interface SignedTxInfo { * 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. */ 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..d7e63bd --- /dev/null +++ b/keeper/test/hosted.test.ts @@ -0,0 +1,331 @@ +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 { 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, VaultState } 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", healthProbeAnyHost: true, + 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 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); + }); +}); + +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" }); + }); + + 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.toMatchObject({ code: "RpcUnavailable" }); + 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(); + }); + + 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", () => { + 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 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-")); + 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 8f77ca1..d17cb85 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); @@ -78,8 +78,21 @@ 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 }); + 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", () => { @@ -102,3 +115,86 @@ 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("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"); + expect(e.txHash).toBeUndefined(); + }); + + it("marks a refusal by the network as not sent", () => { + const e = classifySendError(new SendFailedError('failed {"status": "ERROR"}'), 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]); + } + }); +}); + +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 }); + }); +}); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index d9f2a71..6d4d29e 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.4): which vault backs the keeper. "fake" means no real transaction. */ + backend?: "testnet" | "fake"; + /** Optional (v1.4): "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=()" } + ] + } + ] +}