-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.ts
More file actions
325 lines (291 loc) · 11.3 KB
/
client.ts
File metadata and controls
325 lines (291 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
import { SigningStargateClient, GasPrice, HttpEndpoint } from '@cosmjs/stargate';
import {
cosmosProtoRegistry,
cosmosAminoConverters,
liftedinitProtoRegistry,
liftedinitAminoConverters,
strangeloveVenturesProtoRegistry,
strangeloveVenturesAminoConverters,
osmosisProtoRegistry,
osmosisAminoConverters,
liftedinit,
} from '@manifest-network/manifestjs';
import { Registry } from '@cosmjs/proto-signing';
import { AminoTypes } from '@cosmjs/stargate';
import { RateLimiter } from 'limiter';
import { ManifestMCPConfig, WalletProvider, ManifestMCPError, ManifestMCPErrorCode } from './types.js';
import { DEFAULT_REQUESTS_PER_SECOND } from './config.js';
import { withRetry } from './retry.js';
// Type for the RPC query client from manifestjs liftedinit bundle
// This includes cosmos modules + liftedinit-specific modules (billing, manifest, sku)
export type ManifestQueryClient = Awaited<ReturnType<typeof liftedinit.ClientFactory.createRPCQueryClient>>;
/**
* Extract the registry type expected by SigningStargateClient.connectWithSigner.
*
* The Registry type from @cosmjs/proto-signing doesn't perfectly match the registry type
* in SigningStargateClientOptions due to telescope-generated proto types. This type alias
* extracts the expected registry type from the function signature to enable type-safe casting.
*/
type SigningClientRegistry = Parameters<typeof SigningStargateClient.connectWithSigner>[2] extends { registry?: infer R } ? R : never;
/** Default timeout for transaction broadcast (60 seconds) */
const DEFAULT_BROADCAST_TIMEOUT_MS = 60_000;
/** Default polling interval for transaction confirmation (3 seconds) */
const DEFAULT_BROADCAST_POLL_INTERVAL_MS = 3_000;
/**
* Get combined signing client options with all Manifest registries
*/
function getSigningManifestClientOptions() {
const registry = new Registry([
...cosmosProtoRegistry,
...liftedinitProtoRegistry,
...strangeloveVenturesProtoRegistry,
...osmosisProtoRegistry,
]);
const aminoTypes = new AminoTypes({
...cosmosAminoConverters,
...liftedinitAminoConverters,
...strangeloveVenturesAminoConverters,
...osmosisAminoConverters,
});
return { registry, aminoTypes };
}
/**
* Manages CosmJS client instances with lazy initialization and singleton pattern
*/
export class CosmosClientManager {
private static instances: Map<string, CosmosClientManager> = new Map();
private config: ManifestMCPConfig;
private walletProvider: WalletProvider;
private queryClient: ManifestQueryClient | null = null;
private signingClient: SigningStargateClient | null = null;
private rateLimiter: RateLimiter;
// Promises to prevent concurrent client initialization (lazy init race condition)
private queryClientPromise: Promise<ManifestQueryClient> | null = null;
private signingClientPromise: Promise<SigningStargateClient> | null = null;
private constructor(config: ManifestMCPConfig, walletProvider: WalletProvider) {
this.config = config;
this.walletProvider = walletProvider;
// Initialize rate limiter with configured or default requests per second
const requestsPerSecond = config.rateLimit?.requestsPerSecond ?? DEFAULT_REQUESTS_PER_SECOND;
this.rateLimiter = new RateLimiter({
tokensPerInterval: requestsPerSecond,
interval: 'second',
});
}
/**
* Get or create a singleton instance for the given config.
* Instances are keyed by chainId:rpcUrl. For existing instances:
* - Config and walletProvider references are always updated
* - Signing client is disconnected/recreated if gasPrice or walletProvider changed
* - Rate limiter is updated if requestsPerSecond changed (without affecting signing client)
*/
static getInstance(
config: ManifestMCPConfig,
walletProvider: WalletProvider
): CosmosClientManager {
const key = `${config.chainId}:${config.rpcUrl}`;
let instance = CosmosClientManager.instances.get(key);
if (!instance) {
instance = new CosmosClientManager(config, walletProvider);
CosmosClientManager.instances.set(key, instance);
} else {
// Check what changed to determine what needs updating
const signingClientAffected =
instance.config.gasPrice !== config.gasPrice ||
instance.walletProvider !== walletProvider;
const rateLimitChanged =
instance.config.rateLimit?.requestsPerSecond !== config.rateLimit?.requestsPerSecond;
// Always update config reference
instance.config = config;
instance.walletProvider = walletProvider;
// Only invalidate signing client if fields it depends on changed
if (signingClientAffected) {
if (instance.signingClient) {
instance.signingClient.disconnect();
instance.signingClient = null;
}
// Also clear the promise to allow re-initialization with new config
instance.signingClientPromise = null;
}
// Update rate limiter independently (doesn't affect signing client)
if (rateLimitChanged) {
const newRps = config.rateLimit?.requestsPerSecond ?? DEFAULT_REQUESTS_PER_SECOND;
instance.rateLimiter = new RateLimiter({
tokensPerInterval: newRps,
interval: 'second',
});
}
}
return instance;
}
/**
* Clear all cached instances (useful for testing or reconnection).
* Disconnects signing clients and releases query client references before clearing.
*/
static clearInstances(): void {
for (const instance of CosmosClientManager.instances.values()) {
instance.disconnect();
}
CosmosClientManager.instances.clear();
}
/**
* Get the manifestjs RPC query client with all module extensions
*
* Automatically retries on transient connection failures with exponential backoff.
*/
async getQueryClient(): Promise<ManifestQueryClient> {
// Return cached client if available
if (this.queryClient) {
return this.queryClient;
}
// If initialization is already in progress, wait for it
if (this.queryClientPromise) {
return this.queryClientPromise;
}
// Start initialization and cache the promise to prevent concurrent init
this.queryClientPromise = (async () => {
// Capture reference to detect if superseded by disconnect/config change
const thisInitPromise = this.queryClientPromise;
try {
// Use liftedinit ClientFactory which includes cosmos + liftedinit modules
// Wrap with retry for transient connection failures
const client = await withRetry(
() => liftedinit.ClientFactory.createRPCQueryClient({
rpcEndpoint: this.config.rpcUrl,
}),
{
config: this.config.retry,
operationName: 'connect query client',
}
);
// Only store if this is still the active promise
if (this.queryClientPromise === thisInitPromise) {
this.queryClient = client;
this.queryClientPromise = null;
}
return client;
} catch (error) {
// Clear promise on failure so retry is possible (only if still active)
if (this.queryClientPromise === thisInitPromise) {
this.queryClientPromise = null;
}
if (error instanceof ManifestMCPError) {
throw error;
}
throw new ManifestMCPError(
ManifestMCPErrorCode.RPC_CONNECTION_FAILED,
`Failed to connect to RPC endpoint: ${error instanceof Error ? error.message : String(error)}`,
{ rpcUrl: this.config.rpcUrl }
);
}
})();
return this.queryClientPromise;
}
/**
* Get a signing client with all Manifest registries (for transactions)
*
* Automatically retries on transient connection failures with exponential backoff.
*/
async getSigningClient(): Promise<SigningStargateClient> {
// Return cached client if available
if (this.signingClient) {
return this.signingClient;
}
// If initialization is already in progress, wait for it
if (this.signingClientPromise) {
return this.signingClientPromise;
}
// Start initialization and cache the promise to prevent concurrent init
this.signingClientPromise = (async () => {
// Capture reference to detect if superseded by disconnect/config change
const thisInitPromise = this.signingClientPromise;
try {
const signer = await this.walletProvider.getSigner();
const gasPrice = GasPrice.fromString(this.config.gasPrice);
const { registry, aminoTypes } = getSigningManifestClientOptions();
// Configure endpoint with HTTP timeout
const endpoint: HttpEndpoint = {
url: this.config.rpcUrl,
headers: {},
};
// Note: Registry type from @cosmjs/proto-signing doesn't perfectly match
// SigningStargateClientOptions due to telescope-generated proto types.
// This is a known limitation with custom cosmos-sdk module registries.
// Wrap with retry for transient connection failures
const client = await withRetry(
() => SigningStargateClient.connectWithSigner(
endpoint,
signer,
{
registry: registry as SigningClientRegistry,
aminoTypes,
gasPrice,
broadcastTimeoutMs: DEFAULT_BROADCAST_TIMEOUT_MS,
broadcastPollIntervalMs: DEFAULT_BROADCAST_POLL_INTERVAL_MS,
}
),
{
config: this.config.retry,
operationName: 'connect signing client',
}
);
// Only store if this is still the active promise
if (this.signingClientPromise === thisInitPromise) {
this.signingClient = client;
this.signingClientPromise = null;
} else {
// Promise was superseded, clean up the client we just created
client.disconnect();
}
return client;
} catch (error) {
// Clear promise on failure so retry is possible (only if still active)
if (this.signingClientPromise === thisInitPromise) {
this.signingClientPromise = null;
}
if (error instanceof ManifestMCPError) {
throw error;
}
throw new ManifestMCPError(
ManifestMCPErrorCode.RPC_CONNECTION_FAILED,
`Failed to connect signing client: ${error instanceof Error ? error.message : String(error)}`,
{ rpcUrl: this.config.rpcUrl }
);
}
})();
return this.signingClientPromise;
}
/**
* Get the wallet address
*/
async getAddress(): Promise<string> {
return this.walletProvider.getAddress();
}
/**
* Get the configuration
*/
getConfig(): ManifestMCPConfig {
return this.config;
}
/**
* Acquire a rate limit token before making an RPC request.
* This will wait if the rate limit has been exceeded.
*/
async acquireRateLimit(): Promise<void> {
await this.rateLimiter.removeTokens(1);
}
/**
* Disconnect the signing client and release query client references.
* The query client's underlying HTTP transport is stateless and does not
* require an explicit disconnect.
*/
disconnect(): void {
if (this.signingClient) {
this.signingClient.disconnect();
this.signingClient = null;
}
this.signingClientPromise = null;
this.queryClient = null;
this.queryClientPromise = null;
}
}