diff --git a/examples/ts/eth/approve-erc20token.ts b/examples/ts/eth/approve-erc20token.ts new file mode 100644 index 0000000000..7ca725d27c --- /dev/null +++ b/examples/ts/eth/approve-erc20token.ts @@ -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)); diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 6778d453a0..11890ec58d 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -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); + }); + }); }); diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index f0e8330c5c..578b2a8299 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -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; @@ -899,4 +900,5 @@ export interface IWallet { fetchCrossChainUTXOs(params: FetchCrossChainUTXOsOptions): Promise; getChallengesForEcdsaSigning(): Promise; getNftBalances(): Promise; + approveErc20Token(walletPassphrase: string, tokenName: string): Promise; } diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 8f9a233a83..46345bd2e4 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -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'); @@ -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} The transaction details + */ + async approveErc20Token(walletPassphrase: string, tokenName: string): Promise { + 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); + } }