diff --git a/.env.example b/.env.example index fa8bba2..bb93f11 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,22 @@ SOROBAN_RPC_URL=https://soroban-testnet.stellar.org SETTLEMENT_CONTRACT_ID= SOLVER_REGISTRY_CONTRACT_ID= +# Secret key for the backend's own Soroban signer, used to submit on-chain +# writes (settlement, slashing). A Stellar secret seed: starts with "S", +# 56 base32 characters. Leave blank in development/test — no write path runs +# without it. REQUIRED in production (NODE_ENV=production), and validated +# against that format so it can never silently default to a placeholder. +# +# Local dev: generate a throwaway TESTNET-only keypair, do not reuse it +# anywhere real, and never commit a filled-in value: +# npx @stellar/stellar-cli keys generate local-dev --network testnet +# npx @stellar/stellar-cli keys show local-dev +# or, ad hoc, via the SDK: +# node -e "console.log(require('@stellar/stellar-sdk').Keypair.random().secret())" +# Fund it on testnet with Friendbot before use: +# curl "https://friendbot.stellar.org/?addr=" +SOROBAN_SIGNING_KEY= + # ─── CORS ──────────────────────────────────────────────────────────────────── # Comma-separated list of allowed origins for the frontend ("*" for any in dev) CORS_ORIGIN=* diff --git a/README.md b/README.md index 434ee2a..9a192b7 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,33 @@ cp .env.example .env npm run dev # http://localhost:4000 ``` +### Signing key + +`SOROBAN_SIGNING_KEY` holds the secret key the backend uses to submit its own +on-chain writes (settlement, slashing). It's optional in development/test — +leave it blank and those code paths simply have nothing to sign with — but +**required and format-validated in production** (`NODE_ENV=production`); the +process refuses to start without a well-formed Stellar secret seed rather +than falling back to any placeholder. + +For local dev, generate a throwaway **testnet-only** keypair — never reuse a +mainnet or otherwise real key: + +```bash +npx @stellar/stellar-cli keys generate local-dev --network testnet +npx @stellar/stellar-cli keys show local-dev # paste into SOROBAN_SIGNING_KEY + +# or, ad hoc: +node -e "console.log(require('@stellar/stellar-sdk').Keypair.random().secret())" + +# fund it via Friendbot before using it against testnet: +curl "https://friendbot.stellar.org/?addr=" +``` + +Never commit a filled-in `.env`, and never point a real/funded key at +anything but `mainnet` with `NODE_ENV=production` behind a proper secrets +manager. + ### Scripts | Script | Description | @@ -90,7 +117,7 @@ flags to override them. ## Roadmap - [x] **Soroban RPC reads** — health/ledger/network/account lookups via `/api/v1/chain/*` -- [ ] **On-chain writes** — replace the in-memory intent store with real Soroban transactions +- [ ] **On-chain writes** — replace the in-memory intent store with real Soroban transactions (target design: [`docs/architecture/onchain-settlement.md`](./docs/architecture/onchain-settlement.md)) - [x] **Solver WS client** — reference implementation for a solver bot (`npm run solver:demo`, see [`scripts/README.md`](./scripts/README.md)) --- diff --git a/docs/architecture/onchain-settlement.md b/docs/architecture/onchain-settlement.md new file mode 100644 index 0000000..8ae7546 --- /dev/null +++ b/docs/architecture/onchain-settlement.md @@ -0,0 +1,167 @@ +# Architecture: On-Chain Settlement (Target Design) + +> **Status: target architecture, not yet implemented.** As of this writing, +> `IntentsService` and `SolversService` are in-memory `Map`s +> (`src/intents/intents.service.ts`, `src/solvers/solvers.service.ts`), and +> `SorobanService` (`src/soroban/soroban.service.ts`) only performs +> read-only RPC calls (`/api/v1/chain/*`). No intent, fill, or slash is +> ever written to a Soroban contract today. This document describes the +> data flow the system moves to once on-chain writes ship — it is a design +> reference for that work, not a description of current behavior. The +> README roadmap checkbox for "On-chain writes" stays unchecked until the +> implementation below actually lands. +> +> The formal decision record for this design — custody model, which +> contract calls map to which `IntentState` transitions, replay protection +> — belongs in an ADR at `docs/adr/0001-onchain-settlement.md` (tracked as +> issue #19, not yet written). Where this document makes a call ahead of +> that ADR (see [Custody model](#custody-model-assumption-pending-adr) +> below), treat it as a proposal for that ADR to confirm or override, not +> as settled fact. + +## Why + +The in-memory store means every restart loses all intent and solver state, +and there's no way for a user or solver to verify the relay isn't lying +about an intent's status — the API's response is the only record. Moving +the source of truth on-chain (Soroban settlement + solver-registry +contracts) makes intent state independently verifiable and durable across +restarts/deploys. + +## Data flow + +```mermaid +sequenceDiagram + actor User + actor Solver + participant API as IntentsController
(NestJS) + participant Signer as Soroban Signer
(SOROBAN_SIGNING_KEY) + participant Chain as Soroban Network
(Settlement + Solver-Registry contracts) + participant Ingest as Event Ingestion
(chain event listener) + participant Store as State Store
(reconciled cache, was: in-memory Map) + participant WS as IntentsGateway
(/ws) + + User->>API: POST /api/v1/intents + API->>Signer: build + sign "register_intent" tx + Signer->>Chain: submit tx + Chain-->>API: txHash (submitted) + API-->>User: 201 Created (intentId, pending confirmation) + + Chain->>Chain: tx applies, ledger closes + Chain-->>Ingest: contract event (IntentRegistered) + Ingest->>Store: upsert intent, state=open, source=chain + Ingest->>WS: broadcast intent_created + + Solver->>API: POST /api/v1/intents/:id/accept + API->>Signer: build + sign "accept_intent" tx (solver-registry check) + Signer->>Chain: submit tx + Chain-->>Ingest: contract event (IntentAccepted) + Ingest->>Store: upsert intent, state=accepted + Ingest->>WS: broadcast intent_accepted + + Solver->>API: POST /api/v1/intents/:id/fill + API->>Signer: build + sign "settle_fill" tx + Signer->>Chain: submit tx + Chain-->>Ingest: contract event (IntentFilled) + Ingest->>Store: upsert intent, state=filled, txHash + Ingest->>WS: broadcast intent_filled + + Note over Store,Ingest: Periodic reconciliation:
Store polls Chain directly for any
intent whose last event is stale,
so a missed/delayed event can't
leave Store permanently wrong. +``` + +Four stages, each with a distinct failure mode to design against: + +1. **HTTP request → Soroban tx.** `IntentsController` (or whatever replaces + its current in-memory-only logic) builds the relevant contract + invocation instead of mutating a `Map` directly, and hands it to a + signer. The HTTP response returns as soon as the transaction is + *submitted*, not confirmed — Soroban confirmation isn't synchronous with + the HTTP request/response cycle, so the response must communicate + "pending" rather than a final state. This is the same asynchrony + `scripts/solver-bot.ts` already deals with on the read side; the write + side needs an equivalent submitted-vs-confirmed distinction. +2. **Soroban tx → event ingestion.** A separate listener (extends + `SorobanService` or a new module) subscribes to ledger/contract events + rather than the request handler polling for its own transaction's + result inline — this decouples "did my request's tx confirm" from "what + is the current state of this intent," which matters because *other* + parties' transactions (a solver's `accept`, a sweeper-triggered slash) + also mutate intent state and need to flow through the same path. +3. **Event ingestion → state reconciliation.** Ingested events update the + state store (`Store` above) — replacing today's in-memory `Map`, either + as a full replacement or, more conservatively, the `Map` demoted to a + read cache kept in sync by this stage. Reconciliation additionally + *polls* the chain directly for any intent whose last known update is + older than expected, so a dropped or delayed event doesn't leave the + cache permanently stale — the chain, not the event stream, is the + ultimate source of truth. This poll-based backstop is also what + `docs/runbooks/onchain-cutover.md`'s rollback procedure relies on to + reconstruct in-memory state from the chain during a rollback. +4. **State store → clients.** `IntentsGateway`'s WebSocket broadcast and + the REST read endpoints (`GET /api/v1/intents`, etc.) continue to serve + from the state store exactly as they do today — this layer is + unaffected by the cutover except that the store's writes now originate + from ingested chain events instead of directly from controller methods. + +## `IntentState` → contract call mapping + +| `IntentState` transition | Trigger | Contract call | +|---|---|---| +| *(none)* → `open` | `POST /api/v1/intents` | Settlement contract: register intent | +| `open` → `accepted` | `POST /api/v1/intents/:id/accept` | Solver-registry contract: verify bond/active, then settlement contract: record acceptance | +| `accepted` → `filled` | `POST /api/v1/intents/:id/fill` | Settlement contract: settle fill, verify `fillAmount >= minDstAmount` | +| `open` → `cancelled` | `POST /api/v1/intents/:id/cancel` | Settlement contract: cancel (must verify caller is the original `user`) | +| `open` → `expired` | `IntentsSweeperService` sweep, deadline passed | Settlement contract: mark expired (no penalty — solver never accepted) | +| `accepted` → `slashed` | `IntentsSweeperService` sweep, accepted intent's fill-window deadline passed | Solver-registry contract: penalize solver's bond | + +This table is the concrete artifact the ADR (issue #19) needs to ratify — +in particular, whether `expired` needs an on-chain call at all (arguably +it's a pure absence of action and doesn't need a transaction), and whether +`cancel`/`accept` authorization is checked in the contract, the backend, or +both. + +## Custody model (assumption, pending ADR) + +This document assumes a **backend hot-wallet signer**: the backend holds +`SOROBAN_SIGNING_KEY` and submits transactions on behalf of users and +solvers, rather than relaying pre-signed transactions that users/solvers +sign client-side. This matches how the signing-key config work +(`src/config/env.validation.ts`, `src/config/configuration.ts`) was built — +a single backend-held key, not a per-request user signature. It has an +obvious trust tradeoff (the backend can construct any transaction it wants +against the contracts it holds authority over) that the ADR needs to +either accept explicitly or override in favor of user-signed/relayed +transactions. + +## Error handling and asynchrony + +- **Submission failure** (bad sequence number, insufficient fee, RPC + timeout): surfaced to the caller as a 5xx with retry guidance, no state + store mutation happens. +- **Submitted but not yet confirmed**: the intent enters a `pending`-style + state (either a new value or reusing existing states with an internal + "unconfirmed" flag the ADR needs to decide) rather than an optimistic + update to `open`/`accepted`/`filled` before the chain agrees. +- **Confirmed on-chain but the event listener misses/delays the event**: + covered by the reconciliation poll in stage 3 above — never a permanent + inconsistency, only a bounded staleness window. +- **Reorg / transaction fails after appearing to succeed**: Soroban/Stellar + finality characteristics need to inform how long the reconciliation + layer waits before treating an event as final; out of scope for this + document, but must be resolved by the ADR before stage 2/3 above ship. + +## Relationship to other in-flight design work + +- [`docs/runbooks/onchain-cutover.md`](../runbooks/onchain-cutover.md) — + the operational procedure for actually flipping a live environment onto + this architecture, including the dry-run staging and rollback path. +- Issue #35 (dry-run mode) — the flag that lets stages 1–2 above run in + simulate-only mode during rollout. +- Issue #62 (audit trail) — an independent transition log that's separate + from, and cross-checked against, the event-sourced state store described + in stage 3. +- Issue #33 (solver slashing) — the first concrete on-chain write this + repo implements, ahead of full intent registration/fill; it exercises + the solver-registry contract call described in the state-mapping table + above on a narrower surface (sweeper-triggered only, no user-facing HTTP + write path). diff --git a/docs/runbooks/onchain-cutover.md b/docs/runbooks/onchain-cutover.md new file mode 100644 index 0000000..61b193a --- /dev/null +++ b/docs/runbooks/onchain-cutover.md @@ -0,0 +1,207 @@ +# Runbook: Cutting Over From In-Memory State to On-Chain-Backed State + +## Status + +**This runbook describes the planned cutover procedure, not a completed +migration.** As of this writing, `IntentsService` and `SolversService` are +backed entirely by in-process `Map`s (see `src/intents/intents.service.ts`, +`src/solvers/solvers.service.ts`). Soroban RPC access is currently +**read-only** (`SorobanService`, `/api/v1/chain/*`). + +Landing an on-chain-backed cutover in a live environment requires all of the +following to ship first — this runbook is not actionable until they do, and +should be reviewed/updated as each lands: + +| Dependency | What it provides | Status | +|---|---|---| +| Signing-key config (this repo) | Validated `SOROBAN_SIGNING_KEY` for the backend's own Soroban signer | Landing alongside this runbook | +| Signer strategy (issue #21) | Actual transaction-building/signing/submission code path | Open | +| On-chain intent registration (issue #22) | Replaces in-memory `create()` with a real Soroban tx | Open | +| Solver-registry wiring (issue #23) | `accept()` calls the solver-registry contract | Open | +| On-chain fill settlement (issue #24) | `fill()` submits + confirms a settlement tx | Open | +| Dry-run mode (issue #35) | Config flag to simulate on-chain writes without submitting | Open | +| Intent audit trail (issue #62) | Append-only log of every state transition, independent of the state store | Open | + +Treat the checklist below as the gate for actually running this procedure: +do not attempt a live cutover until every dependency above is merged, has +its own passing tests, and has been exercised on testnet. + +## Overview + +Today, the source of truth for intent and solver state is process memory. +Restarting the service, or losing the pod, loses all state. The cutover +replaces the in-memory `Map`s with the Soroban settlement and +solver-registry contracts as the source of truth, with the in-memory store +becoming a read cache (or being removed entirely, depending on how issue +#22/#24 land). + +The risk this runbook exists to manage: **the switch from +"authoritative in-memory state" to "authoritative on-chain state" is a +single moment where in-flight intents (state `open` or `accepted`) could be +read inconsistently, double-processed, or silently dropped** if the cutover +isn't staged and reversible. + +## Pre-checks + +Run through all of these before scheduling a cutover window, and re-verify +immediately before flipping traffic: + +1. **Dependencies merged and soaked.** Every row in the Status table above + is merged to `main`, deployed to staging, and has run against Soroban + testnet for at least 48h with no unexplained errors. +2. **Dry-run soak clean.** The dry-run flag (issue #35) has been enabled in + the target environment for at least one full sweeper cycle window + (several multiples of the 30s `IntentsSweeperService` interval, but in + practice run it for hours, not seconds) with zero simulation failures + logged. Every code path that would submit a transaction — intent + registration, fill settlement, solver-registry slashing — must have been + exercised in dry-run at least once. +3. **Signing key provisioned correctly.** `SOROBAN_SIGNING_KEY` is set to a + real, funded key in the target environment's secrets manager (never in + plaintext env files), `NODE_ENV=production` validation + (`src/config/env.validation.ts`) passes, and the corresponding public + key has sufficient XLM for fees plus a safety margin. +4. **Contract IDs confirmed.** `SETTLEMENT_CONTRACT_ID` and + `SOLVER_REGISTRY_CONTRACT_ID` point at the intended network's deployed + contracts (cross-check against `STELLAR_NETWORK` — a mainnet key against + a testnet contract ID, or vice versa, is a classic cutover mistake). +5. **Audit trail live and independently queryable.** Issue #62's audit log + is writing for every transition on the current in-memory path *before* + cutover, so there is continuity of history across the switch rather than + a gap starting from zero. +6. **In-flight intent census.** Immediately before the window, snapshot + `GET /api/v1/intents?state=open` and `?state=accepted` (or the + equivalent internal read) and record the full list. This is the + reconciliation baseline — every one of these intents must be accounted + for after cutover, either resolved (filled/expired/cancelled) or + present in the new on-chain-backed store. +7. **Rollback path rehearsed.** The rollback procedure below has been + executed at least once against a staging environment with synthetic + in-flight intents, not just read from this document for the first time + during an incident. +8. **Alerting wired.** Whoever is on call has dashboards/alerts for + Soroban RPC error rate, transaction submission failures, and sweeper + duration (the sweeper now does chain I/O per cycle instead of pure + in-memory work — its latency profile changes materially). + +## Dry-run flag and the cutover + +The dry-run flag (issue #35) is the primary safety mechanism this runbook +leans on. It's a config-level switch (default **on** outside production, +per that issue's requirements) that makes every on-chain-write code path +build and simulate a Soroban transaction, log what *would* be submitted, +and return without broadcasting it. + +How it factors into cutover staging: + +1. **Stage 1 — dry-run in target environment.** Deploy the on-chain code + paths with the dry-run flag forced on, traffic unchanged (reads/writes + still served from the in-memory store). This validates that transaction + construction, contract ID wiring, and the signing key all work, with + zero funds-moving risk. This is pre-check #2 above. +2. **Stage 2 — shadow writes.** Flip dry-run off for a canary slice (or a + single non-critical path, e.g. solver-registry reads before slashing + writes) while the in-memory store remains authoritative for reads. Watch + for transaction failures, unexpected fees, or confirmation-latency + surprises. +3. **Stage 3 — cutover.** Flip the in-memory store from authoritative to + cache (or remove it, per how #22/#24 implement this) for the full + read/write path. Dry-run stays off. This is the point of no return for + this procedure — from here, rollback means the explicit procedure below, + not just re-flipping a flag. + +Keep the dry-run flag itself deployed (not ripped out) after cutover — it's +the fastest lever if a related on-chain code path needs to be redeployed or +patched later without another full staged rollout. + +## Rollback plan + +The goal: **return to in-memory-authoritative state with zero data loss for +any intent that was `open` or `accepted` at the moment of rollback.** + +Rollback triggers (any one is sufficient to invoke this procedure): + +- Transaction failure rate above baseline for on-chain writes. +- Sweeper cycle time regresses badly enough to risk missing expiry/slash + windows. +- Audit trail (#62) shows a state transition that has no corresponding + on-chain confirmation, or vice versa (a mismatch is worse than either + system being briefly behind). +- Any evidence of an in-flight intent being processed twice (e.g. filled + in-memory and also settled on-chain, or slashed on-chain but still shown + `accepted` in a read path). + +Procedure: + +1. **Freeze new writes.** Flip a kill switch (reuse the dry-run flag, + forced on, or a dedicated `read-only` mode if one exists by the time + this runs) so no new intents are created/accepted/filled/slashed + on-chain while rollback is in progress. This is why dry-run must ship as + a runtime-toggleable config value, not a build-time constant. +2. **Reconcile in-flight intents from the chain back into memory.** For + every intent in the pre-cutover census (pre-check #6) that is still + `open` or `accepted`: + - Read its current state from the settlement contract (the same + read-only `SorobanService` methods used today, e.g. + `getAccount`/ledger queries, plus whatever read methods #22/#24 add). + - Reconstruct the in-memory `Intent` record from that on-chain state — + `state`, `solver`, `deadline`, `fillAmount`/`txHash` if present — so + the fields the sweeper and controller depend on + (`src/intents/intents.types.ts`) are populated correctly rather than + defaulted. + - Cross-check the reconstructed record against the audit trail (#62): + the last logged transition for that intent must be consistent with + the on-chain state you just read. A mismatch here means don't guess — + escalate and resolve manually before continuing, since it indicates + the two systems disagree about history. +3. **Re-seed the in-memory store.** Load every reconciled record into + `IntentsService`/`SolversService` before re-enabling the in-memory code + path, so there's no window where a read returns "not found" for an + intent that genuinely exists. +4. **Flip traffic back to in-memory-authoritative.** Revert the read/write + path (config flag or deploy of the pre-cutover build, whichever the + actual #22/#24 implementation supports) so `IntentsController` reads + from and writes to the in-memory store again. +5. **Leave on-chain state alone — don't try to unwind it.** Anything + already confirmed on-chain (a fill, a slash) stays confirmed; rollback + moves the *source of truth* back to memory, it does not reverse + settled transactions. The in-memory record for that intent must reflect + the final on-chain outcome (step 2), not a pre-chain state. +6. **Verify against the census.** Confirm every intent from the pre-cutover + census (pre-check #6) is present in the in-memory store with a state + that's either unchanged or a legitimate progression (e.g. `accepted` → + `filled`), and that the audit trail has no gap across the rollback + boundary. +7. **Post-incident: diff and publish.** Compare the audit trail's record of + what happened during the on-chain window against what the in-memory + store now shows, and publish the diff (even if empty) so reviewers can + confirm no history was silently dropped — this is the concrete + requirement issue #62 exists to satisfy for this runbook. + +## Coordination with the audit trail (issue #62) + +This runbook depends on #62 landing *before* cutover, not after, because: + +- Pre-check #5 and rollback step 2's cross-check both require the audit + trail to already be recording transitions during the in-memory period — + there's no way to retroactively backfill "what happened" once a rollback + is underway. +- Rollback step 7 (the post-incident diff) is only meaningful if the audit + trail's coverage started before the cutover window, giving a continuous + record spanning pre-cutover → on-chain → rollback. +- The whole point of this runbook's "no data loss for in-flight intents" + requirement is unenforceable without an independent record to reconcile + against — the in-memory store and the chain can each individually claim + to be right, and only the audit trail can say which one actually matches + what happened over time. + +## Open questions to resolve before this runbook is actionable + +- Exact mechanism for "in-memory store as cache vs. removed" — depends on + how #22/#24 are implemented; update the rollback procedure's step 3/4 + once that's decided, since "re-seed the cache" and "redeploy the old + code" are different operational procedures. +- Whether the kill switch in rollback step 1 is the same config value as + the dry-run flag (#35) or a separate one — recommend reusing dry-run to + avoid a second flag with overlapping semantics, but that's a call for + whoever implements #35. diff --git a/src/config/configuration.ts b/src/config/configuration.ts index b6c1100..20dabcf 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -6,6 +6,11 @@ export interface AppConfig { sorobanRpcUrl: string; settlementContractId: string; solverRegistryContractId: string; + // Secret key for the backend's Soroban signer. Empty outside production + // (no on-chain write path exists yet); envValidationSchema requires and + // format-checks it in production so it can never silently fall back to + // a placeholder. Never log this value. + signingKey: string; }; corsOrigin: string; } @@ -18,6 +23,7 @@ export default (): AppConfig => ({ sorobanRpcUrl: process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org", settlementContractId: process.env.SETTLEMENT_CONTRACT_ID ?? "", solverRegistryContractId: process.env.SOLVER_REGISTRY_CONTRACT_ID ?? "", + signingKey: process.env.SOROBAN_SIGNING_KEY ?? "", }, corsOrigin: process.env.CORS_ORIGIN ?? "*", }); diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts new file mode 100644 index 0000000..ba19b5f --- /dev/null +++ b/src/config/env.validation.spec.ts @@ -0,0 +1,57 @@ +import { envValidationSchema } from "./env.validation"; + +const BASE_ENV = { + NODE_ENV: "development", +}; + +const VALID_KEY = "S" + "A".repeat(55); + +describe("envValidationSchema — SOROBAN_SIGNING_KEY", () => { + it("defaults to an empty string outside production when unset", () => { + const { error, value } = envValidationSchema.validate(BASE_ENV); + expect(error).toBeUndefined(); + expect(value.SOROBAN_SIGNING_KEY).toBe(""); + }); + + it("accepts a well-formed Stellar secret seed outside production", () => { + const { error, value } = envValidationSchema.validate({ + ...BASE_ENV, + SOROBAN_SIGNING_KEY: VALID_KEY, + }); + expect(error).toBeUndefined(); + expect(value.SOROBAN_SIGNING_KEY).toBe(VALID_KEY); + }); + + it("rejects a placeholder value that doesn't match the strkey format", () => { + const { error } = envValidationSchema.validate({ + ...BASE_ENV, + SOROBAN_SIGNING_KEY: "changeme", + }); + expect(error).toBeDefined(); + }); + + it("is required in production", () => { + const { error } = envValidationSchema.validate({ + NODE_ENV: "production", + }); + expect(error).toBeDefined(); + expect(error?.message).toContain("SOROBAN_SIGNING_KEY"); + }); + + it("rejects an empty string in production", () => { + const { error } = envValidationSchema.validate({ + NODE_ENV: "production", + SOROBAN_SIGNING_KEY: "", + }); + expect(error).toBeDefined(); + }); + + it("accepts a well-formed key in production", () => { + const { error, value } = envValidationSchema.validate({ + NODE_ENV: "production", + SOROBAN_SIGNING_KEY: VALID_KEY, + }); + expect(error).toBeUndefined(); + expect(value.SOROBAN_SIGNING_KEY).toBe(VALID_KEY); + }); +}); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 9a4dc6d..a940cd3 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,5 +1,10 @@ import * as Joi from "joi"; +// Stellar secret seeds ("S..." strkeys) are 56-char base32: prefix + 32-byte +// payload + checksum. This rejects placeholders like "changeme" outright — +// it does not by itself prove the key is a *real, funded* signer. +const STELLAR_SECRET_KEY_PATTERN = /^S[A-Z2-7]{55}$/; + export const envValidationSchema = Joi.object({ NODE_ENV: Joi.string().valid("development", "production", "test").default("development"), PORT: Joi.number().port().default(4000), @@ -9,5 +14,22 @@ export const envValidationSchema = Joi.object({ SETTLEMENT_CONTRACT_ID: Joi.string().allow("").default(""), SOLVER_REGISTRY_CONTRACT_ID: Joi.string().allow("").default(""), + // Secret key for the backend's own Soroban signer (submits on-chain writes + // such as settlement and slashing calls). No default is provided anywhere + // in this schema — an unset value fails closed (empty string) rather than + // ever falling back to a placeholder that could be mistaken for a real key. + SOROBAN_SIGNING_KEY: Joi.string() + .pattern(STELLAR_SECRET_KEY_PATTERN) + .messages({ + "string.pattern.base": + 'SOROBAN_SIGNING_KEY must be a valid Stellar secret seed (starts with "S", 56 base32 characters). ' + + "Generate a throwaway testnet key for local dev — see README's Signing Key section — never commit a real one.", + }) + .when("NODE_ENV", { + is: "production", + then: Joi.required(), + otherwise: Joi.string().allow("").default(""), + }), + CORS_ORIGIN: Joi.string().default("*"), }); diff --git a/src/intents/intents-sweeper.service.spec.ts b/src/intents/intents-sweeper.service.spec.ts new file mode 100644 index 0000000..72c89d8 --- /dev/null +++ b/src/intents/intents-sweeper.service.spec.ts @@ -0,0 +1,125 @@ +import { IntentsSweeperService } from "./intents-sweeper.service"; +import { IntentsService } from "./intents.service"; +import { IntentsGateway } from "./intents.gateway"; +import { SolversService } from "../solvers/solvers.service"; +import { SolverRegistryService } from "../soroban/solver-registry.service"; + +describe("IntentsSweeperService", () => { + let intentsService: IntentsService; + let gateway: IntentsGateway; + let solversService: SolversService; + let solverRegistryService: jest.Mocked; + let sweeper: IntentsSweeperService; + + beforeEach(() => { + intentsService = new IntentsService(); + gateway = { broadcast: jest.fn() } as unknown as IntentsGateway; + solversService = new SolversService(); + solverRegistryService = { + slashSolver: jest.fn().mockResolvedValue({ + submitted: false, + simulated: false, + detail: "not configured — no-op", + }), + } as unknown as jest.Mocked; + + sweeper = new IntentsSweeperService( + intentsService, + gateway, + solversService, + solverRegistryService, + ); + }); + + function makeAcceptedIntent(deadline: number, solver = "SOLVER_ALPHA") { + const intent = intentsService.create({ + user: "GTEST...0000", + srcChain: "ethereum", + srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: deadline + 10_000, // create as open with a far-future deadline first + }); + intentsService.update(intent.intentId, { state: "accepted", solver, deadline }); + return intent.intentId; + } + + it("expires open intents past their deadline (existing behavior preserved)", async () => { + const past = Math.floor(Date.now() / 1000) - 10; + const intent = intentsService.create({ + user: "GTEST...0000", + srcChain: "stellar", + srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: past, + }); + + await sweeper.sweep(); + + expect(intentsService.get(intent.intentId)?.state).toBe("expired"); + expect(gateway.broadcast).toHaveBeenCalledWith( + expect.objectContaining({ type: "intent_expired", intentId: intent.intentId }), + ); + }); + + it("slashes an accepted intent whose fill deadline has passed", async () => { + const past = Math.floor(Date.now() / 1000) - 10; + const intentId = makeAcceptedIntent(past, "SOLVER_ALPHA"); + + await sweeper.sweep(); + + const updated = intentsService.get(intentId); + expect(updated?.state).toBe("slashed"); + expect(updated?.slashedAt).toBeDefined(); + expect(updated?.slashReason).toBeTruthy(); + + expect(gateway.broadcast).toHaveBeenCalledWith( + expect.objectContaining({ type: "intent_slashed", intentId, solver: "SOLVER_ALPHA" }), + ); + expect(solverRegistryService.slashSolver).toHaveBeenCalledWith( + expect.objectContaining({ solverAddress: "SOLVER_ALPHA", intentId }), + ); + }); + + it("bumps the solver's fillsFailed counter on a slash", async () => { + const past = Math.floor(Date.now() / 1000) - 10; + const before = solversService.get("SOLVER_ALPHA")?.fillsFailed ?? 0; + const intentId = makeAcceptedIntent(past, "SOLVER_ALPHA"); + + await sweeper.sweep(); + + expect(solversService.get("SOLVER_ALPHA")?.fillsFailed).toBe(before + 1); + expect(intentsService.get(intentId)?.state).toBe("slashed"); + }); + + it("does not touch accepted intents still within their fill window", async () => { + const future = Math.floor(Date.now() / 1000) + 300; + const intentId = makeAcceptedIntent(future, "SOLVER_ALPHA"); + + await sweeper.sweep(); + + expect(intentsService.get(intentId)?.state).toBe("accepted"); + expect(solverRegistryService.slashSolver).not.toHaveBeenCalled(); + }); + + it("does not throw if an accepted intent somehow has no solver on record", async () => { + const past = Math.floor(Date.now() / 1000) - 10; + const intent = intentsService.create({ + user: "GTEST...0000", + srcChain: "stellar", + srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: past + 10_000, + }); + intentsService.update(intent.intentId, { state: "accepted", deadline: past }); + + await expect(sweeper.sweep()).resolves.not.toThrow(); + expect(intentsService.get(intent.intentId)?.state).toBe("slashed"); + expect(solverRegistryService.slashSolver).not.toHaveBeenCalled(); + }); +}); diff --git a/src/intents/intents-sweeper.service.ts b/src/intents/intents-sweeper.service.ts index cd8ad99..4b23360 100644 --- a/src/intents/intents-sweeper.service.ts +++ b/src/intents/intents-sweeper.service.ts @@ -1,6 +1,8 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; import { IntentsService } from "./intents.service"; import { IntentsGateway } from "./intents.gateway"; +import { SolversService } from "../solvers/solvers.service"; +import { SolverRegistryService } from "../soroban/solver-registry.service"; const SWEEP_INTERVAL_MS = 30_000; @@ -11,17 +13,23 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { constructor( private readonly intentsService: IntentsService, private readonly intentsGateway: IntentsGateway, + private readonly solversService: SolversService, + private readonly solverRegistryService: SolverRegistryService, ) {} onModuleInit() { - this.interval = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS); + this.interval = setInterval(() => { + this.sweep().catch((err) => { + console.error(`[sweeper] sweep failed: ${err instanceof Error ? err.message : err}`); + }); + }, SWEEP_INTERVAL_MS); } onModuleDestroy() { if (this.interval) clearInterval(this.interval); } - private sweep() { + async sweep() { const now = Math.floor(Date.now() / 1000); let expiredCount = 0; @@ -36,5 +44,42 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { if (expiredCount > 0) { console.log(`[sweeper] Expired ${expiredCount} intent(s)`); } + + const missedFills = this.intentsService + .getByState("accepted") + .filter((intent) => intent.deadline <= now); + + for (const intent of missedFills) { + await this.slashMissedFill(intent.intentId, intent.solver, now); + } + } + + private async slashMissedFill(intentId: string, solver: string | undefined, now: number) { + const reason = "accepted intent not filled before deadline"; + + this.intentsService.update(intentId, { + state: "slashed", + slashedAt: now, + slashReason: reason, + }); + this.intentsGateway.broadcast({ type: "intent_slashed", intentId, solver, reason }); + + if (!solver) { + // Shouldn't happen in practice — an "accepted" intent always has a + // solver — but don't let a bad record throw the whole sweep cycle. + console.error(`[sweeper] intent ${intentId} was accepted with no solver on record`); + return; + } + + this.solversService.recordFailedFill(solver); + + const result = await this.solverRegistryService.slashSolver({ + solverAddress: solver, + intentId, + reason, + }); + console.log( + `[sweeper] slashed solver=${solver} for intent=${intentId}: ${result.detail}`, + ); } } diff --git a/src/intents/intents.module.ts b/src/intents/intents.module.ts index 372ed2a..e5f7904 100644 --- a/src/intents/intents.module.ts +++ b/src/intents/intents.module.ts @@ -4,9 +4,10 @@ import { IntentsController } from "./intents.controller"; import { IntentsGateway } from "./intents.gateway"; import { IntentsSweeperService } from "./intents-sweeper.service"; import { SolversModule } from "../solvers/solvers.module"; +import { SorobanModule } from "../soroban/soroban.module"; @Module({ - imports: [SolversModule], + imports: [SolversModule, SorobanModule], controllers: [IntentsController], providers: [IntentsService, IntentsGateway, IntentsSweeperService], exports: [IntentsService], diff --git a/src/intents/intents.types.ts b/src/intents/intents.types.ts index 1a03cd2..4e6dad6 100644 --- a/src/intents/intents.types.ts +++ b/src/intents/intents.types.ts @@ -48,6 +48,8 @@ export interface Intent { filledAt?: number; fillAmount?: string; txHash?: string; // fill tx on Stellar + slashedAt?: number; + slashReason?: string; } export interface Quote { diff --git a/src/solvers/solvers.service.ts b/src/solvers/solvers.service.ts index 357d9a5..9f57d39 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -32,6 +32,21 @@ export class SolversService { return solver; } + /** + * Records that a solver accepted an intent and then missed its fill + * deadline. Bumps the local fillsFailed counter for read paths (e.g. the + * leaderboard); the authoritative bond reduction happens on-chain via + * SolverRegistryService.slashSolver and should reconcile bondAmount here + * once event ingestion exists (see docs/architecture/onchain-settlement.md). + */ + recordFailedFill(address: string): SolverRecord | null { + const solver = this.solvers.get(address); + if (!solver) return null; + const updated = { ...solver, fillsFailed: solver.fillsFailed + 1 }; + this.solvers.set(address, updated); + return updated; + } + private seed() { for (const s of buildSeedSolvers()) { this.solvers.set(s.address, s); diff --git a/src/soroban/solver-registry.service.spec.ts b/src/soroban/solver-registry.service.spec.ts new file mode 100644 index 0000000..20a7e52 --- /dev/null +++ b/src/soroban/solver-registry.service.spec.ts @@ -0,0 +1,57 @@ +import { ConfigService } from "@nestjs/config"; +import { SolverRegistryService } from "./solver-registry.service"; +import { AppConfig } from "../config/configuration"; + +function makeConfigService(overrides: Partial = {}) { + const stellar: AppConfig["stellar"] = { + network: "testnet", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + settlementContractId: "", + solverRegistryContractId: "", + signingKey: "", + ...overrides, + }; + const config: AppConfig = { + nodeEnv: "test", + port: 4000, + stellar, + corsOrigin: "*", + }; + return { + get: (key: string) => { + const parts = key.split("."); + // only "stellar." keys are used by this service + return (config as unknown as Record)[parts[0]] && + parts[0] === "stellar" + ? (stellar as unknown as Record)[parts[1]] + : undefined; + }, + } as unknown as ConfigService; +} + +describe("SolverRegistryService", () => { + it("is not configured when the contract id and signing key are both empty (default)", () => { + const service = new SolverRegistryService(makeConfigService()); + expect(service.isConfigured).toBe(false); + }); + + it("is not configured when only the contract id is set", () => { + const service = new SolverRegistryService( + makeConfigService({ solverRegistryContractId: "CABCDEF" }), + ); + expect(service.isConfigured).toBe(false); + }); + + it("no-ops without contacting the network when unconfigured", async () => { + const service = new SolverRegistryService(makeConfigService()); + const result = await service.slashSolver({ + solverAddress: "GSOLVER", + intentId: "intent-1", + reason: "missed deadline", + }); + + expect(result.submitted).toBe(false); + expect(result.simulated).toBe(false); + expect(result.detail).toMatch(/not configured/i); + }); +}); diff --git a/src/soroban/solver-registry.service.ts b/src/soroban/solver-registry.service.ts new file mode 100644 index 0000000..1499fb1 --- /dev/null +++ b/src/soroban/solver-registry.service.ts @@ -0,0 +1,124 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + Address, + BASE_FEE, + Contract, + Keypair, + Networks, + SorobanRpc, + TransactionBuilder, + nativeToScVal, +} from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; + +const NETWORK_PASSPHRASE: Record = { + testnet: Networks.TESTNET, + futurenet: Networks.FUTURENET, + mainnet: Networks.PUBLIC, +}; + +export interface SlashParams { + solverAddress: string; + intentId: string; + reason: string; +} + +export interface SlashResult { + /** true only if a transaction was actually broadcast to the network. */ + submitted: boolean; + /** true if we got far enough to run a (network, non-mutating) simulation. */ + simulated: boolean; + txHash?: string; + detail: string; +} + +/** + * Client for the on-chain solver-registry contract's penalty path. + * + * There is no deployed solver-registry contract or confirmed function + * signature yet (tracked separately — issue #23 wires solver acceptance to + * this same contract). Until that lands, this service simulates the call + * it *would* make and never submits — safe by construction, since + * SorobanRpc's simulateTransaction never mutates ledger state. It also + * fails closed to a pure no-op whenever the registry contract ID or the + * backend's signing key isn't configured, which is the default in every + * environment today (see src/config/env.validation.ts). + * + * Wiring an actual submit path is deliberately left for once issue #23 + * confirms the real contract interface and the dry-run flag (issue #35) + * exists to stage the rollout — see docs/runbooks/onchain-cutover.md. + */ +@Injectable() +export class SolverRegistryService { + private readonly contractId: string; + private readonly signingKey: string; + private readonly networkPassphrase: string; + private readonly server: SorobanRpc.Server; + + constructor(configService: ConfigService) { + this.contractId = configService.get("stellar.solverRegistryContractId", { infer: true }); + this.signingKey = configService.get("stellar.signingKey", { infer: true }); + const network = configService.get("stellar.network", { infer: true }); + this.networkPassphrase = NETWORK_PASSPHRASE[network]; + const rpcUrl = configService.get("stellar.sorobanRpcUrl", { infer: true }); + this.server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://") }); + } + + get isConfigured(): boolean { + return this.contractId.length > 0 && this.signingKey.length > 0; + } + + async slashSolver(params: SlashParams): Promise { + if (!this.isConfigured) { + const detail = + "SOLVER_REGISTRY_CONTRACT_ID or SOROBAN_SIGNING_KEY not configured — no-op"; + console.log( + `[solver-registry] would slash solver=${params.solverAddress} intent=${params.intentId} reason="${params.reason}" (${detail})`, + ); + return { submitted: false, simulated: false, detail }; + } + + try { + const sourceKeypair = Keypair.fromSecret(this.signingKey); + const account = await this.server.getAccount(sourceKeypair.publicKey()); + const contract = new Contract(this.contractId); + + const operation = contract.call( + "slash", + Address.fromString(params.solverAddress).toScVal(), + nativeToScVal(params.intentId, { type: "string" }), + ); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simulation = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simulation)) { + const detail = `simulation failed: ${simulation.error}`; + console.error( + `[solver-registry] slash simulation errored for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`, + ); + return { submitted: false, simulated: true, detail }; + } + + const detail = + "simulated only — live submission is gated pending issue #23 (confirmed contract interface) and issue #35 (dry-run/live-mode toggle)"; + console.log( + `[solver-registry] simulated slash tx for solver=${params.solverAddress} intent=${params.intentId} (${detail})`, + ); + return { submitted: false, simulated: true, detail }; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.error( + `[solver-registry] slash call errored for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`, + ); + return { submitted: false, simulated: false, detail }; + } + } +} diff --git a/src/soroban/soroban.module.ts b/src/soroban/soroban.module.ts index 67d377d..84f328d 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -1,10 +1,11 @@ import { Module } from "@nestjs/common"; import { SorobanController } from "./soroban.controller"; import { SorobanService } from "./soroban.service"; +import { SolverRegistryService } from "./solver-registry.service"; @Module({ controllers: [SorobanController], - providers: [SorobanService], - exports: [SorobanService], + providers: [SorobanService, SolverRegistryService], + exports: [SorobanService, SolverRegistryService], }) export class SorobanModule {}