@account-kit/infra
Version:
adapters for @aa-sdk/core for interacting with alchemy services
527 lines (487 loc) • 16.8 kB
text/typescript
import type {
Address,
ClientMiddlewareConfig,
ClientMiddlewareFn,
EntryPointVersion,
Multiplier,
SmartContractAccount,
UserOperationContext,
UserOperationFeeOptions,
UserOperationOverrides,
UserOperationRequest,
} from "@aa-sdk/core";
import {
bypassPaymasterAndData,
ChainNotFoundError,
clientHeaderTrack,
deepHexlify,
defaultGasEstimator,
erc7677Middleware,
filterUndefined,
isBigNumberish,
isMultiplier,
noopMiddleware,
resolveProperties,
} from "@aa-sdk/core";
import {
fromHex,
isHex,
type Hex,
encodeAbiParameters,
encodeFunctionData,
parseAbi,
} from "viem";
import type { AlchemySmartAccountClient } from "../client/smartAccountClient.js";
import type { AlchemyTransport } from "../alchemyTransport.js";
import { alchemyFeeEstimator } from "./feeEstimator.js";
import type { RequestGasAndPaymasterAndDataRequest } from "../actions/types.js";
import {
PermitTypes,
EIP7597Abis,
ERC20Abis,
getAlchemyPaymasterAddress,
} from "../gas-manager.js";
import type { PermitMessage, PermitDomain } from "../gas-manager.js";
import type { MiddlewareClient } from "@aa-sdk/core";
import { InvalidSignedPermit } from "../errors/invalidSignedPermit.js";
export type PaymasterContext = {
policyId: string | string[];
erc20Context?: {
tokenAddress: Address;
maxTokenAmount?: bigint;
permit?: Hex;
};
webhookData?: string;
};
export type PolicyToken = {
address: Address;
maxTokenAmount: bigint;
permit?: {
paymasterAddress?: Address;
autoPermitApproveTo: bigint;
autoPermitBelow: bigint;
erc20Name: string;
version: string;
};
};
/**
* Paymaster middleware factory that uses Alchemy's Gas Manager for sponsoring
* transactions. Adheres to the ERC-7677 standardized communication protocol.
*
* @example
* ```ts
* import { sepolia, alchemyGasManagerMiddleware } from "@account-kit/infra";
* import { http } from "viem";
*
* const client = createSmartAccountClient({
* transport: http("rpc-url"),
* chain: sepolia,
* ...alchemyGasManagerMiddleware("policyId")
* });
* ```
*
* @param {string | string[]} policyId - The policyId (or list of policyIds) for Alchemy's gas manager
* @param {PolicyToken | undefined} policyToken - The policy token configuration
* @param {string | undefined} webhookData - The webhook data to include in the request
* @returns {Pick<ClientMiddlewareConfig, "dummyPaymasterAndData" | "paymasterAndData">} Partial client middleware configuration containing `dummyPaymasterAndData` and `paymasterAndData`
*/
export function alchemyGasManagerMiddleware(
policyId: string | string[],
policyToken?: PolicyToken,
webhookData?: string,
): Required<
Pick<ClientMiddlewareConfig, "dummyPaymasterAndData" | "paymasterAndData">
> {
const buildContext = async (
args: Parameters<ClientMiddlewareFn>[1],
): Promise<PaymasterContext> => {
const context: PaymasterContext = { policyId };
const { account, client } = args;
if (!client.chain) {
throw new ChainNotFoundError();
}
if (policyToken !== undefined) {
context.erc20Context = {
tokenAddress: policyToken.address,
maxTokenAmount: policyToken.maxTokenAmount,
};
if (policyToken.permit !== undefined) {
const permit = await generateSignedPermit(client, account, policyToken);
if (permit !== undefined) {
context.erc20Context.permit = permit;
}
} else if (args.context !== undefined) {
context.erc20Context.permit = extractSignedPermitFromContext(
args.context,
);
}
}
if (webhookData !== undefined) {
context.webhookData = webhookData;
}
return context;
};
return {
dummyPaymasterAndData: async (uo, args) => {
const context = await buildContext(args);
const baseMiddleware = erc7677Middleware({ context });
return baseMiddleware.dummyPaymasterAndData(uo, args);
},
paymasterAndData: async (uo, args) => {
const context = await buildContext(args);
const baseMiddleware = erc7677Middleware({ context });
return baseMiddleware.paymasterAndData(uo, args);
},
};
}
interface AlchemyGasAndPaymasterAndDataMiddlewareParams {
policyId: string | string[];
policyToken?: PolicyToken;
webhookData?: string;
transport: AlchemyTransport;
gasEstimatorOverride?: ClientMiddlewareFn;
feeEstimatorOverride?: ClientMiddlewareFn;
}
/**
* Paymaster middleware factory that uses Alchemy's Gas Manager for sponsoring
* transactions. Uses Alchemy's custom `alchemy_requestGasAndPaymasterAndData`
* method instead of conforming to the standard ERC-7677 interface. Note that
* if you use `createAlchemySmartAccountClient`, this middleware is already
* used by default and you do not need to manually include it.
*
* @example
* ```ts twoslash
* import { sepolia, alchemy, alchemyGasAndPaymasterAndDataMiddleware } from "@account-kit/infra";
* import { createSmartAccountClient } from "@aa-sdk/core";
*
* const client = createSmartAccountClient({
* transport: alchemy({ apiKey: "your-api-key" }),
* chain: sepolia,
* ...alchemyGasAndPaymasterAndDataMiddleware({
* policyId: "policyId",
* transport: alchemy({ apiKey: "your-api-key" }),
* })
* });
* ```
*
* @param {AlchemyGasAndPaymasterAndDataMiddlewareParams} params configuration params
* @param {AlchemyGasAndPaymasterAndDataMiddlewareParams.policyId} params.policyId the policyId for Alchemy's gas manager
* @param {AlchemyGasAndPaymasterAndDataMiddlewareParams.transport} params.transport fallback transport to use for fee estimation when not using the paymaster
* @param {AlchemyGasAndPaymasterAndDataMiddlewareParams.gasEstimatorOverride} params.gasEstimatorOverride custom gas estimator middleware
* @param {AlchemyGasAndPaymasterAndDataMiddlewareParams.feeEstimatorOverride} params.feeEstimatorOverride custom fee estimator middleware
* @returns {Pick<ClientMiddlewareConfig, "dummyPaymasterAndData" | "feeEstimator" | "gasEstimator" | "paymasterAndData">} partial client middleware configuration containing `dummyPaymasterAndData`, `feeEstimator`, `gasEstimator`, and `paymasterAndData`
*/
export function alchemyGasAndPaymasterAndDataMiddleware(
params: AlchemyGasAndPaymasterAndDataMiddlewareParams,
): Pick<
ClientMiddlewareConfig,
"dummyPaymasterAndData" | "feeEstimator" | "gasEstimator" | "paymasterAndData"
> {
const {
policyId,
policyToken,
transport,
webhookData,
gasEstimatorOverride,
feeEstimatorOverride,
} = params;
return {
dummyPaymasterAndData: async (uo, args) => {
if (
// No reason to generate dummy data if we are bypassing the paymaster.
bypassPaymasterAndData(args.overrides) ||
// When using alchemy_requestGasAndPaymasterAndData, there is generally no reason to generate dummy
// data. However, if the gas/feeEstimator is overriden, then this option should be enabled.
!(gasEstimatorOverride || feeEstimatorOverride)
) {
return noopMiddleware(uo, args);
}
// Fall back to the default 7677 dummyPaymasterAndData middleware.
return alchemyGasManagerMiddleware(
policyId,
policyToken,
webhookData,
).dummyPaymasterAndData(uo, args);
},
feeEstimator: (uo, args) => {
return feeEstimatorOverride
? feeEstimatorOverride(uo, args)
: bypassPaymasterAndData(args.overrides)
? alchemyFeeEstimator(transport)(uo, args)
: noopMiddleware(uo, args);
},
gasEstimator: (uo, args) => {
return gasEstimatorOverride
? gasEstimatorOverride(uo, args)
: bypassPaymasterAndData(args.overrides)
? defaultGasEstimator(args.client)(uo, args)
: noopMiddleware(uo, args);
},
paymasterAndData: async (
uo,
{
account,
client: client_,
feeOptions,
overrides: overrides_,
context: uoContext,
},
) => {
const client = clientHeaderTrack(client_, "alchemyFeeEstimator");
if (!client.chain) {
throw new ChainNotFoundError();
}
const userOp = deepHexlify(await resolveProperties(uo));
const overrides: UserOperationOverrides = filterUndefined({
maxFeePerGas: overrideField(
"maxFeePerGas",
overrides_ as UserOperationOverrides,
feeOptions,
userOp,
),
maxPriorityFeePerGas: overrideField(
"maxPriorityFeePerGas",
overrides_ as UserOperationOverrides,
feeOptions,
userOp,
),
callGasLimit: overrideField(
"callGasLimit",
overrides_ as UserOperationOverrides,
feeOptions,
userOp,
),
verificationGasLimit: overrideField(
"verificationGasLimit",
overrides_ as UserOperationOverrides,
feeOptions,
userOp,
),
preVerificationGas: overrideField(
"preVerificationGas",
overrides_ as UserOperationOverrides,
feeOptions,
userOp,
),
...(account.getEntryPoint().version === "0.7.0"
? {
paymasterVerificationGasLimit: overrideField<"0.7.0">(
"paymasterVerificationGasLimit",
overrides_ as UserOperationOverrides<"0.7.0">,
feeOptions,
userOp,
),
paymasterPostOpGasLimit: overrideField<"0.7.0">(
"paymasterPostOpGasLimit",
overrides_ as UserOperationOverrides<"0.7.0">,
feeOptions,
userOp,
),
}
: {}),
});
let erc20Context: RequestGasAndPaymasterAndDataRequest[0]["erc20Context"] =
undefined;
if (policyToken !== undefined) {
erc20Context = {
tokenAddress: policyToken.address,
maxTokenAmount: policyToken.maxTokenAmount,
};
if (policyToken.permit !== undefined) {
const permit = await generateSignedPermit(
client,
account,
policyToken,
);
if (permit !== undefined) {
erc20Context.permit = permit;
}
} else if (uoContext !== undefined) {
erc20Context.permit = extractSignedPermitFromContext(uoContext);
}
}
const result = await (client as AlchemySmartAccountClient).request({
method: "alchemy_requestGasAndPaymasterAndData",
params: [
{
policyId,
entryPoint: account.getEntryPoint().address,
userOperation: userOp,
dummySignature: await account.getDummySignature(),
overrides,
webhookData,
...(erc20Context
? {
erc20Context,
}
: {}),
},
],
});
return {
...uo,
...result,
};
},
};
}
/**
* Utility function to override a field in the user operation request with the overrides or fee options
*
* @template {EntryPointVersion} TEntryPointVersion
* @param {keyof UserOperationFeeOptions<TEntryPointVersion>} field the field to override
* @param {UserOperationOverrides<TEntryPointVersion> | undefined} overrides the overrides object
* @param {UserOperationFeeOptions<TEntryPointVersion> | undefined} feeOptions the fee options object from the client
* @param {UserOperationRequest<TEntryPointVersion>} userOperation the user operation request
* @returns {Hex | Multiplier | undefined} the overridden field value
*/
const overrideField = <
TEntryPointVersion extends EntryPointVersion = EntryPointVersion,
>(
field: keyof UserOperationFeeOptions<TEntryPointVersion>,
overrides: UserOperationOverrides<TEntryPointVersion> | undefined,
feeOptions: UserOperationFeeOptions<TEntryPointVersion> | undefined,
userOperation: UserOperationRequest<TEntryPointVersion>,
): Hex | Multiplier | undefined => {
let _field = field as keyof UserOperationOverrides<TEntryPointVersion>;
if (overrides?.[_field] != null) {
// one-off absolute override
if (isBigNumberish(overrides[_field])) {
return deepHexlify(overrides[_field]);
}
// one-off multiplier overrides
else {
return {
multiplier: Number((overrides[_field] as Multiplier).multiplier),
};
}
}
// provider level fee options with multiplier
if (isMultiplier(feeOptions?.[field])) {
return {
multiplier: Number((feeOptions![field] as Multiplier).multiplier),
};
}
const userOpField =
userOperation[field as keyof UserOperationRequest<TEntryPointVersion>];
if (isHex(userOpField) && fromHex(userOpField as Hex, "bigint") > 0n) {
return userOpField;
}
return undefined;
};
/**
* Utility function to generate a signed Permit for erc20 transaction
*
* @param {MiddlewareClient} client - The Alchemy smart account client
* @param {TAccount} account - The smart account instance
* @param {PolicyToken} policyToken - The policy token configuration
* @returns {Promise<Hex>} Returns a Promise containing the signed EIP2612 permit
*/
const generateSignedPermit = async <TAccount extends SmartContractAccount>(
client: MiddlewareClient,
account: TAccount,
policyToken: PolicyToken,
): Promise<Hex | undefined> => {
if (!client.chain) {
throw new ChainNotFoundError();
}
if (!policyToken.permit) {
throw new Error("permit is missing");
}
if (!policyToken.permit?.erc20Name || !policyToken.permit?.version) {
throw new Error("erc20Name or version is missing");
}
const paymasterAddress =
policyToken.permit.paymasterAddress ??
getAlchemyPaymasterAddress(client.chain, account.getEntryPoint().version);
if (paymasterAddress === undefined || paymasterAddress === "0x") {
throw new Error("no paymaster contract address available");
}
let allowanceFuture = client.call({
to: policyToken.address,
data: encodeFunctionData({
abi: parseAbi(ERC20Abis),
functionName: "allowance",
args: [account.address, paymasterAddress],
}),
});
let nonceFuture = client.call({
to: policyToken.address,
data: encodeFunctionData({
abi: parseAbi(EIP7597Abis),
functionName: "nonces",
args: [account.address],
}),
});
const [allowanceResponse, nonceResponse] = await Promise.all([
allowanceFuture,
nonceFuture,
]);
if (!allowanceResponse.data) {
throw new Error("No allowance returned from erc20 contract call");
}
if (!nonceResponse.data) {
throw new Error("No nonces returned from erc20 contract call");
}
const permitLimit = policyToken.permit.autoPermitApproveTo;
const currentAllowance: bigint = BigInt(allowanceResponse.data);
if (currentAllowance > policyToken.permit.autoPermitBelow) {
// no need to generate permit
return undefined;
}
const nonce = BigInt(nonceResponse.data);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 10);
const typedPermitData = {
types: PermitTypes,
primaryType: "Permit" as const,
domain: {
name: policyToken.permit.erc20Name ?? "",
version: policyToken.permit.version ?? "",
chainId: BigInt(client.chain.id),
verifyingContract: policyToken.address,
} satisfies PermitDomain,
message: {
owner: account.address,
spender: paymasterAddress,
value: permitLimit as bigint,
nonce: nonce,
deadline,
} satisfies PermitMessage,
} as const;
const signedPermit = await account.signTypedData(typedPermitData);
return encodeSignedPermit(permitLimit, deadline, signedPermit);
};
function extractSignedPermitFromContext(
context: UserOperationContext,
): Hex | undefined {
if (context.signedPermit === undefined) {
return undefined;
}
if (typeof context.signedPermit !== "object") {
throw new InvalidSignedPermit("signedPermit is not an object");
}
if (typeof context.signedPermit.value !== "bigint") {
throw new InvalidSignedPermit("signedPermit.value is not a bigint");
}
if (typeof context.signedPermit.deadline !== "bigint") {
throw new InvalidSignedPermit("signedPermit.deadline is not a bigint");
}
if (!isHex(context.signedPermit.signature)) {
throw new InvalidSignedPermit("signedPermit.signature is not a hex string");
}
return encodeSignedPermit(
context.signedPermit.value,
context.signedPermit.deadline,
context.signedPermit.signature,
);
}
function encodeSignedPermit(
value: bigint,
deadline: bigint,
signedPermit: Hex,
) {
return encodeAbiParameters(
[{ type: "uint256" }, { type: "uint256" }, { type: "bytes" }],
[value, deadline, signedPermit],
);
}