Skip to content
Merged
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
28 changes: 28 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,30 @@ export interface ChangeFeeOptions {
eip1559?: EIP1559;
}

/**
* Response from the token approval build endpoint
*/
export interface BuildTokenApprovalResponse {
txHex: string;
txInfo: {
amount: string;
contractAddress: string;
spender: string;
};
recipients: {
address: string;
amount: string;
data: string;
}[];
eip1559?: {
maxFeePerGas: string;
maxPriorityFeePerGas: string;
};
nextContractSequenceId: number;
coin: string;
walletId: string;
}

export interface CreatePolicyRuleOptions {
id?: string;
type?: string;
Expand Down Expand Up @@ -945,4 +969,8 @@ export interface IWallet {
getChallengesForEcdsaSigning(): Promise<WalletEcdsaChallenges>;
getNftBalances(): Promise<NftBalance[]>;
approveErc20Token(walletPassphrase: string, tokenName: string): Promise<SubmitTransactionResponse>;
buildErc20TokenApproval(
tokenName: string,
walletPassphrase?: string
): Promise<BuildTokenApprovalResponse | SubmitTransactionResponse>;
}
51 changes: 51 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ import {
WalletSignTransactionOptions,
WalletSignTypedDataOptions,
WalletType,
BuildTokenApprovalResponse,
} from './iWallet';

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

return this.sendTransaction(finalTxParams, reqId);
}

/**
* Build token approval transaction for ERC20 tokens
* If walletPassphrase is provided, also signs and sends the transaction
*
* @param {string} tokenName - The name of the token to be approved
* @param {string} [walletPassphrase] - Optional wallet passphrase for signing and sending
* @returns {Promise<BuildTokenApprovalResponse | SubmitTransactionResponse>} The token approval build response or transaction details if signed
*/
async buildErc20TokenApproval(
tokenName: string,
walletPassphrase?: string
): Promise<BuildTokenApprovalResponse | SubmitTransactionResponse> {
const reqId = new RequestTracer();
this.bitgo.setRequestTracer(reqId);

let tokenApprovalBuild: BuildTokenApprovalResponse;
const url = this.baseCoin.url(`/wallet/${this.id()}/token/approval/build`);
try {
tokenApprovalBuild = await this.bitgo
.post(url)
.send({
tokenName: tokenName,
})
.result();
} catch (error) {
throw new Error(`error building erc20 token approval tx: ${error}`);
}

if (!walletPassphrase) {
return tokenApprovalBuild;
}

const keychains = await this.getKeychainsAndValidatePassphrase({
reqId,
walletPassphrase,
});

const signingParams = {
txPrebuild: tokenApprovalBuild,
keychain: keychains[0],
walletPassphrase,
reqId,
};

const halfSignedTransaction = await this.signTransaction(signingParams);
const finalTxParams = _.extend({}, halfSignedTransaction);

return this.sendTransaction(finalTxParams, reqId);
}
}
153 changes: 153 additions & 0 deletions modules/sdk-core/test/unit/bitgo/wallet/tokenApproval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import sinon from 'sinon';
import 'should';
import { BuildTokenApprovalResponse, Wallet } from '../../../../src';

describe('Wallet - Token Approval', function () {
let wallet: Wallet;
let mockBitGo: any;
let mockBaseCoin: any;
let mockWalletData: any;

beforeEach(function () {
mockBitGo = {
post: sinon.stub(),
get: sinon.stub(),
setRequestTracer: sinon.stub(),
};

mockBaseCoin = {
getFamily: sinon.stub().returns('eth'),
url: sinon.stub(),
keychains: sinon.stub(),
supportsTss: sinon.stub().returns(false),
getMPCAlgorithm: sinon.stub(),
};

mockWalletData = {
id: 'test-wallet-id',
coin: 'teth',
keys: ['user-key', 'backup-key', 'bitgo-key'],
};

wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData);
});

afterEach(function () {
sinon.restore();
});

describe('buildErc20TokenApproval', function () {
const mockTokenApprovalBuild: BuildTokenApprovalResponse = {
txHex: '0x123456',
txInfo: {
amount: '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
contractAddress: '0x1234567890123456789012345678901234567890',
spender: '0x0987654321098765432109876543210987654321',
},
recipients: [
{
address: '0x0987654321098765432109876543210987654321',
amount: '0',
data: '0x095ea7b30000000000000000000000000987654321098765432109876543210987654321ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
},
],
eip1559: {
maxFeePerGas: '0x3b9aca00',
maxPriorityFeePerGas: '0x3b9aca00',
},
nextContractSequenceId: 0,
coin: 'teth',
walletId: 'test-wallet-id',
};

it('should build token approval transaction without signing', async function () {
mockBaseCoin.url.returns('/test/wallet/token/approval/build');
mockBitGo.post.returns({
send: sinon.stub().returns({
result: sinon.stub().resolves(mockTokenApprovalBuild),
}),
});

const result = await wallet.buildErc20TokenApproval('USDC');

result.should.eql(mockTokenApprovalBuild);
sinon.assert.calledWith(mockBaseCoin.url, '/wallet/test-wallet-id/token/approval/build');
sinon.assert.calledOnce(mockBitGo.post);
sinon.assert.calledOnce(mockBitGo.setRequestTracer);
const postRequest = mockBitGo.post.getCall(0);
const sendCall = postRequest.returnValue.send.getCall(0);
sendCall.args[0].should.eql({ tokenName: 'USDC' });
});

it('should throw error if token build request fails', async function () {
mockBaseCoin.url.returns('/test/wallet/token/approval/build');
mockBitGo.post.returns({
send: sinon.stub().returns({
result: sinon.stub().rejects(new Error('token not supported')),
}),
});

await wallet
.buildErc20TokenApproval('INVALID_TOKEN')
.should.be.rejectedWith(/error building erc20 token approval tx: Error: token not supported/);
});

it('should build, sign, and send token approval transaction when passphrase is provided', async function () {
mockBaseCoin.url.returns('/test/wallet/token/approval/build');
mockBitGo.post.returns({
send: sinon.stub().returns({
result: sinon.stub().resolves(mockTokenApprovalBuild),
}),
});

const mockKeychain = { id: 'user-key', pub: 'pub-key', encryptedPrv: 'encrypted-prv' };
mockBaseCoin.keychains.returns({
get: sinon.stub().resolves(mockKeychain),
});

const signTransactionStub = sinon.stub(wallet, 'signTransaction' as keyof Wallet).resolves({ txHex: '0xsigned' });
const sendTransactionStub = sinon.stub(wallet, 'sendTransaction' as keyof Wallet).resolves({ txid: '0xtxid' });
const getKeychainsStub = sinon.stub(wallet as any, 'getKeychainsAndValidatePassphrase').resolves([mockKeychain]);

const result = await wallet.buildErc20TokenApproval('USDC', 'passphrase123');

result.should.have.property('txid', '0xtxid');

sinon.assert.calledOnce(getKeychainsStub);
getKeychainsStub.getCall(0).args[0].should.have.property('walletPassphrase', 'passphrase123');

sinon.assert.calledOnce(signTransactionStub);
const signCall = signTransactionStub.getCall(0);
if (signCall && signCall.args[0]) {
signCall.args[0].should.have.property('txPrebuild', mockTokenApprovalBuild);
signCall.args[0].should.have.property('keychain', mockKeychain);
signCall.args[0].should.have.property('walletPassphrase', 'passphrase123');
}

sinon.assert.calledOnce(sendTransactionStub);
const sendCall = sendTransactionStub.getCall(0);
if (sendCall && sendCall.args[0]) {
sendCall.args[0].should.have.property('txHex', '0xsigned');
}
});

it('should handle signing errors', async function () {
mockBaseCoin.url.returns('/test/wallet/token/approval/build');
mockBitGo.post.returns({
send: sinon.stub().returns({
result: sinon.stub().resolves(mockTokenApprovalBuild),
}),
});

const mockKeychain = { id: 'user-key', pub: 'pub-key', encryptedPrv: 'encrypted-prv' };
mockBaseCoin.keychains.returns({
get: sinon.stub().resolves(mockKeychain),
});

sinon.stub(wallet as any, 'getKeychainsAndValidatePassphrase').resolves([mockKeychain]);
sinon.stub(wallet, 'signTransaction' as keyof Wallet).rejects(new Error('signing error'));

await wallet.buildErc20TokenApproval('USDC', 'passphrase123').should.be.rejectedWith('signing error');
});
});
});