diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 72f90b2af5..6d3dc21ad9 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -5,7 +5,7 @@ import BigNumber from 'bignumber.js'; import * as base58 from 'bs58'; -import { BaseCoin as StaticsBaseCoin, CoinFamily, coins } from '@bitgo/statics'; +import { BaseCoin as StaticsBaseCoin, CoinFamily, coins, BaseNetwork } from '@bitgo/statics'; import * as _ from 'lodash'; import { BaseCoin, @@ -38,6 +38,8 @@ import { MPCUnsignedTx, MPCSweepRecoveryOptions, MPCTxs, + OvcInput, + OvcOutput, } from '@bitgo/sdk-core'; import { KeyPair as SolKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory } from './lib'; import { @@ -46,6 +48,7 @@ import { isValidAddress, isValidPrivateKey, isValidPublicKey, + getSolTokenFromAddress, } from './lib/utils'; import * as request from 'superagent'; import { getDerivationPath } from '@bitgo/sdk-lib-mpc'; @@ -116,6 +119,27 @@ interface SolDurableNonceFromNode { blockhash: string; } +interface TokenAmount { + amount: string; + decimals: number; + uiAmount: number; + uiAmountString: string; +} + +interface TokenAccountInfo { + isNative: boolean; + mint: string; + owner: string; + state: string; + tokenAmount: TokenAmount; +} + +interface TokenAccount { + info: TokenAccountInfo; + pubKey: string; + tokenName?: string; +} + export interface SolRecoveryOptions extends MPCRecoveryOptions { durableNonce?: { publicKey: string; @@ -173,6 +197,10 @@ export class Sol extends BaseCoin { return this._staticsCoin.fullName; } + getNetwork(): BaseNetwork { + return this._staticsCoin.network; + } + getBaseFactor(): string | number { return Math.pow(10, this._staticsCoin.decimalPlaces); } @@ -572,6 +600,38 @@ export class Sol extends BaseCoin { }; } + protected async getTokenAccountsByOwner(pubKey = ''): Promise<[] | TokenAccount[]> { + const response = await this.getDataFromNode({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [ + pubKey, + { + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + { + encoding: 'jsonParsed', + }, + ], + }, + }); + if (response.status !== 200) { + throw new Error('Account not found'); + } + + if (response.body.result.value !== []) { + const tokenAccounts: TokenAccount[] = []; + for (const tokenAccount of response.body.result.value) { + tokenAccounts.push({ info: tokenAccount.account.data.parsed.info, pubKey: tokenAccount.pubKey }); + } + return tokenAccounts; + } + + return []; + } + /** * Creates funds sweep recovery transaction(s) without BitGo * @@ -674,23 +734,100 @@ export class Sol extends BaseCoin { } const factory = this.getBuilder(); + const walletCoin = this.getChain(); + let txBuilder; let blockhash = await this.getBlockhash(); let authority = ''; - const netAmount = balance - totalFee; + if (params.durableNonce) { const durableNonceInfo = await this.getAccountInfo(params.durableNonce.publicKey); blockhash = durableNonceInfo.blockhash; authority = durableNonceInfo.authority; } - const txBuilder = factory - .getTransferBuilder() - .nonce(blockhash) - .sender(bs58EncodedPublicKey) - .send({ address: params.recoveryDestination, amount: netAmount.toString() }) - .fee({ amount: feePerSignature }) - .feePayer(bs58EncodedPublicKey); + // check for possible token recovery, token assets must be recovered first + const tokenAccounts = await this.getTokenAccountsByOwner(bs58EncodedPublicKey); + if (tokenAccounts.length !== 0) { + // there exists token accounts on the given address, but need to check certain conditions: + // 1. if there is a recoverable balance + // 2. if the token is supported by bitgo + const recovereableTokenAccounts: TokenAccount[] = []; + for (const tokenAccount of tokenAccounts as TokenAccount[]) { + const tokenAmount = new BigNumber(tokenAccount.info.tokenAmount.amount); + const network = this.getNetwork(); + const token = getSolTokenFromAddress(tokenAccount.info.mint, network); + + if (!_.isUndefined(token) && tokenAmount.gt(new BigNumber(0))) { + tokenAccount.tokenName = token.name; + recovereableTokenAccounts.push(tokenAccount); + } + } + + if (recovereableTokenAccounts.length !== 0) { + // there are recoverable token accounts, need to check if there is sufficient native solana to recover tokens + const totalTokenFees = new BigNumber(totalFee).multipliedBy(new BigNumber(recovereableTokenAccounts.length)); + if (new BigNumber(balance).lt(totalTokenFees)) { + throw Error('Did not find address with funds to recover'); + } + + txBuilder = factory + .getTokenTransferBuilder() + .nonce(blockhash) + .sender(bs58EncodedPublicKey) + .fee({ amount: feePerSignature }) + .feePayer(bs58EncodedPublicKey); + + // need to get all token accounts of the recipient address and need to create them if they do not exist + const recipientTokenAccounts = await this.getTokenAccountsByOwner(params.recoveryDestination); + + for (const tokenAccount of recovereableTokenAccounts) { + let recipientTokenAccountExists = false; + for (const recipientTokenAccount of recipientTokenAccounts as TokenAccount[]) { + if (recipientTokenAccount.info.mint === tokenAccount.info.mint) { + recipientTokenAccountExists = true; + break; + } + } + + const recipientTokenAccount = await getAssociatedTokenAccountAddress( + tokenAccount.info.mint, + params.recoveryDestination + ); + const tokenName = tokenAccount.tokenName as string; + txBuilder.send({ + address: recipientTokenAccount, + amount: tokenAccount.info.tokenAmount.amount, + tokenName: tokenName, + }); + + if (!recipientTokenAccountExists) { + // recipient token account does not exist for token and must be created + txBuilder.createAssociatedTokenAccount({ ownerAddress: params.recoveryDestination, tokenName: tokenName }); + } + } + } else { + const netAmount = balance - totalFee; + + txBuilder = factory + .getTransferBuilder() + .nonce(blockhash) + .sender(bs58EncodedPublicKey) + .send({ address: params.recoveryDestination, amount: netAmount.toString() }) + .fee({ amount: feePerSignature }) + .feePayer(bs58EncodedPublicKey); + } + } else { + const netAmount = balance - totalFee; + + txBuilder = factory + .getTransferBuilder() + .nonce(blockhash) + .sender(bs58EncodedPublicKey) + .send({ address: params.recoveryDestination, amount: netAmount.toString() }) + .fee({ amount: feePerSignature }) + .feePayer(bs58EncodedPublicKey); + } if (params.durableNonce) { txBuilder.nonce(blockhash, { @@ -762,22 +899,23 @@ export class Sol extends BaseCoin { const completedTransaction = await txBuilder.build(); const serializedTx = completedTransaction.toBroadcastFormat(); const derivationPath = params.seed ? getDerivationPath(params.seed) + `/${index}` : `m/${index}`; - const walletCoin = this.getChain(); - const inputs = [ - { - address: completedTransaction.inputs[0].address, - valueString: completedTransaction.inputs[0].value, - value: new BigNumber(completedTransaction.inputs[0].value).toNumber(), - }, - ]; - const outputs = [ - { - address: completedTransaction.outputs[0].address, - valueString: completedTransaction.inputs[0].value, - coinName: walletCoin, - }, - ]; - const spendAmount = completedTransaction.inputs[0].value; + const inputs: OvcInput[] = []; + for (const input of completedTransaction.inputs) { + inputs.push({ + address: input.address, + valueString: input.value, + value: new BigNumber(input.value).toNumber(), + }); + } + const outputs: OvcOutput[] = []; + for (const output of completedTransaction.outputs) { + outputs.push({ + address: output.address, + valueString: output.value, + coinName: output.coin ? output.coin : walletCoin, + }); + } + const spendAmount = completedTransaction.inputs.length === 1 ? completedTransaction.inputs[0].value : 0; const parsedTx = { inputs: inputs, outputs: outputs, spendAmount: spendAmount, type: '' }; const feeInfo = { fee: totalFee, feeString: new BigNumber(totalFee).toString() }; const coinSpecific = { commonKeychain: bitgoKey }; diff --git a/modules/sdk-coin-sol/test/fixtures/sol.ts b/modules/sdk-coin-sol/test/fixtures/sol.ts index c897ac4e0f..8277642d3f 100644 --- a/modules/sdk-coin-sol/test/fixtures/sol.ts +++ b/modules/sdk-coin-sol/test/fixtures/sol.ts @@ -172,6 +172,169 @@ const getAccountBalanceResponseM2Derivation = { }, }; +const getTokenAccountsByOwnerResponse = { + status: 200, + body: { + jsonrpc: '2.0', + result: { + context: { + apiVersion: '1.17.5', + slot: 259019329, + }, + value: [ + { + account: { + data: { + parsed: { + info: { + isNative: false, + mint: '9cgpBeNZ2HnLda7NWaaU1i3NyTstk2c4zCMUcoAGsi9C', + owner: 'HMEgbR4S2hLKfst2VZUVpHVUu4FioFPyW5iUuJvZdMvs', + state: 'initialized', + tokenAmount: { + amount: '2000000000', + decimals: 9, + uiAmount: 2, + uiAmountString: '2', + }, + }, + type: 'account', + }, + program: 'spl-token', + space: 165, + }, + executable: false, + lamports: 2039280, + owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + rentEpoch: 18446744073709552000, + space: 165, + }, + pubkey: '4FaMdTh9uwmroyfBxgtT7FfZV6e6ngmEFBJtXBUjjoNt', + }, + { + account: { + data: { + parsed: { + info: { + isNative: false, + mint: 'F4uLeXJoFz3hw13MposuwaQbMcZbCjqvEGPPeRRB1Byf', + owner: 'HMEgbR4S2hLKfst2VZUVpHVUu4FioFPyW5iUuJvZdMvs', + state: 'initialized', + tokenAmount: { + amount: '3000000000', + decimals: 9, + uiAmount: 3, + uiAmountString: '3', + }, + }, + type: 'account', + }, + program: 'spl-token', + space: 165, + }, + executable: false, + lamports: 2039280, + owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + rentEpoch: 18446744073709552000, + space: 165, + }, + pubkey: 'F3xVrafJVoKGHfzXB6wXE4S1iBAwH6ZigpNhQcybHghM', + }, + ], + }, + id: '1', + }, +}; + +const getTokenAccountsByOwnerResponse2 = { + status: 200, + body: { + jsonrpc: '2.0', + result: { + context: { + apiVersion: '1.17.5', + slot: 259019329, + }, + value: [ + { + account: { + data: { + parsed: { + info: { + isNative: false, + mint: '9cgpBeNZ2HnLda7NWaaU1i3NyTstk2c4zCMUcoAGsi9C', + owner: 'cyggsFnDvbfsPeiFXziebWsWAp6bW5Nc5SePTx8mebL', + state: 'initialized', + tokenAmount: { + amount: '2000000000', + decimals: 9, + uiAmount: 2, + uiAmountString: '2', + }, + }, + type: 'account', + }, + program: 'spl-token', + space: 165, + }, + executable: false, + lamports: 2039280, + owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + rentEpoch: 18446744073709552000, + space: 165, + }, + pubkey: '4FaMdTh9uwmroyfBxgtT7FfZV6e6ngmEFBJtXBUjjoNt', + }, + { + account: { + data: { + parsed: { + info: { + isNative: false, + mint: 'F4uLeXJoFz3hw13MposuwaQbMcZbCjqvEGPPeRRB1Byf', + owner: 'cyggsFnDvbfsPeiFXziebWsWAp6bW5Nc5SePTx8mebL', + state: 'initialized', + tokenAmount: { + amount: '3000000000', + decimals: 9, + uiAmount: 3, + uiAmountString: '3', + }, + }, + type: 'account', + }, + program: 'spl-token', + space: 165, + }, + executable: false, + lamports: 2039280, + owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + rentEpoch: 18446744073709552000, + space: 165, + }, + pubkey: 'F3xVrafJVoKGHfzXB6wXE4S1iBAwH6ZigpNhQcybHghM', + }, + ], + }, + id: '1', + }, +}; + +const getTokenAccountsByOwnerResponseNoAccounts = { + status: 200, + body: { + jsonrpc: '2.0', + result: { + context: { + apiVersion: '1.17.5', + slot: 259020450, + }, + value: [], + }, + id: '1', + }, +}; + export const SolResponses = { getBlockhashResponse, getFeesResponse, @@ -181,6 +344,9 @@ export const SolResponses = { getAccountBalanceResponseNoFunds, getAccountBalanceResponseM2Derivation, getAccountBalanceResponseM1Derivation, + getTokenAccountsByOwnerResponse, + getTokenAccountsByOwnerResponse2, + getTokenAccountsByOwnerResponseNoAccounts, } as const; export const accountInfo = { @@ -232,6 +398,7 @@ export const keys = { '4368cecc6b8290fdab5e449c70673c5cb8d7f76481db807d41d16629143a2e1d6d97c5672a0\n' + '3060a1777530681a784cb15165f41e49f072d5eb8026d7a287b35', destinationPubKey: '3EJt66Hwfi22FRU2HWPet7faPRstiSdGxrEe486CxhTL', + destinationPubKey2: 'HMEgbR4S2hLKfst2VZUVpHVUu4FioFPyW5iUuJvZdMvs', walletPassword: 't3stSicretly!', durableNoncePubKey: '6LqY5ncj7s4b1c3YJV1hsn2hVPNhEfvDCNYMaCc1jJhX', durableNoncePubKey2: '4Y3kQtmVUfF7nimtABPpCwjihmLgJUgm8eZTAo44c4u9', diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index 0b2f67e8c0..c4685e92a1 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -10,6 +10,8 @@ import { TssUtils, TxRequest, Wallet, MPCSweepTxs, MPCTx, MPCTxs } from '@bitgo/ import { getBuilderFactory } from './getBuilderFactory'; import { Transaction } from '../../src/lib'; import { coins } from '@bitgo/statics'; +import { getAssociatedTokenAccountAddress } from '../../src/lib/utils'; +import { InstructionParams, AtaInit, TokenTransfer } from '../../src/lib/iface'; describe('SOL:', function () { let bitgo: TestBitGoAPI; @@ -1421,6 +1423,8 @@ describe('SOL:', function () { describe('Recover Transactions:', () => { const sandBox = sinon.createSandbox(); const coin = coins.get('tsol'); + const usdtMintAddress = '9cgpBeNZ2HnLda7NWaaU1i3NyTstk2c4zCMUcoAGsi9C'; + const usdcMintAddress = 'F4uLeXJoFz3hw13MposuwaQbMcZbCjqvEGPPeRRB1Byf'; beforeEach(() => { const callBack = sandBox.stub(Sol.prototype, 'getDataFromNode' as keyof Sol); @@ -1503,6 +1507,88 @@ describe('SOL:', function () { }, }) .resolves(testData.SolResponses.getAccountInfoResponse); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [ + testData.keys.destinationPubKey, + { + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + { + encoding: 'jsonParsed', + }, + ], + }, + }) + .resolves(testData.SolResponses.getTokenAccountsByOwnerResponseNoAccounts); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [ + testData.accountInfo.bs58EncodedPublicKey, + { + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + { + encoding: 'jsonParsed', + }, + ], + }, + }) + .resolves(testData.SolResponses.getTokenAccountsByOwnerResponseNoAccounts); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [ + testData.keys.destinationPubKey2, + { + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + { + encoding: 'jsonParsed', + }, + ], + }, + }) + .resolves(testData.SolResponses.getTokenAccountsByOwnerResponse); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [ + testData.wrwUser.walletAddress0, + { + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + { + encoding: 'jsonParsed', + }, + ], + }, + }) + .resolves(testData.SolResponses.getTokenAccountsByOwnerResponse2); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getBalance', + params: [testData.wrwUser.walletAddress0], + }, + }) + .resolves(testData.SolResponses.getAccountBalanceResponse); }); afterEach(() => { @@ -1555,7 +1641,7 @@ describe('SOL:', function () { should.equal(latestBlockhashTxnJson.feePayer, testData.accountInfo.bs58EncodedPublicKey); should.equal(latestBlockhashTxnJson.numSignatures, testData.SolInputData.latestBlockhashSignatures); const solCoin = basecoin as any; - sandBox.assert.callCount(solCoin.getDataFromNode, 3); + sandBox.assert.callCount(solCoin.getDataFromNode, 4); }); it('should recover a txn for non-bitgo recoveries (durable nonce)', async function () { @@ -1585,7 +1671,7 @@ describe('SOL:', function () { should.equal(durableNonceTxnJson.feePayer, testData.accountInfo.bs58EncodedPublicKey); should.equal(durableNonceTxnJson.numSignatures, testData.SolInputData.durableNonceSignatures); const solCoin = basecoin as any; - sandBox.assert.callCount(solCoin.getDataFromNode, 4); + sandBox.assert.callCount(solCoin.getDataFromNode, 5); }); it('should recover a txn for unsigned sweep recoveries', async function () { @@ -1614,7 +1700,7 @@ describe('SOL:', function () { should.equal(unsignedSweepTxnJson.feePayer, testData.accountInfo.bs58EncodedPublicKey); should.equal(unsignedSweepTxnJson.numSignatures, testData.SolInputData.unsignedSweepSignatures); const solCoin = basecoin as any; - sandBox.assert.callCount(solCoin.getDataFromNode, 4); + sandBox.assert.callCount(solCoin.getDataFromNode, 5); }); it('should handle error in recover function if a required field is missing/incorrect', async function () { @@ -1670,9 +1756,177 @@ describe('SOL:', function () { }) .should.rejectedWith('Did not find address with funds to recover'); }); + + it('should recover sol tokens to recovery destination with no existing token accounts', async function () { + const tokenTxn = await basecoin.recover({ + userKey: testData.wrwUser.userKey, + backupKey: testData.wrwUser.backupKey, + bitgoKey: testData.wrwUser.bitgoKey, + recoveryDestination: testData.keys.destinationPubKey, + walletPassphrase: testData.wrwUser.walletPassphrase, + durableNonce: { + publicKey: testData.keys.durableNoncePubKey, + secretKey: testData.keys.durableNoncePrivKey, + }, + }); + + tokenTxn.should.not.be.empty(); + tokenTxn.should.hasOwnProperty('serializedTx'); + tokenTxn.should.hasOwnProperty('scanIndex'); + should.equal((tokenTxn as MPCTx).scanIndex, 0); + + const tokenTxnDeserialize = new Transaction(coin); + tokenTxnDeserialize.fromRawTransaction((tokenTxn as MPCTx).serializedTx); + const tokenTxnJson = tokenTxnDeserialize.toJson(); + + should.equal(tokenTxnJson.nonce, testData.SolInputData.durableNonceBlockhash); + should.equal(tokenTxnJson.feePayer, testData.wrwUser.walletAddress0); + should.equal(tokenTxnJson.numSignatures, testData.SolInputData.durableNonceSignatures); + + const instructionsData = tokenTxnJson.instructionsData as InstructionParams[]; + should.equal(instructionsData.length, 5); + should.equal(instructionsData[0].type, 'NonceAdvance'); + + const destinationUSDTTokenAccount = await getAssociatedTokenAccountAddress( + usdtMintAddress, + testData.keys.destinationPubKey + ); + should.equal(instructionsData[1].type, 'CreateAssociatedTokenAccount'); + should.equal((instructionsData[1] as AtaInit).params.mintAddress, usdtMintAddress); + should.equal((instructionsData[1] as AtaInit).params.ataAddress, destinationUSDTTokenAccount); + should.equal((instructionsData[1] as AtaInit).params.ownerAddress, testData.keys.destinationPubKey); + should.equal((instructionsData[1] as AtaInit).params.tokenName, 'tsol:usdt'); + should.equal((instructionsData[1] as AtaInit).params.payerAddress, testData.wrwUser.walletAddress0); + + const destinationUSDCTokenAccount = await getAssociatedTokenAccountAddress( + usdcMintAddress, + testData.keys.destinationPubKey + ); + should.equal(instructionsData[2].type, 'CreateAssociatedTokenAccount'); + should.equal((instructionsData[2] as AtaInit).params.mintAddress, usdcMintAddress); + should.equal((instructionsData[2] as AtaInit).params.ataAddress, destinationUSDCTokenAccount); + should.equal((instructionsData[2] as AtaInit).params.ownerAddress, testData.keys.destinationPubKey); + should.equal((instructionsData[2] as AtaInit).params.tokenName, 'tsol:usdc'); + should.equal((instructionsData[2] as AtaInit).params.payerAddress, testData.wrwUser.walletAddress0); + + const sourceUSDTTokenAccount = await getAssociatedTokenAccountAddress( + usdtMintAddress, + testData.wrwUser.walletAddress0 + ); + should.equal(instructionsData[3].type, 'TokenTransfer'); + should.equal((instructionsData[3] as TokenTransfer).params.fromAddress, testData.wrwUser.walletAddress0); + should.equal((instructionsData[3] as TokenTransfer).params.toAddress, destinationUSDTTokenAccount); + should.equal((instructionsData[3] as TokenTransfer).params.amount, '2000000000'); + should.equal((instructionsData[3] as TokenTransfer).params.tokenName, 'tsol:usdt'); + should.equal((instructionsData[3] as TokenTransfer).params.sourceAddress, sourceUSDTTokenAccount); + + const sourceUSDCTokenAccount = await getAssociatedTokenAccountAddress( + usdcMintAddress, + testData.wrwUser.walletAddress0 + ); + should.equal(instructionsData[4].type, 'TokenTransfer'); + should.equal((instructionsData[4] as TokenTransfer).params.fromAddress, testData.wrwUser.walletAddress0); + should.equal((instructionsData[4] as TokenTransfer).params.toAddress, destinationUSDCTokenAccount); + should.equal((instructionsData[4] as TokenTransfer).params.amount, '3000000000'); + should.equal((instructionsData[4] as TokenTransfer).params.tokenName, 'tsol:usdc'); + should.equal((instructionsData[4] as TokenTransfer).params.sourceAddress, sourceUSDCTokenAccount); + + const solCoin = basecoin as any; + sandBox.assert.callCount(solCoin.getDataFromNode, 6); + }); + + it('should recover sol tokens to recovery destination with existing token accounts', async function () { + const tokenTxn = await basecoin.recover({ + userKey: testData.wrwUser.userKey, + backupKey: testData.wrwUser.backupKey, + bitgoKey: testData.wrwUser.bitgoKey, + recoveryDestination: testData.keys.destinationPubKey2, + walletPassphrase: testData.wrwUser.walletPassphrase, + durableNonce: { + publicKey: testData.keys.durableNoncePubKey, + secretKey: testData.keys.durableNoncePrivKey, + }, + }); + + tokenTxn.should.not.be.empty(); + tokenTxn.should.hasOwnProperty('serializedTx'); + tokenTxn.should.hasOwnProperty('scanIndex'); + should.equal((tokenTxn as MPCTx).scanIndex, 0); + + const tokenTxnDeserialize = new Transaction(coin); + tokenTxnDeserialize.fromRawTransaction((tokenTxn as MPCTx).serializedTx); + const tokenTxnJson = tokenTxnDeserialize.toJson(); + + should.equal(tokenTxnJson.nonce, testData.SolInputData.durableNonceBlockhash); + should.equal(tokenTxnJson.feePayer, testData.wrwUser.walletAddress0); + should.equal(tokenTxnJson.numSignatures, testData.SolInputData.durableNonceSignatures); + + const instructionsData = tokenTxnJson.instructionsData as TokenTransfer[]; + should.equal(instructionsData.length, 3); + should.equal(instructionsData[0].type, 'NonceAdvance'); + + const sourceUSDTTokenAccount = await getAssociatedTokenAccountAddress( + usdtMintAddress, + testData.wrwUser.walletAddress0 + ); + const destinationUSDTTokenAccount = await getAssociatedTokenAccountAddress( + usdtMintAddress, + testData.keys.destinationPubKey2 + ); + should.equal(instructionsData[1].type, 'TokenTransfer'); + should.equal(instructionsData[1].params.fromAddress, testData.wrwUser.walletAddress0); + should.equal(instructionsData[1].params.toAddress, destinationUSDTTokenAccount); + should.equal(instructionsData[1].params.amount, '2000000000'); + should.equal(instructionsData[1].params.tokenName, 'tsol:usdt'); + should.equal(instructionsData[1].params.sourceAddress, sourceUSDTTokenAccount); + + const sourceUSDCTokenAccount = await getAssociatedTokenAccountAddress( + usdcMintAddress, + testData.wrwUser.walletAddress0 + ); + const destinationUSDCTokenAccount = await getAssociatedTokenAccountAddress( + usdcMintAddress, + testData.keys.destinationPubKey2 + ); + should.equal(instructionsData[2].type, 'TokenTransfer'); + should.equal(instructionsData[2].params.fromAddress, testData.wrwUser.walletAddress0); + should.equal(instructionsData[2].params.toAddress, destinationUSDCTokenAccount); + should.equal(instructionsData[2].params.amount, '3000000000'); + should.equal(instructionsData[2].params.tokenName, 'tsol:usdc'); + should.equal(instructionsData[2].params.sourceAddress, sourceUSDCTokenAccount); + + const solCoin = basecoin as any; + sandBox.assert.callCount(solCoin.getDataFromNode, 6); + }); + + it('should recover sol tokens to recovery destination with existing token accounts for unsigned sweep recoveries', async function () { + const tokenTxn = (await basecoin.recover({ + bitgoKey: testData.wrwUser.bitgoKey, + recoveryDestination: testData.keys.destinationPubKey2, + durableNonce: { + publicKey: testData.keys.durableNoncePubKey, + secretKey: testData.keys.durableNoncePrivKey, + }, + })) as MPCSweepTxs; + + tokenTxn.should.not.be.empty(); + tokenTxn.txRequests[0].transactions[0].unsignedTx.should.hasOwnProperty('serializedTx'); + tokenTxn.txRequests[0].transactions[0].unsignedTx.should.hasOwnProperty('scanIndex'); + should.equal(tokenTxn.txRequests[0].transactions[0].unsignedTx.scanIndex, 0); + + const tokenTxnDeserialize = new Transaction(coin); + tokenTxnDeserialize.fromRawTransaction(tokenTxn.txRequests[0].transactions[0].unsignedTx.serializedTx); + const tokenTxnJson = tokenTxnDeserialize.toJson(); + + should.equal(tokenTxnJson.nonce, testData.SolInputData.durableNonceBlockhash); + should.equal(tokenTxnJson.feePayer, testData.wrwUser.walletAddress0); + should.equal(tokenTxnJson.numSignatures, testData.SolInputData.unsignedSweepSignatures); + const solCoin = basecoin as any; + sandBox.assert.callCount(solCoin.getDataFromNode, 6); + }); }); - describe('Build Unsigned Consolidation Recoveries:', () => { + describe('Build Consolidation Recoveries:', () => { const sandBox = sinon.createSandbox(); const coin = coins.get('tsol'); const durableNonces = { @@ -1770,6 +2024,60 @@ describe('SOL:', function () { }, }) .resolves(testData.SolResponses.getAccountInfoResponse2); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [ + testData.wrwUser.walletAddress1, + { + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + { + encoding: 'jsonParsed', + }, + ], + }, + }) + .resolves(testData.SolResponses.getTokenAccountsByOwnerResponseNoAccounts); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [ + testData.wrwUser.walletAddress2, + { + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + { + encoding: 'jsonParsed', + }, + ], + }, + }) + .resolves(testData.SolResponses.getTokenAccountsByOwnerResponseNoAccounts); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [ + testData.wrwUser.walletAddress3, + { + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + { + encoding: 'jsonParsed', + }, + ], + }, + }) + .resolves(testData.SolResponses.getTokenAccountsByOwnerResponseNoAccounts); }); afterEach(() => { diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 09e9828957..54a6e107af 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -457,6 +457,18 @@ export interface MPCTxs { lastScanIndex: number; } +export interface OvcInput { + address: string; + value: number; + valueString: string; +} + +export interface OvcOutput { + address: string; + valueString: string; + coinName?: string; +} + export type BackupGpgKey = SerializedKeyPair | Key; /**