Skip to content

Commit

Permalink
feat: eip-7702 support in aa-sdk/core, and sma7702 support in account…
Browse files Browse the repository at this point in the history
…-kit/smart-contracts (#1287)

* feat: sma 7702

* feat: adds 7702 support to alchemy signer (#1269)

* feat: adds initial impl of signAuthorization

* docs: adds signAuthorization documentation

* fix: rename var

* feat: 7702 progress

* feat: update viem and debug 7702 auth

* feat: add deterministically deployed demo nft

* fix: don't re-sign delegations

* feat: update 7702 middleware to higher order function

* chore: merge fixes

* fix: remove unnecessary console log

* fix: correctly await client actions in infra test

* fix: correctly encode yParity in zero case

* feat: correctly set validation entity in nonce and separate encode actions

* feat: consolidate MAv2Base

* feat: add unified ma v2 client (#1309)

* feat: add unified ma v2 client

* chore: update docs

* chore: make account source unique

* feat: add ma v2 account to useSmartAccountClient hook (#1314)

* feat: add unified ma v2 client

* chore: make account source unique

* feat: add ma v2 account to use smart contract client hook

* chore: remove different type names

* chore: rename mav2 to ModularAccountV2

* fix: review fix, add common type

* fix: typo

* fix: remove await

* fix: narrow the type instead of doing non null assertion

* chore: rebaseme

* fix: client type and middleware inclusion

* docs: update docs with twoslash

* feat: update to new json format for auth

* feat: add defaults for useSmartAccountClient to ma v2 (#1328)

* fix: fix MAv2 React Hook client creation for 7702 (#1329)

* fix: rename MAv2 type to mode, add 7702 middleware for react hook

* fix: don't switch MintCard to MAv2 yet

* fix: reconnect 7702 account after page refresh

* chore: remove comment

* docs: regen docs

* fix: fix docs

* fix: remove unnecessary optional chaining

* feat: update eip 7702 auth format

* fix: make mode optional in useSmartAccountClient (#1335)

* chore: move optional mode into account params (#1339)

* chore: move optional mode into account params

* chore: removed unused var

* fix: make type really optional, add defaults for return type (#1338)

* refactor: assert exhaustive account type handling in getSmartAccountClient

---------

Co-authored-by: Linna <[email protected]>
Co-authored-by: howy <[email protected]>
Co-authored-by: jakehobbs <[email protected]>
  • Loading branch information
4 people authored Feb 10, 2025
1 parent df1d99a commit 8d5501f
Show file tree
Hide file tree
Showing 53 changed files with 1,392 additions and 368 deletions.
2 changes: 1 addition & 1 deletion .vitest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"prool": "^0.0.15",
"tar": "^7.4.1",
"typescript-template": "*",
"viem": "2.20.0"
"viem": "2.22.6"
},
"dependencies": {
"@aa-sdk/core": "^4.0.0-alpha.8"
Expand Down
2 changes: 1 addition & 1 deletion aa-sdk/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"zod": "^3.22.4"
},
"peerDependencies": {
"viem": "^2.20.0"
"viem": "^2.22.6"
},
"repository": {
"type": "git",
Expand Down
69 changes: 39 additions & 30 deletions aa-sdk/core/src/account/smartContractAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,11 @@ export type ToSmartContractAccountParams<
getDummySignature: () => Hex | Promise<Hex>;
encodeExecute: (tx: AccountOp) => Promise<Hex>;
encodeBatchExecute?: (txs: AccountOp[]) => Promise<Hex>;
getNonce?: (nonceKey?: bigint) => Promise<bigint>;
// if not provided, will default to just using signMessage over the Hex
signUserOperationHash?: (uoHash: Hex) => Promise<Hex>;
encodeUpgradeToAndCall?: (params: UpgradeToAndCallParams) => Promise<Hex>;
getImplementationAddress?: () => Promise<NullAddress | Address>;
} & Omit<CustomSource, "signTransaction" | "address">;
// [!endregion ToSmartContractAccountParams]

Expand Down Expand Up @@ -260,6 +262,7 @@ export async function toSmartContractAccount<
source,
accountAddress,
getAccountInitCode,
getNonce,
signMessage,
signTypedData,
encodeBatchExecute,
Expand Down Expand Up @@ -339,11 +342,13 @@ export async function toSmartContractAccount(
getAccountInitCode,
signMessage,
signTypedData,
encodeBatchExecute,
encodeExecute,
encodeBatchExecute,
getNonce,
getDummySignature,
signUserOperationHash,
encodeUpgradeToAndCall,
getImplementationAddress,
} = params;

const client = createBundlerClient({
Expand Down Expand Up @@ -410,16 +415,18 @@ export async function toSmartContractAccount(
return initCode === "0x";
};

const getNonce = async (nonceKey = 0n): Promise<bigint> => {
if (!(await isAccountDeployed())) {
return 0n;
}

return entryPointContract.read.getNonce([
accountAddress_,
nonceKey,
]) as Promise<bigint>;
};
const getNonce_ =
getNonce ??
(async (nonceKey = 0n): Promise<bigint> => {
if (!(await isAccountDeployed())) {
return 0n;
}

return entryPointContract.read.getNonce([
accountAddress_,
nonceKey,
]) as Promise<bigint>;
});

const account = toAccount({
address: accountAddress_,
Expand Down Expand Up @@ -468,25 +475,27 @@ export async function toSmartContractAccount(
return create6492Signature(isDeployed, signature);
};

const getImplementationAddress = async (): Promise<NullAddress | Address> => {
const storage = await client.getStorageAt({
address: account.address,
// This is the default slot for the implementation address for Proxies
slot: "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
const getImplementationAddress_ =
getImplementationAddress ??
(async () => {
const storage = await client.getStorageAt({
address: account.address,
// This is the default slot for the implementation address for Proxies
slot: "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
});

if (storage == null) {
throw new FailedToGetStorageSlotError(
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
"Proxy Implementation Address"
);
}

// The storage slot contains a full bytes32, but we want only the last 20 bytes.
// So, slice off the leading `0x` and the first 12 bytes (24 characters), leaving the last 20 bytes, then prefix with `0x`.
return `0x${storage.slice(26)}`;
});

if (storage == null) {
throw new FailedToGetStorageSlotError(
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
"Proxy Implementation Address"
);
}

// The storage slot contains a full bytes32, but we want only the last 20 bytes.
// So, slice off the leading `0x` and the first 12 bytes (24 characters), leaving the last 20 bytes, then prefix with `0x`.
return `0x${storage.slice(26)}`;
};

if (entryPoint.version !== "0.6.0" && entryPoint.version !== "0.7.0") {
throw new InvalidEntryPointError(chain, entryPoint.version);
}
Expand All @@ -510,9 +519,9 @@ export async function toSmartContractAccount(
encodeUpgradeToAndCall: encodeUpgradeToAndCall_,
getEntryPoint: () => entryPoint,
isAccountDeployed,
getAccountNonce: getNonce,
getAccountNonce: getNonce_,
signMessageWith6492,
signTypedDataWith6492,
getImplementationAddress,
getImplementationAddress: getImplementationAddress_,
};
}
14 changes: 14 additions & 0 deletions aa-sdk/core/src/errors/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,17 @@ export class EntityIdOverrideError extends BaseError {
super(`EntityId of 0 is reserved for the owner and cannot be used`);
}
}

/**
* Error class denoting that the provided ma v2 account mode is invalid.
*/
export class InvalidModularAccountV2Mode extends BaseError {
override name = "InvalidModularAccountV2Mode";

/**
* Initializes a new instance of the error message with a default message indicating that the provided ma v2 account mode is invalid.
*/
constructor() {
super(`The provided account mode is invalid for ModularAccount V2`);
}
}
3 changes: 3 additions & 0 deletions aa-sdk/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export {
InvalidEntityIdError,
InvalidNonceKeyError,
EntityIdOverrideError,
InvalidModularAccountV2Mode,
} from "./errors/client.js";
export {
EntryPointNotFoundError,
Expand All @@ -92,6 +93,8 @@ export {
} from "./errors/useroperation.js";
export { LogLevel, Logger } from "./logger.js";
export { middlewareActions } from "./middleware/actions.js";
export { default7702UserOpSigner } from "./middleware/defaults/7702signer.js";
export { default7702GasEstimator } from "./middleware/defaults/7702gasEstimator.js";
export { defaultFeeEstimator } from "./middleware/defaults/feeEstimator.js";
export { defaultGasEstimator } from "./middleware/defaults/gasEstimator.js";
export { defaultPaymasterAndData } from "./middleware/defaults/paymasterAndData.js";
Expand Down
77 changes: 77 additions & 0 deletions aa-sdk/core/src/middleware/defaults/7702gasEstimator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { zeroHash } from "viem";
import { AccountNotFoundError } from "../../errors/account.js";
import type { UserOperationStruct } from "../../types.js";
import type { ClientMiddlewareFn } from "../types";
import { defaultGasEstimator } from "./gasEstimator.js";

/**
* A middleware function to estimate the gas usage of a user operation when using an EIP-7702 delegated account. Has an optional custom gas estimator.
* This function is only compatible with accounts using EntryPoint v0.7.0, and the account must have an implementation address defined in `getImplementationAddress()`.
*
* @example
* ```ts twoslash
* import {
* default7702GasEstimator,
* default7702UserOpSigner,
* createSmartAccountClient,
* type SmartAccountClient,
* } from "@aa-sdk/core";
* import {
* createModularAccountV2,
* type CreateModularAccountV2ClientParams,
* } from "@account-kit/smart-contracts";
*
* async function createSMA7702AccountClient(
* config: CreateModularAccountV2ClientParams
* ): Promise<SmartAccountClient> {
* const sma7702Account = await createModularAccountV2({ ...config, mode: "7702" });
*
* return createSmartAccountClient({
* account: sma7702Account,
* gasEstimator: default7702GasEstimator(config.gasEstimator),
* signUserOperation: default7702UserOpSigner(config.signUserOperation),
* ...config,
* });
* }
* ```
*
* @param {ClientMiddlewareFn} [gasEstimator] Optional custom gas estimator function
* @returns {Function} A function that takes user operation struct and parameters, estimates gas usage, and returns the user operation with gas limits.
*/
export const default7702GasEstimator: (
gasEstimator?: ClientMiddlewareFn
) => ClientMiddlewareFn =
(gasEstimator?: ClientMiddlewareFn) => async (struct, params) => {
const gasEstimator_ = gasEstimator ?? defaultGasEstimator(params.client);

const account = params.account ?? params.client.account;
if (!account) {
throw new AccountNotFoundError();
}

const entryPoint = account.getEntryPoint();
if (entryPoint.version !== "0.7.0") {
throw new Error(
"This middleware is only compatible with EntryPoint v0.7.0"
);
}

const implementationAddress = await account.getImplementationAddress();

// Note: does not omit the delegation from estimation if the account is already 7702 delegated.

(struct as UserOperationStruct<"0.7.0">).eip7702Auth = {
chainId: "0x0",
nonce: "0x0",
address: implementationAddress,
r: zeroHash, // aka `bytes32(0)`
s: zeroHash,
yParity: "0x0",
};

const estimatedUO = await gasEstimator_(struct, params);

estimatedUO.eip7702Auth = undefined; // Strip out the auth after estimation.

return estimatedUO;
};
105 changes: 105 additions & 0 deletions aa-sdk/core/src/middleware/defaults/7702signer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { toHex } from "viem";
import { isSmartAccountWithSigner } from "../../account/smartContractAccount.js";
import { AccountNotFoundError } from "../../errors/account.js";
import { ChainNotFoundError } from "../../errors/client.js";
import type { ClientMiddlewareFn } from "../types";
import { defaultUserOpSigner } from "./userOpSigner.js";

/**
* Provides a default middleware function for signing user operations with a client account when using EIP-7702 delegated accounts.
* If the signer doesn't support `signAuthorization`, then this just runs the provided `signUserOperation` middleware.
* This function is only compatible with accounts using EntryPoint v0.7.0, and the account must have an implementation address defined in `getImplementationAddress()`.
*
* @example
* ```ts twoslash
* import {
* default7702GasEstimator,
* default7702UserOpSigner,
* createSmartAccountClient,
* type SmartAccountClient,
* } from "@aa-sdk/core";
* import {
* createModularAccountV2,
* type CreateModularAccountV2ClientParams,
* } from "@account-kit/smart-contracts";
*
* async function createSMA7702AccountClient(
* config: CreateModularAccountV2ClientParams
* ): Promise<SmartAccountClient> {
* const sma7702Account = await createModularAccountV2({ ...config, mode: "7702" });
*
* return createSmartAccountClient({
* account: sma7702Account,
* gasEstimator: default7702GasEstimator(config.gasEstimator),
* signUserOperation: default7702UserOpSigner(config.signUserOperation),
* ...config,
* });
* }
* ```
*
* @param {ClientMiddlewareFn} [userOpSigner] Optional user operation signer function
* @returns {Function} A middleware function that signs EIP-7702 authorization tuples if necessary, and also uses the provided or default user operation signer to generate the user op signature.
*/
export const default7702UserOpSigner: (
userOpSigner?: ClientMiddlewareFn
) => ClientMiddlewareFn =
(userOpSigner?: ClientMiddlewareFn) => async (struct, params) => {
const userOpSigner_ = userOpSigner ?? defaultUserOpSigner;

const uo = await userOpSigner_(struct, params);

const account = params.account ?? params.client.account;
const { client } = params;

if (!account || !isSmartAccountWithSigner(account)) {
throw new AccountNotFoundError();
}

const signer = account.getSigner();

if (!signer.signAuthorization) {
return uo;
}

if (!client.chain) {
throw new ChainNotFoundError();
}

const code = (await client.getCode({ address: account.address })) ?? "0x";

const implAddress = await account.getImplementationAddress();

const expectedCode = "0xef0100" + implAddress.slice(2);

if (code.toLowerCase() === expectedCode.toLowerCase()) {
// If the delegation already matches the expected, then we don't need to sign and include an authorization tuple.
return uo;
}

const accountNonce = await params.client.getTransactionCount({
address: account.address,
});

const authSignature = await signer.signAuthorization({
chainId: client.chain.id,
contractAddress: implAddress,
nonce: accountNonce,
});

const { r, s } = authSignature;

const yParity = authSignature.yParity ?? authSignature.v - 27n;

return {
...uo,
eip7702Auth: {
// deepHexlify doesn't encode number(0) correctly, it returns "0x"
chainId: toHex(client.chain.id),
nonce: toHex(accountNonce),
address: implAddress,
r,
s,
yParity: toHex(yParity),
},
};
};
28 changes: 28 additions & 0 deletions aa-sdk/core/src/signer/local-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
privateKeyToAccount,
} from "viem/accounts";
import type { SmartAccountSigner } from "./types.js";
import type { Authorization } from "viem/experimental";

/**
* Represents a local account signer and provides methods to sign messages and transactions, as well as static methods to create the signer from mnemonic or private key.
Expand Down Expand Up @@ -95,6 +96,33 @@ export class LocalAccountSigner<
return this.inner.signTypedData(params);
};

/**
* Signs an unsigned authorization using the provided private key account.
*
* @example
* ```ts twoslash
* import { LocalAccountSigner } from "@aa-sdk/core";
* import { generatePrivateKey } from "viem/accounts";
*
* const signer = LocalAccountSigner.privateKeyToAccountSigner(generatePrivateKey());
* const signedAuthorization = await signer.signAuthorization({
* contractAddress: "0x1234123412341234123412341234123412341234",
* chainId: 1,
* nonce: 3,
* });
* ```
*
* @param {Authorization<number, false>} unsignedAuthorization - The unsigned authorization to be signed.
* @returns {Promise<Authorization<number, true>>} A promise that resolves to the signed authorization.
*/

signAuthorization(
this: LocalAccountSigner<PrivateKeyAccount>,
unsignedAuthorization: Authorization<number, false>
): Promise<Authorization<number, true>> {
return this.inner.experimental_signAuthorization(unsignedAuthorization);
}

/**
* Returns the address of the inner object in a specific hexadecimal format.
*
Expand Down
Loading

0 comments on commit 8d5501f

Please sign in to comment.