Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 100 additions & 11 deletions modules/sdk-coin-algo/src/algo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
142 changes: 140 additions & 2 deletions modules/sdk-coin-algo/test/unit/algo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'
);
});
});
});