Skip to content

Commit 60bb013

Browse files
Merge pull request #5824 from BitGo/WIN-4284
feat(sdk-coin-icp): implemented recover() for WRW support
2 parents 50110b7 + d6bd979 commit 60bb013

File tree

12 files changed

+535
-34
lines changed

12 files changed

+535
-34
lines changed

modules/sdk-coin-icp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
"cbor-x": "^1.5.9",
5252
"crc-32": "^1.2.0",
5353
"js-sha256": "^0.9.0",
54-
"protobufjs": "^7.2.5"
54+
"protobufjs": "^7.2.5",
55+
"superagent": "^10.1.1"
5556
},
5657
"devDependencies": {
5758
"@bitgo/sdk-api": "^1.61.5",

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

Lines changed: 255 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,44 @@
1+
import BigNumber from 'bignumber.js';
2+
import * as request from 'superagent';
13
import {
4+
BaseBroadcastTransactionOptions,
5+
BaseBroadcastTransactionResult,
26
BaseCoin,
37
BitGoBase,
4-
MPCAlgorithm,
8+
Ecdsa,
9+
ECDSAUtils,
10+
Environments,
11+
KeyPair,
512
MethodNotImplementedError,
6-
VerifyTransactionOptions,
7-
TssVerifyAddressOptions,
13+
MPCAlgorithm,
14+
MultisigType,
15+
multisigTypes,
816
ParseTransactionOptions,
917
ParsedTransaction,
10-
KeyPair,
1118
SignTransactionOptions,
1219
SignedTransaction,
13-
Environments,
14-
MultisigType,
15-
multisigTypes,
20+
TssVerifyAddressOptions,
21+
VerifyTransactionOptions,
1622
} from '@bitgo/sdk-core';
17-
import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
23+
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
24+
import {
25+
Network,
26+
PayloadsData,
27+
RecoveryOptions,
28+
Signatures,
29+
SigningPayload,
30+
ACCOUNT_BALANCE_ENDPOINT,
31+
ROOT_PATH,
32+
LEDGER_CANISTER_ID,
33+
PublicNodeSubmitResponse,
34+
CurveType,
35+
PUBLIC_NODE_REQUEST_ENDPOINT,
36+
} from './lib/iface';
37+
import { TransactionBuilderFactory } from './lib/transactionBuilderFactory';
1838
import utils from './lib/utils';
39+
import { createHash } from 'crypto';
40+
import { Principal } from '@dfinity/principal';
41+
import axios from 'axios';
1942

2043
/**
2144
* Class representing the Internet Computer (ICP) coin.
@@ -117,6 +140,229 @@ export class Icp extends BaseCoin {
117140

118141
/** @inheritDoc **/
119142
protected getPublicNodeUrl(): string {
120-
return Environments[this.bitgo.getEnv()].rosettaNodeURL;
143+
return Environments[this.bitgo.getEnv()].icpNodeUrl;
144+
}
145+
146+
protected getRosettaNodeUrl(): string {
147+
return Environments[this.bitgo.getEnv()].icpRosettaNodeUrl;
148+
}
149+
150+
/**
151+
* Sends a POST request to the Rosetta node with the specified payload and endpoint.
152+
*
153+
* @param payload - A JSON string representing the request payload to be sent to the Rosetta node.
154+
* @param endpoint - The endpoint path to append to the Rosetta node URL.
155+
* @returns A promise that resolves to the HTTP response from the Rosetta node.
156+
* @throws An error if the HTTP request fails or if the response status is not 200.
157+
*/
158+
protected async getRosettaNodeResponse(payload: string, endpoint: string): Promise<request.Response> {
159+
const nodeUrl = this.getRosettaNodeUrl();
160+
const fullEndpoint = `${nodeUrl}${endpoint}`;
161+
const body = {
162+
network_identifier: {
163+
blockchain: this.getFullName(),
164+
network: Network.ID,
165+
},
166+
...JSON.parse(payload),
167+
};
168+
169+
try {
170+
const response = await request.post(fullEndpoint).set('Content-Type', 'application/json').send(body);
171+
if (response.status !== 200) {
172+
throw new Error(`Call to Rosetta node failed, got HTTP Status: ${response.status} with body: ${response.body}`);
173+
}
174+
return response;
175+
} catch (error) {
176+
throw new Error(`Unable to call rosetta node: ${error.message || error}`);
177+
}
178+
}
179+
180+
/* inheritDoc */
181+
// this method calls the public node to broadcast the transaction and not the rosetta node
182+
public async broadcastTransaction(payload: BaseBroadcastTransactionOptions): Promise<BaseBroadcastTransactionResult> {
183+
const endpoint = this.getPublicNodeBroadcastEndpoint();
184+
185+
try {
186+
const response = await axios.post(endpoint, payload.serializedSignedTransaction, {
187+
responseType: 'arraybuffer', // This ensures you get a Buffer, not a string
188+
headers: {
189+
'Content-Type': 'application/cbor',
190+
},
191+
});
192+
193+
if (response.status !== 200) {
194+
throw new Error(`Transaction broadcast failed with status: ${response.status} - ${response.statusText}`);
195+
}
196+
197+
const decodedResponse = utils.cborDecode(response.data) as PublicNodeSubmitResponse;
198+
199+
if (decodedResponse.status === 'replied') {
200+
const txnId = this.extractTransactionId(decodedResponse);
201+
return { txId: txnId };
202+
} else {
203+
throw new Error(`Unexpected response status from node: ${decodedResponse.status}`);
204+
}
205+
} catch (error) {
206+
throw new Error(`Transaction broadcast error: ${error?.message || JSON.stringify(error)}`);
207+
}
208+
}
209+
210+
private getPublicNodeBroadcastEndpoint(): string {
211+
const nodeUrl = this.getPublicNodeUrl();
212+
const principal = Principal.fromUint8Array(LEDGER_CANISTER_ID);
213+
const canisterIdHex = principal.toText();
214+
const endpoint = `${nodeUrl}${PUBLIC_NODE_REQUEST_ENDPOINT}${canisterIdHex}/call`;
215+
return endpoint;
216+
}
217+
218+
// TODO: Implement the real logic to extract the transaction ID, Ticket: https://bitgoinc.atlassian.net/browse/WIN-5075
219+
private extractTransactionId(decodedResponse: PublicNodeSubmitResponse): string {
220+
return '4c10cf22a768a20e7eebc86e49c031d0e22895a39c6355b5f7455b2acad59c1e';
221+
}
222+
223+
/**
224+
* Helper to fetch account balance
225+
* @param senderAddress - The address of the account to fetch the balance for
226+
* @returns The balance of the account as a string
227+
* @throws If the account is not found or there is an error fetching the balance
228+
*/
229+
protected async getAccountBalance(address: string): Promise<string> {
230+
try {
231+
const payload = {
232+
account_identifier: {
233+
address: address,
234+
},
235+
};
236+
const response = await this.getRosettaNodeResponse(JSON.stringify(payload), ACCOUNT_BALANCE_ENDPOINT);
237+
const coinName = this._staticsCoin.name.toUpperCase();
238+
const balanceEntry = response.body.balances.find((b) => b.currency?.symbol === coinName);
239+
if (!balanceEntry) {
240+
throw new Error(`No balance found for ICP account ${address}.`);
241+
}
242+
const balance = balanceEntry.value;
243+
return balance;
244+
} catch (error) {
245+
throw new Error(`Unable to fetch account balance: ${error.message || error}`);
246+
}
247+
}
248+
249+
private getBuilderFactory(): TransactionBuilderFactory {
250+
return new TransactionBuilderFactory(coins.get(this.getBaseChain()));
251+
}
252+
253+
/**
254+
* Generates an array of signatures for the provided payloads using MPC
255+
*
256+
* @param payloadsData - The data containing the payloads to be signed.
257+
* @param senderPublicKey - The public key of the sender in hexadecimal format.
258+
* @param userKeyShare - The user's key share as a Buffer.
259+
* @param backupKeyShare - The backup key share as a Buffer.
260+
* @param commonKeyChain - The common key chain identifier used for MPC signing.
261+
* @returns A promise that resolves to an array of `Signatures` objects, each containing the signing payload,
262+
* signature type, public key, and the generated signature in hexadecimal format.
263+
*/
264+
async signatures(
265+
payloadsData: PayloadsData,
266+
senderPublicKey: string,
267+
userKeyShare: Buffer<ArrayBufferLike>,
268+
backupKeyShare: Buffer<ArrayBufferLike>,
269+
commonKeyChain: string
270+
): Promise<Signatures[]> {
271+
try {
272+
const payload = payloadsData.payloads[0] as SigningPayload;
273+
const message = Buffer.from(payload.hex_bytes, 'hex');
274+
const messageHash = createHash('sha256').update(message).digest();
275+
const signature = await ECDSAUtils.signRecoveryMpcV2(messageHash, userKeyShare, backupKeyShare, commonKeyChain);
276+
const signaturePayload: Signatures = {
277+
signing_payload: payload,
278+
signature_type: payload.signature_type,
279+
public_key: {
280+
hex_bytes: senderPublicKey,
281+
curve_type: CurveType.SECP256K1,
282+
},
283+
hex_bytes: signature.r + signature.s,
284+
};
285+
286+
return [signaturePayload];
287+
} catch (error) {
288+
throw new Error(`Error generating signatures: ${error.message || error}`);
289+
}
290+
}
291+
292+
/**
293+
* Builds a funds recovery transaction without BitGo
294+
* @param params
295+
*/
296+
async recover(params: RecoveryOptions): Promise<string> {
297+
if (!params.recoveryDestination || !this.isValidAddress(params.recoveryDestination)) {
298+
throw new Error('invalid recoveryDestination');
299+
}
300+
301+
if (!params.userKey) {
302+
throw new Error('missing userKey');
303+
}
304+
305+
if (!params.backupKey) {
306+
throw new Error('missing backupKey');
307+
}
308+
309+
if (!params.walletPassphrase) {
310+
throw new Error('missing wallet passphrase');
311+
}
312+
313+
const userKey = params.userKey.replace(/\s/g, '');
314+
const backupKey = params.backupKey.replace(/\s/g, '');
315+
316+
const { userKeyShare, backupKeyShare, commonKeyChain } = await ECDSAUtils.getMpcV2RecoveryKeyShares(
317+
userKey,
318+
backupKey,
319+
params.walletPassphrase
320+
);
321+
const MPC = new Ecdsa();
322+
const publicKey = MPC.deriveUnhardened(commonKeyChain, ROOT_PATH).slice(0, 66);
323+
324+
if (!publicKey || !backupKeyShare) {
325+
throw new Error('Missing publicKey or backupKeyShare');
326+
}
327+
328+
const senderAddress = await this.getAddressFromPublicKey(publicKey);
329+
330+
const balance = new BigNumber(await this.getAccountBalance(senderAddress));
331+
const feeData = new BigNumber(utils.feeData());
332+
const actualBalance = balance.plus(feeData); // gas amount returned from gasData is negative so we add it
333+
if (actualBalance.isLessThanOrEqualTo(0)) {
334+
throw new Error('Did not have enough funds to recover');
335+
}
336+
337+
const factory = this.getBuilderFactory();
338+
const txBuilder = factory.getTransferBuilder();
339+
txBuilder.sender(senderAddress, publicKey as string);
340+
txBuilder.receiverId(params.recoveryDestination);
341+
txBuilder.amount(actualBalance.toString());
342+
if (params.memo !== undefined && utils.validateMemo(params.memo)) {
343+
txBuilder.memo(Number(params.memo));
344+
}
345+
await txBuilder.build();
346+
if (txBuilder.transaction.payloadsData.payloads.length === 0) {
347+
throw new Error('Missing payloads to generate signatures');
348+
}
349+
const signatures = await this.signatures(
350+
txBuilder.transaction.payloadsData,
351+
publicKey,
352+
userKeyShare,
353+
backupKeyShare,
354+
commonKeyChain
355+
);
356+
if (!signatures || signatures.length === 0) {
357+
throw new Error('Failed to generate signatures');
358+
}
359+
txBuilder.transaction.addSignature(signatures);
360+
txBuilder.combine();
361+
const broadcastableTxn = txBuilder.transaction.toBroadcastFormat();
362+
const result = await this.broadcastTransaction({ serializedSignedTransaction: broadcastableTxn });
363+
if (!result.txId) {
364+
throw new Error('Transaction failed to broadcast');
365+
}
366+
return result.txId;
121367
}
122368
}

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {
66
export const MAX_INGRESS_TTL = 5 * 60 * 1000_000_000; // 5 minutes in nanoseconds
77
export const PERMITTED_DRIFT = 60 * 1000_000_000; // 60 seconds in nanoseconds
88
export const LEDGER_CANISTER_ID = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 2, 1, 1]); // Uint8Array value for "00000000000000020101" and the string value is "ryjl3-tyaaa-aaaaa-aaaba-cai"
9+
export const ROOT_PATH = 'm/0';
10+
export const ACCOUNT_BALANCE_ENDPOINT = '/account/balance';
11+
export const PUBLIC_NODE_REQUEST_ENDPOINT = '/api/v3/canister/';
912

1013
export enum RequestType {
1114
CALL = 'call',
@@ -32,6 +35,7 @@ export enum Network {
3235
ID = '00000000000000020101', // ICP does not have different network IDs for mainnet and testnet
3336
}
3437

38+
//TODO make memo optional in the interface
3539
export interface IcpTransactionData {
3640
senderAddress: string;
3741
receiverAddress: string;
@@ -68,23 +72,31 @@ export interface IcpOperation {
6872
amount: IcpAmount;
6973
}
7074

71-
export interface IcpMetadata {
75+
//TODO check for optional memo in the interface
76+
export interface IcpTransactionParseMetadata {
7277
created_at_time: number;
7378
memo: number | BigInt; // memo in string is not accepted by ICP chain.
7479
ingress_start?: number | BigInt; // it should be nano seconds
7580
ingress_end?: number | BigInt; // it should be nano seconds
7681
}
7782

83+
export interface IcpTransactionBuildMetadata {
84+
created_at_time: number;
85+
memo?: number | BigInt; // memo in string is not accepted by ICP chain.
86+
ingress_start: number | BigInt; // it should be nano seconds
87+
ingress_end: number | BigInt; // it should be nano seconds
88+
}
89+
7890
export interface IcpTransaction {
7991
public_keys: IcpPublicKey[];
8092
operations: IcpOperation[];
81-
metadata: IcpMetadata;
93+
metadata: IcpTransactionBuildMetadata;
8294
}
8395

8496
export interface ParsedTransaction {
8597
operations: IcpOperation[];
8698
account_identifier_signers: IcpAccount[];
87-
metadata: IcpMetadata;
99+
metadata: IcpTransactionParseMetadata;
88100
}
89101

90102
export interface IcpAccountIdentifier {
@@ -171,3 +183,16 @@ export interface RawTransaction {
171183
serializedTxHex: string;
172184
publicKey: string;
173185
}
186+
187+
export interface RecoveryOptions {
188+
userKey: string; // Box A
189+
backupKey: string; // Box B
190+
bitgoKey?: string;
191+
recoveryDestination: string;
192+
walletPassphrase: string;
193+
memo?: number | BigInt;
194+
}
195+
196+
export interface PublicNodeSubmitResponse {
197+
status: string;
198+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export class Transaction extends BaseTransaction {
9797
const transactionType = parsedTx.operations[0].type;
9898
switch (transactionType) {
9999
case OperationType.TRANSACTION:
100+
//TODO memo is optional here too, check proto def as well
100101
this._icpTransactionData = {
101102
senderAddress: parsedTx.operations[0].account.address,
102103
receiverAddress: parsedTx.operations[1].account.address,
@@ -145,7 +146,7 @@ export class Transaction extends BaseTransaction {
145146
type: BitGoTransactionType.Send,
146147
};
147148
default:
148-
throw new Error('Unsupported transaction type');
149+
throw new Error(`Unsupported transaction type: ${this._icpTransactionData.transactionType}`);
149150
}
150151
}
151152

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
158158
}
159159
}
160160

161-
// combine the unsigned transaction with the signature payload and generates the signed transaction
161+
/**
162+
* Combines the unsigned transaction and the signature payload to create a signed transaction.
163+
*/
162164
public combine(): void {
163165
const signedTransactionBuilder = new SignedTransactionBuilder(
164166
this._transaction.unsignedTransaction,

0 commit comments

Comments
 (0)