diff --git a/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts b/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts index ce3386c01d..2a07325362 100644 --- a/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts +++ b/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts @@ -62,7 +62,8 @@ export async function _initUserOperation< const signature = account.getDummySignature(); - const nonce = account.getAccountNonce(overrides?.nonceKey); + const nonce = + overrides?.nonce ?? account.getAccountNonce(overrides?.nonceKey); const struct = entryPoint.version === "0.6.0" diff --git a/aa-sdk/core/src/types.ts b/aa-sdk/core/src/types.ts index 44770da153..6027617715 100644 --- a/aa-sdk/core/src/types.ts +++ b/aa-sdk/core/src/types.ts @@ -113,14 +113,6 @@ export type UserOperationOverrides< verificationGasLimit: | UserOperationStruct["verificationGasLimit"] | Multiplier; - /** - * This can be used to override the key used when calling `entryPoint.getNonce` - * It is useful when you want to use parallel nonces for user operations - * - * NOTE: not all bundlers fully support this feature and it could be that your bundler will still only include - * one user operation for your account in a bundle - */ - nonceKey: bigint; /** * The same state overrides for @@ -132,7 +124,21 @@ export type UserOperationOverrides< */ stateOverride: StateOverride; } & UserOperationPaymasterOverrides ->; +> & + /** + * This can be used to override the nonce or nonce key used when calling `entryPoint.getNonce` + * It is useful when you want to use parallel nonces for user operations + * + * NOTE: not all bundlers fully support this feature and it could be that your bundler will still only include + * one user operation for your account in a bundle + */ + Partial< + | { + nonceKey: bigint; + nonce: never; + } + | { nonceKey: never; nonce: bigint } + >; // [!endregion UserOperationOverrides] // [!region UserOperationRequest_v6] diff --git a/account-kit/smart-contracts/src/ma-v2/account/nativeSMASigner.ts b/account-kit/smart-contracts/src/ma-v2/account/nativeSMASigner.ts index a66446a978..6ba64d4745 100644 --- a/account-kit/smart-contracts/src/ma-v2/account/nativeSMASigner.ts +++ b/account-kit/smart-contracts/src/ma-v2/account/nativeSMASigner.ts @@ -8,6 +8,7 @@ import { type TypedDataDefinition, type Chain, type Address, + concat, } from "viem"; import { @@ -15,6 +16,7 @@ import { pack1271Signature, DEFAULT_OWNER_ENTITY_ID, } from "../utils.js"; +import { SignatureType } from "../modules/utils.js"; /** * Creates an object with methods for generating a dummy signature, signing user operation hashes, signing messages, and signing typed data. * @@ -99,7 +101,10 @@ export const nativeSMASigner = ( typedDataDefinition?.domain?.verifyingContract === accountAddress; return isDeferredAction - ? signer.signTypedData(typedDataDefinition) + ? concat([ + SignatureType.EOA, + await signer.signTypedData(typedDataDefinition), + ]) : pack1271Signature({ validationSignature: await signer.signTypedData({ domain: { diff --git a/account-kit/smart-contracts/src/ma-v2/actions/DeferralActions.ts b/account-kit/smart-contracts/src/ma-v2/actions/DeferralActions.ts new file mode 100644 index 0000000000..9508800fba --- /dev/null +++ b/account-kit/smart-contracts/src/ma-v2/actions/DeferralActions.ts @@ -0,0 +1,232 @@ +import { + AccountNotFoundError, + InvalidNonceKeyError, + EntryPointNotFoundError, + type UserOperationCallData, + type BatchUserOperationCallData, + type UserOperationRequest_v7, +} from "@aa-sdk/core"; +import { + type Address, + type Hex, + concatHex, + maxUint152, + getContract, + encodePacked, + size, + toHex, +} from "viem"; +import type { ModularAccountV2Client } from "../client/client.js"; + +export type DeferredActionTypedData = { + domain: { + chainId: number; + verifyingContract: Address; + }; + types: { + DeferredAction: [ + { name: "nonce"; type: "uint256" }, + { name: "deadline"; type: "uint48" }, + { name: "call"; type: "bytes" } + ]; + }; + primaryType: "DeferredAction"; + message: { + nonce: bigint; + deadline: number; + call: Hex; + }; +}; + +export type DeferredActionReturnData = { + typedData: DeferredActionTypedData; + nonceOverride: bigint; +}; + +export type CreateDeferredActionTypedDataParams = { + callData: Hex; + deadline: number; + entityId: number; + isGlobalValidation: boolean; + nonceKeyOverride?: bigint; +}; + +export type BuildDeferredActionDigestParams = { + typedData: DeferredActionTypedData; + sig: Hex; +}; + +export type BuildUserOperationWithDeferredActionParams = { + uo: UserOperationCallData | BatchUserOperationCallData; + signaturePrepend: Hex; + nonceOverride: bigint; +}; + +export type DeferralActions = { + createDeferredActionTypedDataObject: ( + args: CreateDeferredActionTypedDataParams + ) => Promise; + buildDeferredActionDigest: (args: BuildDeferredActionDigestParams) => Hex; + buildUserOperationWithDeferredAction: ( + args: BuildUserOperationWithDeferredActionParams + ) => Promise; +}; + +/** + * Provides deferred action functionalities for a MA v2 client, ensuring compatibility with `SmartAccountClient`. + * + * @param {ModularAccountV2Client} client - The client instance which provides account and sendUserOperation functionality. + * @returns {object} - An object containing three methods: `createDeferredActionTypedDataObject`, `buildDeferredActionDigest`, and `buildUserOperationWithDeferredAction`. + */ +export const deferralActions: ( + client: ModularAccountV2Client +) => DeferralActions = (client: ModularAccountV2Client): DeferralActions => { + const createDeferredActionTypedDataObject = async ({ + callData, + deadline, + entityId, + isGlobalValidation, + nonceKeyOverride, + }: CreateDeferredActionTypedDataParams): Promise => { + if (!client.account) { + throw new AccountNotFoundError(); + } + + const baseNonceKey = nonceKeyOverride || 0n; + if (baseNonceKey > maxUint152) { + throw new InvalidNonceKeyError(baseNonceKey); + } + + const entryPoint = client.account.getEntryPoint(); + if (entryPoint === undefined) { + throw new EntryPointNotFoundError(client.chain, "0.7.0"); + } + + const entryPointContract = getContract({ + address: entryPoint.address, + abi: entryPoint.abi, + client: client, + }); + + // 2 = deferred action flags 0b10 + // 1 = isGlobal validation flag 0b01 + const fullNonceKey: bigint = + ((baseNonceKey << 40n) + (BigInt(entityId) << 8n)) | + 2n | + (isGlobalValidation ? 1n : 0n); + + const nonceOverride = (await entryPointContract.read.getNonce([ + client.account.address, + fullNonceKey, + ])) as bigint; + + return { + typedData: { + domain: { + chainId: await client.getChainId(), + verifyingContract: client.account.address, + }, + types: { + DeferredAction: [ + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint48" }, + { name: "call", type: "bytes" }, + ], + }, + primaryType: "DeferredAction", + message: { + nonce: nonceOverride, + deadline: deadline, + call: callData, + }, + }, + nonceOverride: nonceOverride, + }; + }; + + /** + * Creates the digest which must be prepended to the userOp signature. + * + * Assumption: The client this extends is used to sign the typed data. + * + * @param {object} args The argument object containing the following: + * @param {DeferredActionTypedData} args.typedData The typed data object for the deferred action + * @param {Hex} args.sig The signature to include in the digest + * @returns {Hex} The encoded digest to be prepended to the userOp signature + */ + const buildDeferredActionDigest = ({ + typedData, + sig, + }: BuildDeferredActionDigestParams): Hex => { + const signerEntity = client.account.signerEntity; + const validationLocator = + (BigInt(signerEntity.entityId) << 8n) | + (signerEntity.isGlobalValidation ? 1n : 0n); + + let encodedData = encodePacked( + ["uint168", "uint48", "bytes"], + [validationLocator, typedData.message.deadline, typedData.message.call] + ); + + const encodedDataLength = size(encodedData); + const sigLength = size(sig); + + encodedData = concatHex([ + toHex(encodedDataLength, { size: 4 }), + encodedData, + toHex(sigLength, { size: 4 }), + sig, + ]); + + return encodedData; + }; + + /** + * Builds a user operation with a deferred action by wrapping buildUserOperation() with a dummy signature override. + * + * @param {object} args The argument object containing the following: + * @param {UserOperationCallData | BatchUserOperationCallData} args.uo The user operation call data to build + * @param {Hex} args.signaturePrepend The signature data to prepend to the dummy signature + * @param {bigint} args.nonceOverride The nonce to override in the user operation, generally given from the typed data builder + * @returns {Promise} The unsigned user operation request with the deferred action + */ + const buildUserOperationWithDeferredAction = async ({ + uo, + signaturePrepend, + nonceOverride, + }: BuildUserOperationWithDeferredActionParams): Promise => { + // Check if client.account is defined + if (client.account === undefined) { + throw new AccountNotFoundError(); + } + + // Pre-fetch the dummy sig so we can override `client.account.getDummySignature()` + const dummySig = await client.account.getDummySignature(); + + // Cache the previous dummy signature getter + const previousDummySigGetter = client.account.getDummySignature; + + // Override client.account.getDummySignature() so `client.buildUserOperation()` uses the prepended hex and the dummy signature during gas estimation + client.account.getDummySignature = () => { + return concatHex([signaturePrepend, dummySig as Hex]); + }; + + const unsignedUo = (await client.buildUserOperation({ + uo: uo, + overrides: { + nonce: nonceOverride, + }, + })) as UserOperationRequest_v7; + + // Restore the dummy signature getter + client.account.getDummySignature = previousDummySigGetter; + + return unsignedUo; + }; + + return { + createDeferredActionTypedDataObject, + buildDeferredActionDigest, + buildUserOperationWithDeferredAction, + }; +}; diff --git a/account-kit/smart-contracts/src/ma-v2/client/client.test.ts b/account-kit/smart-contracts/src/ma-v2/client/client.test.ts index be7f641279..10e2d15776 100644 --- a/account-kit/smart-contracts/src/ma-v2/client/client.test.ts +++ b/account-kit/smart-contracts/src/ma-v2/client/client.test.ts @@ -20,6 +20,8 @@ import { concat, testActions, type TestActions, + concatHex, + type Hex, } from "viem"; import { HookType } from "../actions/common/types.js"; import { @@ -56,17 +58,17 @@ import { alchemyGasAndPaymasterAndDataMiddleware, } from "@account-kit/infra"; import { getMAV2UpgradeToData } from "@account-kit/smart-contracts"; +import { deferralActions } from "../actions/DeferralActions.js"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; -// TODO: Include a snapshot to reset to in afterEach +// Note: These tests maintain a shared state to not break the local-running rundler by desyncing the chain. describe("MA v2 Tests", async () => { const instance = local070Instance; + const isValidSigSuccess = "0x1626ba7e"; let client: ReturnType & ReturnType & TestActions; - - const isValidSigSuccess = "0x1626ba7e"; - beforeAll(async () => { client = instance .getClient() @@ -324,6 +326,230 @@ describe("MA v2 Tests", async () => { ); }); + it("installs a session key via deferred action signed by the owner and has it sign a UO", async () => { + let provider = (await givenConnectedProvider({ signer })) + .extend(installValidationActions) + .extend(deferralActions); + + await setBalance(client, { + address: provider.getAddress(), + value: parseEther("2"), + }); + + // Test variables + const sessionKeyEntityId = 1; + const isGlobalValidation = true; + + // Encode install data to defer + let encodedInstallData = await provider.encodeInstallValidation({ + validationConfig: { + moduleAddress: getDefaultSingleSignerValidationModuleAddress( + provider.chain + ), + entityId: sessionKeyEntityId, + isGlobal: isGlobalValidation, + isSignatureValidation: true, + isUserOpValidation: true, + }, + selectors: [], + installData: SingleSignerValidationModule.encodeOnInstallData({ + entityId: sessionKeyEntityId, + signer: await sessionKey.getAddress(), + }), + hooks: [], + }); + + // Build the typed data we need for the deferred action (provider/client only used for account address & entrypoint) + const { typedData, nonceOverride } = + await provider.createDeferredActionTypedDataObject({ + callData: encodedInstallData, + deadline: 0, + entityId: sessionKeyEntityId, + isGlobalValidation: isGlobalValidation, + }); + + // Sign the typed data using the owner (fallback) validation, this must be done via the account to skip 6492 + const deferredValidationSig = await provider.account.signTypedData( + typedData + ); + + // Build the full hex to prepend to the UO signature + // This MUST be done with the *same* client that has signed the typed data + const signaturePrepend = provider.buildDeferredActionDigest({ + typedData: typedData, + sig: deferredValidationSig, + }); + + // Build the full UO with the deferred action signature prepend (provider/client only used for account address & entrypoint) + const unsignedUo = await provider.buildUserOperationWithDeferredAction({ + uo: { target, data: "0x" }, + signaturePrepend, + nonceOverride, + }); + + // Initialize the session key client corresponding to the session key we will install in the deferred action + let sessionKeyClient = await createModularAccountV2Client({ + chain: instance.chain, + signer: sessionKey, + transport: custom(instance.getClient()), + accountAddress: provider.getAddress(), + signerEntity: { + entityId: sessionKeyEntityId, + isGlobalValidation: isGlobalValidation, + }, + }); + + // Sign the UO with the session key + const uo = await sessionKeyClient.signUserOperation({ + uoStruct: unsignedUo, + }); + + // Prepend the full hex for the deferred action to the new, real signature + uo.signature = concatHex([signaturePrepend, uo.signature as Hex]); + + // Send the raw UserOp + const result = await sessionKeyClient.sendRawUserOperation( + uo, + provider.account.getEntryPoint().address + ); + + await provider.waitForUserOperationTransaction({ hash: result }); + }); + + it("installs a session key via deferred action signed by another session key and has it sign a UO", async () => { + let provider = (await givenConnectedProvider({ signer })) + .extend(installValidationActions) + .extend(deferralActions); + + await setBalance(client, { + address: provider.getAddress(), + value: parseEther("2"), + }); + + const sessionKeyEntityId = 1; + + // First, install a session key + let sessionKeyInstallResult = await provider.installValidation({ + validationConfig: { + moduleAddress: getDefaultSingleSignerValidationModuleAddress( + provider.chain + ), + entityId: sessionKeyEntityId, + isGlobal: true, + isSignatureValidation: true, + isUserOpValidation: true, + }, + selectors: [], + installData: SingleSignerValidationModule.encodeOnInstallData({ + entityId: sessionKeyEntityId, + signer: await sessionKey.getAddress(), + }), + hooks: [], + }); + + await provider.waitForUserOperationTransaction(sessionKeyInstallResult); + + // Create a client with the first session key + let sessionKeyClient = ( + await createModularAccountV2Client({ + chain: instance.chain, + signer: sessionKey, + transport: custom(instance.getClient()), + accountAddress: provider.getAddress(), + signerEntity: { + entityId: sessionKeyEntityId, + isGlobalValidation: true, + }, + }) + ) + .extend(installValidationActions) + .extend(deferralActions); + + const randomWallet = privateKeyToAccount(generatePrivateKey()); + const newSessionKey: SmartAccountSigner = new LocalAccountSigner( + randomWallet + ); + + // Test variables + const newSessionKeyEntityId = 2; + const isGlobalValidation = true; + + // Encode install data to defer + let encodedInstallData = await sessionKeyClient.encodeInstallValidation({ + validationConfig: { + moduleAddress: getDefaultSingleSignerValidationModuleAddress( + provider.chain + ), + entityId: newSessionKeyEntityId, + isGlobal: isGlobalValidation, + isSignatureValidation: true, + isUserOpValidation: true, + }, + selectors: [], + installData: SingleSignerValidationModule.encodeOnInstallData({ + entityId: newSessionKeyEntityId, + signer: await newSessionKey.getAddress(), + }), + hooks: [], + }); + + // Build the typed data we need for the deferred action (provider/client only used for account address & entrypoint) + const { typedData, nonceOverride } = + await provider.createDeferredActionTypedDataObject({ + callData: encodedInstallData, + deadline: 0, + entityId: newSessionKeyEntityId, + isGlobalValidation: isGlobalValidation, + }); + + // Sign the typed data using the first session key + const deferredValidationSig = await sessionKeyClient.account.signTypedData( + typedData + ); + + // Build the full hex to prepend to the UO signature + // This MUST be done with the *same* client that has signed the typed data + const signaturePrepend = sessionKeyClient.buildDeferredActionDigest({ + typedData: typedData, + sig: deferredValidationSig, + }); + + // Build the full UO with the deferred action signature prepend (provider/client only used for account address & entrypoint) + const unsignedUo = await provider.buildUserOperationWithDeferredAction({ + uo: { target, data: "0x" }, + signaturePrepend, + nonceOverride, + }); + + // Initialize the session key client corresponding to the session key we will install in the deferred action + let newSessionKeyClient = await createModularAccountV2Client({ + chain: instance.chain, + signer: newSessionKey, + transport: custom(instance.getClient()), + accountAddress: provider.getAddress(), + signerEntity: { + entityId: newSessionKeyEntityId, + isGlobalValidation: isGlobalValidation, + }, + }); + + // Sign the UO with the newly installed session key + const uo = await newSessionKeyClient.signUserOperation({ + uoStruct: unsignedUo, + }); + + // Prepend the full hex for the deferred action to the new, real signature + uo.signature = concatHex([signaturePrepend, uo.signature as Hex]); + + // Send the raw UserOp (provider/client only used for account address & entrypoint) + const result = await provider.sendRawUserOperation( + uo, + provider.account.getEntryPoint().address + ); + + await provider.waitForUserOperationTransaction({ hash: result }); + }); + it("uninstalls a session key", async () => { let provider = (await givenConnectedProvider({ signer })).extend( installValidationActions @@ -1214,6 +1440,7 @@ describe("MA v2 Tests", async () => { expect(alchemyClientSpy).toHaveBeenCalled(); expect(notAlchemyClientSpy).not.toHaveBeenCalled(); }); + it("custom client calls the createAlchemySmartAccountClient", async () => { const alchemyClientSpy = vi .spyOn(AAInfraModule, "createAlchemySmartAccountClient") diff --git a/account-kit/smart-contracts/src/ma-v2/modules/single-signer-validation/signer.ts b/account-kit/smart-contracts/src/ma-v2/modules/single-signer-validation/signer.ts index 6de808de1b..5d29b27e90 100644 --- a/account-kit/smart-contracts/src/ma-v2/modules/single-signer-validation/signer.ts +++ b/account-kit/smart-contracts/src/ma-v2/modules/single-signer-validation/signer.ts @@ -9,8 +9,12 @@ import { type TypedDataDefinition, type Chain, type Address, + concat, } from "viem"; -import { getDefaultSingleSignerValidationModuleAddress } from "../utils.js"; +import { + getDefaultSingleSignerValidationModuleAddress, + SignatureType, +} from "../utils.js"; import { packUOSignature, pack1271Signature } from "../../utils.js"; /** @@ -111,13 +115,14 @@ export const singleSignerMessageSigner = ( ReplaySafeHash: [{ name: "hash", type: "bytes32" }], }, message: { - hash: await hashTypedData(typedDataDefinition), + hash: hashTypedData(typedDataDefinition), }, primaryType: "ReplaySafeHash", }); + // TODO: Handle non-EOA signer case return isDeferredAction - ? validationSignature + ? concat([SignatureType.EOA, validationSignature]) : pack1271Signature({ validationSignature, entityId, diff --git a/account-kit/smart-contracts/src/ma-v2/modules/utils.ts b/account-kit/smart-contracts/src/ma-v2/modules/utils.ts index e940d097a4..2e67815cbb 100644 --- a/account-kit/smart-contracts/src/ma-v2/modules/utils.ts +++ b/account-kit/smart-contracts/src/ma-v2/modules/utils.ts @@ -12,6 +12,11 @@ import { sepolia, } from "@account-kit/infra"; +export enum SignatureType { + EOA = "0x00", + CONTRACT = "0x01", +} + /** * Maps a given chain to a specific address of the webauthn validation module by its chain ID. If no direct mapping exists, it defaults to returning a specific address. *