@solana/keychain-fireblocks 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,12 @@
1
1
  import { Address, assertIsAddress } from '@solana/addresses';
2
- import { getBase58Encoder } from '@solana/codecs-strings';
3
- import { createSignatureDictionary, SignerErrorCode, SolanaSigner, throwSignerError } from '@solana/keychain-core';
2
+ import { getBase16Decoder, getBase16Encoder, getBase58Encoder } from '@solana/codecs-strings';
3
+ import {
4
+ assertSignatureValid,
5
+ createSignatureDictionary,
6
+ SignerErrorCode,
7
+ SolanaSigner,
8
+ throwSignerError,
9
+ } from '@solana/keychain-core';
4
10
  import { SignatureBytes } from '@solana/keys';
5
11
  import { SignableMessage, SignatureDictionary } from '@solana/signers';
6
12
  import {
@@ -18,7 +24,16 @@ import type {
18
24
  TransactionResponse,
19
25
  VaultAddressesResponse,
20
26
  } from './types.js';
21
- import { FireblocksTransactionStatus, TERMINAL_STATUSES } from './types.js';
27
+ import { FireblocksTransactionStatus, isTerminalStatus } from './types.js';
28
+
29
+ export async function createFireblocksSigner<TAddress extends string = string>(
30
+ config: FireblocksSignerConfig,
31
+ ): Promise<SolanaSigner<TAddress>> {
32
+ return await FireblocksSigner.create(config);
33
+ }
34
+
35
+ let base16Encoder: ReturnType<typeof getBase16Encoder> | undefined;
36
+ let base16Decoder: ReturnType<typeof getBase16Decoder> | undefined;
22
37
 
23
38
  const DEFAULT_API_BASE_URL = 'https://api.fireblocks.io';
24
39
  const DEFAULT_ASSET_ID = 'SOL';
@@ -33,13 +48,14 @@ const DEFAULT_MAX_POLL_ATTEMPTS = 60;
33
48
  *
34
49
  * @example
35
50
  * ```typescript
36
- * const signer = new FireblocksSigner({
51
+ * const signer = await FireblocksSigner.create({
37
52
  * apiKey: 'your-api-key',
38
53
  * privateKeyPem: '-----BEGIN PRIVATE KEY-----\n...',
39
54
  * vaultAccountId: '0',
40
55
  * });
41
- * await signer.init();
42
56
  * ```
57
+ *
58
+ * @deprecated Prefer `createFireblocksSigner()`. Class export will be removed in a future version.
43
59
  */
44
60
  export class FireblocksSigner<TAddress extends string = string> implements SolanaSigner<TAddress> {
45
61
  private _address: Address<TAddress> | null = null;
@@ -54,6 +70,21 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
54
70
  private readonly useProgramCall: boolean;
55
71
  private initialized = false;
56
72
 
73
+ /**
74
+ * Fetches the public key from Fireblocks API during initialization.
75
+ * @deprecated Use `createFireblocksSigner()` instead.
76
+ */
77
+ static async create<TAddress extends string = string>(
78
+ config: FireblocksSignerConfig,
79
+ ): Promise<FireblocksSigner<TAddress>> {
80
+ const signer = new FireblocksSigner<TAddress>(config);
81
+ await signer.init();
82
+ return signer;
83
+ }
84
+
85
+ /**
86
+ * @deprecated Use `createFireblocksSigner()` instead. Direct construction will be removed in a future version.
87
+ */
57
88
  constructor(config: FireblocksSignerConfig) {
58
89
  if (!config.apiKey) {
59
90
  throwSignerError(SignerErrorCode.CONFIG_ERROR, {
@@ -101,6 +132,7 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
101
132
 
102
133
  /**
103
134
  * Initialize the signer by fetching the public key from Fireblocks
135
+ * @deprecated Use `createFireblocksSigner()` instead, which handles initialization automatically.
104
136
  */
105
137
  async init(): Promise<void> {
106
138
  if (this.initialized) {
@@ -145,13 +177,22 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
145
177
  const token = await createJwt(this.apiKey, this.privateKeyPem, uri, '');
146
178
 
147
179
  const url = `${this.apiBaseUrl}${uri}`;
148
- const response = await fetch(url, {
149
- headers: {
150
- Authorization: `Bearer ${token}`,
151
- 'X-API-Key': this.apiKey,
152
- },
153
- method: 'GET',
154
- });
180
+ let response: Response;
181
+ try {
182
+ response = await fetch(url, {
183
+ headers: {
184
+ Authorization: `Bearer ${token}`,
185
+ 'X-API-Key': this.apiKey,
186
+ },
187
+ method: 'GET',
188
+ });
189
+ } catch (error) {
190
+ throwSignerError(SignerErrorCode.HTTP_ERROR, {
191
+ cause: error,
192
+ message: 'Fireblocks network request failed',
193
+ url,
194
+ });
195
+ }
155
196
 
156
197
  if (!response.ok) {
157
198
  const errorText = await response.text().catch(() => 'Failed to read error response');
@@ -165,8 +206,9 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
165
206
  let addressesResponse: VaultAddressesResponse;
166
207
  try {
167
208
  addressesResponse = (await response.json()) as VaultAddressesResponse;
168
- } catch {
209
+ } catch (error) {
169
210
  throwSignerError(SignerErrorCode.PARSING_ERROR, {
211
+ cause: error,
170
212
  message: 'Failed to parse Fireblocks response',
171
213
  });
172
214
  }
@@ -181,8 +223,9 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
181
223
  try {
182
224
  assertIsAddress(firstAddress);
183
225
  return firstAddress as Address;
184
- } catch {
226
+ } catch (error) {
185
227
  throwSignerError(SignerErrorCode.INVALID_PUBLIC_KEY, {
228
+ cause: error,
186
229
  message: 'Invalid address from Fireblocks',
187
230
  });
188
231
  }
@@ -196,15 +239,24 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
196
239
  const token = await createJwt(this.apiKey, this.privateKeyPem, uri, bodyStr);
197
240
 
198
241
  const url = `${this.apiBaseUrl}${uri}`;
199
- const response = await fetch(url, {
200
- body: body ? bodyStr : undefined,
201
- headers: {
202
- Authorization: `Bearer ${token}`,
203
- 'Content-Type': 'application/json',
204
- 'X-API-Key': this.apiKey,
205
- },
206
- method,
207
- });
242
+ let response: Response;
243
+ try {
244
+ response = await fetch(url, {
245
+ body: body ? bodyStr : undefined,
246
+ headers: {
247
+ Authorization: `Bearer ${token}`,
248
+ 'Content-Type': 'application/json',
249
+ 'X-API-Key': this.apiKey,
250
+ },
251
+ method,
252
+ });
253
+ } catch (error) {
254
+ throwSignerError(SignerErrorCode.HTTP_ERROR, {
255
+ cause: error,
256
+ message: 'Fireblocks network request failed',
257
+ url,
258
+ });
259
+ }
208
260
 
209
261
  if (!response.ok) {
210
262
  const errorText = await response.text().catch(() => 'Failed to read error response');
@@ -217,8 +269,9 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
217
269
 
218
270
  try {
219
271
  return (await response.json()) as T;
220
- } catch {
272
+ } catch (error) {
221
273
  throwSignerError(SignerErrorCode.PARSING_ERROR, {
274
+ cause: error,
222
275
  message: 'Failed to parse Fireblocks response',
223
276
  });
224
277
  }
@@ -228,7 +281,8 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
228
281
  * Sign raw bytes using Fireblocks RAW operation
229
282
  */
230
283
  private async signRawBytes(messageBytes: Uint8Array): Promise<SignatureBytes> {
231
- const hexContent = bytesToHex(messageBytes);
284
+ base16Decoder ||= getBase16Decoder();
285
+ const hexContent = base16Decoder.decode(messageBytes);
232
286
 
233
287
  const request: CreateTransactionRequest = {
234
288
  assetId: this.assetId,
@@ -289,7 +343,14 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
289
343
  // Try signedMessages first (RAW signing - hex encoded)
290
344
  const fullSig = txResponse.signedMessages?.[0]?.signature?.fullSig;
291
345
  if (fullSig) {
292
- const sigBytes = hexToBytes(fullSig);
346
+ const cleanHex = fullSig.startsWith('0x') || fullSig.startsWith('0X') ? fullSig.slice(2) : fullSig;
347
+ if (cleanHex.length % 2 !== 0) {
348
+ throwSignerError(SignerErrorCode.SIGNING_FAILED, {
349
+ message: `Invalid hex signature: odd length (${cleanHex.length} chars)`,
350
+ });
351
+ }
352
+ base16Encoder ||= getBase16Encoder();
353
+ const sigBytes = new Uint8Array(base16Encoder.encode(cleanHex.toLowerCase()));
293
354
  if (sigBytes.length !== 64) {
294
355
  throwSignerError(SignerErrorCode.SIGNING_FAILED, {
295
356
  message: `Invalid signature length: expected 64 bytes, got ${sigBytes.length}`,
@@ -315,7 +376,7 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
315
376
  }
316
377
 
317
378
  // Check for terminal failure statuses
318
- if (TERMINAL_STATUSES.has(status)) {
379
+ if (isTerminalStatus(status)) {
319
380
  throwSignerError(SignerErrorCode.SIGNING_FAILED, {
320
381
  message: `Transaction failed with status: ${txResponse.status}`,
321
382
  });
@@ -344,6 +405,11 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
344
405
  ? message.content
345
406
  : new Uint8Array(Array.from(message.content));
346
407
  const signatureBytes = await this.signRawBytes(messageBytes);
408
+ await assertSignatureValid({
409
+ data: messageBytes,
410
+ signature: signatureBytes,
411
+ signerAddress: this.address,
412
+ });
347
413
  return createSignatureDictionary({
348
414
  signature: signatureBytes,
349
415
  signerAddress: this.address,
@@ -366,6 +432,15 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
366
432
  const signatureBytes = this.useProgramCall
367
433
  ? await this.signWithProgramCall(transaction)
368
434
  : await this.signRawBytes(new Uint8Array(transaction.messageBytes));
435
+ // Skip verification for PROGRAM_CALL: it broadcasts to Solana and returns
436
+ // a txHash (on-chain confirmation), not a signature over the message bytes.
437
+ if (!this.useProgramCall) {
438
+ await assertSignatureValid({
439
+ data: transaction.messageBytes,
440
+ signature: signatureBytes,
441
+ signerAddress: this.address,
442
+ });
443
+ }
369
444
  return createSignatureDictionary({
370
445
  signature: signatureBytes,
371
446
  signerAddress: this.address,
@@ -408,24 +483,3 @@ export class FireblocksSigner<TAddress extends string = string> implements Solan
408
483
  }
409
484
  }
410
485
  }
411
-
412
- /**
413
- * Convert Uint8Array to hex string
414
- */
415
- function bytesToHex(bytes: Uint8Array): string {
416
- return Array.from(bytes)
417
- .map(b => b.toString(16).padStart(2, '0'))
418
- .join('');
419
- }
420
-
421
- /**
422
- * Convert hex string to Uint8Array
423
- */
424
- function hexToBytes(hex: string): Uint8Array {
425
- const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex;
426
- const bytes = new Uint8Array(cleanHex.length / 2);
427
- for (let i = 0; i < bytes.length; i++) {
428
- bytes[i] = parseInt(cleanHex.substring(i * 2, i * 2 + 2), 16);
429
- }
430
- return bytes;
431
- }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { FireblocksSigner } from './fireblocks-signer.js';
1
+ export { FireblocksSigner, createFireblocksSigner } from './fireblocks-signer.js';
2
2
  export type {
3
3
  FireblocksSignerConfig,
4
4
  CreateTransactionRequest,
@@ -6,4 +6,4 @@ export type {
6
6
  TransactionResponse,
7
7
  VaultAddressesResponse,
8
8
  } from './types.js';
9
- export { FireblocksTransactionStatus, TERMINAL_STATUSES } from './types.js';
9
+ export { FireblocksTransactionStatus, isTerminalStatus } from './types.js';
package/src/jwt.ts CHANGED
@@ -1,6 +1,9 @@
1
+ import { getBase16Decoder } from '@solana/codecs-strings';
1
2
  import { SignerErrorCode, throwSignerError } from '@solana/keychain-core';
2
3
  import { importPKCS8, SignJWT } from 'jose';
3
4
 
5
+ let base16Decoder: ReturnType<typeof getBase16Decoder> | undefined;
6
+
4
7
  /**
5
8
  * Create a JWT for Fireblocks API authentication
6
9
  *
@@ -50,9 +53,8 @@ export async function createJwt(apiKey: string, privateKeyPem: string, uri: stri
50
53
  * Compute SHA256 hash and return as hex string
51
54
  */
52
55
  async function sha256Hex(data: string): Promise<string> {
53
- const encoder = new TextEncoder();
54
- const dataBuffer = encoder.encode(data);
56
+ const dataBuffer = new TextEncoder().encode(data);
55
57
  const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
56
- const hashArray = Array.from(new Uint8Array(hashBuffer));
57
- return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
58
+ base16Decoder ||= getBase16Decoder();
59
+ return base16Decoder.decode(new Uint8Array(hashBuffer));
58
60
  }
package/src/types.ts CHANGED
@@ -112,29 +112,36 @@ export interface VaultAddress {
112
112
  /**
113
113
  * Fireblocks transaction status values
114
114
  */
115
- export enum FireblocksTransactionStatus {
116
- SUBMITTED = 'SUBMITTED',
117
- QUEUED = 'QUEUED',
118
- PENDING_SIGNATURE = 'PENDING_SIGNATURE',
119
- PENDING_AUTHORIZATION = 'PENDING_AUTHORIZATION',
120
- PENDING_3RD_PARTY_MANUAL_APPROVAL = 'PENDING_3RD_PARTY_MANUAL_APPROVAL',
121
- PENDING_3RD_PARTY = 'PENDING_3RD_PARTY',
122
- BROADCASTING = 'BROADCASTING',
123
- CONFIRMING = 'CONFIRMING',
124
- COMPLETED = 'COMPLETED',
125
- CANCELLED = 'CANCELLED',
126
- REJECTED = 'REJECTED',
127
- BLOCKED = 'BLOCKED',
128
- FAILED = 'FAILED',
129
- }
115
+ export const FireblocksTransactionStatus = {
116
+ BLOCKED: 'BLOCKED',
117
+ BROADCASTING: 'BROADCASTING',
118
+ CANCELLED: 'CANCELLED',
119
+ COMPLETED: 'COMPLETED',
120
+ CONFIRMING: 'CONFIRMING',
121
+ FAILED: 'FAILED',
122
+ PENDING_3RD_PARTY: 'PENDING_3RD_PARTY',
123
+ PENDING_3RD_PARTY_MANUAL_APPROVAL: 'PENDING_3RD_PARTY_MANUAL_APPROVAL',
124
+ PENDING_AUTHORIZATION: 'PENDING_AUTHORIZATION',
125
+ PENDING_SIGNATURE: 'PENDING_SIGNATURE',
126
+ QUEUED: 'QUEUED',
127
+ REJECTED: 'REJECTED',
128
+ SUBMITTED: 'SUBMITTED',
129
+ } as const;
130
+ export type FireblocksTransactionStatus =
131
+ (typeof FireblocksTransactionStatus)[keyof typeof FireblocksTransactionStatus];
130
132
 
131
133
  /**
132
- * Terminal transaction statuses (polling should stop)
134
+ * Check whether a Fireblocks transaction has reached a terminal state (polling should stop).
135
+ *
136
+ * Replaces the previously exported `TERMINAL_STATUSES` Set, which was removed
137
+ * because module-level `Set` allocation is a side effect that prevents tree-shaking.
133
138
  */
134
- export const TERMINAL_STATUSES = new Set([
135
- FireblocksTransactionStatus.COMPLETED,
136
- FireblocksTransactionStatus.CANCELLED,
137
- FireblocksTransactionStatus.REJECTED,
138
- FireblocksTransactionStatus.BLOCKED,
139
- FireblocksTransactionStatus.FAILED,
140
- ]);
139
+ export function isTerminalStatus(status: string): boolean {
140
+ return (
141
+ status === FireblocksTransactionStatus.COMPLETED ||
142
+ status === FireblocksTransactionStatus.CANCELLED ||
143
+ status === FireblocksTransactionStatus.REJECTED ||
144
+ status === FireblocksTransactionStatus.BLOCKED ||
145
+ status === FireblocksTransactionStatus.FAILED
146
+ );
147
+ }