diff --git a/modules/sdk-coin-algo/src/algo.ts b/modules/sdk-coin-algo/src/algo.ts index 3be4de9a8a..9f77cd43de 100644 --- a/modules/sdk-coin-algo/src/algo.ts +++ b/modules/sdk-coin-algo/src/algo.ts @@ -44,6 +44,7 @@ import { TESTNET_GENESIS_ID, } from './lib/transactionBuilder'; import { Buffer } from 'buffer'; +import { toNumber } from 'lodash'; const SUPPORTED_ADDRESS_VERSION = 1; const MSIG_THRESHOLD = 2; // m in m-of-n @@ -600,18 +601,26 @@ export class Algo extends BaseCoin { const tx = await txBuilder.build(); const txJson = tx.toJson(); + // Check if this is a token enablement transaction + const isTokenEnablementTx = txParams.type === 'enabletoken'; + // Validate based on Algorand transaction type - switch (txJson.type) { - case 'pay': - this.validatePayTransaction(txJson, txParams); - break; - case 'axfer': - this.validateAssetTransferTransaction(txJson, txParams); - break; - default: - // For other transaction types, perform basic validation - this.validateBasicTransaction(txJson); - break; + if (isTokenEnablementTx && verification?.verifyTokenEnablement) { + // Validate token enablement transaction + this.validateTokenEnablementTransaction(txJson, txParams); + } else { + switch (txJson.type) { + case 'pay': + this.validatePayTransaction(txJson, txParams); + break; + case 'axfer': + this.validateAssetTransferTransaction(txJson, txParams); + break; + default: + // For other transaction types, perform basic validation + this.validateBasicTransaction(txJson); + break; + } } // Verify consolidation transactions send to base address @@ -704,6 +713,86 @@ export class Algo extends BaseCoin { return true; } + /** + * Extract token ID from token name + * Token names are in format like "talgo:JPT-162085446" where the number after the last hyphen is the token ID + */ + private extractTokenIdFromName(tokenName: string): number | null { + // Handle format like "talgo:JPT-162085446" or "algo:TOKEN-123456" + const parts = tokenName.split(':'); + if (parts.length < 2) { + return null; + } + + // Get the part after colon (e.g., "JPT-162085446") + const tokenPart = parts[1]; + + // Extract the number after the last hyphen + const lastHyphenIndex = tokenPart.lastIndexOf('-'); + if (lastHyphenIndex === -1) { + return null; + } + + const tokenIdStr = tokenPart.substring(lastHyphenIndex + 1); + const tokenId = parseInt(tokenIdStr, 10); + + return isNaN(tokenId) ? null : tokenId; + } + + /** + * Validate Token Enablement (opt-in) transaction + */ + private validateTokenEnablementTransaction(txJson: any, txParams: any): boolean { + this.validateBasicTransaction(txJson); + + // Verify it's an asset transfer (axfer) transaction + if (txJson.type !== 'axfer') { + throw new Error('Invalid token enablement transaction: must be of type axfer'); + } + + // Verify amount is 0 (token opt-in requirement) + if (toNumber(txJson.amount) !== 0) { + throw new Error('Invalid token enablement transaction: amount must be 0 for token opt-in'); + } + + // Verify sender and recipient are the same (self-transaction) + if (!txJson.from || !txJson.to || txJson.from !== txJson.to) { + throw new Error('Invalid token enablement transaction: sender and recipient must be the same address'); + } + + // Verify token ID is present + if (!txJson.tokenId) { + throw new Error('Invalid token enablement transaction: missing token ID'); + } + + // If txParams specifies token information, verify the token ID matches + let expectedTokenId: number | null = null; + + // Check for enableTokens array (used in TSS wallets) + if (txParams.enableTokens && Array.isArray(txParams.enableTokens) && txParams.enableTokens.length > 0) { + const tokenName = txParams.enableTokens[0].name; + if (tokenName) { + expectedTokenId = this.extractTokenIdFromName(tokenName); + } + } + // Check for recipients array with tokenName (used in non-TSS wallets) + else if (txParams.recipients && Array.isArray(txParams.recipients) && txParams.recipients.length > 0) { + const recipient = txParams.recipients[0]; + if (recipient.tokenName) { + expectedTokenId = this.extractTokenIdFromName(recipient.tokenName); + } + } + + // Verify the token ID matches if we have an expected value + if (expectedTokenId !== null && txJson.tokenId !== expectedTokenId) { + throw new Error( + `Token enablement verification failed: expected token ID ${expectedTokenId} but transaction has token ID ${txJson.tokenId}` + ); + } + + return true; + } + decodeTx(txn: Buffer): unknown { return AlgoLib.algoUtils.decodeAlgoTxn(txn); } diff --git a/modules/sdk-coin-algo/test/unit/algo.ts b/modules/sdk-coin-algo/test/unit/algo.ts index 5092fc53d4..6235138afc 100644 --- a/modules/sdk-coin-algo/test/unit/algo.ts +++ b/modules/sdk-coin-algo/test/unit/algo.ts @@ -4,13 +4,14 @@ import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; import * as AlgoResources from '../fixtures/algo'; import { randomBytes } from 'crypto'; import { coins } from '@bitgo/statics'; -import Sinon, { SinonStub } from 'sinon'; +import Sinon, { SinonStub, spy, stub } from 'sinon'; import assert from 'assert'; import { Algo } from '../../src/algo'; import BigNumber from 'bignumber.js'; import { TransactionBuilderFactory } from '../../src/lib'; -import { KeyPair } from '@bitgo/sdk-core'; +import { common, KeyPair, Wallet } from '@bitgo/sdk-core'; import { algoBackupKey } from './fixtures/algoBackupKey'; +import nock from 'nock'; describe('ALGO:', function () { let bitgo: TestBitGoAPI; @@ -1180,4 +1181,141 @@ describe('ALGO:', function () { ); }); }); + + describe('blind signing token enablement protection', () => { + let wallet: Wallet; + const bgUrl = common.Environments['mock'].uri; + + before(() => { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.safeRegister('talgo', Talgo.createInstance); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('talgo'); + + wallet = new Wallet(bitgo, basecoin, { + id: '123', + coin: 'talgo', + keys: ['1', '2', '3'], + coinSpecific: { + rootAddress: '123', + }, + type: 'hot', + }); + }); + it('should verify a valid token enablement transaction', async function () { + const verifyTransactionStub = spy(basecoin, 'verifyTransaction'); + nock(bgUrl) + .post(`/api/v2/talgo/wallet/${wallet.id()}/tx/build`) + .reply(200, { + txHex: + 'iaRhcmN2xCBfnMgYtbyG4RL1DspYhxQeyn9QrJ+s2ZcDTcxK+yOH+KNmZWXNA+iiZnbOA2tlJKNnZW6sdGVzdG5ldC12MS4womdoxCBIY7UYpLPITsgQ8i1PEIHLD3HwWaesIN7GL39w5Qk6IqJsds4Da2kMo3NuZMQgX5zIGLW8huES9Q7KWIcUHsp/UKyfrNmXA03MSvsjh/ikdHlwZaVheGZlcqR4YWlkzgmpOkY=', + txHash: 'ARYMOXMKZWM372JBFBTADZNJZU7S5HK44NZDZLVX5UR5UPIWT7FA', + txInfo: { + id: 'ARYMOXMKZWM372JBFBTADZNJZU7S5HK44NZDZLVX5UR5UPIWT7FA', + type: 'axfer', + from: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE', + fee: 1000, + firstRound: 57369892, + lastRound: 57370892, + note: {}, + tokenId: 162085446, + genesisID: 'testnet-v1.0', + genesisHash: 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + to: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE', + amount: '0', + txType: 'enableToken', + tokenName: 'TALGO', + }, + feeInfo: { + size: 251, + fee: 1000, + feeRate: 4, + feeString: '1000', + }, + keys: [ + '42MIYL2KBISV6WJRALTTSXHBEGLNF7MMQ74FGSYUCT5YP3V2KENJGRFVVQ', + 'MAGXZTDFW5QEXUKOUDIGHTXOKDW7TNQEDLWPBEZEL3VFU3YAA2PGYRS65M', + 'TMEJTI7XNCACDG3BTODINW6CMR6ARYIKTCFPA2GMIXL4X5Q4BCE6VHMWAM', + ], + addressVersion: 1, + coin: 'talgo', + }); + const validatePwdStub = stub(wallet, 'getKeychainsAndValidatePassphrase' as keyof Wallet).resolves([]); + const signTxStub = stub(wallet, 'signTransaction').resolves({}); + + await wallet.sendTokenEnablements({ + verification: { verifyTokenEnablement: true }, + enableTokens: [ + { + name: 'talgo:JPT-162085446', + address: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE', + }, + ], + }); + + verifyTransactionStub.called.should.be.true(); + verifyTransactionStub.restore(); + validatePwdStub.restore(); + signTxStub.restore(); + }); + + it('should throw error when invalid token enablement transaction is returned', async () => { + const verifyTransactionStub = spy(basecoin, 'verifyTransaction'); + nock(bgUrl) + .post(`/api/v2/talgo/wallet/${wallet.id()}/tx/build`) + .reply(200, { + txHex: + 'iaRhcmN2xCBfnMgYtbyG4RL1DspYhxQeyn9QrJ+s2ZcDTcxK+yOH+KNmZWXNA+iiZnbOA2tpeqNnZW6sdGVzdG5ldC12MS4womdoxCBIY7UYpLPITsgQ8i1PEIHLD3HwWaesIN7GL39w5Qk6IqJsds4Da21io3NuZMQgX5zIGLW8huES9Q7KWIcUHsp/UKyfrNmXA03MSvsjh/ikdHlwZaVheGZlcqR4YWlkzgACwN8=', + txHash: 'YPQGYNBOCPXMBFTBZH2AQGCDZ3C2762T6EYQY46F7LRG2URX6DLA', + txInfo: { + id: 'YPQGYNBOCPXMBFTBZH2AQGCDZ3C2762T6EYQY46F7LRG2URX6DLA', + type: 'axfer', + from: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE', + fee: 1000, + firstRound: 57371002, + lastRound: 57372002, + note: {}, + tokenId: 180447, + genesisID: 'testnet-v1.0', + genesisHash: 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + to: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE', + amount: '0', + txType: 'enableToken', + tokenName: 'TALGO', + }, + feeInfo: { + size: 251, + fee: 1000, + feeRate: 4, + feeString: '1000', + }, + keys: [ + '42MIYL2KBISV6WJRALTTSXHBEGLNF7MMQ74FGSYUCT5YP3V2KENJGRFVVQ', + 'MAGXZTDFW5QEXUKOUDIGHTXOKDW7TNQEDLWPBEZEL3VFU3YAA2PGYRS65M', + 'TMEJTI7XNCACDG3BTODINW6CMR6ARYIKTCFPA2GMIXL4X5Q4BCE6VHMWAM', + ], + addressVersion: 1, + coin: 'talgo', + }); + stub(wallet, 'getKeychainsAndValidatePassphrase' as keyof Wallet).resolves([]); + stub(wallet, 'signTransaction').resolves({}); + + const { success, failure } = await wallet.sendTokenEnablements({ + verification: { verifyTokenEnablement: true }, + enableTokens: [ + { + name: 'talgo:JPT-162085446', + address: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE', + }, + ], + }); + + verifyTransactionStub.called.should.be.true(); + success.length.should.equal(0); + failure.length.should.equal(1); + failure[0].message.should.equal( + 'Token enablement verification failed: expected token ID 162085446 but transaction has token ID 180447' + ); + }); + }); });