@@ -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,8 @@ 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
+ // it is considered a success because ICP returns response in a CBOR map with a status of 'replied'
240
+ return { } ; // returned empty object as ICP does not return a txid
273
241
} else {
274
242
throw new Error ( `Unexpected response status from node: ${ decodedResponse . status } ` ) ;
275
243
}
@@ -286,37 +254,60 @@ export class Icp extends BaseCoin {
286
254
return endpoint ;
287
255
}
288
256
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' ;
257
+ /**
258
+ * Fetches the account balance for a given public key.
259
+ * @param publicKeyHex - Hex-encoded public key of the account.
260
+ * @returns Promise resolving to the account balance as a string.
261
+ * @throws Error if the balance could not be fetched.
262
+ */
263
+ protected async getAccountBalance ( publicKeyHex : string ) : Promise < string > {
264
+ try {
265
+ const principalId = utils . getPrincipalIdFromPublicKey ( publicKeyHex ) . toText ( ) ;
266
+ return await this . getBalanceFromPrincipal ( principalId ) ;
267
+ } catch ( error : any ) {
268
+ throw new Error ( `Unable to fetch account balance: ${ error . message || error } ` ) ;
269
+ }
292
270
}
293
271
294
272
/**
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
273
+ * Fetches the account balance for a given principal ID.
274
+ * @param principalId - The principal ID of the account.
275
+ * @returns Promise resolving to the account balance as a string.
276
+ * @throws Error if the balance could not be fetched.
299
277
*/
300
- protected async getAccountBalance ( address : string ) : Promise < string > {
278
+ protected async getBalanceFromPrincipal ( principalId : string ) : Promise < string > {
301
279
try {
302
- const payload = {
303
- account_identifier : {
304
- address : address ,
305
- } ,
280
+ const agent = this . createAgent ( ) ; // TODO: WIN-5512: move to a ICP agent file WIN-5512
281
+ const ic = replica ( agent , { local : true } ) ;
282
+
283
+ const ledger = ic ( Principal . fromUint8Array ( LEDGER_CANISTER_ID ) . toText ( ) ) ;
284
+ const subaccountHex = '0000000000000000000000000000000000000000000000000000000000000000' ;
285
+
286
+ const account = {
287
+ owner : Principal . fromText ( principalId ) ,
288
+ subaccount : [ utils . hexToBytes ( subaccountHex ) ] ,
306
289
} ;
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 } ` ) ;
290
+
291
+ const balance = await ledger . call ( ACCOUNT_BALANCE_CALL , account ) ;
292
+ return balance . toString ( ) ;
293
+ } catch ( error : any ) {
294
+ throw new Error ( `Error fetching balance for principal ${ principalId } : ${ error . message || error } ` ) ;
317
295
}
318
296
}
319
297
298
+ /**
299
+ * Creates a new HTTP agent for communicating with the Internet Computer.
300
+ * @param host - The host URL to connect to (defaults to the public node URL).
301
+ * @returns An instance of HttpAgent.
302
+ */
303
+ protected createAgent ( host : string = this . getPublicNodeUrl ( ) ) : HttpAgent {
304
+ return new HttpAgent ( {
305
+ host,
306
+ fetch,
307
+ verifyQuerySignatures : false ,
308
+ } ) ;
309
+ }
310
+
320
311
private getBuilderFactory ( ) : TransactionBuilderFactory {
321
312
return new TransactionBuilderFactory ( coins . get ( this . getBaseChain ( ) ) ) ;
322
313
}
@@ -364,76 +355,113 @@ export class Icp extends BaseCoin {
364
355
* Builds a funds recovery transaction without BitGo
365
356
* @param params
366
357
*/
367
- async recover ( params : RecoveryOptions ) : Promise < string > {
368
- if ( ! params . recoveryDestination || ! this . isValidAddress ( params . recoveryDestination ) ) {
369
- throw new Error ( 'invalid recoveryDestination' ) ;
370
- }
358
+ async recover ( params : RecoveryOptions ) : Promise < RecoveryTransaction | UnsignedSweepRecoveryTransaction > {
359
+ try {
360
+ if ( ! params . recoveryDestination || ! this . isValidAddress ( params . recoveryDestination ) ) {
361
+ throw new Error ( 'invalid recoveryDestination' ) ;
362
+ }
371
363
372
- if ( ! params . userKey ) {
373
- throw new Error ( 'missing userKey' ) ;
374
- }
364
+ const isUnsignedSweep = ! params . userKey && ! params . backupKey && ! params . walletPassphrase ;
375
365
376
- if ( ! params . backupKey ) {
377
- throw new Error ( 'missing backupKey' ) ;
378
- }
366
+ let publicKey : string | undefined ;
367
+ let userKeyShare , backupKeyShare , commonKeyChain ;
368
+ const MPC = new Ecdsa ( ) ;
379
369
380
- if ( ! params . walletPassphrase ) {
381
- throw new Error ( 'missing wallet passphrase' ) ;
382
- }
370
+ if ( ! isUnsignedSweep ) {
371
+ if ( ! params . userKey ) {
372
+ throw new Error ( 'missing userKey' ) ;
373
+ }
383
374
384
- const userKey = params . userKey . replace ( / \s / g, '' ) ;
385
- const backupKey = params . backupKey . replace ( / \s / g, '' ) ;
375
+ if ( ! params . backupKey ) {
376
+ throw new Error ( 'missing backupKey' ) ;
377
+ }
386
378
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 ) ;
379
+ if ( ! params . walletPassphrase ) {
380
+ throw new Error ( 'missing wallet passphrase' ) ;
381
+ }
394
382
395
- if ( ! publicKey || ! backupKeyShare ) {
396
- throw new Error ( 'Missing publicKey or backupKeyShare' ) ;
397
- }
383
+ const userKey = params . userKey . replace ( / \s / g, '' ) ;
384
+ const backupKey = params . backupKey . replace ( / \s / g, '' ) ;
398
385
399
- const senderAddress = await this . getAddressFromPublicKey ( publicKey ) ;
386
+ ( { userKeyShare, backupKeyShare, commonKeyChain } = await ECDSAUtils . getMpcV2RecoveryKeyShares (
387
+ userKey ,
388
+ backupKey ,
389
+ params . walletPassphrase
390
+ ) ) ;
391
+ publicKey = MPC . deriveUnhardened ( commonKeyChain , ROOT_PATH ) . slice ( 0 , 66 ) ;
392
+ } else {
393
+ const bitgoKey = params . bitgoKey ;
394
+ if ( ! bitgoKey ) {
395
+ throw new Error ( 'missing bitgoKey' ) ;
396
+ }
397
+
398
+ const hdTree = new mpc . Secp256k1Bip32HdTree ( ) ;
399
+ const derivationPath = 'm/0' ;
400
+ const derivedPub = hdTree . publicDerive (
401
+ {
402
+ pk : mpc . bigIntFromBufferBE ( Buffer . from ( bitgoKey . slice ( 0 , 66 ) , 'hex' ) ) ,
403
+ chaincode : mpc . bigIntFromBufferBE ( Buffer . from ( bitgoKey . slice ( 66 ) , 'hex' ) ) ,
404
+ } ,
405
+ derivationPath
406
+ ) ;
400
407
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
- }
408
+ publicKey = mpc . bigIntToBufferBE ( derivedPub . pk ) . toString ( 'hex' ) ;
409
+ }
407
410
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' ) ;
411
+ if ( ! publicKey ) {
412
+ throw new Error ( 'failed to derive public key' ) ;
413
+ }
414
+
415
+ const senderAddress = await this . getAddressFromPublicKey ( publicKey ) ;
416
+ const balance = new BigNumber ( await this . getAccountBalance ( publicKey ) ) ;
417
+ const feeData = new BigNumber ( utils . feeData ( ) ) ;
418
+ const actualBalance = balance . plus ( feeData ) ; // gas amount returned from gasData is negative so we add it
419
+ if ( actualBalance . isLessThanOrEqualTo ( 0 ) ) {
420
+ throw new Error ( 'Did not have enough funds to recover' ) ;
421
+ }
422
+
423
+ const factory = this . getBuilderFactory ( ) ;
424
+ const txBuilder = factory . getTransferBuilder ( ) ;
425
+ txBuilder . sender ( senderAddress , publicKey as string ) ;
426
+ txBuilder . receiverId ( params . recoveryDestination ) ;
427
+ txBuilder . amount ( actualBalance . toString ( ) ) ;
428
+ if ( params . memo !== undefined && utils . validateMemo ( params . memo ) ) {
429
+ txBuilder . memo ( Number ( params . memo ) ) ;
430
+ }
431
+ await txBuilder . build ( ) ;
432
+ if ( txBuilder . transaction . payloadsData . payloads . length === 0 ) {
433
+ throw new Error ( 'Missing payloads to generate signatures' ) ;
434
+ }
435
+
436
+ if ( isUnsignedSweep ) {
437
+ return {
438
+ txHex : txBuilder . transaction . unsignedTransaction ,
439
+ coin : this . getChain ( ) ,
440
+ } ;
441
+ }
442
+
443
+ const signatures = await this . signatures (
444
+ txBuilder . transaction . payloadsData ,
445
+ publicKey ,
446
+ userKeyShare ,
447
+ backupKeyShare ,
448
+ commonKeyChain
449
+ ) ;
450
+ if ( ! signatures || signatures . length === 0 ) {
451
+ throw new Error ( 'Failed to generate signatures' ) ;
452
+ }
453
+ txBuilder . transaction . addSignature ( signatures ) ;
454
+ txBuilder . combine ( ) ;
455
+ const broadcastableTxn = txBuilder . transaction . toBroadcastFormat ( ) ;
456
+ await this . broadcastTransaction ( { serializedSignedTransaction : broadcastableTxn } ) ;
457
+ const txId = txBuilder . transaction . id ;
458
+ const recoveredTransaction : RecoveryTransaction = {
459
+ id : txId ,
460
+ tx : broadcastableTxn ,
461
+ } ;
462
+ return recoveredTransaction ;
463
+ } catch ( error ) {
464
+ throw new Error ( `Error during ICP recovery: ${ error . message || error } ` ) ;
436
465
}
437
- return result . txId ;
438
466
}
439
467
}
0 commit comments