From cc97230b637647afb13ea9b91b63079bb641f89d Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Wed, 1 Jul 2026 19:54:38 +0100 Subject: [PATCH 1/3] fix(openclaw): unwrap optional-tuple in stream/DAO lookups, add rate limit get-stream and get-dao return (optional (tuple ...)), which cvToJSON nests one level deeper than the code assumed. getStream/getDao passed result.value to their parsers instead of result.value.value, so raw.sender was undefined and every stream and workspace lookup 500'd on mainnet. Reproduced against mainnet and verified the fix returns full stream data. Also rate-limit /api (60 req/min per IP) since every endpoint proxies to Hiro read-only calls, and set trust proxy so the limiter sees real client IPs behind Railway's proxy. /health stays unthrottled for the healthcheck. --- openclaw-service/package-lock.json | 16 ++++++++++++++++ openclaw-service/package.json | 1 + openclaw-service/src/index.ts | 22 +++++++++++++++++++++- openclaw-service/src/stacks-client.ts | 7 +++++-- 4 files changed, 43 insertions(+), 3 deletions(-) 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/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..6300d66 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 { From 6b9d71dc76a1950cd5d1eac98f3fa3699d41480f Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Wed, 1 Jul 2026 19:55:28 +0100 Subject: [PATCH 2/3] fix(frontend): render stream id from API streamId field The OpenClaw API returns streamId, but the widget read data.id, so stream results showed "Stream #undefined". Read streamId with an id fallback. --- frontend/src/components/openclaw/assistant-widget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)} From 698771279e51929924f97cfcba7f0673139ddfad Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Wed, 1 Jul 2026 21:15:34 +0100 Subject: [PATCH 3/3] fix(openclaw): mainnet token balances and Nakamoto block cadence getTokenBalance hardcoded the ::mock-sbtc asset key, so on mainnet it always returned 0 for real tokens. Match the balance entry by contract-id prefix so any SIP-010 asset name resolves. Verified against a live USDA holder. Also correct the pre-Nakamoto block constants (BLOCKS_PER_DAY 144 -> 17_280, BLOCKS_PER_MONTH 4320 -> 518_400) and blocksToTimeString (10min -> 5s/block) to match the frontend and current chain cadence. --- openclaw-service/src/config.ts | 7 +++++-- openclaw-service/src/stacks-client.ts | 10 ++++++++-- openclaw-service/src/utils.ts | 4 ++-- 3 files changed, 15 insertions(+), 6 deletions(-) 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/stacks-client.ts b/openclaw-service/src/stacks-client.ts index 6300d66..32b9542 100644 --- a/openclaw-service/src/stacks-client.ts +++ b/openclaw-service/src/stacks-client.ts @@ -247,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`;