@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.
- package/README.md +4 -7
- package/dist/__tests__/fireblocks-signer.integration.test.js +27 -19
- package/dist/__tests__/fireblocks-signer.integration.test.js.map +1 -1
- package/dist/__tests__/fireblocks-signer.test.js +73 -0
- package/dist/__tests__/fireblocks-signer.test.js.map +1 -1
- package/dist/__tests__/setup.d.ts +2 -2
- package/dist/__tests__/setup.d.ts.map +1 -1
- package/dist/__tests__/setup.js +7 -13
- package/dist/__tests__/setup.js.map +1 -1
- package/dist/fireblocks-signer.d.ts +13 -2
- package/dist/fireblocks-signer.d.ts.map +1 -1
- package/dist/fireblocks-signer.js +91 -46
- package/dist/fireblocks-signer.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -4
- package/dist/jwt.js.map +1 -1
- package/dist/types.d.ts +21 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +26 -24
- package/dist/types.js.map +1 -1
- package/package.json +5 -4
- package/src/__tests__/fireblocks-signer.integration.test.ts +27 -20
- package/src/__tests__/fireblocks-signer.test.ts +94 -0
- package/src/__tests__/setup.ts +12 -17
- package/src/fireblocks-signer.ts +102 -48
- package/src/index.ts +2 -2
- package/src/jwt.ts +6 -4
- package/src/types.ts +30 -23
package/src/fireblocks-signer.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Address, assertIsAddress } from '@solana/addresses';
|
|
2
|
-
import { getBase58Encoder } from '@solana/codecs-strings';
|
|
3
|
-
import {
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
|
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
|
-
|
|
57
|
-
return
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
*
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
}
|