@@ -24,21 +24,24 @@ import { Principal } from '@dfinity/principal';
2424import axios from 'axios' ;
2525import BigNumber from 'bignumber.js' ;
2626import { 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+
2830import {
29- ACCOUNT_BALANCE_ENDPOINT ,
3031 CurveType ,
3132 LEDGER_CANISTER_ID ,
32- Network ,
3333 PayloadsData ,
3434 PUBLIC_NODE_REQUEST_ENDPOINT ,
3535 PublicNodeSubmitResponse ,
3636 RecoveryOptions ,
37+ RecoveryTransaction ,
3738 ROOT_PATH ,
3839 Signatures ,
3940 SigningPayload ,
4041 IcpTransactionExplanation ,
4142 TransactionHexParams ,
43+ ACCOUNT_BALANCE_CALL ,
44+ UnsignedSweepRecoveryTransaction ,
4245} from './lib/iface' ;
4346import { TransactionBuilderFactory } from './lib/transactionBuilderFactory' ;
4447import utils from './lib/utils' ;
@@ -214,51 +217,16 @@ export class Icp extends BaseCoin {
214217 return Environments [ this . bitgo . getEnv ( ) ] . icpNodeUrl ;
215218 }
216219
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 **/
252221 // this method calls the public node to broadcast the transaction and not the rosetta node
253222 public async broadcastTransaction ( payload : BaseBroadcastTransactionOptions ) : Promise < BaseBroadcastTransactionResult > {
254223 const endpoint = this . getPublicNodeBroadcastEndpoint ( ) ;
255224
256225 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' } ,
258229 responseType : 'arraybuffer' , // This ensures you get a Buffer, not a string
259- headers : {
260- 'Content-Type' : 'application/cbor' ,
261- } ,
262230 } ) ;
263231
264232 if ( response . status !== 200 ) {
@@ -268,8 +236,8 @@ export class Icp extends BaseCoin {
268236 const decodedResponse = utils . cborDecode ( response . data ) as PublicNodeSubmitResponse ;
269237
270238 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
273241 } else {
274242 throw new Error ( `Unexpected response status from node: ${ decodedResponse . status } ` ) ;
275243 }
@@ -286,37 +254,60 @@ export class Icp extends BaseCoin {
286254 return endpoint ;
287255 }
288256
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+ }
292270 }
293271
294272 /**
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.
299277 */
300- protected async getAccountBalance ( address : string ) : Promise < string > {
278+ protected async getBalanceFromPrincipal ( principalId : string ) : Promise < string > {
301279 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 ) ] ,
306289 } ;
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 } ` ) ;
317295 }
318296 }
319297
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+
320311 private getBuilderFactory ( ) : TransactionBuilderFactory {
321312 return new TransactionBuilderFactory ( coins . get ( this . getBaseChain ( ) ) ) ;
322313 }
@@ -364,76 +355,113 @@ export class Icp extends BaseCoin {
364355 * Builds a funds recovery transaction without BitGo
365356 * @param params
366357 */
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+ }
371363
372- if ( ! params . userKey ) {
373- throw new Error ( 'missing userKey' ) ;
374- }
364+ const isUnsignedSweep = ! params . userKey && ! params . backupKey && ! params . walletPassphrase ;
375365
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 ( ) ;
379369
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+ }
383374
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+ }
386378
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+ }
394382
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, '' ) ;
398385
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+ ) ;
400407
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+ }
407410
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 } ` ) ;
436465 }
437- return result . txId ;
438466 }
439467}
0 commit comments