@@ -13,12 +13,22 @@ import {
1313 MultisigType ,
1414 multisigTypes ,
1515 AuditDecryptedKeyParams ,
16+ common ,
17+ TransactionType ,
1618} from '@bitgo/sdk-core' ;
1719import { bip32 } from '@bitgo/secp256k1' ;
1820import { CoinFamily , coins , BaseCoin as StaticsBaseCoin } from '@bitgo/statics' ;
1921import BigNumber from 'bignumber.js' ;
2022import { 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' ;
2232export 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