Skip to content

Commit 5b1c928

Browse files
committed
feat(sdk-coin-icp): refactor account balance and fee retrieval to use IcpAgent class
Ticket: Win-5302
1 parent a80f608 commit 5b1c928

File tree

5 files changed

+127
-60
lines changed

5 files changed

+127
-60
lines changed

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

Lines changed: 18 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import { Principal } from '@dfinity/principal';
2525
import axios from 'axios';
2626
import BigNumber from 'bignumber.js';
2727
import { createHash, Hash } from 'crypto';
28-
import { HttpAgent, replica } from 'ic0';
2928
import * as mpc from '@bitgo/sdk-lib-mpc';
3029

3130
import {
@@ -41,12 +40,12 @@ import {
4140
SigningPayload,
4241
IcpTransactionExplanation,
4342
TransactionHexParams,
44-
ACCOUNT_BALANCE_CALL,
4543
UnsignedSweepRecoveryTransaction,
4644
} from './lib/iface';
4745
import { TransactionBuilderFactory } from './lib/transactionBuilderFactory';
4846
import utils from './lib/utils';
4947
import { auditEcdsaPrivateKey } from '@bitgo/sdk-lib-mpc';
48+
import { IcpAgent } from './lib/icpAgent';
5049

5150
/**
5251
* Class representing the Internet Computer (ICP) coin.
@@ -262,52 +261,24 @@ export class Icp extends BaseCoin {
262261
* @returns Promise resolving to the account balance as a string.
263262
* @throws Error if the balance could not be fetched.
264263
*/
265-
protected async getAccountBalance(publicKeyHex: string): Promise<string> {
266-
try {
267-
const principalId = utils.getPrincipalIdFromPublicKey(publicKeyHex).toText();
268-
return await this.getBalanceFromPrincipal(principalId);
269-
} catch (error: any) {
270-
throw new Error(`Unable to fetch account balance: ${error.message || error}`);
271-
}
272-
}
273-
274-
/**
275-
* Fetches the account balance for a given principal ID.
276-
* @param principalId - The principal ID of the account.
277-
* @returns Promise resolving to the account balance as a string.
278-
* @throws Error if the balance could not be fetched.
279-
*/
280-
protected async getBalanceFromPrincipal(principalId: string): Promise<string> {
281-
try {
282-
const agent = this.createAgent(); // TODO: WIN-5512: move to a ICP agent file WIN-5512
283-
const ic = replica(agent, { local: true });
284-
285-
const ledger = ic(Principal.fromUint8Array(LEDGER_CANISTER_ID).toText());
286-
const subaccountHex = '0000000000000000000000000000000000000000000000000000000000000000';
287-
288-
const account = {
289-
owner: Principal.fromText(principalId),
290-
subaccount: [utils.hexToBytes(subaccountHex)],
291-
};
292-
293-
const balance = await ledger.call(ACCOUNT_BALANCE_CALL, account);
294-
return balance.toString();
295-
} catch (error: any) {
296-
throw new Error(`Error fetching balance for principal ${principalId}: ${error.message || error}`);
297-
}
264+
protected async getAccountBalance(publicKeyHex: string): Promise<BigNumber> {
265+
const principalId = utils.getPrincipalIdFromPublicKey(publicKeyHex).toText();
266+
const agent = new IcpAgent(this.getPublicNodeUrl());
267+
return agent.getBalance(principalId);
298268
}
299269

300270
/**
301-
* Creates a new HTTP agent for communicating with the Internet Computer.
302-
* @param host - The host URL to connect to (defaults to the public node URL).
303-
* @returns An instance of HttpAgent.
271+
* Retrieves the current transaction fee data from the ICP public node.
272+
*
273+
* This method creates an instance of `IcpAgent` using the public node URL,
274+
* then queries the node for the current fee information.
275+
*
276+
* @returns A promise that resolves to a `BigNumber` representing the current transaction fee.
277+
* @throws Will propagate any errors encountered while communicating with the ICP node.
304278
*/
305-
protected createAgent(host: string = this.getPublicNodeUrl()): HttpAgent {
306-
return new HttpAgent({
307-
host,
308-
fetch,
309-
verifyQuerySignatures: false,
310-
});
279+
protected async getFeeData(): Promise<BigNumber> {
280+
const agent = new IcpAgent(this.getPublicNodeUrl());
281+
return await agent.getFee();
311282
}
312283

313284
private getBuilderFactory(): TransactionBuilderFactory {
@@ -415,9 +386,9 @@ export class Icp extends BaseCoin {
415386
}
416387

417388
const senderAddress = await this.getAddressFromPublicKey(publicKey);
418-
const balance = new BigNumber(await this.getAccountBalance(publicKey));
419-
const feeData = new BigNumber(utils.feeData());
420-
const actualBalance = balance.plus(feeData); // gas amount returned from gasData is negative so we add it
389+
const balance = await this.getAccountBalance(publicKey);
390+
const feeData = await this.getFeeData();
391+
const actualBalance = balance.minus(feeData);
421392
if (actualBalance.isLessThanOrEqualTo(0)) {
422393
throw new Error('Did not have enough funds to recover');
423394
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Principal } from '@dfinity/principal';
2+
import { HttpAgent, replica, AgentCanister } from 'ic0';
3+
import { Utils } from './utils';
4+
import { ACCOUNT_BALANCE_CALL, LEDGER_CANISTER_ID, ICRC1_FEE_KEY, METADATA_CALL, DEFAULT_SUBACCOUNT } from './iface';
5+
import BigNumber from 'bignumber.js';
6+
7+
export class IcpAgent {
8+
private readonly utils: Utils;
9+
private readonly host: string;
10+
11+
constructor(host: string) {
12+
this.host = host;
13+
this.utils = new Utils();
14+
}
15+
16+
/**
17+
* Creates a new HTTP agent for communicating with the Internet Computer.
18+
* @returns An instance of HttpAgent.
19+
*/
20+
private createAgent(): HttpAgent {
21+
return new HttpAgent({
22+
host: this.host,
23+
fetch,
24+
verifyQuerySignatures: false,
25+
});
26+
}
27+
28+
/**
29+
* Retrieves an instance of the ledger canister agent.
30+
*
31+
* This method creates a new agent using `createAgent()`, initializes a replica interface
32+
* with the agent (configured for local use), and returns an `AgentCanister` instance
33+
* for the ledger canister identified by `LEDGER_CANISTER_ID`.
34+
*
35+
* @returns {AgentCanister} An agent interface for interacting with the ledger canister.
36+
*/
37+
private getLedger(): AgentCanister {
38+
const agent = this.createAgent();
39+
const ic = replica(agent, { local: true });
40+
return ic(Principal.fromUint8Array(LEDGER_CANISTER_ID).toText());
41+
}
42+
43+
/**
44+
* Fetches the account balance for a given principal ID.
45+
* @param principalId - The principal ID of the account.
46+
* @returns Promise resolving to the account balance as a string.
47+
* @throws Error if the balance could not be fetched.
48+
*/
49+
public async getBalance(principalId: string): Promise<BigNumber> {
50+
try {
51+
if (!principalId) {
52+
throw new Error('Principal ID is required');
53+
}
54+
const ledger = this.getLedger();
55+
const account = {
56+
owner: Principal.fromText(principalId),
57+
subaccount: [this.utils.hexToBytes(DEFAULT_SUBACCOUNT)],
58+
};
59+
60+
const balance = await ledger.call(ACCOUNT_BALANCE_CALL, account);
61+
return BigNumber(balance);
62+
} catch (error) {
63+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
64+
throw new Error(`Error fetching balance for principal ${principalId}: ${errorMessage}`);
65+
}
66+
}
67+
68+
/**
69+
* Fetches the transaction fee from the ledger.
70+
* @returns Promise resolving to the transaction fee as a string.
71+
* @throws Error if the fee could not be fetched.
72+
*/
73+
public async getFee(): Promise<BigNumber> {
74+
try {
75+
const ledger = this.getLedger();
76+
const metadata = await ledger.call(METADATA_CALL);
77+
78+
const feeEntry = metadata.find(
79+
(entry): entry is [string, { Nat: string }] => entry[0] === ICRC1_FEE_KEY && entry[1]?.Nat !== undefined
80+
);
81+
82+
if (!feeEntry) {
83+
throw new Error(`${ICRC1_FEE_KEY} metadata not found or invalid format`);
84+
}
85+
86+
return BigNumber(feeEntry[1].Nat);
87+
} catch (error) {
88+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
89+
throw new Error(`Error fetching transaction fee: ${errorMessage}`);
90+
}
91+
}
92+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export const ROOT_PATH = 'm/0';
1010
export const ACCOUNT_BALANCE_CALL = 'icrc1_balance_of';
1111
export const PUBLIC_NODE_REQUEST_ENDPOINT = '/api/v3/canister/';
1212
export const DEFAULT_MEMO = 0; // default memo value is 0
13+
export const ICRC1_FEE_KEY = 'icrc1:fee';
14+
export const METADATA_CALL = 'icrc1_metadata';
15+
export const DEFAULT_SUBACCOUNT = '0000000000000000000000000000000000000000000000000000000000000000'; // default subaccount value is 0x
1316

1417
export enum RequestType {
1518
CALL = 'call',

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,10 @@ export class Utils implements BaseUtils {
4545
}
4646

4747
/**
48-
* gets the gas data of this transaction.
48+
* gets the fee data of this transaction.
4949
*/
50-
//TODO WIN-4242: to moved to a config and eventually to an API for dynamic value
5150
feeData(): string {
52-
return '-10000';
51+
return '-10000'; // fee is static for ICP transactions as per ICP documentation
5352
}
5453

5554
/**

modules/sdk-coin-icp/test/unit/transactionBuilder/transactionRecover.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { TestBitGo } from '@bitgo/sdk-test';
66
import { BitGoAPI } from '@bitgo/sdk-api';
77
import nock from 'nock';
88
import { Icp } from '../../../src/index';
9+
import { IcpAgent } from '../../../src/lib/icpAgent';
910
import { RecoveryOptions, LEDGER_CANISTER_ID } from '../../../src/lib/iface';
1011
import { Principal } from '@dfinity/principal';
12+
import BigNumber from 'bignumber.js';
1113

1214
describe('ICP transaction recovery', async () => {
1315
let bitgo;
@@ -38,6 +40,12 @@ describe('ICP transaction recovery', async () => {
3840
broadcastResponse = Buffer.from(testData.PublicNodeApiBroadcastResponse, 'hex');
3941
});
4042

43+
beforeEach(function () {
44+
// Common stubs for IcpAgent
45+
sinon.stub(IcpAgent.prototype, 'getBalance').resolves(BigNumber(1000000000));
46+
sinon.stub(IcpAgent.prototype, 'getFee').resolves(BigNumber(10000));
47+
});
48+
4149
afterEach(function () {
4250
recoveryParams = {
4351
userKey: testData.WRWRecovery.userKey,
@@ -64,7 +72,6 @@ describe('ICP transaction recovery', async () => {
6472
});
6573

6674
const body = testData.RecoverySignedTransactionWithDefaultMemo;
67-
sinon.stub(icp, 'getBalanceFromPrincipal').returns('1000000000');
6875
nock(nodeUrl).post(broadcastEndpoint, body).reply(200, broadcastResponse);
6976
const recoverTxn = await icp.recover(recoveryParams);
7077
recoverTxn.id.should.be.a.String();
@@ -85,7 +92,6 @@ describe('ICP transaction recovery', async () => {
8592
});
8693

8794
const body = testData.RecoverySignedTransactionWithMemo;
88-
sinon.stub(icp, 'getBalanceFromPrincipal').returns('1000000000');
8995
nock(nodeUrl).post(broadcastEndpoint, body).reply(200, broadcastResponse);
9096
recoveryParams.memo = testData.MetaDataWithMemo.memo;
9197
const recoverTxn = await icp.recover(recoveryParams);
@@ -105,8 +111,6 @@ describe('ICP transaction recovery', async () => {
105111
ingressEndTime: testData.MetaDataWithMemo.ingress_end,
106112
});
107113

108-
sinon.stub(icp, 'getBalanceFromPrincipal').returns('1000000000');
109-
110114
const unsignedSweepRecoveryParams = {
111115
bitgoKey:
112116
'0310768736a005ea5364e1b5b5288cf553224dd28b2df8ced63b72a8020478967f05ec5bce1f26cd7eb009a4bea445bb55c2f54a30f2706c1a3747e8df2d288829',
@@ -129,8 +133,6 @@ describe('ICP transaction recovery', async () => {
129133
ingressEndTime: testData.MetaDataWithMemo.ingress_end,
130134
});
131135

132-
sinon.stub(icp, 'getBalanceFromPrincipal').returns('1000000000');
133-
134136
const unsignedSweepRecoveryParams = {
135137
bitgoKey: 'testKey',
136138
recoveryDestination: testData.Accounts.account2.address,
@@ -152,16 +154,13 @@ describe('ICP transaction recovery', async () => {
152154
ingressEndTime: testData.MetaDataWithMemo.ingress_end,
153155
});
154156

155-
sinon.stub(icp, 'getBalanceFromPrincipal').returns('1000000000');
156-
157157
const unsignedSweepRecoveryParams = {
158158
recoveryDestination: testData.Accounts.account2.address,
159159
};
160160
await icp.recover(unsignedSweepRecoveryParams).should.rejectedWith('Error during ICP recovery: missing bitgoKey');
161161
});
162162

163163
it('should fail to recover if broadcast API fails', async () => {
164-
sinon.stub(icp, 'getBalanceFromPrincipal').returns('1000000000');
165164
nock(nodeUrl).post(broadcastEndpoint).reply(500, 'Internal Server Error');
166165
recoveryParams.memo = 0;
167166
await icp
@@ -172,7 +171,10 @@ describe('ICP transaction recovery', async () => {
172171
});
173172

174173
it('should fail to recover txn if balance is low', async () => {
175-
sinon.stub(icp, 'getBalanceFromPrincipal').returns('10');
174+
// Override the default balance stub for this specific test
175+
sinon.restore();
176+
sinon.stub(IcpAgent.prototype, 'getBalance').resolves(BigNumber(10));
177+
sinon.stub(IcpAgent.prototype, 'getFee').resolves(BigNumber(10000));
176178
nock(nodeUrl).post(broadcastEndpoint).reply(200, broadcastResponse);
177179
await icp
178180
.recover(recoveryParams)

0 commit comments

Comments
 (0)