diff --git a/docs/rfq-demo-guide.md b/docs/rfq-demo-guide.md index 8807d87..1040ffa 100644 --- a/docs/rfq-demo-guide.md +++ b/docs/rfq-demo-guide.md @@ -8,9 +8,13 @@ Router settlement path. ## One-command demo ```sh -scripts/e2e-anvil.sh --profile buidl-like --mode rfq +scripts/demo.sh --profile buidl-like ``` +The launcher starts Anvil, deploys the selected profile, starts the RFQ backend, +starts the read-only Operator API and serves the dashboard. Open the printed URL +and press Ctrl-C to stop all local services. + The command exits non-zero on failure. A successful run proves, in order: 1. the selected asset profile is valid for the deployed stack; diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100755 index 0000000..7148272 --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT_DIR" + +PROFILE=buidl-like +MODE=rfq +ANVIL_PORT=8545 +BACKEND_PORT=8787 +OPERATOR_API_PORT=8788 +DASHBOARD_PORT=8790 + +while [ $# -gt 0 ]; do + case "$1" in + --profile) PROFILE="$2"; shift 2 ;; + --profile=*) PROFILE="${1#*=}"; shift ;; + --mode) MODE="$2"; shift 2 ;; + --mode=*) MODE="${1#*=}"; shift ;; + --port) ANVIL_PORT="$2"; shift 2 ;; + --backend-port) BACKEND_PORT="$2"; shift 2 ;; + --operator-api-port) OPERATOR_API_PORT="$2"; shift 2 ;; + --dashboard-port) DASHBOARD_PORT="$2"; shift 2 ;; + -h|--help) + echo "Usage: scripts/demo.sh [--profile buidl-like|reg-d] [--mode rfq|full]" + echo " [--port N] [--backend-port N] [--operator-api-port N] [--dashboard-port N]" + exit 0 + ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/corner-store-demo.XXXXXX") +PID_FILE="$STATE_DIR/pids" +OPERATOR_API_PID="" +DASHBOARD_PID="" + +cleanup() { + if [ -n "$DASHBOARD_PID" ] && kill -0 "$DASHBOARD_PID" 2>/dev/null; then + kill "$DASHBOARD_PID" 2>/dev/null || true + wait "$DASHBOARD_PID" 2>/dev/null || true + fi + if [ -n "$OPERATOR_API_PID" ] && kill -0 "$OPERATOR_API_PID" 2>/dev/null; then + kill "$OPERATOR_API_PID" 2>/dev/null || true + wait "$OPERATOR_API_PID" 2>/dev/null || true + fi + if [ -s "$PID_FILE" ]; then + mapfile -t DEMO_PIDS < "$PID_FILE" + for pid in "${DEMO_PIDS[@]}"; do + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + done + fi + rm -rf "$STATE_DIR" +} +trap cleanup EXIT INT TERM + +echo "==> Starting Corner Store local demo" +scripts/e2e-anvil.sh --profile "$PROFILE" --mode "$MODE" \ + --port "$ANVIL_PORT" --backend-port "$BACKEND_PORT" --pid-file "$PID_FILE" --keep + +echo "==> Starting read-only Operator API" +npm run build --prefix services/operator-api >/dev/null +CORNER_STORE_CONFIG="services/toolkit/examples/corner-store.config.json" +if [ "$PROFILE" = "reg-d" ]; then + CORNER_STORE_CONFIG="services/toolkit/examples/corner-store.reg-d.config.json" +fi +CORNER_STORE_CONFIG="$CORNER_STORE_CONFIG" \ +CORNER_STORE_ARTIFACT="deployments/anvil-e2e.json" \ +PORT="$OPERATOR_API_PORT" \ +node services/operator-api/dist/src/index.js >"$STATE_DIR/operator-api.log" 2>&1 & +OPERATOR_API_PID=$! + +echo "==> Starting dashboard" +CORNER_STORE_OPERATOR_API="http://127.0.0.1:${OPERATOR_API_PORT}" \ +PORT="$DASHBOARD_PORT" \ +npm run start --prefix services/operator-dashboard >"$STATE_DIR/dashboard.log" 2>&1 & +DASHBOARD_PID=$! + +DASHBOARD_READY=0 +for _ in $(seq 1 25); do + if curl -fsS "http://127.0.0.1:${DASHBOARD_PORT}/health" >/dev/null 2>&1; then + DASHBOARD_READY=1 + break + fi + sleep 0.2 +done +if [ "$DASHBOARD_READY" -ne 1 ]; then + cat "$STATE_DIR/dashboard.log" >&2 || true + exit 1 +fi + +echo "" +echo "Demo is ready: http://127.0.0.1:${DASHBOARD_PORT}" +echo "RFQ backend: http://127.0.0.1:${BACKEND_PORT}" +echo "Press Ctrl-C to stop all demo services." +wait "$DASHBOARD_PID" + diff --git a/scripts/e2e-anvil.sh b/scripts/e2e-anvil.sh index ce79d04..099a468 100755 --- a/scripts/e2e-anvil.sh +++ b/scripts/e2e-anvil.sh @@ -7,13 +7,14 @@ # and tears the node down on exit. Runs fully offline. # # Usage: -# scripts/e2e-anvil.sh [--port N] [--backend-port N] [--profile buidl-like|reg-d] [--mode full|rfq] [--keep] +# scripts/e2e-anvil.sh [--port N] [--backend-port N] [--profile buidl-like|reg-d] [--mode full|rfq] [--pid-file PATH] [--keep] # # --port N Anvil port (default 8545). # --backend-port N RFQ demo backend port (default 8787). # --profile Asset profile (default buidl-like; alternative reg-d). # --mode full runs the 7-scenario suite plus RFQ; rfq runs the concise # backend/CLI/Router RFQ walkthrough only (default full). +# --pid-file write Anvil and RFQ backend PIDs for a supervising launcher. # --keep Leave Anvil running after the suite (attach a UI / continue the # demo interactively). Otherwise Anvil is killed on exit. # @@ -28,6 +29,7 @@ KEEP=0 ASSET_PROFILE=buidl-like BACKEND_PORT=8787 DEMO_MODE=full +PID_FILE="" while [ $# -gt 0 ]; do case "$1" in --port) PORT="$2"; shift 2 ;; @@ -38,6 +40,8 @@ while [ $# -gt 0 ]; do --backend-port=*) BACKEND_PORT="${1#*=}"; shift ;; --mode) DEMO_MODE="$2"; shift 2 ;; --mode=*) DEMO_MODE="${1#*=}"; shift ;; + --pid-file) PID_FILE="$2"; shift 2 ;; + --pid-file=*) PID_FILE="${1#*=}"; shift ;; --keep) KEEP=1; shift ;; -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; @@ -65,6 +69,9 @@ CHECKPOINT_FILE="${TMPDIR:-/tmp}/corner-store-e2e-checkpoint.$$" cleanup() { if [ "$KEEP" -eq 1 ]; then + if [ -n "$PID_FILE" ]; then + printf '%s\n%s\n' "$ANVIL_PID" "$BACKEND_PID" > "$PID_FILE" + fi if [ -n "$ANVIL_PID" ]; then echo "" echo "==> --keep set: Anvil left running (pid ${ANVIL_PID}) on ${RPC}" @@ -180,10 +187,10 @@ DEMO_SETTLED=$(curl -fsS -X POST "http://127.0.0.1:${BACKEND_PORT}/demo/trade" \ -H "content-type: application/json" \ -d '{"amountIn":"5000000000000000000000000","action":"settle"}') node -e 'const value=JSON.parse(process.argv[1]); if (!value.transaction?.hash || BigInt(value.transaction.rwaDelta) <= 0n) process.exit(1); console.log(` PASS: dashboard trade settled in block ${value.transaction.blockNumber}`);' "$DEMO_SETTLED" -DEMO_REJECTED=$(curl -fsS -X POST "http://127.0.0.1:${BACKEND_PORT}/demo/trade" \ +DEMO_REJECTED=$(curl -sS -X POST "http://127.0.0.1:${BACKEND_PORT}/demo/trade" \ -H "content-type: application/json" \ -d '{"amountIn":"5000000000000000000000000","action":"revoked-maker"}') -node -e 'const value=JSON.parse(process.argv[1]); if (!value.rejection) process.exit(1); console.log(" PASS: dashboard maker-revocation was rejected");' "$DEMO_REJECTED" +node -e 'const value=JSON.parse(process.argv[1]); if (!value.rejection) { console.error(value); process.exit(1); } console.log(" PASS: dashboard maker-revocation was rejected");' "$DEMO_REJECTED" echo "==> Requesting and filling a backend-signed RFQ quote through the Router" "${CLI[@]}" rfq-quote --backend "http://127.0.0.1:${BACKEND_PORT}" \ diff --git a/services/operator-dashboard/README.md b/services/operator-dashboard/README.md index d88a219..cb75584 100644 --- a/services/operator-dashboard/README.md +++ b/services/operator-dashboard/README.md @@ -13,21 +13,17 @@ This dashboard has two modes: - Governance changes must be prepared as an external multisig proposal and reviewed against the deployment artifact before execution. -For the RFQ-first MVP demo: +For the RFQ-first MVP demo, use the one-command launcher: ```sh -# terminal 1 -scripts/e2e-anvil.sh --profile buidl-like --mode rfq --keep - -# terminal 2 -npm run start --prefix services/operator-dashboard +scripts/demo.sh --profile buidl-like ``` -Open `http://127.0.0.1:8790` and select a view. The dashboard never has a -private key and does not submit transactions. Start Operator API on port 8788, -or set `CORNER_STORE_OPERATOR_API` to its URL. If the API uses authentication, -set `CORNER_STORE_API_TOKEN` on the dashboard process; the token stays server -side and is never exposed to the browser. +Open the printed URL and select a view. Press Ctrl-C to stop all local services. +For manual composition, start Operator API on port 8788 or set +`CORNER_STORE_OPERATOR_API` to its URL. If the API uses authentication, set +`CORNER_STORE_API_TOKEN` on the dashboard process; the token stays server side +and is never exposed to the browser. Production hosting, authentication, CSRF policy and multisig provider integration remain deployment-specific work. diff --git a/services/operator-dashboard/test/smoke.js b/services/operator-dashboard/test/smoke.js index e9a2d73..be1020d 100644 --- a/services/operator-dashboard/test/smoke.js +++ b/services/operator-dashboard/test/smoke.js @@ -9,4 +9,8 @@ const server = fs.readFileSync(path.join(__dirname, "..", "server.js"), "utf8"); for (const marker of ["CORNER_STORE_OPERATOR_API", "CORNER_STORE_API_TOKEN", "proxyOperatorApi", "operator_api_unavailable"]) { if (!server.includes(marker)) throw new Error(`dashboard proxy marker missing: ${marker}`); } +const launcher = fs.readFileSync(path.join(__dirname, "..", "..", "..", "scripts", "demo.sh"), "utf8"); +for (const marker of ["scripts/e2e-anvil.sh", "CORNER_STORE_ARTIFACT", "Press Ctrl-C to stop all demo services"]) { + if (!launcher.includes(marker)) throw new Error(`demo launcher marker missing: ${marker}`); +} console.log("corner-store operator dashboard smoke ok"); diff --git a/services/rfq-demo-backend/src/settlement.ts b/services/rfq-demo-backend/src/settlement.ts index b01dc60..f110412 100644 --- a/services/rfq-demo-backend/src/settlement.ts +++ b/services/rfq-demo-backend/src/settlement.ts @@ -29,9 +29,10 @@ export interface DemoTradeResult { export class DemoSettlementService { private readonly provider: JsonRpcProvider; private readonly investor: NonceManager; - private readonly operator: NonceManager; + private readonly operator: HDNodeWallet; private readonly investorAddress: string; private nextRouterNonce = BigInt(Date.now()); + private operatorNonce?: number; constructor(private readonly config: DemoBackendConfig, private readonly quotes: RFQBackendSDK) { if (!config.demoSettlement.enabled) throw new Error("demo settlement is disabled"); @@ -49,7 +50,7 @@ export class DemoSettlementService { this.provider = new JsonRpcProvider(config.rpcUrl, config.chainId); this.investor = new NonceManager(investor.connect(this.provider)); - this.operator = new NonceManager(operator.connect(this.provider)); + this.operator = operator.connect(this.provider); } async trade(amountIn: string, action: DemoTradeAction): Promise { @@ -122,9 +123,19 @@ export class DemoSettlementService { } private async setMakerApproval(approved: boolean): Promise { + // The operator account also performed deployment/onboarding transactions. + // Refresh before each policy toggle so a revoke/finally-restore pair cannot + // reuse a stale cached nonce in the long-lived demo backend. + const nonce = this.operatorNonce ?? await this.operator.getNonce("pending"); + this.operatorNonce = nonce + 1; const adapter = new Contract(this.config.artifact.rfqAdapter, RFQ_ADAPTER_ABI, this.operator); - const tx = await adapter.setMakerApproved(this.config.artifact.maker, approved); - await tx.wait(); + try { + const tx = await adapter.setMakerApproved(this.config.artifact.maker, approved, {nonce}); + await tx.wait(); + } catch (error) { + this.operatorNonce = undefined; + throw error; + } } }