@canton-network/core-signing-fireblocks 0.1.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 ADDED
@@ -0,0 +1,14 @@
1
+ # Fireblocks Signing Driver
2
+
3
+ A driver for signing and retrieving transactions using the Fireblocks API implementing the `SigningDriverInterface` from `@canton-network/core-signing-lib`.
4
+
5
+ # Testing
6
+
7
+ To test the Fireblocks signing driver with mocks, you can use the normal `yarn workspace @canton-network/core-signing-fireblocks test` command from the root directory.
8
+
9
+ To test with an actual Fireblocks account:
10
+
11
+ 1. Generate a Fireblocks signing key: `openssl req -new -newkey rsa:4096 -nodes -keyout fireblocks_secret.key -out fireblocks.csr -subj '/O=Digital Asset - Canton'`
12
+ 2. Put the `fireblocks_secret.key` file in this directory
13
+ 3. Create an API User in Fireblocks and upload the `fireblocks.csr` file. Save the API Key it gives you (it will be a UUIDv4 string)
14
+ 4. From the root directory, run the tests with: `FIREBLOCKS_API_KEY=<your_api_key> yarn workspace @canton-network/core-signing-fireblocks test`
@@ -0,0 +1,71 @@
1
+ import { PublicKeyInformationAlgorithmEnum } from '@fireblocks/ts-sdk';
2
+ import { SigningStatus } from '@canton-network/core-signing-lib';
3
+ interface FireblocksKey {
4
+ name: string;
5
+ publicKey: string;
6
+ derivationPath: number[];
7
+ algorithm: PublicKeyInformationAlgorithmEnum;
8
+ }
9
+ export interface FireblocksTransaction {
10
+ txId: string;
11
+ status: SigningStatus;
12
+ createdAt?: number;
13
+ signature?: string | undefined;
14
+ publicKey?: string | undefined;
15
+ derivationPath: number[];
16
+ }
17
+ export interface FireblocksApiKeyInfo {
18
+ apiKey: string;
19
+ apiSecret: string;
20
+ }
21
+ export declare class FireblocksHandler {
22
+ private defaultClient;
23
+ private clients;
24
+ private keyInfoByPublicKey;
25
+ private publicKeyByDerivationPath;
26
+ private getClient;
27
+ constructor(defaultKey: FireblocksApiKeyInfo | undefined, userKeys: Map<string, FireblocksApiKeyInfo>, apiPath?: string);
28
+ /**
29
+ * Get all public keys which correspond to Fireblocks vault accounts. This will
30
+ * also refresh the key cache.
31
+ * @returns List of Fireblocks public key information
32
+ */
33
+ getPublicKeys(userId: string | undefined): Promise<FireblocksKey[]>;
34
+ /**
35
+ * Takes a Fireblocks response from a transactions call and extracts the transaction information
36
+ * relevant to the Wallet Kernel. This will potentially fetch the public key since unsigned transactions
37
+ * do not include it
38
+ * @returns FireblocksTransaction
39
+ */
40
+ private formatTransaction;
41
+ /**
42
+ * Looks up or fetches the public key (only) for a given derivation path
43
+ * @returns The public key as a string
44
+ */
45
+ private lookupPublicKey;
46
+ /**
47
+ * Fetch a single RAW transaction from Fireblocks by its transaction ID
48
+ * @returns FireblocksTransaction or undefined if not found
49
+ */
50
+ getTransaction(userId: string | undefined, txId: string): Promise<FireblocksTransaction | undefined>;
51
+ /**
52
+ * Get all RAW transactions from Fireblocks. Returns an async generator as
53
+ * this may return a large number of transactions and will occasionally need to
54
+ * refresh the key cache.
55
+ * @returns AsyncGenerator of FireblocksTransactions
56
+ */
57
+ getTransactions(userId: string | undefined, { limit, before, }?: {
58
+ limit?: number;
59
+ before?: number;
60
+ }): AsyncGenerator<FireblocksTransaction>;
61
+ /**
62
+ * Sign a transaction using a public key
63
+ * @param tx - The transaction to sign, as a string
64
+ * @param publicKey - The public key to use for signing
65
+ * @param externalTxId - The transaction ID assigned by the wallet kernel
66
+ * @return The transaction object from Fireblocks
67
+ */
68
+ signTransaction(userId: string | undefined, tx: string, publicKey: string, externalTxId?: string): Promise<FireblocksTransaction>;
69
+ }
70
+ export {};
71
+ //# sourceMappingURL=fireblocks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fireblocks.d.ts","sourceRoot":"","sources":["../src/fireblocks.ts"],"names":[],"mappings":"AAAA,OAAO,EAEH,iCAAiC,EAGpC,MAAM,oBAAoB,CAAA;AAE3B,OAAO,EAAE,aAAa,EAAgB,MAAM,kCAAkC,CAAA;AAiB9E,UAAU,aAAa;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,SAAS,EAAE,iCAAiC,CAAA;CAC/C;AAED,MAAM,WAAW,qBAAqB;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,aAAa,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC9B,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC9B,cAAc,EAAE,MAAM,EAAE,CAAA;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;CACpB;AAID,qBAAa,iBAAiB;IAC1B,OAAO,CAAC,aAAa,CAAoC;IACzD,OAAO,CAAC,OAAO,CAAqC;IAEpD,OAAO,CAAC,kBAAkB,CAAwC;IAClE,OAAO,CAAC,yBAAyB,CAAiC;IAElE,OAAO,CAAC,SAAS,CAQhB;gBAGG,UAAU,EAAE,oBAAoB,GAAG,SAAS,EAC5C,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,oBAAoB,CAAC,EAC3C,OAAO,GAAE,MAAuC;IAmBpD;;;;OAIG;IACU,aAAa,CACtB,MAAM,EAAE,MAAM,GAAG,SAAS,GAC3B,OAAO,CAAC,aAAa,EAAE,CAAC;IA+C3B;;;;;OAKG;YACW,iBAAiB;IAmD/B;;;OAGG;YACW,eAAe;IA+B7B;;;OAGG;IACU,cAAc,CACvB,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,IAAI,EAAE,MAAM,GACb,OAAO,CAAC,qBAAqB,GAAG,SAAS,CAAC;IAa7C;;;;;OAKG;IACW,eAAe,CACzB,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,EACI,KAAW,EACX,MAAM,GACT,GAAE;QACC,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,MAAM,CAAC,EAAE,MAAM,CAAA;KACb,GACP,cAAc,CAAC,qBAAqB,CAAC;IAmCxC;;;;;;OAMG;IACU,eAAe,CACxB,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,EACjB,YAAY,CAAC,EAAE,MAAM,GACtB,OAAO,CAAC,qBAAqB,CAAC;CAwDpC"}
@@ -0,0 +1,286 @@
1
+ import { Fireblocks, PublicKeyInformationAlgorithmEnum, } from '@fireblocks/ts-sdk';
2
+ import { pino } from 'pino';
3
+ import { CC_COIN_TYPE } from '@canton-network/core-signing-lib';
4
+ import { z } from 'zod';
5
+ const RawMessageSchema = z.object({
6
+ content: z.string(),
7
+ derivationPath: z.array(z.number()),
8
+ });
9
+ const RawMessageDataSchema = z.object({
10
+ messages: z.array(RawMessageSchema),
11
+ algorithm: z.string(),
12
+ });
13
+ const RawMessageExtraParametersSchema = z.object({
14
+ rawMessageData: RawMessageDataSchema,
15
+ });
16
+ const logger = pino({ name: 'main', level: 'debug' });
17
+ export class FireblocksHandler {
18
+ defaultClient = undefined;
19
+ clients = new Map();
20
+ keyInfoByPublicKey = new Map();
21
+ publicKeyByDerivationPath = new Map();
22
+ getClient = (userId) => {
23
+ if (userId !== undefined && this.clients.has(userId)) {
24
+ return this.clients.get(userId);
25
+ }
26
+ else if (this.defaultClient) {
27
+ return this.defaultClient;
28
+ }
29
+ else {
30
+ throw new Error('No Fireblocks client available for this user.');
31
+ }
32
+ };
33
+ constructor(defaultKey, userKeys, apiPath = 'https://api.fireblocks.io/v1') {
34
+ if (defaultKey) {
35
+ this.defaultClient = new Fireblocks({
36
+ apiKey: defaultKey.apiKey,
37
+ basePath: apiPath,
38
+ secretKey: defaultKey.apiSecret,
39
+ });
40
+ }
41
+ userKeys.forEach((keyInfo, userId) => {
42
+ const client = new Fireblocks({
43
+ apiKey: keyInfo.apiKey,
44
+ basePath: apiPath,
45
+ secretKey: keyInfo.apiSecret,
46
+ });
47
+ this.clients.set(userId, client);
48
+ });
49
+ }
50
+ /**
51
+ * Get all public keys which correspond to Fireblocks vault accounts. This will
52
+ * also refresh the key cache.
53
+ * @returns List of Fireblocks public key information
54
+ */
55
+ async getPublicKeys(userId) {
56
+ const keys = [];
57
+ try {
58
+ const client = this.getClient(userId);
59
+ const vaultAccounts = [];
60
+ let after = undefined;
61
+ do {
62
+ const resp = await client.vaults.getPagedVaultAccounts(after ? { after } : {});
63
+ after = resp.data.paging?.after;
64
+ vaultAccounts.push(...(resp.data.accounts || []));
65
+ } while (after !== undefined);
66
+ for (const vault of vaultAccounts) {
67
+ if (vault.id) {
68
+ const derivationPath = [
69
+ 44,
70
+ CC_COIN_TYPE,
71
+ Number(vault.id) || 0,
72
+ 0,
73
+ 0,
74
+ ];
75
+ const publicKey = await this.lookupPublicKey(userId, derivationPath);
76
+ const storedKey = {
77
+ derivationPath,
78
+ publicKey,
79
+ name: vault.name || vault.id,
80
+ algorithm: PublicKeyInformationAlgorithmEnum.EddsaEd25519,
81
+ };
82
+ keys.push(storedKey);
83
+ this.keyInfoByPublicKey.set(storedKey.publicKey, storedKey);
84
+ }
85
+ }
86
+ }
87
+ catch (error) {
88
+ logger.error(error, 'Error fetching vault accounts:');
89
+ throw error;
90
+ }
91
+ return keys;
92
+ }
93
+ /**
94
+ * Takes a Fireblocks response from a transactions call and extracts the transaction information
95
+ * relevant to the Wallet Kernel. This will potentially fetch the public key since unsigned transactions
96
+ * do not include it
97
+ * @returns FireblocksTransaction
98
+ */
99
+ async formatTransaction(userId, tx) {
100
+ if (tx.signedMessages && tx.signedMessages.length > 0) {
101
+ const signedMessage = tx.signedMessages[0];
102
+ if (!signedMessage.publicKey ||
103
+ !signedMessage.content ||
104
+ !signedMessage.signature) {
105
+ return undefined;
106
+ }
107
+ return {
108
+ txId: tx.id,
109
+ status: 'signed',
110
+ createdAt: tx.createdAt,
111
+ publicKey: signedMessage.publicKey,
112
+ signature: signedMessage.signature.fullSig,
113
+ derivationPath: signedMessage.derivationPath,
114
+ };
115
+ }
116
+ else {
117
+ const rawMessageData = RawMessageExtraParametersSchema.safeParse(tx.extraParameters);
118
+ if (!rawMessageData.success) {
119
+ // Skip transactions with invalid rawMessageData
120
+ return undefined;
121
+ }
122
+ const message = rawMessageData.data.rawMessageData.messages[0];
123
+ const publicKey = await this.lookupPublicKey(userId, message.derivationPath);
124
+ const status = tx.status === 'REJECTED' || tx.status === 'BLOCKED'
125
+ ? 'rejected'
126
+ : tx.status === 'FAILED'
127
+ ? 'failed'
128
+ : 'pending';
129
+ return {
130
+ txId: tx.id,
131
+ status: status,
132
+ createdAt: tx.createdAt,
133
+ publicKey: publicKey,
134
+ derivationPath: message.derivationPath,
135
+ };
136
+ }
137
+ }
138
+ /**
139
+ * Looks up or fetches the public key (only) for a given derivation path
140
+ * @returns The public key as a string
141
+ */
142
+ async lookupPublicKey(userId, derivationPath) {
143
+ const derivationPathString = JSON.stringify(derivationPath);
144
+ if (this.publicKeyByDerivationPath.has(derivationPathString)) {
145
+ return this.publicKeyByDerivationPath.get(derivationPathString);
146
+ }
147
+ else {
148
+ try {
149
+ const client = this.getClient(userId);
150
+ const key = await client.vaults.getPublicKeyInfo({
151
+ algorithm: PublicKeyInformationAlgorithmEnum.EddsaEd25519,
152
+ derivationPath: derivationPathString,
153
+ });
154
+ if (key.data.publicKey) {
155
+ this.publicKeyByDerivationPath.set(derivationPathString, key.data.publicKey);
156
+ return key.data.publicKey;
157
+ }
158
+ else {
159
+ throw new Error('Malformed public key response from Fireblocks');
160
+ }
161
+ }
162
+ catch (error) {
163
+ throw new Error(`Error looking up public key: ${error}`);
164
+ }
165
+ }
166
+ }
167
+ /**
168
+ * Fetch a single RAW transaction from Fireblocks by its transaction ID
169
+ * @returns FireblocksTransaction or undefined if not found
170
+ */
171
+ async getTransaction(userId, txId) {
172
+ try {
173
+ const client = this.getClient(userId);
174
+ const transaction = await client.transactions.getTransaction({
175
+ txId: txId,
176
+ });
177
+ return await this.formatTransaction(userId, transaction.data);
178
+ }
179
+ catch {
180
+ // if the transaction was not found for any reason, return undefined
181
+ return undefined;
182
+ }
183
+ }
184
+ /**
185
+ * Get all RAW transactions from Fireblocks. Returns an async generator as
186
+ * this may return a large number of transactions and will occasionally need to
187
+ * refresh the key cache.
188
+ * @returns AsyncGenerator of FireblocksTransactions
189
+ */
190
+ async *getTransactions(userId, { limit = 200, before, } = {}) {
191
+ let fetchedLength = 0;
192
+ let beforeQuery = before;
193
+ try {
194
+ const client = this.getClient(userId);
195
+ do {
196
+ const transactions = await client.transactions.getTransactions({
197
+ sourceType: 'VAULT_ACCOUNT',
198
+ limit,
199
+ ...(beforeQuery ? { before: beforeQuery.toString() } : {}),
200
+ });
201
+ fetchedLength = transactions.data.length;
202
+ for (const tx of transactions.data) {
203
+ // set next before to createdAt - 1 as before is inclusive of any transaction exactly at that
204
+ // timestamp
205
+ beforeQuery = tx.createdAt - 1;
206
+ const formatTransaction = await this.formatTransaction(userId, tx);
207
+ if (formatTransaction) {
208
+ yield formatTransaction;
209
+ }
210
+ else {
211
+ // if the transaction failed to format, continue so we do not skip remaining valid transactions
212
+ continue;
213
+ }
214
+ }
215
+ // once the fetched length is 0 before our last createdAt tx,
216
+ // there will be no transactions to fetch
217
+ } while (fetchedLength > 0);
218
+ }
219
+ catch (error) {
220
+ logger.error(error, 'Error fetching signatures');
221
+ throw error;
222
+ }
223
+ }
224
+ /**
225
+ * Sign a transaction using a public key
226
+ * @param tx - The transaction to sign, as a string
227
+ * @param publicKey - The public key to use for signing
228
+ * @param externalTxId - The transaction ID assigned by the wallet kernel
229
+ * @return The transaction object from Fireblocks
230
+ */
231
+ async signTransaction(userId, tx, publicKey, externalTxId) {
232
+ try {
233
+ const client = this.getClient(userId);
234
+ if (!this.keyInfoByPublicKey.has(publicKey)) {
235
+ // refresh the keycache
236
+ await this.getPublicKeys(userId);
237
+ }
238
+ const key = this.keyInfoByPublicKey.get(publicKey);
239
+ if (!key) {
240
+ throw new Error(`Public key ${publicKey} not found in vaults`);
241
+ }
242
+ const transaction = await client.transactions.createTransaction({
243
+ transactionRequest: {
244
+ operation: 'RAW',
245
+ note: `Signing transaction with public key ${publicKey}`,
246
+ externalTxId,
247
+ extraParameters: {
248
+ rawMessageData: {
249
+ messages: [
250
+ {
251
+ content: tx,
252
+ derivationPath: key.derivationPath,
253
+ },
254
+ ],
255
+ algorithm: key.algorithm,
256
+ },
257
+ },
258
+ },
259
+ });
260
+ let status = 'pending';
261
+ switch (transaction.data.status) {
262
+ case 'REJECTED':
263
+ status = 'rejected';
264
+ break;
265
+ case 'COMPLETED':
266
+ status = 'signed';
267
+ break;
268
+ case 'CANCELLED':
269
+ case 'FAILED':
270
+ case 'BLOCKED':
271
+ status = 'failed';
272
+ break;
273
+ }
274
+ return {
275
+ txId: transaction.data.id,
276
+ status,
277
+ publicKey: key.publicKey,
278
+ derivationPath: key.derivationPath,
279
+ };
280
+ }
281
+ catch (error) {
282
+ logger.error(error, 'Error signing transaction:');
283
+ throw error;
284
+ }
285
+ }
286
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=fireblocks.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fireblocks.test.d.ts","sourceRoot":"","sources":["../src/fireblocks.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,52 @@
1
+ import { expect, test, describe } from '@jest/globals';
2
+ import { FireblocksHandler } from './fireblocks.js';
3
+ import { readFileSync } from 'fs-extra';
4
+ import path from 'path';
5
+ const TEST_TRANSACTION_HASH = '88beb0783e394f6128699bad42906374ab64197d260db05bb0cfeeb518ba3ac2';
6
+ const SECRET_KEY_LOCATION = 'fireblocks_secret.key';
7
+ const TEST_USER_ID = 'test-user-id';
8
+ describe('fireblocks handler', () => {
9
+ const apiKey = process.env.FIREBLOCKS_API_KEY;
10
+ if (!apiKey) {
11
+ // skip this test suite if FIREBLOCKS_API_KEY is not set - there's really nothing to test for this class
12
+ // if the API Key is not set. Mocked functionality of this class is tested in context of the controller
13
+ // in index.test.ts
14
+ test.skip('FIREBLOCKS_API_KEY environment variable is not set, skipping test.', () => { });
15
+ }
16
+ else {
17
+ const secretPath = path.resolve(process.cwd(), SECRET_KEY_LOCATION);
18
+ const apiSecret = readFileSync(secretPath, 'utf8');
19
+ const userApiKeys = new Map([[TEST_USER_ID, { apiKey, apiSecret }]]);
20
+ const handler = new FireblocksHandler(undefined, userApiKeys);
21
+ test('error is thrown if userId is not found and there is no default', async () => {
22
+ await expect(handler.getPublicKeys('unknown')).rejects.toThrow();
23
+ });
24
+ const userId = TEST_USER_ID;
25
+ test('getPublicKeys', async () => {
26
+ const keys = await handler.getPublicKeys(userId);
27
+ expect(keys.length).toBeGreaterThan(0);
28
+ }, 25000);
29
+ test('sign and find transaction', async () => {
30
+ const transaction = await handler.signTransaction(userId, TEST_TRANSACTION_HASH, '02fefbcc9aebc8a479f211167a9f564df53aefd603a8662d9449a98c1ead2eba');
31
+ expect(transaction).toBeDefined();
32
+ const foundTransaction = await handler.getTransaction(userId, transaction.txId);
33
+ expect(foundTransaction).toBeDefined();
34
+ });
35
+ test('findTransaction failure', async () => {
36
+ const badTransaction = await handler.getTransaction(userId, 'bad-tx-id');
37
+ expect(badTransaction).toBeUndefined();
38
+ });
39
+ test('getTransactions', async () => {
40
+ const transactions = await Array.fromAsync(handler.getTransactions(userId, { limit: 200 }));
41
+ // ensure transactions created by other tests are not included
42
+ const before = transactions[0]?.createdAt;
43
+ const limitedTransactions = await Array.fromAsync(handler.getTransactions(userId, { limit: 25, before }));
44
+ expect(transactions.length).toEqual(limitedTransactions.length);
45
+ }, 25000);
46
+ const defaultHandler = new FireblocksHandler({ apiKey, apiSecret }, new Map());
47
+ test('getPublicKeys works with a default handler', async () => {
48
+ const keys = await defaultHandler.getPublicKeys(userId);
49
+ expect(keys.length).toBeGreaterThan(0);
50
+ }, 25000);
51
+ }
52
+ });
@@ -0,0 +1,26 @@
1
+ import { PartyMode, SigningDriverInterface, SigningProvider } from '@canton-network/core-signing-lib';
2
+ import { FireblocksApiKeyInfo } from './fireblocks.js';
3
+ import { AuthContext } from '@canton-network/core-wallet-auth';
4
+ export interface FireblocksConfig {
5
+ defaultKeyInfo?: FireblocksApiKeyInfo;
6
+ userApiKeys: Map<string, FireblocksApiKeyInfo>;
7
+ apiPath?: string;
8
+ }
9
+ export default class FireblocksSigningDriver implements SigningDriverInterface {
10
+ private fireblocks;
11
+ private config;
12
+ constructor(config: FireblocksConfig);
13
+ partyMode: PartyMode;
14
+ signingProvider: SigningProvider;
15
+ controller: (userId: AuthContext["userId"] | undefined) => {
16
+ signTransaction: import("@canton-network/core-signing-lib").SignTransaction;
17
+ getTransaction: import("@canton-network/core-signing-lib").GetTransaction;
18
+ getTransactions: import("@canton-network/core-signing-lib").GetTransactions;
19
+ getKeys: import("@canton-network/core-signing-lib").GetKeys;
20
+ createKey: import("@canton-network/core-signing-lib").CreateKey;
21
+ getConfiguration: import("@canton-network/core-signing-lib").GetConfiguration;
22
+ setConfiguration: import("@canton-network/core-signing-lib").SetConfiguration;
23
+ subscribeTransactions: import("@canton-network/core-signing-lib").SubscribeTransactions;
24
+ };
25
+ }
26
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAEH,SAAS,EACT,sBAAsB,EACtB,eAAe,EAClB,MAAM,kCAAkC,CAAA;AAmBzC,OAAO,EAAqB,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AAGzE,OAAO,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAE9D,MAAM,WAAW,gBAAgB;IAC7B,cAAc,CAAC,EAAE,oBAAoB,CAAA;IACrC,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAA;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAA;CACnB;AA4BD,MAAM,CAAC,OAAO,OAAO,uBAAwB,YAAW,sBAAsB;IAC1E,OAAO,CAAC,UAAU,CAAmB;IACrC,OAAO,CAAC,MAAM,CAAkB;gBAEpB,MAAM,EAAE,gBAAgB;IAI7B,SAAS,YAAqB;IAC9B,eAAe,kBAA6B;IAC5C,UAAU,GAAI,QAAQ,WAAW,CAAC,QAAQ,CAAC,GAAG,SAAS;;;;;;;;;MA0KxD;CACT"}
package/dist/index.js ADDED
@@ -0,0 +1,162 @@
1
+ // Disabled unused vars rule to allow for future implementations
2
+ /* eslint-disable @typescript-eslint/no-unused-vars */
3
+ import { buildController, PartyMode, SigningProvider, } from '@canton-network/core-signing-lib';
4
+ import { FireblocksHandler } from './fireblocks.js';
5
+ import _ from 'lodash';
6
+ import { z } from 'zod';
7
+ const FireblocksApiKeyInfoSchema = z.object({
8
+ apiKey: z.string(),
9
+ apiSecret: z.string(),
10
+ });
11
+ const FireblocksConfigSchema = z.object({
12
+ defaultApiKey: FireblocksApiKeyInfoSchema.optional(),
13
+ userApiKeys: z.map(z.string(), FireblocksApiKeyInfoSchema),
14
+ apiPath: z.string().optional(),
15
+ });
16
+ const createFireblocksHandler = (config) => {
17
+ return new FireblocksHandler(config.defaultKeyInfo
18
+ ? {
19
+ apiKey: config.defaultKeyInfo.apiKey,
20
+ apiSecret: config.defaultKeyInfo.apiSecret,
21
+ }
22
+ : undefined, config.userApiKeys, config.apiPath || 'https://api.fireblocks.io/v1');
23
+ };
24
+ export default class FireblocksSigningDriver {
25
+ fireblocks;
26
+ config;
27
+ constructor(config) {
28
+ this.config = config;
29
+ this.fireblocks = createFireblocksHandler(config);
30
+ }
31
+ partyMode = PartyMode.EXTERNAL;
32
+ signingProvider = SigningProvider.FIREBLOCKS;
33
+ controller = (userId) => buildController({
34
+ signTransaction: async (params) => {
35
+ // TODO: validate transaction here
36
+ try {
37
+ const tx = await this.fireblocks.signTransaction(userId, params.txHash, params.publicKey, params.internalTxId);
38
+ return {
39
+ txId: tx.txId,
40
+ status: tx.status,
41
+ signature: tx.signature,
42
+ publicKey: tx.publicKey,
43
+ };
44
+ }
45
+ catch (error) {
46
+ return {
47
+ error: 'signing_error',
48
+ error_description: error.message,
49
+ };
50
+ }
51
+ },
52
+ getTransaction: async (params) => {
53
+ const tx = await this.fireblocks.getTransaction(userId, params.txId);
54
+ if (tx) {
55
+ return {
56
+ txId: tx.txId,
57
+ status: tx.status,
58
+ signature: tx.signature,
59
+ publicKey: tx.publicKey,
60
+ };
61
+ }
62
+ else {
63
+ return {
64
+ error: 'transaction_not_found',
65
+ error_description: 'The requested transaction does not exist.',
66
+ };
67
+ }
68
+ },
69
+ getTransactions: async (params) => {
70
+ const transactions = [];
71
+ if (params.publicKeys || params.txIds) {
72
+ const txIds = new Set(params.txIds);
73
+ const publicKeys = new Set(params.publicKeys);
74
+ for await (const tx of this.fireblocks.getTransactions(userId)) {
75
+ if (txIds.has(tx.txId) ||
76
+ publicKeys.has(tx.publicKey || '')) {
77
+ transactions.push({
78
+ txId: tx.txId,
79
+ status: tx.status,
80
+ signature: tx.signature,
81
+ publicKey: tx.publicKey,
82
+ });
83
+ }
84
+ if (params.txIds &&
85
+ !params.publicKeys &&
86
+ transactions.length == txIds.size) {
87
+ // stop if we are filtering by only txIds and have found all requested transactions
88
+ break;
89
+ }
90
+ }
91
+ return {
92
+ transactions: transactions,
93
+ };
94
+ }
95
+ else {
96
+ return {
97
+ error: 'bad_arguments',
98
+ error_description: 'either public key or txIds must be supplied',
99
+ };
100
+ }
101
+ },
102
+ getKeys: async () => {
103
+ try {
104
+ const keys = await this.fireblocks.getPublicKeys(userId);
105
+ return {
106
+ keys: keys.map((k) => ({
107
+ id: k.derivationPath.join('-'),
108
+ name: k.name,
109
+ publicKey: k.publicKey,
110
+ })),
111
+ };
112
+ }
113
+ catch (error) {
114
+ return {
115
+ error: 'fetch_error',
116
+ error_description: error.message,
117
+ };
118
+ }
119
+ },
120
+ createKey: async (_params) => {
121
+ return {
122
+ error: 'not_allowed',
123
+ error_description: 'Creating a Fireblocks key through the Wallet Kernel is not allowed, please create new keys directly in Fireblocks.',
124
+ };
125
+ },
126
+ getConfiguration: async () => {
127
+ const hideFireblocksKeySecret = (keyInfo) => {
128
+ return keyInfo
129
+ ? {
130
+ apiKey: keyInfo.apiKey,
131
+ apiSecret: '***HIDDEN***',
132
+ }
133
+ : undefined;
134
+ };
135
+ return {
136
+ ...this.config,
137
+ defaultKeyInfo: hideFireblocksKeySecret(this.config.defaultKeyInfo),
138
+ userApiKeys: new Map([...this.config.userApiKeys].map(([k, v]) => [
139
+ k,
140
+ hideFireblocksKeySecret(v),
141
+ ])),
142
+ };
143
+ },
144
+ setConfiguration: async (params) => {
145
+ const validated = FireblocksConfigSchema.safeParse(params);
146
+ if (!validated.success) {
147
+ return {
148
+ error: 'bad_arguments',
149
+ error_description: validated.error.message,
150
+ };
151
+ }
152
+ if (!_.isEqual(validated.data, this.config)) {
153
+ this.config = validated.data;
154
+ this.fireblocks = createFireblocksHandler(this.config);
155
+ }
156
+ return params;
157
+ },
158
+ // TODO: implement subscribeTransactions - we will need to figure out how to handle subscriptions
159
+ // when the controller is not running in a server context
160
+ subscribeTransactions: async (params) => Promise.resolve({}),
161
+ });
162
+ }
@@ -0,0 +1,3 @@
1
+ import { Error as RpcError } from '@canton-network/core-signing-lib';
2
+ export declare function throwWhenRpcError<T>(value: T | RpcError): void;
3
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"AAMA,OAAO,EAIH,KAAK,IAAI,QAAQ,EAEpB,MAAM,kCAAkC,CAAA;AA0GzC,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,QAAQ,GAAG,IAAI,CAM9D"}
@@ -0,0 +1,188 @@
1
+ import { expect, test } from '@jest/globals';
2
+ import FireblocksSigningDriver from './index.js';
3
+ import { readFileSync } from 'fs-extra';
4
+ import path from 'path';
5
+ import { isRpcError, CC_COIN_TYPE, } from '@canton-network/core-signing-lib';
6
+ import { PublicKeyInformationAlgorithmEnum } from '@fireblocks/ts-sdk';
7
+ const TEST_KEY_NAME = 'test-key-name';
8
+ const TEST_TRANSACTION = 'test-tx';
9
+ const TEST_TRANSACTION_HASH = '88beb0783e394f6128699bad42906374ab64197d260db05bb0cfeeb518ba3ac2';
10
+ const TEST_FIREBLOCKS_DERIVATION_PATH = [42, CC_COIN_TYPE, 4, 0, 0];
11
+ const TEST_FIREBLOCKS_VAULT_ID = TEST_FIREBLOCKS_DERIVATION_PATH.join('-');
12
+ const TEST_FIREBLOCKS_PUBLIC_KEY = '02fefbcc9aebc8a479f211167a9f564df53aefd603a8662d9449a98c1ead2eba';
13
+ const FAKE_TRANSACTION = {
14
+ txId: TEST_TRANSACTION_HASH,
15
+ status: 'signed',
16
+ signature: 'test-signature',
17
+ publicKey: TEST_FIREBLOCKS_PUBLIC_KEY,
18
+ derivationPath: TEST_FIREBLOCKS_DERIVATION_PATH,
19
+ };
20
+ const TEST_AUTH_CONTEXT = {
21
+ userId: 'test-user-id',
22
+ accessToken: 'test-access-token',
23
+ };
24
+ const TEST_BAD_AUTH_CONTEXT = {
25
+ userId: 'bad-user-id',
26
+ accessToken: 'test-access-token',
27
+ };
28
+ jest.mock('./fireblocks', () => {
29
+ const actual = jest.requireActual('./fireblocks');
30
+ if (process.env.FIREBLOCKS_API_KEY) {
31
+ return actual;
32
+ }
33
+ else {
34
+ return {
35
+ // NOTE: beware that the mock's constructor is _not_ typesafe, if the constructor's first argument is changed,
36
+ // the test will fail at runtime, not at compile time
37
+ FireblocksHandler: jest
38
+ .fn()
39
+ .mockImplementation((defaultKey) => {
40
+ return {
41
+ constructor: jest.fn(),
42
+ getPublicKeys: jest
43
+ .fn()
44
+ .mockImplementation((userId) => {
45
+ if (userId === TEST_AUTH_CONTEXT.userId ||
46
+ defaultKey !== undefined) {
47
+ return [
48
+ {
49
+ name: TEST_KEY_NAME,
50
+ publicKey: TEST_FIREBLOCKS_PUBLIC_KEY,
51
+ derivationPath: [
52
+ 42,
53
+ CC_COIN_TYPE,
54
+ 4,
55
+ 0,
56
+ 0,
57
+ ],
58
+ algorithm: PublicKeyInformationAlgorithmEnum.EddsaEd25519,
59
+ },
60
+ ];
61
+ }
62
+ else {
63
+ return {
64
+ error: 'User not found',
65
+ error_description: 'User does not exist in Fireblocks',
66
+ };
67
+ }
68
+ }),
69
+ getTransactions: jest.fn(() => {
70
+ async function* generator() {
71
+ yield FAKE_TRANSACTION;
72
+ }
73
+ return generator();
74
+ }),
75
+ getTransaction: jest
76
+ .fn()
77
+ .mockResolvedValue(FAKE_TRANSACTION),
78
+ signTransaction: jest.fn().mockResolvedValue({
79
+ txId: TEST_TRANSACTION_HASH,
80
+ status: 'signed',
81
+ }),
82
+ };
83
+ }),
84
+ };
85
+ }
86
+ });
87
+ export function throwWhenRpcError(value) {
88
+ if (isRpcError(value)) {
89
+ throw new Error(`Expected a valid return, but got an error: ${value.error_description}`);
90
+ }
91
+ }
92
+ async function setupTest(keyName = TEST_KEY_NAME) {
93
+ const apiKey = process.env.FIREBLOCKS_API_KEY;
94
+ const secretLocation = process.env.SECRET_KEY_LOCATION || 'fireblocks_secret.key';
95
+ let keyInfo;
96
+ if (!apiKey) {
97
+ keyInfo = {
98
+ apiKey: 'mocked',
99
+ apiSecret: 'mocked',
100
+ };
101
+ }
102
+ else {
103
+ const secretPath = path.resolve(process.cwd(), secretLocation);
104
+ const apiSecret = readFileSync(secretPath, 'utf8');
105
+ keyInfo = {
106
+ apiKey,
107
+ apiSecret,
108
+ };
109
+ }
110
+ const userApiKeys = new Map([
111
+ [TEST_AUTH_CONTEXT.userId, keyInfo],
112
+ ]);
113
+ const signingDriver = new FireblocksSigningDriver({
114
+ defaultKeyInfo: keyInfo,
115
+ userApiKeys,
116
+ });
117
+ const noDefaultSigningDriver = new FireblocksSigningDriver({
118
+ defaultKeyInfo: undefined,
119
+ userApiKeys,
120
+ });
121
+ const key = {
122
+ id: TEST_FIREBLOCKS_VAULT_ID,
123
+ name: keyName,
124
+ publicKey: TEST_FIREBLOCKS_PUBLIC_KEY,
125
+ };
126
+ return {
127
+ signingDriver,
128
+ noDefaultSigningDriver,
129
+ key,
130
+ controller: signingDriver.controller(TEST_AUTH_CONTEXT.userId),
131
+ };
132
+ }
133
+ test('key creation', async () => {
134
+ const { controller } = await setupTest();
135
+ const err = await controller.createKey({ name: 'test' });
136
+ expect(isRpcError(err)).toBe(true);
137
+ });
138
+ test('non-existing user cannot use driver without a default', async () => {
139
+ const { noDefaultSigningDriver } = await setupTest();
140
+ const err = await noDefaultSigningDriver
141
+ .controller(TEST_BAD_AUTH_CONTEXT.userId)
142
+ .getKeys();
143
+ expect(isRpcError(err)).toBe(true);
144
+ });
145
+ test('non-existing user can use driver that does have a default', async () => {
146
+ const { signingDriver } = await setupTest();
147
+ const keys = await signingDriver
148
+ .controller(TEST_BAD_AUTH_CONTEXT.userId)
149
+ .getKeys();
150
+ expect(isRpcError(keys)).toBe(false);
151
+ });
152
+ test('transaction signature', async () => {
153
+ const { controller, key } = await setupTest();
154
+ const tx = await controller.signTransaction({
155
+ tx: TEST_TRANSACTION,
156
+ txHash: TEST_TRANSACTION_HASH,
157
+ publicKey: key.publicKey,
158
+ });
159
+ throwWhenRpcError(tx);
160
+ // this hash has already been signed so Fireblocks won't bother getting it signed again
161
+ expect(tx.status).toBe('signed');
162
+ const transactionsByKey = await controller.getTransactions({
163
+ publicKeys: [key.publicKey],
164
+ });
165
+ throwWhenRpcError(transactionsByKey);
166
+ expect(transactionsByKey.transactions?.find((t) => t.txId === tx.txId)).toBeDefined();
167
+ const transactionsById = await controller.getTransactions({
168
+ txIds: [tx.txId],
169
+ });
170
+ throwWhenRpcError(transactionsById);
171
+ expect(transactionsById.transactions?.find((t) => t.txId === tx.txId)).toBeDefined();
172
+ const foundTx = await controller.getTransaction({
173
+ txId: tx.txId,
174
+ });
175
+ throwWhenRpcError(foundTx);
176
+ }, 60000);
177
+ test('test config change', async () => {
178
+ const { controller } = await setupTest();
179
+ const newPath = 'new-path';
180
+ const config = await controller.getConfiguration();
181
+ controller.setConfiguration({
182
+ defaultKeyInfo: config.defaultKeyInfo,
183
+ userApiKeys: config.userApiKeys,
184
+ apiPath: newPath,
185
+ });
186
+ const newConfig = await controller.getConfiguration();
187
+ expect(newConfig.apiPath).toBe(newPath);
188
+ });
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@canton-network/core-signing-fireblocks",
3
+ "version": "0.1.0",
4
+ "packageManager": "yarn@4.9.2",
5
+ "scripts": {
6
+ "build": "tsc -b",
7
+ "clean": "tsc -b --clean && rm -rf ./dist",
8
+ "test": "jest"
9
+ },
10
+ "main": "dist/index.js",
11
+ "types": "dist/index.d.ts",
12
+ "type": "module",
13
+ "dependencies": {
14
+ "@canton-network/core-signing-lib": "^0.1.0",
15
+ "@canton-network/core-wallet-auth": "^0.1.0",
16
+ "@fireblocks/ts-sdk": "^10.2.0",
17
+ "async-mutex": "^0.5.0",
18
+ "fs-extra": "^11.3.0",
19
+ "lodash": "^4.17.21",
20
+ "pino": "^9.7.0",
21
+ "tweetnacl": "^1.0.3",
22
+ "tweetnacl-util": "^0.15.1",
23
+ "zod": "^3.25.67"
24
+ },
25
+ "devDependencies": {
26
+ "@jest/globals": "^29.0.0",
27
+ "@swc/core": "^1.11.31",
28
+ "@swc/jest": "^0.2.38",
29
+ "@types/fs-extra": "^11",
30
+ "@types/jest": "^29.5.14",
31
+ "@types/lodash": "^4.17.17",
32
+ "@types/node": "^22.15.29",
33
+ "jest": "^29.7.0",
34
+ "ts-jest-resolver": "^2.0.1",
35
+ "typescript": "^5.8.3"
36
+ },
37
+ "files": [
38
+ "dist/*"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }