Skip to content

Commit 9370a10

Browse files
committed
feat(sdk-coin-icp): integrate ic0 library for account balance retrieval
Ticket: WIN-5479
1 parent 0795883 commit 9370a10

File tree

7 files changed

+307
-191
lines changed

7 files changed

+307
-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: 154 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,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

Comments
 (0)