@@ -24,21 +24,24 @@ import { Principal } from '@dfinity/principal';
24
24
import axios from 'axios' ;
25
25
import BigNumber from 'bignumber.js' ;
26
26
import { createHash , Hash } from 'crypto' ;
27
- import * as request from 'superagent' ;
27
+ import { HttpAgent , replica } from 'ic0' ;
28
+ import * as mpc from '@bitgo/sdk-lib-mpc' ;
29
+
28
30
import {
29
- ACCOUNT_BALANCE_ENDPOINT ,
30
31
CurveType ,
31
32
LEDGER_CANISTER_ID ,
32
- Network ,
33
33
PayloadsData ,
34
34
PUBLIC_NODE_REQUEST_ENDPOINT ,
35
35
PublicNodeSubmitResponse ,
36
36
RecoveryOptions ,
37
+ RecoveryTransaction ,
37
38
ROOT_PATH ,
38
39
Signatures ,
39
40
SigningPayload ,
40
41
IcpTransactionExplanation ,
41
42
TransactionHexParams ,
43
+ ACCOUNT_BALANCE_CALL ,
44
+ UnsignedSweepRecoveryTransaction ,
42
45
} from './lib/iface' ;
43
46
import { TransactionBuilderFactory } from './lib/transactionBuilderFactory' ;
44
47
import utils from './lib/utils' ;
@@ -214,51 +217,16 @@ export class Icp extends BaseCoin {
214
217
return Environments [ this . bitgo . getEnv ( ) ] . icpNodeUrl ;
215
218
}
216
219
217
- protected getRosettaNodeUrl ( ) : string {
218
- return Environments [ this . bitgo . getEnv ( ) ] . icpRosettaNodeUrl ;
219
- }
220
-
221
- /**
222
- * Sends a POST request to the Rosetta node with the specified payload and endpoint.
223
- *
224
- * @param payload - A JSON string representing the request payload to be sent to the Rosetta node.
225
- * @param endpoint - The endpoint path to append to the Rosetta node URL.
226
- * @returns A promise that resolves to the HTTP response from the Rosetta node.
227
- * @throws An error if the HTTP request fails or if the response status is not 200.
228
- */
229
- protected async getRosettaNodeResponse ( payload : string , endpoint : string ) : Promise < request . Response > {
230
- const nodeUrl = this . getRosettaNodeUrl ( ) ;
231
- const fullEndpoint = `${ nodeUrl } ${ endpoint } ` ;
232
- const body = {
233
- network_identifier : {
234
- blockchain : this . getFullName ( ) ,
235
- network : Network . ID ,
236
- } ,
237
- ...JSON . parse ( payload ) ,
238
- } ;
239
-
240
- try {
241
- const response = await request . post ( fullEndpoint ) . set ( 'Content-Type' , 'application/json' ) . send ( body ) ;
242
- if ( response . status !== 200 ) {
243
- throw new Error ( `Call to Rosetta node failed, got HTTP Status: ${ response . status } with body: ${ response . body } ` ) ;
244
- }
245
- return response ;
246
- } catch ( error ) {
247
- throw new Error ( `Unable to call rosetta node: ${ error . message || error } ` ) ;
248
- }
249
- }
250
-
251
- /* inheritDoc */
220
+ /** @inheritDoc **/
252
221
// this method calls the public node to broadcast the transaction and not the rosetta node
253
222
public async broadcastTransaction ( payload : BaseBroadcastTransactionOptions ) : Promise < BaseBroadcastTransactionResult > {
254
223
const endpoint = this . getPublicNodeBroadcastEndpoint ( ) ;
255
224
256
225
try {
257
- const response = await axios . post ( endpoint , payload . serializedSignedTransaction , {
226
+ const bodyBytes = utils . blobFromHex ( payload . serializedSignedTransaction ) ;
227
+ const response = await axios . post ( endpoint , bodyBytes , {
228
+ headers : { 'Content-Type' : 'application/cbor' } ,
258
229
responseType : 'arraybuffer' , // This ensures you get a Buffer, not a string
259
- headers : {
260
- 'Content-Type' : 'application/cbor' ,
261
- } ,
262
230
} ) ;
263
231
264
232
if ( response . status !== 200 ) {
@@ -268,8 +236,7 @@ export class Icp extends BaseCoin {
268
236
const decodedResponse = utils . cborDecode ( response . data ) as PublicNodeSubmitResponse ;
269
237
270
238
if ( decodedResponse . status === 'replied' ) {
271
- const txnId = this . extractTransactionId ( decodedResponse ) ;
272
- return { txId : txnId } ;
239
+ return { } ; // returned empty object as ICP does not return a txid
273
240
} else {
274
241
throw new Error ( `Unexpected response status from node: ${ decodedResponse . status } ` ) ;
275
242
}
@@ -286,37 +253,60 @@ export class Icp extends BaseCoin {
286
253
return endpoint ;
287
254
}
288
255
289
- // TODO: Implement the real logic to extract the transaction ID, Ticket: https://bitgoinc.atlassian.net/browse/WIN-5075
290
- private extractTransactionId ( decodedResponse : PublicNodeSubmitResponse ) : string {
291
- return '4c10cf22a768a20e7eebc86e49c031d0e22895a39c6355b5f7455b2acad59c1e' ;
256
+ /**
257
+ * Fetches the account balance for a given public key.
258
+ * @param publicKeyHex - Hex-encoded public key of the account.
259
+ * @returns Promise resolving to the account balance as a string.
260
+ * @throws Error if the balance could not be fetched.
261
+ */
262
+ protected async getAccountBalance ( publicKeyHex : string ) : Promise < string > {
263
+ try {
264
+ const principalId = utils . getPrincipalIdFromPublicKey ( publicKeyHex ) . toText ( ) ;
265
+ return await this . getBalanceFromPrincipal ( principalId ) ;
266
+ } catch ( error : any ) {
267
+ throw new Error ( `Unable to fetch account balance: ${ error . message || error } ` ) ;
268
+ }
292
269
}
293
270
294
271
/**
295
- * Helper to fetch account balance
296
- * @param senderAddress - The address of the account to fetch the balance for
297
- * @returns The balance of the account as a string
298
- * @throws If the account is not found or there is an error fetching the balance
272
+ * Fetches the account balance for a given principal ID.
273
+ * @param principalId - The principal ID of the account.
274
+ * @returns Promise resolving to the account balance as a string.
275
+ * @throws Error if the balance could not be fetched.
299
276
*/
300
- protected async getAccountBalance ( address : string ) : Promise < string > {
277
+ protected async getBalanceFromPrincipal ( principalId : string ) : Promise < string > {
301
278
try {
302
- const payload = {
303
- account_identifier : {
304
- address : address ,
305
- } ,
279
+ const agent = this . createAgent ( ) ;
280
+ const ic = replica ( agent , { local : true } ) ;
281
+
282
+ const ledger = ic ( Principal . fromUint8Array ( LEDGER_CANISTER_ID ) . toText ( ) ) ;
283
+ const subaccountHex = '0000000000000000000000000000000000000000000000000000000000000000' ;
284
+
285
+ const account = {
286
+ owner : Principal . fromText ( principalId ) ,
287
+ subaccount : [ utils . hexToBytes ( subaccountHex ) ] ,
306
288
} ;
307
- const response = await this . getRosettaNodeResponse ( JSON . stringify ( payload ) , ACCOUNT_BALANCE_ENDPOINT ) ;
308
- const coinName = this . _staticsCoin . name . toUpperCase ( ) ;
309
- const balanceEntry = response . body . balances . find ( ( b ) => b . currency ?. symbol === coinName ) ;
310
- if ( ! balanceEntry ) {
311
- throw new Error ( `No balance found for ICP account ${ address } .` ) ;
312
- }
313
- const balance = balanceEntry . value ;
314
- return balance ;
315
- } catch ( error ) {
316
- throw new Error ( `Unable to fetch account balance: ${ error . message || error } ` ) ;
289
+
290
+ const balance = await ledger . call ( ACCOUNT_BALANCE_CALL , account ) ;
291
+ return balance . toString ( ) ;
292
+ } catch ( error : any ) {
293
+ throw new Error ( `Error fetching balance for principal ${ principalId } : ${ error . message || error } ` ) ;
317
294
}
318
295
}
319
296
297
+ /**
298
+ * Creates a new HTTP agent for communicating with the Internet Computer.
299
+ * @param host - The host URL to connect to (defaults to the public node URL).
300
+ * @returns An instance of HttpAgent.
301
+ */
302
+ protected createAgent ( host : string = this . getPublicNodeUrl ( ) ) : HttpAgent {
303
+ return new HttpAgent ( {
304
+ host,
305
+ fetch,
306
+ verifyQuerySignatures : false ,
307
+ } ) ;
308
+ }
309
+
320
310
private getBuilderFactory ( ) : TransactionBuilderFactory {
321
311
return new TransactionBuilderFactory ( coins . get ( this . getBaseChain ( ) ) ) ;
322
312
}
@@ -364,76 +354,113 @@ export class Icp extends BaseCoin {
364
354
* Builds a funds recovery transaction without BitGo
365
355
* @param params
366
356
*/
367
- async recover ( params : RecoveryOptions ) : Promise < string > {
368
- if ( ! params . recoveryDestination || ! this . isValidAddress ( params . recoveryDestination ) ) {
369
- throw new Error ( 'invalid recoveryDestination' ) ;
370
- }
357
+ async recover ( params : RecoveryOptions ) : Promise < RecoveryTransaction | UnsignedSweepRecoveryTransaction > {
358
+ try {
359
+ if ( ! params . recoveryDestination || ! this . isValidAddress ( params . recoveryDestination ) ) {
360
+ throw new Error ( 'invalid recoveryDestination' ) ;
361
+ }
371
362
372
- if ( ! params . userKey ) {
373
- throw new Error ( 'missing userKey' ) ;
374
- }
363
+ const isUnsignedSweep = ! params . userKey && ! params . backupKey && ! params . walletPassphrase ;
375
364
376
- if ( ! params . backupKey ) {
377
- throw new Error ( 'missing backupKey' ) ;
378
- }
365
+ let publicKey : string | undefined ;
366
+ let userKeyShare , backupKeyShare , commonKeyChain ;
367
+ const MPC = new Ecdsa ( ) ;
379
368
380
- if ( ! params . walletPassphrase ) {
381
- throw new Error ( 'missing wallet passphrase' ) ;
382
- }
369
+ if ( ! isUnsignedSweep ) {
370
+ if ( ! params . userKey ) {
371
+ throw new Error ( 'missing userKey' ) ;
372
+ }
383
373
384
- const userKey = params . userKey . replace ( / \s / g, '' ) ;
385
- const backupKey = params . backupKey . replace ( / \s / g, '' ) ;
374
+ if ( ! params . backupKey ) {
375
+ throw new Error ( 'missing backupKey' ) ;
376
+ }
386
377
387
- const { userKeyShare, backupKeyShare, commonKeyChain } = await ECDSAUtils . getMpcV2RecoveryKeyShares (
388
- userKey ,
389
- backupKey ,
390
- params . walletPassphrase
391
- ) ;
392
- const MPC = new Ecdsa ( ) ;
393
- const publicKey = MPC . deriveUnhardened ( commonKeyChain , ROOT_PATH ) . slice ( 0 , 66 ) ;
378
+ if ( ! params . walletPassphrase ) {
379
+ throw new Error ( 'missing wallet passphrase' ) ;
380
+ }
394
381
395
- if ( ! publicKey || ! backupKeyShare ) {
396
- throw new Error ( 'Missing publicKey or backupKeyShare' ) ;
397
- }
382
+ const userKey = params . userKey . replace ( / \s / g, '' ) ;
383
+ const backupKey = params . backupKey . replace ( / \s / g, '' ) ;
398
384
399
- const senderAddress = await this . getAddressFromPublicKey ( publicKey ) ;
385
+ ( { userKeyShare, backupKeyShare, commonKeyChain } = await ECDSAUtils . getMpcV2RecoveryKeyShares (
386
+ userKey ,
387
+ backupKey ,
388
+ params . walletPassphrase
389
+ ) ) ;
390
+ publicKey = MPC . deriveUnhardened ( commonKeyChain , ROOT_PATH ) . slice ( 0 , 66 ) ;
391
+ } else {
392
+ const bitgoKey = params . bitgoKey ;
393
+ if ( ! bitgoKey ) {
394
+ throw new Error ( 'missing bitgoKey' ) ;
395
+ }
396
+
397
+ const hdTree = new mpc . Secp256k1Bip32HdTree ( ) ;
398
+ const derivationPath = 'm/0' ;
399
+ const derivedPub = hdTree . publicDerive (
400
+ {
401
+ pk : mpc . bigIntFromBufferBE ( Buffer . from ( bitgoKey . slice ( 0 , 66 ) , 'hex' ) ) ,
402
+ chaincode : mpc . bigIntFromBufferBE ( Buffer . from ( bitgoKey . slice ( 66 ) , 'hex' ) ) ,
403
+ } ,
404
+ derivationPath
405
+ ) ;
400
406
401
- const balance = new BigNumber ( await this . getAccountBalance ( senderAddress ) ) ;
402
- const feeData = new BigNumber ( utils . feeData ( ) ) ;
403
- const actualBalance = balance . plus ( feeData ) ; // gas amount returned from gasData is negative so we add it
404
- if ( actualBalance . isLessThanOrEqualTo ( 0 ) ) {
405
- throw new Error ( 'Did not have enough funds to recover' ) ;
406
- }
407
+ publicKey = mpc . bigIntToBufferBE ( derivedPub . pk ) . toString ( 'hex' ) ;
408
+ }
407
409
408
- const factory = this . getBuilderFactory ( ) ;
409
- const txBuilder = factory . getTransferBuilder ( ) ;
410
- txBuilder . sender ( senderAddress , publicKey as string ) ;
411
- txBuilder . receiverId ( params . recoveryDestination ) ;
412
- txBuilder . amount ( actualBalance . toString ( ) ) ;
413
- if ( params . memo !== undefined && utils . validateMemo ( params . memo ) ) {
414
- txBuilder . memo ( Number ( params . memo ) ) ;
415
- }
416
- await txBuilder . build ( ) ;
417
- if ( txBuilder . transaction . payloadsData . payloads . length === 0 ) {
418
- throw new Error ( 'Missing payloads to generate signatures' ) ;
419
- }
420
- const signatures = await this . signatures (
421
- txBuilder . transaction . payloadsData ,
422
- publicKey ,
423
- userKeyShare ,
424
- backupKeyShare ,
425
- commonKeyChain
426
- ) ;
427
- if ( ! signatures || signatures . length === 0 ) {
428
- throw new Error ( 'Failed to generate signatures' ) ;
429
- }
430
- txBuilder . transaction . addSignature ( signatures ) ;
431
- txBuilder . combine ( ) ;
432
- const broadcastableTxn = txBuilder . transaction . toBroadcastFormat ( ) ;
433
- const result = await this . broadcastTransaction ( { serializedSignedTransaction : broadcastableTxn } ) ;
434
- if ( ! result . txId ) {
435
- throw new Error ( 'Transaction failed to broadcast' ) ;
410
+ if ( ! publicKey ) {
411
+ throw new Error ( 'failed to derive public key' ) ;
412
+ }
413
+
414
+ const senderAddress = await this . getAddressFromPublicKey ( publicKey ) ;
415
+ const balance = new BigNumber ( await this . getAccountBalance ( publicKey ) ) ;
416
+ const feeData = new BigNumber ( utils . feeData ( ) ) ;
417
+ const actualBalance = balance . plus ( feeData ) ; // gas amount returned from gasData is negative so we add it
418
+ if ( actualBalance . isLessThanOrEqualTo ( 0 ) ) {
419
+ throw new Error ( 'Did not have enough funds to recover' ) ;
420
+ }
421
+
422
+ const factory = this . getBuilderFactory ( ) ;
423
+ const txBuilder = factory . getTransferBuilder ( ) ;
424
+ txBuilder . sender ( senderAddress , publicKey as string ) ;
425
+ txBuilder . receiverId ( params . recoveryDestination ) ;
426
+ txBuilder . amount ( actualBalance . toString ( ) ) ;
427
+ if ( params . memo !== undefined && utils . validateMemo ( params . memo ) ) {
428
+ txBuilder . memo ( Number ( params . memo ) ) ;
429
+ }
430
+ await txBuilder . build ( ) ;
431
+ if ( txBuilder . transaction . payloadsData . payloads . length === 0 ) {
432
+ throw new Error ( 'Missing payloads to generate signatures' ) ;
433
+ }
434
+
435
+ if ( isUnsignedSweep ) {
436
+ return {
437
+ txHex : txBuilder . transaction . unsignedTransaction ,
438
+ coin : this . getChain ( ) ,
439
+ } ;
440
+ }
441
+
442
+ const signatures = await this . signatures (
443
+ txBuilder . transaction . payloadsData ,
444
+ publicKey ,
445
+ userKeyShare ,
446
+ backupKeyShare ,
447
+ commonKeyChain
448
+ ) ;
449
+ if ( ! signatures || signatures . length === 0 ) {
450
+ throw new Error ( 'Failed to generate signatures' ) ;
451
+ }
452
+ txBuilder . transaction . addSignature ( signatures ) ;
453
+ txBuilder . combine ( ) ;
454
+ const broadcastableTxn = txBuilder . transaction . toBroadcastFormat ( ) ;
455
+ await this . broadcastTransaction ( { serializedSignedTransaction : broadcastableTxn } ) ;
456
+ const txId = txBuilder . transaction . id ;
457
+ const recoveredTransaction : RecoveryTransaction = {
458
+ id : txId ,
459
+ tx : broadcastableTxn ,
460
+ } ;
461
+ return recoveredTransaction ;
462
+ } catch ( error ) {
463
+ throw new Error ( `Error during ICP recovery: ${ error . message || error } ` ) ;
436
464
}
437
- return result . txId ;
438
465
}
439
466
}
0 commit comments