diff --git a/modules/sdk-coin-ada/src/ada.ts b/modules/sdk-coin-ada/src/ada.ts index 870fe2ecc7..31ad91e11a 100644 --- a/modules/sdk-coin-ada/src/ada.ts +++ b/modules/sdk-coin-ada/src/ada.ts @@ -393,10 +393,32 @@ export class Ada extends BaseCoin { throw new Error('Did not find address with funds to recover.'); } + // Aggregate token assets from all UTxOs into a fingerprint-keyed map for the builder + const aggregatedAssetList: Record = {}; + for (const utxo of utxoSet) { + for (const asset of utxo.asset_list ?? []) { + const fp = asset.fingerprint as string; + if (aggregatedAssetList[fp]) { + aggregatedAssetList[fp].quantity = ( + BigInt(aggregatedAssetList[fp].quantity) + BigInt(asset.quantity) + ).toString(); + } else { + aggregatedAssetList[fp] = { + policy_id: asset.policy_id, + asset_name: asset.encoded_asset_name ?? asset.asset_name, + quantity: asset.quantity, + }; + } + } + } + // first build the unsigned txn const tipAbsSlot = await this.getChainTipInfo(); const txBuilder = this.getBuilder().getTransferBuilder(); - txBuilder.changeAddress(params.recoveryDestination, balance.toString()); + txBuilder.changeAddress(params.recoveryDestination, balance.toString(), aggregatedAssetList); + if (Object.keys(aggregatedAssetList).length > 0) { + txBuilder.isTokenTransaction(); + } for (const utxo of utxoSet) { txBuilder.input({ transaction_id: utxo.tx_hash, transaction_index: utxo.tx_index }); } diff --git a/modules/sdk-coin-ada/test/resources/index.ts b/modules/sdk-coin-ada/test/resources/index.ts index 930252d7a8..d5cac2ef57 100644 --- a/modules/sdk-coin-ada/test/resources/index.ts +++ b/modules/sdk-coin-ada/test/resources/index.ts @@ -375,6 +375,20 @@ export const testnetUTXO = { tx_index: 0, value: 1000000, }, + UTXO_TOKEN: { + tx_hash: 'a824b6a13e2649c7b4ef27277c23f588a67a55618b957d36320a98ac71ed4af5', + tx_index: 0, + value: 5000000, + asset_list: [ + { + policy_id: '2533cca6eb42076e144e9f2772c390dece9fce173bc38c72294b3924', + asset_name: 'water', + encoded_asset_name: '5741544552', + quantity: '111', + fingerprint: 'asset1t9uhe7a7lkjrezseduvwvnwwn38hfm3s', + }, + ], + }, }; const ZeroUTXO = { @@ -427,12 +441,23 @@ const TwoUTXO = { ], }; +const ADAAndTokenUTXOs = { + status: 200, + body: [ + { + balance: testnetUTXO.UTXO_1.value + testnetUTXO.UTXO_TOKEN.value, + utxo_set: [testnetUTXO.UTXO_1, testnetUTXO.UTXO_TOKEN], + }, + ], +}; + const addressInfoResponse = { ZeroUTXO, OneUTXO, OneUTXO2, TwoUTXO, OneSmallUTXO, + ADAAndTokenUTXOs, }; const tipInfoResponse = { diff --git a/modules/sdk-coin-ada/test/unit/ada.ts b/modules/sdk-coin-ada/test/unit/ada.ts index 21ffa39013..fd324cbab0 100644 --- a/modules/sdk-coin-ada/test/unit/ada.ts +++ b/modules/sdk-coin-ada/test/unit/ada.ts @@ -21,6 +21,7 @@ import { wrwUser, } from '../resources'; import * as _ from 'lodash'; +import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs'; import { Ada, KeyPair, Tada } from '../../src'; import { Transaction } from '../../src/lib'; import { TransactionType } from '../../../sdk-core/src/account-lib/baseCoin/enum'; @@ -584,9 +585,10 @@ describe('ADA', function () { describe('Recover Transactions:', () => { const destAddr = address.address2; const sandBox = sinon.createSandbox(); + let callBack: sinon.SinonStub; beforeEach(function () { - const callBack = sandBox.stub(Ada.prototype, 'getDataFromNode' as keyof Ada); + callBack = sandBox.stub(Ada.prototype, 'getDataFromNode' as keyof Ada); callBack .withArgs('address_info', { _addresses: [wrwUser.walletAddress0], @@ -671,6 +673,56 @@ describe('ADA', function () { should.deepEqual(txJson.outputs[0].address, destAddr); should.deepEqual(Number(txJson.outputs[0].amount) + fee, testnetUTXO.UTXO_1.value); }); + + it('should recover ADA plus token UTXOs - token and ADA both appear in outputs (signed)', async function () { + callBack + .withArgs('address_info', { _addresses: [wrwUser.walletAddress0] }) + .resolves(endpointResponses.addressInfoResponse.ADAAndTokenUTXOs); + + const res = await basecoin.recover({ + userKey: wrwUser.userKey, + backupKey: wrwUser.backupKey, + bitgoKey: wrwUser.bitgoKey, + walletPassphrase: wrwUser.walletPassphrase, + recoveryDestination: destAddr, + }); + res.should.not.be.empty(); + res.should.hasOwnProperty('serializedTx'); + + const tx = new Transaction(basecoin); + tx.fromRawTransaction(res.serializedTx); + const txJson = tx.toJson(); + + txJson.inputs.length.should.equal(2); + should.deepEqual(txJson.inputs[0].transaction_id, testnetUTXO.UTXO_1.tx_hash); + should.deepEqual(txJson.inputs[1].transaction_id, testnetUTXO.UTXO_TOKEN.tx_hash); + + // expect 2 outputs: one token output + one ADA change output, both at destAddr + txJson.outputs.length.should.equal(2); + + const tokenPolicyId = '2533cca6eb42076e144e9f2772c390dece9fce173bc38c72294b3924'; + const tokenEncodedAssetName = '5741544552'; + const tokenQuantity = '111'; + const minADAForToken = 1500000; + + const tokenOutput = txJson.outputs.find((o) => o.multiAssets !== undefined); + should.exist(tokenOutput); + should.deepEqual(tokenOutput!.address, destAddr); + should.deepEqual(Number(tokenOutput!.amount), minADAForToken); + const expectedPolicyId = CardanoWasm.ScriptHash.from_bytes(Buffer.from(tokenPolicyId, 'hex')); + const expectedAssetName = CardanoWasm.AssetName.new(Buffer.from(tokenEncodedAssetName, 'hex')); + (tokenOutput!.multiAssets as CardanoWasm.MultiAsset) + .get_asset(expectedPolicyId, expectedAssetName) + .to_str() + .should.equal(tokenQuantity); + + const adaOutput = txJson.outputs.find((o) => o.multiAssets === undefined); + should.exist(adaOutput); + should.deepEqual(adaOutput!.address, destAddr); + const fee = Number(tx.explainTransaction().fee.fee); + const totalBalance = testnetUTXO.UTXO_1.value + testnetUTXO.UTXO_TOKEN.value; + should.deepEqual(Number(adaOutput!.amount), totalBalance - minADAForToken - fee); + }); }); describe('Recover Transactions Multiple UTXO:', () => {