diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index aa00a14..2e69ca5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -34,6 +34,30 @@ proof_registry/src/ @stellar-vortex-protocol/proof-registry-rev # Solver registry contract — solver identity and configuration solver_registry/src/ @stellar-vortex-protocol/solver-registry-reviewers +# Reputation badge contract — soulbound solver tier badges (issue #515) +reputation_badge/src/ @stellar-vortex-protocol/solver-registry-reviewers + +# Governor contract — on-chain governance and proposal execution (issue #515) +governor/src/ @stellar-vortex-protocol/governance-reviewers + +# Backstop vault — liquidity backstop for settlement (issue #515) +backstop_vault/src/ @stellar-vortex-protocol/intent-settlement-reviewers + +# Fee router — fee distribution and routing logic (issue #515) +fee_router/src/ @stellar-vortex-protocol/intent-settlement-reviewers + +# Intent periphery — helper contracts wrapping settlement intents (issue #515) +intent_periphery/src/ @stellar-vortex-protocol/intent-settlement-reviewers + +# Treasury streams — streaming payment / treasury management (issue #515) +treasury_streams/src/ @stellar-vortex-protocol/intent-settlement-reviewers + +# Timelock — generic timelock controller (issue #515) +timelock/src/ @stellar-vortex-protocol/governance-reviewers + +# Vortex common — shared constants, types, and helpers (issue #515) +vortex-common/src/ @stellar-vortex-protocol/maintainers + # ─────────────────────────────────────────────────────────────────────────── # Integration, Indexing, and Tooling # ─────────────────────────────────────────────────────────────────────────── diff --git a/Makefile b/Makefile index ee39e1a..2b80052 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,26 @@ # Vortex Contracts — Makefile # # Wraps the commands documented in README.md so contributors never need to -# copy-paste raw multi-flag invocations. All targets run inside the -# intent_settlement workspace directory. +# copy-paste raw multi-flag invocations. All workspace-wide targets (fmt, +# fmt-check, lint, test, build, audit, clean) iterate over every Rust crate +# listed in ALL_CRATES so that a single `make all` covers the whole repo +# (issue #514). +# +# Per-crate targets are also available for focused work: +# make -fmt | -lint | -test | -build | -all # # Usage: -# make — print help -# make all — fmt + lint + test + build (full pre-push check) -# make fmt — format all source files -# make lint — clippy with -D warnings (identical to CI) -# make test — run unit / integration tests -# make build — compile the release wasm binary -# make deploy-testnet — deploy to Stellar testnet (requires STELLAR_SOURCE) +# make — print help +# make all — fmt + lint + test + build across ALL crates +# make fmt — format every crate +# make lint — clippy -D warnings on every crate +# make test — run tests in every crate +# make build — build release wasm for every crate +# make deploy-testnet — deploy intent_settlement to Stellar testnet # -# Proof Registry targets (issue #213): -# make proof-registry-fmt — format proof_registry source -# make proof-registry-lint — clippy proof_registry -# make proof-registry-test — test proof_registry -# make proof-registry-build — build proof_registry wasm -# make proof-registry-all — full check for proof_registry +# Kani / proptest targets (issues #415, #544): +# make kani — model-check Kani harnesses (requires kani-verifier) +# make proptest — run proptest suites across Kani-covered crates .PHONY: build-wasms integration-test @@ -28,57 +30,136 @@ build-wasms: # ── Configuration ───────────────────────────────────────────────────────────── +# Primary deploy target kept for backward compat (deploy-testnet target). WORKSPACE := intent_settlement -PROOF_REGISTRY := proof_registry WASM_TARGET := wasm32-unknown-unknown WASM_OUT := $(WORKSPACE)/target/$(WASM_TARGET)/release/vortex_intent_settlement.wasm -PR_WASM_OUT := $(PROOF_REGISTRY)/target/$(WASM_TARGET)/release/vortex_proof_registry.wasm STELLAR_NETWORK ?= testnet # Set STELLAR_SOURCE to your secret key or key-name before running deploy-testnet STELLAR_SOURCE ?= +# All Rust crates in the workspace (issue #514). +# vortex-common is a library crate with no wasm target; it is included in +# fmt/lint/test but excluded from the build target (no cdylib output). +ALL_CRATES := \ + vortex-common \ + intent_settlement \ + solver_registry \ + proof_registry \ + reputation_badge \ + treasury_streams \ + fee_router \ + arbiter_panel \ + governor \ + backstop_vault \ + intent_periphery \ + timelock + +# Crates that produce a wasm binary (cdylib). vortex-common is excluded. +WASM_CRATES := \ + intent_settlement \ + solver_registry \ + proof_registry \ + reputation_badge \ + treasury_streams \ + fee_router \ + arbiter_panel \ + governor \ + backstop_vault \ + intent_periphery \ + timelock + # Crates that carry Kani harnesses and/or property-based tests (issue #544). KANI_CRATES := intent_settlement solver_registry proof_registry -# ── Compound targets ────────────────────────────────────────────────────────── +# ── Workspace-wide compound targets ────────────────────────────────────────── .PHONY: all -all: fmt lint test build ## Run fmt, lint, test, and build (full pre-push check) +all: fmt lint test build ## Run fmt, lint, test, and build across all crates (full pre-push check) -.PHONY: proof-registry-all -proof-registry-all: proof-registry-fmt proof-registry-lint proof-registry-test proof-registry-build ## Full check for proof_registry - -# ── Individual targets (intent_settlement) ───────────────────────────────────── +# ── Workspace-wide individual targets ──────────────────────────────────────── .PHONY: fmt -fmt: ## Format all source files with rustfmt - cd $(WORKSPACE) && cargo fmt --all +fmt: ## Format every crate with rustfmt + @set -e; for crate in $(ALL_CRATES); do \ + echo "==> cargo fmt in $$crate"; \ + ( cd $$crate && cargo fmt --all ); \ + done .PHONY: fmt-check -fmt-check: ## Check formatting without modifying files (used by CI) - cd $(WORKSPACE) && cargo fmt --all -- --check +fmt-check: ## Check formatting in every crate without modifying files (used by CI) + @set -e; for crate in $(ALL_CRATES); do \ + echo "==> cargo fmt --check in $$crate"; \ + ( cd $$crate && cargo fmt --all -- --check ); \ + done .PHONY: lint -lint: ## Run clippy with -D warnings (identical to the CI command) - cd $(WORKSPACE) && cargo clippy --all-targets -- -D warnings +lint: ## Run clippy -D warnings on every crate (identical to the CI command) + @set -e; for crate in $(ALL_CRATES); do \ + echo "==> cargo clippy in $$crate"; \ + ( cd $$crate && cargo clippy --all-targets -- -D warnings ); \ + done .PHONY: test -test: ## Run the full test suite - cd $(WORKSPACE) && cargo test +test: ## Run the full test suite in every crate + @set -e; for crate in $(ALL_CRATES); do \ + echo "==> cargo test in $$crate"; \ + ( cd $$crate && cargo test ); \ + done .PHONY: build -build: ## Build the release wasm binary - cd $(WORKSPACE) && cargo build --target $(WASM_TARGET) --release +build: ## Build the release wasm binary for every wasm crate + @set -e; for crate in $(WASM_CRATES); do \ + echo "==> cargo build --release in $$crate"; \ + ( cd $$crate && cargo build --target $(WASM_TARGET) --release ); \ + done .PHONY: audit -audit: ## Run cargo-audit against the dependency tree - cd $(WORKSPACE) && cargo audit +audit: ## Run cargo-audit against every crate's dependency tree + @set -e; for crate in $(ALL_CRATES); do \ + echo "==> cargo audit in $$crate"; \ + ( cd $$crate && cargo audit ); \ + done -# ── Generated documentation (issue #531) ───────────────────────────────────── +.PHONY: clean +clean: ## Remove build artefacts from every crate + @set -e; for crate in $(ALL_CRATES); do \ + echo "==> cargo clean in $$crate"; \ + ( cd $$crate && cargo clean ); \ + done + +# ── Per-crate convenience targets ───────────────────────────────────────────── # -# README.md's "Error Reference" table is generated from the `Error` enum in -# intent_settlement/src/lib.rs. `error-reference-check` fails when the two -# have drifted apart; `error-reference` rewrites the table. +# Pattern: make -fmt | -fmt-check | -lint | +# -test | -build | -audit | -clean | +# -all +# +# Implemented via static rules so `make help` can document them and tab- +# completion works. Add new crates to ALL_CRATES above; the rules below +# need no changes. + +define CRATE_RULES +.PHONY: $(1)-fmt $(1)-fmt-check $(1)-lint $(1)-test $(1)-build $(1)-audit $(1)-clean $(1)-all +$(1)-fmt: ## Format $(1) source files + cd $(1) && cargo fmt --all +$(1)-fmt-check: ## Check $(1) formatting without modifying + cd $(1) && cargo fmt --all -- --check +$(1)-lint: ## Run clippy on $(1) + cd $(1) && cargo clippy --all-targets -- -D warnings +$(1)-test: ## Run $(1) test suite + cd $(1) && cargo test +$(1)-build: ## Build $(1) release wasm + cd $(1) && cargo build --target $(WASM_TARGET) --release +$(1)-audit: ## Run cargo-audit on $(1) + cd $(1) && cargo audit +$(1)-clean: ## Remove $(1) build artefacts + cd $(1) && cargo clean +$(1)-all: $(1)-fmt $(1)-lint $(1)-test $(1)-build ## Full check for $(1) +endef + +$(foreach crate,$(ALL_CRATES),$(eval $(call CRATE_RULES,$(crate)))) + +# ── Generated documentation (issue #531) ───────────────────────────────────── .PHONY: error-reference error-reference: ## Regenerate README.md's Error Reference table from the Error enum @@ -88,44 +169,10 @@ error-reference: ## Regenerate README.md's Error Reference table from the Error error-reference-check: ## Verify README.md's Error Reference table matches the Error enum python3 scripts/generate-error-reference.py --check -.PHONY: clean -clean: ## Remove build artefacts - cd $(WORKSPACE) && cargo clean - -# ── Proof Registry targets (issue #213) ──────────────────────────────────────── - -.PHONY: proof-registry-fmt -proof-registry-fmt: ## Format proof_registry source files - cd $(PROOF_REGISTRY) && cargo fmt --all - -.PHONY: proof-registry-fmt-check -proof-registry-fmt-check: ## Check proof_registry formatting without modifying - cd $(PROOF_REGISTRY) && cargo fmt --all -- --check - -.PHONY: proof-registry-lint -proof-registry-lint: ## Run clippy on proof_registry - cd $(PROOF_REGISTRY) && cargo clippy --all-targets -- -D warnings - -.PHONY: proof-registry-test -proof-registry-test: ## Run proof_registry test suite - cd $(PROOF_REGISTRY) && cargo test - -.PHONY: proof-registry-build -proof-registry-build: ## Build proof_registry release wasm - cd $(PROOF_REGISTRY) && cargo build --target $(WASM_TARGET) --release - -.PHONY: proof-registry-audit -proof-registry-audit: ## Run cargo-audit on proof_registry - cd $(PROOF_REGISTRY) && cargo audit - -.PHONY: proof-registry-clean -proof-registry-clean: ## Remove proof_registry build artefacts - cd $(PROOF_REGISTRY) && cargo clean - # ── Deploy ──────────────────────────────────────────────────────────────────── .PHONY: deploy-testnet -deploy-testnet: build ## Deploy the wasm to Stellar testnet (set STELLAR_SOURCE first) +deploy-testnet: intent_settlement-build ## Deploy intent_settlement wasm to Stellar testnet (set STELLAR_SOURCE first) ifndef STELLAR_SOURCE $(error STELLAR_SOURCE is not set. Export your Stellar secret key or key-name: export STELLAR_SOURCE=) endif diff --git a/examples/events-webhook-relay/package.json b/examples/events-webhook-relay/package.json index 70350c8..f9495ff 100644 --- a/examples/events-webhook-relay/package.json +++ b/examples/events-webhook-relay/package.json @@ -9,7 +9,9 @@ "dev": "node --watch dist/index.js", "build": "tsc", "lint": "eslint src/", - "format": "prettier --write src/" + "format": "prettier --write src/", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "express": "^4.18.2", @@ -24,7 +26,11 @@ "@types/express": "^4.17.17", "@types/node": "^20.0.0", "eslint": "^8.40.0", - "prettier": "^2.8.8" + "prettier": "^2.8.8", + "vitest": "^1.6.0", + "@vitest/coverage-v8": "^1.6.0", + "supertest": "^6.3.4", + "@types/supertest": "^6.0.2" }, "keywords": [ "vortex", diff --git a/examples/events-webhook-relay/src/__tests__/index.test.ts b/examples/events-webhook-relay/src/__tests__/index.test.ts new file mode 100644 index 0000000..dd6fafd --- /dev/null +++ b/examples/events-webhook-relay/src/__tests__/index.test.ts @@ -0,0 +1,328 @@ +/** + * Test suite for events-webhook-relay (issue #513) + * + * Coverage areas: + * 1. loadConfig – reads env vars and applies defaults + * 2. EventRelay constructor – validateConfig rejects bad configuration + * 3. Express routes – /health and /stats return correct JSON shapes + * 4. relayEvent – successful webhook delivery, retry on transient failure, + * drop after max retries + * 5. pollEvents – processes fetched events and advances the cursor + * 6. start/stop – isRunning flag transitions + */ + +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, + type MockInstance, +} from "vitest"; +import request from "supertest"; +import axios from "axios"; + +// We import from the compiled source. Vitest resolves TypeScript directly via +// the ts plugin configured in vitest.config.ts, so no build step is needed +// during test runs. +import { loadConfig, EventRelay } from "../index.js"; + +// ── helpers ─────────────────────────────────────────────────────────────── + +function baseConfig() { + return { + rpcUrl: "http://localhost:8001", + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + webhookUrl: "http://localhost:9999/hook", + maxRetries: 3, + retryBackoffMs: 10, // speed up tests + port: 0, // OS-assigned port; we don't actually listen in unit tests + logLevel: "error" as const, // suppress pino output during tests + startingCursor: undefined, + }; +} + +// ── 1. loadConfig ───────────────────────────────────────────────────────── + +describe("loadConfig", () => { + const saved: Record = {}; + + // Save and restore env vars touched by each test. + beforeEach(() => { + for (const key of [ + "SOROBAN_RPC_URL", + "CONTRACT_ID", + "WEBHOOK_URL", + "MAX_RETRIES", + "RETRY_BACKOFF_MS", + "PORT", + "LOG_LEVEL", + "STARTING_CURSOR", + ]) { + saved[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it("returns defaults when no env vars are set", () => { + const cfg = loadConfig(); + expect(cfg.rpcUrl).toBe("http://localhost:8001"); + expect(cfg.maxRetries).toBe(3); + expect(cfg.retryBackoffMs).toBe(1000); + expect(cfg.port).toBe(3000); + expect(cfg.logLevel).toBe("info"); + expect(cfg.startingCursor).toBeUndefined(); + }); + + it("reads overrides from environment variables", () => { + process.env.SOROBAN_RPC_URL = "http://rpc.example.com"; + process.env.CONTRACT_ID = "CABC123"; + process.env.WEBHOOK_URL = "http://hook.example.com"; + process.env.MAX_RETRIES = "5"; + process.env.RETRY_BACKOFF_MS = "500"; + process.env.PORT = "4000"; + process.env.LOG_LEVEL = "debug"; + process.env.STARTING_CURSOR = "42"; + + const cfg = loadConfig(); + expect(cfg.rpcUrl).toBe("http://rpc.example.com"); + expect(cfg.contractId).toBe("CABC123"); + expect(cfg.webhookUrl).toBe("http://hook.example.com"); + expect(cfg.maxRetries).toBe(5); + expect(cfg.retryBackoffMs).toBe(500); + expect(cfg.port).toBe(4000); + expect(cfg.logLevel).toBe("debug"); + expect(cfg.startingCursor).toBe("42"); + }); + + it("parses numeric strings to numbers", () => { + process.env.MAX_RETRIES = "7"; + process.env.RETRY_BACKOFF_MS = "2500"; + process.env.PORT = "8080"; + const cfg = loadConfig(); + expect(typeof cfg.maxRetries).toBe("number"); + expect(typeof cfg.retryBackoffMs).toBe("number"); + expect(typeof cfg.port).toBe("number"); + }); +}); + +// ── 2. EventRelay constructor / validateConfig ──────────────────────────── + +describe("EventRelay constructor", () => { + it("throws when CONTRACT_ID is empty", () => { + expect( + () => new EventRelay({ ...baseConfig(), contractId: "" }) + ).toThrow(/CONTRACT_ID/); + }); + + it("throws when WEBHOOK_URL is empty", () => { + expect( + () => new EventRelay({ ...baseConfig(), webhookUrl: "" }) + ).toThrow(/WEBHOOK_URL/); + }); + + it("throws when WEBHOOK_URL is not a valid URL", () => { + expect( + () => new EventRelay({ ...baseConfig(), webhookUrl: "not-a-url" }) + ).toThrow(/WEBHOOK_URL/); + }); + + it("does not throw with valid configuration", () => { + expect(() => new EventRelay(baseConfig())).not.toThrow(); + }); +}); + +// ── 3. Express routes ───────────────────────────────────────────────────── + +describe("Express routes", () => { + let relay: EventRelay; + + beforeEach(() => { + relay = new EventRelay({ ...baseConfig(), startingCursor: "10" }); + }); + + it("GET /health returns status ok and shape", async () => { + const res = await request(relay.expressApp).get("/health"); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + status: "ok", + running: false, + }); + expect(res.body).toHaveProperty("currentCursor"); + expect(res.body).toHaveProperty("contractId"); + }); + + it("GET /health reflects the starting cursor", async () => { + const res = await request(relay.expressApp).get("/health"); + expect(res.body.currentCursor).toBe("10"); + }); + + it("GET /stats returns contractId and webhookUrl", async () => { + const res = await request(relay.expressApp).get("/stats"); + expect(res.status).toBe(200); + expect(res.body.contractId).toBe(baseConfig().contractId); + expect(res.body.webhookUrl).toBe(baseConfig().webhookUrl); + expect(res.body).toHaveProperty("isRunning"); + }); + + it("GET /health running flag is false before start()", async () => { + const res = await request(relay.expressApp).get("/health"); + expect(res.body.running).toBe(false); + }); +}); + +// ── 4. relayEvent ───────────────────────────────────────────────────────── + +describe("relayEvent", () => { + let relay: EventRelay; + let postSpy: MockInstance; + + const sampleEvent = { + id: "evt-001", + contractId: "CABC", + ledgerSequence: 100, + timestamp: 1_700_000_000, + type: "badge_minted", + data: { tier: "Gold" }, + relayedAt: new Date().toISOString(), + }; + + beforeEach(() => { + relay = new EventRelay(baseConfig()); + postSpy = vi.spyOn(axios, "post"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("POSTs event payload to the webhook URL on success", async () => { + postSpy.mockResolvedValueOnce({ status: 200, data: {} }); + + // Access private method via type cast for white-box testing. + await (relay as unknown as { relayEvent: (e: unknown) => Promise }).relayEvent(sampleEvent); + + expect(postSpy).toHaveBeenCalledOnce(); + const [url, body, opts] = postSpy.mock.calls[0] as [string, unknown, { headers: Record }]; + expect(url).toBe(baseConfig().webhookUrl); + expect(body).toEqual(sampleEvent); + expect(opts.headers["X-Vortex-Event-ID"]).toBe(sampleEvent.id); + expect(opts.headers["X-Vortex-Contract-ID"]).toBe(sampleEvent.contractId); + }); + + it("retries on transient 500 and succeeds on second attempt", async () => { + postSpy + .mockRejectedValueOnce(new Error("server error")) + .mockResolvedValueOnce({ status: 200, data: {} }); + + await (relay as unknown as { relayEvent: (e: unknown) => Promise }).relayEvent(sampleEvent); + + expect(postSpy).toHaveBeenCalledTimes(2); + }); + + it("drops the event after max retries without throwing", async () => { + postSpy.mockRejectedValue(new Error("always fails")); + + // Should resolve (not reject) — the relay logs and drops after max attempts. + await expect( + (relay as unknown as { relayEvent: (e: unknown) => Promise }).relayEvent(sampleEvent) + ).resolves.toBeUndefined(); + + expect(postSpy).toHaveBeenCalledTimes(baseConfig().maxRetries); + }); + + it("does not retry on a 2xx response", async () => { + postSpy.mockResolvedValueOnce({ status: 201, data: {} }); + + await (relay as unknown as { relayEvent: (e: unknown) => Promise }).relayEvent(sampleEvent); + + expect(postSpy).toHaveBeenCalledOnce(); + }); + + it("retries when webhook returns a non-2xx status code", async () => { + postSpy + .mockResolvedValueOnce({ status: 503, data: {} }) + .mockResolvedValueOnce({ status: 200, data: {} }); + + await (relay as unknown as { relayEvent: (e: unknown) => Promise }).relayEvent(sampleEvent); + + expect(postSpy).toHaveBeenCalledTimes(2); + }); +}); + +// ── 5. pollEvents / fetchContractEvents ────────────────────────────────── + +describe("pollEvents", () => { + let relay: EventRelay; + let postSpy: MockInstance; + + beforeEach(() => { + relay = new EventRelay(baseConfig()); + postSpy = vi.spyOn(axios, "post"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("advances currentCursor to the last event id when events are returned", async () => { + const events = [ + { id: "10", contractId: "C1", ledgerSequence: 10, timestamp: 1, type: "t", data: {}, relayedAt: "" }, + { id: "11", contractId: "C1", ledgerSequence: 11, timestamp: 2, type: "t", data: {}, relayedAt: "" }, + ]; + // First call: fetchContractEvents (getEvents RPC); subsequent calls: relayEvent webhooks + postSpy + .mockResolvedValueOnce({ data: { result: { events } } }) // getEvents + .mockResolvedValue({ status: 200, data: {} }); // webhook deliveries + + await (relay as unknown as { pollEvents: () => Promise }).pollEvents(); + + // Cursor should have been updated to the last event's id. + const health = await request(relay.expressApp).get("/health"); + expect(health.body.currentCursor).toBe("11"); + }); + + it("does not advance cursor when no events are returned", async () => { + postSpy.mockResolvedValueOnce({ data: { result: { events: [] } } }); + + await (relay as unknown as { pollEvents: () => Promise }).pollEvents(); + + const health = await request(relay.expressApp).get("/health"); + expect(health.body.currentCursor).toBeUndefined(); + }); + + it("does not throw when the RPC call fails", async () => { + postSpy.mockRejectedValueOnce(new Error("RPC unavailable")); + + await expect( + (relay as unknown as { pollEvents: () => Promise }).pollEvents() + ).resolves.toBeUndefined(); + }); +}); + +// ── 6. start / stop ─────────────────────────────────────────────────────── + +describe("start / stop", () => { + let relay: EventRelay; + + afterEach(() => { + relay?.stop(); + }); + + it("stop() sets isRunning to false (via /health)", async () => { + relay = new EventRelay(baseConfig()); + + // Don't await start() — it blocks; just check stop() flips the flag. + relay.stop(); + const res = await request(relay.expressApp).get("/health"); + expect(res.body.running).toBe(false); + }); +}); diff --git a/examples/events-webhook-relay/src/index.ts b/examples/events-webhook-relay/src/index.ts index a3f6652..6039e92 100644 --- a/examples/events-webhook-relay/src/index.ts +++ b/examples/events-webhook-relay/src/index.ts @@ -47,7 +47,7 @@ interface Config { /** * Load configuration from environment variables with defaults */ -function loadConfig(): Config { +export function loadConfig(): Config { return { rpcUrl: process.env.SOROBAN_RPC_URL || "http://localhost:8001", contractId: process.env.CONTRACT_ID || "", @@ -78,7 +78,7 @@ interface EventPayload { /** * Event relay service */ -class EventRelay { +export class EventRelay { private config: Config; private logger: ReturnType; private server: Server; @@ -327,10 +327,17 @@ class EventRelay { /** * Stop the relay service */ - private stop(): void { + stop(): void { this.isRunning = false; this.logger.info("Event relay service stopped"); } + + /** + * Expose the internal Express app for integration testing. + */ + get expressApp() { + return this.app; + } } // ────────────────────────────────────────────────────────────────────────── @@ -338,7 +345,7 @@ class EventRelay { /** * Main entry point */ -async function main(): Promise { +export async function main(): Promise { try { const config = loadConfig(); const relay = new EventRelay(config); @@ -349,4 +356,10 @@ async function main(): Promise { } } -main(); +// Only run when executed directly (not when imported by tests). +// In ESM the canonical check is comparing import.meta.url to process.argv[1]. +const moduleUrl = new URL(import.meta.url).pathname; +const entryPath = process.argv[1]; +if (moduleUrl === entryPath || entryPath?.endsWith("index.js")) { + main(); +} diff --git a/examples/events-webhook-relay/vitest.config.ts b/examples/events-webhook-relay/vitest.config.ts new file mode 100644 index 0000000..50a66e1 --- /dev/null +++ b/examples/events-webhook-relay/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Run in Node environment (no DOM needed for a server-side relay). + environment: "node", + // Test files: anything under src/**/__tests__/**/*.test.ts + include: ["src/**/__tests__/**/*.test.ts"], + // Timeouts: increase slightly to allow retries with short back-off. + testTimeout: 10_000, + // Produce a clean, readable summary in CI. + reporter: ["verbose"], + }, +}); diff --git a/reputation_badge/src/lib.rs b/reputation_badge/src/lib.rs index 1964e6c..9f67f78 100644 --- a/reputation_badge/src/lib.rs +++ b/reputation_badge/src/lib.rs @@ -1,16 +1,18 @@ #![no_std] -//! Vortex Protocol — Solver Reputation Tier Badge (prototype) +//! Vortex Protocol — Solver Reputation Tier Badge //! //! Minimal, non-transferable on-chain record of a solver's reputation tier, -//! prototyping the roadmap item tracked by issue #242. See +//! implementing the roadmap item tracked by issue #242. See //! `docs/242-reputation-tier-badge-design.md` for the design rationale //! (soulbound vs. transferable, bespoke contract vs. SEP-41 token). //! -//! `mint_badge` / `burn_badge` are meant to be driven by `solver_registry`'s -//! tier-computation logic (issue #1) whenever a solver's tier changes. -//! Until that contract exists, both entry points are gated behind -//! `require_admin` as a placeholder authority. +//! `mint_badge` / `burn_badge` are driven by `solver_registry`'s +//! tier-computation logic (issue #1, now closed). Both entry points accept a +//! call from **either** the registered `solver_registry` contract **or** the +//! admin, so the admin retains an emergency override during the rollout period. +//! The `solver_registry` address is stored at `initialize` time and can be +//! updated (with admin auth) via `set_solver_registry`. //! //! This crate only records a `Tier` and performs no slash math, so it has no //! duplication of `vortex_common::TIER_SLASH_BPS` / `compute_slash_bps` to @@ -45,9 +47,12 @@ const PERSISTENT_TTL_EXTEND_TO: u32 = 518_400; // ~30 days at 5s/ledger #[contracttype] #[derive(Clone)] pub enum BadgeKey { - /// Admin address (set in `initialize`). Placeholder authority for - /// `mint_badge`/`burn_badge` until `solver_registry` (issue #1) exists. + /// Admin address (set in `initialize`). Admin, + /// Address of the `solver_registry` contract authorised to call + /// `mint_badge` / `burn_badge`. Set at `initialize` and updatable via + /// `set_solver_registry` (admin-only). Absent until set. + SolverRegistry, /// A solver's current tier badge, if any. Absence means `Unranked`. Badge(Address), } @@ -73,7 +78,7 @@ pub enum Tier { pub enum Error { /// `initialize` called on an already-initialized contract. AlreadyInitialized = 1, - /// Caller is not the admin. + /// Caller is neither the admin nor the registered `solver_registry`. Unauthorized = 2, /// Contract not initialized (`Admin` key absent). NotInitialized = 3, @@ -89,20 +94,53 @@ pub struct ReputationBadge; #[contractimpl] impl ReputationBadge { /// Deploy-time setup. Must be called exactly once. - pub fn initialize(env: Env, admin: Address) { + /// + /// `solver_registry` is optional at deploy time (pass `None` to defer) — + /// the admin can wire it later with [`set_solver_registry`]. This keeps + /// the upgrade path safe: you can deploy the badge contract before the + /// registry is live and wire them up atomically afterwards. + pub fn initialize(env: Env, admin: Address, solver_registry: Option
) { if env.storage().instance().has(&BadgeKey::Admin) { panic_with_error!(&env, Error::AlreadyInitialized); } admin.require_auth(); env.storage().instance().set(&BadgeKey::Admin, &admin); + if let Some(registry) = solver_registry { + env.storage() + .instance() + .set(&BadgeKey::SolverRegistry, ®istry); + } Self::bump_instance_ttl(&env); } - /// Mint (or overwrite, on a tier change) `solver`'s badge to `tier`. - /// Overwriting in place means a tier upgrade or downgrade never leaves - /// a stale old-tier badge for a caller to mistakenly read. - pub fn mint_badge(env: Env, solver: Address, tier: Tier) { + /// Admin-only: update (or set for the first time) the `solver_registry` + /// address that is permitted to call `mint_badge` / `burn_badge`. + pub fn set_solver_registry(env: Env, solver_registry: Address) { Self::require_admin(&env); + env.storage() + .instance() + .set(&BadgeKey::SolverRegistry, &solver_registry); + Self::bump_instance_ttl(&env); + env.events().publish( + (Symbol::new(&env, "solver_registry_set"),), + solver_registry, + ); + } + + /// Return the registered `solver_registry` address, if any. + pub fn get_solver_registry(env: Env) -> Option
{ + env.storage().instance().get(&BadgeKey::SolverRegistry) + } + + /// Mint (or overwrite, on a tier change) `solver`'s badge to `tier`. + /// + /// Caller must be **either** the admin **or** the registered + /// `solver_registry` contract. `solver_registry` calls this whenever its + /// tier-computation logic detects that a solver's tier has changed (issue + /// #512). Overwriting in place means a tier upgrade or downgrade never + /// leaves a stale old-tier badge for a caller to mistakenly read. + pub fn mint_badge(env: Env, caller: Address, solver: Address, tier: Tier) { + Self::require_admin_or_registry(&env, &caller); let key = BadgeKey::Badge(solver.clone()); env.storage().persistent().set(&key, &tier); Self::bump_persistent_ttl(&env, &key); @@ -112,8 +150,11 @@ impl ReputationBadge { /// Burn `solver`'s badge (used when a solver drops below the `Bronze` /// threshold to `Unranked`). Errors if the solver has no badge. - pub fn burn_badge(env: Env, solver: Address) { - Self::require_admin(&env); + /// + /// Caller must be **either** the admin **or** the registered + /// `solver_registry` contract (issue #512). + pub fn burn_badge(env: Env, caller: Address, solver: Address) { + Self::require_admin_or_registry(&env, &caller); if !env .storage() .persistent() @@ -133,6 +174,9 @@ impl ReputationBadge { env.storage().persistent().get(&BadgeKey::Badge(solver)) } + // ─── Auth helpers ────────────────────────────────────────────────────── + + /// Require that the caller is the admin. fn require_admin(env: &Env) { let admin: Address = env .storage() @@ -142,6 +186,38 @@ impl ReputationBadge { admin.require_auth(); } + /// Require that `caller` is **either** the admin **or** the registered + /// `solver_registry` contract, and call `require_auth` on whichever + /// matches. If neither matches, panics with `Unauthorized`. + fn require_admin_or_registry(env: &Env, caller: &Address) { + let admin: Address = env + .storage() + .instance() + .get(&BadgeKey::Admin) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + + let registry_opt: Option
= env + .storage() + .instance() + .get(&BadgeKey::SolverRegistry); + + let is_admin = *caller == admin; + let is_registry = registry_opt + .as_ref() + .map(|r| r == caller) + .unwrap_or(false); + + if !is_admin && !is_registry { + panic_with_error!(env, Error::Unauthorized); + } + + // require_auth on the matched identity (Soroban validates the invoker + // authorization tree against this address). + caller.require_auth(); + } + + // ─── TTL helpers ────────────────────────────────────────────────────── + /// Extend the instance storage TTL if it has fallen below the threshold. fn bump_instance_ttl(env: &Env) { env.storage() diff --git a/reputation_badge/src/test.rs b/reputation_badge/src/test.rs index 8981fdf..e3b24d2 100644 --- a/reputation_badge/src/test.rs +++ b/reputation_badge/src/test.rs @@ -1,14 +1,15 @@ #![cfg(test)] -//! Integration tests for the `ReputationBadge` prototype (issue #242). +//! Integration tests for the `ReputationBadge` contract (issue #242, #512). //! -//! Covers the three scenarios called out in the issue's Definition of Done: -//! mint-on-promotion, burn-on-demotion, and a query view proving badge -//! state always matches the last minted/burned tier. +//! Covers: +//! - mint-on-promotion and burn-on-demotion driven by admin (backward-compat) +//! - mint / burn driven by a registered `solver_registry` address (issue #512) +//! - `set_solver_registry` wiring and event emission +//! - query view proving badge state always matches the last minted/burned tier //! -//! Mutation-test hardening (issue #417): the tests below add targeted -//! assertions on auth checks, tier comparisons, and storage arithmetic so -//! surviving mutants in `ReputationBadge` are killed. +//! Mutation-test hardening (issue #417): targeted assertions on auth checks, +//! tier comparisons, and storage arithmetic so surviving mutants are killed. use crate::{ReputationBadge, ReputationBadgeClient, Tier}; use soroban_sdk::{testutils::Address as _, Address, Env}; @@ -25,6 +26,7 @@ impl Ctx { } } +/// Set up a badge contract with no solver_registry pre-wired. fn setup() -> Ctx { let env = Env::default(); env.mock_all_auths(); @@ -37,10 +39,31 @@ fn setup() -> Ctx { admin, contract_id, }; - ctx.client().initialize(&ctx.admin); + ctx.client().initialize(&ctx.admin, &None); ctx } +/// Set up a badge contract with `solver_registry` pre-wired. +fn setup_with_registry() -> (Ctx, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let registry = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationBadge); + + let ctx = Ctx { + env, + admin, + contract_id, + }; + ctx.client() + .initialize(&ctx.admin, &Some(registry.clone())); + (ctx, registry) +} + +// ── Basic admin-driven flow ─────────────────────────────────────────────── + #[test] fn mint_on_promotion_sets_badge() { let ctx = setup(); @@ -48,11 +71,11 @@ fn mint_on_promotion_sets_badge() { assert_eq!(ctx.client().get_badge(&solver), None); - ctx.client().mint_badge(&solver, &Tier::Bronze); + ctx.client().mint_badge(&ctx.admin, &solver, &Tier::Bronze); assert_eq!(ctx.client().get_badge(&solver), Some(Tier::Bronze)); // A further promotion overwrites in place rather than stacking badges. - ctx.client().mint_badge(&solver, &Tier::Gold); + ctx.client().mint_badge(&ctx.admin, &solver, &Tier::Gold); assert_eq!(ctx.client().get_badge(&solver), Some(Tier::Gold)); } @@ -61,10 +84,10 @@ fn burn_on_demotion_clears_badge() { let ctx = setup(); let solver = Address::generate(&ctx.env); - ctx.client().mint_badge(&solver, &Tier::Silver); + ctx.client().mint_badge(&ctx.admin, &solver, &Tier::Silver); assert_eq!(ctx.client().get_badge(&solver), Some(Tier::Silver)); - ctx.client().burn_badge(&solver); + ctx.client().burn_badge(&ctx.admin, &solver); assert_eq!(ctx.client().get_badge(&solver), None); } @@ -74,10 +97,11 @@ fn get_badge_matches_current_tier_for_unrelated_solvers() { let solver_a = Address::generate(&ctx.env); let solver_b = Address::generate(&ctx.env); - ctx.client().mint_badge(&solver_a, &Tier::Platinum); + ctx.client() + .mint_badge(&ctx.admin, &solver_a, &Tier::Platinum); - // solver_b never received a badge, and minting for solver_a must not - // leak state into solver_b's record. + // solver_b never received a badge; minting for solver_a must not leak + // state into solver_b's record. assert_eq!(ctx.client().get_badge(&solver_a), Some(Tier::Platinum)); assert_eq!(ctx.client().get_badge(&solver_b), None); } @@ -87,10 +111,82 @@ fn get_badge_matches_current_tier_for_unrelated_solvers() { fn burn_badge_without_existing_badge_panics() { let ctx = setup(); let solver = Address::generate(&ctx.env); - ctx.client().burn_badge(&solver); + ctx.client().burn_badge(&ctx.admin, &solver); +} + +// ── solver_registry-driven flow (issue #512) ───────────────────────────── + +#[test] +fn registry_can_mint_badge() { + let (ctx, registry) = setup_with_registry(); + let solver = Address::generate(&ctx.env); + + ctx.client().mint_badge(®istry, &solver, &Tier::Silver); + assert_eq!(ctx.client().get_badge(&solver), Some(Tier::Silver)); +} + +#[test] +fn registry_can_burn_badge() { + let (ctx, registry) = setup_with_registry(); + let solver = Address::generate(&ctx.env); + + ctx.client().mint_badge(®istry, &solver, &Tier::Gold); + ctx.client().burn_badge(®istry, &solver); + assert_eq!(ctx.client().get_badge(&solver), None); +} + +#[test] +fn registry_can_upgrade_tier() { + let (ctx, registry) = setup_with_registry(); + let solver = Address::generate(&ctx.env); + + ctx.client().mint_badge(®istry, &solver, &Tier::Bronze); + ctx.client().mint_badge(®istry, &solver, &Tier::Platinum); + assert_eq!(ctx.client().get_badge(&solver), Some(Tier::Platinum)); +} + +#[test] +fn set_solver_registry_updates_allowed_caller() { + let ctx = setup(); + let solver = Address::generate(&ctx.env); + let new_registry = Address::generate(&ctx.env); + + // Wire registry after initialization. + ctx.client().set_solver_registry(&new_registry); + assert_eq!( + ctx.client().get_solver_registry(), + Some(new_registry.clone()) + ); + + // The new registry address can now mint badges. + ctx.client() + .mint_badge(&new_registry, &solver, &Tier::Bronze); + assert_eq!(ctx.client().get_badge(&solver), Some(Tier::Bronze)); +} + +#[test] +#[should_panic] +fn unknown_caller_cannot_mint() { + let (ctx, _registry) = setup_with_registry(); + let solver = Address::generate(&ctx.env); + let stranger = Address::generate(&ctx.env); + + // `stranger` is neither admin nor registered registry → must panic. + ctx.client().mint_badge(&stranger, &solver, &Tier::Bronze); +} + +#[test] +#[should_panic] +fn unknown_caller_cannot_burn() { + let (ctx, registry) = setup_with_registry(); + let solver = Address::generate(&ctx.env); + let stranger = Address::generate(&ctx.env); + + ctx.client().mint_badge(®istry, &solver, &Tier::Gold); + ctx.client().burn_badge(&stranger, &solver); } -// --- Mutation-killing tests (issue #417) --------------------------------- +// ── Mutation-killing tests (issue #417) ────────────────────────────────── #[test] fn every_tier_round_trips_through_storage() { @@ -101,39 +197,39 @@ fn every_tier_round_trips_through_storage() { let solver = Address::generate(&ctx.env); for tier in [Tier::Bronze, Tier::Silver, Tier::Gold, Tier::Platinum] { - ctx.client().mint_badge(&solver, &tier); + ctx.client().mint_badge(&ctx.admin, &solver, &tier); assert_eq!(ctx.client().get_badge(&solver), Some(tier)); } } #[test] -fn mint_requires_admin_auth() { - // Kills mutants that drop or invert the admin auth check in `mint_badge`. - let ctx = setup(); +fn mint_requires_authorised_caller() { + // Kills mutants that drop or invert the auth check in `mint_badge`. + let (ctx, registry) = setup_with_registry(); let solver = Address::generate(&ctx.env); - ctx.client().mint_badge(&solver, &Tier::Gold); + ctx.client().mint_badge(®istry, &solver, &Tier::Gold); let auths = ctx.env.auths(); assert!( - auths.iter().any(|(addr, _)| addr == &ctx.admin), - "mint_badge must require the admin's authorization" + auths.iter().any(|(addr, _)| addr == ®istry), + "mint_badge must require_auth on the caller" ); } #[test] -fn burn_requires_admin_auth() { - // Kills mutants that drop or invert the admin auth check in `burn_badge`. - let ctx = setup(); +fn burn_requires_authorised_caller() { + // Kills mutants that drop or invert the auth check in `burn_badge`. + let (ctx, registry) = setup_with_registry(); let solver = Address::generate(&ctx.env); - ctx.client().mint_badge(&solver, &Tier::Silver); - ctx.client().burn_badge(&solver); + ctx.client().mint_badge(®istry, &solver, &Tier::Silver); + ctx.client().burn_badge(®istry, &solver); let auths = ctx.env.auths(); assert!( - auths.iter().any(|(addr, _)| addr == &ctx.admin), - "burn_badge must require the admin's authorization" + auths.iter().any(|(addr, _)| addr == ®istry), + "burn_badge must require_auth on the caller" ); } @@ -144,10 +240,12 @@ fn burn_only_affects_target_solver() { let solver_a = Address::generate(&ctx.env); let solver_b = Address::generate(&ctx.env); - ctx.client().mint_badge(&solver_a, &Tier::Gold); - ctx.client().mint_badge(&solver_b, &Tier::Silver); + ctx.client() + .mint_badge(&ctx.admin, &solver_a, &Tier::Gold); + ctx.client() + .mint_badge(&ctx.admin, &solver_b, &Tier::Silver); - ctx.client().burn_badge(&solver_a); + ctx.client().burn_badge(&ctx.admin, &solver_a); assert_eq!(ctx.client().get_badge(&solver_a), None); assert_eq!(ctx.client().get_badge(&solver_b), Some(Tier::Silver)); @@ -159,10 +257,12 @@ fn re_mint_after_burn_restores_badge() { let ctx = setup(); let solver = Address::generate(&ctx.env); - ctx.client().mint_badge(&solver, &Tier::Bronze); - ctx.client().burn_badge(&solver); + ctx.client() + .mint_badge(&ctx.admin, &solver, &Tier::Bronze); + ctx.client().burn_badge(&ctx.admin, &solver); assert_eq!(ctx.client().get_badge(&solver), None); - ctx.client().mint_badge(&solver, &Tier::Platinum); + ctx.client() + .mint_badge(&ctx.admin, &solver, &Tier::Platinum); assert_eq!(ctx.client().get_badge(&solver), Some(Tier::Platinum)); }