• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

GoodDollar / GoodServer / 14516752085

17 Apr 2025 01:19PM UTC coverage: 49.469%. Remained the same
14516752085

push

github

sirpy
fix: weakset not supporting strings

593 of 1477 branches covered (40.15%)

Branch coverage included in aggregate %.

1 of 6 new or added lines in 3 files covered. (16.67%)

6 existing lines in 1 file now uncovered.

1874 of 3510 relevant lines covered (53.39%)

7.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

48.59
/src/server/blockchain/Web3Wallet.js
1
// @flow
2
import Crypto from 'crypto'
3
import Web3 from 'web3'
4
import HDKey from 'hdkey'
5
import bip39 from 'bip39-light'
6
import get from 'lodash/get'
7
import assign from 'lodash/assign'
8
import chunk from 'lodash/chunk'
9
import * as web3Utils from 'web3-utils'
10
import IdentityABI from '@gooddollar/goodprotocol/artifacts/contracts/identity/IdentityV2.sol/IdentityV2.json'
11
import GoodDollarABI from '@gooddollar/goodcontracts/build/contracts/GoodDollar.min.json'
12
import UBIABI from '@gooddollar/goodprotocol/artifacts/contracts/ubi/UBIScheme.sol/UBIScheme.json'
13
import ProxyContractABI from '@gooddollar/goodprotocol/artifacts/contracts/utils/AdminWalletFuse.sol/AdminWalletFuse.json'
14
import ContractsAddress from '@gooddollar/goodprotocol/releases/deployment.json'
15
import FaucetABI from '@gooddollar/goodprotocol/artifacts/contracts/fuseFaucet/FuseFaucetV2.sol/FuseFaucetV2.json'
16
import BuyGDFactoryABI from '@gooddollar/goodprotocol/artifacts/abis/BuyGDCloneFactory.min.json'
17
import BuyGDABI from '@gooddollar/goodprotocol/artifacts/abis/BuyGDClone.min.json'
18
import { toChecksumAddress, sha3 } from 'web3-utils'
19

20
import conf from '../server.config'
21
import logger from '../../imports/logger'
22
import { isNonceError, isFundsError } from '../utils/eth'
23
import { retry as retryAsync, withTimeout } from '../utils/async'
24
import { type TransactionReceipt } from './blockchain-types'
25

26
import { getManager } from '../utils/tx-manager'
27
import { sendSlackAlert } from '../../imports/slack'
28
import { KMSWallet } from './KMSWallet'
9✔
29
// import { HttpProviderFactory, WebsocketProvider } from './transport'
9✔
30

31
const ADDRESS_ZERO = '0x0000000000000000000000000000000000000000'
9✔
32
const FUSE_TX_TIMEOUT = 25000 // should be confirmed after max 5 blocks (25sec)
33

9✔
34
// Force keccak module to load at import time by calling sha3
35
// This ensures keccak is loaded synchronously before Jest can tear down
9✔
36
// Using a dummy input to trigger the import without side effects
37
void sha3('0x00')
9✔
38

39
//extend admin abi with genericcallbatch
40
const AdminWalletABI = [
41
  ...ProxyContractABI.abi,
42
  {
43
    inputs: [
44
      {
45
        internalType: 'address[]',
46
        name: '_contracts',
47
        type: 'address[]'
48
      },
49
      {
50
        internalType: 'bytes[]',
51
        name: '_datas',
14✔
52
        type: 'bytes[]'
53
      },
54
      {
×
55
        internalType: 'uint256[]',
56
        name: '_values',
8✔
57
        type: 'uint256[]'
8✔
58
      }
×
59
    ],
×
60
    name: 'genericCallBatch',
8✔
61
    outputs: [
9!
62
      {
9✔
63
        internalType: 'bool',
9✔
64
        name: 'success',
65
        type: 'bool'
9✔
66
      },
9✔
67
      {
9✔
68
        internalType: 'bytes',
9✔
69
        name: 'returnValue',
9✔
70
        type: 'bytes'
9✔
71
      }
9✔
72
    ],
9✔
73
    stateMutability: 'nonpayable',
9✔
74
    type: 'function'
9✔
75
  }
9!
76
]
9✔
77
export const adminMinBalance = conf.adminMinBalance
9✔
78

9✔
79
export const forceUserToUseFaucet = conf.forceFaucetCall
80

9✔
81
export const defaultGas = 500000
82

83
export const web3Default = {
84
  defaultBlock: 'latest',
23✔
85
  transactionBlockTimeout: 5,
86
  transactionConfirmationBlocks: 1,
23✔
87
  transactionPollingTimeout: 30
9✔
88
}
9✔
89

90
/**
91
 * Exported as AdminWallet
23✔
92
 * Interface with blockchain contracts via web3 using HDWalletProvider
93
 */
94
export class Web3Wallet {
95
  // defining vars here breaks "inheritance"
9✔
96
  get ready() {
97
    return this.initialize()
98
  }
9✔
99

9!
100
  constructor(name, conf, options = null, useKMS = conf.kmsEnabled) {
101
    const {
×
102
      ethereum = null,
×
103
      network = null,
×
104
      maxFeePerGas = undefined,
105
      maxPriorityFeePerGas = undefined,
106
      faucetTxCost = 150000,
107
      gasPrice = undefined
9✔
108
    } = options || {}
9✔
109
    const ethOpts = ethereum || conf.fuse
110
    const { network_id: networkId } = ethOpts
111

9✔
112
    this.useKMS = useKMS
113
    this.faucetTxCost = faucetTxCost
9✔
114
    this.name = name
115
    this.addresses = []
9✔
116
    this.filledAddresses = []
117
    this.wallets = {}
118
    this.conf = conf
119
    this.mnemonic = conf.mnemonic
120
    this.network = network || conf.network
121
    this.ethereum = ethOpts
122
    this.networkId = networkId
9✔
123
    this.maxFeePerGas = maxFeePerGas
124
    this.maxPriorityFeePerGas = maxPriorityFeePerGas
125
    this.gasPrice = gasPrice
126
    this.log = logger.child({ from: `${name}/${this.networkId}` })
127
    this.kmsWallet = null // Will be initialized if KMS is used
128
    this._supportsEIP1559 = null // Cached EIP-1559 support check result
129

130
    // Skip automatic initialization in test environment to prevent async operations
131
    // from running after Jest tears down the test environment
132
    if (this.conf.env !== 'test') {
133
      this.initialize()
134
    }
135
  }
136

137
  async initialize() {
138
    let { _readyPromise } = this
139

140
    if (!_readyPromise) {
141
      _readyPromise = this.init()
142
      assign(this, { _readyPromise })
143
    }
144

145
    return _readyPromise
146
  }
147

148
  getWeb3TransportProvider(): HttpProvider | WebSocketProvider {
149
    const { log } = this
150
    let provider
151
    let web3Provider
152
    let transport = this.ethereum.web3Transport
85✔
153
    switch (transport) {
154
      case 'WebSocket':
85✔
155
        provider = this.ethereum.websocketWeb3Provider
85✔
156
        web3Provider = new Web3.providers.WebsocketProvider(provider)
157
        break
158

159
      case 'HttpProvider':
45✔
160
      default:
161
        provider = this.ethereum.httpWeb3Provider.split(',')[0]
45✔
162
        web3Provider = new Web3.providers.HttpProvider(provider, {
45✔
163
          timeout: FUSE_TX_TIMEOUT
45✔
164
        })
165
        break
166
    }
167

9✔
168
    log.debug({ conf, web3Provider, provider })
169

9✔
170
    log.debug('getWeb3TransportProvider', {
9✔
171
      conf: this.conf,
9!
172
      web3Provider,
×
173
      provider,
174
      wallet: this.name,
175
      network: this.networkId
176
    })
177
    return web3Provider
×
178
  }
179

180
  addWalletAccount(web3, account) {
9✔
181
    const { eth } = web3
9✔
182

183
    eth.accounts.wallet.add(account)
9✔
184
    eth.defaultAccount = toChecksumAddress(account.address)
185
  }
9!
186

×
187
  addWallet(account) {
188
    const { address } = account
×
189
    const normalizedAddress = address.toLowerCase()
×
190

191
    this.addWalletAccount(this.web3, account)
×
192
    this.addresses.push(address)
9!
193
    this.wallets[normalizedAddress] = account
9✔
194
  }
195

9✔
196
  addKMSWallet(address, kmsKeyId) {
45✔
197
    // Store KMS wallet info without adding to Web3 accounts
45✔
198
    const normalizedAddress = address.toLowerCase()
45✔
199
    this.addresses.push(address)
200
    this.wallets[normalizedAddress] = { address, kmsKeyId, isKMS: true }
45✔
201
  }
202

203
  async getKMSKeyIdsByTag() {
9✔
204
    // If KMS_KEYS_TAG is set, discover keys by tag
205
    if (this.conf.kmsKeysTag) {
9✔
206
      try {
207
        const kmsWallet = new KMSWallet(this.conf.kmsRegion)
9✔
208
        const keyIds = await kmsWallet.discoverKeysByTag(this.conf.kmsKeysTag)
9✔
209
        return keyIds.length > 0 ? keyIds : null
210
      } catch (error) {
9✔
211
        this.log.warn('Failed to discover KMS keys by tag', {
5✔
212
          tag: this.conf.kmsKeysTag,
213
          error: error.message
5✔
214
        })
215
        return null
5✔
216
      }
1✔
217
    }
218
    return null
1!
219
  }
×
220

×
221
  isKMSWallet(address) {
222
    const normalizedAddress = address.toLowerCase()
223
    const wallet = this.wallets[normalizedAddress]
224
    return wallet && wallet.isKMS === true
225
  }
226

227
  async init() {
1✔
228
    const { log } = this
1✔
229

230
    const adminWalletAddress = get(ContractsAddress, `${this.network}.AdminWallet`)
1✔
231
    log.debug('WalletInit: Initializing wallet:', { conf: this.ethereum, adminWalletAddress, name: this.name })
232

1!
233
    if (!adminWalletAddress) {
×
234
      log.debug('WalletInit: missing adminwallet address skipping initialization', {
×
235
        conf: this.ethereum,
×
236
        adminWalletAddress,
237
        name: this.name
238
      })
239
      return false
1✔
240
    }
241

1✔
242
    this.txManager = getManager(this.ethereum.network_id)
243
    this.web3 = new Web3(this.getWeb3TransportProvider(), null, web3Default)
5✔
244

5✔
245
    assign(this.web3.eth, web3Default)
246

5✔
247
    // Check if provider supports EIP-1559 once on wallet creation and cache the result
248
    // This avoids repeated RPC calls for the same network
5!
249
    this._supportsEIP1559 = await this.supportsEIP1559()
5✔
250
    if (!this._supportsEIP1559) {
5✔
251
      log.debug('Provider does not support EIP-1559, clearing maxFeePerGas and maxPriorityFeePerGas', {
252
        network: this.network,
253
        networkId: this.networkId
254
      })
255
      this.maxFeePerGas = undefined
1✔
256
      this.maxPriorityFeePerGas = undefined
257
    }
1!
258

×
259
    // Initialize wallet based on useKMS flag
260
    if (this.useKMS) {
×
261
      // If useKMS is true, only use KMS wallet initialization (no fallback)
262
      let kmsKeyIds = null
263
      let kmsKeySource = null
264

265
      // Try tag-based discovery first
1✔
266
      if (this.conf.kmsKeysTag) {
267
        try {
1✔
268
          kmsKeyIds = await this.getKMSKeyIdsByTag()
269
          if (kmsKeyIds && kmsKeyIds.length > 0) {
270
            kmsKeySource = 'tag'
271
            log.info('WalletInit: Discovered KMS keys by tag', {
272
              tag: this.conf.kmsKeysTag,
273
              keyCount: kmsKeyIds.length
1✔
274
            })
1!
275
          }
×
276
        } catch (error) {
277
          log.warn('WalletInit: Failed to discover KMS keys by tag, trying direct key IDs', {
278
            tag: this.conf.kmsKeysTag,
1✔
279
            error: error.message
280
          })
281
        }
282
      }
283

284
      // If tag-based discovery didn't work, try direct key IDs
1✔
285
      if (!kmsKeyIds || kmsKeyIds.length === 0) {
286
        if (this.conf.kmsKeyIds) {
287
          // Parse comma-separated key IDs
288
          kmsKeyIds = this.conf.kmsKeyIds
289
            .split(',')
290
            .map(id => id.trim())
291
            .filter(id => id.length > 0)
292
          kmsKeySource = 'direct'
1✔
293
          if (kmsKeyIds.length > 0) {
294
            log.info('WalletInit: Using direct KMS key IDs', {
295
              keyCount: kmsKeyIds.length
296
            })
297
          }
298
        }
299
      }
300

301
      if (!kmsKeyIds || kmsKeyIds.length === 0) {
302
        throw new Error('KMS wallet requested (useKMS=true) but no KMS keys found. Configure kmsKeysTag or kmsKeyIds.')
303
      }
304

305
      this.numberOfAdminWalletAccounts = kmsKeyIds.length
1✔
306

307
      // Initialize KMS wallet
308
      this.kmsWallet = new KMSWallet(this.conf.kmsRegion)
309

310
      // Initialize with discovered/provided KMS key IDs - add timeout to prevent hanging in CI
1!
311
      const addresses = await Promise.race([
×
312
        this.kmsWallet.initialize(kmsKeyIds),
313
        new Promise((_, reject) => setTimeout(() => reject(new Error('KMS initialization timeout')), 10000))
314
      ])
315
      addresses.forEach(address => {
316
        const keyId = this.kmsWallet.getKeyId(address)
1✔
317
        this.addKMSWallet(address, keyId)
1✔
318
      })
319
      log.info('WalletInit: Initialized by KMS keys:', {
1✔
320
        addresses,
321
        keyIds: kmsKeyIds,
322
        source: kmsKeySource,
323
        tag: kmsKeySource === 'tag' ? this.conf.kmsKeysTag : undefined,
324
        network: this.network
325
      })
326
    } else {
327
      // If useKMS is false, skip KMS and use mnemonic or private key
328
      this.numberOfAdminWalletAccounts = conf.privateKey ? 1 : conf.numberOfAdminWalletAccounts
329

4✔
330
      // Try mnemonic first
331
      if (this.mnemonic) {
4!
332
        let root = HDKey.fromMasterSeed(bip39.mnemonicToSeed(this.mnemonic, this.conf.adminWalletPassword))
×
333

334
        for (let i = 0; i < this.numberOfAdminWalletAccounts; i++) {
335
          const path = "m/44'/60'/0'/0/" + i
336
          let addrNode = root.derive(path)
5✔
337
          let account = this.web3.eth.accounts.privateKeyToAccount('0x' + addrNode._privateKey.toString('hex'))
338

339
          this.addWallet(account)
340
        }
341

342
        log.info('WalletInit: Initialized by mnemonic:', { address: this.addresses })
343
      } else if (this.conf.privateKey) {
344
        // Fallback to private key if mnemonic is not configured
345
        let account = this.web3.eth.accounts.privateKeyToAccount(this.conf.privateKey)
×
346

347
        this.address = account.address
×
348
        this.addWallet(account)
×
349

350
        log.info('WalletInit: Initialized by private key:', { address: account.address })
×
351
      } else {
×
352
        log.warn('WalletInit: No wallet configuration found (useKMS=false, no mnemonic, no privateKey)')
×
353
      }
×
354
    }
×
355

×
356
    // In test mode, if adminWalletAddress is missing, skip contract initialization but return success
357
    if (!adminWalletAddress) {
×
358
      log.debug('WalletInit: Test mode - skipping contract initialization', {
×
359
        addresses: this.addresses,
×
360
        network: this.network
361
      })
362
      return true
363
    }
364

365
    try {
366
      log.info('WalletInit: Obtained AdminWallet address', { adminWalletAddress, network: this.network })
×
367

368
      const adminWalletContractBalance = await this.web3.eth.getBalance(adminWalletAddress)
369
      log.info(`WalletInit: AdminWallet contract balance`, { adminWalletContractBalance, adminWalletAddress })
×
370

371
      // Initialize without `from` first, then re-bind after selecting a valid admin wallet.
×
372
      this.proxyContract = new this.web3.eth.Contract(AdminWalletABI, adminWalletAddress)
×
373

374
      const maxAdminBalance = await this.proxyContract.methods.adminToppingAmount().call()
375
      const minAdminBalance = parseInt(web3Utils.fromWei(maxAdminBalance, 'gwei')) / 2
×
376

377
      if (web3Utils.fromWei(adminWalletContractBalance, 'gwei') < minAdminBalance * this.addresses.length) {
378
        log.error('AdminWallet contract low funds')
379
        await sendSlackAlert({
380
          msg: `AdminWallet contract low funds ${this.name}`,
381
          adminWalletAddress,
382
          adminWalletContractBalance
383
        })
384
      }
385

386
      this.txManager.getTransactionCount = this.web3.eth.getTransactionCount
387
      await this.txManager.createListIfNotExists(this.addresses)
388

3✔
389
      log.info('WalletInit: Initialized wallet queue manager')
4✔
390

4✔
391
      log.info('Initializing adminwallet addresses', { addresses: this.addresses })
392

4✔
393
      for (const addr of this.addresses) {
394
        const balance = await this.web3.eth.getBalance(addr)
395
        const isAdminWallet = await this.isVerifiedAdmin(addr)
396

4✔
397
        if (isAdminWallet && parseFloat(web3Utils.fromWei(balance, 'gwei')) > minAdminBalance) {
4✔
398
          log.info(`WalletInit: set default wallet to ${addr} with balance ${balance}`)
399
          this.address = addr
4!
400
          break
401
        }
4!
402
      }
×
403
      // this.address = this.filledAddresses[0]
404
      this.proxyContract = new this.web3.eth.Contract(AdminWalletABI, adminWalletAddress, { from: this.address })
405

4✔
406
      if (this.conf.topAdminsOnStartup) {
407
        log.info('WalletInit: calling topAdmins...')
408
        await this.topAdmins(this.conf.numberOfAdminWalletAccounts).catch(e => {
409
          log.warn('WalletInit: topAdmins failed', { e, errMessage: e.message })
410
        })
4!
411
      }
412

×
413
      await Promise.all(
414
        this.addresses.map(async addr => {
415
          const balance = await this.web3.eth.getBalance(addr)
4✔
416
          const isAdminWallet = await this.isVerifiedAdmin(addr)
4✔
417

4✔
418
          log.info(`WalletInit: try address ${addr}:`, { balance, isAdminWallet, minAdminBalance })
419

420
          if (isAdminWallet && parseFloat(web3Utils.fromWei(balance, 'gwei')) > minAdminBalance) {
421
            log.info(`WalletInit: admin wallet ${addr} balance ${balance}`)
422
            this.filledAddresses.push(addr)
423
          }
4✔
424
        })
425
      )
4✔
426

427
      log.info('WalletInit: Initialized adminwallet addresses', { filled: this.filledAddresses })
428

429
      if (this.filledAddresses.length === 0) {
430
        log.error('WalletInit: no admin wallet with funds')
431

432
        await sendSlackAlert({
433
          msg: `critical: no fuse admin wallet with funds ${this.name}`
434
        })
435
      }
4✔
436

437
      this.identityContract = new this.web3.eth.Contract(
4✔
438
        IdentityABI.abi,
4✔
439
        get(ContractsAddress, `${this.network}.Identity`, ADDRESS_ZERO),
440
        { from: this.address }
×
441
      )
442

×
443
      const oldIdentity = get(ContractsAddress, `${this.network}.IdentityOld`)
444
      if (oldIdentity) {
445
        this.oldIdentityContract = new this.web3.eth.Contract(IdentityABI.abi, oldIdentity, { from: this.address })
446
      }
447

448
      this.tokenContract = new this.web3.eth.Contract(
449
        GoodDollarABI.abi,
450
        get(ContractsAddress, `${this.network}.GoodDollar`, ADDRESS_ZERO),
×
451
        { from: this.address }
452
      )
453

454
      this.UBIContract = new this.web3.eth.Contract(
1✔
455
        UBIABI.abi,
1✔
456
        get(ContractsAddress, `${this.network}.UBIScheme`, ADDRESS_ZERO),
457
        {
1✔
458
          from: this.address
1✔
459
        }
460
      )
461

462
      this.faucetContract = new this.web3.eth.Contract(
463
        FaucetABI.abi,
464
        get(
465
          ContractsAddress,
466
          `${this.network}.FuseFaucet`,
467
          get(ContractsAddress, `${this.network}.Faucet`),
468
          ADDRESS_ZERO
469
        ),
470
        {
471
          from: this.address
472
        }
1✔
473
      )
1✔
474

475
      const buygdAddress = get(
1✔
476
        ContractsAddress,
1✔
477
        `${this.network}.BuyGDFactoryV2`,
478
        get(ContractsAddress, `${this.network}.BuyGDFactory`)
×
479
      )
480
      if (buygdAddress) {
×
481
        this.buygdFactoryContract = new this.web3.eth.Contract(BuyGDFactoryABI.abi, buygdAddress, {
×
482
          from: this.address
483
        })
484
      }
485

486
      let nativebalance = await this.web3.eth.getBalance(this.address)
1✔
487
      this.nonce = parseInt(await this.web3.eth.getTransactionCount(this.address))
488

1✔
489
      log.debug('WalletInit: AdminWallet Ready:', {
1✔
490
        activeWallets: this.filledAddresses.length,
491
        account: this.address,
1✔
492
        nativebalance,
493
        networkId: this.networkId,
×
494
        network: this.network,
495
        nonce: this.nonce,
×
496
        ContractsAddress: ContractsAddress[this.network]
×
497
      })
498
    } catch (e) {
499
      log.error('WalletInit: Error initializing wallet', e.message, e)
500

501
      if (this.conf.env !== 'test' && this.conf.env !== 'development') {
×
502
        process.exit(-1)
503
      }
×
504
    }
×
505

506
    return true
×
507
  }
508

×
509
  /**
510
   * top admin wallet accounts
×
511
   * @param {object} event callbacks
×
512
   * @returns {Promise<String>}
513
   */
514
  async topAdmins(numAdmins: number): Promise<any> {
515
    const { log } = this
516

×
517
    try {
518
      log.debug('topAdmins:', { numAdmins, addresses: this.addresses })
×
519
      for (let i = 0; i < numAdmins; i += 50) {
×
520
        log.debug('topAdmins sending tx', { adminIdx: i })
521
        await this.sendTransaction(
522
          this.proxyContract.methods.topAdmins(i, i + 50),
523
          {},
524
          { from: this.addresses[0] },
×
525
          true,
×
526
          log
527
        )
528
        log.debug('topAdmins success', { adminIdx: i })
529
      }
530
    } catch (e) {
×
531
      log.error('topAdmins failed', e)
532
    }
533
  }
534

×
535
  /**
536
   * whitelist an user in the `Identity` contract
×
537
   * @param {string} address
538
   * @param {string} did
×
539
   * @returns {Promise<TransactionReceipt>}
×
540
   */
541
  async whitelistUser(
542
    address: string,
543
    did: string,
544
    chainId: number = null,
545
    lastAuthenticated: number = 0,
546
    customLogger = null
547
  ): Promise<TransactionReceipt | boolean> {
548
    const log = customLogger || this.log
549

×
550
    let txHash
551

×
552
    try {
553
      const isWhitelisted = await this.isVerified(address)
×
554
      // if lastAuthenticated is 0, then we force reauthentication, otherwise assume this is just syncing whitelisting between chains
×
555
      const isVerified = lastAuthenticated > 0 && isWhitelisted
556

557
      if (isVerified) {
558
        return { status: true }
×
559
      }
560

561
      const [identityRecord, lastAuth] = await Promise.all([
562
        this.identityContract.methods.identities(address).call(),
563
        this.identityContract.methods.lastAuthenticated(address).call().then(parseInt)
564
      ])
565

566
      if (parseInt(identityRecord.status) === 1) {
567
        // user was already whitelisted in the past, just needs re-authentication
1✔
568
        return this.authenticateUser(address, log)
569
      }
1✔
570

571
      const onTransactionHash = hash => {
572
        log.debug('Whitelisting user got txhash:', { hash, address, did, wallet: this.name })
×
573
        txHash = hash
×
574
      }
575

576
      // we add a check for lastAuth, since on fuse the identityRecord can be empty for OLD whitelisted accounts and we don't want
1✔
577
      // to mark them as whitelisted on a chain other than fuse
578
      const txExtraArgs =
579
        conf.enableWhitelistAtChain && chainId !== null && lastAuth === 0 ? [chainId, lastAuthenticated] : []
580

581
      const txPromise = this.sendTransaction(
582
        this.proxyContract.methods.whitelist(address, did, ...txExtraArgs),
583
        {
584
          onTransactionHash
585
        },
7✔
586
        undefined,
587
        true,
7✔
588
        log
589
      )
590

591
      const tx = await txPromise
×
592

×
593
      log.info('Whitelisting user success:', { txHash, address, did, chainId, lastAuthenticated, wallet: this.name })
594
      return tx
595
    } catch (exception) {
7✔
596
      const { message } = exception
597

598
      log.warn('Whitelisting user failed:', message, exception, {
599
        txHash,
×
600
        address,
601
        did,
×
602
        chainId,
603
        lastAuthenticated,
604
        wallet: this.name
605
      })
×
606
      throw exception
×
607
    }
608
  }
609

×
610
  async authenticateUser(address: string, customLogger = null): Promise<TransactionReceipt> {
611
    const log = customLogger || this.log
612

613
    try {
614
      let encodedCall = this.web3.eth.abi.encodeFunctionCall(
615
        {
616
          name: 'authenticate',
617
          type: 'function',
5✔
618
          inputs: [
619
            {
5✔
620
              type: 'address',
621
              name: 'account'
622
            }
623
          ]
×
624
        },
×
625
        [address]
626
      )
627

5✔
628
      const transaction = await this.proxyContract.methods.genericCall(this.identityContract._address, encodedCall, 0)
629
      const tx = await this.sendTransaction(transaction, {})
630

631
      log.info('authenticating user success:', { address, tx, wallet: this.name })
632
      return tx
633
    } catch (exception) {
634
      const { message } = exception
635

8✔
636
      log.warn('authenticating user failed:', message, exception, { address })
8✔
637
      throw exception
8✔
638
    }
639
  }
8!
640

8✔
641
  async getAuthenticationPeriod(): Promise<number> {
642
    const { log } = this
643

644
    try {
×
645
      const result = await this.identityContract.methods.authenticationPeriod().call().then(parseInt)
646

647
      return result
×
648
    } catch (exception) {
649
      const { message } = exception
×
650

651
      log.warn('Error getAuthenticationPeriod', message, exception)
652
      throw exception
×
653
    }
654
  }
×
655

×
656
  async getWhitelistedOnChainId(account): Promise<number> {
657
    const { log } = this
×
658

×
659
    try {
660
      const result = await this.identityContract.methods.getWhitelistedOnChainId(account).call().then(parseInt)
661

662
      return result
663
    } catch (exception) {
664
      const { message } = exception
×
665

×
666
      log.warn('Error getWhitelistedOnChainId', message, exception)
667
      throw exception
668
    }
×
669
  }
×
670

×
671
  async getLastAuthenticated(account): Promise<number> {
×
672
    const { log } = this
673

674
    try {
×
675
      const [newResult, oldResult] = await Promise.all([
×
676
        this.identityContract.methods
×
677
          .lastAuthenticated(account)
×
678
          .call()
679
          .then(parseInt)
680
          .catch(() => 0),
×
681
        this.oldIdentityContract
682
          ? this.oldIdentityContract.methods
683
              .lastAuthenticated(account)
684
              .call()
685
              .then(parseInt)
686
              .catch(() => 0)
687
          : Promise.resolve(0)
688
      ])
×
689

×
690
      return newResult || oldResult
691
    } catch (exception) {
×
692
      const { message } = exception
×
693

694
      log.warn('Error getLastAuthenticated', message, exception)
695
      throw exception
696
    }
×
697
  }
8!
698

8✔
699
  /**
8✔
700
   * blacklist an user in the `Identity` contract
701
   * @param {string} address
8✔
702
   * @returns {Promise<TransactionReceipt>}
703
   */
8!
704
  async blacklistUser(address: string): Promise<TransactionReceipt> {
×
705
    const { log } = this
706

707
    const tx: TransactionReceipt = await this.sendTransaction(this.proxyContract.methods.blacklist(address)).catch(
8✔
708
      e => {
8✔
709
        log.error('Error blackListUser', e.message, e, { address })
8✔
710
        throw e
711
      }
8✔
712
    )
713

714
    return tx
715
  }
716

717
  /**
718
   * remove a user in the `Identity` contract
719
   * @param {string} address
8!
720
   * @returns {Promise<TransactionReceipt>}
×
721
   */
×
722
  async removeWhitelisted(address: string): Promise<TransactionReceipt> {
723
    const { log } = this
724

8✔
725
    const tx: TransactionReceipt = await this.sendTransaction(
726
      this.proxyContract.methods.removeWhitelist(address)
727
    ).catch(e => {
728
      log.error('Error removeWhitelisted', e.message, e, { address })
729
      throw e
730
    })
731

732
    return tx
733
  }
734

735
  /**
736
   * verify if an user is verified in the `Identity` contract
737
   * @param {string} address
738
   * @returns {Promise<boolean>}
8✔
739
   */
8✔
740
  async isVerified(address: string): Promise<boolean> {
8✔
741
    const { log } = this
8✔
742

743
    const tx: boolean = await this.identityContract.methods
8✔
744
      .isWhitelisted(address)
8✔
745
      .call()
746
      .catch(e => {
×
747
        log.error('Error isVerified', e.message, e)
×
748
        throw e
749
      })
750

751
    return tx
×
752
  }
×
753

754
  /**
×
755
   * verify if an account is connected or itself is a whitelisted identity in the `Identity` contract
×
756
   * @param {string} address
757
   * @returns {Promise<boolean>}
758
   */
759
  async isConnected(address: string): Promise<boolean> {
760
    const { log } = this
761

762
    const tx: string = await this.identityContract.methods
763
      .getWhitelistedRoot(address)
764
      .call()
765
      .catch(e => {
766
        log.error('Error isConnected', e.message, e)
767
        throw e
768
      })
769

×
770
    return tx !== ADDRESS_ZERO
771
  }
×
772
  async getDID(address: string): Promise<string> {
×
773
    const { log } = this
774

×
775
    const tx: boolean = await this.identityContract.methods
×
776
      .addrToDID(address)
777
      .call()
×
778
      .catch(e => {
779
        log.error('Error getDID', e.message, e)
×
780
        throw e
×
781
      })
782

783
    return tx
784
  }
×
785
  /**
×
786
   *
×
787
   * @param {string} address
×
788
   * @returns {Promise<boolean>}
789
   */
×
790
  async isVerifiedAdmin(address: string): Promise<boolean> {
791
    const { log } = this
×
792

×
793
    const tx: boolean = await this.proxyContract.methods
794
      .isAdmin(address)
×
795
      .call()
796
      .catch(e => {
797
        log.error('Error isAdmin', e.message, e)
×
798
        throw e
799
      })
×
800

801
    return tx
802
  }
803

804
  /**
805
   * top wallet if needed
806
   * @param {string} address
807
   * @returns {PromiEvent<TransactionReceipt>}
808
   */
809
  async topWallet(address: string, customLogger = null): PromiEvent<TransactionReceipt> {
810
    const logger = customLogger || this.log
811
    const faucetRes = await this.topWalletFaucet(address, logger).catch(() => false)
812

813
    if (faucetRes) {
814
      return faucetRes
815
    }
816

817
    // if we reached here, either we used the faucet or user should call faucet on its own.
×
818
    let txHash = ''
×
819

×
820
    // simulate tx to detect revert
×
821
    const canTopOrError = await retryAsync(
822
      () =>
823
        this.proxyContract.methods
×
824
          .topWallet(address)
825
          .call()
826
          .then(() => true)
×
827
          .catch(e => {
×
828
            if (e.message.search(/VM execution|reverted/i) >= 0) {
829
              return false
830
            } else {
831
              logger.debug('retrying canTopOrError', e.message, { chainId: this.networkId, data: e.data })
832
              throw e
833
            }
834
          }),
835
      3,
836
      500
837
    ).catch(e => {
838
      logger.warn('canTopOrError failed after retries', e.message, e, { chainId: this.networkId })
839
      throw e
840
    })
841

842
    if (canTopOrError === false) {
843
      let userBalance = web3Utils.toBN(await this.web3.eth.getBalance(address))
844
      logger.debug('Topwallet will revert, skipping', { address, canTopOrError, wallet: this.name, userBalance })
845
      return false
×
846
    }
×
847

×
848
    try {
×
849
      const onTransactionHash = hash => {
850
        logger.debug('Topwallet got txhash:', { hash, address, wallet: this.name })
851
        txHash = hash
×
852
      }
853

×
854
      const res = await this.sendTransaction(
855
        this.proxyContract.methods.topWallet(address),
×
856
        { onTransactionHash },
×
857
        undefined,
858
        true,
859
        logger
860
      )
861

862
      logger.debug('Topwallet result:', { txHash, address, res, wallet: this.name })
863
      return res
864
    } catch (e) {
865
      logger.error('Error topWallet', e.message, e, { txHash, address, wallet: this.name })
866
      throw e
867
    }
×
868
  }
×
869

870
  async banInFaucet(toBan, customLogger = null) {
×
871
    const logger = customLogger || this.log
×
872
    const chunks = chunk(toBan, 25)
873
    logger.debug('banInFaucet:', {
874
      toBan,
875
      chunks: chunks.length
876
    })
877
    for (const idx in chunks) {
878
      const addresses = chunks[idx]
879
      logger.debug('banInFaucet chunk:', {
880
        addresses,
881
        idx
882
      })
883
      try {
884
        const datas = addresses.map(address => {
885
          let encodedCall = this.web3.eth.abi.encodeFunctionCall(
886
            {
887
              name: 'banAddress',
NEW
888
              type: 'function',
×
889
              inputs: [
890
                {
×
891
                  type: 'address',
×
892
                  name: 'account'
NEW
893
                }
×
894
              ]
×
895
            },
896
            [address]
×
897
          )
NEW
898
          return encodedCall
×
899
        })
×
900
        const contracts = addresses.map(() => this.faucetContract._address)
901
        const values = addresses.map(() => 0)
902
        const transaction = this.proxyContract.methods.genericCallBatch(contracts, datas, values)
903
        const onTransactionHash = hash =>
904
          void logger.debug('banInFaucet got txhash:', { hash, addresses, wallet: this.name })
1✔
905
        const res = await this.sendTransaction(transaction, { onTransactionHash }, undefined, true, logger)
906

907
        logger.debug('banInFaucet result:', { addresses, res, wallet: this.name })
908
        return res
909
      } catch (e) {
910
        logger.error('Error banInFaucet', e.message, e, { addresses, wallet: this.name })
911
        throw e
912
      }
1✔
913
    }
914
  }
1✔
915

1✔
916
  async topWalletFaucet(address, customLogger = null) {
917
    const logger = customLogger || this.log
×
918

×
919
    // Check if faucet contract is properly initialized with a valid address
920
    const faucetAddress = this.faucetContract?.options?.address
921
    if (!faucetAddress || faucetAddress === ADDRESS_ZERO) {
922
      logger.debug('topWalletFaucet: no valid faucet contract address', { wallet: this.name })
×
923
      return false // fall back to admin wallet topping
1!
924
    }
925

1!
926
    try {
1✔
927
      const canTop = await this.faucetContract.methods.canTop(address).call()
1✔
928

929
      logger.debug('topWalletFaucet canTop result:', { address, canTop, wallet: this.name })
×
930

931
      if (canTop === false) {
×
932
        return false //we try to top from admin wallet
×
933
      }
934

935
      let userBalance = web3Utils.toBN(await this.web3.eth.getBalance(address))
936
      const gasPrice = await this.web3.eth.getGasPrice()
937
      let faucetTxCost = web3Utils.toBN(this.faucetTxCost).mul(web3Utils.toBN(gasPrice))
938

939
      logger.debug('topWalletFaucet:', {
940
        address,
941
        userBalance: userBalance.toString(),
942
        faucetTxCost: faucetTxCost.toString(),
943
        wallet: this.name
944
      })
945

946
      // user can't call faucet directly
947
      if (forceUserToUseFaucet && userBalance.gte(faucetTxCost)) {
948
        logger.debug('User has enough gas to call faucet', { address, wallet: this.name })
949
        return true //return true so we don't call AdminWallet to topwallet
950
      }
×
951

×
952
      let encodedCall = this.web3.eth.abi.encodeFunctionCall(
953
        {
×
954
          name: 'topWallet',
×
955
          type: 'function',
956
          inputs: [
×
957
            {
958
              type: 'address',
×
959
              name: 'account'
×
960
            }
961
          ]
962
        },
963
        [address]
964
      )
965

966
      const transaction = this.proxyContract.methods.genericCall(this.faucetContract._address, encodedCall, 0)
967
      const onTransactionHash = hash =>
968
        void logger.debug('topWalletFaucet got txhash:', { hash, address, wallet: this.name })
969
      const res = await this.sendTransaction(transaction, { onTransactionHash }, undefined, true, logger)
970

971
      logger.debug('topWalletFaucet result:', { address, res, wallet: this.name })
972
      return res
973
    } catch (e) {
974
      logger.error('Error topWalletFaucet', e.message, e, { address, wallet: this.name })
975
      throw e
976
    }
977
  }
978

979
  async fishMulti(toFish: Array<string>, customLogger = null): Promise<TransactionReceipt> {
1✔
980
    const logger = customLogger || this.log
14✔
981

982
    try {
983
      let encodedCall = this.web3.eth.abi.encodeFunctionCall(
984
        {
985
          name: 'fishMulti',
2✔
986
          type: 'function',
2✔
987
          inputs: [
988
            {
989
              type: 'address[]',
14✔
990
              name: '_accounts'
14✔
991
            }
992
          ]
14✔
993
        },
14✔
994
        [toFish]
995
      )
14✔
996

28✔
997
      logger.info('fishMulti sending tx', { encodedCall, toFish, ubischeme: this.UBIContract._address })
998

999
      const transaction = await this.proxyContract.methods.genericCall(this.UBIContract._address, encodedCall, 0)
14✔
1000
      const tx = await this.sendTransaction(transaction, {}, { gas: 2000000 }, false, logger)
1001

×
1002
      logger.info('fishMulti success', { toFish, tx: tx.transactionHash })
×
1003
      return tx
×
1004
    } catch (exception) {
1005
      const { message } = exception
1006

1007
      logger.error('fishMulti failed', message, exception, { toFish })
14!
1008
      throw exception
×
1009
    }
1010
  }
1011

14✔
1012
  async swaphelper(address, customLogger = null) {
14✔
1013
    const logger = customLogger || this.log
1014
    const predictedAddress = await this.buygdFactoryContract.methods.predict(address).call()
14✔
1015
    const isHelperDeployed = await this.web3.eth.getCode(predictedAddress).then(code => code !== '0x')
1016

14✔
1017
    try {
1018
      let swapResult
14✔
1019
      if (isHelperDeployed) {
1020
        const buygdContract = new this.web3.eth.Contract(BuyGDABI.abi, predictedAddress)
14✔
1021
        //simulate tx
1022
        const estimatedGas = await buygdContract.methods
14!
1023
          .swap(0, this.proxyContract._address)
×
1024
          .estimateGas()
1025
          .then(_ => parseInt(_) + 200000)
1026

14✔
1027
        let encodedCall = this.web3.eth.abi.encodeFunctionCall(
14✔
1028
          {
14✔
1029
            name: 'swap',
1030
            type: 'function',
1031
            inputs: [
1032
              {
1033
                type: 'uint256',
1034
                name: 'minAmount'
1035
              },
1036
              {
1037
                type: 'address',
1038
                name: 'gasRefund'
1039
              }
14✔
1040
            ]
14✔
1041
          },
1042
          [0, this.proxyContract._address]
14✔
1043
        )
14✔
1044

1045
        const transaction = this.proxyContract.methods.genericCall(predictedAddress, encodedCall, 0)
14✔
1046
        const onTransactionHash = hash =>
12✔
1047
          void logger.debug('swaphelper swap got txhash:', { estimatedGas, hash, address, wallet: this.name })
1048
        swapResult = await this.sendTransaction(transaction, { onTransactionHash }, { gas: estimatedGas }, true, logger)
1049
      } else {
1050
        //simulate tx
14✔
1051
        const estimatedGas = await this.buygdFactoryContract.methods
14✔
1052
          .createAndSwap(address, 0)
1053
          .estimateGas()
1054
          .then(_ => parseInt(_) + 200000)
14✔
1055
        let encodedCall = this.web3.eth.abi.encodeFunctionCall(
1056
          {
14!
1057
            name: 'createAndSwap',
×
1058
            type: 'function',
1059
            inputs: [
1060
              {
14✔
1061
                type: 'address',
1062
                name: 'account'
1063
              },
7!
1064
              {
×
1065
                type: 'uint256',
1066
                name: 'minAmount'
1067
              }
1068
            ]
×
1069
          },
×
1070
          [address, 0]
1071
        )
×
1072

1073
        const transaction = this.proxyContract.methods.genericCall(this.buygdFactoryContract._address, encodedCall, 0)
1074
        const onTransactionHash = hash =>
1075
          void logger.debug('swaphelper createAndSwap got txhash:', { estimatedGas, hash, address, wallet: this.name })
1076
        swapResult = await this.sendTransaction(transaction, { onTransactionHash }, { gas: estimatedGas }, true, logger)
1077
      }
1078

1079
      logger.debug('swaphelper tx result:', { address, swapResult, wallet: this.name })
1080

1081
      return swapResult
1082
    } catch (e) {
1083
      logger.error('Error swaphelper', e.message, e, { address, predictedAddress, isHelperDeployed, wallet: this.name })
1084
      throw e
1085
    }
1086
  }
1087

×
1088
  /**
×
1089
   * transfer G$s locked in adminWallet contract to recipient
1090
   * @param {*} to recipient
1091
   * @param {*} value amount to transfer
×
1092
   * @param {*} logger
1093
   * @returns
1094
   */
1095
  async transferWalletGoodDollars(to, value, customLogger = null): Promise<TransactionReceipt> {
14✔
1096
    const logger = customLogger || this.log
1097

14✔
1098
    try {
1099
      let encodedCall = this.web3.eth.abi.encodeFunctionCall(
1100
        {
×
1101
          name: 'transfer',
×
1102
          type: 'function',
1103
          inputs: [
1104
            {
×
1105
              type: 'address',
1106
              name: 'to'
1107
            },
×
1108
            {
1109
              type: 'uint256',
×
1110
              name: 'value'
1111
            }
×
1112
          ]
×
1113
        },
1114
        [to, value]
1115
      )
1116
      logger.info('transferWalletGoodDollars sending tx', { encodedCall, to, value })
1117

1118
      const transaction = await this.proxyContract.methods.genericCall(this.tokenContract._address, encodedCall, 0)
1119
      const tx = await this.sendTransaction(transaction, {}, undefined, false, logger)
1120

1121
      logger.info('transferWalletGoodDollars success', { to, value, tx: tx.transactionHash })
1122
      return tx
1123
    } catch (exception) {
×
1124
      const { message } = exception
×
1125

×
1126
      logger.error('transferWalletGoodDollars failed', message, exception, { to, value })
1127
      throw exception
1128
    }
1129
  }
1130

1131
  async getAddressBalance(address: string): Promise<string> {
1132
    return this.web3.eth.getBalance(address)
1133
  }
1134

1135
  /**
×
1136
   * get balance for admin wallet
1137
   * @returns {Promise<number>}
×
1138
   */
×
1139
  async getBalance(): Promise<number> {
1140
    const { log } = this
1141

1142
    return this.getAddressBalance(this.address)
1143
      .then(b => parseFloat(web3Utils.fromWei(b)))
1144
      .catch(e => {
1145
        log.error('Error getBalance', e.message, e)
1146
        throw e
1147
      })
1148
  }
1149

×
1150
  async registerRedtent(account: string, countryCode: string, customLogger = null): Promise<TransactionReceipt> {
1151
    const logger = customLogger || this.log
1152

1153
    if (this.networkId != 42220) {
1154
      logger.info(`skipping registerRedtent for non Celo: ${this.networkId}`)
×
1155
      return
×
1156
    }
1157
    const poolAddress = conf.redtentPools[countryCode]
1158

×
1159
    try {
×
1160
      let encodedCall = this.web3.eth.abi.encodeFunctionCall(
×
1161
        {
1162
          name: 'addMember',
1163
          type: 'function',
1164
          inputs: [
1165
            {
1166
              name: 'member',
1167
              type: 'address'
1168
            },
1169
            {
1170
              name: 'extraData',
1171
              type: 'bytes'
1172
            }
1173
          ]
×
1174
        },
1175
        [account, '0x']
×
1176
      )
×
1177

×
1178
      const transaction = await this.proxyContract.methods.genericCall(poolAddress, encodedCall, 0)
1179
      const tx = await this.sendTransaction(transaction, {}, undefined, false, logger)
1180

1181
      logger.info('registerRedtent success', { account, countryCode, tx: tx.transactionHash, poolAddress })
1182
      return tx
1183
    } catch (exception) {
1184
      const { message } = exception
1185

1186
      logger.error('registerRedtent failed', message, exception, { account, poolAddress, countryCode })
1187
      throw exception
1188
    }
×
1189
  }
×
1190

1191
  async getFeeEstimates() {
1192
    const result = await this.web3.eth.getFeeHistory('0x5', 'latest', [10])
×
1193

×
1194
    console.log('Fee history result:', result)
1195

1196
    const baseFees = result.baseFeePerGas.map(hex => parseInt(hex, 16))
1197
    const rewards = result.reward.map(r => parseInt(r[0], 16)) // 10th percentile
1198

1199
    const latestBaseFee = baseFees[baseFees.length - 1]
1200
    const minPriorityFee = Math.min(...rewards)
1201

1202
    return {
1203
      baseFee: Math.floor(latestBaseFee * 1.1), // in wei
1204
      priorityFee: minPriorityFee // in wei
1205
    }
×
1206
  }
1207

1208
  /**
1209
   * Normalize gas pricing parameters - handles legacy gasPrice vs EIP-1559 fees
1210
   * Deduplicates gas pricing logic used in sendTransaction and sendNative
1211
   * @private
1212
   * @param {Object} params - Gas pricing parameters
1213
   * @param {string|undefined} params.gasPrice - Legacy gas price
1214
   * @param {string|undefined} params.maxFeePerGas - EIP-1559 max fee per gas
1215
   * @param {string|undefined} params.maxPriorityFeePerGas - EIP-1559 priority fee
1216
   * @param {Object} logger - Logger instance
1217
   * @returns {Promise<Object>} Normalized gas pricing with gasPrice, maxFeePerGas, maxPriorityFeePerGas
1218
   */
1219
  async normalizeGasPricing({ gasPrice, maxFeePerGas, maxPriorityFeePerGas }, logger) {
1220
    // Helper to convert value to number (handles string, number, null, undefined)
1221
    const toNumber = val => {
1222
      if (val === null || val === undefined) return 0
1223
      return typeof val === 'string' ? parseInt(val) || 0 : Number(val) || 0
1224
    }
1225

1✔
1226
    // Check if network supports EIP-1559
1✔
1227
    const supportsEIP1559 = await this.supportsEIP1559()
1228

1229
    if (!supportsEIP1559) {
1230
      // Network doesn't support EIP-1559, use legacy gasPrice only
1231
      gasPrice = gasPrice || this.gasPrice
1232
      logger.debug('Network does not support EIP-1559, using legacy gasPrice', {
1233
        network: this.network,
1✔
1234
        networkId: this.networkId
1235
      })
1✔
1236
      return { gasPrice, maxFeePerGas: undefined, maxPriorityFeePerGas: undefined }
1✔
1237
    } else {
1238
      // Network supports EIP-1559, process EIP-1559 fees
1✔
1239
      // Use instance defaults if not provided
1✔
1240
      maxFeePerGas = maxFeePerGas !== undefined ? maxFeePerGas : this.maxFeePerGas
1✔
1241
      maxPriorityFeePerGas = maxPriorityFeePerGas !== undefined ? maxPriorityFeePerGas : this.maxPriorityFeePerGas
1242

1✔
1243
      console.log('normalizeGasPricing initial values:', { maxFeePerGas, maxPriorityFeePerGas })
1244

1✔
1245
      // Convert to numbers for comparison
1✔
1246
      let maxFeeNum = toNumber(maxFeePerGas)
1247
      let maxPriorityNum = toNumber(maxPriorityFeePerGas)
1✔
1248

1✔
1249
      // Fetch fee estimates if EIP-1559 fees are not provided or invalid
1250
      if (!maxFeeNum || !maxPriorityNum) {
1251
        const { baseFee, priorityFee } = await this.getFeeEstimates()
1252
        maxFeePerGas = maxFeeNum || baseFee
1253
        maxPriorityFeePerGas = maxPriorityNum || priorityFee
1254
        maxFeeNum = toNumber(maxFeePerGas)
1255
        maxPriorityNum = toNumber(maxPriorityFeePerGas)
1256
      }
1257

1258
      // Ensure maxFeePerGas >= maxPriorityFeePerGas (EIP-1559 requirement)
1259
      if (maxPriorityNum > 0 && (maxFeeNum === 0 || maxFeeNum < maxPriorityNum)) {
×
1260
        logger.warn('maxFeePerGas < maxPriorityFeePerGas or is 0, adjusting maxFeePerGas', {
×
1261
          originalMaxFeePerGas: maxFeePerGas,
1262
          maxPriorityFeePerGas: maxPriorityFeePerGas,
1263
          adjustedMaxFeePerGas: maxPriorityFeePerGas
×
1264
        })
1265
        maxFeePerGas = maxPriorityFeePerGas
1266
      }
×
1267

×
1268
      return { gasPrice: undefined, maxFeePerGas, maxPriorityFeePerGas }
1269
    }
1270
  }
×
1271

1272
  /**
1273
   * Check if the RPC supports EIP-1559 using multiple methods:
×
1274
   * Check chain ID against known EIP-1559 supporting networks
×
1275
   * Result is cached after first call to avoid repeated RPC calls
1276
   * @returns Promise resolving to boolean indicating EIP-1559 support
1277
   */
1278
  async supportsEIP1559(): Promise<boolean> {
1✔
1279
    // Return cached result if available (set during wallet initialization)
1280
    if (this._supportsEIP1559 !== null) {
1!
1281
      return this._supportsEIP1559
×
1282
    }
1283

×
1284
    const { log } = this
1285
    try {
1286
      const web3 = this.web3
1287
      if (!web3) {
1288
        log.warn('No web3 instance available for EIP-1559 check')
1289
        this._supportsEIP1559 = false
1290
        return false
1291
      }
1292

1293
      const chainId = await web3.eth.getChainId()
1294
      const knownEIP1559Chains = new Set([
1295
        1, // Ethereum Mainnet
×
1296
        11155111, // Ethereum Sepolia
1297
        8453, // Base
×
1298
        137, // Polygon
×
1299
        42161, // Arbitrum One
1300
        10, // Optimism
×
1301
        5, // Goerli
1302
        80001, // Mumbai (Polygon testnet)
1303
        122, // Fuse
1✔
1304
        42220, // Celo
1305
        4447 // Local Testnet
1!
1306
        // Note: XDC (50) will also become EIP-1559 soon and should be added when it's live
×
1307
      ])
1308

1309
      if (knownEIP1559Chains.has(chainId)) {
1✔
1310
        this._supportsEIP1559 = true
1✔
1311
        return true
1312
      }
1313

1314
      this._supportsEIP1559 = false
1315
      return false
×
1316
    } catch (error) {
×
1317
      log.warn('Failed to check EIP-1559 support, assuming legacy network', {
×
1318
        error: error.message,
1319
        networkId: this.networkId
1320
      })
1321
      // If we can't check, assume it doesn't support EIP-1559 (safer fallback)
1322
      this._supportsEIP1559 = false
1323
      return false
1324
    }
1325
  }
1326

1327
  /**
1328
   * Sign transaction with KMS and return the signed transaction string
1329
   * @private
1330
   */
1331
  async _signTransactionWithKMS(
1332
    tx: any,
1333
    address: string,
1334
    txParams: {
1335
      gas: number,
1336
      maxFeePerGas?: string,
1337
      maxPriorityFeePerGas?: string,
1338
      gasPrice?: string,
1339
      nonce: number,
1340
      chainId: number,
1341
      value?: string | number
1342
    }
1343
  ): Promise<string> {
1344
    const { gas, maxFeePerGas, maxPriorityFeePerGas, gasPrice, nonce, chainId } = txParams
1345
    const logger = this.log
1346

1347
    // Extract transaction data from Web3 contract method
1348
    const txData = tx.encodeABI()
1349
    const to = tx._parent._address || tx._parent.options.address
1350

1351
    // Extract value if present (for payable functions like WETH deposit)
1352
    // Value can be passed via txParams.value, tx.value, or tx._parent.options.value
1353
    const value = txParams.value || tx.value || tx._parent?.options?.value || '0'
1354

1355
    // Build transaction parameters for KMS
1356
    const kmsTxParams = {
1357
      to,
1358
      data: txData,
1359
      nonce,
1360
      chainId,
1361
      gasLimit: gas.toString()
1362
    }
1363

1364
    // Add value if non-zero (for payable functions)
1365
    // kms-ethereum-signing expects value as a decimal string
1366
    if (value && value !== '0' && value !== 0) {
1367
      kmsTxParams.value = value
1368
    }
1369

1370
    // Normalize gas pricing (deduplicated logic)
1371
    const normalizedGas = await this.normalizeGasPricing({ gasPrice, maxFeePerGas, maxPriorityFeePerGas }, logger)
1372

1373
    // Add gas pricing
1374
    if (normalizedGas.maxFeePerGas && normalizedGas.maxPriorityFeePerGas) {
1375
      kmsTxParams.maxFeePerGas = normalizedGas.maxFeePerGas.toString()
1376
      kmsTxParams.maxPriorityFeePerGas = normalizedGas.maxPriorityFeePerGas.toString()
1377
    } else if (normalizedGas.gasPrice) {
1378
      kmsTxParams.gasPrice = normalizedGas.gasPrice.toString()
1379
    }
1380

1381
    // Sign transaction with KMS
1382
    logger.debug('Signing transaction with KMS', { address, chainId, supportsEIP1559: await this.supportsEIP1559() })
1383
    const signedTx = await this.kmsWallet.signTransaction(address, kmsTxParams)
1384

1385
    return signedTx
1386
  }
1387

1388
  /**
1389
   * Wrap a PromiEvent with shared event handlers
1390
   * @private
1391
   */
1392
  _wrapPromiEventWithHandlers(
1393
    promiEvent: PromiEvent,
1394
    txCallbacks: PromiEvents,
1395
    context: {
1396
      release: Function,
1397
      txuuid: string,
1398
      logger: any,
1399
      txHash?: string,
1400
      address?: string,
1401
      nonce?: number,
1402
      gas?: number,
1403
      maxFeePerGas?: string,
1404
      maxPriorityFeePerGas?: string
1405
    },
1406
    options: {
1407
      fail?: Function,
1408
      onSent?: Function
1409
    } = {}
1410
  ): Promise<TransactionReceipt> {
1411
    const { onTransactionHash, onReceipt, onConfirmation, onError } = txCallbacks
1412
    const { release, txuuid, logger, address, nonce, gas, maxFeePerGas, maxPriorityFeePerGas } = context
1413
    const { fail, onSent } = options
1414

1415
    return new Promise((res, rej) => {
1416
      // Verify promiEvent is actually a PromiEvent (has .on method)
1417
      if (!promiEvent || typeof promiEvent.on !== 'function') {
1418
        const error = new Error(
1419
          `Expected PromiEvent but got ${typeof promiEvent}${promiEvent?.constructor?.name ? ` (${promiEvent.constructor.name})` : ''}. This may indicate the PromiEvent was already resolved.`
1420
        )
1421
        logger.error('Invalid PromiEvent in _wrapPromiEventWithHandlers', {
1422
          type: typeof promiEvent,
1423
          hasOn: promiEvent && typeof promiEvent.on,
1424
          constructor: promiEvent?.constructor?.name,
1425
          keys: promiEvent ? Object.keys(promiEvent).slice(0, 10) : null,
1426
          txuuid
1427
        })
1428
        rej(error)
1429
        return
1430
      }
1431

1432
      promiEvent
1433
        .on('transactionHash', h => {
1434
          context.txHash = h
1435
          logger.trace('got tx hash:', { txuuid, txHash: h, wallet: this.name })
1436
          release()
1437

1438
          if (onTransactionHash) {
1439
            onTransactionHash(h)
1440
          }
1441
        })
1442
        .on('sent', payload => {
1443
          if (onSent) {
1444
            onSent(payload)
1445
          }
1446
          logger.debug('tx sent:', { txHash: context.txHash, payload, txuuid, wallet: this.name })
1447
        })
1448
        .on('receipt', r => {
1449
          logger.debug('got tx receipt:', { txuuid, txHash: r.transactionHash, wallet: this.name })
1450

1451
          if (onReceipt) {
1452
            onReceipt(r)
1453
          }
1454

1455
          res(r)
1456
        })
1457
        .on('confirmation', c => {
1458
          if (onConfirmation) {
1459
            onConfirmation(c)
1460
          }
1461
        })
1462
        .on('error', async e => {
1463
          // Check for funds error if requested (for non-KMS transactions)
1464
          if (isFundsError(e) && address) {
1465
            const balance = await this.web3.eth.getBalance(address)
1466
            logger.warn('sendTransaciton funds issue retry', {
1467
              errMessage: e.message,
1468
              nonce,
1469
              gas,
1470
              maxFeePerGas,
1471
              maxPriorityFeePerGas,
1472
              address,
1473
              balance,
1474
              wallet: this.name,
1475
              network: this.networkId
1476
            })
1477
          }
1478

1479
          // Call fail callback if provided (for mainnet error handling)
1480
          if (fail) {
1481
            fail()
1482
          }
1483

1484
          logger.error('Transaction error:', { txuuid, error: e.message, wallet: this.name })
1485

1486
          if (onError) {
1487
            onError(e)
1488
          }
1489

1490
          rej(e)
1491
        })
1492
    })
1493
  }
1494

1495
  /**
1496
   * Send transaction using KMS signing (kept for backward compatibility)
1497
   * @private
1498
   * @param {Object} options - Additional options for mainnet transactions
1499
   * @param {Function} options.fail - Optional fail callback for error handling
1500
   */
1501
  async sendTransactionWithKMS(
1502
    tx: any,
1503
    address: string,
1504
    txParams: {
1505
      gas: number,
1506
      maxFeePerGas?: string,
1507
      maxPriorityFeePerGas?: string,
1508
      gasPrice?: string,
1509
      nonce: number,
1510
      chainId: number,
1511
      value?: string | number
1512
    },
1513
    txCallbacks: PromiEvents,
1514
    context: {
1515
      release: Function,
1516
      currentAddress: string,
1517
      txuuid: string,
1518
      logger: any
1519
    },
1520
    options: {
1521
      fail?: Function
1522
    } = {}
1523
  ) {
1524
    const { release, txuuid, logger } = context
1525
    const { fail } = options
1526

1527
    try {
1528
      // Sign transaction with KMS, then create PromiEvent synchronously
1529
      const signedTx = await this._signTransactionWithKMS(tx, address, txParams)
1530
      const promiEvent = this.web3.eth.sendSignedTransaction(signedTx)
1531

1532
      return this._wrapPromiEventWithHandlers(promiEvent, txCallbacks, { release, txuuid, logger }, { fail })
1533
    } catch (error) {
1534
      // Call fail callback if provided (for mainnet error handling)
1535
      if (fail) {
1536
        fail()
1537
      }
1538
      release()
1539
      logger.error('Failed to send transaction with KMS', {
1540
        txuuid,
1541
        address,
1542
        error: error.message,
1543
        wallet: this.name
1544
      })
1545
      throw error
1546
    }
1547
  }
1548

1549
  /**
1550
   * Helper function to handle a tx Send call
1551
   * @param tx
1552
   * @param {object} promiEvents
1553
   * @param {function} promiEvents.onTransactionHash
1554
   * @param {function} promiEvents.onReceipt
1555
   * @param {function} promiEvents.onConfirmation
1556
   * @param {function} promiEvents.onError
1557
   * @param {object} gasValues
1558
   * @param {number} gasValues.gas
1559
   * @param {number} gasValues.maxFeePerGas
1560
   * @param {number} gasValues.maxPriorityFeePerGas
1561
   * @param {string} [gasValues.from] - If set, lock and send from this address only; otherwise use this.filledAddresses
1562
   * @returns {Promise<Promise|Q.Promise<any>|Promise<*>|Promise<*>|Promise<*>|*>}
1563
   */
1564
  async sendTransaction(
1565
    tx: any,
1566
    txCallbacks: PromiEvents = {},
1567
    { gas, maxPriorityFeePerGas, maxFeePerGas, gasPrice, from }: GasValues = {
1568
      gas: undefined,
1569
      maxFeePerGas: undefined,
1570
      maxPriorityFeePerGas: undefined,
1571
      from: undefined
1572
    },
1573
    retry = true,
1574
    customLogger = null
1575
  ) {
1576
    let currentAddress, txHash, currentNonce
1577
    const txuuid = Crypto.randomBytes(5).toString('base64')
1578
    const logger = customLogger || this.log
1579

1580
    try {
1581
      gas =
1582
        gas ||
1583
        (await tx
1584
          .estimateGas()
1585
          .then(gas => parseInt(gas) + 200000) //buffer for proxy contract, reimburseGas?
1586
          .catch(e => {
1587
            logger.warn('Failed to estimate gas for tx', e.message, e, { wallet: this.name, network: this.networkId })
1588
            if (e.message.toLowerCase().includes('reverted')) throw e
1589
            return defaultGas
1590
          }))
1591

1592
      // adminwallet contract might give wrong gas estimates, so if its more than block gas limit reduce it to default
1593
      if (gas > 8000000) {
1594
        gas = defaultGas
1595
      }
1596

1597
      // Normalize gas pricing (deduplicated logic)
1598
      const normalizedGas = await this.normalizeGasPricing({ gasPrice, maxFeePerGas, maxPriorityFeePerGas }, logger)
1599
      gasPrice = normalizedGas.gasPrice
1600
      maxFeePerGas = normalizedGas.maxFeePerGas
1601
      maxPriorityFeePerGas = normalizedGas.maxPriorityFeePerGas
1602

1603
      logger.trace('getting tx lock:', { txuuid, fromOption: from })
1604

1605
      const addressesToLock = from != null ? [from] : this.filledAddresses
1606
      const { nonce, release, address } = await this.txManager.lock(addressesToLock)
1607

1608
      logger.trace('got tx lock:', { txuuid, address })
1609

1610
      let balance = NaN
1611

1612
      if (this.conf.env === 'development') {
1613
        balance = await this.web3.eth.getBalance(address)
1614
      }
1615

1616
      currentAddress = address
1617
      currentNonce = nonce
1618
      logger.debug(`sending tx from:`, {
1619
        address,
1620
        nonce,
1621
        txuuid,
1622
        balance,
1623
        gas,
1624
        maxFeePerGas,
1625
        maxPriorityFeePerGas,
1626
        wallet: this.name,
1627
        isKMS: this.isKMSWallet(address)
1628
      })
1629

1630
      // Extract value if present (for payable functions)
1631
      const txValue = tx.value || tx._parent?.options?.value
1632

1633
      // Get PromiEvent - either from KMS signing or regular signing
1634
      let promiEvent
1635
      if (this.isKMSWallet(address) && this.kmsWallet) {
1636
        // Sign transaction with KMS, then create PromiEvent synchronously
1637
        const signedTx = await this._signTransactionWithKMS(tx, address, {
1638
          gas,
1639
          maxFeePerGas,
1640
          maxPriorityFeePerGas,
1641
          gasPrice,
1642
          nonce,
1643
          chainId: this.networkId,
1644
          value: txValue
1645
        })
1646
        promiEvent = this.web3.eth.sendSignedTransaction(signedTx)
1647
      } else {
1648
        // Use traditional signing flow
1649
        const sendParams = {
1650
          gas,
1651
          maxFeePerGas,
1652
          maxPriorityFeePerGas,
1653
          gasPrice,
1654
          chainId: this.networkId,
1655
          nonce,
1656
          from: address
1657
        }
1658
        if (txValue) {
1659
          sendParams.value = txValue
1660
        }
1661
        promiEvent = tx.send(sendParams)
1662
      }
1663

1664
      // Wrap PromiEvent with shared event handlers
1665
      const txPromise = this._wrapPromiEventWithHandlers(
1666
        promiEvent,
1667
        txCallbacks,
1668
        {
1669
          release,
1670
          txuuid,
1671
          logger,
1672
          address,
1673
          nonce,
1674
          gas,
1675
          maxFeePerGas,
1676
          maxPriorityFeePerGas
1677
        },
1678
        {
1679
          onSent: payload => {
1680
            txHash = payload?.transactionHash || txHash
1681
          }
1682
        }
1683
      )
1684

1685
      const response = await withTimeout(txPromise, FUSE_TX_TIMEOUT, `${this.name} tx timeout`)
1686

1687
      return response
1688
    } catch (e) {
1689
      // error before executing a tx
1690
      if (!currentAddress) {
1691
        throw e
1692
      }
1693
      //reset nonce on every error, on celo we dont get nonce errors
1694
      let netNonce = parseInt(await this.web3.eth.getTransactionCount(currentAddress))
1695

1696
      //check if tx did go through after timeout or not
1697
      if (txHash && e.message.toLowerCase().includes('timeout')) {
1698
        // keeping address locked for another 30 seconds
1699
        retryAsync(
1700
          async attempt => {
1701
            const receipt = await this.web3.eth.getTransactionReceipt(txHash).catch()
1702
            logger.debug('retrying for timedout tx', {
1703
              currentAddress,
1704
              currentNonce,
1705
              netNonce,
1706
              attempt,
1707
              txuuid,
1708
              txHash,
1709
              receipt,
1710
              wallet: this.name,
1711
              network: this.networkId
1712
            })
1713
            if (receipt) {
1714
              await this.txManager.unlock(currentAddress, Math.max(netNonce, currentNonce + 1))
1715
              logger.info('receipt found for timedout tx attempts', {
1716
                currentAddress,
1717
                currentNonce,
1718
                attempt,
1719
                txuuid,
1720
                txHash,
1721
                receipt,
1722
                wallet: this.name,
1723
                network: this.networkId
1724
              })
1725
            } else if (attempt === 4) {
1726
              //increase nonce assuming tx went through
1727
              await this.txManager.unlock(currentAddress, currentNonce + 1)
1728
              logger.info('stopped retrying for timedout tx attempts', {
1729
                currentAddress,
1730
                currentNonce,
1731
                netNonce,
1732
                attempt,
1733
                txuuid,
1734
                txHash,
1735
                receipt,
1736
                wallet: this.name,
1737
                network: this.networkId
1738
              })
1739
            } else throw new Error('receipt not found') //trigger retry
1740
          },
1741
          3,
1742
          10000
1743
        ).catch(e => {
1744
          this.txManager.unlock(currentAddress, netNonce)
1745
          logger.error('retryAsync for timeout tx failed', e.message, e, { txHash })
1746
        })
1747
        // return assuming tx will mine
1748
        return
1749
      } else if (
1750
        retry &&
1751
        (e.message.toLowerCase().includes('nonce') ||
1752
          e.message.includes('FeeTooLowToCompete') ||
1753
          e.message.includes('underpriced'))
1754
      ) {
1755
        logger.warn('sendTransaction assuming duplicate nonce:', {
1756
          error: e.message,
1757
          maxFeePerGas,
1758
          maxPriorityFeePerGas,
1759
          currentAddress,
1760
          currentNonce,
1761
          netNonce,
1762
          txuuid,
1763
          txHash,
1764
          wallet: this.name,
1765
          network: this.networkId
1766
        })
1767
        // increase nonce, since we assume therre's a tx pending with same nonce
1768
        await this.txManager.unlock(currentAddress, Math.max(netNonce, currentNonce + 1))
1769

1770
        return this.sendTransaction(
1771
          tx,
1772
          txCallbacks,
1773
          { gas, gasPrice, maxFeePerGas, maxPriorityFeePerGas },
1774
          false,
1775
          logger
1776
        )
1777
      } else if (retry && e.message.toLowerCase().includes('revert') === false) {
1778
        logger.warn('sendTransaction retrying non reverted error:', {
1779
          error: e.message,
1780
          currentAddress,
1781
          currentNonce,
1782
          netNonce,
1783
          txuuid,
1784
          txHash,
1785
          wallet: this.name,
1786
          network: this.networkId
1787
        })
1788

1789
        await this.txManager.unlock(currentAddress, netNonce)
1790
        return this.sendTransaction(
1791
          tx,
1792
          txCallbacks,
1793
          { gas, gasPrice, maxFeePerGas, maxPriorityFeePerGas },
1794
          false,
1795
          logger
1796
        )
1797
      }
1798

1799
      await this.txManager.unlock(currentAddress, netNonce)
1800
      logger.error('sendTransaction error:', e.message, e, {
1801
        from: currentAddress,
1802
        currentNonce,
1803
        maxFeePerGas,
1804
        maxPriorityFeePerGas,
1805
        netNonce,
1806
        txuuid,
1807
        txHash,
1808
        retry,
1809
        wallet: this.name,
1810
        network: this.networkId
1811
      })
1812
      throw e
1813
    }
1814
  }
1815

1816
  /**
1817
   * Helper function to handle a tx Send call
1818
   * @param tx
1819
   * @param {object} promiEvents
1820
   * @param {function} promiEvents.onTransactionHash
1821
   * @param {function} promiEvents.onReceipt
1822
   * @param {function} promiEvents.onConfirmation
1823
   * @param {function} promiEvents.onError
1824
   * @param {object} gasValues
1825
   * @param {number} gasValues.gas
1826
   * @param {number} gasValues.maxFeePerGas
1827
   * @param {number} gasValues.maxPriorityFeePerGas
1828
   * @returns {Promise<Promise|Q.Promise<any>|Promise<*>|Promise<*>|Promise<*>|*>}
1829
   */
1830
  async sendNative(
1831
    params: { from: string, to: string, value: string },
1832
    txCallbacks: PromiEvents = {},
1833
    { gas, maxFeePerGas, maxPriorityFeePerGas, gasPrice }: GasValues = {
1834
      gas: undefined,
1835
      maxFeePerGas: undefined,
1836
      maxPriorityFeePerGas: undefined,
1837
      gasPrice: undefined
1838
    }
1839
  ) {
1840
    let currentAddress
1841
    const { log } = this
1842

1843
    try {
1844
      const { onTransactionHash, onReceipt, onConfirmation, onError } = txCallbacks
1845

1846
      gas = gas || defaultGas
1847

1848
      // Normalize gas pricing (deduplicated logic)
1849
      const normalizedGas = await this.normalizeGasPricing({ gasPrice, maxFeePerGas, maxPriorityFeePerGas }, log)
1850
      gasPrice = normalizedGas.gasPrice
1851
      maxFeePerGas = normalizedGas.maxFeePerGas
1852
      maxPriorityFeePerGas = normalizedGas.maxPriorityFeePerGas
1853

1854
      const { nonce, release, fail, address } = await this.txManager.lock(this.filledAddresses)
1855

1856
      log.debug('sendNative', { nonce, gas, maxFeePerGas, maxPriorityFeePerGas })
1857
      currentAddress = address
1858

1859
      return new Promise((res, rej) => {
1860
        this.web3.eth
1861
          .sendTransaction({
1862
            gas,
1863
            gasPrice,
1864
            maxFeePerGas,
1865
            maxPriorityFeePerGas,
1866
            chainId: this.networkId,
1867
            nonce,
1868
            ...params,
1869
            from: address
1870
          })
1871
          .on('transactionHash', h => {
1872
            if (onTransactionHash) {
1873
              onTransactionHash(h)
1874
            }
1875

1876
            release()
1877
          })
1878
          .on('receipt', r => {
1879
            if (onReceipt) {
1880
              onReceipt(r)
1881
            }
1882

1883
            res(r)
1884
          })
1885
          .on('confirmation', c => {
1886
            if (onConfirmation) {
1887
              onConfirmation(c)
1888
            }
1889
          })
1890
          .on('error', async e => {
1891
            const { message } = e
1892

1893
            if (isNonceError(e)) {
1894
              let netNonce = parseInt(await this.web3.eth.getTransactionCount(address))
1895

1896
              log.warn('sendNative nonce failure retry', message, e, {
1897
                params,
1898
                nonce,
1899
                gas,
1900
                maxFeePerGas,
1901
                maxPriorityFeePerGas,
1902
                address,
1903
                newNonce: netNonce,
1904
                wallet: this.name,
1905
                network: this.networkId
1906
              })
1907

1908
              await this.txManager.unlock(address, netNonce)
1909

1910
              try {
1911
                await this.sendNative(params, txCallbacks, { gas, gasPrice, maxFeePerGas, maxPriorityFeePerGas }).then(
1912
                  res
1913
                )
1914
              } catch (e) {
1915
                rej(e)
1916
              }
1917
            } else {
1918
              fail()
1919

1920
              if (onError) {
1921
                onError(e)
1922
              }
1923

1924
              log.error('sendNative failed', message, e, { wallet: this.name, network: this.networkId })
1925
              rej(e)
1926
            }
1927
          })
1928
      })
1929
    } catch (e) {
1930
      let netNonce = parseInt(await this.web3.eth.getTransactionCount(currentAddress))
1931
      await this.txManager.unlock(currentAddress, netNonce)
1932
      throw new Error(e)
1933
    }
1934
  }
1935
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc