Skip to content

Commit 76b9577

Browse files
feat(sdk-core): tokenApproval func for hot and cold multisig
Ticket: COIN-5892
1 parent 49e9906 commit 76b9577

File tree

3 files changed

+232
-0
lines changed

3 files changed

+232
-0
lines changed

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,30 @@ export interface ChangeFeeOptions {
800800
eip1559?: EIP1559;
801801
}
802802

803+
/**
804+
* Response from the token approval build endpoint
805+
*/
806+
export interface BuildTokenApprovalResponse {
807+
txHex: string;
808+
txInfo: {
809+
amount: string;
810+
contractAddress: string;
811+
spender: string;
812+
};
813+
recipients: {
814+
address: string;
815+
amount: string;
816+
data: string;
817+
}[];
818+
eip1559?: {
819+
maxFeePerGas: string;
820+
maxPriorityFeePerGas: string;
821+
};
822+
nextContractSequenceId: number;
823+
coin: string;
824+
walletId: string;
825+
}
826+
803827
export interface CreatePolicyRuleOptions {
804828
id?: string;
805829
type?: string;
@@ -945,4 +969,8 @@ export interface IWallet {
945969
getChallengesForEcdsaSigning(): Promise<WalletEcdsaChallenges>;
946970
getNftBalances(): Promise<NftBalance[]>;
947971
approveErc20Token(walletPassphrase: string, tokenName: string): Promise<SubmitTransactionResponse>;
972+
buildErc20TokenApproval(
973+
tokenName: string,
974+
walletPassphrase?: string
975+
): Promise<BuildTokenApprovalResponse | SubmitTransactionResponse>;
948976
}

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ import {
122122
WalletSignTransactionOptions,
123123
WalletSignTypedDataOptions,
124124
WalletType,
125+
BuildTokenApprovalResponse,
125126
} from './iWallet';
126127

127128
const debug = require('debug')('bitgo:v2:wallet');
@@ -4039,4 +4040,54 @@ export class Wallet implements IWallet {
40394040

40404041
return this.sendTransaction(finalTxParams, reqId);
40414042
}
4043+
4044+
/**
4045+
* Build token approval transaction for ERC20 tokens
4046+
* If walletPassphrase is provided, also signs and sends the transaction
4047+
*
4048+
* @param {string} tokenName - The name of the token to be approved
4049+
* @param {string} [walletPassphrase] - Optional wallet passphrase for signing and sending
4050+
* @returns {Promise<BuildTokenApprovalResponse | SubmitTransactionResponse>} The token approval build response or transaction details if signed
4051+
*/
4052+
async buildErc20TokenApproval(
4053+
tokenName: string,
4054+
walletPassphrase?: string
4055+
): Promise<BuildTokenApprovalResponse | SubmitTransactionResponse> {
4056+
const reqId = new RequestTracer();
4057+
this.bitgo.setRequestTracer(reqId);
4058+
4059+
let tokenApprovalBuild: BuildTokenApprovalResponse;
4060+
const url = this.baseCoin.url(`/wallet/${this.id()}/token/approval/build`);
4061+
try {
4062+
tokenApprovalBuild = await this.bitgo
4063+
.post(url)
4064+
.send({
4065+
tokenName: tokenName,
4066+
})
4067+
.result();
4068+
} catch (error) {
4069+
throw new Error(`error building erc20 token approval tx: ${error}`);
4070+
}
4071+
4072+
if (!walletPassphrase) {
4073+
return tokenApprovalBuild;
4074+
}
4075+
4076+
const keychains = await this.getKeychainsAndValidatePassphrase({
4077+
reqId,
4078+
walletPassphrase,
4079+
});
4080+
4081+
const signingParams = {
4082+
txPrebuild: tokenApprovalBuild,
4083+
keychain: keychains[0],
4084+
walletPassphrase,
4085+
reqId,
4086+
};
4087+
4088+
const halfSignedTransaction = await this.signTransaction(signingParams);
4089+
const finalTxParams = _.extend({}, halfSignedTransaction);
4090+
4091+
return this.sendTransaction(finalTxParams, reqId);
4092+
}
40424093
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import sinon from 'sinon';
2+
import 'should';
3+
import { BuildTokenApprovalResponse, Wallet } from '../../../../src';
4+
5+
describe('Wallet - Token Approval', function () {
6+
let wallet: Wallet;
7+
let mockBitGo: any;
8+
let mockBaseCoin: any;
9+
let mockWalletData: any;
10+
11+
beforeEach(function () {
12+
mockBitGo = {
13+
post: sinon.stub(),
14+
get: sinon.stub(),
15+
setRequestTracer: sinon.stub(),
16+
};
17+
18+
mockBaseCoin = {
19+
getFamily: sinon.stub().returns('eth'),
20+
url: sinon.stub(),
21+
keychains: sinon.stub(),
22+
supportsTss: sinon.stub().returns(false),
23+
getMPCAlgorithm: sinon.stub(),
24+
};
25+
26+
mockWalletData = {
27+
id: 'test-wallet-id',
28+
coin: 'teth',
29+
keys: ['user-key', 'backup-key', 'bitgo-key'],
30+
};
31+
32+
wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData);
33+
});
34+
35+
afterEach(function () {
36+
sinon.restore();
37+
});
38+
39+
describe('buildErc20TokenApproval', function () {
40+
const mockTokenApprovalBuild: BuildTokenApprovalResponse = {
41+
txHex: '0x123456',
42+
txInfo: {
43+
amount: '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
44+
contractAddress: '0x1234567890123456789012345678901234567890',
45+
spender: '0x0987654321098765432109876543210987654321',
46+
},
47+
recipients: [
48+
{
49+
address: '0x0987654321098765432109876543210987654321',
50+
amount: '0',
51+
data: '0x095ea7b30000000000000000000000000987654321098765432109876543210987654321ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
52+
},
53+
],
54+
eip1559: {
55+
maxFeePerGas: '0x3b9aca00',
56+
maxPriorityFeePerGas: '0x3b9aca00',
57+
},
58+
nextContractSequenceId: 0,
59+
coin: 'teth',
60+
walletId: 'test-wallet-id',
61+
};
62+
63+
it('should build token approval transaction without signing', async function () {
64+
mockBaseCoin.url.returns('/test/wallet/token/approval/build');
65+
mockBitGo.post.returns({
66+
send: sinon.stub().returns({
67+
result: sinon.stub().resolves(mockTokenApprovalBuild),
68+
}),
69+
});
70+
71+
const result = await wallet.buildErc20TokenApproval('USDC');
72+
73+
result.should.eql(mockTokenApprovalBuild);
74+
sinon.assert.calledWith(mockBaseCoin.url, '/wallet/test-wallet-id/token/approval/build');
75+
sinon.assert.calledOnce(mockBitGo.post);
76+
sinon.assert.calledOnce(mockBitGo.setRequestTracer);
77+
const postRequest = mockBitGo.post.getCall(0);
78+
const sendCall = postRequest.returnValue.send.getCall(0);
79+
sendCall.args[0].should.eql({ tokenName: 'USDC' });
80+
});
81+
82+
it('should throw error if token build request fails', async function () {
83+
mockBaseCoin.url.returns('/test/wallet/token/approval/build');
84+
mockBitGo.post.returns({
85+
send: sinon.stub().returns({
86+
result: sinon.stub().rejects(new Error('token not supported')),
87+
}),
88+
});
89+
90+
await wallet
91+
.buildErc20TokenApproval('INVALID_TOKEN')
92+
.should.be.rejectedWith(/error building erc20 token approval tx: Error: token not supported/);
93+
});
94+
95+
it('should build, sign, and send token approval transaction when passphrase is provided', async function () {
96+
mockBaseCoin.url.returns('/test/wallet/token/approval/build');
97+
mockBitGo.post.returns({
98+
send: sinon.stub().returns({
99+
result: sinon.stub().resolves(mockTokenApprovalBuild),
100+
}),
101+
});
102+
103+
const mockKeychain = { id: 'user-key', pub: 'pub-key', encryptedPrv: 'encrypted-prv' };
104+
mockBaseCoin.keychains.returns({
105+
get: sinon.stub().resolves(mockKeychain),
106+
});
107+
108+
const signTransactionStub = sinon.stub(wallet, 'signTransaction' as keyof Wallet).resolves({ txHex: '0xsigned' });
109+
const sendTransactionStub = sinon.stub(wallet, 'sendTransaction' as keyof Wallet).resolves({ txid: '0xtxid' });
110+
const getKeychainsStub = sinon.stub(wallet as any, 'getKeychainsAndValidatePassphrase').resolves([mockKeychain]);
111+
112+
const result = await wallet.buildErc20TokenApproval('USDC', 'passphrase123');
113+
114+
result.should.have.property('txid', '0xtxid');
115+
116+
sinon.assert.calledOnce(getKeychainsStub);
117+
getKeychainsStub.getCall(0).args[0].should.have.property('walletPassphrase', 'passphrase123');
118+
119+
sinon.assert.calledOnce(signTransactionStub);
120+
const signCall = signTransactionStub.getCall(0);
121+
if (signCall && signCall.args[0]) {
122+
signCall.args[0].should.have.property('txPrebuild', mockTokenApprovalBuild);
123+
signCall.args[0].should.have.property('keychain', mockKeychain);
124+
signCall.args[0].should.have.property('walletPassphrase', 'passphrase123');
125+
}
126+
127+
sinon.assert.calledOnce(sendTransactionStub);
128+
const sendCall = sendTransactionStub.getCall(0);
129+
if (sendCall && sendCall.args[0]) {
130+
sendCall.args[0].should.have.property('txHex', '0xsigned');
131+
}
132+
});
133+
134+
it('should handle signing errors', async function () {
135+
mockBaseCoin.url.returns('/test/wallet/token/approval/build');
136+
mockBitGo.post.returns({
137+
send: sinon.stub().returns({
138+
result: sinon.stub().resolves(mockTokenApprovalBuild),
139+
}),
140+
});
141+
142+
const mockKeychain = { id: 'user-key', pub: 'pub-key', encryptedPrv: 'encrypted-prv' };
143+
mockBaseCoin.keychains.returns({
144+
get: sinon.stub().resolves(mockKeychain),
145+
});
146+
147+
sinon.stub(wallet as any, 'getKeychainsAndValidatePassphrase').resolves([mockKeychain]);
148+
sinon.stub(wallet, 'signTransaction' as keyof Wallet).rejects(new Error('signing error'));
149+
150+
await wallet.buildErc20TokenApproval('USDC', 'passphrase123').should.be.rejectedWith('signing error');
151+
});
152+
});
153+
});

0 commit comments

Comments
 (0)