@chorus-one/signer-fireblocks
Version:
Fireblocks signer for Chorus One SDK
286 lines (285 loc) • 13.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.newFireblocksSignerBackend = exports.FireblocksSigner = void 0;
const fireblocks_sdk_1 = require("fireblocks-sdk");
const fireblocks_sdk_2 = require("fireblocks-sdk");
const utils_1 = require("@chorus-one/utils");
const authProvider_1 = require("./authProvider");
const viem_1 = require("viem");
class FireblocksSigner {
config;
fireblocksClient;
vault;
addressDerivationFn;
accounts;
logger;
/**
* Constructs a new FireblocksSigner.
*
* @param params - The parameters required to initialize the FireblocksSigner
* @param params.apiSecretKey - Fireblocks API Secret key
* @param params.apiKey - Fireblocks API Key
* @param params.vaultName - The name of the Fireblocks vault where the assets are stored
* @param params.assetId - The identifier for the asset you intend to manage
* @param params.addressDerivationFn - A function that derives the address from the public key
* @param params.timeout - (Optional) The maximum time (in ms) to wait for the Fireblocks API sign request to complete
* @param params.pollInterval - (Optional) The interval (in ms) at which the signer polls the Fireblocks API to check if the sign request has completed
* @param params.apiUrl - (Optional) The URL of the Fireblocks API, defaults to `https://api.fireblocks.io`
* @param params.logger - (Optional) A logger to use for logging messages, i.e `console`
*
* @returns A new instance of FireblocksSigner.
*/
constructor(params) {
const { addressDerivationFn, ...config } = params;
this.config = { ...config, apiUrl: params.apiUrl ?? 'https://api.fireblocks.io' };
this.logger = params.logger ?? utils_1.nopLogger;
this.addressDerivationFn = addressDerivationFn;
this.accounts = new Map();
}
/**
* Initializes the signer, performing any necessary setup or configuration.
* @returns A promise that resolves once the initialization is complete.
*/
async init() {
const backend = await newFireblocksSignerBackend(this.config);
const vaults = await backend
.getVaultAccountsWithPageInfo({
namePrefix: this.config.vaultName
})
.then((res) => {
return res.accounts.filter((account) => account.name === this.config.vaultName);
});
if (vaults.length !== 1) {
throw new Error('fireblocks vault name not found, expected exactly 1 result, got: ' + vaults.length);
}
this.fireblocksClient = backend;
this.vault = vaults[0];
// BIP44 format: m / purpose' / coin_type' / account' / change / address_index
const response = await this.fireblocksClient.getMaxBip44IndexUsed(this.vault.id, this.config.assetId);
const maxChangeAddress = response.maxBip44ChangeAddressIndexUsed ?? 0;
const maxAddressIndex = response.maxBip44AddressIndexUsed ?? 0;
const promises = [];
for (let change = 0; change <= maxChangeAddress; change++) {
for (let addressIndex = 0; addressIndex <= maxAddressIndex; addressIndex++) {
promises.push(this.getPublicKeyInfoForVaultAccount(change, addressIndex));
}
}
const publicKeysResponse = await Promise.all(promises);
publicKeysResponse.forEach(async (response) => {
const pk = Uint8Array.from(Buffer.from(response.publicKey, 'hex'));
const pth = response.derivationPath;
// fireblocks hardens the first 3 indexes
const derivationPath = `m/${pth[0]}'/${pth[1]}'/${pth[2]}'/${pth[3]}/${pth[4]}`;
const derivedAddresses = await this.addressDerivationFn(pk, derivationPath);
derivedAddresses.forEach((address) => {
this.accounts.set(address.toLowerCase(), {
hdPath: derivationPath,
publicKey: pk
});
});
});
}
/**
* Signs the provided data using the private key associated with the signer's address.
*
* @param signerAddress - The address of the signer
* @param signerData - The data to be signed, which can be a raw message or custom data
* @param options - Additional options
* @param options.note - An optional note to include with the transaction
*
* @returns A promise that resolves to an object containing the signature and public key.
*/
async sign(signerAddress, signerData, options) {
if (!this.vault) {
throw new Error('FireblocksSigner instance is not initialized');
}
const args = {
assetId: this.config.assetId,
source: {
type: fireblocks_sdk_1.PeerType.VAULT_ACCOUNT,
id: this.vault.id,
address: signerAddress
},
note: options?.note ?? '',
operation: fireblocks_sdk_1.TransactionOperation.RAW,
extraParameters: {
rawMessageData: {
messages: [
{
content: signerData.message
}
]
}
}
};
// https://developers.fireblocks.com/docs/raw-message-signing
this.logger.info('wait for the TX signature from the remote signer');
if (!this.fireblocksClient) {
throw new Error('FireblocksSigner instance is not initialized');
}
const { id } = await this.fireblocksClient.createTransaction(args);
let txInfo = await this.fireblocksClient.getTransactionById(id);
let status = txInfo.status;
const states = [fireblocks_sdk_1.TransactionStatus.COMPLETED, fireblocks_sdk_1.TransactionStatus.FAILED, fireblocks_sdk_1.TransactionStatus.BLOCKED];
const pollInterval = this.config.pollInterval ?? 1000;
const startTime = Date.now();
while (!states.some((x) => x === status)) {
try {
this.logger.info(`* signer request ID: ${id} with status: ${status}`);
txInfo = await this.fireblocksClient.getTransactionById(id);
status = txInfo.status;
}
catch (err) {
this.logger.error('probing remote signer failed', err);
}
// trigger timeout if the signer takes too long
if (this.config.timeout !== undefined && Date.now() - startTime > this.config.timeout) {
throw new Error('timeout waiting for the signer to complete');
}
await new Promise((resolve, _) => setTimeout(resolve, pollInterval));
}
const details = txInfo.subStatus === '' ? 'none' : txInfo.subStatus;
this.logger.info(`* signer request ID finished with status ${status}; details: ${details}`);
if (txInfo.signedMessages === undefined || txInfo.signedMessages.length !== 1) {
throw new Error(('expected exactly 1 signed message, got: ' +
(txInfo.signedMessages === undefined ? 0 : txInfo.signedMessages.length)));
}
return {
sig: txInfo.signedMessages[0].signature,
pk: Uint8Array.from(Buffer.from(txInfo.signedMessages[0].publicKey, 'hex'))
};
}
/**
* Signs an Ethereum contract call transaction using Fireblocks.
*
* @param params - Parameters for the contract call
* @param params.to - The destination contract address
* @param params.value - The amount to send in wei (optional)
* @param params.data - The contract call data
* @param params.gasLimit - Gas limit for the transaction (optional)
* @param params.maxFeePerGas - Maximum fee per gas in wei (optional)
* @param params.maxPriorityFeePerGas - Maximum priority fee per gas in wei (optional)
* @param params.note - Optional note for the transaction
*
* @returns A promise that resolves to the transaction response from Fireblocks.
*/
async contractCall(params) {
const { to, value, data, gas, maxFeePerGas, maxPriorityFeePerGas, gasPrice, note } = params;
if (!this.vault) {
throw new Error('FireblocksSigner instance is not initialized');
}
if (!this.fireblocksClient) {
throw new Error('FireblocksSigner instance is not initialized');
}
const amount = value ? (0, viem_1.formatEther)(value) : '0';
const args = {
assetId: this.config.assetId,
source: {
type: fireblocks_sdk_1.PeerType.VAULT_ACCOUNT,
id: this.vault.id
},
destination: {
type: fireblocks_sdk_1.PeerType.ONE_TIME_ADDRESS,
oneTimeAddress: {
address: to
}
},
amount: amount,
note,
operation: fireblocks_sdk_1.TransactionOperation.CONTRACT_CALL,
extraParameters: {
contractCallData: data
},
gasLimit: gas?.toString(),
maxFee: maxFeePerGas?.toString(),
priorityFee: maxPriorityFeePerGas?.toString(),
gasPrice: gasPrice?.toString()
};
this.logger.info('Creating contract call transaction in Fireblocks');
const { id } = await this.fireblocksClient.createTransaction(args);
// Wait for transaction completion
let txInfo = await this.fireblocksClient.getTransactionById(id);
let status = txInfo.status;
const completedStates = [fireblocks_sdk_1.TransactionStatus.COMPLETED];
const failedStates = [
fireblocks_sdk_1.TransactionStatus.FAILED,
fireblocks_sdk_1.TransactionStatus.BLOCKED,
fireblocks_sdk_1.TransactionStatus.CANCELLED,
fireblocks_sdk_1.TransactionStatus.REJECTED
];
const pollInterval = this.config.pollInterval ?? 1000;
const startTime = Date.now();
while (!completedStates.includes(status) && !failedStates.includes(status)) {
try {
this.logger.info(`* Contract call transaction ID: ${id} with status: ${status}`);
txInfo = await this.fireblocksClient.getTransactionById(id);
status = txInfo.status;
}
catch (err) {
this.logger.error('Error checking transaction status', err);
}
// Handle failed states
if (failedStates.includes(status)) {
const errorMessages = {
BLOCKED: 'The transaction has been blocked by the TAP security policy.',
FAILED: 'The transaction has failed.',
CANCELLED: 'The transaction has been cancelled.',
REJECTED: 'The transaction has been rejected by the TAP security policy.'
};
return {
status: 'failure',
reason: errorMessages[status],
receipt: txInfo
};
}
// Trigger timeout if the transaction takes too long
if (this.config.timeout !== undefined && Date.now() - startTime > this.config.timeout) {
return {
status: 'failure',
reason: 'Timeout waiting for the contract call transaction to complete',
receipt: txInfo
};
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
const details = txInfo.subStatus === '' ? 'none' : txInfo.subStatus;
this.logger.info(`* Contract call transaction completed with status ${status}; details: ${details}`);
return {
status: 'success',
receipt: txInfo
};
}
/**
* Retrieves the public key associated with the signer's address.
*
* @param address - The address of the signer
*
* @returns A promise that resolves to a Uint8Array representing the public key.
*/
async getPublicKey(address) {
const account = this.accounts.get(address.toLowerCase());
if (account === undefined) {
throw new Error(`no public key found for address: ${address}`);
}
return account.publicKey;
}
async getPublicKeyInfoForVaultAccount(change, addressIndex) {
if (!this.fireblocksClient || !this.vault) {
throw new Error('FireblocksSigner instance is not initialized');
}
const pubKeyArgs = {
assetId: this.config.assetId,
vaultAccountId: Number.parseInt(this.vault.id),
change,
addressIndex
};
return await this.fireblocksClient.getPublicKeyInfoForVaultAccount(pubKeyArgs);
}
}
exports.FireblocksSigner = FireblocksSigner;
async function newFireblocksSignerBackend(config) {
const { apiSecretKey, apiKey, apiUrl } = config;
const authProvider = (0, authProvider_1.getAuthProvider)(apiSecretKey, apiKey);
return new fireblocks_sdk_2.FireblocksSDK(apiSecretKey.trim(), apiKey.trim(), apiUrl, authProvider);
}
exports.newFireblocksSignerBackend = newFireblocksSignerBackend;