Skip to content

Commit

Permalink
Merge branch 'main' into release-0.12.3
Browse files Browse the repository at this point in the history
  • Loading branch information
karlmoll authored Mar 23, 2022
2 parents e9ef3f6 + f19b9b7 commit 52f83b1
Show file tree
Hide file tree
Showing 23 changed files with 386 additions and 125 deletions.
6 changes: 3 additions & 3 deletions background/redux-slices/transaction-construction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const enum TransactionConstructionStatus {

export type NetworkFeeSettings = {
feeType: NetworkFeeTypeChosen
gasLimit: string
gasLimit: bigint | undefined
suggestedGasLimit: bigint | undefined
values: {
maxFeePerGas: bigint
Expand Down Expand Up @@ -278,9 +278,9 @@ export const selectDefaultNetworkFeeSettings = createSelector(
],
suggestedGasLimit: transactionConstruction.transactionRequest?.gasLimit,
}),
({ feeType, selectedFeesPerGas, suggestedGasLimit }) => ({
({ feeType, selectedFeesPerGas, suggestedGasLimit }): NetworkFeeSettings => ({
feeType,
gasLimit: "",
gasLimit: undefined,
suggestedGasLimit,
values: {
maxFeePerGas: selectedFeesPerGas?.maxFeePerGas ?? 0n,
Expand Down
120 changes: 95 additions & 25 deletions background/services/chain/serial-fallback-provider.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Network } from "@ethersproject/networks"
import {
EventType,
JsonRpcProvider,
Listener,
WebSocketProvider,
} from "@ethersproject/providers"
import { MINUTE } from "../../constants"
import { MINUTE, SECOND } from "../../constants"
import logger from "../../lib/logger"
import { AnyEVMTransaction, EVMNetwork } from "../../networks"
import { AddressOnNetwork } from "../../accounts"
Expand All @@ -16,9 +15,17 @@ import { transactionFromAlchemyWebsocketTransaction } from "../../lib/alchemy"
const BASE_BACKOFF_MS = 150
// Reset backoffs after 5 minutes.
const COOLDOWN_PERIOD = 5 * MINUTE
// Retry 3 times before falling back to the next provider.
const MAX_RETRIES = 3

// Retry 8 times before falling back to the next provider.
// This generally results in a wait time of around 30 seconds (with a maximum time
// of 76.5 seconds for 8 completely serial requests) before falling back since we
// usually have multiple requests going out at once.
const MAX_RETRIES = 8
// Wait 15 seconds between primary provider reconnect attempts.
const PRIMARY_PROVIDER_RECONNECT_INTERVAL = 15 * SECOND
// Wait 2 seconds after a primary provider is created before resubscribing.
const WAIT_BEFORE_SUBSCRIBING = 2 * SECOND
// Wait 100ms before attempting another send if a websocket provider is still connecting.
const WAIT_BEFORE_SEND_AGAIN = 100
/**
* Wait the given number of ms, then run the provided function. Returns a
* promise that will resolve after the delay has elapsed and the passed
Expand All @@ -43,8 +50,8 @@ function waitAnd<T, E extends Promise<T>>(
* ms to back off before making the next attempt.
*/
function backedOffMs(backoffCount: number): number {
const backoffSlotStart = BASE_BACKOFF_MS ** backoffCount
const backoffSlotEnd = BASE_BACKOFF_MS ** (backoffCount + 1)
const backoffSlotStart = BASE_BACKOFF_MS * 2 ** backoffCount
const backoffSlotEnd = BASE_BACKOFF_MS * 2 ** (backoffCount + 1)

return backoffSlotStart + Math.random() * (backoffSlotEnd - backoffSlotStart)
}
Expand Down Expand Up @@ -72,6 +79,22 @@ function isClosedOrClosingWebSocketProvider(
return false
}

/**
* Returns true if the given provider is using a WebSocket AND the WebSocket is
* connecting. Ethers does not provide direct access to this information.
*/
function isConnectingWebSocketProvider(provider: JsonRpcProvider): boolean {
if (provider instanceof WebSocketProvider) {
// Digging into the innards of Ethers here because there's no
// other way to get access to the WebSocket connection situation.
// eslint-disable-next-line no-underscore-dangle
const webSocket = provider._websocket as WebSocket
return webSocket.readyState === WebSocket.CONNECTING
}

return false
}

/**
* The SerialFallbackProvider is an Ethers JsonRpcProvider that can fall back
* through a series of providers in case previous ones fail.
Expand Down Expand Up @@ -154,6 +177,12 @@ export default class SerialFallbackProvider extends JsonRpcProvider {
throw new Error("WebSocket is already in CLOSING")
}

if (isConnectingWebSocketProvider(this.currentProvider)) {
// If the websocket is still connecting, wait and try to send again.
return await waitAnd(WAIT_BEFORE_SEND_AGAIN, async () =>
this.send(method, params)
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await this.currentProvider.send(method, params as any)
} catch (error) {
Expand Down Expand Up @@ -245,10 +274,6 @@ export default class SerialFallbackProvider extends JsonRpcProvider {
}
}

async detectNetwork(): Promise<Network> {
return this.currentProvider.detectNetwork()
}

/**
* Subscribe to pending transactions that have been resolved to a full
* transaction object; uses optimized paths when the provider supports it,
Expand Down Expand Up @@ -431,6 +456,7 @@ export default class SerialFallbackProvider extends JsonRpcProvider {
* has been somehow set out of range, resets it to 0.
*/
private async reconnectProvider() {
await this.disconnectCurrentProvider()
if (this.currentProviderIndex >= this.providerCreators.length) {
this.currentProviderIndex = 0
}
Expand All @@ -442,7 +468,7 @@ export default class SerialFallbackProvider extends JsonRpcProvider {
)

this.currentProvider = this.providerCreators[this.currentProviderIndex]()
await this.resubscribe()
await this.resubscribe(this.currentProvider)

// TODO After a longer backoff, attempt to reset the current provider to 0.
}
Expand All @@ -451,12 +477,21 @@ export default class SerialFallbackProvider extends JsonRpcProvider {
* Resubscribes existing WebSocket subscriptions (if the current provider is
* a `WebSocketProvider`) and regular Ethers subscriptions (for all
* providers).
* @param provider The provider to use to resubscribe
* @returns A boolean indicating if websocket subscription was successful or not
*/
private async resubscribe() {
private async resubscribe(provider: JsonRpcProvider): Promise<boolean> {
logger.debug("Resubscribing subscriptions...")

if (this.currentProvider instanceof WebSocketProvider) {
const provider = this.currentProvider as WebSocketProvider
if (
isClosedOrClosingWebSocketProvider(provider) ||
isConnectingWebSocketProvider(provider)
) {
return false
}

if (provider instanceof WebSocketProvider) {
const websocketProvider = provider as WebSocketProvider

// Chain promises to serially resubscribe.
//
Expand All @@ -469,29 +504,64 @@ export default class SerialFallbackProvider extends JsonRpcProvider {
// Direct subscriptions are internal, but we want to be able to
// restore them.
// eslint-disable-next-line no-underscore-dangle
provider._subscribe(tag, param, processFunc)
websocketProvider._subscribe(tag, param, processFunc)
)
),
Promise.resolve()
)
} else if (this.subscriptions.length > 0) {
logger.warn(
`Cannot resubscribe ${this.subscriptions.length} subscription(s) ` +
`as the current provider is not a WebSocket provider; waiting ` +
`until a WebSocket provider connects to restore subscriptions ` +
`properly.`
)
}

this.eventSubscriptions.forEach(({ eventName, listener, once }) => {
if (once) {
this.currentProvider.once(eventName, listener)
provider.once(eventName, listener)
} else {
this.currentProvider.on(eventName, listener)
provider.on(eventName, listener)
}
})
if (!(provider instanceof WebSocketProvider)) {
if (this.subscriptions.length > 0) {
logger.warn(
`Cannot resubscribe ${this.subscriptions.length} subscription(s) ` +
`as the current provider is not a WebSocket provider; waiting ` +
`until a WebSocket provider connects to restore subscriptions ` +
`properly.`
)
// Intentionally not awaited - This starts off a recursive reconnect loop
// that keeps trying to reconnect until successful.
this.attemptToReconnectToPrimaryProvider()
}
return false
}

logger.debug("Subscriptions resubscribed...")
return true
}

private async attemptToReconnectToPrimaryProvider(): Promise<unknown> {
// Attempt to reconnect to primary provider every 15 seconds
return waitAnd(PRIMARY_PROVIDER_RECONNECT_INTERVAL, async () => {
if (this.currentProviderIndex === 0) {
// If we are already connected to the primary provider - don't resubscribe
// and stop attempting to reconnect.
return null
}
const primaryProvider = this.providerCreators[0]()
// We need to wait before attempting to resubscribe of the primaryProvider's
// websocket connection will almost always still be in a CONNECTING state when
// resubscribing.
return waitAnd(WAIT_BEFORE_SUBSCRIBING, async (): Promise<unknown> => {
const subscriptionsSuccessful = await this.resubscribe(primaryProvider)
if (!subscriptionsSuccessful) {
await this.attemptToReconnectToPrimaryProvider()
return
}
// Cleanup the subscriptions on the backup provider.
await this.disconnectCurrentProvider()
// only set if subscriptions are successful
this.currentProvider = primaryProvider
this.currentProviderIndex = 0
})
})
}

/**
Expand Down
62 changes: 58 additions & 4 deletions background/services/enrichment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { enrichAssetAmountWithDecimalValues } from "../../redux-slices/utils/asset-utils"

import { parseERC20Tx, parseLogsForERC20Transfers } from "../../lib/erc20"
import { sameEVMAddress } from "../../lib/utils"
import { normalizeEVMAddress, sameEVMAddress } from "../../lib/utils"

import ChainService from "../chain"
import IndexingService from "../indexing"
Expand Down Expand Up @@ -122,6 +122,8 @@ export default class EnrichmentService extends BaseService<Events> {
transaction.input === "0x" ||
typeof transaction.input === "undefined"
) {
const toName = await this.nameService.lookUpName(transaction.to, ETHEREUM)

// This is _almost certainly_ not a contract interaction, move on. Note that
// a simple ETH send to a contract address can still effectively be a
// contract interaction (because it calls the fallback function on the
Expand All @@ -134,6 +136,7 @@ export default class EnrichmentService extends BaseService<Events> {
timestamp: resolvedTime,
type: "asset-transfer",
senderAddress: transaction.from,
recipientName: toName,
recipientAddress: transaction.to, // TODO ingest address
assetAmount: enrichAssetAmountWithDecimalValues(
{
Expand All @@ -148,6 +151,7 @@ export default class EnrichmentService extends BaseService<Events> {
txAnnotation = {
timestamp: resolvedTime,
type: "contract-interaction",
contractName: toName,
}
}
} else {
Expand All @@ -170,13 +174,19 @@ export default class EnrichmentService extends BaseService<Events> {
erc20Tx &&
(erc20Tx.name === "transfer" || erc20Tx.name === "transferFrom")
) {
const toName = await this.nameService.lookUpName(
erc20Tx.args.to,
ETHEREUM
)

// We have an ERC-20 transfer
txAnnotation = {
timestamp: resolvedTime,
type: "asset-transfer",
transactionLogoURL,
senderAddress: erc20Tx.args.from ?? transaction.from,
recipientAddress: erc20Tx.args.to, // TODO ingest address
recipientName: toName,
assetAmount: enrichAssetAmountWithDecimalValues(
{
asset: matchingFungibleAsset,
Expand All @@ -190,11 +200,17 @@ export default class EnrichmentService extends BaseService<Events> {
erc20Tx &&
erc20Tx.name === "approve"
) {
const spenderName = await this.nameService.lookUpName(
erc20Tx.args.spender,
ETHEREUM
)

txAnnotation = {
timestamp: resolvedTime,
type: "asset-approval",
transactionLogoURL,
spenderAddress: erc20Tx.args.spender, // TODO ingest address
spenderName,
assetAmount: enrichAssetAmountWithDecimalValues(
{
asset: matchingFungibleAsset,
Expand All @@ -204,6 +220,11 @@ export default class EnrichmentService extends BaseService<Events> {
),
}
} else {
const toName = await this.nameService.lookUpName(
transaction.to,
ETHEREUM
)

// Fall back on a standard contract interaction.
txAnnotation = {
timestamp: resolvedTime,
Expand All @@ -212,6 +233,7 @@ export default class EnrichmentService extends BaseService<Events> {
// non-specific; the UI can choose to use it or not, but if we know the
// address has an associated logo it's worth passing on.
transactionLogoURL,
contractName: toName,
}
}
}
Expand All @@ -220,9 +242,35 @@ export default class EnrichmentService extends BaseService<Events> {
if ("logs" in transaction && typeof transaction.logs !== "undefined") {
const assets = await this.indexingService.getCachedAssets(network)

const subannotations = parseLogsForERC20Transfers(
transaction.logs
).flatMap<TransactionAnnotation>(
const erc20TransferLogs = parseLogsForERC20Transfers(transaction.logs)

// Look up transfer log names, then flatten to an address -> name map.
const namesByAddress = Object.fromEntries(
(
await Promise.allSettled(
[
...new Set(
...erc20TransferLogs.map(
({ recipientAddress }) => recipientAddress
)
),
].map(
async (address) =>
[
normalizeEVMAddress(address),
await this.nameService.lookUpName(address, ETHEREUM),
] as const
)
)
)
.filter(
(result): result is PromiseFulfilledResult<[string, string]> =>
result.status === "fulfilled" && result.value[1] !== undefined
)
.map(({ value }) => value)
)

const subannotations = erc20TransferLogs.flatMap<TransactionAnnotation>(
({ contractAddress, amount, senderAddress, recipientAddress }) => {
// See if the address matches a fungible asset.
const matchingFungibleAsset = assets.find(
Expand All @@ -231,6 +279,11 @@ export default class EnrichmentService extends BaseService<Events> {
sameEVMAddress(asset.contractAddress, contractAddress)
)

// Try to find a resolved name for the recipient; we should probably
// do this for the sender as well, but one thing at a time.
const recipientName =
namesByAddress[normalizeEVMAddress(recipientAddress)]

return typeof matchingFungibleAsset !== "undefined"
? [
{
Expand All @@ -244,6 +297,7 @@ export default class EnrichmentService extends BaseService<Events> {
),
senderAddress,
recipientAddress,
recipientName,
timestamp: resolvedTime,
},
]
Expand Down
Loading

0 comments on commit 52f83b1

Please sign in to comment.