@faast/ethereum-payments
Version:
Library to assist in processing ethereum payments, such as deriving addresses and sweeping funds
351 lines (307 loc) • 11.6 kB
text/typescript
import Web3 from 'web3'
import {
PaymentsUtils,
Payport,
createUnitConverters,
AutoFeeLevels,
FeeRate,
FeeRateType,
NetworkType,
BalanceResult,
TransactionStatus,
} from '@faast/payments-common'
import {
Logger,
DelegateLogger,
assertType,
isNull,
Numeric
} from '@faast/ts-common'
import BigNumber from 'bignumber.js'
import { Transaction, TransactionReceipt } from 'web3-core'
import {
PACKAGE_NAME, ETH_DECIMAL_PLACES, ETH_NAME, ETH_SYMBOL, DEFAULT_ADDRESS_FORMAT, MIN_SWEEPABLE_WEI, MIN_CONFIRMATIONS,
} from './constants'
import { EthereumAddressFormat, EthereumAddressFormatT, EthereumPaymentsUtilsConfig, EthereumTransactionInfo } from './types'
import { isValidXkey } from './bip44'
import { NetworkData } from './NetworkData'
import { retryIfDisconnected } from './utils'
type UnitConverters = ReturnType<typeof createUnitConverters>
export class EthereumPaymentsUtils implements PaymentsUtils {
readonly networkType: NetworkType
readonly coinSymbol: string
readonly coinName: string
readonly coinDecimals: number
logger: Logger
server: string | null
web3: Web3
eth: Web3['eth']
gasStation: NetworkData
constructor(config: EthereumPaymentsUtilsConfig) {
this.logger = new DelegateLogger(config.logger, PACKAGE_NAME)
this.networkType = config.network || NetworkType.Mainnet
this.coinName = config.name ?? ETH_NAME
this.coinSymbol = config.symbol ?? ETH_SYMBOL
this.coinDecimals = config.decimals ?? ETH_DECIMAL_PLACES
this.server = config.fullNode || null
let provider: any
if (config.web3) {
this.web3 = config.web3
} else if (isNull(this.server)) {
this.web3 = new Web3()
} else if (this.server.startsWith('http')) {
provider = new Web3.providers.HttpProvider(this.server, config.providerOptions)
this.web3 = new Web3(provider)
} else if (this.server.startsWith('ws')) {
provider = new Web3.providers.WebsocketProvider(this.server, config.providerOptions)
this.web3 = new Web3(provider)
} else {
throw new Error(`Invalid ethereum payments fullNode, must start with http or ws: ${this.server}`)
}
// Debug mode to print out all outgoing req/res
if (provider && process.env.NODE_DEBUG && process.env.NODE_DEBUG.includes('ethereum-payments')) {
const send = provider.send
provider.send = (payload: any, cb: Function) => {
this.logger.debug(`web3 provider request ${this.server}`, payload)
send.call(provider, payload, (error: Error, result: any) => {
if (error) {
this.logger.debug(`web3 provider response error ${this.server}`, error)
} else {
this.logger.debug(`web3 provider response result ${this.server}`, result)
}
cb(error, result)
})
}
}
this.eth = this.web3.eth
this.gasStation = new NetworkData(this.eth, config.gasStation, config.parityNode, this.logger)
const unitConverters = createUnitConverters(this.coinDecimals)
this.toMainDenominationBigNumber = unitConverters.toMainDenominationBigNumber
this.toBaseDenominationBigNumber = unitConverters.toBaseDenominationBigNumber
this.toMainDenomination = unitConverters.toMainDenominationString
this.toBaseDenomination = unitConverters.toBaseDenominationString
const ethUnitConverters = createUnitConverters(ETH_DECIMAL_PLACES)
this.toMainDenominationBigNumberEth = ethUnitConverters.toMainDenominationBigNumber
this.toBaseDenominationBigNumberEth = ethUnitConverters.toBaseDenominationBigNumber
this.toMainDenominationEth = ethUnitConverters.toMainDenominationString
this.toBaseDenominationEth = ethUnitConverters.toBaseDenominationString
}
async init() {}
async destroy() {}
toMainDenominationBigNumber: UnitConverters['toMainDenominationBigNumber']
toBaseDenominationBigNumber: UnitConverters['toMainDenominationBigNumber']
toMainDenomination: UnitConverters['toMainDenominationString']
toBaseDenomination: UnitConverters['toBaseDenominationString']
toMainDenominationBigNumberEth: UnitConverters['toMainDenominationBigNumber']
toBaseDenominationBigNumberEth: UnitConverters['toMainDenominationBigNumber']
toMainDenominationEth: UnitConverters['toMainDenominationString']
toBaseDenominationEth: UnitConverters['toBaseDenominationString']
isValidAddress(address: string, options: { format?: string } = {}): boolean {
const { format } = options
if (format === EthereumAddressFormat.Lowercase) {
return this.web3.utils.isAddress(address) &&
address === address.toLowerCase()
} else if (format === EthereumAddressFormat.Checksum) {
return this.web3.utils.checkAddressChecksum(address)
}
return this.web3.utils.isAddress(address)
}
standardizeAddress(address: string, options?: { format?: string }): string | null {
if (!this.web3.utils.isAddress(address)) {
return null
}
const format = assertType(EthereumAddressFormatT, options?.format ?? DEFAULT_ADDRESS_FORMAT, 'format')
if (format === EthereumAddressFormat.Lowercase) {
return address.toLowerCase()
} else {
return this.web3.utils.toChecksumAddress(address)
}
}
isValidExtraId(extraId: unknown): boolean {
return false
}
// XXX Payport methods can be moved to payments-common
isValidPayport(payport: Payport): boolean {
return Payport.is(payport) && !this._getPayportValidationMessage(payport)
}
validatePayport(payport: Payport): void {
const message = this._getPayportValidationMessage(payport)
if (message) {
throw new Error(message)
}
}
getPayportValidationMessage(payport: Payport): string | undefined {
try {
payport = assertType(Payport, payport, 'payport')
} catch (e) {
return e.message
}
return this._getPayportValidationMessage(payport)
}
isValidXprv(xprv: string): boolean {
return isValidXkey(xprv) && xprv.substring(0, 4) === 'xprv'
}
isValidXpub(xpub: string): boolean {
return isValidXkey(xpub) && xpub.substring(0, 4) === 'xpub'
}
isValidPrivateKey(prv: string): boolean {
try {
return Boolean(this.web3.eth.accounts.privateKeyToAccount(prv))
} catch (e) {
return false
}
}
privateKeyToAddress(prv: string): string {
let key: string
if (prv.substring(0, 2) === '0x') {
key = prv
} else {
key = `0x${prv}`
}
return this.web3.eth.accounts.privateKeyToAccount(key).address.toLowerCase()
}
private _getPayportValidationMessage(payport: Payport): string | undefined {
try {
const { address } = payport
if (!(this.isValidAddress(address))) {
return 'Invalid payport address'
}
} catch (e) {
return 'Invalid payport address'
}
return undefined
}
async getFeeRateRecommendation(level: AutoFeeLevels): Promise<FeeRate> {
const gasPrice = await this.gasStation.getGasPrice(level)
return {
feeRate: gasPrice,
feeRateType: FeeRateType.BasePerWeight,
}
}
async _retryDced<T>(fn: () => Promise<T>): Promise<T> {
return retryIfDisconnected(fn, this.logger)
}
async getCurrentBlockNumber() {
return this._retryDced(() => this.eth.getBlockNumber())
}
isAddressBalanceSweepable(balanceEth: Numeric): boolean {
return this.toBaseDenominationBigNumberEth(balanceEth).gt(MIN_SWEEPABLE_WEI)
}
async getAddressBalance(address: string): Promise<BalanceResult> {
const balance = await this._retryDced(() => this.eth.getBalance(address))
const confirmedBalance = this.toMainDenomination(balance).toString()
const sweepable = this.isAddressBalanceSweepable(confirmedBalance)
return {
confirmedBalance,
unconfirmedBalance: '0',
spendableBalance: confirmedBalance,
sweepable,
requiresActivation: false,
}
}
async getAddressNextSequenceNumber(address: string) {
return this.gasStation.getNonce(address)
}
async getAddressUtxos() {
return []
}
async getTransactionInfo(txid: string): Promise<EthereumTransactionInfo> {
// XXX it is suggested to keep 12 confirmations
// https://ethereum.stackexchange.com/questions/319/what-number-of-confirmations-is-considered-secure-in-ethereum
const minConfirmations = MIN_CONFIRMATIONS
const tx: Transaction | null = await this._retryDced(() => this.eth.getTransaction(txid))
if (!tx) {
throw new Error(`Transaction ${txid} not found`)
}
const currentBlockNumber = await this.getCurrentBlockNumber()
let txInfo: TransactionReceipt | null = await this._retryDced(() => this.eth.getTransactionReceipt(txid))
tx.from = tx.from ? tx.from.toLowerCase() : '';
tx.to = tx.to ? tx.to.toLowerCase() : '';
// NOTE: for the sake of consistent schema return
if (!txInfo) {
txInfo = {
transactionHash: tx.hash,
from: tx.from,
to: tx.to,
status: true,
blockNumber: 0,
cumulativeGasUsed: 0,
gasUsed: 0,
transactionIndex: 0,
blockHash: '',
logs: [],
logsBloom: ''
}
return {
id: txid,
amount: this.toMainDenomination(tx.value),
toAddress: tx.to ? tx.to.toLowerCase() : null,
fromAddress: tx.from ? tx.from.toLowerCase() : null,
toExtraId: null,
fromIndex: null,
toIndex: null,
fee: this.toMainDenomination((new BigNumber(tx.gasPrice)).multipliedBy(tx.gas)),
sequenceNumber: tx.nonce,
weight: tx.gas,
isExecuted: false,
isConfirmed: false,
confirmations: 0,
confirmationId: null,
confirmationTimestamp: null,
currentBlockNumber: currentBlockNumber,
status: TransactionStatus.Pending,
data: {
...tx,
...txInfo,
currentBlock: currentBlockNumber
},
}
}
let isConfirmed = false
let confirmationTimestamp: Date | null = null
let confirmations = 0
if (tx.blockNumber) {
confirmations = currentBlockNumber - tx.blockNumber
if (confirmations > minConfirmations) {
isConfirmed = true
const txBlock = await this._retryDced(() => this.eth.getBlock(tx.blockNumber!))
confirmationTimestamp = new Date(Number(txBlock.timestamp) * 1000)
}
}
let status: TransactionStatus = TransactionStatus.Pending
if (isConfirmed) {
status = TransactionStatus.Confirmed
// No trust to types description of web3
if (txInfo.hasOwnProperty('status') && (txInfo.status === false || txInfo.status.toString() === 'false')) {
status = TransactionStatus.Failed
}
}
txInfo.from = tx.from
txInfo.to = tx.to
return {
id: txid,
amount: this.toMainDenomination(tx.value),
toAddress: tx.to ? tx.to.toLowerCase() : null,
fromAddress: tx.from ? tx.from.toLowerCase() : null,
toExtraId: null,
fromIndex: null,
toIndex: null,
fee: this.toMainDenomination((new BigNumber(tx.gasPrice)).multipliedBy(txInfo.gasUsed)),
sequenceNumber: tx.nonce,
weight: txInfo.gasUsed,
// XXX if tx was confirmed but not accepted by network isExecuted must be false
isExecuted: status !== TransactionStatus.Failed,
isConfirmed,
confirmations,
confirmationId: tx.blockHash,
confirmationTimestamp,
status,
currentBlockNumber: currentBlockNumber,
data: {
...tx,
...txInfo,
currentBlock: currentBlockNumber
},
}
}
}