@account-kit/infra
Version:
adapters for @aa-sdk/core for interacting with alchemy services
289 lines (262 loc) • 8.94 kB
text/typescript
import {
ChainNotFoundError,
ConnectionConfigSchema,
split,
type ConnectionConfig,
type NoUndefined,
} from "@aa-sdk/core";
import {
createTransport,
http,
type Chain,
type EIP1193RequestFn,
type HttpTransportConfig,
type PublicRpcSchema,
type Transport,
type TransportConfig,
} from "viem";
import { mutateRemoveTrackingHeaders } from "./alchemyTrackerHeaders.js";
import type { AlchemyRpcSchema } from "./client/types.js";
import { AlchemyChainSchema } from "./schema.js";
import { VERSION } from "./version.js";
type Never<T> = T extends object
? {
[K in keyof T]?: never;
}
: never;
type AlchemyConnectionConfig = ConnectionConfig;
type SplitTransportConfig = {
alchemyConnection: AlchemyConnectionConfig;
nodeRpcUrl: string;
};
const alchemyMethods = [
"eth_sendUserOperation",
"eth_estimateUserOperationGas",
"eth_getUserOperationReceipt",
"eth_getUserOperationByHash",
"eth_supportedEntryPoints",
"rundler_maxPriorityFeePerGas",
"pm_getPaymasterData",
"pm_getPaymasterStubData",
"alchemy_requestGasAndPaymasterAndData",
];
const chainAgnosticMethods = [
"wallet_prepareCalls",
"wallet_sendPreparedCalls",
"wallet_requestAccount",
"wallet_createAccount",
"wallet_listAccounts",
"wallet_createSession",
"wallet_getCallsStatus",
"wallet_requestQuote_v0",
];
export type AlchemyTransportConfig = (
| (AlchemyConnectionConfig & Never<SplitTransportConfig>)
| (SplitTransportConfig & Never<AlchemyConnectionConfig>)
) & {
/** The max number of times to retry. */
retryCount?: TransportConfig["retryCount"] | undefined;
/** The base delay (in ms) between retries. */
retryDelay?: TransportConfig["retryDelay"] | undefined;
fetchOptions?: NoUndefined<HttpTransportConfig["fetchOptions"]>;
};
type AlchemyTransportBase = Transport<
"alchemy",
{
alchemyRpcUrl: string;
fetchOptions?: AlchemyTransportConfig["fetchOptions"];
},
EIP1193RequestFn<[...PublicRpcSchema, ...AlchemyRpcSchema]>
>;
export type AlchemyTransport = AlchemyTransportBase & {
updateHeaders(newHeaders: HeadersInit): void;
config: AlchemyTransportConfig;
dynamicFetchOptions: AlchemyTransportConfig["fetchOptions"];
};
/**
* A type guard for the transport to determine if it is an Alchemy transport.
* Used in cases where we would like to do switching depending on the transport, where there used
* to be two clients for an alchemy and a non-alchemy, and with this switch we don't need the two seperate clients. *
*
* @param {Transport} transport The transport to check
* @param {Chain} chain Chain for the transport to run its function to return the transport config
* @returns {boolean} `true` if the transport is an Alchemy transport, otherwise `false`
*/
export function isAlchemyTransport(
transport: Transport,
chain: Chain,
): transport is AlchemyTransport {
return transport({ chain }).config.type === "alchemy";
}
/**
* Creates an Alchemy transport with the specified configuration options.
* When sending all traffic to Alchemy, you must pass in one of rpcUrl, apiKey, or jwt.
* If you want to send Bundler and Paymaster traffic to Alchemy and Node traffic to a different RPC, you must pass in alchemyConnection and nodeRpcUrl.
*
* @example
* ### Basic Example
* If the chain you're using is supported for both Bundler and Node RPCs, then you can do the following:
* ```ts
* import { alchemy } from "@account-kit/infra";
*
* const transport = alchemy({
* // NOTE: you can also pass in an rpcUrl or jwt here or rpcUrl and jwt
* apiKey: "your-api-key",
* });
* ```
*
* ### AA Only Chains
* For AA-only chains, you need to specify the alchemyConnection and nodeRpcUrl since Alchemy only
* handles the Bundler and Paymaster RPCs for these chains.
* ```ts
* import { alchemy } from "@account-kit/infra";
*
* const transport = alchemy({
* alchemyConnection: {
* apiKey: "your-api-key",
* },
* nodeRpcUrl: "https://zora.rpc.url",
* });
* ```
*
* @param {AlchemyTransportConfig} config The configuration object for the Alchemy transport.
* @param {number} config.retryDelay Optional The delay between retries, in milliseconds.
* @param {number} config.retryCount Optional The number of retry attempts.
* @param {string} [config.alchemyConnection] Optional Alchemy connection configuration (if this is passed in, nodeRpcUrl is required).
* @param {string} [config.fetchOptions] Optional fetch options for HTTP requests.
* @param {string} [config.nodeRpcUrl] Optional RPC URL for node (if this is passed in, alchemyConnection is required).
* @param {string} [config.rpcUrl] Optional RPC URL.
* @param {string} [config.apiKey] Optional API key for Alchemy.
* @param {string} [config.jwt] Optional JSON Web Token for authorization.
* @returns {AlchemyTransport} The configured Alchemy transport object.
*/
export function alchemy(config: AlchemyTransportConfig): AlchemyTransport {
const { retryDelay, retryCount = 0 } = config;
// we create a copy here in case we create a split transport down below
// we don't want to add alchemy headers to 3rd party nodes
const fetchOptions = { ...config.fetchOptions };
const connectionConfig = ConnectionConfigSchema.parse(
config.alchemyConnection ?? config,
);
const headersAsObject = convertHeadersToObject(fetchOptions.headers);
// TODO: we probably should just log these headers during telemetry logging instead of doing this mutable header stuff
fetchOptions.headers = {
...headersAsObject,
"Alchemy-AA-Sdk-Version": VERSION,
};
if (connectionConfig.jwt != null || connectionConfig.apiKey != null) {
fetchOptions.headers = {
...fetchOptions.headers,
Authorization: `Bearer ${
connectionConfig.jwt ?? connectionConfig.apiKey
}`,
};
}
const transport: AlchemyTransportBase = (opts) => {
const { chain: chain_ } = opts;
if (!chain_) {
throw new ChainNotFoundError();
}
const chain = AlchemyChainSchema.parse(chain_);
const rpcUrl =
connectionConfig.rpcUrl == null
? chain.rpcUrls.alchemy.http[0]
: connectionConfig.rpcUrl;
const chainAgnosticRpcUrl =
connectionConfig.rpcUrl == null
? "https://api.g.alchemy.com/v2"
: (connectionConfig.chainAgnosticUrl ?? connectionConfig.rpcUrl);
const innerTransport = (() => {
mutateRemoveTrackingHeaders(config?.fetchOptions?.headers);
if (config.alchemyConnection && config.nodeRpcUrl) {
return split({
overrides: [
{
methods: alchemyMethods,
transport: http(rpcUrl, { fetchOptions, retryCount }),
},
{
methods: chainAgnosticMethods,
transport: http(chainAgnosticRpcUrl, {
fetchOptions,
retryCount,
retryDelay,
}),
},
],
fallback: http(config.nodeRpcUrl, {
fetchOptions: config.fetchOptions,
retryCount,
retryDelay,
}),
});
}
return split({
overrides: [
{
methods: chainAgnosticMethods,
transport: http(chainAgnosticRpcUrl, {
fetchOptions,
retryCount,
retryDelay,
}),
},
],
fallback: http(rpcUrl, { fetchOptions, retryCount, retryDelay }),
});
})();
return createTransport(
{
key: "alchemy",
name: "Alchemy Transport",
request: innerTransport({
...opts,
// Retries are already handled above within the split transport,
// so `retryCount` must be 0 here for the expected behavior.
retryCount: 0,
}).request,
// Retries are already handled above within the split transport,
// so `retryCount` must be 0 here too for the expected behavior.
retryCount: 0,
retryDelay,
type: "alchemy",
},
{ alchemyRpcUrl: rpcUrl, fetchOptions },
);
};
return Object.assign(transport, {
dynamicFetchOptions: fetchOptions,
updateHeaders(newHeaders_: HeadersInit) {
const newHeaders = convertHeadersToObject(newHeaders_);
fetchOptions.headers = {
...fetchOptions.headers,
...newHeaders,
};
},
config,
});
}
export const convertHeadersToObject = (
headers?: HeadersInit,
): Record<string, string> => {
if (!headers) {
return {};
}
if (headers instanceof Headers) {
const headersObject = {} as Record<string, string>;
headers.forEach((value, key) => {
headersObject[key] = value;
});
return headersObject;
}
if (Array.isArray(headers)) {
return headers.reduce(
(acc, header) => {
acc[header[0]] = header[1];
return acc;
},
{} as Record<string, string>,
);
}
return headers;
};