|
| 1 | +import BigNumber from 'bignumber.js'; |
| 2 | +import * as request from 'superagent'; |
1 | 3 | import { |
| 4 | + BaseBroadcastTransactionOptions, |
| 5 | + BaseBroadcastTransactionResult, |
2 | 6 | BaseCoin, |
3 | 7 | BitGoBase, |
4 | | - MPCAlgorithm, |
| 8 | + Ecdsa, |
| 9 | + ECDSAUtils, |
| 10 | + Environments, |
| 11 | + KeyPair, |
5 | 12 | MethodNotImplementedError, |
6 | | - VerifyTransactionOptions, |
7 | | - TssVerifyAddressOptions, |
| 13 | + MPCAlgorithm, |
| 14 | + MultisigType, |
| 15 | + multisigTypes, |
8 | 16 | ParseTransactionOptions, |
9 | 17 | ParsedTransaction, |
10 | | - KeyPair, |
11 | 18 | SignTransactionOptions, |
12 | 19 | SignedTransaction, |
13 | | - Environments, |
14 | | - MultisigType, |
15 | | - multisigTypes, |
| 20 | + TssVerifyAddressOptions, |
| 21 | + VerifyTransactionOptions, |
16 | 22 | } from '@bitgo/sdk-core'; |
17 | | -import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; |
| 23 | +import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; |
| 24 | +import { |
| 25 | + Network, |
| 26 | + PayloadsData, |
| 27 | + RecoveryOptions, |
| 28 | + Signatures, |
| 29 | + SigningPayload, |
| 30 | + ACCOUNT_BALANCE_ENDPOINT, |
| 31 | + ROOT_PATH, |
| 32 | + LEDGER_CANISTER_ID, |
| 33 | + PublicNodeSubmitResponse, |
| 34 | + CurveType, |
| 35 | + PUBLIC_NODE_REQUEST_ENDPOINT, |
| 36 | +} from './lib/iface'; |
| 37 | +import { TransactionBuilderFactory } from './lib/transactionBuilderFactory'; |
18 | 38 | import utils from './lib/utils'; |
| 39 | +import { createHash } from 'crypto'; |
| 40 | +import { Principal } from '@dfinity/principal'; |
| 41 | +import axios from 'axios'; |
19 | 42 |
|
20 | 43 | /** |
21 | 44 | * Class representing the Internet Computer (ICP) coin. |
@@ -117,6 +140,229 @@ export class Icp extends BaseCoin { |
117 | 140 |
|
118 | 141 | /** @inheritDoc **/ |
119 | 142 | protected getPublicNodeUrl(): string { |
120 | | - return Environments[this.bitgo.getEnv()].rosettaNodeURL; |
| 143 | + return Environments[this.bitgo.getEnv()].icpNodeUrl; |
| 144 | + } |
| 145 | + |
| 146 | + protected getRosettaNodeUrl(): string { |
| 147 | + return Environments[this.bitgo.getEnv()].icpRosettaNodeUrl; |
| 148 | + } |
| 149 | + |
| 150 | + /** |
| 151 | + * Sends a POST request to the Rosetta node with the specified payload and endpoint. |
| 152 | + * |
| 153 | + * @param payload - A JSON string representing the request payload to be sent to the Rosetta node. |
| 154 | + * @param endpoint - The endpoint path to append to the Rosetta node URL. |
| 155 | + * @returns A promise that resolves to the HTTP response from the Rosetta node. |
| 156 | + * @throws An error if the HTTP request fails or if the response status is not 200. |
| 157 | + */ |
| 158 | + protected async getRosettaNodeResponse(payload: string, endpoint: string): Promise<request.Response> { |
| 159 | + const nodeUrl = this.getRosettaNodeUrl(); |
| 160 | + const fullEndpoint = `${nodeUrl}${endpoint}`; |
| 161 | + const body = { |
| 162 | + network_identifier: { |
| 163 | + blockchain: this.getFullName(), |
| 164 | + network: Network.ID, |
| 165 | + }, |
| 166 | + ...JSON.parse(payload), |
| 167 | + }; |
| 168 | + |
| 169 | + try { |
| 170 | + const response = await request.post(fullEndpoint).set('Content-Type', 'application/json').send(body); |
| 171 | + if (response.status !== 200) { |
| 172 | + throw new Error(`Call to Rosetta node failed, got HTTP Status: ${response.status} with body: ${response.body}`); |
| 173 | + } |
| 174 | + return response; |
| 175 | + } catch (error) { |
| 176 | + throw new Error(`Unable to call rosetta node: ${error.message || error}`); |
| 177 | + } |
| 178 | + } |
| 179 | + |
| 180 | + /* inheritDoc */ |
| 181 | + // this method calls the public node to broadcast the transaction and not the rosetta node |
| 182 | + public async broadcastTransaction(payload: BaseBroadcastTransactionOptions): Promise<BaseBroadcastTransactionResult> { |
| 183 | + const endpoint = this.getPublicNodeBroadcastEndpoint(); |
| 184 | + |
| 185 | + try { |
| 186 | + const response = await axios.post(endpoint, payload.serializedSignedTransaction, { |
| 187 | + responseType: 'arraybuffer', // This ensures you get a Buffer, not a string |
| 188 | + headers: { |
| 189 | + 'Content-Type': 'application/cbor', |
| 190 | + }, |
| 191 | + }); |
| 192 | + |
| 193 | + if (response.status !== 200) { |
| 194 | + throw new Error(`Transaction broadcast failed with status: ${response.status} - ${response.statusText}`); |
| 195 | + } |
| 196 | + |
| 197 | + const decodedResponse = utils.cborDecode(response.data) as PublicNodeSubmitResponse; |
| 198 | + |
| 199 | + if (decodedResponse.status === 'replied') { |
| 200 | + const txnId = this.extractTransactionId(decodedResponse); |
| 201 | + return { txId: txnId }; |
| 202 | + } else { |
| 203 | + throw new Error(`Unexpected response status from node: ${decodedResponse.status}`); |
| 204 | + } |
| 205 | + } catch (error) { |
| 206 | + throw new Error(`Transaction broadcast error: ${error?.message || JSON.stringify(error)}`); |
| 207 | + } |
| 208 | + } |
| 209 | + |
| 210 | + private getPublicNodeBroadcastEndpoint(): string { |
| 211 | + const nodeUrl = this.getPublicNodeUrl(); |
| 212 | + const principal = Principal.fromUint8Array(LEDGER_CANISTER_ID); |
| 213 | + const canisterIdHex = principal.toText(); |
| 214 | + const endpoint = `${nodeUrl}${PUBLIC_NODE_REQUEST_ENDPOINT}${canisterIdHex}/call`; |
| 215 | + return endpoint; |
| 216 | + } |
| 217 | + |
| 218 | + // TODO: Implement the real logic to extract the transaction ID, Ticket: https://bitgoinc.atlassian.net/browse/WIN-5075 |
| 219 | + private extractTransactionId(decodedResponse: PublicNodeSubmitResponse): string { |
| 220 | + return '4c10cf22a768a20e7eebc86e49c031d0e22895a39c6355b5f7455b2acad59c1e'; |
| 221 | + } |
| 222 | + |
| 223 | + /** |
| 224 | + * Helper to fetch account balance |
| 225 | + * @param senderAddress - The address of the account to fetch the balance for |
| 226 | + * @returns The balance of the account as a string |
| 227 | + * @throws If the account is not found or there is an error fetching the balance |
| 228 | + */ |
| 229 | + protected async getAccountBalance(address: string): Promise<string> { |
| 230 | + try { |
| 231 | + const payload = { |
| 232 | + account_identifier: { |
| 233 | + address: address, |
| 234 | + }, |
| 235 | + }; |
| 236 | + const response = await this.getRosettaNodeResponse(JSON.stringify(payload), ACCOUNT_BALANCE_ENDPOINT); |
| 237 | + const coinName = this._staticsCoin.name.toUpperCase(); |
| 238 | + const balanceEntry = response.body.balances.find((b) => b.currency?.symbol === coinName); |
| 239 | + if (!balanceEntry) { |
| 240 | + throw new Error(`No balance found for ICP account ${address}.`); |
| 241 | + } |
| 242 | + const balance = balanceEntry.value; |
| 243 | + return balance; |
| 244 | + } catch (error) { |
| 245 | + throw new Error(`Unable to fetch account balance: ${error.message || error}`); |
| 246 | + } |
| 247 | + } |
| 248 | + |
| 249 | + private getBuilderFactory(): TransactionBuilderFactory { |
| 250 | + return new TransactionBuilderFactory(coins.get(this.getBaseChain())); |
| 251 | + } |
| 252 | + |
| 253 | + /** |
| 254 | + * Generates an array of signatures for the provided payloads using MPC |
| 255 | + * |
| 256 | + * @param payloadsData - The data containing the payloads to be signed. |
| 257 | + * @param senderPublicKey - The public key of the sender in hexadecimal format. |
| 258 | + * @param userKeyShare - The user's key share as a Buffer. |
| 259 | + * @param backupKeyShare - The backup key share as a Buffer. |
| 260 | + * @param commonKeyChain - The common key chain identifier used for MPC signing. |
| 261 | + * @returns A promise that resolves to an array of `Signatures` objects, each containing the signing payload, |
| 262 | + * signature type, public key, and the generated signature in hexadecimal format. |
| 263 | + */ |
| 264 | + async signatures( |
| 265 | + payloadsData: PayloadsData, |
| 266 | + senderPublicKey: string, |
| 267 | + userKeyShare: Buffer<ArrayBufferLike>, |
| 268 | + backupKeyShare: Buffer<ArrayBufferLike>, |
| 269 | + commonKeyChain: string |
| 270 | + ): Promise<Signatures[]> { |
| 271 | + try { |
| 272 | + const payload = payloadsData.payloads[0] as SigningPayload; |
| 273 | + const message = Buffer.from(payload.hex_bytes, 'hex'); |
| 274 | + const messageHash = createHash('sha256').update(message).digest(); |
| 275 | + const signature = await ECDSAUtils.signRecoveryMpcV2(messageHash, userKeyShare, backupKeyShare, commonKeyChain); |
| 276 | + const signaturePayload: Signatures = { |
| 277 | + signing_payload: payload, |
| 278 | + signature_type: payload.signature_type, |
| 279 | + public_key: { |
| 280 | + hex_bytes: senderPublicKey, |
| 281 | + curve_type: CurveType.SECP256K1, |
| 282 | + }, |
| 283 | + hex_bytes: signature.r + signature.s, |
| 284 | + }; |
| 285 | + |
| 286 | + return [signaturePayload]; |
| 287 | + } catch (error) { |
| 288 | + throw new Error(`Error generating signatures: ${error.message || error}`); |
| 289 | + } |
| 290 | + } |
| 291 | + |
| 292 | + /** |
| 293 | + * Builds a funds recovery transaction without BitGo |
| 294 | + * @param params |
| 295 | + */ |
| 296 | + async recover(params: RecoveryOptions): Promise<string> { |
| 297 | + if (!params.recoveryDestination || !this.isValidAddress(params.recoveryDestination)) { |
| 298 | + throw new Error('invalid recoveryDestination'); |
| 299 | + } |
| 300 | + |
| 301 | + if (!params.userKey) { |
| 302 | + throw new Error('missing userKey'); |
| 303 | + } |
| 304 | + |
| 305 | + if (!params.backupKey) { |
| 306 | + throw new Error('missing backupKey'); |
| 307 | + } |
| 308 | + |
| 309 | + if (!params.walletPassphrase) { |
| 310 | + throw new Error('missing wallet passphrase'); |
| 311 | + } |
| 312 | + |
| 313 | + const userKey = params.userKey.replace(/\s/g, ''); |
| 314 | + const backupKey = params.backupKey.replace(/\s/g, ''); |
| 315 | + |
| 316 | + const { userKeyShare, backupKeyShare, commonKeyChain } = await ECDSAUtils.getMpcV2RecoveryKeyShares( |
| 317 | + userKey, |
| 318 | + backupKey, |
| 319 | + params.walletPassphrase |
| 320 | + ); |
| 321 | + const MPC = new Ecdsa(); |
| 322 | + const publicKey = MPC.deriveUnhardened(commonKeyChain, ROOT_PATH).slice(0, 66); |
| 323 | + |
| 324 | + if (!publicKey || !backupKeyShare) { |
| 325 | + throw new Error('Missing publicKey or backupKeyShare'); |
| 326 | + } |
| 327 | + |
| 328 | + const senderAddress = await this.getAddressFromPublicKey(publicKey); |
| 329 | + |
| 330 | + const balance = new BigNumber(await this.getAccountBalance(senderAddress)); |
| 331 | + const feeData = new BigNumber(utils.feeData()); |
| 332 | + const actualBalance = balance.plus(feeData); // gas amount returned from gasData is negative so we add it |
| 333 | + if (actualBalance.isLessThanOrEqualTo(0)) { |
| 334 | + throw new Error('Did not have enough funds to recover'); |
| 335 | + } |
| 336 | + |
| 337 | + const factory = this.getBuilderFactory(); |
| 338 | + const txBuilder = factory.getTransferBuilder(); |
| 339 | + txBuilder.sender(senderAddress, publicKey as string); |
| 340 | + txBuilder.receiverId(params.recoveryDestination); |
| 341 | + txBuilder.amount(actualBalance.toString()); |
| 342 | + if (params.memo !== undefined && utils.validateMemo(params.memo)) { |
| 343 | + txBuilder.memo(Number(params.memo)); |
| 344 | + } |
| 345 | + await txBuilder.build(); |
| 346 | + if (txBuilder.transaction.payloadsData.payloads.length === 0) { |
| 347 | + throw new Error('Missing payloads to generate signatures'); |
| 348 | + } |
| 349 | + const signatures = await this.signatures( |
| 350 | + txBuilder.transaction.payloadsData, |
| 351 | + publicKey, |
| 352 | + userKeyShare, |
| 353 | + backupKeyShare, |
| 354 | + commonKeyChain |
| 355 | + ); |
| 356 | + if (!signatures || signatures.length === 0) { |
| 357 | + throw new Error('Failed to generate signatures'); |
| 358 | + } |
| 359 | + txBuilder.transaction.addSignature(signatures); |
| 360 | + txBuilder.combine(); |
| 361 | + const broadcastableTxn = txBuilder.transaction.toBroadcastFormat(); |
| 362 | + const result = await this.broadcastTransaction({ serializedSignedTransaction: broadcastableTxn }); |
| 363 | + if (!result.txId) { |
| 364 | + throw new Error('Transaction failed to broadcast'); |
| 365 | + } |
| 366 | + return result.txId; |
121 | 367 | } |
122 | 368 | } |
0 commit comments