alchemy-sdk
Version:
Extended Ethers.js SDK for Alchemy APIs
1,147 lines (1,143 loc) • 46.7 kB
JavaScript
import { _ as __awaiter, f as fromHex, t as toHex, n as noop, C as CustomNetworks, i as isAlchemyEvent, v as verifyAlchemyEventName, c as EthersEvent, e as getAlchemyEventTag, D as DEFAULT_ALCHEMY_API_KEY, A as ALCHEMY_EVENT_TYPES, h as ALCHEMY_PENDING_TRANSACTIONS_EVENT_TYPE, j as AlchemySubscription, k as ALCHEMY_MINED_TRANSACTIONS_EVENT_TYPE, V as VERSION, E as EthersNetwork } from './index-1789de96.js';
import SturdyWebSocket from 'sturdy-websocket';
import { BigNumber } from '@ethersproject/bignumber';
import { getNetwork } from '@ethersproject/networks';
import { WebSocketProvider } from '@ethersproject/providers';
import { AlchemyProvider } from './alchemy-provider-8b0c3e20.js';
import './api/utils';
import 'axios';
import '@ethersproject/abstract-provider';
import '@ethersproject/wallet';
import '@ethersproject/contracts';
import '@ethersproject/web';
/**
* The maximum number of blocks to backfill. If more than this many blocks have
* been missed, then we'll sadly miss data, but we want to make sure we don't
* end up requesting thousands of blocks if somebody left their laptop closed for a week.
*/
const MAX_BACKFILL_BLOCKS = 120;
/**
* The WebsocketBackfiller fetches events that were sent since a provided block
* number. This is used in the {@link AlchemyWebSocketProvider} to backfill
* events that were transmitted while the websocket connection was down.
*
* The backfiller backfills two main eth_subscribe events: `logs` and `newHeads`.
*
* @internal
*/
class WebsocketBackfiller {
constructor(provider) {
this.provider = provider;
// TODO: Use HTTP provider to do backfill.
this.maxBackfillBlocks = MAX_BACKFILL_BLOCKS;
}
/**
* Runs backfill for `newHeads` events.
*
* @param isCancelled Whether the backfill request is cancelled.
* @param previousHeads Previous head requests that were sent.
* @param fromBlockNumber The block number to start backfilling from.
* @returns A list of `newHeads` events that were sent since the last backfill.
*/
getNewHeadsBackfill(isCancelled, previousHeads, fromBlockNumber) {
return __awaiter(this, void 0, void 0, function* () {
throwIfCancelled(isCancelled);
const toBlockNumber = yield this.getBlockNumber();
throwIfCancelled(isCancelled);
// If there are no previous heads to fetch, return new heads since
// `fromBlockNumber`, or up to maxBackfillBlocks from the current head.
if (previousHeads.length === 0) {
return this.getHeadEventsInRange(Math.max(fromBlockNumber, toBlockNumber - this.maxBackfillBlocks) + 1, toBlockNumber + 1);
}
// If the last emitted event is too far back in the past, there's no need
// to backfill for reorgs. Just fetch the last `maxBackfillBlocks` worth of
// new heads.
const lastSeenBlockNumber = fromHex(previousHeads[previousHeads.length - 1].number);
const minBlockNumber = toBlockNumber - this.maxBackfillBlocks + 1;
if (lastSeenBlockNumber <= minBlockNumber) {
return this.getHeadEventsInRange(minBlockNumber, toBlockNumber + 1);
}
// To capture all `newHeads` events, return all head events from the last
// seen block number to current + any of the previous heads that were re-orged.
const reorgHeads = yield this.getReorgHeads(isCancelled, previousHeads);
throwIfCancelled(isCancelled);
const intermediateHeads = yield this.getHeadEventsInRange(lastSeenBlockNumber + 1, toBlockNumber + 1);
throwIfCancelled(isCancelled);
return [...reorgHeads, ...intermediateHeads];
});
}
/**
* Runs backfill for `logs` events.
*
* @param isCancelled Whether the backfill request is cancelled.
* @param filter The filter object that accompanies a logs subscription.
* @param previousLogs Previous log requests that were sent.
* @param fromBlockNumber The block number to start backfilling from.
*/
getLogsBackfill(isCancelled, filter, previousLogs, fromBlockNumber) {
return __awaiter(this, void 0, void 0, function* () {
throwIfCancelled(isCancelled);
const toBlockNumber = yield this.getBlockNumber();
throwIfCancelled(isCancelled);
// If there are no previous logs to fetch, return new logs since
// `fromBlockNumber`, or up to `maxBackfillBlocks` from the current head.
if (previousLogs.length === 0) {
return this.getLogsInRange(filter, Math.max(fromBlockNumber, toBlockNumber - this.maxBackfillBlocks) + 1, toBlockNumber + 1);
}
// If the last emitted log is too far back in the past, there's no need
// to backfill for removed logs. Just fetch the last `maxBackfillBlocks`
// worth of logs.
const lastSeenBlockNumber = fromHex(previousLogs[previousLogs.length - 1].blockNumber);
const minBlockNumber = toBlockNumber - this.maxBackfillBlocks + 1;
if (lastSeenBlockNumber < minBlockNumber) {
return this.getLogsInRange(filter, minBlockNumber, toBlockNumber + 1);
}
// Return all log events that have happened along with log events that have
// been removed due to a chain reorg.
const commonAncestor = yield this.getCommonAncestor(isCancelled, previousLogs);
throwIfCancelled(isCancelled);
// All previous logs with a block number greater than the common ancestor
// were part of a re-org, so mark them as such.
const removedLogs = previousLogs
.filter(log => fromHex(log.blockNumber) > commonAncestor.blockNumber)
.map(log => (Object.assign(Object.assign({}, log), { removed: true })));
// If no common ancestor was found, start backfill from the oldest log's
// block number.
const fromBlockInclusive = commonAncestor.blockNumber === Number.NEGATIVE_INFINITY
? fromHex(previousLogs[0].blockNumber)
: commonAncestor.blockNumber;
let addedLogs = yield this.getLogsInRange(filter, fromBlockInclusive, toBlockNumber + 1);
// De-dupe any logs that were already emitted.
addedLogs = addedLogs.filter(log => log &&
(fromHex(log.blockNumber) > commonAncestor.blockNumber ||
fromHex(log.logIndex) > commonAncestor.logIndex));
throwIfCancelled(isCancelled);
return [...removedLogs, ...addedLogs];
});
}
/**
* Sets a new max backfill blocks. VISIBLE ONLY FOR TESTING.
*
* @internal
*/
setMaxBackfillBlock(newMax) {
this.maxBackfillBlocks = newMax;
}
/**
* Gets the current block number as a number.
*
* @private
*/
getBlockNumber() {
return __awaiter(this, void 0, void 0, function* () {
const blockNumberHex = yield this.provider.send('eth_blockNumber');
return fromHex(blockNumberHex);
});
}
/**
* Gets all `newHead` events in the provided range. Note that the returned
* heads do not include re-orged heads. Use {@link getReorgHeads} to find heads
* that were part of a re-org.
*
* @private
*/
getHeadEventsInRange(fromBlockInclusive, toBlockExclusive) {
return __awaiter(this, void 0, void 0, function* () {
if (fromBlockInclusive >= toBlockExclusive) {
return [];
}
const batchParts = [];
for (let i = fromBlockInclusive; i < toBlockExclusive; i++) {
batchParts.push({
method: 'eth_getBlockByNumber',
params: [toHex(i), false]
});
}
// TODO: handle errors
const blockHeads = yield this.provider.sendBatch(batchParts);
return blockHeads.map(toNewHeadsEvent);
});
}
/**
* Returns all heads that were part of a reorg event.
*
* @private
*/
getReorgHeads(isCancelled, previousHeads) {
return __awaiter(this, void 0, void 0, function* () {
const result = [];
// Iterate from the most recent head backwards in order to find the first
// block that was part of a re-org.
for (let i = previousHeads.length - 1; i >= 0; i--) {
const oldEvent = previousHeads[i];
const blockHead = yield this.getBlockByNumber(fromHex(oldEvent.number));
throwIfCancelled(isCancelled);
// If the hashes match, then current head in the iteration was not re-orged.
if (oldEvent.hash === blockHead.hash) {
break;
}
result.push(toNewHeadsEvent(blockHead));
}
return result.reverse();
});
}
/**
* Simple wrapper around `eth_getBlockByNumber` that returns the complete
* block information for the provided block number.
*
* @private
*/
getBlockByNumber(blockNumber) {
return __awaiter(this, void 0, void 0, function* () {
return this.provider.send('eth_getBlockByNumber', [
toHex(blockNumber),
false
]);
});
}
/**
* Given a list of previous log events, finds the common block number from the
* logs that matches the block head.
*
* This can be used to identify which logs are part of a re-org.
*
* Returns 1 less than the oldest log's block number if no common ancestor was found.
*
* @private
*/
getCommonAncestor(isCancelled, previousLogs) {
return __awaiter(this, void 0, void 0, function* () {
// Iterate from the most recent head backwards in order to find the first
// block that was part of a re-org.
let blockHead = yield this.getBlockByNumber(fromHex(previousLogs[previousLogs.length - 1].blockNumber));
throwIfCancelled(isCancelled);
for (let i = previousLogs.length - 1; i >= 0; i--) {
const oldLog = previousLogs[i];
// Ensure that updated blocks are fetched every time the log's block number
// changes.
if (oldLog.blockNumber !== blockHead.number) {
blockHead = yield this.getBlockByNumber(fromHex(oldLog.blockNumber));
}
// Since logs are ordered in ascending order, the first log that matches
// the hash should be the largest logIndex.
if (oldLog.blockHash === blockHead.hash) {
return {
blockNumber: fromHex(oldLog.blockNumber),
logIndex: fromHex(oldLog.logIndex)
};
}
}
return {
blockNumber: Number.NEGATIVE_INFINITY,
logIndex: Number.NEGATIVE_INFINITY
};
});
}
/**
* Gets all `logs` events in the provided range. Note that the returned logs
* do not include removed logs.
*
* @private
*/ getLogsInRange(filter, fromBlockInclusive, toBlockExclusive) {
return __awaiter(this, void 0, void 0, function* () {
if (fromBlockInclusive >= toBlockExclusive) {
return [];
}
const rangeFilter = Object.assign(Object.assign({}, filter), { fromBlock: toHex(fromBlockInclusive), toBlock: toHex(toBlockExclusive - 1) });
return this.provider.send('eth_getLogs', [rangeFilter]);
});
}
}
function toNewHeadsEvent(head) {
const result = Object.assign({}, head);
delete result.totalDifficulty;
delete result.transactions;
delete result.uncles;
return result;
}
function dedupeNewHeads(events) {
return dedupe(events, event => event.hash);
}
function dedupeLogs(events) {
return dedupe(events, event => `${event.blockHash}/${event.logIndex}`);
}
function dedupe(items, getKey) {
const keysSeen = new Set();
const result = [];
items.forEach(item => {
const key = getKey(item);
if (!keysSeen.has(key)) {
keysSeen.add(key);
result.push(item);
}
});
return result;
}
const CANCELLED = new Error('Cancelled');
function throwIfCancelled(isCancelled) {
if (isCancelled()) {
throw CANCELLED;
}
}
const HEARTBEAT_INTERVAL = 30000;
const HEARTBEAT_WAIT_TIME = 10000;
const BACKFILL_TIMEOUT = 60000;
const BACKFILL_RETRIES = 5;
/**
* Subscriptions have a memory of recent events they have sent so that in the
* event that they disconnect and need to backfill, they can detect re-orgs.
* Keep a buffer that goes back at least these many blocks, the maximum amount
* at which we might conceivably see a re-org.
*
* Note that while our buffer goes back this many blocks, it may contain more
* than this many elements, since in the case of logs subscriptions more than
* one event may be emitted for a block.
*/
const RETAINED_EVENT_BLOCK_COUNT = 10;
/**
* SDK's custom implementation fo the ethers.js's 'AlchemyWebSocketProvider'.
*
* Do not call this constructor directly. Instead, instantiate an instance of
* {@link Alchemy} and call {@link Alchemy.config.getWebSocketProvider()}.
*
* @public
*/
class AlchemyWebSocketProvider extends WebSocketProvider {
/** @internal */
constructor(config, wsConstructor) {
var _a;
// Normalize the API Key to a string.
const apiKey = AlchemyProvider.getApiKey(config.apiKey);
// Generate our own connection info with the correct endpoint URLs.
const alchemyNetwork = AlchemyProvider.getAlchemyNetwork(config.network);
const connection = AlchemyProvider.getAlchemyConnectionInfo(alchemyNetwork, apiKey, 'wss');
const protocol = `alchemy-sdk-${VERSION}`;
// Use the provided config URL override if it exists, otherwise use the created one.
const ws = new SturdyWebSocket((_a = config.url) !== null && _a !== void 0 ? _a : connection.url, protocol, {
wsConstructor: wsConstructor !== null && wsConstructor !== void 0 ? wsConstructor : getWebsocketConstructor()
});
// Normalize the Alchemy named network input to the network names used by
// ethers. This allows the parent super constructor in JsonRpcProvider to
// correctly set the network.
const ethersNetwork = EthersNetwork[alchemyNetwork];
super(ws, ethersNetwork);
this._events = [];
// In the case of a WebSocket reconnection, all subscriptions are lost and we
// create new ones to replace them, but we want to create the illusion that
// the original subscriptions persist. Thus, maintain a mapping from the
// "virtual" subscription ids which are visible to the consumer to the
// "physical" subscription ids of the actual connections. This terminology is
// borrowed from virtual and physical memory, which has a similar mapping.
/** @internal */
this.virtualSubscriptionsById = new Map();
/** @internal */
this.virtualIdsByPhysicalId = new Map();
/**
* The underlying ethers {@link WebSocketProvider} already handles and emits
* messages. To allow backfilling, track all messages that are emitted.
*
* This is a field arrow function in order to preserve `this` context when
* passing the method as an event listener.
*
* @internal
*/
this.handleMessage = (event) => {
const message = JSON.parse(event.data);
if (!isSubscriptionEvent(message)) {
return;
}
const physicalId = message.params.subscription;
const virtualId = this.virtualIdsByPhysicalId.get(physicalId);
if (!virtualId) {
return;
}
const subscription = this.virtualSubscriptionsById.get(virtualId);
if (subscription.method !== 'eth_subscribe') {
return;
}
switch (subscription.params[0]) {
case 'newHeads': {
const newHeadsSubscription = subscription;
const newHeadsMessage = message;
const { isBackfilling, backfillBuffer } = newHeadsSubscription;
const { result } = newHeadsMessage.params;
if (isBackfilling) {
addToNewHeadsEventsBuffer(backfillBuffer, result);
}
else if (physicalId !== virtualId) {
// In the case of a re-opened subscription, ethers will not emit the
// event, so the SDK has to.
this.emitAndRememberEvent(virtualId, result, getNewHeadsBlockNumber);
}
else {
// Ethers subscription mapping will emit the event, just store it.
this.rememberEvent(virtualId, result, getNewHeadsBlockNumber);
}
break;
}
case 'logs': {
const logsSubscription = subscription;
const logsMessage = message;
const { isBackfilling, backfillBuffer } = logsSubscription;
const { result } = logsMessage.params;
if (isBackfilling) {
addToLogsEventsBuffer(backfillBuffer, result);
}
else if (virtualId !== physicalId) {
this.emitAndRememberEvent(virtualId, result, getLogsBlockNumber);
}
else {
this.rememberEvent(virtualId, result, getLogsBlockNumber);
}
break;
}
default:
if (physicalId !== virtualId) {
// In the case of a re-opened subscription, ethers will not emit the
// event, so the SDK has to.
const { result } = message.params;
this.emitEvent(virtualId, result);
}
}
};
/**
* When the websocket connection reopens:
*
* 1. Resubscribe to all existing subscriptions and start backfilling
* 2. Restart heart beat.
*
* This is a field arrow function in order to preserve `this` context when
* passing the method as an event listener.
*
* @internal
*/
this.handleReopen = () => {
this.virtualIdsByPhysicalId.clear();
const { cancel, isCancelled } = makeCancelToken();
this.cancelBackfill = cancel;
for (const subscription of this.virtualSubscriptionsById.values()) {
void (() => __awaiter(this, void 0, void 0, function* () {
try {
yield this.resubscribeAndBackfill(isCancelled, subscription);
}
catch (error) {
if (!isCancelled()) {
console.error(`Error while backfilling "${subscription.params[0]}" subscription. Some events may be missing.`, error);
}
}
}))();
}
this.startHeartbeat();
};
/**
* Cancels the heartbeat and any pending backfills being performed. This is
* called when the websocket connection goes down or is disconnected.
*
* This is a field arrow function in order to preserve `this` context when
* passing the method as an event listener.
*
* @internal
*/
this.stopHeartbeatAndBackfill = () => {
if (this.heartbeatIntervalId != null) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = undefined;
}
this.cancelBackfill();
};
this.apiKey = apiKey;
// Start heartbeat and backfiller for the websocket connection.
this.backfiller = new WebsocketBackfiller(this);
this.addSocketListeners();
this.startHeartbeat();
this.cancelBackfill = noop;
}
/**
* Overrides the `BaseProvider.getNetwork` method as implemented by ethers.js.
*
* This override allows the SDK to set the provider's network to values not
* yet supported by ethers.js.
*
* @internal
* @override
*/
static getNetwork(network) {
if (typeof network === 'string' && network in CustomNetworks) {
return CustomNetworks[network];
}
// Call the standard ethers.js getNetwork method for other networks.
return getNetwork(network);
}
/**
* Overridden implementation of ethers that includes Alchemy based subscriptions.
*
* @param eventName Event to subscribe to
* @param listener The listener function to call when the event is triggered.
* @override
* @public
*/
// TODO: Override `Listener` type to get type autocompletions.
on(eventName, listener) {
return this._addEventListener(eventName, listener, false);
}
/**
* Overridden implementation of ethers that includes Alchemy based
* subscriptions. Adds a listener to the triggered for only the next
* {@link eventName} event, after which it will be removed.
*
* @param eventName Event to subscribe to
* @param listener The listener function to call when the event is triggered.
* @override
* @public
*/
// TODO: Override `Listener` type to get type autocompletions.
once(eventName, listener) {
return this._addEventListener(eventName, listener, true);
}
/**
* Removes the provided {@link listener} for the {@link eventName} event. If no
* listener is provided, all listeners for the event will be removed.
*
* @param eventName Event to unlisten to.
* @param listener The listener function to remove.
* @override
* @public
*/
off(eventName, listener) {
if (isAlchemyEvent(eventName)) {
return this._off(eventName, listener);
}
else {
return super.off(eventName, listener);
}
}
/**
* Remove all listeners for the provided {@link eventName} event. If no event
* is provided, all events and their listeners are removed.
*
* @param eventName The event to remove all listeners for.
* @override
* @public
*/
removeAllListeners(eventName) {
if (eventName !== undefined && isAlchemyEvent(eventName)) {
return this._removeAllListeners(eventName);
}
else {
return super.removeAllListeners(eventName);
}
}
/**
* Returns the number of listeners for the provided {@link eventName} event. If
* no event is provided, the total number of listeners for all events is returned.
*
* @param eventName The event to get the number of listeners for.
* @public
* @override
*/
listenerCount(eventName) {
if (eventName !== undefined && isAlchemyEvent(eventName)) {
return this._listenerCount(eventName);
}
else {
return super.listenerCount(eventName);
}
}
/**
* Returns an array of listeners for the provided {@link eventName} event. If
* no event is provided, all listeners will be included.
*
* @param eventName The event to get the listeners for.
* @public
* @override
*/
listeners(eventName) {
if (eventName !== undefined && isAlchemyEvent(eventName)) {
return this._listeners(eventName);
}
else {
return super.listeners(eventName);
}
}
/**
* Overrides the method in `BaseProvider` in order to properly format the
* Alchemy subscription events.
*
* @internal
* @override
*/
_addEventListener(eventName, listener, once) {
if (isAlchemyEvent(eventName)) {
verifyAlchemyEventName(eventName);
const event = new EthersEvent(getAlchemyEventTag(eventName), listener, once);
this._events.push(event);
this._startEvent(event);
return this;
}
else {
return super._addEventListener(eventName, listener, once);
}
}
/**
* Overrides the `_startEvent()` method in ethers.js's
* {@link WebSocketProvider} to include additional alchemy methods.
*
* @param event
* @override
* @internal
*/
_startEvent(event) {
// Check if the event type is a custom Alchemy subscription.
const customLogicTypes = [...ALCHEMY_EVENT_TYPES, 'block', 'filter'];
if (customLogicTypes.includes(event.type)) {
this.customStartEvent(event);
}
else {
super._startEvent(event);
}
}
/**
* Overridden from ethers.js's {@link WebSocketProvider}
*
* Modified in order to add mappings for backfilling.
*
* @internal
* @override
*/
_subscribe(tag, param, processFunc, event) {
return __awaiter(this, void 0, void 0, function* () {
let subIdPromise = this._subIds[tag];
// BEGIN MODIFIED CODE
const startingBlockNumber = yield this.getBlockNumber();
// END MODIFIED CODE
if (subIdPromise == null) {
subIdPromise = Promise.all(param).then(param => {
return this.send('eth_subscribe', param);
});
this._subIds[tag] = subIdPromise;
}
const subId = yield subIdPromise;
// BEGIN MODIFIED CODE
const resolvedParams = yield Promise.all(param);
this.virtualSubscriptionsById.set(subId, {
event: event,
method: 'eth_subscribe',
params: resolvedParams,
startingBlockNumber,
virtualId: subId,
physicalId: subId,
sentEvents: [],
isBackfilling: false,
backfillBuffer: []
});
this.virtualIdsByPhysicalId.set(subId, subId);
// END MODIFIED CODE
this._subs[subId] = { tag, processFunc };
});
}
/**
* DO NOT MODIFY.
*
* Original code copied over from ether.js's `BaseProvider`.
*
* This method is copied over directly in order to implement Alchemy's unique
* subscription types. The only difference is that this method calls
* {@link getAlchemyEventTag} instead of the original `getEventTag()` method in
* order to parse the Alchemy subscription event.
*
* @internal
* @override
*/
emit(eventName, ...args) {
if (isAlchemyEvent(eventName)) {
let result = false;
const stopped = [];
// This line is the only modified line from the original method.
const eventTag = getAlchemyEventTag(eventName);
this._events = this._events.filter(event => {
if (event.tag !== eventTag) {
return true;
}
setTimeout(() => {
event.listener.apply(this, args);
}, 0);
result = true;
if (event.once) {
stopped.push(event);
return false;
}
return true;
});
stopped.forEach(event => {
this._stopEvent(event);
});
return result;
}
else {
return super.emit(eventName, ...args);
}
}
/** @internal */
sendBatch(parts) {
return __awaiter(this, void 0, void 0, function* () {
let nextId = 0;
const payload = parts.map(({ method, params }) => {
return {
method,
params,
jsonrpc: '2.0',
id: `alchemy-sdk:${nextId++}`
};
});
return this.sendBatchConcurrently(payload);
});
}
/** @override */
destroy() {
this.removeSocketListeners();
this.stopHeartbeatAndBackfill();
return super.destroy();
}
/**
* Overrides the ether's `isCommunityResource()` method. Returns true if the
* current api key is the default key.
*
* @override
*/
isCommunityResource() {
return this.apiKey === DEFAULT_ALCHEMY_API_KEY;
}
/**
* DO NOT MODIFY.
*
* Original code copied over from ether.js's `WebSocketProvider._stopEvent()`.
*
* This method is copied over directly in order to support Alchemy's
* subscription type by allowing the provider to properly stop Alchemy's
* subscription events.
*
* @internal
*/
_stopEvent(event) {
let tag = event.tag;
// START MODIFIED CODE
if (ALCHEMY_EVENT_TYPES.includes(event.type)) {
// There are remaining pending transaction listeners.
if (this._events.filter(e => ALCHEMY_EVENT_TYPES.includes(e.type)).length) {
return;
}
// END MODIFIED CODE
}
else if (event.type === 'tx') {
// There are remaining transaction event listeners
if (this._events.filter(e => e.type === 'tx').length) {
return;
}
tag = 'tx';
}
else if (this.listenerCount(event.event)) {
// There are remaining event listeners
return;
}
const subId = this._subIds[tag];
if (!subId) {
return;
}
delete this._subIds[tag];
void subId.then(subId => {
if (!this._subs[subId]) {
return;
}
delete this._subs[subId];
void this.send('eth_unsubscribe', [subId]);
});
}
/** @internal */
addSocketListeners() {
this._websocket.addEventListener('message', this.handleMessage);
this._websocket.addEventListener('reopen', this.handleReopen);
this._websocket.addEventListener('down', this.stopHeartbeatAndBackfill);
}
/** @internal */
removeSocketListeners() {
this._websocket.removeEventListener('message', this.handleMessage);
this._websocket.removeEventListener('reopen', this.handleReopen);
this._websocket.removeEventListener('down', this.stopHeartbeatAndBackfill);
}
/**
* Reopens the backfill based on
*
* @param isCancelled
* @param subscription
* @internal
*/
resubscribeAndBackfill(isCancelled, subscription) {
return __awaiter(this, void 0, void 0, function* () {
const { virtualId, method, params, sentEvents, backfillBuffer, startingBlockNumber } = subscription;
subscription.isBackfilling = true;
backfillBuffer.length = 0;
try {
const physicalId = yield this.send(method, params);
throwIfCancelled(isCancelled);
subscription.physicalId = physicalId;
this.virtualIdsByPhysicalId.set(physicalId, virtualId);
switch (params[0]) {
case 'newHeads': {
const backfillEvents = yield withBackoffRetries(() => withTimeout(this.backfiller.getNewHeadsBackfill(isCancelled, sentEvents, startingBlockNumber), BACKFILL_TIMEOUT), BACKFILL_RETRIES, () => !isCancelled());
throwIfCancelled(isCancelled);
const events = dedupeNewHeads([...backfillEvents, ...backfillBuffer]);
events.forEach(event => this.emitNewHeadsEvent(virtualId, event));
break;
}
case 'logs': {
const filter = params[1] || {};
const backfillEvents = yield withBackoffRetries(() => withTimeout(this.backfiller.getLogsBackfill(isCancelled, filter, sentEvents, startingBlockNumber), BACKFILL_TIMEOUT), BACKFILL_RETRIES, () => !isCancelled());
throwIfCancelled(isCancelled);
const events = dedupeLogs([...backfillEvents, ...backfillBuffer]);
events.forEach(event => this.emitLogsEvent(virtualId, event));
break;
}
default:
break;
}
}
finally {
subscription.isBackfilling = false;
backfillBuffer.length = 0;
}
});
}
/** @internal */
emitNewHeadsEvent(virtualId, result) {
this.emitAndRememberEvent(virtualId, result, getNewHeadsBlockNumber);
}
/** @internal */
emitLogsEvent(virtualId, result) {
this.emitAndRememberEvent(virtualId, result, getLogsBlockNumber);
}
/**
* Emits an event to consumers, but also remembers it in its subscriptions's
* `sentEvents` buffer so that we can detect re-orgs if the connection drops
* and needs to be reconnected.
*
* @internal
*/
emitAndRememberEvent(virtualId, result, getBlockNumber) {
this.rememberEvent(virtualId, result, getBlockNumber);
this.emitEvent(virtualId, result);
}
emitEvent(virtualId, result) {
const subscription = this.virtualSubscriptionsById.get(virtualId);
if (!subscription) {
return;
}
this.emitGenericEvent(subscription, result);
}
/** @internal */
rememberEvent(virtualId, result, getBlockNumber) {
const subscription = this.virtualSubscriptionsById.get(virtualId);
if (!subscription) {
return;
}
// Web3 modifies these event objects once we pass them on (changing hex
// numbers to numbers). We want the original event, so make a defensive
// copy.
addToPastEventsBuffer(subscription.sentEvents, Object.assign({}, result), getBlockNumber);
}
/** @internal */
emitGenericEvent(subscription, result) {
const emitFunction = this.emitProcessFn(subscription.event);
emitFunction(result);
}
/**
* Starts a heartbeat that pings the websocket server periodically to ensure
* that the connection stays open.
*
* @internal
*/
startHeartbeat() {
if (this.heartbeatIntervalId != null) {
return;
}
this.heartbeatIntervalId = setInterval(() => __awaiter(this, void 0, void 0, function* () {
try {
yield withTimeout(this.send('net_version'), HEARTBEAT_WAIT_TIME);
}
catch (_a) {
this._websocket.reconnect();
}
}), HEARTBEAT_INTERVAL);
}
/**
* This method sends the batch concurrently as individual requests rather than
* as a batch, which was the original implementation. The original batch logic
* is preserved in this implementation in order for faster porting.
*
* @param payload
* @internal
*/
// TODO(cleanup): Refactor and remove usages of `sendBatch()`.
// TODO(errors): Use allSettled() once we have more error handling.
sendBatchConcurrently(payload) {
return __awaiter(this, void 0, void 0, function* () {
return Promise.all(payload.map(req => this.send(req.method, req.params)));
});
}
/** @internal */
customStartEvent(event) {
if (event.type === ALCHEMY_PENDING_TRANSACTIONS_EVENT_TYPE) {
const { fromAddress, toAddress, hashesOnly } = event;
void this._subscribe(event.tag, [
AlchemySubscription.PENDING_TRANSACTIONS,
{ fromAddress, toAddress, hashesOnly }
], this.emitProcessFn(event), event);
}
else if (event.type === ALCHEMY_MINED_TRANSACTIONS_EVENT_TYPE) {
const { addresses, includeRemoved, hashesOnly } = event;
void this._subscribe(event.tag, [
AlchemySubscription.MINED_TRANSACTIONS,
{ addresses, includeRemoved, hashesOnly }
], this.emitProcessFn(event), event);
}
else if (event.type === 'block') {
void this._subscribe('block', ['newHeads'], this.emitProcessFn(event), event);
}
else if (event.type === 'filter') {
void this._subscribe(event.tag, ['logs', this._getFilter(event.filter)], this.emitProcessFn(event), event);
}
}
/** @internal */
emitProcessFn(event) {
switch (event.type) {
case ALCHEMY_PENDING_TRANSACTIONS_EVENT_TYPE:
return result => this.emit({
method: AlchemySubscription.PENDING_TRANSACTIONS,
fromAddress: event.fromAddress,
toAddress: event.toAddress,
hashesOnly: event.hashesOnly
}, result);
case ALCHEMY_MINED_TRANSACTIONS_EVENT_TYPE:
return result => this.emit({
method: AlchemySubscription.MINED_TRANSACTIONS,
addresses: event.addresses,
includeRemoved: event.includeRemoved,
hashesOnly: event.hashesOnly
}, result);
case 'block':
return result => {
const blockNumber = BigNumber.from(result.number).toNumber();
this._emitted.block = blockNumber;
this.emit('block', blockNumber);
};
case 'filter':
return result => {
if (result.removed == null) {
result.removed = false;
}
this.emit(event.filter, this.formatter.filterLog(result));
};
default:
throw new Error('Invalid event type to `emitProcessFn()`');
}
}
/**
* DO NOT MODIFY.
*
* Original code copied over from ether.js's `BaseProvider.off()`.
*
* This method is copied over directly in order to implement Alchemy's unique
* subscription types. The only difference is that this method calls
* {@link getAlchemyEventTag} instead of the original `getEventTag()` method in
* order to parse the Alchemy subscription event.
*
* @private
*/
_off(eventName, listener) {
if (listener == null) {
return this.removeAllListeners(eventName);
}
const stopped = [];
let found = false;
const eventTag = getAlchemyEventTag(eventName);
this._events = this._events.filter(event => {
if (event.tag !== eventTag || event.listener != listener) {
return true;
}
if (found) {
return true;
}
found = true;
stopped.push(event);
return false;
});
stopped.forEach(event => {
this._stopEvent(event);
});
return this;
}
/**
* DO NOT MODIFY.
*
* Original code copied over from ether.js's `BaseProvider.removeAllListeners()`.
*
* This method is copied over directly in order to implement Alchemy's unique
* subscription types. The only difference is that this method calls
* {@link getAlchemyEventTag} instead of the original `getEventTag()` method in
* order to parse the Alchemy subscription event.
*
* @private
*/
_removeAllListeners(eventName) {
let stopped = [];
if (eventName == null) {
stopped = this._events;
this._events = [];
}
else {
const eventTag = getAlchemyEventTag(eventName);
this._events = this._events.filter(event => {
if (event.tag !== eventTag) {
return true;
}
stopped.push(event);
return false;
});
}
stopped.forEach(event => {
this._stopEvent(event);
});
return this;
}
/**
* DO NOT MODIFY.
*
* Original code copied over from ether.js's `BaseProvider.listenerCount()`.
*
* This method is copied over directly in order to implement Alchemy's unique
* subscription types. The only difference is that this method calls
* {@link getAlchemyEventTag} instead of the original `getEventTag()` method in
* order to parse the Alchemy subscription event.
*
* @private
*/
_listenerCount(eventName) {
if (!eventName) {
return this._events.length;
}
const eventTag = getAlchemyEventTag(eventName);
return this._events.filter(event => {
return event.tag === eventTag;
}).length;
}
/**
* DO NOT MODIFY.
*
* Original code copied over from ether.js's `BaseProvider.listeners()`.
*
* This method is copied over directly in order to implement Alchemy's unique
* subscription types. The only difference is that this method calls
* {@link getAlchemyEventTag} instead of the original `getEventTag()` method in
* order to parse the Alchemy subscription event.
*
* @private
*/
_listeners(eventName) {
if (eventName == null) {
return this._events.map(event => event.listener);
}
const eventTag = getAlchemyEventTag(eventName);
return this._events
.filter(event => event.tag === eventTag)
.map(event => event.listener);
}
}
function getWebsocketConstructor() {
return isNodeEnvironment() ? require('websocket').w3cwebsocket : WebSocket;
}
function isNodeEnvironment() {
return (typeof process !== 'undefined' &&
process != null &&
process.versions != null &&
process.versions.node != null);
}
// TODO(cleanup): Use class variable rather than passing `isCancelled` everywhere.
function makeCancelToken() {
let cancelled = false;
return { cancel: () => (cancelled = true), isCancelled: () => cancelled };
}
// TODO(cleanup): replace with SDK's backoff implementation
const MIN_RETRY_DELAY = 1000;
const RETRY_BACKOFF_FACTOR = 2;
const MAX_RETRY_DELAY = 30000;
function withBackoffRetries(f, retryCount, shouldRetry = () => true) {
return __awaiter(this, void 0, void 0, function* () {
let nextWaitTime = 0;
let i = 0;
while (true) {
try {
return yield f();
}
catch (error) {
i++;
if (i >= retryCount || !shouldRetry(error)) {
throw error;
}
yield delay(nextWaitTime);
if (!shouldRetry(error)) {
throw error;
}
nextWaitTime =
nextWaitTime === 0
? MIN_RETRY_DELAY
: Math.min(MAX_RETRY_DELAY, RETRY_BACKOFF_FACTOR * nextWaitTime);
}
}
});
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms))
]);
}
function getNewHeadsBlockNumber(event) {
return fromHex(event.number);
}
function getLogsBlockNumber(event) {
return fromHex(event.blockNumber);
}
function isResponse(message) {
return (Array.isArray(message) ||
(message.jsonrpc === '2.0' && message.id !== undefined));
}
function isSubscriptionEvent(message) {
return !isResponse(message);
}
function addToNewHeadsEventsBuffer(pastEvents, event) {
addToPastEventsBuffer(pastEvents, event, getNewHeadsBlockNumber);
}
function addToLogsEventsBuffer(pastEvents, event) {
addToPastEventsBuffer(pastEvents, event, getLogsBlockNumber);
}
/**
* Adds a new event to an array of events, evicting any events which are so old
* that they will no longer feasibly be part of a reorg.
*/
function addToPastEventsBuffer(pastEvents, event, getBlockNumber) {
const currentBlockNumber = getBlockNumber(event);
// Find first index of an event recent enough to retain, then drop everything
// at a lower index.
const firstGoodIndex = pastEvents.findIndex(e => getBlockNumber(e) > currentBlockNumber - RETAINED_EVENT_BLOCK_COUNT);
if (firstGoodIndex === -1) {
pastEvents.length = 0;
}
else {
pastEvents.splice(0, firstGoodIndex);
}
pastEvents.push(event);
}
export { AlchemyWebSocketProvider };
//# sourceMappingURL=alchemy-websocket-provider-948c1ea8.js.map