From ef10edd92b790df10a184174498d7b60ffe6849a Mon Sep 17 00:00:00 2001 From: N V Rakesh Reddy Date: Fri, 25 Oct 2024 16:10:57 +0530 Subject: [PATCH] feat(sdk-coin-xrp): add non-bitgo recovery for xrpl token TICKET: WIN-3720 --- modules/bitgo/test/v2/lib/recovery-nocks.ts | 18 ++ modules/sdk-coin-xrp/src/lib/iface.ts | 1 + modules/sdk-coin-xrp/src/xrp.ts | 110 +++++++++- modules/sdk-coin-xrp/test/resources/xrp.ts | 219 ++++++++++++++++++++ modules/sdk-coin-xrp/test/unit/xrp.ts | 77 +++++++ 5 files changed, 423 insertions(+), 2 deletions(-) diff --git a/modules/bitgo/test/v2/lib/recovery-nocks.ts b/modules/bitgo/test/v2/lib/recovery-nocks.ts index 74ccdb392f..734b767360 100644 --- a/modules/bitgo/test/v2/lib/recovery-nocks.ts +++ b/modules/bitgo/test/v2/lib/recovery-nocks.ts @@ -145,6 +145,24 @@ module.exports.nockXrpRecovery = function nockXrpRecovery() { }, status: 'success', }, + }) + .post('/', { + method: 'account_lines', + params: [ + { + account: 'raGZWRkRBUWdQJsKYEzwXJNbCZMTqX56aA', + ledger_index: 'validated', + }, + ], + }) + .reply(200, { + result: { + account: 'rMficzfw4t5iGu9hhB23eKwDjM879vJWTR', + ledger_hash: 'E6F38D1D7B94153BF7FFC8D8CC1DF57D57151D26FC2EB7647B5631786B955EFF', + ledger_index: 1848964, + lines: [], + validated: true, + }, }); }; diff --git a/modules/sdk-coin-xrp/src/lib/iface.ts b/modules/sdk-coin-xrp/src/lib/iface.ts index d2c35893a7..58d0cba56b 100644 --- a/modules/sdk-coin-xrp/src/lib/iface.ts +++ b/modules/sdk-coin-xrp/src/lib/iface.ts @@ -67,6 +67,7 @@ export interface RecoveryOptions { bitgoKey?: string; walletPassphrase: string; krsProvider?: string; + tokenName?: string; } export interface HalfSignedTransaction { diff --git a/modules/sdk-coin-xrp/src/xrp.ts b/modules/sdk-coin-xrp/src/xrp.ts index 7ede6b1920..2a4dadfd65 100644 --- a/modules/sdk-coin-xrp/src/xrp.ts +++ b/modules/sdk-coin-xrp/src/xrp.ts @@ -329,14 +329,24 @@ export class Xrp extends BaseCoin { params: [ { account: params.rootAddress, - strict: true, ledger_index: 'current', queue: true, + strict: true, signer_lists: true, }, ], }; + const accountLinesParams = { + method: 'account_lines', + params: [ + { + account: params.rootAddress, + ledger_index: 'validated', + }, + ], + }; + if (isKrsRecovery) { checkKrsProvider(this, params.krsProvider); } @@ -348,10 +358,11 @@ export class Xrp extends BaseCoin { const keys = getBip32Keys(this.bitgo, params, { requireBitGoXpub: false }); - const { addressDetails, feeDetails, serverDetails } = await promiseProps({ + const { addressDetails, feeDetails, serverDetails, accountLines } = await promiseProps({ addressDetails: this.bitgo.post(rippledUrl).send(accountInfoParams), feeDetails: this.bitgo.post(rippledUrl).send({ method: 'fee' }), serverDetails: this.bitgo.post(rippledUrl).send({ method: 'server_info' }), + accountLines: this.bitgo.post(rippledUrl).send(accountLinesParams), }); const openLedgerFee = new BigNumber(feeDetails.body.result.drops.open_ledger_fee); @@ -452,6 +463,25 @@ export class Xrp extends BaseCoin { ); } + const tokenName = params?.tokenName; + if (!!tokenName) { + const tokenParams = { + destinationAddress, + destinationTag, + recoverableBalance, + currentLedger, + openLedgerFee, + sequenceId, + accountLines, + keys, + isKrsRecovery, + userAddress, + backupAddress, + }; + + return this.recoverXrpToken(params, tokenName, tokenParams); + } + const transaction = { TransactionType: 'Payment', Account: params.rootAddress, // source address @@ -504,6 +534,82 @@ export class Xrp extends BaseCoin { return transactionExplanation; } + public async recoverXrpToken(params, tokenName, tokenParams) { + const { currency, issuer } = utils.getXrpCurrencyFromTokenName(tokenName); + + // const accountLines = JSON.parse(tokenParams.accountLines); + // const lines = accountLines.body.result.lines; + const lines = tokenParams.accountLines.body.result.lines; + + let amount; + for (const line of lines) { + if (line.currency === currency && line.account === issuer) { + amount = line.balance; + break; + } + } + + if (amount === undefined) { + throw new Error(`Does not have Trustline with ${issuer}`); + } + if (amount === '0') { + throw new Error(`Does not have funds to recover`); + } + + const FLAG_VALUE = 2147483648; + + const transaction = { + TransactionType: 'Payment', + Amount: { + value: amount, + currency, + issuer, + }, // source address + Destination: tokenParams.destinationAddress, + DestinationTag: tokenParams.destinationTag, + Account: params.rootAddress, + Flags: FLAG_VALUE, + LastLedgerSequence: tokenParams.currentLedger + 1000000, // give it 1 million ledgers' time (~1 month, suitable for KRS) + Fee: tokenParams.openLedgerFee.times(3).toFixed(0), // the factor three is for the multisigning + Sequence: tokenParams.sequenceId, + }; + const txJSON: string = JSON.stringify(transaction); + + const { keys, isKrsRecovery, userAddress, backupAddress } = tokenParams; + + if (!keys[0].privateKey) { + throw new Error(`userKey is not a private key`); + } + + const userKey = keys[0].privateKey.toString('hex'); + const userSignature = ripple.signWithPrivateKey(txJSON, userKey, { signAs: userAddress }); + + let signedTransaction: string; + + if (isKrsRecovery) { + signedTransaction = userSignature.signedTransaction; + } else { + if (!keys[1].privateKey) { + throw new Error(`backupKey is not a private key`); + } + const backupKey = keys[1].privateKey.toString('hex'); + const backupSignature = ripple.signWithPrivateKey(txJSON, backupKey, { signAs: backupAddress }); + signedTransaction = ripple.multisign([userSignature.signedTransaction, backupSignature.signedTransaction]); + } + + const transactionExplanation: RecoveryInfo = (await this.explainTransaction({ + txHex: signedTransaction, + })) as RecoveryInfo; + + transactionExplanation.txHex = signedTransaction; + + if (isKrsRecovery) { + transactionExplanation.backupKey = params.backupKey; + transactionExplanation.coin = this.getChain(); + } + return transactionExplanation; + } + /** * Generate a new keypair for this coin. * @param seed Seed from which the new keypair should be generated, otherwise a random seed is used diff --git a/modules/sdk-coin-xrp/test/resources/xrp.ts b/modules/sdk-coin-xrp/test/resources/xrp.ts index a869175762..7e58a3d552 100644 --- a/modules/sdk-coin-xrp/test/resources/xrp.ts +++ b/modules/sdk-coin-xrp/test/resources/xrp.ts @@ -78,3 +78,222 @@ export const TEST_TOKEN_TRANSFER_TX = { signedTxHex: '1200002280000000240017972A61D4C8E1BC9BF04000524C555344000000000000000000000000000000FCF4DD8C64636BC503F4A58DC6C684D2C7C3C24F6840000000000003E8730081143A216D9FD01469A7D4D90332A5D732CD86E5E38C8314CDBC9E5CA061B57159D89C3F77D434EA9C428031F3E01073210354A53774FD9B5EF4B2A474A369BECD92E379FA759681A831A4F86D1E5313D6FF744630440220245F27F79415F1C47CE85F34417F8C47CCFC5D699943456CF75734033670A32D0220254874C265CC91F9AFC7588E836CA8F2A2BB91DB864E5F4091A94AAF18CA4A2A81142B6846E804F59FB21B1B3B2CA202DCC321303D3EE1E01073210346779230AAC40C8BF83E5006CC5715CF24DC8E1A446E498ADB3C05AB00B097D37446304402203549AA24CE9339A5730B64DAD02569C80ED9244F06DEB8090609455868D5177C02206FA75998EE414130F9555E20B807DB91DF2E5BD35F8A3084146FC81F744C618881143870751788B5A4552965725080F5F65B0ACB8D20E1F1', }; + +export const keys = { + userKey: + '{"iv":"ZN/gBap8QYIpjbbkZCDY8g==","v":1,"iter":10000,"ks":256,"ts":64,"mode"\n' + + ':"ccm","adata":"","cipher":"aes","salt":"Egt/IC14ugw=","ct":"eRiONKtGrlEX8e\n' + + '5s5EAon7MadWZxyQWJMFJp16rimEd/2LWyGObo/d6hdJWUSZE1lDzpYV9x/Qg3vKz8Wy4ee8R0h\n' + + '8J+Ddo/Q8dR/yDNImcNGBclBMrh9c8cowuzRMnbMlbrLc949tN3d3A1jXOu3Rr5Wt4h1ag="}', + backupKey: + '{"iv":"D5SCw343R+l9qbP3TrXzlg==","v":1,"iter":10000,"ks":256,"ts":64,"mode"\n' + + ':"ccm","adata":"","cipher":"aes","salt":"yug6WjWDjCA=","ct":"++m1LyBWw9emM2\n' + + 'J1P85+T2VJEFPXFjshWssVBaHuccsiD0MsYsFX5d+hVfDrWV2aDOJAuOdtoCo+R3LrG2JST80ru\n' + + '37Y383IvRlB3A85MSo/poMtN1JyzorwF6Cfiz26bY3OKxywaeWJvr9SEDJxTDTx8HH9GsE="}', + bitgoKey: + 'xpub661MyMwAqRbcGBXTTnaLrqur67ZHc9BA9X3GdAx6Kj8HVyg32TvktXv8DPN13QvnWSnrfC8\n' + + 'KFWvaUfR4kfwyikf6TuyJ3Ei8HGs7vxfdyia', + rootAddress: 'rNTfZB1h4TDdF9QXw37nbWk9euZmRby4qn', +}; + +export const accountInfoResponse = { + body: { + result: { + account_data: { + Account: 'rQNZGRLwTAF4WAxZvc1UbueEs4rQb7KQmN', + Balance: '99997952', + Flags: 1179648, + LedgerEntryType: 'AccountRoot', + OwnerCount: 2, + PreviousTxnID: '1216237378659EC1849D45D210B45C96B7B091B4134BA3A00509B8F9DCBA59C4', + PreviousTxnLgrSeq: 1851142, + Sequence: 807096, + index: 'BDD920F6F4C21E2EDF8E604257AB2D3EFF3CD8374330B98989A58E9AB27FDC9D', + signer_lists: [ + { + Flags: 65536, + LedgerEntryType: 'SignerList', + OwnerNode: '0', + PreviousTxnID: '1216237378659EC1849D45D210B45C96B7B091B4134BA3A00509B8F9DCBA59C4', + PreviousTxnLgrSeq: 1851142, + SignerEntries: [ + { + SignerEntry: { + Account: 'rwFcXstMseu91iejAdoYWCPaVR4GgdiV5i', + SignerWeight: 1, + }, + }, + { + SignerEntry: { + Account: 'r45kBeT5cmtaW6DHGAXzfjYHQzsVFhPX3M', + SignerWeight: 1, + }, + }, + { + SignerEntry: { + Account: 'r3mykfPQZt4eJZKLUGMNVB49eDSJiE9zh3', + SignerWeight: 1, + }, + }, + ], + SignerListID: 0, + SignerQuorum: 2, + index: '00B47042E37B5F11E6325D7BECAA08D165C6681DB4F6528AF7D1CA6ED50075B7', + }, + ], + }, + account_flags: { + allowTrustLineClawback: false, + defaultRipple: false, + depositAuth: false, + disableMasterKey: false, + disallowIncomingCheck: false, + disallowIncomingNFTokenOffer: false, + disallowIncomingPayChan: false, + disallowIncomingTrustline: false, + disallowIncomingXRP: false, + globalFreeze: false, + noFreeze: false, + passwordSpent: false, + requireAuthorization: false, + requireDestinationTag: false, + }, + ledger_current_index: 1851200, + queue_data: { + txn_count: 0, + }, + validated: false, + }, + status: 'success', + type: 'response', + }, +}; + +export const accountlinesResponse = { + body: { + result: { + account: 'rMficzfw4t5iGu9hhB23eKwDjM879vJWTR', + ledger_hash: 'E6F38D1D7B94153BF7FFC8D8CC1DF57D57151D26FC2EB7647B5631786B955EFF', + ledger_index: 1848964, + lines: [ + { + account: 'rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV', + balance: '4', + currency: '524C555344000000000000000000000000000000', + limit: '1000000000', + limit_peer: '0', + no_ripple: false, + no_ripple_peer: false, + quality_in: 0, + quality_out: 0, + }, + ], + validated: true, + }, + status: 'success', + type: 'response', + }, +}; + +export const feeResponse = { + body: { + id: 'fee_websocket_example', + result: { + current_ledger_size: '5', + current_queue_size: '0', + drops: { + base_fee: '10', + median_fee: '5000', + minimum_fee: '10', + open_ledger_fee: '10', + }, + expected_ledger_size: '1168', + ledger_current_index: 1847940, + levels: { + median_level: '128000', + minimum_level: '256', + open_ledger_level: '256', + reference_level: '256', + }, + max_queue_size: '23360', + }, + status: 'success', + type: 'response', + }, +}; + +export const serverInfoResponse = { + body: { + result: { + info: { + build_version: '2.2.2', + complete_ledgers: '6-1848000', + hostid: 'TUN', + initial_sync_duration_us: '5977360', + io_latency_ms: 1, + jq_trans_overflow: '0', + last_close: { + converge_time_s: 2, + proposers: 6, + }, + load_factor: 1, + network_id: 1, + peer_disconnects: '227613', + peer_disconnects_resources: '1245', + peers: 84, + ports: [ + { + port: '2459', + protocol: ['peer'], + }, + { + port: '51233', + protocol: ['ws2', 'wss2'], + }, + { + port: '50051', + protocol: ['grpc'], + }, + ], + pubkey_node: 'n9KEk3TLMuoiTTLgrWWmfYm99hHFBZTXWzoyrHr3FbJWmRCXm96v', + server_state: 'full', + server_state_duration_us: '3738974235451', + state_accounting: { + connected: { + duration_us: '0', + transitions: '0', + }, + disconnected: { + duration_us: '2975611', + transitions: '1', + }, + full: { + duration_us: '3738974235451', + transitions: '1', + }, + syncing: { + duration_us: '3001722', + transitions: '1', + }, + tracking: { + duration_us: '26', + transitions: '1', + }, + }, + time: '2024-Oct-28 06:29:00.124327 UTC', + uptime: 3738980, + validated_ledger: { + age: 1, + base_fee_xrp: 0.00001, + hash: 'ED30F1BEFE89FE2C87A768A3791104E2A658DFC20A4085C61863B2837551E8E6', + reserve_base_xrp: 10, + reserve_inc_xrp: 2, + seq: 1848000, + }, + validation_quorum: 5, + }, + }, + status: 'success', + type: 'response', + }, +}; diff --git a/modules/sdk-coin-xrp/test/unit/xrp.ts b/modules/sdk-coin-xrp/test/unit/xrp.ts index 23556ade4f..ce4c35292f 100644 --- a/modules/sdk-coin-xrp/test/unit/xrp.ts +++ b/modules/sdk-coin-xrp/test/unit/xrp.ts @@ -8,6 +8,8 @@ import ripple from '../../src/ripple'; import * as nock from 'nock'; import assert from 'assert'; import * as rippleBinaryCodec from 'ripple-binary-codec'; +import sinon from 'sinon'; +import * as testData from '../resources/xrp'; nock.disableNetConnect(); @@ -217,4 +219,79 @@ describe('XRP:', function () { basecoin.isValidPub(pub).should.equal(true); }); }); + + describe('Recover Token Transactions', () => { + const sandBox = sinon.createSandbox(); + const tokenName = 'txrp:rlusd'; + const destination = 'raBSn6ipeWXYe7rNbNafZSx9dV2fU3zRyP'; + const passPhrase = '#Bondiola1234'; + let xrplStub; + + afterEach(() => { + sandBox.restore(); + }); + + it('should recover a token txn for non-bitgo recovery', async function () { + xrplStub = sinon.stub(basecoin.bitgo, 'post'); + const accountInfoParams = { + method: 'account_info', + params: [ + { + account: testData.keys.rootAddress, + strict: true, + ledger_index: 'current', + queue: true, + signer_lists: true, + }, + ], + }; + + const accountLinesParams = { + method: 'account_lines', + params: [ + { + account: testData.keys.rootAddress, + ledger_index: 'validated', + }, + ], + }; + + const accountInfoResponse = testData.accountInfoResponse; + const feeResponse = testData.feeResponse; + const accountLinesResponse = testData.accountlinesResponse; + const serverInfoResponse = testData.serverInfoResponse; + + const sendStub = sinon.stub(); + sendStub.withArgs(accountInfoParams).resolves(accountInfoResponse); + sendStub.withArgs({ method: 'fee' }).resolves(feeResponse); + sendStub.withArgs({ method: 'server_info' }).resolves(serverInfoResponse); + sendStub.withArgs(accountLinesParams).resolves(accountLinesResponse); + + // Apply the stub to the `xrplStub` + xrplStub.withArgs(basecoin.getRippledUrl()).returns({ + send: sendStub, + }); + + const res = await basecoin.recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + rootAddress: testData.keys.rootAddress, + recoveryDestination: destination, + walletPassphrase: passPhrase, + tokenName: tokenName, + }); + + res.should.not.be.empty(); + res.should.hasOwnProperty('txHex'); + res.should.hasOwnProperty('fee'); + res.should.hasOwnProperty('outputAmount'); + res.id.should.equal('BED49314330C3EB252B7275B1ADDBB6BF87439F2886ED7ACB1255BFF3A113FBC'); + res.outputAmount.value.should.equal('4'); + res.outputAmount.currency.should.equal('524C555344000000000000000000000000000000'); + res.outputAmount.issuer.should.equal('rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV'); + res.txHex.should.equal( + '120000228000000024000C50B8201B002B750061D48E35FA931A0000524C555344000000000000000000000000000000FCF4DD8C64636BC503F4A58DC6C684D2C7C3C24F68400000000000001E730081149389EC07DF6E6567D658BACC54606EBB33DC13E6831438D1B9A61C0FFA1A82FCF8A40AF709A9C8CF1890F3E0107321035F72A84A6BCD8ED2D26EAD2C5F864C55C26364EAF20257EFF7241F0F8D987BDA74463044022014B6C2471088A08B1C4FD065CE87DEB7AB30EBDB00C1A38A689BCFC36AA3D02402205CF25122414B766FBAA87EED35C12579799EC06DB3E9B41ABBDF47A4C9FAFEF681146BBA54CE60D9F3C926711A2C60D1CC712F21993CE1E01073210261E923400BDF6024D1D05574A7303C3D6878C7678F31254BD769DD4037495D9974473045022100AA1386B125E3131D21A95D735CDCC1923CDCE0B950F1B165F9DC50336F6C68A40220791EE568403D444E218E7BACF203B63AE96A803C0AF704F9EB6DD1FF91A355D88114EE3FBE636ADCBDD05B53493501DA6FBBC9287562E1F1' + ); + }); + }); });