-
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`;