Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add approveToken function for erc20 sendMany #5842

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
51 changes: 51 additions & 0 deletions examples/ts/eth/approve-erc20token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Approve an ERC20 token for use with a batcher contract at BitGo.
*
* Copyright 2023, BitGo, Inc. All Rights Reserved.
*/
import { BitGoAPI } from '@bitgo/sdk-api';
import { Topeth } from '@bitgo/sdk-coin-opeth';
require('dotenv').config({ path: '../../../.env' });

const bitgo = new BitGoAPI({
accessToken: process.env.TESTNET_ACCESS_TOKEN,
env: 'test',
});

const coin = 'topeth';
bitgo.register(coin, Topeth.createInstance);

const walletId = process.env.TESTNET_ETH_WALLET_ID;
const walletPassphrase = process.env.TESTNET_ETH_WALLET_PASSPHRASE;
const tokenName = 'topeth:terc18dp'; // Replace with the token you want to approve

async function main() {
if (!walletId) {
throw new Error('Please set TESTNET_ETH_WALLET_ID environment variable');
}
if (!walletPassphrase) {
throw new Error('Please set TESTNET_ETH_WALLET_PASSPHRASE environment variable');
}

const walletInstance = await bitgo.coin(coin).wallets().get({ id: walletId });

console.log('Wallet ID:', walletInstance.id());
console.log('Current Receive Address:', walletInstance.receiveAddress());

console.log(`Approving token ${tokenName} for use with batcher contract...`);

try {
const approvalTransaction = await walletInstance.approveErc20Token(walletPassphrase, tokenName);

console.log('Token Approval Transaction:', JSON.stringify(approvalTransaction, null, 4));
console.log('Transaction ID:', approvalTransaction.txid);
console.log('Status:', approvalTransaction.status);
} catch (e) {
console.error('Error approving token:', e.message);
if (e.stack) {
console.error(e.stack);
}
}
}

main().catch((e) => console.error(e));
165 changes: 165 additions & 0 deletions modules/bitgo/test/v2/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4681,4 +4681,169 @@ describe('V2 Wallet:', function () {
await adaWallet.sendMany(sendManyParams).should.be.resolved();
});
});

describe('ERC20 Token Approval', function () {
let wallet;
const walletId = '5b34252f1bf349930e34020a00000000';
let topethCoin;
const tokenName = 'topeth:terc18dp';
const coin = 'topeth';

beforeEach(function () {
topethCoin = bitgo.coin('topeth');
wallet = new Wallet(bitgo, topethCoin, {
id: walletId,
coin: coin,
keys: ['keyid1', 'keyid2', 'keyid3'],
});
});

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

it('should successfully approve a token', async function () {
const walletPassphrase = 'password123';
const expectedApprovalBuild = {
txHex: '0x123456',
feeInfo: {
fee: '1000000000',
},
};

const expectedSignedTx = {
txHex: '0x123456signed',
};

const expectedSendResult = {
txid: '0xabcdef',
tx: '0x123456signed',
status: 'signed',
};

// Mock the token approval build API
const ethUrl = common.Environments[bitgo.getEnv()].uri;
nock(ethUrl).post(`/api/v2/${coin}/wallet/${walletId}/token/approval/build`).reply(200, expectedApprovalBuild);

// Mock the getKeychainsAndValidatePassphrase method
sinon.stub(wallet, 'getKeychainsAndValidatePassphrase').resolves([
{
id: 'keyid1',
pub: 'pub1',
encryptedPrv: 'encryptedPrv',
},
]);

// Mock the sign transaction method
sinon.stub(wallet, 'signTransaction').resolves(expectedSignedTx);

// Mock the send transaction method
sinon.stub(wallet, 'sendTransaction').resolves(expectedSendResult);

const result = await wallet.approveErc20Token(walletPassphrase, tokenName);

should.exist(result);
result.should.deepEqual(expectedSendResult);

// Verify the parameters passed to signTransaction
const signParams = wallet.signTransaction.firstCall.args[0];
signParams.should.have.property('txPrebuild', expectedApprovalBuild);
signParams.should.have.property('keychain');
signParams.should.have.property('walletPassphrase', walletPassphrase);
});

it('should handle token approval build API errors', async function () {
const walletPassphrase = 'password123';
const errorMessage = 'token approval build failed';

// Mock the token approval build API to return an error
nock(bgUrl).post(`/api/v2/${coin}/wallet/${walletId}/token/approval/build`).replyWithError(errorMessage);

await wallet.approveErc20Token(walletPassphrase, tokenName).should.be.rejectedWith(errorMessage);
});

it('should handle wallet passphrase validation errors', async function () {
const walletPassphrase = 'wrong-password';
const expectedApprovalBuild = {
txHex: '0x123456',
feeInfo: {
fee: '1000000000',
},
};

// Mock the token approval build API
nock(bgUrl).post(`/api/v2/${coin}/wallet/${walletId}/token/approval/build`).reply(200, expectedApprovalBuild);

// Mock the getKeychainsAndValidatePassphrase method to throw an error
const error = new Error('unable to decrypt keychain with the given wallet passphrase');
error.name = 'wallet_passphrase_incorrect';
sinon.stub(wallet, 'getKeychainsAndValidatePassphrase').rejects(error);

await wallet
.approveErc20Token(walletPassphrase, tokenName)
.should.be.rejectedWith('unable to decrypt keychain with the given wallet passphrase');
});

it('should handle signing errors', async function () {
const walletPassphrase = 'password123';
const expectedApprovalBuild = {
txHex: '0x123456',
feeInfo: {
fee: '1000000000',
},
};
const signingError = new Error('signing failed');

// Mock the token approval build API
nock(bgUrl).post(`/api/v2/${coin}/wallet/${walletId}/token/approval/build`).reply(200, expectedApprovalBuild);

// Mock the getKeychainsAndValidatePassphrase method
sinon.stub(wallet, 'getKeychainsAndValidatePassphrase').resolves([
{
id: 'keyid1',
pub: 'pub1',
encryptedPrv: 'encryptedPrv',
},
]);

// Mock the sign transaction method to throw an error
sinon.stub(wallet, 'signTransaction').rejects(signingError);

await wallet.approveErc20Token(walletPassphrase, tokenName).should.be.rejectedWith(signingError);
});

it('should handle send transaction errors', async function () {
const walletPassphrase = 'password123';
const expectedApprovalBuild = {
txHex: '0x123456',
feeInfo: {
fee: '1000000000',
},
};
const expectedSignedTx = {
txHex: '0x123456signed',
};
const sendError = new Error('send failed');

// Mock the token approval build API
nock(bgUrl).post(`/api/v2/${coin}/wallet/${walletId}/token/approval/build`).reply(200, expectedApprovalBuild);

// Mock the getKeychainsAndValidatePassphrase method
sinon.stub(wallet, 'getKeychainsAndValidatePassphrase').resolves([
{
id: 'keyid1',
pub: 'pub1',
encryptedPrv: 'encryptedPrv',
},
]);

// Mock the sign transaction method
sinon.stub(wallet, 'signTransaction').resolves(expectedSignedTx);

// Mock the send transaction method to throw an error
sinon.stub(wallet, 'sendTransaction').rejects(sendError);

await wallet.approveErc20Token(walletPassphrase, tokenName).should.be.rejectedWith(sendError);
});
});
});
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { SerializedNtilde } from '../../account-lib/mpc/tss/ecdsa/types';
import { IAddressBook } from '../address-book';
import { WalletUser } from '@bitgo/public-types';
import { SubmitTransactionResponse } from '../inscriptionBuilder';

export interface MaximumSpendableOptions {
minValue?: number | string;
Expand Down Expand Up @@ -899,4 +900,5 @@ export interface IWallet {
fetchCrossChainUTXOs(params: FetchCrossChainUTXOsOptions): Promise<CrossChainUTXO[]>;
getChallengesForEcdsaSigning(): Promise<WalletEcdsaChallenges>;
getNftBalances(): Promise<NftBalance[]>;
approveErc20Token(walletPassphrase: string, tokenName: string): Promise<SubmitTransactionResponse>;
}
44 changes: 44 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import { AddressBook, IAddressBook } from '../address-book';
import { IRequestTracer } from '../../api';
import { getTxRequestApiVersion, validateTxRequestApiVersion } from '../utils/txRequest';
import { getLightningAuthKey } from '../lightning/lightningWalletUtil';
import { SubmitTransactionResponse } from '../inscriptionBuilder';

const debug = require('debug')('bitgo:v2:wallet');

Expand Down Expand Up @@ -3739,4 +3740,47 @@ export class Wallet implements IWallet {
}
return keychains;
}

/**
* Approve token for use with a batcher contract
* This function builds, signs, and sends a token approval transaction
*
* @param {string} walletPassphrase - The passphrase to be used to decrypt the user key
* @param {string} tokenName - The name of the token to be approved
* @returns {Promise<any>} The transaction details
*/
async approveErc20Token(walletPassphrase: string, tokenName: string): Promise<SubmitTransactionResponse> {
const reqId = new RequestTracer();
this.bitgo.setRequestTracer(reqId);

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

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);
}
}