Skip to content

Commit 6a46ea4

Browse files
Merge pull request #7458 from BitGo/WP-5752/algo-enable-token-verification
feat(sdk-coin-algo): enable token enablement verification
2 parents c82d952 + 98f9e66 commit 6a46ea4

File tree

2 files changed

+240
-13
lines changed

2 files changed

+240
-13
lines changed

modules/sdk-coin-algo/src/algo.ts

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
TESTNET_GENESIS_ID,
4545
} from './lib/transactionBuilder';
4646
import { Buffer } from 'buffer';
47+
import { toNumber } from 'lodash';
4748

4849
const SUPPORTED_ADDRESS_VERSION = 1;
4950
const MSIG_THRESHOLD = 2; // m in m-of-n
@@ -600,18 +601,26 @@ export class Algo extends BaseCoin {
600601
const tx = await txBuilder.build();
601602
const txJson = tx.toJson();
602603

604+
// Check if this is a token enablement transaction
605+
const isTokenEnablementTx = txParams.type === 'enabletoken';
606+
603607
// Validate based on Algorand transaction type
604-
switch (txJson.type) {
605-
case 'pay':
606-
this.validatePayTransaction(txJson, txParams);
607-
break;
608-
case 'axfer':
609-
this.validateAssetTransferTransaction(txJson, txParams);
610-
break;
611-
default:
612-
// For other transaction types, perform basic validation
613-
this.validateBasicTransaction(txJson);
614-
break;
608+
if (isTokenEnablementTx && verification?.verifyTokenEnablement) {
609+
// Validate token enablement transaction
610+
this.validateTokenEnablementTransaction(txJson, txParams);
611+
} else {
612+
switch (txJson.type) {
613+
case 'pay':
614+
this.validatePayTransaction(txJson, txParams);
615+
break;
616+
case 'axfer':
617+
this.validateAssetTransferTransaction(txJson, txParams);
618+
break;
619+
default:
620+
// For other transaction types, perform basic validation
621+
this.validateBasicTransaction(txJson);
622+
break;
623+
}
615624
}
616625

617626
// Verify consolidation transactions send to base address
@@ -704,6 +713,86 @@ export class Algo extends BaseCoin {
704713
return true;
705714
}
706715

716+
/**
717+
* Extract token ID from token name
718+
* Token names are in format like "talgo:JPT-162085446" where the number after the last hyphen is the token ID
719+
*/
720+
private extractTokenIdFromName(tokenName: string): number | null {
721+
// Handle format like "talgo:JPT-162085446" or "algo:TOKEN-123456"
722+
const parts = tokenName.split(':');
723+
if (parts.length < 2) {
724+
return null;
725+
}
726+
727+
// Get the part after colon (e.g., "JPT-162085446")
728+
const tokenPart = parts[1];
729+
730+
// Extract the number after the last hyphen
731+
const lastHyphenIndex = tokenPart.lastIndexOf('-');
732+
if (lastHyphenIndex === -1) {
733+
return null;
734+
}
735+
736+
const tokenIdStr = tokenPart.substring(lastHyphenIndex + 1);
737+
const tokenId = parseInt(tokenIdStr, 10);
738+
739+
return isNaN(tokenId) ? null : tokenId;
740+
}
741+
742+
/**
743+
* Validate Token Enablement (opt-in) transaction
744+
*/
745+
private validateTokenEnablementTransaction(txJson: any, txParams: any): boolean {
746+
this.validateBasicTransaction(txJson);
747+
748+
// Verify it's an asset transfer (axfer) transaction
749+
if (txJson.type !== 'axfer') {
750+
throw new Error('Invalid token enablement transaction: must be of type axfer');
751+
}
752+
753+
// Verify amount is 0 (token opt-in requirement)
754+
if (toNumber(txJson.amount) !== 0) {
755+
throw new Error('Invalid token enablement transaction: amount must be 0 for token opt-in');
756+
}
757+
758+
// Verify sender and recipient are the same (self-transaction)
759+
if (!txJson.from || !txJson.to || txJson.from !== txJson.to) {
760+
throw new Error('Invalid token enablement transaction: sender and recipient must be the same address');
761+
}
762+
763+
// Verify token ID is present
764+
if (!txJson.tokenId) {
765+
throw new Error('Invalid token enablement transaction: missing token ID');
766+
}
767+
768+
// If txParams specifies token information, verify the token ID matches
769+
let expectedTokenId: number | null = null;
770+
771+
// Check for enableTokens array (used in TSS wallets)
772+
if (txParams.enableTokens && Array.isArray(txParams.enableTokens) && txParams.enableTokens.length > 0) {
773+
const tokenName = txParams.enableTokens[0].name;
774+
if (tokenName) {
775+
expectedTokenId = this.extractTokenIdFromName(tokenName);
776+
}
777+
}
778+
// Check for recipients array with tokenName (used in non-TSS wallets)
779+
else if (txParams.recipients && Array.isArray(txParams.recipients) && txParams.recipients.length > 0) {
780+
const recipient = txParams.recipients[0];
781+
if (recipient.tokenName) {
782+
expectedTokenId = this.extractTokenIdFromName(recipient.tokenName);
783+
}
784+
}
785+
786+
// Verify the token ID matches if we have an expected value
787+
if (expectedTokenId !== null && txJson.tokenId !== expectedTokenId) {
788+
throw new Error(
789+
`Token enablement verification failed: expected token ID ${expectedTokenId} but transaction has token ID ${txJson.tokenId}`
790+
);
791+
}
792+
793+
return true;
794+
}
795+
707796
decodeTx(txn: Buffer): unknown {
708797
return AlgoLib.algoUtils.decodeAlgoTxn(txn);
709798
}

modules/sdk-coin-algo/test/unit/algo.ts

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import { BitGoAPI, encrypt } from '@bitgo/sdk-api';
44
import * as AlgoResources from '../fixtures/algo';
55
import { randomBytes } from 'crypto';
66
import { coins } from '@bitgo/statics';
7-
import Sinon, { SinonStub } from 'sinon';
7+
import Sinon, { SinonStub, spy, stub } from 'sinon';
88
import assert from 'assert';
99
import { Algo } from '../../src/algo';
1010
import BigNumber from 'bignumber.js';
1111
import { TransactionBuilderFactory } from '../../src/lib';
12-
import { KeyPair } from '@bitgo/sdk-core';
12+
import { common, KeyPair, Wallet } from '@bitgo/sdk-core';
1313
import { algoBackupKey } from './fixtures/algoBackupKey';
14+
import nock from 'nock';
1415

1516
describe('ALGO:', function () {
1617
let bitgo: TestBitGoAPI;
@@ -1180,4 +1181,141 @@ describe('ALGO:', function () {
11801181
);
11811182
});
11821183
});
1184+
1185+
describe('blind signing token enablement protection', () => {
1186+
let wallet: Wallet;
1187+
const bgUrl = common.Environments['mock'].uri;
1188+
1189+
before(() => {
1190+
bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' });
1191+
bitgo.safeRegister('talgo', Talgo.createInstance);
1192+
bitgo.initializeTestVars();
1193+
basecoin = bitgo.coin('talgo');
1194+
1195+
wallet = new Wallet(bitgo, basecoin, {
1196+
id: '123',
1197+
coin: 'talgo',
1198+
keys: ['1', '2', '3'],
1199+
coinSpecific: {
1200+
rootAddress: '123',
1201+
},
1202+
type: 'hot',
1203+
});
1204+
});
1205+
it('should verify a valid token enablement transaction', async function () {
1206+
const verifyTransactionStub = spy(basecoin, 'verifyTransaction');
1207+
nock(bgUrl)
1208+
.post(`/api/v2/talgo/wallet/${wallet.id()}/tx/build`)
1209+
.reply(200, {
1210+
txHex:
1211+
'iaRhcmN2xCBfnMgYtbyG4RL1DspYhxQeyn9QrJ+s2ZcDTcxK+yOH+KNmZWXNA+iiZnbOA2tlJKNnZW6sdGVzdG5ldC12MS4womdoxCBIY7UYpLPITsgQ8i1PEIHLD3HwWaesIN7GL39w5Qk6IqJsds4Da2kMo3NuZMQgX5zIGLW8huES9Q7KWIcUHsp/UKyfrNmXA03MSvsjh/ikdHlwZaVheGZlcqR4YWlkzgmpOkY=',
1212+
txHash: 'ARYMOXMKZWM372JBFBTADZNJZU7S5HK44NZDZLVX5UR5UPIWT7FA',
1213+
txInfo: {
1214+
id: 'ARYMOXMKZWM372JBFBTADZNJZU7S5HK44NZDZLVX5UR5UPIWT7FA',
1215+
type: 'axfer',
1216+
from: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE',
1217+
fee: 1000,
1218+
firstRound: 57369892,
1219+
lastRound: 57370892,
1220+
note: {},
1221+
tokenId: 162085446,
1222+
genesisID: 'testnet-v1.0',
1223+
genesisHash: 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=',
1224+
to: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE',
1225+
amount: '0',
1226+
txType: 'enableToken',
1227+
tokenName: 'TALGO',
1228+
},
1229+
feeInfo: {
1230+
size: 251,
1231+
fee: 1000,
1232+
feeRate: 4,
1233+
feeString: '1000',
1234+
},
1235+
keys: [
1236+
'42MIYL2KBISV6WJRALTTSXHBEGLNF7MMQ74FGSYUCT5YP3V2KENJGRFVVQ',
1237+
'MAGXZTDFW5QEXUKOUDIGHTXOKDW7TNQEDLWPBEZEL3VFU3YAA2PGYRS65M',
1238+
'TMEJTI7XNCACDG3BTODINW6CMR6ARYIKTCFPA2GMIXL4X5Q4BCE6VHMWAM',
1239+
],
1240+
addressVersion: 1,
1241+
coin: 'talgo',
1242+
});
1243+
const validatePwdStub = stub(wallet, 'getKeychainsAndValidatePassphrase' as keyof Wallet).resolves([]);
1244+
const signTxStub = stub(wallet, 'signTransaction').resolves({});
1245+
1246+
await wallet.sendTokenEnablements({
1247+
verification: { verifyTokenEnablement: true },
1248+
enableTokens: [
1249+
{
1250+
name: 'talgo:JPT-162085446',
1251+
address: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE',
1252+
},
1253+
],
1254+
});
1255+
1256+
verifyTransactionStub.called.should.be.true();
1257+
verifyTransactionStub.restore();
1258+
validatePwdStub.restore();
1259+
signTxStub.restore();
1260+
});
1261+
1262+
it('should throw error when invalid token enablement transaction is returned', async () => {
1263+
const verifyTransactionStub = spy(basecoin, 'verifyTransaction');
1264+
nock(bgUrl)
1265+
.post(`/api/v2/talgo/wallet/${wallet.id()}/tx/build`)
1266+
.reply(200, {
1267+
txHex:
1268+
'iaRhcmN2xCBfnMgYtbyG4RL1DspYhxQeyn9QrJ+s2ZcDTcxK+yOH+KNmZWXNA+iiZnbOA2tpeqNnZW6sdGVzdG5ldC12MS4womdoxCBIY7UYpLPITsgQ8i1PEIHLD3HwWaesIN7GL39w5Qk6IqJsds4Da21io3NuZMQgX5zIGLW8huES9Q7KWIcUHsp/UKyfrNmXA03MSvsjh/ikdHlwZaVheGZlcqR4YWlkzgACwN8=',
1269+
txHash: 'YPQGYNBOCPXMBFTBZH2AQGCDZ3C2762T6EYQY46F7LRG2URX6DLA',
1270+
txInfo: {
1271+
id: 'YPQGYNBOCPXMBFTBZH2AQGCDZ3C2762T6EYQY46F7LRG2URX6DLA',
1272+
type: 'axfer',
1273+
from: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE',
1274+
fee: 1000,
1275+
firstRound: 57371002,
1276+
lastRound: 57372002,
1277+
note: {},
1278+
tokenId: 180447,
1279+
genesisID: 'testnet-v1.0',
1280+
genesisHash: 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=',
1281+
to: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE',
1282+
amount: '0',
1283+
txType: 'enableToken',
1284+
tokenName: 'TALGO',
1285+
},
1286+
feeInfo: {
1287+
size: 251,
1288+
fee: 1000,
1289+
feeRate: 4,
1290+
feeString: '1000',
1291+
},
1292+
keys: [
1293+
'42MIYL2KBISV6WJRALTTSXHBEGLNF7MMQ74FGSYUCT5YP3V2KENJGRFVVQ',
1294+
'MAGXZTDFW5QEXUKOUDIGHTXOKDW7TNQEDLWPBEZEL3VFU3YAA2PGYRS65M',
1295+
'TMEJTI7XNCACDG3BTODINW6CMR6ARYIKTCFPA2GMIXL4X5Q4BCE6VHMWAM',
1296+
],
1297+
addressVersion: 1,
1298+
coin: 'talgo',
1299+
});
1300+
stub(wallet, 'getKeychainsAndValidatePassphrase' as keyof Wallet).resolves([]);
1301+
stub(wallet, 'signTransaction').resolves({});
1302+
1303+
const { success, failure } = await wallet.sendTokenEnablements({
1304+
verification: { verifyTokenEnablement: true },
1305+
enableTokens: [
1306+
{
1307+
name: 'talgo:JPT-162085446',
1308+
address: 'L6OMQGFVXSDOCEXVB3FFRBYUD3FH6UFMT6WNTFYDJXGEV6ZDQ74EXGF6BE',
1309+
},
1310+
],
1311+
});
1312+
1313+
verifyTransactionStub.called.should.be.true();
1314+
success.length.should.equal(0);
1315+
failure.length.should.equal(1);
1316+
failure[0].message.should.equal(
1317+
'Token enablement verification failed: expected token ID 162085446 but transaction has token ID 180447'
1318+
);
1319+
});
1320+
});
11831321
});

0 commit comments

Comments
 (0)