@@ -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,7 @@ 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+ return { } ; // returned empty object as ICP does not return a txid
273240 } else {
274241 throw new Error ( `Unexpected response status from node: ${ decodedResponse . status } ` ) ;
275242 }
@@ -286,37 +253,60 @@ export class Icp extends BaseCoin {
286253 return endpoint ;
287254 }
288255
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+ }
292269 }
293270
294271 /**
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.
299276 */
300- protected async getAccountBalance ( address : string ) : Promise < string > {
277+ protected async getBalanceFromPrincipal ( principalId : string ) : Promise < string > {
301278 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 ) ] ,
306288 } ;
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 } ` ) ;
317294 }
318295 }
319296
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+
320310 private getBuilderFactory ( ) : TransactionBuilderFactory {
321311 return new TransactionBuilderFactory ( coins . get ( this . getBaseChain ( ) ) ) ;
322312 }
@@ -364,76 +354,113 @@ export class Icp extends BaseCoin {
364354 * Builds a funds recovery transaction without BitGo
365355 * @param params
366356 */
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+ }
371362
372- if ( ! params . userKey ) {
373- throw new Error ( 'missing userKey' ) ;
374- }
363+ const isUnsignedSweep = ! params . userKey && ! params . backupKey && ! params . walletPassphrase ;
375364
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 ( ) ;
379368
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+ }
383373
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+ }
386377
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+ }
394381
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, '' ) ;
398384
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+ ) ;
400406
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+ }
407409
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 } ` ) ;
436464 }
437- return result . txId ;
438465 }
439466}
0 commit comments