Skip to content

Use a KMS key with Alchemy Wallet APIs ​

This guide makes a KMS key the signer of a smart wallet through Alchemy's Wallet APIs, for example to have gas sponsored. It checks the key's signatures without sending anything, then sends a call.

You need a working KMS key setup, such as Set up an AWS KMS key, and to know what a viem account is; no prior knowledge of ERC-4337 or EIP-7702 is needed.

NOTE

Audience: users who want a KMS key to own a smart wallet through Alchemy's Wallet APIs.

Checked with @alchemy/wallet-apis 5.3.0 on 2026-10-08, with viem 2.57.1 and Hardhat 3.18.0. The script in step 3 ran on that date with an AWS KMS key on Sepolia: the authorization, a personal_sign over an example user operation hash and a typed-data signature each recovered to the key's address, and nothing was sent. The send in step 4 needs an Alchemy API key and was not run. Its checkPreparedCalls call is tested against responses that prepareCalls of @alchemy/wallet-apis 5.3.0 decoded, on 2026-10-16. This repository does not install @alchemy/wallet-apis, so its checks do not typecheck the scripts in steps 3 and 4; a later release of the package can change the calls they make.

connection.kms.getAccount returns a viem LocalAccount for a KMS key (Library accounts reference). @alchemy/wallet-apis takes a LocalAccount as the signer of a smart wallet, so the KMS key can be that signer with no adapter. The plugin adds nothing Alchemy-specific.

What the key signs and what Alchemy does ​

By default the Wallet APIs use EIP-7702: the KMS key's own address becomes the smart wallet, delegated to Alchemy's Modular Account v2 contract. The client's account is the signer's address unless you pass another (src/client.ts in the package). Alchemy's EIP-7702 page gives the default delegate as Modular Account v2 v1.1.0 at 0x77021100bD87b7008E5E1989d0eB38555d0d0000.

StepWho does it
Build the user operation, estimate gasAlchemy, in prepareCalls (wallet_prepareCalls)
Apply a gas sponsorship policyAlchemy, when you pass a policy id
Sign the EIP-7702 authorizationThe KMS key, through signAuthorization, on the first send on a chain only
Sign the user operationThe KMS key, through signMessage (personal_sign) or signTypedData (eth_signTypedData_v4)
Bundle and submitAlchemy, in sendPreparedCalls

Alchemy's response says which of the two user operation signature types to use, and the client passes it to the account unchanged (src/actions/signSignatureRequest.ts). The KMS key never signs a transaction for the Wallet APIs: Alchemy's third-party signer page notes that signTransaction is unused by the client.

Each signature goes through the plugin's usual checks before the client gets it: low S, the recovery bit, and a check that it recovers to the key's address (Every signature is verified). The account also refuses an authorization for another chain than its connection's, and typed data whose domain names another chain, so connect to the chain the smart wallet client uses.

If you used the Account Kit packages before: @alchemy/wallet-apis replaces @account-kit/wallet-client, @account-kit/infra and @aa-sdk/core, and the LocalAccountSigner and WalletClientSigner wrappers are gone; you pass the viem account directly (Alchemy's v5 migration guide).

1. Install the Wallet APIs package ​

@alchemy/wallet-apis 5.3.0 has viem ^2.45.0 as a peer dependency. getAccount needs viem 2.55.13 or later (Library accounts reference), so keep viem at 2.55.13 or later. A new install takes the newest viem release, which meets both ranges:

sh
npm install --save-dev @alchemy/wallet-apis viem
sh
pnpm add --save-dev @alchemy/wallet-apis viem
sh
yarn add --dev @alchemy/wallet-apis viem

2. Configure the key and the network ​

The scripts below use a key named owner with its address pinned, on a sepolia network that lists it in kmsAccounts. Use your own key name and address:

ts
import hardhatKmsAws from "@hardhat-kms/aws";
import { configVariable, defineConfig } from "hardhat/config";

export default defineConfig({
  plugins: [hardhatKmsAws],
  kms: {
    keys: {
      owner: {
        provider: "aws",
        keyId: configVariable("AWS_KMS_KEY_ID"),
        // The key's address, from `npx hardhat kms address owner`.
        address: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826",
      },
    },
  },
  networks: {
    sepolia: {
      type: "http",
      url: configVariable("SEPOLIA_RPC_URL"),
      chainId: 11155111,
      kmsAccounts: ["owner"],
    },
  },
});

3. Check the signatures without sending ​

This script builds the smart wallet client with the KMS account as its signer, then asks the client for the two signatures a first send needs: the EIP-7702 authorization and a personal_sign over a user operation hash. It also signs typed data, the other user operation signature type. Each request goes through signSignatureRequest, the client action that signPreparedCalls calls for each signature a send needs. The script checks that every signature recovers to the key's address.

It needs no Alchemy API key: signing does not call Alchemy, so the transport is never used. It reads the account's nonce and code through the Sepolia connection, and sends nothing. The user operation is an example with fixed gas values; in a real send, Alchemy fills it.

Save it as scripts/check-alchemy-owner.ts:

ts
// The project's hardhat.config.ts loads the plugin's types; the import makes the file stand alone.
import "hardhat-kms";
import { alchemyWalletTransport, createSmartWalletClient } from "@alchemy/wallet-apis";
import hre from "hardhat";
import {
  createPublicClient,
  custom,
  isAddressEqual,
  parseSignature,
  recoverMessageAddress,
  recoverTypedDataAddress,
  type Address,
} from "viem";
import { entryPoint07Address, getUserOperationHash } from "viem/account-abstraction";
import { sepolia } from "viem/chains";
import { recoverAuthorizationAddress } from "viem/utils";

// The default EIP-7702 delegate of the Wallet APIs, from Alchemy's EIP-7702 page.
const MODULAR_ACCOUNT_V2: Address = "0x77021100bD87b7008E5E1989d0eB38555d0d0000";

const ownerAddress = hre.config.kms.keys["owner"]?.address;
if (ownerAddress === undefined) {
  throw new Error("pin the owner key's address in hardhat.config.ts first");
}

const connection = await hre.network.create("sepolia");
const owner = await connection.kms.getAccount(ownerAddress);
const client = createSmartWalletClient({
  signer: owner,
  // Signing does not call Alchemy, so this check runs without an API key.
  transport: alchemyWalletTransport({ apiKey: process.env.ALCHEMY_API_KEY ?? "unused" }),
  chain: sepolia,
});
const publicClient = createPublicClient({ chain: sepolia, transport: custom(connection.provider) });

// With EIP-7702, the smart wallet is the key's own address.
console.log("smart wallet:", client.account.address);
const nonce = await publicClient.getTransactionCount({ address: owner.address });
const code = await publicClient.getCode({ address: owner.address });
console.log("delegated already:", code !== undefined);

// 1. The EIP-7702 authorization of a first send, for the account's current nonce.
const authorization = await client.signSignatureRequest({
  type: "eip7702Auth",
  data: { address: MODULAR_ACCOUNT_V2, chainId: sepolia.id, nonce },
});
const { r, s, yParity = 0 } = parseSignature(authorization.data);
const authorizationSigner = await recoverAuthorizationAddress({
  authorization: { address: MODULAR_ACCOUNT_V2, chainId: sepolia.id, nonce, r, s, yParity },
});
console.log("authorization signed by the key:", isAddressEqual(authorizationSigner, owner.address));

// 2. A user operation for EntryPoint v0.7, which Modular Account v2 uses (src/ma-v2/mav2StaticImpl.ts
// in @alchemy/smart-accounts 5.2.0), signed as personal_sign over its hash.
const userOpHash = getUserOperationHash({
  chainId: sepolia.id,
  entryPointAddress: entryPoint07Address,
  entryPointVersion: "0.7",
  userOperation: {
    sender: owner.address,
    nonce: 0n,
    callData: "0x",
    callGasLimit: 100_000n,
    verificationGasLimit: 100_000n,
    preVerificationGas: 50_000n,
    maxFeePerGas: 2_000_000_000n,
    maxPriorityFeePerGas: 1_000_000_000n,
    signature: "0x",
  },
});
const userOpSignature = await client.signSignatureRequest({
  type: "personal_sign",
  data: { raw: userOpHash },
});
const userOpSigner = await recoverMessageAddress({
  message: { raw: userOpHash },
  signature: userOpSignature.data,
});
console.log("user operation signed by the key:", isAddressEqual(userOpSigner, owner.address));

// 3. Typed data, the other user operation signature type.
const typedData = {
  domain: { name: "check", version: "1", chainId: sepolia.id, verifyingContract: owner.address },
  types: { Check: [{ name: "hash", type: "bytes32" }] },
  primaryType: "Check",
  message: { hash: userOpHash },
} as const;
const typedDataSignature = await client.signSignatureRequest({
  type: "eth_signTypedData_v4",
  data: typedData,
});
const typedDataSigner = await recoverTypedDataAddress({
  ...typedData,
  signature: typedDataSignature.data,
});
console.log("typed data signed by the key:", isAddressEqual(typedDataSigner, owner.address));

await connection.close();

Run it with the variables the config reads set, such as SEPOLIA_RPC_URL:

sh
npx hardhat run scripts/check-alchemy-owner.ts

Each signed by the key line prints true. Each signature is usually one KMS sign request; retries can add more (How many sign requests one call can send).

4. Send a call ​

A send needs an Alchemy API key, and a gas sponsorship policy if Alchemy pays the gas. Both come from the Alchemy dashboard (Sponsor gas). Keep the API key out of the config and out of git: this script reads it, and the policy id, from environment variables.

sendCalls runs prepareCalls, signPreparedCalls and sendPreparedCalls in turn. On the first send on a chain, the KMS key signs the authorization and the user operation; after that, only the user operation.

WARNING

In a send, the KMS key signs what Alchemy's server returns from prepareCalls: the user operation hash in the signature request, and on the first send the EIP-7702 authorization. @alchemy/wallet-apis 5.3.0 signs that hash as returned. It does not recompute the hash or compare it with the calls you passed (signPreparedCalls in src/actions/signPreparedCalls.ts hands each request to signSignatureRequest in src/actions/signSignatureRequest.ts, which signs it). From the first send on, a signature over a user operation hash authorizes spending from the address: the authorization and the first user operation land together.

sendCalls signs right after prepareCalls, with no point to check in between. To check first, call prepareCalls, then signPreparedCalls and sendPreparedCalls yourself. On a first send prepareCalls returns an array holding the authorization and the user operation. Before signPreparedCalls, verify:

  • The signature request type. Require signatureRequest.type to be "personal_sign" with data in the form { raw }, and refuse anything else. The response may instead ask for eth_signTypedData_v4, which the SDK also signs as given.
  • The sender and chain. The user operation's data.sender is the key's address, and the prepared call's chainId is your chain's.
  • The user operation hash. Recompute it with viem's getUserOperationHash for EntryPoint v0.7 at 0x0000000071727De22E5E9d8BAf0edAc6f37da032, from the returned user operation (data) and your own chain id, not the returned one. It must equal signatureRequest.data.raw.
  • The calls. Modular Account v2 encodes calls as execute(target, value, data) or executeBatch((target, value, data)[]), sometimes behind the executeUserOp selector 0x8dd7712f; Alchemy's own example encodes a single call as executeBatch. Compare the decoded calls with yours exactly: the count, the order, and each to, value and data. Compare to with isAddressEqual, and count a missing value as 0 and missing data as 0x. Any other function of the account, such as upgradeToAndCall, and any unknown selector is a mismatch.
  • The paymaster, when you pass a policy id. The user operation names a paymaster and feePayment.sponsored is true.
  • The gas cost, when you pass no policy id. The account then pays whatever gas price and limits the response names: EntryPoint v0.7 takes the sum of the gas limits (call, verification, pre-verification and any paymaster limits) times maxFeePerGas up front, and the bundler keeps what the user operation is charged. Set a cap in wei and refuse a response above it.
  • The delegate. On a first send, the authorization's data.address is the delegate you expect, such as 0x77021100bD87b7008E5E1989d0eB38555d0d0000, and its chainId is your chain's. The address's code is empty or already delegates to that contract, so the authorization never moves the account to another delegate. On a later send, the code delegates to that contract. The authorization's data.nonce is the address's nonce now: a later nonce would leave a signed delegation that anyone can apply once the address reaches it.

checkPreparedCalls from hardhat-kms/alchemy makes each of these checks, and the script below calls it before signing. It throws an error from the core.alchemy group of the error reference when a check fails, and nothing is signed.

checkPreparedCalls needs hardhat-kms 1.1.0 or later, and viem, as getAccount does (API reference). It reads no chain state, so the script reads the address's code with getCode and its nonce with getTransactionCount before prepareCalls. Pass accountNonce so the authorization's nonce is checked; without it, the check leaves the nonce alone. Leave sponsored at true only when the client or prepareCalls has a policy id. Without a policy, pass sponsored: false and maxGasCost, the most in wei you accept the account to pay for gas, such as parseEther("0.001"); the check requires it.

ts
import "hardhat-kms";
import { alchemyWalletTransport, createSmartWalletClient } from "@alchemy/wallet-apis";
import hre from "hardhat";
import { checkPreparedCalls } from "hardhat-kms/alchemy";
import { createPublicClient, custom, type Address, type Hex } from "viem";
import { sepolia } from "viem/chains";

// The only delegate the key may sign an authorization for: Modular Account v2 v1.1.0, the default.
const DELEGATE: Address = "0x77021100bD87b7008E5E1989d0eB38555d0d0000";

const apiKey = process.env.ALCHEMY_API_KEY;
const policyId = process.env.ALCHEMY_POLICY_ID;
const ownerAddress = hre.config.kms.keys["owner"]?.address;
if (apiKey === undefined || policyId === undefined || ownerAddress === undefined) {
  throw new Error("set ALCHEMY_API_KEY and ALCHEMY_POLICY_ID, and pin the owner key's address");
}

const connection = await hre.network.create("sepolia");
const owner = await connection.kms.getAccount(ownerAddress);
const client = createSmartWalletClient({
  signer: owner,
  transport: alchemyWalletTransport({ apiKey }),
  chain: sepolia,
  paymaster: { policyId },
});
const publicClient = createPublicClient({ chain: sepolia, transport: custom(connection.provider) });

// A call with no data and no value to the zero address, as in Alchemy's own example.
const calls: { to: Address; value: bigint; data: Hex }[] = [
  { to: "0x0000000000000000000000000000000000000000", value: 0n, data: "0x" },
];

const accountCode = await publicClient.getCode({ address: owner.address });
const accountNonce = await publicClient.getTransactionCount({ address: owner.address });
const prepared = await client.prepareCalls({ calls });
// Throws before anything is signed unless the key would sign exactly these calls.
checkPreparedCalls(prepared, {
  calls,
  sender: owner.address,
  chainId: sepolia.id,
  delegate: DELEGATE,
  accountCode,
  accountNonce,
  sponsored: true,
});
const signed = await client.signPreparedCalls(prepared);
const { id } = await client.sendPreparedCalls(signed);
const status = await client.waitForCallsStatus({ id, timeout: 60_000 });
console.log("transaction:", status.receipts?.[0]?.transactionHash);

await connection.close();

Before you delegate a deployer key ​

  • After the first send, the key's address has code: it is delegated to Modular Account v2 on that chain. Tools that treat an address with code as a contract treat it that way too: Return the funds from a KMS address refuses this address as RETURN_TO. The key can still send from it, so returning its own funds works as before. @alchemy/wallet-apis has an undelegateAccount action; its source comment says Alchemy sponsors that transaction only on the Enterprise plan (src/actions/undelegateAccount.ts).
  • After delegation, a personal_sign or typed-data signature from the key can authorize a user operation that spends from the address, whether it comes from kms sign, eth_sign or getAccount().signMessage. Sign messages with this key only through the smart wallet client, and only for calls you prepared.
  • The authorization is signed for the account's nonce at the time prepareCalls runs. If the same key sends a transaction through Hardhat before the user operation is included, the nonce moves on and EIP-7702 skips the authorization. Prepare the calls again after such a send.
  • To keep the key's address free of code, use a separate smart contract account instead: Alchemy's EIP-7702 page shows requestAccount with creationHint: { accountType: "sma-b" }, then sendCalls with that account. The KMS key then signs only user operations, no authorization.

MIT licensed. A community plugin, not affiliated with or endorsed by Nomic Foundation, Amazon Web Services, Google or Microsoft.