Skip to content

Commit 8875500

Browse files
committed
feat(sdk-coin-xtz): add xtz recovery
ticket: WIN-6482
1 parent c31e25c commit 8875500

File tree

7 files changed

+746
-6
lines changed

7 files changed

+746
-6
lines changed

modules/sdk-coin-xtz/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@
5050
"bignumber.js": "^9.0.0",
5151
"bs58check": "^2.1.2",
5252
"libsodium-wrappers": "^0.7.6",
53-
"lodash": "^4.17.15"
53+
"lodash": "^4.17.15",
54+
"superagent": "^9.0.1"
5455
},
5556
"devDependencies": {
5657
"@bitgo/sdk-api": "^1.66.1",

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,39 @@ export interface Key extends BaseKey, IndexedData {}
135135
export interface IndexedSignature extends IndexedData {
136136
signature: string;
137137
}
138+
139+
export type RecoverOptions = {
140+
userKey: string;
141+
backupKey: string;
142+
walletPassphrase?: string;
143+
walletContractAddress: string; // use this as walletBaseAddress for TSS
144+
recoveryDestination: string;
145+
krsProvider?: string;
146+
gasPrice?: number;
147+
gasLimit?: number;
148+
bitgoFeeAddress?: string;
149+
bitgoDestinationAddress?: string;
150+
tokenContractAddress?: string;
151+
intendedChain?: string;
152+
derivationSeed?: string;
153+
apiKey?: string;
154+
isUnsignedSweep?: boolean;
155+
};
156+
157+
export interface OfflineVaultTxInfo {
158+
nextContractSequenceId?: string;
159+
contractSequenceId?: string;
160+
tx?: string;
161+
txHex?: string;
162+
userKey?: string;
163+
backupKey?: string;
164+
coin: string;
165+
gasPrice: number;
166+
gasLimit: number;
167+
recipients: Recipient[];
168+
walletContractAddress: string;
169+
amount: string;
170+
backupKeyNonce: number;
171+
isEvmBasedCrossChainRecovery?: boolean;
172+
walletVersion?: number;
173+
}

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,3 +463,22 @@ export enum DEFAULT_STORAGE_LIMIT {
463463
TRANSFER = 257,
464464
REVEAL = 0,
465465
}
466+
467+
export enum TRANSACTION_FEE {
468+
ORIGINATION = 47640,
469+
TRANSFER = 47640,
470+
REVEAL = 1420,
471+
}
472+
473+
export enum TRANSACTION_STORAGE_LIMIT {
474+
ORIGINATION = 3000,
475+
TRANSFER = 300,
476+
REVEAL = 5,
477+
}
478+
479+
export enum TRANSACTION_GAS_LIMIT {
480+
ORIGINATION = 4600,
481+
TRANSFER = 6000,
482+
CONTRACT_TRANSFER = 20000,
483+
REVEAL = 1500,
484+
}

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

Lines changed: 261 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,22 @@ import {
1313
MultisigType,
1414
multisigTypes,
1515
AuditDecryptedKeyParams,
16+
common,
17+
TransactionType,
1618
} from '@bitgo/sdk-core';
1719
import { bip32 } from '@bitgo/secp256k1';
1820
import { CoinFamily, coins, BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
1921
import BigNumber from 'bignumber.js';
2022
import { Interface, KeyPair, TransactionBuilder, Utils } from './lib';
21-
23+
import { RecoverOptions } from './lib/iface';
24+
import {
25+
generateDataToSign,
26+
isValidOriginatedAddress,
27+
TRANSACTION_FEE,
28+
TRANSACTION_GAS_LIMIT,
29+
TRANSACTION_STORAGE_LIMIT,
30+
} from './lib/utils';
31+
import request from 'superagent';
2232
export class Xtz extends BaseCoin {
2333
protected readonly _staticsCoin: Readonly<StaticsBaseCoin>;
2434

@@ -183,6 +193,133 @@ export class Xtz extends BaseCoin {
183193
const signatureData = await Utils.sign(keyPair, messageHex);
184194
return Buffer.from(signatureData.sig);
185195
}
196+
/**
197+
* Method to validate recovery params
198+
* @param {RecoverOptions} params
199+
* @returns {void}
200+
*/
201+
validateRecoveryParams(params: RecoverOptions): void {
202+
if (params.userKey === undefined) {
203+
throw new Error('missing userKey');
204+
}
205+
206+
if (params.backupKey === undefined) {
207+
throw new Error('missing backupKey');
208+
}
209+
210+
if (!params.isUnsignedSweep && params.walletPassphrase === undefined && !params.userKey.startsWith('xpub')) {
211+
throw new Error('missing wallet passphrase');
212+
}
213+
214+
if (params.walletContractAddress === undefined || !this.isValidAddress(params.walletContractAddress)) {
215+
throw new Error('invalid walletContractAddress');
216+
}
217+
218+
if (params.recoveryDestination === undefined || !this.isValidAddress(params.recoveryDestination)) {
219+
throw new Error('invalid recoveryDestination');
220+
}
221+
}
222+
223+
/**
224+
* Make a query to blockchain explorer for information such as balance, token balance, solidity calls
225+
* @param query {Object} key-value pairs of parameters to append after /api
226+
* @param apiKey {string} optional API key to use instead of the one from the environment
227+
* @returns {Object} response from the blockchain explorer
228+
*/
229+
async recoveryBlockchainExplorerQuery(
230+
params: {
231+
actionPath: string;
232+
address?: string;
233+
action?: string;
234+
},
235+
apiKey?: string
236+
): Promise<unknown> {
237+
const response = await request.get(
238+
`${common.Environments[this.bitgo.getEnv()].xtzExplorerBaseUrl}/v1/${params.actionPath}${
239+
params.address ? '/' + params.address : ''
240+
}${params.action ? '/' + params.action : ''}${apiKey ? `?apikey=${apiKey}` : ''}`
241+
);
242+
243+
if (!response.ok) {
244+
throw new Error('could not reach TZKT');
245+
}
246+
247+
if (response.status === 429) {
248+
throw new Error('TZKT rate limit reached');
249+
}
250+
return response.body;
251+
}
252+
253+
/**
254+
* Queries public block explorer to get the next XTZ address details
255+
* @param {string} address
256+
* @param {string} apiKey - optional API key to use instead of the one from the environment
257+
* @returns {Promise<any>}
258+
*/
259+
async getAddressDetails(address: string, apiKey?: string): Promise<any> {
260+
const result = await this.recoveryBlockchainExplorerQuery(
261+
{
262+
actionPath: 'accounts',
263+
address,
264+
},
265+
apiKey
266+
);
267+
268+
if (!result) {
269+
throw new Error(`Unable to find details for ${address}`);
270+
}
271+
return result;
272+
}
273+
274+
/**
275+
* Query explorer for the balance of an address
276+
* @param {String} address - the XTZ base/receive address
277+
* @param {String} apiKey - optional API key to use instead of the one from the environment
278+
* @returns {BigNumber} address balance
279+
*/
280+
async queryAddressBalance(address: string, apiKey?: string): Promise<any> {
281+
const result: any = await this.recoveryBlockchainExplorerQuery(
282+
{
283+
actionPath: isValidOriginatedAddress(address) ? 'contracts' : 'accounts',
284+
address,
285+
},
286+
apiKey
287+
);
288+
// throw if the result does not exist or the result is not a valid number
289+
if (!result || !result.balance) {
290+
throw new Error(`Could not obtain address balance for ${address} from the explorer`);
291+
}
292+
return new BigNumber(result.balance, 10);
293+
}
294+
295+
/**
296+
* Generate and pack the data to sign for each transfer.
297+
*
298+
* @param {String} contractAddress Wallet address to withdraw funds from
299+
* @param {String} contractCounter Wallet internal counter
300+
* @param {String} destination Tezos address to send the funds to
301+
* @param {String} amount Number of mutez to move
302+
* @param {IMSClient} imsClient Existing IMS client connection to reuse
303+
* @return {String} data to sign in hex format
304+
*/
305+
async packDataToSign(contractAddress, contractCounter, destination, amount) {
306+
const dataToSign = generateDataToSign(contractAddress, destination, amount, contractCounter);
307+
const xtzRpcUrl = `${
308+
common.Environments[this.bitgo.getEnv()].xtzRpcUrl
309+
}/chains/main/blocks/head/helpers/scripts/pack_data`;
310+
311+
if (!xtzRpcUrl) {
312+
throw new Error('XTZ RPC url not found');
313+
}
314+
315+
const response = await request.post(xtzRpcUrl).send(dataToSign);
316+
if (response.status === 404) {
317+
throw new Error(`unable to pack data to sign ${response.status}: ${response.body.error.message}`);
318+
} else if (response.status !== 200) {
319+
throw new Error(`unexpected IMS response status ${response.status}: ${response.body.error.message}`);
320+
}
321+
return response.body.packed;
322+
}
186323

187324
/**
188325
* Builds a funds recovery transaction without BitGo.
@@ -192,8 +329,129 @@ export class Xtz extends BaseCoin {
192329
* 3) Send signed build - send our signed build to a public node
193330
* @param params
194331
*/
195-
async recover(params: any): Promise<any> {
196-
throw new MethodNotImplementedError();
332+
async recover(params: RecoverOptions): Promise<unknown> {
333+
this.validateRecoveryParams(params);
334+
335+
// Clean up whitespace from entered values
336+
337+
const backupKey = params.backupKey.replace(/\s/g, '');
338+
339+
const userAddressDetails = await this.getAddressDetails(params.walletContractAddress, params.apiKey);
340+
341+
if (!userAddressDetails) {
342+
throw new Error('Unable to fetch user address details');
343+
}
344+
345+
// Decrypt backup private key and get address
346+
let backupPrv;
347+
348+
try {
349+
backupPrv = this.bitgo.decrypt({
350+
input: backupKey,
351+
password: params.walletPassphrase,
352+
});
353+
} catch (e) {
354+
throw new Error(`Error decrypting backup keychain: ${e.message}`);
355+
}
356+
const keyPair = new KeyPair({ prv: backupPrv });
357+
const backupSigningKey = keyPair.getKeys().prv;
358+
if (!backupSigningKey) {
359+
throw new Error('no private key');
360+
}
361+
const backupKeyAddress = keyPair.getAddress();
362+
363+
const backupAddressDetails = await this.getAddressDetails(backupKeyAddress, params.apiKey || '');
364+
365+
if (!backupAddressDetails.counter || !backupAddressDetails.balance) {
366+
throw new Error(`Missing required detail(s): counter, balance`);
367+
}
368+
const backupKeyNonce = new BigNumber(backupAddressDetails.counter + 1, 10);
369+
370+
// get balance of backupKey to ensure funds are available to pay fees
371+
const backupKeyBalance = new BigNumber(backupAddressDetails.balance, 10);
372+
373+
const gasLimit = isValidOriginatedAddress(params.recoveryDestination)
374+
? TRANSACTION_GAS_LIMIT.CONTRACT_TRANSFER
375+
: TRANSACTION_GAS_LIMIT.TRANSFER;
376+
const gasPrice = TRANSACTION_FEE.TRANSFER;
377+
378+
// Checking whether back up key address has sufficient funds for transaction
379+
if (backupKeyBalance.lt(gasPrice)) {
380+
const weiToGwei = 10 ** 6;
381+
throw new Error(
382+
`Backup key address ${backupKeyAddress} has balance ${(
383+
backupKeyBalance.toNumber() / weiToGwei
384+
).toString()} Gwei.` +
385+
`This address must have a balance of at least ${(gasPrice / weiToGwei).toString()}` +
386+
` Gwei to perform recoveries. Try sending some funds to this address then retry.`
387+
);
388+
}
389+
390+
// get balance of sender address
391+
if (!userAddressDetails.balance || userAddressDetails.balance === 0) {
392+
throw new Error('No funds to recover from source address');
393+
}
394+
const txAmount = userAddressDetails.balance;
395+
if (new BigNumber(txAmount).isLessThanOrEqualTo(0)) {
396+
throw new Error('Wallet does not have enough funds to recover');
397+
}
398+
399+
const feeInfo = {
400+
fee: new BigNumber(TRANSACTION_FEE.TRANSFER),
401+
gasLimit: new BigNumber(gasLimit),
402+
storageLimit: new BigNumber(TRANSACTION_STORAGE_LIMIT.TRANSFER),
403+
};
404+
405+
const txBuilder = new TransactionBuilder(coins.get(this.getChain()));
406+
407+
txBuilder.type(TransactionType.Send);
408+
txBuilder.source(backupKeyAddress);
409+
410+
// Used to set the branch for the transaction
411+
const chainHead: any = await this.recoveryBlockchainExplorerQuery({
412+
actionPath: 'head',
413+
});
414+
415+
if (!chainHead || !chainHead.hash) {
416+
throw new Error('Unable to fetch chain head');
417+
}
418+
txBuilder.branch(chainHead.hash);
419+
420+
if (!backupAddressDetails.revealed) {
421+
feeInfo.fee = feeInfo.fee.plus(TRANSACTION_FEE.REVEAL);
422+
feeInfo.gasLimit = feeInfo.gasLimit.plus(TRANSACTION_GAS_LIMIT.REVEAL);
423+
feeInfo.storageLimit = feeInfo.storageLimit.plus(TRANSACTION_STORAGE_LIMIT.REVEAL);
424+
backupKeyNonce.plus(1);
425+
const publicKeyToReveal = keyPair.getKeys();
426+
txBuilder.publicKeyToReveal(publicKeyToReveal.pub);
427+
}
428+
429+
txBuilder.counter(backupKeyNonce.toString());
430+
431+
const packedDataToSign = await this.packDataToSign(
432+
params.walletContractAddress,
433+
backupKeyNonce.toString(),
434+
params.recoveryDestination,
435+
txAmount?.toString()
436+
);
437+
438+
txBuilder
439+
.transfer(txAmount?.toString())
440+
.from(params.walletContractAddress)
441+
.to(params.recoveryDestination)
442+
.counter(backupKeyNonce.toString())
443+
.fee(TRANSACTION_FEE.TRANSFER.toString())
444+
.storageLimit(TRANSACTION_STORAGE_LIMIT.TRANSFER.toString())
445+
.gasLimit(gasLimit.toString())
446+
.dataToSign(packedDataToSign);
447+
448+
txBuilder.sign({ key: backupSigningKey });
449+
const signedTx = await txBuilder.build();
450+
451+
return {
452+
id: signedTx.id,
453+
tx: signedTx.toBroadcastFormat(),
454+
};
197455
}
198456

199457
/**

0 commit comments

Comments
 (0)