Skip to content

Commit 71d93ce

Browse files
authored
Merge pull request #6118 from BitGo/WIN-5479
feat(sdk-coin-icp): integrate ic0 library for account balance retrieval
2 parents edd5b27 + 63df09e commit 71d93ce

File tree

7 files changed

+308
-191
lines changed

7 files changed

+308
-191
lines changed

modules/sdk-coin-icp/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
},
4343
"dependencies": {
4444
"@bitgo/sdk-core": "^33.2.0",
45+
"@bitgo/sdk-lib-mpc": "^10.2.0",
4546
"@bitgo/secp256k1": "^1.3.3",
4647
"@bitgo/statics": "^52.2.0",
4748
"@dfinity/agent": "^2.2.0",
@@ -51,10 +52,10 @@
5152
"bignumber.js": "^9.1.1",
5253
"cbor-x": "^1.6.0",
5354
"crc-32": "^1.2.0",
55+
"ic0": "^0.3.2",
5456
"js-sha256": "^0.9.0",
5557
"long": "^5.3.2",
56-
"protobufjs": "^7.5.0",
57-
"superagent": "^10.1.1"
58+
"protobufjs": "^7.5.0"
5859
},
5960
"devDependencies": {
6061
"@bitgo/sdk-api": "^1.62.3",

modules/sdk-coin-icp/src/icp.ts

Lines changed: 155 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,24 @@ import { Principal } from '@dfinity/principal';
2424
import axios from 'axios';
2525
import BigNumber from 'bignumber.js';
2626
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+
2830
import {
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';
4346
import { TransactionBuilderFactory } from './lib/transactionBuilderFactory';
4447
import 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

Comments
 (0)