Skip to content
Draft
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
1 change: 1 addition & 0 deletions modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ interface EthTransactionParams extends TransactionParams {
hop?: boolean;
prebuildTx?: PrebuildTransactionResult;
tokenName?: string;
feeToken?: string;
}

export interface VerifyEthTransactionOptions extends VerifyTransactionOptions {
Expand Down
56 changes: 56 additions & 0 deletions modules/bitgo/test/v2/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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'));

Expand Down
24 changes: 24 additions & 0 deletions modules/sdk-coin-tempo/src/tempo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions modules/sdk-coin-tempo/test/unit/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
6 changes: 6 additions & 0 deletions modules/sdk-core/src/bitgo/utils/mpcUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`);
Expand All @@ -245,6 +249,7 @@ export abstract class MpcUtils {
token: params.tokenName,
enableTokens: params.enableTokens,
feeOptions: params.feeOptions,
feeToken: params.feeToken,
};
}

Expand All @@ -253,6 +258,7 @@ export abstract class MpcUtils {
memo: params.memo?.value,
token: params.tokenName,
enableTokens: params.enableTokens,
feeToken: params.feeToken,
};
}

Expand Down
11 changes: 11 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface EIP1559FeeOptions {
gasLimit?: number;
maxFeePerGas: number;
maxPriorityFeePerGas: number;
feeToken?: string;
}

export interface FeeOption {
Expand All @@ -30,6 +31,7 @@ export interface FeeOption {
feeType?: 'base' | 'max' | 'tip';
gasLimit?: number;
gasPrice?: number;
feeToken?: string;
}

export interface TokenEnablement {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/wallet/BuildParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
])
);
Expand Down
5 changes: 5 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 15 additions & 2 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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,
},
Expand Down Expand Up @@ -3680,6 +3689,7 @@ export class Wallet implements IWallet {
lowFeeTxid: params.lowFeeTxid,
receiveAddress: params.receiveAddress,
feeOptions,
feeToken: params.feeToken,
},
apiVersion,
params.preview
Expand All @@ -3694,6 +3704,7 @@ export class Wallet implements IWallet {
nonce: params.nonce,
receiveAddress: params.receiveAddress,
feeOptions,
feeToken: params.feeToken,
},
apiVersion,
params.preview
Expand All @@ -3705,6 +3716,7 @@ export class Wallet implements IWallet {
reqId,
intentType: 'tokenApproval',
tokenName: params.tokenName,
feeToken: params.feeToken,
},
apiVersion,
params.preview
Expand Down Expand Up @@ -3789,6 +3801,7 @@ export class Wallet implements IWallet {
amount: params.intentAmount,
nonce: params.nonce,
feeOptions,
feeToken: params.feeToken,
},
apiVersion,
params.preview
Expand Down
Loading