Skip to content

Commit edbe6f3

Browse files
chore: support tokenApproval for MPC
Ticket: COIN-4071
1 parent bc45905 commit edbe6f3

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 = {
@@ -4942,4 +5005,99 @@ describe('V2 Wallet:', function () {
49425005
.should.be.rejectedWith('invalid argument for amount - Integer greater than zero or numeric string expected');
49435006
});
49445007
});
5008+
5009+
describe('Token Approval', function () {
5010+
let ethWallet;
5011+
const tokenName = 'usdc';
5012+
const walletPassphrase = 'test_passphrase';
5013+
5014+
before(async function () {
5015+
const walletData = {
5016+
id: '598f606cd8fc24710d2ebadb1d9459bb',
5017+
coin: 'teth',
5018+
keys: [
5019+
'598f606cd8fc24710d2ebad89dce86c2',
5020+
'598f606cc8e43aef09fcb785221d9dd2',
5021+
'5935d59cf660764331bafcade1855fd7',
5022+
],
5023+
};
5024+
ethWallet = new Wallet(bitgo, bitgo.coin('teth'), walletData);
5025+
});
5026+
5027+
it('should successfully approve ERC20 token', async function () {
5028+
const mockTokenApprovalBuild = {
5029+
txPrebuild: {
5030+
txHex: '0x1234567890abcdef',
5031+
txInfo: {
5032+
gasPrice: 20000000000,
5033+
gasLimit: 60000,
5034+
nonce: 1,
5035+
},
5036+
},
5037+
coin: 'teth',
5038+
tokenName: tokenName,
5039+
};
5040+
5041+
const mockSignedTransaction = {
5042+
txHex: '0xabcdef1234567890',
5043+
halfSigned: {
5044+
txHex: '0xabcdef1234567890',
5045+
},
5046+
};
5047+
5048+
const mockSentTransaction = {
5049+
hash: '0x9876543210fedcba',
5050+
status: 'signed',
5051+
};
5052+
5053+
// Mock the token approval build endpoint
5054+
const buildScope = nock(bgUrl)
5055+
.post(`/api/v2/teth/wallet/${ethWallet.id()}/token/approval/build`)
5056+
.reply(200, mockTokenApprovalBuild);
5057+
5058+
// Mock getKeychainsAndValidatePassphrase
5059+
sinon.stub(ethWallet, 'getKeychainsAndValidatePassphrase').resolves([
5060+
{
5061+
id: '598f606cd8fc24710d2ebad89dce86c2',
5062+
prv: 'test_private_key',
5063+
},
5064+
]);
5065+
5066+
// Mock signTransaction
5067+
sinon.stub(ethWallet, 'signTransaction').resolves(mockSignedTransaction);
5068+
5069+
// Mock sendTransaction
5070+
sinon.stub(ethWallet, 'sendTransaction').resolves(mockSentTransaction);
5071+
5072+
const result = await ethWallet.approveErc20Token(walletPassphrase, tokenName);
5073+
5074+
result.should.equal(mockSentTransaction);
5075+
buildScope.isDone().should.be.true();
5076+
});
5077+
5078+
it('should handle token approval build failure', async function () {
5079+
const buildScope = nock(bgUrl)
5080+
.post(`/api/v2/teth/wallet/${ethWallet.id()}/token/approval/build`)
5081+
.reply(400, { error: 'Invalid token name' });
5082+
5083+
sinon.stub(ethWallet, 'getKeychainsAndValidatePassphrase').resolves([
5084+
{
5085+
id: '598f606cd8fc24710d2ebad89dce86c2',
5086+
prv: 'test_private_key',
5087+
},
5088+
]);
5089+
5090+
await ethWallet.approveErc20Token(walletPassphrase, 'invalid_token').should.be.rejectedWith('Invalid token name');
5091+
5092+
buildScope.isDone().should.be.true();
5093+
});
5094+
5095+
it('should validate required parameters for approveErc20Token', async function () {
5096+
await ethWallet.approveErc20Token('', tokenName).should.be.rejectedWith();
5097+
});
5098+
5099+
afterEach(function () {
5100+
sinon.restore();
5101+
});
5102+
});
49455103
});

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)