From 178bb278e3c38dafcaa8b1c3c79f7dd8a5c13260 Mon Sep 17 00:00:00 2001 From: Hrishikesh Jain Date: Tue, 4 Aug 2026 16:41:02 +0530 Subject: [PATCH] feat(sdk-coin-eth): add ERC-7984 confidential token non-BitGo recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the no-proof handle sweep for ERC-7984 tokens using sendMultiSig with ETHER prefix and bytes calldata (not sendMultiSigToken). The recover() override queries confidentialBalanceOf for the encrypted handle and passes it directly as the transfer amount — no Zama relayer needed for recovery. - Add encryptedHandle option to RecoverOptions for manual override - Add queryConfidentialBalance() for on-chain eth_call - Override setGasLimit() with 800k default (validated ~530k on Hoodi) - Guard against TSS recovery (not yet supported) - Add unit tests for recover, queryConfidentialBalance, setGasLimit TICKET: CHALO-1115 Co-authored-by: Cursor --- .../src/abstractEthLikeNewCoins.ts | 2 + modules/sdk-coin-eth/src/erc7984Token.ts | 269 +++++++++++++- .../sdk-coin-eth/test/unit/erc7984Token.ts | 333 ++++++++++++++++++ 3 files changed, 602 insertions(+), 2 deletions(-) diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 31cdeef85c..d4c2a6b453 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -287,6 +287,8 @@ export type RecoverOptions = { derivationSeed?: string; apiKey?: string; // optional API key to use instead of the one from the environment isUnsignedSweep?: boolean; // specify if this is an unsigned recovery + /** ERC-7984: skip auto-query and use this encrypted balance handle directly (bytes32, 0x-prefixed) */ + encryptedHandle?: string; } & TSSRecoverOptions; export type GetBatchExecutionInfoRT = { diff --git a/modules/sdk-coin-eth/src/erc7984Token.ts b/modules/sdk-coin-eth/src/erc7984Token.ts index 6712b6bba1..81486ed41d 100644 --- a/modules/sdk-coin-eth/src/erc7984Token.ts +++ b/modules/sdk-coin-eth/src/erc7984Token.ts @@ -1,8 +1,16 @@ /** * @prettier */ -import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor } from '@bitgo/sdk-core'; - +import { + BitGoBase, + CoinConstructor, + checkKrsProvider, + getIsKrsRecovery, + getIsUnsignedSweep, + MPCAlgorithm, + NamedCoinConstructor, + Util, +} from '@bitgo/sdk-core'; import { coins, Erc7984TokenConfig, EthereumNetwork, tokens } from '@bitgo/statics'; import { CoinNames, @@ -14,10 +22,17 @@ import { decodeSendMultiSigFlushERC7984Data, sendMultisigMethodId, confidentialTransferWithProofMethodId, + buildConfidentialTransferByHandleCalldata, + optionalDeps, + RecoverOptions, + RecoveryInfo, + OfflineVaultTxInfo, VerifyEthTransactionOptions, aclMulticallMethodId, callFromParentMethodId, } from '@bitgo/abstract-eth'; +import { bip32 } from '@bitgo/secp256k1'; +import * as _ from 'lodash'; import { Eth } from './eth'; import { TransactionBuilder } from './lib'; @@ -546,6 +561,256 @@ export class Erc7984Token extends Eth { } } + /** + * Override gas limit for ERC-7984 confidential token recovery. + * Actual on-chain usage is ~506-530k gas based on testnet results. + * Default: 800,000 (comfortable buffer over observed usage). + */ + setGasLimit(userGasLimit?: number): number { + if (!userGasLimit) { + return 800000; + } + return super.setGasLimit(userGasLimit); + } + + /** + * Queries the encrypted balance handle for a wallet address via eth_call to the + * token contract's confidentialBalanceOf(address) function. + * + * @param walletAddress - the wallet contract address to query balance for + * @param apiKey - optional Etherscan API key + * @returns bytes32 encrypted handle (0x-prefixed, 66 chars). + * Note: handles are always non-zero even for zero balances (encrypted domain). + */ + async queryConfidentialBalance(walletAddress: string, apiKey?: string): Promise { + const methodSignature = optionalDeps.ethAbi.methodID('confidentialBalanceOf', ['address']); + const encodedArgs = optionalDeps.ethAbi.rawEncode(['address'], [walletAddress]); + const calldata = Buffer.concat([methodSignature, encodedArgs]).toString('hex'); + + const result = await this.recoveryBlockchainExplorerQuery( + { + chainid: this.getChainId().toString(), + module: 'proxy', + action: 'eth_call', + to: this.tokenContractAddress, + data: calldata, + tag: 'latest', + }, + apiKey + ); + + if (!result || !result.result) { + throw new Error( + `Could not obtain confidential balance for ${walletAddress} from token ${this.tokenContractAddress}` + ); + } + + const handle = result.result as string; + if (!handle.startsWith('0x') || handle.length !== 66) { + throw new Error(`Unexpected confidentialBalanceOf response format: ${handle}`); + } + + return handle; + } + + /** + * Builds a non-BitGo recovery transaction for ERC-7984 confidential tokens. + * + * Uses the no-proof handle sweep: confidentialTransfer(recipient, handle) with 2 args. + * No FHE decryption or proof generation is needed — the wallet transfers its entire + * encrypted balance handle without knowing the plaintext amount. + * + * The outer transaction shape is: + * tx.to = walletContractAddress + * tx.data = sendMultiSig(tokenContractAddr, 0, confidentialTransfer(recoveryDest, handle), ...) + */ + async recover(params: RecoverOptions): Promise { + if (params.isTss === true) { + throw new Error('ERC-7984 TSS recovery is not yet supported'); + } + + if (_.isUndefined(params.userKey)) { + throw new Error('missing userKey'); + } + + if (_.isUndefined(params.backupKey)) { + throw new Error('missing backupKey'); + } + + if (_.isUndefined(params.walletPassphrase) && !params.userKey.startsWith('xpub')) { + throw new Error('missing wallet passphrase'); + } + + if (_.isUndefined(params.walletContractAddress) || !this.isValidAddress(params.walletContractAddress)) { + throw new Error('invalid walletContractAddress'); + } + + if (_.isUndefined(params.recoveryDestination) || !this.isValidAddress(params.recoveryDestination)) { + throw new Error('invalid recoveryDestination'); + } + + const isKrsRecovery = getIsKrsRecovery(params); + const isUnsignedSweep = getIsUnsignedSweep(params); + + if (isKrsRecovery) { + checkKrsProvider(this, params.krsProvider, { checkCoinFamilySupport: false }); + } + + let userKey = params.userKey.replace(/\s/g, ''); + const backupKey = params.backupKey.replace(/\s/g, ''); + + const gasPrice = params.eip1559 + ? new optionalDeps.ethUtil.BN(params.eip1559.maxFeePerGas) + : new optionalDeps.ethUtil.BN(this.setGasPrice(params.gasPrice)); + const gasLimit = new optionalDeps.ethUtil.BN(this.setGasLimit(params.gasLimit)); + + let userPrv: string | undefined; + if (!isUnsignedSweep) { + if (!userKey.startsWith('xpub') && !userKey.startsWith('xprv')) { + try { + userKey = await this.bitgo.decrypt({ + input: userKey, + password: params.walletPassphrase, + }); + } catch (e) { + throw new Error(`Error decrypting user keychain: ${(e as Error).message}`); + } + } + userPrv = userKey; + } + + let backupKeyAddress: string; + let backupSigningKey: Buffer; + + if (isKrsRecovery || isUnsignedSweep) { + const backupHDNode = bip32.fromBase58(backupKey); + backupSigningKey = backupHDNode.publicKey; + backupKeyAddress = `0x${optionalDeps.ethUtil.publicToAddress(backupSigningKey, true).toString('hex')}`; + } else { + let backupPrv: string; + try { + backupPrv = await this.bitgo.decrypt({ + input: backupKey, + password: params.walletPassphrase, + }); + } catch (e) { + throw new Error(`Error decrypting backup keychain: ${(e as Error).message}`); + } + + const backupHDNode = bip32.fromBase58(backupPrv); + backupSigningKey = backupHDNode.privateKey as Buffer; + backupKeyAddress = `0x${optionalDeps.ethUtil.privateToAddress(backupSigningKey).toString('hex')}`; + } + + const backupKeyNonce = await this.getAddressNonce(backupKeyAddress, params.apiKey); + + const backupKeyBalance = await this.queryAddressBalance(backupKeyAddress, params.apiKey); + const totalGasNeeded = gasPrice.mul(gasLimit); + const weiToGwei = 10 ** 9; + if (backupKeyBalance.lt(totalGasNeeded)) { + throw new Error( + `Backup key address ${backupKeyAddress} has balance ${(backupKeyBalance / weiToGwei).toString()} Gwei.` + + `This address must have a balance of at least ${(totalGasNeeded / weiToGwei).toString()}` + + ` Gwei to perform recoveries. Try sending some ETH to this address then retry.` + ); + } + + // Get the encrypted balance handle (auto-query or user-provided) + let encryptedHandle: string; + if (params.encryptedHandle) { + encryptedHandle = params.encryptedHandle; + } else { + await new Promise((resolve) => setTimeout(resolve, 1000)); + encryptedHandle = await this.queryConfidentialBalance(params.walletContractAddress, params.apiKey); + } + + // Note: encrypted handles are always non-zero even for zero balances (encrypted domain). + // The transfer will execute but move 0 tokens if the wallet is empty. + + // Build the inner calldata: confidentialTransfer(recoveryDestination, handle) + const innerCalldata = buildConfidentialTransferByHandleCalldata(params.recoveryDestination, encryptedHandle); + + // For sendMultiSig: recipient is the token contract, amount is 0, data carries the confidentialTransfer calldata + const recipients = [ + { + address: this.tokenContractAddress, + amount: '0', + data: optionalDeps.ethUtil.stripHexPrefix(innerCalldata), + }, + ]; + + await new Promise((resolve) => setTimeout(resolve, 1000)); + const sequenceId = await this.querySequenceId(params.walletContractAddress, params.apiKey); + + let operationHash: string | undefined; + let signature: string | undefined; + if (!isUnsignedSweep) { + operationHash = this.getOperationSha3ForExecuteAndConfirm(recipients, this.getDefaultExpireTime(), sequenceId); + signature = Util.ethSignMsgHash(operationHash, Util.xprvToEthPrivateKey(userPrv!)); + + try { + Util.ecRecoverEthAddress(operationHash, signature); + } catch (e) { + throw new Error('Invalid signature'); + } + } + + const txInfo = { + recipient: recipients[0], + expireTime: this.getDefaultExpireTime(), + contractSequenceId: sequenceId, + operationHash: operationHash, + signature: signature ?? '', + gasLimit: gasLimit.toString(10), + }; + + const sendMethodArgs = this.getSendMethodArgs(txInfo); + const methodSignature = optionalDeps.ethAbi.methodID(this.sendMethodName, _.map(sendMethodArgs, 'type')); + const encodedArgs = optionalDeps.ethAbi.rawEncode(_.map(sendMethodArgs, 'type'), _.map(sendMethodArgs, 'value')); + const sendData = Buffer.concat([methodSignature, encodedArgs]); + + let tx = Eth.buildTransaction({ + to: params.walletContractAddress, + nonce: backupKeyNonce, + value: 0, + gasPrice: gasPrice, + gasLimit: gasLimit, + data: sendData, + eip1559: params.eip1559, + replayProtectionOptions: params.replayProtectionOptions, + }); + + if (isUnsignedSweep) { + return this.formatForOfflineVault( + txInfo, + tx, + userKey, + backupKey, + gasPrice, + gasLimit, + params.eip1559, + params.replayProtectionOptions, + params.apiKey + ) as any; + } + + if (!isKrsRecovery) { + tx = tx.sign(backupSigningKey); + } + + const signedTx: RecoveryInfo = { + id: optionalDeps.ethUtil.bufferToHex(tx.hash()), + tx: tx.serialize().toString('hex'), + }; + + if (isKrsRecovery) { + signedTx.backupKey = backupKey; + signedTx.coin = this.getChain(); + } + + return signedTx; + } + /** * Returns a DecryptionDelegationBuilder for constructing Zama ACL decryption * delegation transactions. diff --git a/modules/sdk-coin-eth/test/unit/erc7984Token.ts b/modules/sdk-coin-eth/test/unit/erc7984Token.ts index 348585cdb4..3753114e1f 100644 --- a/modules/sdk-coin-eth/test/unit/erc7984Token.ts +++ b/modules/sdk-coin-eth/test/unit/erc7984Token.ts @@ -6,8 +6,11 @@ * - verifyTransaction (TSS and multisig paths) * - verifyTransaction (confidential transfer / SendERC7984 path) * - decodeTokenAddressesFromDelegationCalldata (round-trip and forwarder-wrapped) + * - recover() (ERC-7984 confidential token recovery) + * - queryConfidentialBalance() */ import should from 'should'; +import sinon from 'sinon'; import { BitGoAPI } from '@bitgo/sdk-api'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { TransactionType, Wallet } from '@bitgo/sdk-core'; @@ -18,6 +21,7 @@ import { wrapInCallFromParent, decodeTokenAddressesFromDelegationCalldata, TransferBuilderERC7984, + confidentialTransferNoProofMethodId, } from '@bitgo/abstract-eth'; import { Erc7984Token } from '../../src/erc7984Token'; import { TransactionBuilder } from '../../src/lib'; @@ -1147,3 +1151,332 @@ describe('verifyTransaction – confidential consolidation (FlushERC7984Forwarde .should.be.rejectedWith(/parent address mismatch/); }); }); + +// --------------------------------------------------------------------------- +// recover() – ERC-7984 confidential token recovery +// --------------------------------------------------------------------------- + +describe('Erc7984Token – recover()', function () { + let bitgo: TestBitGoAPI; + let coin: Erc7984Token; + let explorerStub: sinon.SinonStub; + + const WALLET_CONTRACT = '0x8f977e912ef500548a0c3be6ddde9899f1199b81'; + const RECOVERY_DEST = '0x19645032c7f1533395d44a629462e751084d3e4c'; + const HANDLE = '0x' + 'ab'.repeat(32); + const ZERO_HANDLE = '0x' + '00'.repeat(32); + + const USER_KEY = testData.PRIVATE_KEY; + const BACKUP_KEY = testData.PRIVATE_KEY; + const WALLET_PASSPHRASE = 'test wallet passphrase'; + + // Encrypted keys for test (encrypt with the test passphrase) + let encryptedUserKey: string; + let encryptedBackupKey: string; + + before(async function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.initializeTestVars(); + register(bitgo); + coin = bitgo.coin('hteth:ctest1') as Erc7984Token; + + encryptedUserKey = await bitgo.encrypt({ input: USER_KEY, password: WALLET_PASSPHRASE }); + encryptedBackupKey = await bitgo.encrypt({ input: BACKUP_KEY, password: WALLET_PASSPHRASE }); + }); + + beforeEach(function () { + explorerStub = sinon.stub(coin, 'recoveryBlockchainExplorerQuery' as any); + }); + + afterEach(function () { + sinon.restore(); + }); + + function mockExplorerForRecovery(handle: string = HANDLE) { + explorerStub.callsFake(async (query: Record) => { + if (query.action === 'txlist') { + // getAddressNonce: return empty tx list (nonce = 0) + return { result: [] }; + } + if (query.action === 'balance') { + // queryAddressBalance: return enough ETH for gas + return { result: '100000000000000000' }; // 0.1 ETH + } + if (query.action === 'eth_call' && query.data?.startsWith('a0b7967b')) { + // querySequenceId: getNextSequenceId + return { result: '0x0000000000000000000000000000000000000000000000000000000000000005' }; + } + if (query.action === 'eth_call' && query.to === CTEST1_TOKEN_ADDRESS) { + // queryConfidentialBalance + return { result: handle }; + } + throw new Error(`Unexpected explorer query: ${JSON.stringify(query)}`); + }); + } + + it('should build a full balance sweep recovery transaction', async function () { + mockExplorerForRecovery(); + + const result = await coin.recover({ + userKey: encryptedUserKey, + backupKey: encryptedBackupKey, + walletPassphrase: WALLET_PASSPHRASE, + walletContractAddress: WALLET_CONTRACT, + recoveryDestination: RECOVERY_DEST, + }); + + should.exist(result); + result.should.have.property('id'); + result.should.have.property('tx'); + (result as any).tx.should.be.a.String(); + (result as any).tx.length.should.be.greaterThan(0); + }); + + it('should use user-provided encryptedHandle and skip auto-query', async function () { + const userHandle = '0x' + 'ff'.repeat(32); + + explorerStub.callsFake(async (query: Record) => { + if (query.action === 'txlist') { + return { result: [] }; + } + if (query.action === 'balance') { + return { result: '100000000000000000' }; + } + if (query.action === 'eth_call' && query.data?.startsWith('a0b7967b')) { + return { result: '0x0000000000000000000000000000000000000000000000000000000000000005' }; + } + if (query.action === 'eth_call' && query.to === CTEST1_TOKEN_ADDRESS) { + throw new Error('Should not query confidentialBalanceOf when handle is provided'); + } + throw new Error(`Unexpected explorer query: ${JSON.stringify(query)}`); + }); + + const result = await coin.recover({ + userKey: encryptedUserKey, + backupKey: encryptedBackupKey, + walletPassphrase: WALLET_PASSPHRASE, + walletContractAddress: WALLET_CONTRACT, + recoveryDestination: RECOVERY_DEST, + encryptedHandle: userHandle, + }); + + should.exist(result); + result.should.have.property('tx'); + }); + + it('should still build a recovery tx even with a zero-value handle (encrypted handles are always non-zero)', async function () { + mockExplorerForRecovery(ZERO_HANDLE); + + const result = await coin.recover({ + userKey: encryptedUserKey, + backupKey: encryptedBackupKey, + walletPassphrase: WALLET_PASSPHRASE, + walletContractAddress: WALLET_CONTRACT, + recoveryDestination: RECOVERY_DEST, + }); + + should.exist(result); + result.should.have.property('tx'); + }); + + it('should return unsigned sweep format when isUnsignedSweep is true', async function () { + mockExplorerForRecovery(); + + const result = await coin.recover({ + userKey: testData.PUBLIC_KEY, + backupKey: testData.PUBLIC_KEY, + walletContractAddress: WALLET_CONTRACT, + recoveryDestination: RECOVERY_DEST, + isUnsignedSweep: true, + }); + + should.exist(result); + result.should.have.property('tx'); + result.should.have.property('userKey'); + result.should.have.property('backupKey'); + }); + + it('should return KRS recovery format with backupKey and coin', async function () { + explorerStub.callsFake(async (query: Record) => { + if (query.action === 'txlist') { + return { result: [] }; + } + if (query.action === 'balance') { + return { result: '100000000000000000' }; + } + if (query.action === 'eth_call' && query.data?.startsWith('a0b7967b')) { + return { result: '0x0000000000000000000000000000000000000000000000000000000000000005' }; + } + if (query.action === 'eth_call' && query.to === CTEST1_TOKEN_ADDRESS) { + return { result: HANDLE }; + } + throw new Error(`Unexpected explorer query: ${JSON.stringify(query)}`); + }); + + const result = (await coin.recover({ + userKey: encryptedUserKey, + backupKey: testData.PUBLIC_KEY, + walletPassphrase: WALLET_PASSPHRASE, + walletContractAddress: WALLET_CONTRACT, + recoveryDestination: RECOVERY_DEST, + krsProvider: 'keyternal', + })) as any; + + should.exist(result); + result.should.have.property('tx'); + result.should.have.property('backupKey', testData.PUBLIC_KEY); + result.should.have.property('coin', 'hteth:ctest1'); + }); + + it('should throw when backup key has insufficient gas', async function () { + explorerStub.callsFake(async (query: Record) => { + if (query.action === 'txlist') { + return { result: [] }; + } + if (query.action === 'balance') { + // Very low balance, not enough for 12M gas + return { result: '100' }; + } + throw new Error(`Unexpected explorer query: ${JSON.stringify(query)}`); + }); + + await coin + .recover({ + userKey: encryptedUserKey, + backupKey: encryptedBackupKey, + walletPassphrase: WALLET_PASSPHRASE, + walletContractAddress: WALLET_CONTRACT, + recoveryDestination: RECOVERY_DEST, + }) + .should.be.rejectedWith(/has balance.*Gwei/); + }); + + it('should throw when userKey is missing', async function () { + await coin + .recover({ + userKey: undefined as any, + backupKey: encryptedBackupKey, + walletPassphrase: WALLET_PASSPHRASE, + walletContractAddress: WALLET_CONTRACT, + recoveryDestination: RECOVERY_DEST, + }) + .should.be.rejectedWith(/missing userKey/); + }); + + it('should throw when walletContractAddress is invalid', async function () { + await coin + .recover({ + userKey: encryptedUserKey, + backupKey: encryptedBackupKey, + walletPassphrase: WALLET_PASSPHRASE, + walletContractAddress: 'not-an-address', + recoveryDestination: RECOVERY_DEST, + }) + .should.be.rejectedWith(/invalid walletContractAddress/); + }); + + it('should produce tx with inner calldata matching confidentialTransfer selector 0x5bebed7e', async function () { + mockExplorerForRecovery(); + + const result = (await coin.recover({ + userKey: encryptedUserKey, + backupKey: encryptedBackupKey, + walletPassphrase: WALLET_PASSPHRASE, + walletContractAddress: WALLET_CONTRACT, + recoveryDestination: RECOVERY_DEST, + })) as any; + + should.exist(result.tx); + // The serialized tx contains the sendMultiSig calldata which wraps confidentialTransfer. + // The confidentialTransfer selector (5bebed7e) must appear somewhere in the serialized tx. + const selectorNoPrefix = confidentialTransferNoProofMethodId.slice(2); + result.tx.should.containEql(selectorNoPrefix); + }); +}); + +// --------------------------------------------------------------------------- +// queryConfidentialBalance() tests +// --------------------------------------------------------------------------- + +describe('Erc7984Token – queryConfidentialBalance()', function () { + let bitgo: TestBitGoAPI; + let coin: Erc7984Token; + let explorerStub: sinon.SinonStub; + + const WALLET_ADDRESS = '0x8f977e912ef500548a0c3be6ddde9899f1199b81'; + const HANDLE = '0x' + 'ab'.repeat(32); + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.initializeTestVars(); + register(bitgo); + coin = bitgo.coin('hteth:ctest1') as Erc7984Token; + }); + + beforeEach(function () { + explorerStub = sinon.stub(coin, 'recoveryBlockchainExplorerQuery' as any); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('should return a valid bytes32 handle from eth_call', async function () { + explorerStub.resolves({ result: HANDLE }); + + const result = await coin.queryConfidentialBalance(WALLET_ADDRESS); + result.should.equal(HANDLE); + }); + + it('should verify the eth_call is made to the correct token contract', async function () { + explorerStub.resolves({ result: HANDLE }); + + await coin.queryConfidentialBalance(WALLET_ADDRESS); + + explorerStub.calledOnce.should.be.true(); + const callArgs = explorerStub.firstCall.args[0]; + callArgs.should.have.property('action', 'eth_call'); + callArgs.should.have.property('to', CTEST1_TOKEN_ADDRESS); + callArgs.should.have.property('module', 'proxy'); + }); + + it('should throw when explorer returns no result', async function () { + explorerStub.resolves({ result: null }); + + await coin.queryConfidentialBalance(WALLET_ADDRESS).should.be.rejectedWith(/Could not obtain confidential balance/); + }); + + it('should throw when response format is unexpected (not 66 chars)', async function () { + explorerStub.resolves({ result: '0xabcd' }); + + await coin + .queryConfidentialBalance(WALLET_ADDRESS) + .should.be.rejectedWith(/Unexpected confidentialBalanceOf response format/); + }); +}); + +// --------------------------------------------------------------------------- +// setGasLimit() override tests +// --------------------------------------------------------------------------- + +describe('Erc7984Token – setGasLimit()', function () { + let bitgo: TestBitGoAPI; + let coin: Erc7984Token; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.initializeTestVars(); + register(bitgo); + coin = bitgo.coin('hteth:ctest1') as Erc7984Token; + }); + + it('should return 800000 as default gas limit', function () { + const gasLimit = coin.setGasLimit(); + gasLimit.should.equal(800000); + }); + + it('should accept user-provided gas limit', function () { + const gasLimit = coin.setGasLimit(1000000); + gasLimit.should.equal(1000000); + }); +});