Skip to content

Commit 1dd0328

Browse files
authored
Merge pull request #2178 from hirosystems/beta
Cut release `v8.3.0`
2 parents 52cec8a + 361c4d4 commit 1dd0328

File tree

11 files changed

+294
-11
lines changed

11 files changed

+294
-11
lines changed

.vscode/launch.json

+7
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,13 @@
460460
"TS_NODE_SKIP_IGNORE": "true"
461461
}
462462
},
463+
{
464+
"type": "node",
465+
"request": "launch",
466+
"name": "docs: openapi-generator",
467+
"runtimeArgs": ["-r", "ts-node/register/transpile-only"],
468+
"args": ["${workspaceFolder}/src/openapi-generator.ts"]
469+
},
463470
{
464471
"type": "node",
465472
"request": "launch",

client/src/socket-io/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export class StacksApiSocketClient {
7272

7373
handleSubscription(topic: Topic, subscribe = false, listener?: (...args: any[]) => void) {
7474
const subsQuery = this.socket.io.opts.query?.subscriptions as string | undefined;
75-
const subscriptions = new Set(subsQuery?.split(',') ?? []);
75+
const subscriptions = new Set(subsQuery ? subsQuery.split(',') : []);
7676
if (subscribe) {
7777
this.socket.emit('subscribe', topic, error => {
7878
if (error) console.error(`Error subscribing: ${error}`);

package-lock.json

+31-7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@
147147
"strict-event-emitter-types": "2.0.0",
148148
"tiny-secp256k1": "2.2.1",
149149
"ts-unused-exports": "7.0.3",
150+
"undici": "6.21.0",
150151
"uuid": "8.3.2",
151152
"ws": "7.5.10",
152153
"zone-file": "2.0.0-beta.3"

src/api/routes/core-node-rpc-proxy.ts

+55
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@ function getReqUrl(req: { url: string; hostname: string }): URL {
2121
return new URL(req.url, `http://${req.hostname}`);
2222
}
2323

24+
// https://github.com/stacks-network/stacks-core/blob/20d5137438c7d169ea97dd2b6a4d51b8374a4751/stackslib/src/chainstate/stacks/db/blocks.rs#L338
25+
const MINIMUM_TX_FEE_RATE_PER_BYTE = 1;
26+
27+
interface FeeEstimation {
28+
fee: number;
29+
fee_rate: number;
30+
}
31+
interface FeeEstimateResponse {
32+
cost_scalar_change_by_byte: number;
33+
estimated_cost: {
34+
read_count: number;
35+
read_length: number;
36+
runtime: number;
37+
write_count: number;
38+
write_length: number;
39+
};
40+
estimated_cost_scalar: number;
41+
estimations: [FeeEstimation, FeeEstimation, FeeEstimation];
42+
}
43+
2444
export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
2545
Record<never, never>,
2646
Server,
@@ -117,10 +137,22 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
117137
}
118138
);
119139

140+
let feeEstimationModifier: number | null = null;
141+
fastify.addHook('onReady', () => {
142+
const feeEstEnvVar = process.env['STACKS_CORE_FEE_ESTIMATION_MODIFIER'];
143+
if (feeEstEnvVar) {
144+
const parsed = parseFloat(feeEstEnvVar);
145+
if (!isNaN(parsed) && parsed > 0) {
146+
feeEstimationModifier = parsed;
147+
}
148+
}
149+
});
150+
120151
await fastify.register(fastifyHttpProxy, {
121152
upstream: `http://${stacksNodeRpcEndpoint}`,
122153
rewritePrefix: '/v2',
123154
http2: false,
155+
globalAgent: true,
124156
preValidation: async (req, reply) => {
125157
if (getReqUrl(req).pathname !== '/v2/transactions') {
126158
return;
@@ -201,6 +233,29 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
201233
const txId = responseBuffer.toString();
202234
await logTxBroadcast(txId);
203235
await reply.send(responseBuffer);
236+
} else if (
237+
getReqUrl(req).pathname === '/v2/fees/transaction' &&
238+
reply.statusCode === 200 &&
239+
feeEstimationModifier !== null
240+
) {
241+
const reqBody = req.body as {
242+
estimated_len?: number;
243+
transaction_payload: string;
244+
};
245+
// https://github.com/stacks-network/stacks-core/blob/20d5137438c7d169ea97dd2b6a4d51b8374a4751/stackslib/src/net/api/postfeerate.rs#L200-L201
246+
const txSize = Math.max(
247+
reqBody.estimated_len ?? 0,
248+
reqBody.transaction_payload.length / 2
249+
);
250+
const minFee = txSize * MINIMUM_TX_FEE_RATE_PER_BYTE;
251+
const modifier = feeEstimationModifier;
252+
const responseBuffer = await readRequestBody(response as ServerResponse);
253+
const responseJson = JSON.parse(responseBuffer.toString()) as FeeEstimateResponse;
254+
responseJson.estimations.forEach(estimation => {
255+
// max(min fee, estimate returned by node * configurable modifier)
256+
estimation.fee = Math.max(minFee, Math.round(estimation.fee * modifier));
257+
});
258+
await reply.removeHeader('content-length').send(JSON.stringify(responseJson));
204259
} else {
205260
await reply.send(response);
206261
}

src/api/routes/ws/channels/socket-io-channel.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ export class SocketIOChannel extends WebSocketChannel {
9090
io.use((socket, next) => {
9191
const subscriptions = socket.handshake.query['subscriptions'];
9292
if (subscriptions) {
93-
const topics = [...[subscriptions]].flat().flatMap(r => r.split(','));
93+
const topics = [...[subscriptions]]
94+
.flat()
95+
.flatMap(r => r.split(','))
96+
.filter(r => !!r);
9497
const invalidSubs = this.getInvalidSubscriptionTopics(topics as Topic[]);
9598
if (invalidSubs) {
9699
const error = new Error(`Invalid topic: ${invalidSubs.join(', ')}`);

src/api/schemas/responses/responses.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ import { NakamotoBlockSchema, SignerSignatureSchema } from '../entities/block';
1717
export const ErrorResponseSchema = Type.Object(
1818
{
1919
error: Type.String(),
20+
message: Type.Optional(Type.String()),
2021
},
21-
{ title: 'Error Response' }
22+
{ title: 'Error Response', additionalProperties: true }
2223
);
2324

2425
export const ServerStatusResponseSchema = Type.Object(

src/event-stream/event-server.ts

+34
Original file line numberDiff line numberDiff line change
@@ -757,17 +757,51 @@ export async function startEventServer(opts: {
757757
}
758758

759759
const bodyLimit = 1_000_000 * 500; // 500MB body limit
760+
761+
const reqLogSerializer = (req: FastifyRequest) => ({
762+
method: req.method,
763+
url: req.url,
764+
version: req.headers?.['accept-version'] as string,
765+
hostname: req.hostname,
766+
remoteAddress: req.ip,
767+
remotePort: req.socket?.remotePort,
768+
bodySize: parseInt(req.headers?.['content-length'] as string) || 'unknown',
769+
});
770+
760771
const loggerOpts: FastifyServerOptions['logger'] = {
761772
...PINO_LOGGER_CONFIG,
762773
name: 'stacks-node-event',
774+
serializers: {
775+
req: reqLogSerializer,
776+
res: reply => ({
777+
statusCode: reply.statusCode,
778+
method: reply.request?.method,
779+
url: reply.request?.url,
780+
requestBodySize: parseInt(reply.request?.headers['content-length'] as string) || 'unknown',
781+
responseBodySize: parseInt(reply.getHeader?.('content-length') as string) || 'unknown',
782+
}),
783+
},
763784
};
785+
764786
const app = Fastify({
765787
bodyLimit,
766788
trustProxy: true,
767789
logger: loggerOpts,
768790
ignoreTrailingSlash: true,
769791
});
770792

793+
app.addHook('onRequest', (req, reply, done) => {
794+
req.raw.on('close', () => {
795+
if (req.raw.aborted) {
796+
req.log.warn(
797+
reqLogSerializer(req),
798+
`Request was aborted by the client: ${req.method} ${req.url}`
799+
);
800+
}
801+
});
802+
done();
803+
});
804+
771805
const handleRawEventRequest = async (req: FastifyRequest) => {
772806
await messageHandler.handleRawEventRequest(req.url, req.body, db);
773807

src/openapi-generator.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import Fastify from 'fastify';
2-
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
2+
import { TSchema, TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
33
import FastifySwagger from '@fastify/swagger';
44
import { writeFileSync } from 'fs';
55
import { OpenApiSchemaOptions } from './api/schemas/openapi';
66
import { StacksApiRoutes } from './api/init';
7+
import { ErrorResponseSchema } from './api/schemas/responses/responses';
78

89
/**
910
* Generates `openapi.yaml` based on current Swagger definitions.
@@ -14,6 +15,16 @@ async function generateOpenApiFiles() {
1415
logger: true,
1516
}).withTypeProvider<TypeBoxTypeProvider>();
1617

18+
// If a response schema is defined but lacks a '4xx' response, add it
19+
fastify.addHook(
20+
'onRoute',
21+
(route: { schema?: { response: Record<string | number, TSchema> } }) => {
22+
if (route.schema?.response && !route.schema?.response['4xx']) {
23+
route.schema.response['4xx'] = ErrorResponseSchema;
24+
}
25+
}
26+
);
27+
1728
await fastify.register(FastifySwagger, OpenApiSchemaOptions);
1829
await fastify.register(StacksApiRoutes);
1930
await fastify.ready();

tests/api/socket-io.test.ts

+57
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
NftEvent,
2121
Transaction,
2222
} from 'client/src/types';
23+
import { Socket } from 'node:net';
2324

2425
describe('socket-io', () => {
2526
let apiServer: ApiServer;
@@ -40,6 +41,62 @@ describe('socket-io', () => {
4041
await migrate('down');
4142
});
4243

44+
test('socket-io-client > reconnect', async () => {
45+
const serverSocketConnectWaiter = waiter<Socket>();
46+
apiServer.server.once('upgrade', (_req, socket: Socket) => {
47+
serverSocketConnectWaiter.finish(socket);
48+
});
49+
50+
const client = new StacksApiSocketClient({
51+
url: `http://${apiServer.address}`,
52+
// socketOpts: { reconnection: false },
53+
});
54+
55+
const updateWaiter: Waiter<Block> = waiter();
56+
const subResult = client.subscribeBlocks(block => updateWaiter.finish(block));
57+
58+
// subscriptions should be saved in the client query obj
59+
expect(client.socket.io.opts.query).toMatchObject({ subscriptions: 'block' });
60+
61+
// wait for initial client connection
62+
await new Promise<void>(resolve => client.socket.once('connect', resolve));
63+
64+
const connectAttempt = waiter();
65+
client.socket.io.once('reconnect_attempt', attempt => {
66+
// subscriptions should be saved in the client query obj
67+
expect(client.socket.io.opts.query).toMatchObject({ subscriptions: 'block' });
68+
connectAttempt.finish();
69+
});
70+
71+
const reconnectWaiter = waiter();
72+
client.socket.io.once('reconnect', () => reconnectWaiter.finish());
73+
74+
// force kill client connection on the server to trigger reconnect
75+
const serverSocket = await serverSocketConnectWaiter;
76+
serverSocket.resetAndDestroy();
77+
78+
await connectAttempt;
79+
await reconnectWaiter;
80+
81+
// ensure client still waiting for block update
82+
expect(updateWaiter.isFinished).toBe(false);
83+
84+
const block = new TestBlockBuilder({ block_hash: '0x1234', burn_block_hash: '0x5454' })
85+
.addTx({ tx_id: '0x4321' })
86+
.build();
87+
await db.update(block);
88+
89+
const result = await updateWaiter;
90+
try {
91+
expect(result.hash).toEqual('0x1234');
92+
expect(result.burn_block_hash).toEqual('0x5454');
93+
expect(result.txs[0]).toEqual('0x4321');
94+
} finally {
95+
subResult.unsubscribe();
96+
client.socket.close();
97+
}
98+
});
99+
43100
test('socket-io-client > block updates', async () => {
44101
const client = new StacksApiSocketClient({
45102
url: `http://${apiServer.address}`,

0 commit comments

Comments
 (0)