diff --git a/modules/sdk-coin-algo/src/algo.ts b/modules/sdk-coin-algo/src/algo.ts index 54f1b60011..3be4de9a8a 100644 --- a/modules/sdk-coin-algo/src/algo.ts +++ b/modules/sdk-coin-algo/src/algo.ts @@ -30,6 +30,7 @@ import { MultisigType, multisigTypes, AuditDecryptedKeyParams, + TxIntentMismatchError, } from '@bitgo/sdk-core'; import stellar from 'stellar-sdk'; import BigNumber from 'bignumber.js'; @@ -580,7 +581,7 @@ export class Algo extends BaseCoin { } async verifyTransaction(params: VerifyTransactionOptions): Promise { - const { txParams, txPrebuild } = params; + const { txParams, txPrebuild, wallet, verification } = params; if (!txParams) { throw new Error('missing txParams'); @@ -602,14 +603,40 @@ export class Algo extends BaseCoin { // Validate based on Algorand transaction type switch (txJson.type) { case 'pay': - return this.validatePayTransaction(txJson, txParams); + this.validatePayTransaction(txJson, txParams); + break; case 'axfer': - return this.validateAssetTransferTransaction(txJson, txParams); + this.validateAssetTransferTransaction(txJson, txParams); + break; default: // For other transaction types, perform basic validation this.validateBasicTransaction(txJson); - return true; + break; } + + // Verify consolidation transactions send to base address + if (verification?.consolidationToBaseAddress) { + if (!wallet?.coinSpecific()?.rootAddress) { + throw new Error('Unable to determine base address for consolidation'); + } + const rootAddress = wallet.coinSpecific()?.rootAddress as string; + + // Verify the transaction recipient matches the rootAddress + if (!txJson.to) { + throw new Error('Transaction is missing recipient address'); + } + + if (txJson.to !== rootAddress) { + throw new TxIntentMismatchError( + 'Consolidation transaction recipient does not match wallet base address', + txJson.to, + [txParams], + txJson + ); + } + } + + return true; } /** diff --git a/modules/sdk-coin-algo/test/fixtures/resources.ts b/modules/sdk-coin-algo/test/fixtures/resources.ts index 456096a070..494cd692fa 100644 --- a/modules/sdk-coin-algo/test/fixtures/resources.ts +++ b/modules/sdk-coin-algo/test/fixtures/resources.ts @@ -132,3 +132,13 @@ export const base64Txn = { txid: 'RSWDVNXWLX7UEDVGJR7B7SSCL3Q43UILPYIEX2AMDD72JIFV7TVA', }, } as const; + +export const consolidation = { + // Real consolidation transaction from account-consolidation-build + rootAddress: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE', + receiveAddress: 'U3YYPVGS7MOEPLAMOEBQYMQL6K6OG6VA5CL4RGMPXQTMWEWHBSN2RYS774', + txHex: + 'iaNhbXTOAJcL+KNmZWXNA+iiZnbOA2bxbqNnZW6sdGVzdG5ldC12MS4womdoxCBIY7UYpLPITsgQ8i1PEIHLD3HwWaesIN7GL39w5Qk6IqJsds4DZvVWo3JjdsQgX5zIGLW8huES9Q7KWIcUHsp/UKyfrNmXA03MSvsjh/ijc25kxCCm8YfU0vscR6wMcQMMMgvyvON6oOiXyJmPvCbLEscMm6R0eXBlo3BheQ==', + amount: '9899000', + txId: 'ROTFDRSEAWKSYIOBE6PKX5TVZIYKRFWEN4PET54ODY3RDMRCMMQA', +} as const; diff --git a/modules/sdk-coin-algo/test/unit/verifyTransaction.ts b/modules/sdk-coin-algo/test/unit/verifyTransaction.ts index 3bfb62d420..cf6ed04bdb 100644 --- a/modules/sdk-coin-algo/test/unit/verifyTransaction.ts +++ b/modules/sdk-coin-algo/test/unit/verifyTransaction.ts @@ -3,6 +3,9 @@ import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; import * as testData from '../fixtures/resources'; import assert from 'assert'; +import nock = require('nock'); +import * as sinon from 'sinon'; +import { common, Wallet } from '@bitgo/sdk-core'; describe('Algorand Verify Transaction:', function () { let bitgo: TestBitGoAPI; @@ -15,6 +18,11 @@ describe('Algorand Verify Transaction:', function () { basecoin = bitgo.coin('talgo') as Talgo; }); + afterEach(function () { + sinon.restore(); + nock.cleanAll(); + }); + describe('Parameter Validation', () => { it('should throw error when txParams is missing', async function () { const txPrebuild = { @@ -288,4 +296,462 @@ describe('Algorand Verify Transaction:', function () { assert.strictEqual(result, true); }); }); + + describe('Consolidation Verification', () => { + it('should verify consolidation through sendAccountConsolidations with nock - positive case', async function () { + // This test verifies that sendAccountConsolidations allows valid consolidation transactions + // It calls verifyTransaction, which should pass when the tx sends to the correct rootAddress + const walletData = { + id: '6434384f1deb5200070345111cba7405', + coin: 'talgo', + keys: [ + '6434383c40af290007578fdb89c69028', + '6434383c9e52490007e6f69f5523c82e', + '6434383e4b5a440007289caeebf76225', + ], + coinSpecific: { + rootAddress: testData.consolidation.rootAddress, // Correct rootAddress + addressVersion: 1, + trustedTokens: [], + }, + multisigType: 'onchain' as const, + }; + + const consolidationTx = { + txHex: testData.consolidation.txHex, // Sends to rootAddress + txHash: testData.consolidation.txId, + feeInfo: { + size: 247, + fee: 1000, + feeRate: 4, + feeString: '1000', + }, + keys: [ + '42MIYL2KBISV6WJRALTTSXHBEGLNF7MMQ74FGSYUCT5YP3V2KENJGRFVVQ', + 'MAGXZTDFW5QEXUKOUDIGHTXOKDW7TNQEDLWPBEZEL3VFU3YAA2PGYRS65M', + 'IBHXE4L4HCGJSXJACQAM6ZU6JHTLDEQNVNDZ25TJVX5W43B3AQRMB4VEDU', + ], + addressVersion: 1, + consolidateId: '690572bd61f0a4695dd8710ae6c185cc', + coin: 'talgo', + }; + + const bgUrl = common.Environments['mock'].uri; + const walletObj = new Wallet(bitgo, basecoin, walletData); + + // Mock the consolidateAccount/build API call + nock(bgUrl) + .post('/api/v2/talgo/wallet/6434384f1deb5200070345111cba7405/consolidateAccount/build') + .reply(200, [consolidationTx]); + + // Mock getKeychainsAndValidatePassphrase + sinon.stub(walletObj, 'getKeychainsAndValidatePassphrase' as keyof Wallet).resolves([]); + + // The key test: should NOT throw the consolidation verification error + // because the transaction sends to the correct rootAddress + try { + await walletObj.sendAccountConsolidations({ walletPassphrase: 'test' }); + // If we get here without throwing, the verification passed + assert.ok(true, 'Consolidation verification passed - no error thrown'); + } catch (error) { + // If an error is thrown, it should NOT be the consolidation mismatch error + // (other errors like signing errors are okay since we're not fully mocking everything) + if (error.message === 'Consolidation transaction recipient does not match wallet base address') { + assert.fail('Should not throw consolidation verification error for valid transaction'); + } + // Other errors are acceptable in this test (e.g., incomplete mocking of signing) + } + }); + + it('should reject malicious consolidation through sendAccountConsolidations with nock', async function () { + // This test verifies that sendAccountConsolidations rejects malicious transactions + // We use a wallet configured with the WRONG rootAddress to simulate a mismatch + const walletData = { + id: '6434384f1deb5200070345111cba7405', + coin: 'talgo', + keys: [ + '6434383c40af290007578fdb89c69028', + '6434383c9e52490007e6f69f5523c82e', + '6434383e4b5a440007289caeebf76225', + ], + coinSpecific: { + rootAddress: testData.consolidation.receiveAddress, // WRONG: not where the tx sends + addressVersion: 1, + trustedTokens: [], + }, + multisigType: 'onchain' as const, + }; + + // The txHex sends to testData.consolidation.rootAddress, but wallet expects receiveAddress + const consolidationTx = { + txHex: testData.consolidation.txHex, // Sends to rootAddress (not receiveAddress) + txHash: testData.consolidation.txId, + feeInfo: { + size: 247, + fee: 1000, + feeRate: 4, + feeString: '1000', + }, + keys: [ + '42MIYL2KBISV6WJRALTTSXHBEGLNF7MMQ74FGSYUCT5YP3V2KENJGRFVVQ', + 'MAGXZTDFW5QEXUKOUDIGHTXOKDW7TNQEDLWPBEZEL3VFU3YAA2PGYRS65M', + 'IBHXE4L4HCGJSXJACQAM6ZU6JHTLDEQNVNDZ25TJVX5W43B3AQRMB4VEDU', + ], + addressVersion: 1, + consolidateId: '690572bd61f0a4695dd8710ae6c185cc', + coin: 'talgo', + }; + + const bgUrl = common.Environments['mock'].uri; + const walletObj = new Wallet(bitgo, basecoin, walletData); + + // Mock the consolidateAccount/build API call + nock(bgUrl) + .post('/api/v2/talgo/wallet/6434384f1deb5200070345111cba7405/consolidateAccount/build') + .reply(200, [consolidationTx]); + + // Mock getKeychainsAndValidatePassphrase + sinon.stub(walletObj, 'getKeychainsAndValidatePassphrase' as any).resolves([]); + + // The verification should catch this and throw an error BEFORE signing/sending + await assert.rejects(walletObj.sendAccountConsolidations({ walletPassphrase: 'test' }), (error: Error) => { + assert.strictEqual(error.message, 'Consolidation transaction recipient does not match wallet base address'); + return true; + }); + }); + + it('should verify consolidation flow as called from sendAccountConsolidations', async function () { + // This test simulates how sendAccountConsolidations calls verifyTransaction + // It uses the real consolidation data and mimics the wallet structure + const mockWallet = { + _wallet: { + coinSpecific: { + rootAddress: testData.consolidation.rootAddress, + addressVersion: 1, + trustedTokens: [], + }, + multisigType: 'onchain', + }, + coinSpecific: function () { + return this._wallet.coinSpecific; + }, + }; + + // This is the build result from buildAccountConsolidations + const build = { + txHex: testData.consolidation.txHex, + consolidateId: '690572bd61f0a4695dd8710ae6c185cc', + coin: 'talgo', + }; + + // This is what sendAccountConsolidations does: verify with consolidationToBaseAddress + const result = await basecoin.verifyTransaction({ + txPrebuild: build, + txParams: {}, // sendAccountConsolidations passes empty params from the build call + verification: { + consolidationToBaseAddress: true, + }, + wallet: mockWallet as any, + walletType: mockWallet._wallet.multisigType as any, + }); + + assert.strictEqual(result, true); + }); + + it('should reject malicious consolidation with wrong recipient in sendAccountConsolidations flow', async function () { + // This test simulates a malicious transaction that tries to send to a different address + // The verification should catch this and throw an error + const mockWallet = { + _wallet: { + coinSpecific: { + rootAddress: testData.consolidation.receiveAddress, // WRONG: This is not where the tx is sending + addressVersion: 1, + trustedTokens: [], + }, + multisigType: 'onchain', + }, + coinSpecific: function () { + return this._wallet.coinSpecific; + }, + }; + + const build = { + txHex: testData.consolidation.txHex, // This sends to rootAddress, not receiveAddress + consolidateId: '690572bd61f0a4695dd8710ae6c185cc', + coin: 'talgo', + }; + + // This should throw because the transaction recipient doesn't match the wallet's rootAddress + await assert.rejects( + basecoin.verifyTransaction({ + txPrebuild: build, + txParams: {}, + verification: { + consolidationToBaseAddress: true, + }, + wallet: mockWallet as any, + walletType: mockWallet._wallet.multisigType as any, + }), + { + message: 'Consolidation transaction recipient does not match wallet base address', + } + ); + }); + + it('should verify real consolidation transaction with valid rootAddress', async function () { + // Using real consolidation transaction data from actual wallet + const mockWallet = { + coinSpecific: () => ({ + rootAddress: testData.consolidation.rootAddress, + }), + }; + + const txParams = { + recipients: [ + { + address: testData.consolidation.rootAddress, + amount: testData.consolidation.amount, + }, + ], + }; + const txPrebuild = { + txHex: testData.consolidation.txHex, + }; + + const result = await basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet: mockWallet as any, + verification: { + consolidationToBaseAddress: true, + }, + }); + assert.strictEqual(result, true); + }); + + it('should reject consolidation when recipient does not match rootAddress', async function () { + // Using different rootAddress than what's in the consolidation transaction + const mockWallet = { + coinSpecific: () => ({ + rootAddress: testData.consolidation.receiveAddress, // Wrong: using receive address instead of root + }), + }; + + const txParams = { + recipients: [ + { + address: testData.consolidation.rootAddress, + amount: testData.consolidation.amount, + }, + ], + }; + const txPrebuild = { + txHex: testData.consolidation.txHex, + }; + + await assert.rejects( + basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet: mockWallet as any, + verification: { + consolidationToBaseAddress: true, + }, + }), + { + message: 'Consolidation transaction recipient does not match wallet base address', + } + ); + }); + + it('should handle missing rootAddress in wallet coinSpecific', async function () { + const mockWallet = { + coinSpecific: () => ({}), + }; + + const txParams = { + recipients: [ + { + address: testData.consolidation.rootAddress, + amount: testData.consolidation.amount, + }, + ], + }; + const txPrebuild = { + txHex: testData.consolidation.txHex, + }; + + await assert.rejects( + basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet: mockWallet as any, + verification: { + consolidationToBaseAddress: true, + }, + }), + { + message: 'Unable to determine base address for consolidation', + } + ); + }); + + it('should handle consolidation verification for token transfers (axfer)', async function () { + // Using asset transfer transaction to root address + const rootAddress = testData.accounts.account2.address; + const mockWallet = { + coinSpecific: () => ({ + rootAddress: rootAddress, + }), + }; + + const txParams = { + recipients: [ + { + address: rootAddress, + amount: '1000', + }, + ], + }; + const txPrebuild = { + txHex: testData.rawTx.assetTransfer.unsigned, + }; + + const result = await basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet: mockWallet as any, + verification: { + consolidationToBaseAddress: true, + }, + }); + assert.strictEqual(result, true); + }); + + it('should reject consolidation for token transfer when recipient does not match rootAddress', async function () { + const rootAddress = testData.accounts.account1.address; // Different from transaction recipient + const mockWallet = { + coinSpecific: () => ({ + rootAddress: rootAddress, + }), + }; + + const txParams = { + recipients: [ + { + address: testData.accounts.account2.address, + amount: '1000', + }, + ], + }; + const txPrebuild = { + txHex: testData.rawTx.assetTransfer.unsigned, + }; + + await assert.rejects( + basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet: mockWallet as any, + verification: { + consolidationToBaseAddress: true, + }, + }), + { + message: 'Consolidation transaction recipient does not match wallet base address', + } + ); + }); + + it('should pass verification when consolidationToBaseAddress is not set', async function () { + // Using real consolidation tx but with wrong rootAddress - should still pass because verification is disabled + const mockWallet = { + coinSpecific: () => ({ + rootAddress: testData.consolidation.receiveAddress, // Different from transaction recipient + }), + }; + + const txParams = { + recipients: [ + { + address: testData.consolidation.rootAddress, + amount: testData.consolidation.amount, + }, + ], + }; + const txPrebuild = { + txHex: testData.consolidation.txHex, + }; + + // Should not throw error even though recipient doesn't match rootAddress + // because consolidationToBaseAddress verification is not enabled + const result = await basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet: mockWallet as any, + verification: { + consolidationToBaseAddress: false, + }, + }); + assert.strictEqual(result, true); + }); + + it('should pass verification when verification object is not provided', async function () { + // Using real consolidation tx but with wrong rootAddress - should still pass because no verification + const mockWallet = { + coinSpecific: () => ({ + rootAddress: testData.consolidation.receiveAddress, // Different from transaction recipient + }), + }; + + const txParams = { + recipients: [ + { + address: testData.consolidation.rootAddress, + amount: testData.consolidation.amount, + }, + ], + }; + const txPrebuild = { + txHex: testData.consolidation.txHex, + }; + + // Should not throw error even though recipient doesn't match rootAddress + // because no verification object is provided + const result = await basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet: mockWallet as any, + }); + assert.strictEqual(result, true); + }); + + it('should handle missing wallet when consolidation verification is enabled', async function () { + const txParams = { + recipients: [ + { + address: testData.consolidation.rootAddress, + amount: testData.consolidation.amount, + }, + ], + }; + const txPrebuild = { + txHex: testData.consolidation.txHex, + }; + + await assert.rejects( + basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet: undefined as any, + verification: { + consolidationToBaseAddress: true, + }, + }), + { + message: 'Unable to determine base address for consolidation', + } + ); + }); + }); });