diff --git a/frontend/src/components/openclaw/assistant-widget.tsx b/frontend/src/components/openclaw/assistant-widget.tsx index 6a1122c..d04623b 100644 --- a/frontend/src/components/openclaw/assistant-widget.tsx +++ b/frontend/src/components/openclaw/assistant-widget.tsx @@ -29,7 +29,7 @@ function StreamResult({ data }: { data: any }) { return (
- Stream #{data.id} + Stream #{data.streamId ?? data.id} {data.status?.label ?? getStreamStatusLabel(data.status)} diff --git a/openclaw-service/package-lock.json b/openclaw-service/package-lock.json index 0232d24..0c0e7d4 100644 --- a/openclaw-service/package-lock.json +++ b/openclaw-service/package-lock.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "express-rate-limit": "^7.5.1", "zod": "^3.24.2" }, "devDependencies": { @@ -990,6 +991,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", diff --git a/openclaw-service/package.json b/openclaw-service/package.json index 66953d9..a0bcb1d 100644 --- a/openclaw-service/package.json +++ b/openclaw-service/package.json @@ -13,6 +13,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "express-rate-limit": "^7.5.1", "zod": "^3.24.2" }, "devDependencies": { diff --git a/openclaw-service/src/config.ts b/openclaw-service/src/config.ts index 58a7a08..6636776 100644 --- a/openclaw-service/src/config.ts +++ b/openclaw-service/src/config.ts @@ -27,6 +27,9 @@ export const STREAM_STATUS = { DEPLETED: 3, } as const; -export const BLOCKS_PER_DAY = 144; -export const BLOCKS_PER_MONTH = 4320; +// Nakamoto (Epoch 3.0+) cadence: Stacks blocks advance ~every 5s, so a day is +// 17_280 blocks, not the pre-Nakamoto 144. Kept in sync with the frontend. +export const BLOCK_TIME_SECONDS = 5; +export const BLOCKS_PER_DAY = 17_280; +export const BLOCKS_PER_MONTH = 518_400; export const MAX_STREAMS_PER_USER = 100; diff --git a/openclaw-service/src/index.ts b/openclaw-service/src/index.ts index 1b9e38c..dc38e85 100644 --- a/openclaw-service/src/index.ts +++ b/openclaw-service/src/index.ts @@ -1,5 +1,6 @@ import express from "express"; import cors from "cors"; +import rateLimit from "express-rate-limit"; import { PORT } from "./config"; import { bigIntReplacer } from "./utils"; import { errorHandler } from "./middleware/error-handler"; @@ -12,17 +13,36 @@ import transactionsRouter from "./routes/transactions"; const app = express(); +// Railway terminates TLS at a proxy in front of us, so trust the first hop. +// Without this, express-rate-limit can't read the real client IP from +// X-Forwarded-For and would bucket every request under the proxy's address. +app.set("trust proxy", 1); + // BigInt JSON serialization app.set("json replacer", bigIntReplacer); app.use(cors()); app.use(express.json()); -// Health check +// Health check — kept outside the rate limiter so Railway's probe is never throttled app.get("/health", (_req, res) => { res.json({ status: "ok", service: "stackstream-openclaw" }); }); +// Rate limit the API. Every endpoint proxies to Hiro read-only calls, so an +// unthrottled client could burn our upstream quota. 60 requests/min per IP is +// comfortably above normal widget usage while capping abuse. +app.use( + "/api", + rateLimit({ + windowMs: 60 * 1000, + limit: 60, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many requests, please slow down." }, + }) +); + // Routes app.use("/api/streams", streamsRouter); app.use("/api/daos", daosRouter); diff --git a/openclaw-service/src/stacks-client.ts b/openclaw-service/src/stacks-client.ts index 64d13b5..32b9542 100644 --- a/openclaw-service/src/stacks-client.ts +++ b/openclaw-service/src/stacks-client.ts @@ -102,7 +102,9 @@ export async function getStream(streamId: number): Promise { [uintCV(streamId)] ); if (result.value === null) return null; - return parseStreamData(result.value); + // cvToJSON nests an (optional (tuple ...)) as { value: { value: {fields} } } + // — the tuple fields live one level below the optional's unwrapped value. + return parseStreamData(result.value.value); } export async function getStreamStatus(streamId: number): Promise { @@ -214,7 +216,8 @@ export async function getDao(admin: string): Promise { [principalCV(admin)] ); if (result.value === null) return null; - return parseDaoData(result.value); + // Same optional-of-tuple nesting as get-stream (see getStream above). + return parseDaoData(result.value.value); } export async function getDaoCount(): Promise { @@ -244,8 +247,14 @@ export async function getTokenBalance( ); const data: any = await res.json(); const ftBalances = data.fungible_tokens || {}; - const key = `${tokenContract}::mock-sbtc`; - return BigInt(ftBalances[key]?.balance ?? "0"); + // Balances are keyed by `::`, and the asset name + // varies per token (sbtc, usda, wrapped-bitcoin, ...). Match by contract + // prefix so this works for any SIP-010 token, not just the testnet mock. + const entry = Object.entries(ftBalances).find(([k]) => + k.startsWith(`${tokenContract}::`) + ); + const balance = (entry?.[1] as { balance?: string } | undefined)?.balance; + return BigInt(balance ?? "0"); } // ============================================================================ diff --git a/openclaw-service/src/utils.ts b/openclaw-service/src/utils.ts index 4da5d94..e219944 100644 --- a/openclaw-service/src/utils.ts +++ b/openclaw-service/src/utils.ts @@ -30,9 +30,9 @@ export function getStreamStatusLabel(status: number): string { } } -/** Estimate time remaining from blocks (Stacks ~10 min/block) */ +/** Estimate time remaining from blocks (Nakamoto ~5s/block) */ export function blocksToTimeString(blocks: number): string { - const minutes = blocks * 10; + const minutes = Math.round((blocks * 5) / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ${minutes % 60}m`;