diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 10ee40ee17..7f830bd1a9 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -359,6 +359,7 @@ interface EthTransactionParams extends TransactionParams { hop?: boolean; prebuildTx?: PrebuildTransactionResult; tokenName?: string; + feeToken?: string; } export interface VerifyEthTransactionOptions extends VerifyTransactionOptions { diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index c52023e60b..2e9e9b8e82 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -3268,6 +3268,34 @@ describe('V2 Wallet:', function () { args[1]!.should.equal('full'); }); + it('should pass feeToken parameter to prebuildTxWithIntent', async function () { + const recipients = [ + { + address: '0xAB100912e133AA06cEB921459aaDdBd62381F5A3', + amount: '1000', + }, + ]; + + const feeToken = '0x20c0000000000000000000000000000000000002'; + + const prebuildTxWithIntent = sandbox.stub(ECDSAUtils.EcdsaUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequestFull); + + await tssEthWallet.prebuildTransaction({ + reqId, + recipients, + type: 'transfer', + feeToken, + }); + + sinon.assert.calledOnce(prebuildTxWithIntent); + const args = prebuildTxWithIntent.args[0]; + args[0]!.recipients!.should.deepEqual(recipients); + args[0]!.feeToken!.should.equal(feeToken); + args[0]!.intentType.should.equal('payment'); + args[1]!.should.equal('full'); + }); + it('should call prebuildTxWithIntent with the correct params for eth transfertokens', async function () { const recipients = [ { @@ -3562,6 +3590,34 @@ describe('V2 Wallet:', function () { intent.intentType.should.equal('acceleration'); }); + it('populate intent should place feeToken at intent level, not in feeOptions', async function () { + const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth')); + + const feeToken = '0x20c0000000000000000000000000000000000002'; + const feeOptions = { + maxFeePerGas: 3000000000, + maxPriorityFeePerGas: 2000000000, + }; + + const intent = mpcUtils.populateIntent(bitgo.coin('hteth'), { + reqId, + intentType: 'payment', + recipients: [ + { + address: '0xAB100912e133AA06cEB921459aaDdBd62381F5A3', + amount: '1000', + }, + ], + feeOptions, + feeToken, + }); + + intent.intentType.should.equal('payment'); + intent.feeOptions!.should.deepEqual(feeOptions); + intent.feeToken!.should.equal(feeToken); + intent.feeOptions!.should.not.have.property('feeToken'); + }); + it('populate intent should return valid coredao acceleration intent', async function () { const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('coredao')); diff --git a/modules/sdk-coin-tempo/src/tempo.ts b/modules/sdk-coin-tempo/src/tempo.ts index 18d569ff25..76e32e819c 100644 --- a/modules/sdk-coin-tempo/src/tempo.ts +++ b/modules/sdk-coin-tempo/src/tempo.ts @@ -22,6 +22,8 @@ import { ParseTransactionOptions, ParsedTransaction, UnexpectedAddressError, + PopulatedIntent, + PrebuildTransactionWithIntentOptions, } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; import { Tip20Transaction, Tip20TransactionBuilder } from './lib'; @@ -278,9 +280,31 @@ export class Tempo extends AbstractEthLikeNewCoins { } } + // Verify fee token if specified + if (txParams?.feeToken) { + const txFeeToken = tx.getFeeToken(); + if (txFeeToken?.toLowerCase() !== txParams.feeToken.toLowerCase()) { + throw new Error(`Fee token mismatch: expected ${txParams.feeToken}, got ${txFeeToken || 'none'}`); + } + } + return true; } + /** + * Set coin-specific fields in the intent for Tempo TSS transactions. + * Ensures feeToken is properly wired through the intent for Tempo transactions. + * @param intent - The populated intent to modify + * @param params - The parameters containing feeToken + */ + setCoinSpecificFieldsInIntent(intent: PopulatedIntent, params: PrebuildTransactionWithIntentOptions): void { + if (params.feeToken) { + intent.feeOptions = intent.feeOptions + ? { ...intent.feeOptions, feeToken: params.feeToken } + : { feeToken: params.feeToken }; + } + } + /** * Build unsigned sweep transaction for TSS * TODO: Implement sweep transaction logic diff --git a/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts b/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts index 1a77c29542..66364720ff 100644 --- a/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts +++ b/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts @@ -686,4 +686,93 @@ describe('Tempo coin - parseTransaction / verifyTransaction', () => { assert.strictEqual(result, true); }); }); + + describe('verifyTransaction with feeToken', () => { + it('should verify transaction with matching fee token', async () => { + const feeToken = ethers.utils.getAddress(TESTNET_TOKENS.betaUSD.address); + const builder = new Tip20TransactionBuilder(coins.get('ttempo')); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100' }) + .feeToken(feeToken) + .nonce(0) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + const tx = (await builder.build()) as Tip20Transaction; + const txHex = await tx.serialize(); + + const result = await coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { feeToken: feeToken }, + }); + assert.strictEqual(result, true); + }); + + it('should throw when fee token does not match', async () => { + const feeToken = ethers.utils.getAddress(TESTNET_TOKENS.betaUSD.address); + const wrongFeeToken = ethers.utils.getAddress(TESTNET_TOKENS.alphaUSD.address); + const builder = new Tip20TransactionBuilder(coins.get('ttempo')); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100' }) + .feeToken(feeToken) + .nonce(0) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + const tx = (await builder.build()) as Tip20Transaction; + const txHex = await tx.serialize(); + + await assert.rejects( + () => + coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { feeToken: wrongFeeToken }, + }), + /Fee token mismatch/ + ); + }); + + it('should throw when fee token is expected but transaction has none', async () => { + const expectedFeeToken = ethers.utils.getAddress(TESTNET_TOKENS.betaUSD.address); + const builder = new Tip20TransactionBuilder(coins.get('ttempo')); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100' }) + .nonce(0) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + const tx = (await builder.build()) as Tip20Transaction; + const txHex = await tx.serialize(); + + await assert.rejects( + () => + coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { feeToken: expectedFeeToken }, + }), + /Fee token mismatch/ + ); + }); + + it('should pass verification when no fee token is specified in params', async () => { + const feeToken = ethers.utils.getAddress(TESTNET_TOKENS.betaUSD.address); + const builder = new Tip20TransactionBuilder(coins.get('ttempo')); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100' }) + .feeToken(feeToken) + .nonce(0) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + const tx = (await builder.build()) as Tip20Transaction; + const txHex = await tx.serialize(); + + // No feeToken in txParams - should not verify, just pass + const result = await coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: {}, + }); + assert.strictEqual(result, true); + }); + }); }); diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index f116cd5635..ee19e12ae3 100644 --- a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts @@ -206,6 +206,7 @@ export abstract class MpcUtils { ...baseIntent, selfSend: params.selfSend, feeOptions: params.feeOptions, + feeToken: params.feeToken, hopParams: params.hopParams, isTss: params.isTss, nonce: params.nonce, @@ -218,18 +219,21 @@ export abstract class MpcUtils { txid: params.lowFeeTxid, receiveAddress: params.receiveAddress, feeOptions: params.feeOptions, + feeToken: params.feeToken, }; case 'tokenApproval': return { ...baseIntent, tokenName: params.tokenName, feeOptions: params.feeOptions, + feeToken: params.feeToken, }; case 'bridgeFunds': return { ...baseIntent, amount: params.amount, feeOptions: params.feeOptions, + feeToken: params.feeToken, }; default: throw new Error(`Unsupported intent type ${params.intentType}`); @@ -245,6 +249,7 @@ export abstract class MpcUtils { token: params.tokenName, enableTokens: params.enableTokens, feeOptions: params.feeOptions, + feeToken: params.feeToken, }; } @@ -253,6 +258,7 @@ export abstract class MpcUtils { memo: params.memo?.value, token: params.tokenName, enableTokens: params.enableTokens, + feeToken: params.feeToken, }; } diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 2277a879ba..bd7e52035c 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -22,6 +22,7 @@ export interface EIP1559FeeOptions { gasLimit?: number; maxFeePerGas: number; maxPriorityFeePerGas: number; + feeToken?: string; } export interface FeeOption { @@ -30,6 +31,7 @@ export interface FeeOption { feeType?: 'base' | 'max' | 'tip'; gasLimit?: number; gasPrice?: number; + feeToken?: string; } export interface TokenEnablement { @@ -275,6 +277,10 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase * Amount for intents that use a top-level amount instead of recipients (e.g. bridgeFunds). */ amount?: { value: string; symbol: string }; + /** + * TIP-20 token address to use for paying transaction fees (Tempo only). + */ + feeToken?: string; } export interface IntentRecipient { address: { @@ -323,6 +329,11 @@ export interface PopulatedIntent extends PopulatedIntentBase { // ETH & ETH-like params selfSend?: boolean; feeOptions?: FeeOption | EIP1559FeeOptions; + /** + * TIP-20 token address to use for paying transaction fees (Tempo only). + * This is placed at the intent level, not nested in feeOptions. + */ + feeToken?: string; hopParams?: HopParams; txid?: string; receiveAddress?: string; diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index ba86241aff..c7c4ffa7c5 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -119,6 +119,7 @@ export const BuildParams = t.exact( // Aptos custom transaction parameters for smart contract calls aptosCustomTransactionParams: t.unknown, isTestTransaction: t.unknown, + feeToken: t.unknown, }), ]) ); diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index fe336d7269..edfdf97a82 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -224,6 +224,11 @@ export interface PrebuildTransactionOptions { * Named intentAmount to avoid collision with SendOptions.amount which is string | number. */ intentAmount?: { value: string; symbol: string }; + /** + * TIP-20 token address to use for paying transaction fees (Tempo only). + * When specified, fees will be deducted in this token instead of the native currency. + */ + feeToken?: string; } export interface PrebuildAndSignTransactionOptions extends PrebuildTransactionOptions, WalletSignTransactionOptions { diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 026e41b5dd..65fd66d0b3 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -3604,16 +3604,23 @@ export class Wallet implements IWallet { // TODO(BG-59685): deprecate one of these so that we have a single way to pass fees let feeOptions; if (params.feeOptions) { - feeOptions = params.feeOptions; + feeOptions = params.feeToken ? { ...params.feeOptions, feeToken: params.feeToken } : params.feeOptions; } else if (params.gasPrice !== undefined || params.eip1559 !== undefined) { feeOptions = params.gasPrice !== undefined - ? { gasPrice: params.gasPrice, gasLimit: params.gasLimit } + ? { + gasPrice: params.gasPrice, + gasLimit: params.gasLimit, + ...(params.feeToken !== undefined && { feeToken: params.feeToken }), + } : { maxFeePerGas: Number(params.eip1559?.maxFeePerGas), maxPriorityFeePerGas: Number(params.eip1559?.maxPriorityFeePerGas), gasLimit: params.gasLimit, + ...(params.feeToken !== undefined && { feeToken: params.feeToken }), }; + } else if (params.feeToken !== undefined) { + feeOptions = { feeToken: params.feeToken }; } else if (params.gasLimit !== undefined) { feeOptions = { gasLimit: params.gasLimit }; } else { @@ -3633,6 +3640,7 @@ export class Wallet implements IWallet { memo: params.memo, nonce: params.nonce, feeOptions, + feeToken: params.feeToken, custodianTransactionId: params.custodianTransactionId, unspents: params.unspents, senderAddress: params.senderAddress, @@ -3651,6 +3659,7 @@ export class Wallet implements IWallet { recipients: params.recipients || [], nonce: params.nonce, feeOptions, + feeToken: params.feeToken, unspents: params.unspents, sequenceId: params.sequenceId, }, @@ -3680,6 +3689,7 @@ export class Wallet implements IWallet { lowFeeTxid: params.lowFeeTxid, receiveAddress: params.receiveAddress, feeOptions, + feeToken: params.feeToken, }, apiVersion, params.preview @@ -3694,6 +3704,7 @@ export class Wallet implements IWallet { nonce: params.nonce, receiveAddress: params.receiveAddress, feeOptions, + feeToken: params.feeToken, }, apiVersion, params.preview @@ -3705,6 +3716,7 @@ export class Wallet implements IWallet { reqId, intentType: 'tokenApproval', tokenName: params.tokenName, + feeToken: params.feeToken, }, apiVersion, params.preview @@ -3789,6 +3801,7 @@ export class Wallet implements IWallet { amount: params.intentAmount, nonce: params.nonce, feeOptions, + feeToken: params.feeToken, }, apiVersion, params.preview