diff --git a/modules/sdk-coin-xdc/src/xdcToken.ts b/modules/sdk-coin-xdc/src/xdcToken.ts index 3e6d39c7df..7a4b88dece 100644 --- a/modules/sdk-coin-xdc/src/xdcToken.ts +++ b/modules/sdk-coin-xdc/src/xdcToken.ts @@ -3,7 +3,12 @@ */ import { EthLikeTokenConfig, coins } from '@bitgo/statics'; import { BitGoBase, CoinConstructor, NamedCoinConstructor, common, MPCAlgorithm } from '@bitgo/sdk-core'; -import { CoinNames, EthLikeToken, recoveryBlockchainExplorerQuery } from '@bitgo/abstract-eth'; +import { + CoinNames, + EthLikeToken, + recoveryBlockchainExplorerQuery, + VerifyEthTransactionOptions, +} from '@bitgo/abstract-eth'; import { TransactionBuilder } from './lib'; export { EthLikeTokenConfig }; @@ -52,4 +57,34 @@ export class XdcToken extends EthLikeToken { getMPCAlgorithm(): MPCAlgorithm { return 'ecdsa'; } + + /** + * Verify if a tss transaction is valid + * + * @param {VerifyEthTransactionOptions} params + * @param {TransactionParams} params.txParams - params object passed to send + * @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server + * @param {Wallet} params.wallet - Wallet object to obtain keys to verify against + * @returns {boolean} + */ + async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise { + const { txParams, txPrebuild, wallet } = params; + if ( + !txParams?.recipients && + !( + txParams.prebuildTx?.consolidateId || + (txParams.type && ['acceleration', 'fillNonce', 'transferToken'].includes(txParams.type)) + ) + ) { + throw new Error(`missing txParams`); + } + if (!wallet || !txPrebuild) { + throw new Error(`missing params`); + } + if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { + throw new Error(`tx cannot be both a batch and hop transaction`); + } + + return true; + } } diff --git a/modules/sdk-coin-xdc/test/resources.ts b/modules/sdk-coin-xdc/test/resources.ts index d6827a41b5..35e16c3023 100644 --- a/modules/sdk-coin-xdc/test/resources.ts +++ b/modules/sdk-coin-xdc/test/resources.ts @@ -77,6 +77,34 @@ const getBalanceResponseNonBitGoRecovery: Record = { message: 'OK', }; +// Mock data for txdc:tmt token transfer TSS transaction +export const mockTokenTransferData = { + txRequestId: '2475368d-f604-46e3-a743-e32f663fa350', + walletId: '695e1ca4fb4a739c8c6f9b49120c55c7', + serializedTxHex: + 'f86a0485045d964b8083061a8094b283ec8dad644effc5c4c50bb7bb21442ac3c2db80b844a9059cbb000000000000000000000000421cdf5e890070c28db0fd8e4bf87deac0cd0ffc00000000000000000000000000000000000000000000000000000000000f4240808080', + signableHex: + 'f86a0485045d964b8083061a8094b283ec8dad644effc5c4c50bb7bb21442ac3c2db80b844a9059cbb000000000000000000000000421cdf5e890070c28db0fd8e4bf87deac0cd0ffc00000000000000000000000000000000000000000000000000000000000f4240338080', + tokenContractAddress: '0xb283ec8dad644effc5c4c50bb7bb21442ac3c2db', + recipientAddress: '0x421cdf5e890070c28db0fd8e4bf87deac0cd0ffc', + senderAddress: '0x6aafaddf545f96772140f0008190c176a065df9a', + tokenAmount: '1000000', + feeInfo: { + fee: 7500000000000000, + feeString: '7500000000000000', + }, + txPrebuild: { + txHex: + 'f86a0485045d964b8083061a8094b283ec8dad644effc5c4c50bb7bb21442ac3c2db80b844a9059cbb000000000000000000000000421cdf5e890070c28db0fd8e4bf87deac0cd0ffc00000000000000000000000000000000000000000000000000000000000f4240808080', + recipients: [ + { + address: '0x421cdf5e890070c28db0fd8e4bf87deac0cd0ffc', + amount: '1000000', + }, + ], + }, +}; + export const mockDataNonBitGoRecovery = { recoveryDestination: '0xd76b586901850f2c656db0cbef795c0851bbec35', userKeyData: diff --git a/modules/sdk-coin-xdc/test/unit/xdcToken.ts b/modules/sdk-coin-xdc/test/unit/xdcToken.ts index 7bbd9883ad..362cab347e 100644 --- a/modules/sdk-coin-xdc/test/unit/xdcToken.ts +++ b/modules/sdk-coin-xdc/test/unit/xdcToken.ts @@ -1,8 +1,10 @@ import 'should'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; +import { IWallet } from '@bitgo/sdk-core'; -import { register } from '../../src'; +import { register, XdcToken } from '../../src'; +import { mockTokenTransferData } from '../resources'; describe('XDC Token:', function () { let bitgo: TestBitGoAPI; @@ -27,4 +29,244 @@ describe('XDC Token:', function () { xdcTokenCoin.network.should.equal('Mainnet'); xdcTokenCoin.decimalPlaces.should.equal(6); }); + + describe('Token Registration and TransactionBuilder', function () { + const mainnetTokens = ['xdc:usdc', 'xdc:lbt', 'xdc:gama', 'xdc:srx', 'xdc:weth']; + const testnetTokens = ['txdc:tmt']; + + describe('Mainnet tokens', function () { + mainnetTokens.forEach((tokenName) => { + it(`${tokenName} should be registered as XdcToken`, function () { + const token = bitgo.coin(tokenName); + token.should.be.instanceOf(XdcToken); + }); + + it(`${tokenName} should create TransactionBuilder without error`, function () { + const token = bitgo.coin(tokenName) as XdcToken; + // @ts-expect-error - accessing protected method for testing + (() => token.getTransactionBuilder()).should.not.throw(); + }); + + it(`${tokenName} should use XDC-specific TransactionBuilder`, function () { + const token = bitgo.coin(tokenName) as XdcToken; + // @ts-expect-error - accessing protected method for testing + const builder = token.getTransactionBuilder(); + builder.should.have.property('_common'); + // Verify it's using XDC's getCommon, not EVM's + // XDC's TransactionBuilder should create successfully without SHARED_EVM_SDK feature + builder.constructor.name.should.equal('TransactionBuilder'); + }); + }); + }); + + describe('Testnet tokens', function () { + testnetTokens.forEach((tokenName) => { + it(`${tokenName} should be registered as XdcToken`, function () { + const token = bitgo.coin(tokenName); + token.should.be.instanceOf(XdcToken); + }); + + it(`${tokenName} should create TransactionBuilder without error`, function () { + const token = bitgo.coin(tokenName) as XdcToken; + // @ts-expect-error - accessing protected method for testing + (() => token.getTransactionBuilder()).should.not.throw(); + }); + + it(`${tokenName} should use XDC-specific TransactionBuilder`, function () { + const token = bitgo.coin(tokenName) as XdcToken; + // @ts-expect-error - accessing protected method for testing + const builder = token.getTransactionBuilder(); + builder.should.have.property('_common'); + builder.constructor.name.should.equal('TransactionBuilder'); + }); + + it(`${tokenName} should have correct base chain`, function () { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const token: any = bitgo.coin(tokenName); + token.getBaseChain().should.equal('txdc'); + }); + + it(`${tokenName} should not throw "Cannot use common sdk module" error`, function () { + const token = bitgo.coin(tokenName) as XdcToken; + let errorThrown = false; + let errorMessage = ''; + + try { + // @ts-expect-error - accessing protected method for testing + const builder = token.getTransactionBuilder(); + // Try to use the builder to ensure it's fully functional + // @ts-expect-error - type expects TransactionType enum + builder.type('Send'); + } catch (e) { + errorThrown = true; + errorMessage = (e as Error).message; + } + + errorThrown.should.equal(false); + errorMessage.should.not.match(/Cannot use common sdk module/); + }); + }); + }); + + it('should verify all XDC tokens use XdcToken class, not EthLikeErc20Token', function () { + const allTokens = [...mainnetTokens, ...testnetTokens]; + + allTokens.forEach((tokenName) => { + const token = bitgo.coin(tokenName); + token.should.be.instanceOf(XdcToken); + token.constructor.name.should.equal('XdcToken'); + token.constructor.name.should.not.equal('EthLikeErc20Token'); + }); + }); + }); + + describe('verifyTssTransaction', function () { + it('should return true for valid token transfer params', async function () { + const token = bitgo.coin('txdc:tmt') as XdcToken; + const mockWallet = {} as unknown as IWallet; + + const result = await token.verifyTssTransaction({ + txParams: { + recipients: [ + { + address: mockTokenTransferData.recipientAddress, + amount: mockTokenTransferData.tokenAmount, + }, + ], + }, + txPrebuild: mockTokenTransferData.txPrebuild as unknown as Parameters< + typeof token.verifyTssTransaction + >[0]['txPrebuild'], + wallet: mockWallet, + }); + + result.should.equal(true); + }); + + it('should return true for transferToken type without recipients', async function () { + const token = bitgo.coin('txdc:tmt') as XdcToken; + const mockWallet = {} as unknown as IWallet; + + const result = await token.verifyTssTransaction({ + txParams: { + type: 'transferToken', + }, + txPrebuild: mockTokenTransferData.txPrebuild as unknown as Parameters< + typeof token.verifyTssTransaction + >[0]['txPrebuild'], + wallet: mockWallet, + }); + + result.should.equal(true); + }); + + it('should throw error when txParams.recipients is missing and no valid type', async function () { + const token = bitgo.coin('txdc:tmt') as XdcToken; + const mockWallet = {} as unknown as IWallet; + + await token + .verifyTssTransaction({ + txParams: {}, + txPrebuild: mockTokenTransferData.txPrebuild as unknown as Parameters< + typeof token.verifyTssTransaction + >[0]['txPrebuild'], + wallet: mockWallet, + }) + .should.be.rejectedWith('missing txParams'); + }); + + it('should throw error when wallet is missing', async function () { + const token = bitgo.coin('txdc:tmt') as XdcToken; + + await token + .verifyTssTransaction({ + txParams: { + recipients: [ + { + address: mockTokenTransferData.recipientAddress, + amount: mockTokenTransferData.tokenAmount, + }, + ], + }, + txPrebuild: mockTokenTransferData.txPrebuild as unknown as Parameters< + typeof token.verifyTssTransaction + >[0]['txPrebuild'], + wallet: undefined as unknown as IWallet, + }) + .should.be.rejectedWith('missing params'); + }); + + it('should throw error when txPrebuild is missing', async function () { + const token = bitgo.coin('txdc:tmt') as XdcToken; + const mockWallet = {} as unknown as IWallet; + + await token + .verifyTssTransaction({ + txParams: { + recipients: [ + { + address: mockTokenTransferData.recipientAddress, + amount: mockTokenTransferData.tokenAmount, + }, + ], + }, + txPrebuild: undefined as unknown as Parameters[0]['txPrebuild'], + wallet: mockWallet, + }) + .should.be.rejectedWith('missing params'); + }); + + it('should throw error for batch + hop transaction', async function () { + const token = bitgo.coin('txdc:tmt') as XdcToken; + const mockWallet = {} as unknown as IWallet; + + await token + .verifyTssTransaction({ + txParams: { + hop: true, + recipients: [ + { address: '0x1111111111111111111111111111111111111111', amount: '1000' }, + { address: '0x2222222222222222222222222222222222222222', amount: '2000' }, + ], + }, + txPrebuild: mockTokenTransferData.txPrebuild as unknown as Parameters< + typeof token.verifyTssTransaction + >[0]['txPrebuild'], + wallet: mockWallet, + }) + .should.be.rejectedWith('tx cannot be both a batch and hop transaction'); + }); + + it('should not throw EIP155 error when verifying token transaction', async function () { + // This test ensures that verifyTssTransaction does NOT parse the txHex + // which would fail with "Incompatible EIP155-based V" error + const token = bitgo.coin('txdc:tmt') as XdcToken; + const mockWallet = {} as unknown as IWallet; + + // Use the signableHex (with v=51) which would fail if parsed + const txPrebuildWithSignableHex = { + ...mockTokenTransferData.txPrebuild, + txHex: mockTokenTransferData.signableHex, + }; + + // This should NOT throw EIP155 error because verifyTssTransaction + // does not parse the transaction + const result = await token.verifyTssTransaction({ + txParams: { + recipients: [ + { + address: mockTokenTransferData.recipientAddress, + amount: mockTokenTransferData.tokenAmount, + }, + ], + }, + txPrebuild: txPrebuildWithSignableHex as unknown as Parameters< + typeof token.verifyTssTransaction + >[0]['txPrebuild'], + wallet: mockWallet, + }); + + result.should.equal(true); + }); + }); }); diff --git a/modules/statics/src/account.ts b/modules/statics/src/account.ts index c3d01dc402..0afdabb27e 100644 --- a/modules/statics/src/account.ts +++ b/modules/statics/src/account.ts @@ -2888,7 +2888,7 @@ export function xdcErc20( decimalPlaces: number, contractAddress: string, asset: UnderlyingAsset, - features: CoinFeature[] = [...AccountCoin.DEFAULT_FEATURES, CoinFeature.EIP1559], + features: CoinFeature[] = AccountCoin.DEFAULT_FEATURES, prefix = '', suffix: string = name.toUpperCase(), network: AccountNetwork = Networks.main.xdc,