From 3341f99b6a8ac0178c0f0befd92248197878a47a Mon Sep 17 00:00:00 2001 From: spotkorner-dot Date: Sat, 25 Jul 2026 13:02:27 +0000 Subject: [PATCH 1/5] feat: add validated config for the Soroban signing key Adds SOROBAN_SIGNING_KEY: required and strkey-format-validated in production, optional (empty by default) elsewhere, with no placeholder default anywhere in the schema. Documents throwaway testnet key generation for local dev in README and .env.example. --- .env.example | 16 +++++++++ README.md | 27 +++++++++++++++ src/config/configuration.ts | 6 ++++ src/config/env.validation.spec.ts | 57 +++++++++++++++++++++++++++++++ src/config/env.validation.ts | 22 ++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 src/config/env.validation.spec.ts 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..fee1866 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 | 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("*"), }); From 667ba9e356f0cd77ab01dc8e9fe9f8a0d4ce7147 Mon Sep 17 00:00:00 2001 From: spotkorner-dot Date: Sat, 25 Jul 2026 13:04:22 +0000 Subject: [PATCH 2/5] docs: add on-chain settlement cutover runbook Documents pre-checks, staged rollout via the (upcoming) dry-run flag, and a rollback path back to in-memory state with no data loss for in-flight intents, coordinated with the audit trail work in #62. --- docs/runbooks/onchain-cutover.md | 207 +++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/runbooks/onchain-cutover.md 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. From f612e5fef4ade48ac2453e779893c577624e6b38 Mon Sep 17 00:00:00 2001 From: spotkorner-dot Date: Sat, 25 Jul 2026 13:05:52 +0000 Subject: [PATCH 3/5] docs: document on-chain settlement architecture (target design) Adds docs/architecture/onchain-settlement.md covering the planned HTTP request -> Soroban tx -> event ingestion -> state reconciliation flow, the IntentState-to-contract-call mapping, and the open custody question for the ADR (issue #19) to ratify. Links it from the README roadmap without checking off "On-chain writes" -- that item ships when the linked design is actually implemented, not with this doc. --- README.md | 2 +- docs/architecture/onchain-settlement.md | 167 ++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/onchain-settlement.md diff --git a/README.md b/README.md index 434ee2a..dabc689 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,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). From 41c933428f74ec4174e9ad196af459d415082556 Mon Sep 17 00:00:00 2001 From: spotkorner-dot Date: Sat, 25 Jul 2026 13:02:27 +0000 Subject: [PATCH 4/5] feat: add validated config for the Soroban signing key Adds SOROBAN_SIGNING_KEY: required and strkey-format-validated in production, optional (empty by default) elsewhere, with no placeholder default anywhere in the schema. Documents throwaway testnet key generation for local dev in README and .env.example. --- .env.example | 16 +++++++++ README.md | 27 +++++++++++++++ src/config/configuration.ts | 6 ++++ src/config/env.validation.spec.ts | 57 +++++++++++++++++++++++++++++++ src/config/env.validation.ts | 22 ++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 src/config/env.validation.spec.ts 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..fee1866 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 | 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("*"), }); From f19685d9b5e7a10986d16ad0ed229a374e5f73d4 Mon Sep 17 00:00:00 2001 From: spotkorner-dot Date: Sat, 25 Jul 2026 13:11:21 +0000 Subject: [PATCH 5/5] feat: slash solvers who accept but fail to fill in time IntentsSweeperService now detects "accepted" intents whose fill deadline has passed, transitions them to "slashed" (previously unset anywhere in the codebase), bumps the solver's fillsFailed counter, and calls the new SolverRegistryService to invoke the solver-registry contract's penalty path. SolverRegistryService builds and simulates the on-chain "slash" call via the Soroban RPC signed with SOROBAN_SIGNING_KEY. It never submits a transaction yet: there's no deployed solver-registry contract or confirmed function signature to submit against (issue #23 is still open), and there's no dry-run/live-mode gate for staged rollout yet (issue #35). It fails closed to a pure no-op whenever the contract ID or signing key isn't configured, which is the default everywhere today. Wiring real submission is left for once both of those land. Includes the signing-key config from #34 (feature/signing-key-config) since this depends on it directly. --- src/intents/intents-sweeper.service.spec.ts | 125 ++++++++++++++++++++ src/intents/intents-sweeper.service.ts | 49 +++++++- src/intents/intents.module.ts | 3 +- src/intents/intents.types.ts | 2 + src/solvers/solvers.service.ts | 15 +++ src/soroban/solver-registry.service.spec.ts | 57 +++++++++ src/soroban/solver-registry.service.ts | 124 +++++++++++++++++++ src/soroban/soroban.module.ts | 5 +- 8 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 src/intents/intents-sweeper.service.spec.ts create mode 100644 src/soroban/solver-registry.service.spec.ts create mode 100644 src/soroban/solver-registry.service.ts 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 {}