Skip to content

Commit f95933e

Browse files
chore: support tokenApproval for MPC
Ticket: COIN-4071
1 parent cf28bf4 commit f95933e

File tree

5 files changed

+179
-2
lines changed

5 files changed

+179
-2
lines changed

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2542,7 +2542,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
25422542
!txParams?.recipients &&
25432543
!(
25442544
txParams.prebuildTx?.consolidateId ||
2545-
(txParams.type && ['acceleration', 'fillNonce', 'transferToken'].includes(txParams.type))
2545+
(txParams.type && ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval'].includes(txParams.type))
25462546
)
25472547
) {
25482548
throw new Error(`missing txParams`);

modules/bitgo/test/v2/unit/wallet.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3180,6 +3180,69 @@ describe('V2 Wallet:', function () {
31803180
intent.intentType.should.equal('fillNonce');
31813181
});
31823182

3183+
it('populate intent should return valid eth tokenApproval intent', async function () {
3184+
const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth'));
3185+
const feeOptions = {
3186+
maxFeePerGas: 3000000000,
3187+
maxPriorityFeePerGas: 2000000000,
3188+
};
3189+
const tokenName = 'usdc';
3190+
3191+
const intent = mpcUtils.populateIntent(bitgo.coin('hteth'), {
3192+
reqId,
3193+
intentType: 'tokenApproval',
3194+
tokenName,
3195+
feeOptions,
3196+
});
3197+
3198+
intent.should.have.property('recipients', undefined);
3199+
intent.feeOptions!.should.deepEqual(feeOptions);
3200+
intent.tokenName!.should.equal(tokenName);
3201+
intent.intentType.should.equal('tokenApproval');
3202+
});
3203+
3204+
it('populate intent should return valid polygon tokenApproval intent', async function () {
3205+
const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('tpolygon'));
3206+
const feeOptions = {
3207+
maxFeePerGas: 3000000000,
3208+
maxPriorityFeePerGas: 2000000000,
3209+
};
3210+
const tokenName = 'usdt';
3211+
3212+
const intent = mpcUtils.populateIntent(bitgo.coin('tpolygon'), {
3213+
reqId,
3214+
intentType: 'tokenApproval',
3215+
tokenName,
3216+
feeOptions,
3217+
});
3218+
3219+
intent.should.have.property('recipients', undefined);
3220+
intent.feeOptions!.should.deepEqual(feeOptions);
3221+
intent.tokenName!.should.equal(tokenName);
3222+
intent.intentType.should.equal('tokenApproval');
3223+
});
3224+
3225+
it('populate intent should return valid bsc tokenApproval intent', async function () {
3226+
const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('tbsc'));
3227+
const feeOptions = {
3228+
maxFeePerGas: 3000000000,
3229+
maxPriorityFeePerGas: 2000000000,
3230+
};
3231+
const tokenName = 'busd';
3232+
3233+
const intent = mpcUtils.populateIntent(bitgo.coin('tbsc'), {
3234+
reqId,
3235+
intentType: 'tokenApproval',
3236+
tokenName,
3237+
feeOptions,
3238+
});
3239+
3240+
intent.should.have.property('recipients', undefined);
3241+
intent.feeOptions!.should.deepEqual(feeOptions);
3242+
intent.tokenName!.should.equal(tokenName);
3243+
intent.intentType.should.equal('tokenApproval');
3244+
});
3245+
31833246
it('should populate intent with custodianTransactionId', async function () {
31843247
const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth'));
31853248
const feeOptions = {
@@ -4965,4 +5028,99 @@ describe('V2 Wallet:', function () {
49655028
.should.be.rejectedWith('invalid argument for amount - Integer greater than zero or numeric string expected');
49665029
});
49675030
});
5031+
5032+
describe('Token Approval', function () {
5033+
let ethWallet;
5034+
const tokenName = 'usdc';
5035+
const walletPassphrase = 'test_passphrase';
5036+
5037+
before(async function () {
5038+
const walletData = {
5039+
id: '598f606cd8fc24710d2ebadb1d9459bb',
5040+
coin: 'teth',
5041+
keys: [
5042+
'598f606cd8fc24710d2ebad89dce86c2',
5043+
'598f606cc8e43aef09fcb785221d9dd2',
5044+
'5935d59cf660764331bafcade1855fd7',
5045+
],
5046+
};
5047+
ethWallet = new Wallet(bitgo, bitgo.coin('teth'), walletData);
5048+
});
5049+
5050+
it('should successfully approve ERC20 token', async function () {
5051+
const mockTokenApprovalBuild = {
5052+
txPrebuild: {
5053+
txHex: '0x1234567890abcdef',
5054+
txInfo: {
5055+
gasPrice: 20000000000,
5056+
gasLimit: 60000,
5057+
nonce: 1,
5058+
},
5059+
},
5060+
coin: 'teth',
5061+
tokenName: tokenName,
5062+
};
5063+
5064+
const mockSignedTransaction = {
5065+
txHex: '0xabcdef1234567890',
5066+
halfSigned: {
5067+
txHex: '0xabcdef1234567890',
5068+
},
5069+
};
5070+
5071+
const mockSentTransaction = {
5072+
hash: '0x9876543210fedcba',
5073+
status: 'signed',
5074+
};
5075+
5076+
// Mock the token approval build endpoint
5077+
const buildScope = nock(bgUrl)
5078+
.post(`/api/v2/teth/wallet/${ethWallet.id()}/token/approval/build`)
5079+
.reply(200, mockTokenApprovalBuild);
5080+
5081+
// Mock getKeychainsAndValidatePassphrase
5082+
sinon.stub(ethWallet, 'getKeychainsAndValidatePassphrase').resolves([
5083+
{
5084+
id: '598f606cd8fc24710d2ebad89dce86c2',
5085+
prv: 'test_private_key',
5086+
},
5087+
]);
5088+
5089+
// Mock signTransaction
5090+
sinon.stub(ethWallet, 'signTransaction').resolves(mockSignedTransaction);
5091+
5092+
// Mock sendTransaction
5093+
sinon.stub(ethWallet, 'sendTransaction').resolves(mockSentTransaction);
5094+
5095+
const result = await ethWallet.approveErc20Token(walletPassphrase, tokenName);
5096+
5097+
result.should.equal(mockSentTransaction);
5098+
buildScope.isDone().should.be.true();
5099+
});
5100+
5101+
it('should handle token approval build failure', async function () {
5102+
const buildScope = nock(bgUrl)
5103+
.post(`/api/v2/teth/wallet/${ethWallet.id()}/token/approval/build`)
5104+
.reply(400, { error: 'Invalid token name' });
5105+
5106+
sinon.stub(ethWallet, 'getKeychainsAndValidatePassphrase').resolves([
5107+
{
5108+
id: '598f606cd8fc24710d2ebad89dce86c2',
5109+
prv: 'test_private_key',
5110+
},
5111+
]);
5112+
5113+
await ethWallet.approveErc20Token(walletPassphrase, 'invalid_token').should.be.rejectedWith('Invalid token name');
5114+
5115+
buildScope.isDone().should.be.true();
5116+
});
5117+
5118+
it('should validate required parameters for approveErc20Token', async function () {
5119+
await ethWallet.approveErc20Token('', tokenName).should.be.rejectedWith();
5120+
});
5121+
5122+
afterEach(function () {
5123+
sinon.restore();
5124+
});
5125+
});
49685126
});

modules/sdk-core/src/bitgo/utils/mpcUtils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export abstract class MpcUtils {
117117
populateIntent(baseCoin: IBaseCoin, params: PrebuildTransactionWithIntentOptions): PopulatedIntent {
118118
const chain = this.baseCoin.getChain();
119119

120-
if (!['acceleration', 'fillNonce', 'transferToken'].includes(params.intentType)) {
120+
if (!['acceleration', 'fillNonce', 'transferToken', 'tokenApproval'].includes(params.intentType)) {
121121
assert(params.recipients, `'recipients' is a required parameter for ${params.intentType} intent`);
122122
}
123123
const intentRecipients = params.recipients?.map((recipient) => {
@@ -153,6 +153,7 @@ export abstract class MpcUtils {
153153
comment: params.comment,
154154
nonce: params.nonce,
155155
recipients: intentRecipients,
156+
tokenName: params.tokenName,
156157
};
157158

158159
if (baseCoin.getFamily() === 'eth' || baseCoin.getFamily() === 'polygon' || baseCoin.getFamily() === 'bsc') {
@@ -177,6 +178,12 @@ export abstract class MpcUtils {
177178
receiveAddress: params.receiveAddress,
178179
feeOptions: params.feeOptions,
179180
};
181+
case 'tokenApproval':
182+
return {
183+
...baseIntent,
184+
tokenName: params.tokenName,
185+
feeOptions: params.feeOptions,
186+
};
180187
default:
181188
throw new Error(`Unsupported intent type ${params.intentType}`);
182189
}

modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ export interface PopulatedIntent extends PopulatedIntentBase {
256256
receiveAddress?: string;
257257
custodianTransactionId?: string;
258258
custodianMessageId?: string;
259+
tokenName?: string;
259260
}
260261

261262
export type TxRequestState =

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3355,6 +3355,17 @@ export class Wallet implements IWallet {
33553355
params.preview
33563356
);
33573357
break;
3358+
case 'tokenApproval':
3359+
txRequest = await this.tssUtils!.prebuildTxWithIntent(
3360+
{
3361+
reqId,
3362+
intentType: 'tokenApproval',
3363+
tokenName: params.tokenName,
3364+
},
3365+
apiVersion,
3366+
params.preview
3367+
);
3368+
break;
33583369
default:
33593370
throw new Error(`transaction type not supported: ${params.type}`);
33603371
}

0 commit comments

Comments
 (0)