From b6a7685c7655d37bbba2ce92c3e74bae0102054d Mon Sep 17 00:00:00 2001 From: zer0dot Date: Mon, 10 Mar 2025 18:57:14 -0400 Subject: [PATCH 01/18] feat: modify type and nonce override logic --- .../internal/initUserOperation.ts | 5 +++- aa-sdk/core/src/types.ts | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts b/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts index ce3386c01d..2e9b779ea2 100644 --- a/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts +++ b/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts @@ -62,7 +62,10 @@ export async function _initUserOperation< const signature = account.getDummySignature(); - const nonce = account.getAccountNonce(overrides?.nonceKey); + const nonce = + overrides?.nonce !== undefined + ? 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] From 7f97ac03764e2bd668a0e0d441f309803d0372c8 Mon Sep 17 00:00:00 2001 From: zer0dot Date: Tue, 11 Mar 2025 11:38:08 -0400 Subject: [PATCH 02/18] feat: deferred action typed data builder --- .../src/ma-v2/client/client.test.ts | 32 +++++++++ .../src/ma-v2/deferredActionUtils.ts | 67 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts 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..7a16dfafe8 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 @@ -56,6 +56,7 @@ import { alchemyGasAndPaymasterAndDataMiddleware, } from "@account-kit/infra"; import { getMAV2UpgradeToData } from "@account-kit/smart-contracts"; +import { DeferredActionBuilder } from "../deferredActionUtils.js"; // TODO: Include a snapshot to reset to in afterEach describe("MA v2 Tests", async () => { @@ -324,6 +325,37 @@ describe("MA v2 Tests", async () => { ); }); + it("Deferred Actions", async () => { + let provider = (await givenConnectedProvider({ signer })).extend( + installValidationActions + ); + + await setBalance(client, { + address: provider.getAddress(), + value: parseEther("2"), + }); + + const startingAddressBalance = await getTargetBalance(); + + // connect session key and send tx with session key + let sessionKeyClient = await createModularAccountV2Client({ + chain: instance.chain, + signer: sessionKey, + transport: custom(instance.getClient()), + accountAddress: provider.getAddress(), + signerEntity: { entityId: 1, isGlobalValidation: true }, + }); + + const res = await DeferredActionBuilder.createTypedDataObject({ + client: provider, + calldata: "0x", + deadline: 0, + entityId: 0, + isGlobalValidation: false, + }); + console.log(res); + }); + it("uninstalls a session key", async () => { let provider = (await givenConnectedProvider({ signer })).extend( installValidationActions diff --git a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts new file mode 100644 index 0000000000..73a857bba0 --- /dev/null +++ b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts @@ -0,0 +1,67 @@ +import type { Address, SmartAccountClient } from "@aa-sdk/core"; +import { type Hex } from "viem"; + +type DeferredActionData = { + typedData: { + 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; + }; + }; + nonceOverride: bigint; +}; + +export const DeferredActionBuilder = { + createTypedDataObject: async (args: { + client: SmartAccountClient; + calldata: Hex; + deadline: number; + entityId: number; + isGlobalValidation: boolean; + nonceKeyOverride?: bigint; + }): Promise => { + if (!args.client.account) { + throw "Account undefined in client"; + } + + // TODO: override the nonce + const nonce = + (await args.client.account.getAccountNonce(args.nonceKeyOverride)) | 2n; + + return { + typedData: { + domain: { + chainId: await args.client.getChainId(), + verifyingContract: args.client.account.address, + }, + types: { + DeferredAction: [ + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint48" }, + { name: "call", type: "bytes" }, + ], + }, + primaryType: "DeferredAction", + message: { + nonce: nonce, + deadline: args.deadline, + call: args.calldata, + }, + }, + nonceOverride: nonce, + }; + }, +}; From 8482e1993ea4d459233cdf14c23fb3a8512c2272 Mon Sep 17 00:00:00 2001 From: zer0dot Date: Tue, 11 Mar 2025 12:38:24 -0400 Subject: [PATCH 03/18] feat: small type refactor --- .../src/ma-v2/deferredActionUtils.ts | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts index 73a857bba0..094ea61cac 100644 --- a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts +++ b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts @@ -1,26 +1,28 @@ import type { Address, SmartAccountClient } from "@aa-sdk/core"; import { type Hex } from "viem"; -type DeferredActionData = { - typedData: { - 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; - }; +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; + }; +}; + +type DeferredActionReturnData = { + typedData: DeferredActionTypedData; nonceOverride: bigint; }; @@ -32,7 +34,7 @@ export const DeferredActionBuilder = { entityId: number; isGlobalValidation: boolean; nonceKeyOverride?: bigint; - }): Promise => { + }): Promise => { if (!args.client.account) { throw "Account undefined in client"; } @@ -64,4 +66,9 @@ export const DeferredActionBuilder = { nonceOverride: nonce, }; }, + // Maybe a better name for this + buildDigest: async (args: { + typedData: DeferredActionTypedData; + sig: Hex; + }) => {}, }; From 4bb327a0c684bfd8bf0a87b47d8e70f361a50a7f Mon Sep 17 00:00:00 2001 From: zer0dot Date: Tue, 11 Mar 2025 16:02:39 -0400 Subject: [PATCH 04/18] feat: (WIP) start building buildDigest --- .../src/ma-v2/client/client.test.ts | 59 ++++++++++++++----- .../src/ma-v2/deferredActionUtils.ts | 31 ++++++++-- 2 files changed, 70 insertions(+), 20 deletions(-) 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 7a16dfafe8..003d6e3160 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 @@ -58,16 +58,14 @@ import { import { getMAV2UpgradeToData } from "@account-kit/smart-contracts"; import { DeferredActionBuilder } from "../deferredActionUtils.js"; -// 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() @@ -335,7 +333,49 @@ describe("MA v2 Tests", async () => { value: parseEther("2"), }); - const startingAddressBalance = await getTargetBalance(); + // const startingAddressBalance = await getTargetBalance(); + + const sessionKeyEntityId = 1; + const isGlobalValidation = true; + + 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: [], + }); + + const res = await DeferredActionBuilder.createTypedDataObject({ + client: provider, + calldata: encodedInstallData, + deadline: 0, + entityId: sessionKeyEntityId, + isGlobalValidation: isGlobalValidation, + }); + console.log(res); + + const sig = await provider.signTypedData({ typedData: res.typedData }); + + console.log("SIGNATURE:\n", sig); + + DeferredActionBuilder.buildDigest({ + typedData: res.typedData, + sig: sig, + nonce: res.nonceOverride, + }); + + // Ideally we have one function which returns the slice of data to prepend to the signature, and a nonce override. // connect session key and send tx with session key let sessionKeyClient = await createModularAccountV2Client({ @@ -345,15 +385,6 @@ describe("MA v2 Tests", async () => { accountAddress: provider.getAddress(), signerEntity: { entityId: 1, isGlobalValidation: true }, }); - - const res = await DeferredActionBuilder.createTypedDataObject({ - client: provider, - calldata: "0x", - deadline: 0, - entityId: 0, - isGlobalValidation: false, - }); - console.log(res); }); it("uninstalls a session key", async () => { diff --git a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts index 094ea61cac..287e907031 100644 --- a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts +++ b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts @@ -1,5 +1,5 @@ import type { Address, SmartAccountClient } from "@aa-sdk/core"; -import { type Hex } from "viem"; +import { encodePacked, type Hex, toHex } from "viem"; type DeferredActionTypedData = { domain: { @@ -27,6 +27,8 @@ type DeferredActionReturnData = { }; export const DeferredActionBuilder = { + /// Creates the typed data object ready for signing and the nonce for the given deferred action. + /// Returns the typed data object and the nonce to override. createTypedDataObject: async (args: { client: SmartAccountClient; calldata: Hex; @@ -39,8 +41,7 @@ export const DeferredActionBuilder = { throw "Account undefined in client"; } - // TODO: override the nonce - const nonce = + const nonceOverride = (await args.client.account.getAccountNonce(args.nonceKeyOverride)) | 2n; return { @@ -58,17 +59,35 @@ export const DeferredActionBuilder = { }, primaryType: "DeferredAction", message: { - nonce: nonce, + nonce: nonceOverride, deadline: args.deadline, call: args.calldata, }, }, - nonceOverride: nonce, + nonceOverride: nonceOverride, }; }, // Maybe a better name for this buildDigest: async (args: { typedData: DeferredActionTypedData; + nonce: bigint; sig: Hex; - }) => {}, + }) => { + // nonce used to determine validation locator + const validationLocator = ((args.nonce << 88n) >> 88n) & 0xffffffffffn; + console.log("LOCATOR:", validationLocator.toString(16)); + console.log("LOCATOR:", toHex(validationLocator)); + + let encodedData = encodePacked( + ["uint168", "uint48", "bytes"], + [ + validationLocator, + args.typedData.message.deadline, + args.typedData.message.call, + ] + ); + + console.log("ENCODED DATA:", encodedData); + console.log("ENCODED DATA LENGTH:", encodedData.length); + }, }; From 8a106f607ccd0d5d19c5b5f78ac0ca499916e9bc Mon Sep 17 00:00:00 2001 From: zer0dot Date: Wed, 12 Mar 2025 19:18:35 -0400 Subject: [PATCH 05/18] feat: (wip) functioning deferred actions and test, deferred action flag override missing --- .../src/ma-v2/client/client.test.ts | 92 ++++++++++++++----- .../src/ma-v2/deferredActionUtils.ts | 52 +++++++++-- 2 files changed, 110 insertions(+), 34 deletions(-) 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 003d6e3160..e2c0a54976 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,9 @@ import { concat, testActions, type TestActions, + concatHex, + toHex, + type Hex, } from "viem"; import { HookType } from "../actions/common/types.js"; import { @@ -323,7 +326,10 @@ describe("MA v2 Tests", async () => { ); }); - it("Deferred Actions", async () => { + it.only("Deferred Actions", async () => { + // TODO FIX: The nonce key used to query the EP does not include the deferred action flag, it only appends the flag after fetching the nonce, this causes a desync and the test to fail. + // The fix is to introduce a way to override the `options` byte of the nonceKey, or manually call the entryPoint for the specific Deferred action case. + let provider = (await givenConnectedProvider({ signer })).extend( installValidationActions ); @@ -333,11 +339,11 @@ describe("MA v2 Tests", async () => { value: parseEther("2"), }); - // const startingAddressBalance = await getTargetBalance(); - + // Test variables const sessionKeyEntityId = 1; const isGlobalValidation = true; + // Encode install data to defer let encodedInstallData = await provider.encodeInstallValidation({ validationConfig: { moduleAddress: getDefaultSingleSignerValidationModuleAddress( @@ -356,28 +362,7 @@ describe("MA v2 Tests", async () => { hooks: [], }); - const res = await DeferredActionBuilder.createTypedDataObject({ - client: provider, - calldata: encodedInstallData, - deadline: 0, - entityId: sessionKeyEntityId, - isGlobalValidation: isGlobalValidation, - }); - console.log(res); - - const sig = await provider.signTypedData({ typedData: res.typedData }); - - console.log("SIGNATURE:\n", sig); - - DeferredActionBuilder.buildDigest({ - typedData: res.typedData, - sig: sig, - nonce: res.nonceOverride, - }); - - // Ideally we have one function which returns the slice of data to prepend to the signature, and a nonce override. - - // connect session key and send tx with session key + // Initialize the client (we'll use this to build the deferred validation typed data, as it'll validate the entire UO) let sessionKeyClient = await createModularAccountV2Client({ chain: instance.chain, signer: sessionKey, @@ -385,6 +370,62 @@ describe("MA v2 Tests", async () => { accountAddress: provider.getAddress(), signerEntity: { entityId: 1, isGlobalValidation: true }, }); + + // Build the typed data we need for the deferred action using the session key client so the nonce uses the session key as the UO validation + // this installation will however be validated with the owner (fallback) validation + const { typedData, nonceOverride } = + await DeferredActionBuilder.createTypedDataObject({ + client: sessionKeyClient, + calldata: encodedInstallData, + deadline: 0, + entityId: sessionKeyEntityId, + isGlobalValidation: isGlobalValidation, + }); + + // Sign the typed data using the owner (fallback) validation, we must use the inner signTypedData method to bypass 6492 for deferred actions + // Prepend 0x00 for the EOA_TYPE_SIGNATURE byte + const deferredValidationSig = concatHex([ + "0x00", + await provider.account.signTypedData(typedData), + ]); + + // Build the full hex to prepend to the UO signature + const signaturePrepend = DeferredActionBuilder.buildDigest({ + typedData: typedData, + sig: deferredValidationSig, + nonce: nonceOverride, + }); + + // Pre-fetch the dummy sig so we can override `provider.account.getDummySignature()` + const dummySig = await provider.account.getDummySignature(); + + // Override provider.account.getDummySignature() so `provider.buildUserOperation()` uses the prepended hex and the dummy signature during gas estimation + provider.account.getDummySignature = () => { + return concatHex([signaturePrepend, dummySig as Hex]); + }; + + // Generate the unsigned UO + const unsignedUo = (await provider.buildUserOperation({ + uo: { target, data: "0x" }, + overrides: { + nonce: nonceOverride, // FIX: Currently, we aren't setting the deferred validation flag in the nonce key, instead we're setting it in + // the returned nonce itself. This means sequential nonces will be incorrect. + }, + })) as UserOperationRequest_v7; + + // 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 + await sessionKeyClient.sendRawUserOperation( + uo, + provider.account.getEntryPoint().address + ); }); it("uninstalls a session key", async () => { @@ -1277,6 +1318,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/deferredActionUtils.ts b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts index 287e907031..48a6567b00 100644 --- a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts +++ b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts @@ -1,5 +1,5 @@ import type { Address, SmartAccountClient } from "@aa-sdk/core"; -import { encodePacked, type Hex, toHex } from "viem"; +import { concatHex, encodePacked, type Hex, size, toHex } from "viem"; type DeferredActionTypedData = { domain: { @@ -40,9 +40,11 @@ export const DeferredActionBuilder = { if (!args.client.account) { throw "Account undefined in client"; } - + // First, get the normal nonce, which should be the standard fallback validation + 1 for global validation + // Then, toggle the flag for "deferred action" (2 << 64) const nonceOverride = - (await args.client.account.getAccountNonce(args.nonceKeyOverride)) | 2n; + (await args.client.account.getAccountNonce(args.nonceKeyOverride)) | + (2n << 64n); return { typedData: { @@ -68,15 +70,13 @@ export const DeferredActionBuilder = { }; }, // Maybe a better name for this - buildDigest: async (args: { + buildDigest: (args: { typedData: DeferredActionTypedData; nonce: bigint; sig: Hex; - }) => { + }): Hex => { // nonce used to determine validation locator - const validationLocator = ((args.nonce << 88n) >> 88n) & 0xffffffffffn; - console.log("LOCATOR:", validationLocator.toString(16)); - console.log("LOCATOR:", toHex(validationLocator)); + const validationLocator = 1n; // fallback validation with isGlobal set to true let encodedData = encodePacked( ["uint168", "uint48", "bytes"], @@ -87,7 +87,41 @@ export const DeferredActionBuilder = { ] ); + const encodedDataLength = size(encodedData); + + const sigLength = size(args.sig); + console.log("ENCODED DATA:", encodedData); - console.log("ENCODED DATA LENGTH:", encodedData.length); + console.log("ENCODED DATA LENGTH:", encodedDataLength); + console.log("\n\nSIG:\n\n", args.sig); + console.log("\n\nSIG LENGTH:\n\n", sigLength); + + encodedData = concatHex([ + toHex(encodedDataLength, { size: 4 }), + encodedData, + toHex(sigLength, { size: 4 }), + args.sig, + ]); + + return encodedData; }, }; + +// 0x0000013f // Encoded data length (319 bytes) +// 000000000000000000000000000000000000000001 // Validation locator (fallback + isGlobal) +// 000000000000 // deadline +// 1bbf564c // installValidation selector +// 00000000000099DE0BF6fA90dEB851E2A2df7d83000000010700000000000000 // abi-encoded validation config +// 0000000000000000000000000000000000000000000000000000000000000080 // Offset of bytes4[] selectors +// 00000000000000000000000000000000000000000000000000000000000000a0 // Offset of bytes installData +// 0000000000000000000000000000000000000000000000000000000000000100 // Offset of bytes[] hooks +// 0000000000000000000000000000000000000000000000000000000000000000 // Length of bytes4[] selectors (0) +// 0000000000000000000000000000000000000000000000000000000000000040 // Length of bytes installData (40) +// 0000000000000000000000000000000000000000000000000000000000000001 // InstallData word 1 +// 0000000000000000000000008391de4cacfb91f1cf953cf345948d92e137b6b9 // InstallData word 2 +// 0000000000000000000000000000000000000000000000000000000000000000 // Length of bytes4[] hooks (0) + +// sig: +// 00000041 Sig length (65) +// a298f66ce1ea79d426a0174efc0e4a4f31f9c51598d0adcfd679ca896a853aab22b32d2c5c8b57ddb5a8da4b2eaba452bb636998e096dc70fef3e4e768d753af1c // Deferred action sig +// FF005ee15838521cdbba6d691c6b62cc07e6c2bf4b4ddc86d993ccc78fdf41d3e4677ed504d78ed8ed62fe15ef5fa6fa2dd58c1fa67ea8d8135a856c7b6af79de6a71b // UO sig From afd452307ad58cba25223c36509140d98e93dec6 Mon Sep 17 00:00:00 2001 From: zer0dot Date: Thu, 13 Mar 2025 18:02:55 -0400 Subject: [PATCH 06/18] feat: functional deferred actions --- .../src/ma-v2/client/client.test.ts | 5 +- .../src/ma-v2/deferredActionUtils.ts | 68 ++++++++++++------- 2 files changed, 43 insertions(+), 30 deletions(-) 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 e2c0a54976..aeed8fbae9 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 @@ -326,10 +326,7 @@ describe("MA v2 Tests", async () => { ); }); - it.only("Deferred Actions", async () => { - // TODO FIX: The nonce key used to query the EP does not include the deferred action flag, it only appends the flag after fetching the nonce, this causes a desync and the test to fail. - // The fix is to introduce a way to override the `options` byte of the nonceKey, or manually call the entryPoint for the specific Deferred action case. - + it("Deferred Actions", async () => { let provider = (await givenConnectedProvider({ signer })).extend( installValidationActions ); diff --git a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts index 48a6567b00..25195eb88b 100644 --- a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts +++ b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts @@ -1,5 +1,17 @@ -import type { Address, SmartAccountClient } from "@aa-sdk/core"; -import { concatHex, encodePacked, type Hex, size, toHex } from "viem"; +import { + InvalidNonceKeyError, + type Address, + type SmartAccountClient, +} from "@aa-sdk/core"; +import { + concatHex, + encodePacked, + getContract, + type Hex, + maxUint152, + size, + toHex, +} from "viem"; type DeferredActionTypedData = { domain: { @@ -40,11 +52,34 @@ export const DeferredActionBuilder = { if (!args.client.account) { throw "Account undefined in client"; } - // First, get the normal nonce, which should be the standard fallback validation + 1 for global validation - // Then, toggle the flag for "deferred action" (2 << 64) - const nonceOverride = - (await args.client.account.getAccountNonce(args.nonceKeyOverride)) | - (2n << 64n); + + const baseNonceKey = args.nonceKeyOverride || 0n; + if (baseNonceKey > maxUint152) { + throw new InvalidNonceKeyError(baseNonceKey); + } + + if (args.client.account === undefined) { + throw "temp"; + } + + const entryPoint = args.client.account.getEntryPoint(); + if (entryPoint === undefined) { + throw "temp"; + } + + const entryPointContract = getContract({ + address: entryPoint.address, + abi: entryPoint.abi, + client: args.client, + }); + + const fullNonceKey: bigint = + ((baseNonceKey << 40n) + (BigInt(args.entityId) << 8n)) | 3n; // 3 = isGlobal + deferred action flags + + const nonceOverride = (await entryPointContract.read.getNonce([ + args.client.account.address, + fullNonceKey, + ])) as bigint; return { typedData: { @@ -106,22 +141,3 @@ export const DeferredActionBuilder = { return encodedData; }, }; - -// 0x0000013f // Encoded data length (319 bytes) -// 000000000000000000000000000000000000000001 // Validation locator (fallback + isGlobal) -// 000000000000 // deadline -// 1bbf564c // installValidation selector -// 00000000000099DE0BF6fA90dEB851E2A2df7d83000000010700000000000000 // abi-encoded validation config -// 0000000000000000000000000000000000000000000000000000000000000080 // Offset of bytes4[] selectors -// 00000000000000000000000000000000000000000000000000000000000000a0 // Offset of bytes installData -// 0000000000000000000000000000000000000000000000000000000000000100 // Offset of bytes[] hooks -// 0000000000000000000000000000000000000000000000000000000000000000 // Length of bytes4[] selectors (0) -// 0000000000000000000000000000000000000000000000000000000000000040 // Length of bytes installData (40) -// 0000000000000000000000000000000000000000000000000000000000000001 // InstallData word 1 -// 0000000000000000000000008391de4cacfb91f1cf953cf345948d92e137b6b9 // InstallData word 2 -// 0000000000000000000000000000000000000000000000000000000000000000 // Length of bytes4[] hooks (0) - -// sig: -// 00000041 Sig length (65) -// a298f66ce1ea79d426a0174efc0e4a4f31f9c51598d0adcfd679ca896a853aab22b32d2c5c8b57ddb5a8da4b2eaba452bb636998e096dc70fef3e4e768d753af1c // Deferred action sig -// FF005ee15838521cdbba6d691c6b62cc07e6c2bf4b4ddc86d993ccc78fdf41d3e4677ed504d78ed8ed62fe15ef5fa6fa2dd58c1fa67ea8d8135a856c7b6af79de6a71b // UO sig From db241c9051d0389c66e0bc5c429a588af3bdd816 Mon Sep 17 00:00:00 2001 From: zer0dot Date: Thu, 13 Mar 2025 18:46:07 -0400 Subject: [PATCH 07/18] test: clean up test --- .../smart-contracts/src/ma-v2/client/client.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 aeed8fbae9..efb22a8c2d 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 @@ -326,7 +326,7 @@ describe("MA v2 Tests", async () => { ); }); - it("Deferred Actions", async () => { + it("installs a session key via deferred actions and has it sign the UO", async () => { let provider = (await givenConnectedProvider({ signer })).extend( installValidationActions ); @@ -396,20 +396,27 @@ describe("MA v2 Tests", async () => { // Pre-fetch the dummy sig so we can override `provider.account.getDummySignature()` const dummySig = await provider.account.getDummySignature(); + // Cache the previous dummy signature getter + const previousDummySigGetter = provider.account.getDummySignature; + // Override provider.account.getDummySignature() so `provider.buildUserOperation()` uses the prepended hex and the dummy signature during gas estimation provider.account.getDummySignature = () => { return concatHex([signaturePrepend, dummySig as Hex]); }; - // Generate the unsigned UO + // Generate the unsigned UO with the overridden dummy signature getter used for gas estimation const unsignedUo = (await provider.buildUserOperation({ uo: { target, data: "0x" }, overrides: { nonce: nonceOverride, // FIX: Currently, we aren't setting the deferred validation flag in the nonce key, instead we're setting it in // the returned nonce itself. This means sequential nonces will be incorrect. + // dummySignature: "0x", }, })) as UserOperationRequest_v7; + // Restore the dummy signature getter + provider.getDummySignature = previousDummySigGetter; + // Sign the UO with the session key const uo = await sessionKeyClient.signUserOperation({ uoStruct: unsignedUo, From 59d179e49d38bcf561bbff5bce136b262081c844 Mon Sep 17 00:00:00 2001 From: zer0dot Date: Thu, 13 Mar 2025 18:46:32 -0400 Subject: [PATCH 08/18] chore: clean up comments --- account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts index 25195eb88b..ec974f2e53 100644 --- a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts +++ b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts @@ -126,11 +126,6 @@ export const DeferredActionBuilder = { const sigLength = size(args.sig); - console.log("ENCODED DATA:", encodedData); - console.log("ENCODED DATA LENGTH:", encodedDataLength); - console.log("\n\nSIG:\n\n", args.sig); - console.log("\n\nSIG LENGTH:\n\n", sigLength); - encodedData = concatHex([ toHex(encodedDataLength, { size: 4 }), encodedData, From cdc08b7a4928a4e2987ccf87ea1090440e2a5f1a Mon Sep 17 00:00:00 2001 From: zer0dot Date: Fri, 14 Mar 2025 13:02:03 -0400 Subject: [PATCH 09/18] refactor: optimize deferred action flow --- .../src/ma-v2/client/client.test.ts | 37 ++++++----------- .../src/ma-v2/deferredActionUtils.ts | 41 ++++++++++++++++++- 2 files changed, 52 insertions(+), 26 deletions(-) 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 efb22a8c2d..4dde8d72cc 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 @@ -326,7 +326,7 @@ describe("MA v2 Tests", async () => { ); }); - it("installs a session key via deferred actions and has it sign the UO", async () => { + it("installs a session key via deferred action and has it sign a UO", async () => { let provider = (await givenConnectedProvider({ signer })).extend( installValidationActions ); @@ -365,7 +365,10 @@ describe("MA v2 Tests", async () => { signer: sessionKey, transport: custom(instance.getClient()), accountAddress: provider.getAddress(), - signerEntity: { entityId: 1, isGlobalValidation: true }, + signerEntity: { + entityId: sessionKeyEntityId, + isGlobalValidation: isGlobalValidation, + }, }); // Build the typed data we need for the deferred action using the session key client so the nonce uses the session key as the UO validation @@ -393,29 +396,13 @@ describe("MA v2 Tests", async () => { nonce: nonceOverride, }); - // Pre-fetch the dummy sig so we can override `provider.account.getDummySignature()` - const dummySig = await provider.account.getDummySignature(); - - // Cache the previous dummy signature getter - const previousDummySigGetter = provider.account.getDummySignature; - - // Override provider.account.getDummySignature() so `provider.buildUserOperation()` uses the prepended hex and the dummy signature during gas estimation - provider.account.getDummySignature = () => { - return concatHex([signaturePrepend, dummySig as Hex]); - }; - - // Generate the unsigned UO with the overridden dummy signature getter used for gas estimation - const unsignedUo = (await provider.buildUserOperation({ - uo: { target, data: "0x" }, - overrides: { - nonce: nonceOverride, // FIX: Currently, we aren't setting the deferred validation flag in the nonce key, instead we're setting it in - // the returned nonce itself. This means sequential nonces will be incorrect. - // dummySignature: "0x", - }, - })) as UserOperationRequest_v7; - - // Restore the dummy signature getter - provider.getDummySignature = previousDummySigGetter; + const unsignedUo = + await DeferredActionBuilder.buildUserOperationWithDeferredAction({ + client: provider, + uo: { target, data: "0x" }, + signaturePrepend, + nonceOverride, + }); // Sign the UO with the session key const uo = await sessionKeyClient.signUserOperation({ diff --git a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts index ec974f2e53..eab88b738e 100644 --- a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts +++ b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts @@ -1,7 +1,10 @@ import { InvalidNonceKeyError, type Address, + type BatchUserOperationCallData, type SmartAccountClient, + type UserOperationCallData, + type UserOperationRequest_v7, } from "@aa-sdk/core"; import { concatHex, @@ -12,6 +15,7 @@ import { size, toHex, } from "viem"; +import type { ModularAccountV2Client } from "./client/client"; type DeferredActionTypedData = { domain: { @@ -104,7 +108,6 @@ export const DeferredActionBuilder = { nonceOverride: nonceOverride, }; }, - // Maybe a better name for this buildDigest: (args: { typedData: DeferredActionTypedData; nonce: bigint; @@ -135,4 +138,40 @@ export const DeferredActionBuilder = { return encodedData; }, + buildUserOperationWithDeferredAction: async (args: { + client: ModularAccountV2Client; + uo: UserOperationCallData | BatchUserOperationCallData; + signaturePrepend: Hex; + nonceOverride: bigint; + }): Promise => { + // Pre-fetch the dummy sig so we can override `provider.account.getDummySignature()` + if (args.client.account === undefined) { + throw "client.account undefined"; + } + + // Pre-fetch the dummy sig so we can override `provider.account.getDummySignature()` + const dummySig = await args.client.account.getDummySignature(); + + // Cache the previous dummy signature getter + const previousDummySigGetter = args.client.account.getDummySignature; + + // Override provider.account.getDummySignature() so `provider.buildUserOperation()` uses the prepended hex and the dummy signature during gas estimation + args.client.account.getDummySignature = () => { + return concatHex([args.signaturePrepend, dummySig as Hex]); + }; + + const unsignedUo = (await args.client.buildUserOperation({ + uo: args.uo, + overrides: { + nonce: args.nonceOverride, // FIX: Currently, we aren't setting the deferred validation flag in the nonce key, instead we're setting it in + // the returned nonce itself. This means sequential nonces will be incorrect. + // dummySignature: "0x", + }, + })) as UserOperationRequest_v7; + + // Restore the dummy signature getter + args.client.getDummySignature = previousDummySigGetter; + + return unsignedUo; + }, }; From 6d429c5b634cb45441afdc0b2b64137e835b3e89 Mon Sep 17 00:00:00 2001 From: zer0dot Date: Fri, 14 Mar 2025 16:57:12 -0400 Subject: [PATCH 10/18] feat: docs and more accurate typing --- .../src/ma-v2/client/client.test.ts | 30 ++++++------ .../src/ma-v2/deferredActionUtils.ts | 49 ++++++++++++++++--- 2 files changed, 57 insertions(+), 22 deletions(-) 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 4dde8d72cc..7a67f8443c 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 @@ -326,7 +326,7 @@ describe("MA v2 Tests", async () => { ); }); - it("installs a session key via deferred action and has it sign a UO", 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 ); @@ -359,24 +359,12 @@ describe("MA v2 Tests", async () => { hooks: [], }); - // Initialize the client (we'll use this to build the deferred validation typed data, as it'll validate the entire UO) - let sessionKeyClient = await createModularAccountV2Client({ - chain: instance.chain, - signer: sessionKey, - transport: custom(instance.getClient()), - accountAddress: provider.getAddress(), - signerEntity: { - entityId: sessionKeyEntityId, - isGlobalValidation: isGlobalValidation, - }, - }); - // Build the typed data we need for the deferred action using the session key client so the nonce uses the session key as the UO validation // this installation will however be validated with the owner (fallback) validation const { typedData, nonceOverride } = await DeferredActionBuilder.createTypedDataObject({ - client: sessionKeyClient, - calldata: encodedInstallData, + client: provider, + callData: encodedInstallData, deadline: 0, entityId: sessionKeyEntityId, isGlobalValidation: isGlobalValidation, @@ -404,6 +392,18 @@ describe("MA v2 Tests", async () => { 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, diff --git a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts index eab88b738e..4aa5d02664 100644 --- a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts +++ b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts @@ -2,7 +2,6 @@ import { InvalidNonceKeyError, type Address, type BatchUserOperationCallData, - type SmartAccountClient, type UserOperationCallData, type UserOperationRequest_v7, } from "@aa-sdk/core"; @@ -43,11 +42,21 @@ type DeferredActionReturnData = { }; export const DeferredActionBuilder = { - /// Creates the typed data object ready for signing and the nonce for the given deferred action. - /// Returns the typed data object and the nonce to override. + /** + * Creates the typed data object ready for signing and the nonce for the given deferred action. + * + * @param {object} args The argument object containing the following: + * @param {ModularAccountV2Client} args.client A client associated with the sender account, only needed to access the account address and entrypoint + * @param {Hex} args.callData The call data to defer + * @param {number} args.deadline The deadline to include in this typed data, or zero for no deadline + * @param {number} args.entityId The entityId to use with the entire userOp, generally this will be the entity ID of the session key being installed in the deferred action, used to build the nonce + * @param {boolean} args.isGlobalValidation Whether the validation to use with the entire userOp is global + * @param {bigint} args.nonceKeyOverride The nonce key override for the entire UserOp if needed + * @returns {Promise} Object containing the typed data object and nonce override for the deferred action. + */ createTypedDataObject: async (args: { - client: SmartAccountClient; - calldata: Hex; + client: ModularAccountV2Client; + callData: Hex; deadline: number; entityId: number; isGlobalValidation: boolean; @@ -77,8 +86,12 @@ export const DeferredActionBuilder = { client: args.client, }); + // 2 = deferred action flags 0b10 + // 1 = isGlobal validation flag 0b01 const fullNonceKey: bigint = - ((baseNonceKey << 40n) + (BigInt(args.entityId) << 8n)) | 3n; // 3 = isGlobal + deferred action flags + ((baseNonceKey << 40n) + (BigInt(args.entityId) << 8n)) | + 2n | + (args.isGlobalValidation ? 1n : 0n); const nonceOverride = (await entryPointContract.read.getNonce([ args.client.account.address, @@ -102,12 +115,22 @@ export const DeferredActionBuilder = { message: { nonce: nonceOverride, deadline: args.deadline, - call: args.calldata, + call: args.callData, }, }, nonceOverride: nonceOverride, }; }, + + /** + * Creates the digest which must be prepended to the userOp signature. + * + * @param {object} args The argument object containing the following: + * @param {DeferredActionTypedData} args.typedData The typed data object for the deferred action + * @param {bigint} args.nonce The nonce to use for the entire UserOp + * @param {Hex} args.sig The signature to include in the digest + * @returns {Hex} The encoded digest to be prepended to the userOp signature + */ buildDigest: (args: { typedData: DeferredActionTypedData; nonce: bigint; @@ -138,6 +161,18 @@ export const DeferredActionBuilder = { 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 {ModularAccountV2Client} args.client A client associated with the sender account + * @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 + * @returns {Promise} The unsigned user operation request with the deferred action + * @throws {string} If client.account is undefined + */ buildUserOperationWithDeferredAction: async (args: { client: ModularAccountV2Client; uo: UserOperationCallData | BatchUserOperationCallData; From b1134b5326f9cf4cdd86d8bcea9cb14cc38513c9 Mon Sep 17 00:00:00 2001 From: zer0dot Date: Fri, 14 Mar 2025 17:16:15 -0400 Subject: [PATCH 11/18] fix: update errors --- .../smart-contracts/src/ma-v2/deferredActionUtils.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts index 4aa5d02664..9f7c48aff2 100644 --- a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts +++ b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts @@ -1,4 +1,6 @@ import { + AccountNotFoundError, + EntryPointNotFoundError, InvalidNonceKeyError, type Address, type BatchUserOperationCallData, @@ -63,7 +65,7 @@ export const DeferredActionBuilder = { nonceKeyOverride?: bigint; }): Promise => { if (!args.client.account) { - throw "Account undefined in client"; + throw new AccountNotFoundError(); } const baseNonceKey = args.nonceKeyOverride || 0n; @@ -71,13 +73,9 @@ export const DeferredActionBuilder = { throw new InvalidNonceKeyError(baseNonceKey); } - if (args.client.account === undefined) { - throw "temp"; - } - const entryPoint = args.client.account.getEntryPoint(); if (entryPoint === undefined) { - throw "temp"; + throw new EntryPointNotFoundError(args.client.chain, "0.7.0"); } const entryPointContract = getContract({ @@ -181,7 +179,7 @@ export const DeferredActionBuilder = { }): Promise => { // Pre-fetch the dummy sig so we can override `provider.account.getDummySignature()` if (args.client.account === undefined) { - throw "client.account undefined"; + throw new AccountNotFoundError(); } // Pre-fetch the dummy sig so we can override `provider.account.getDummySignature()` From 3e29c5fd0ebb9afaf4d8732e14107e70c287431c Mon Sep 17 00:00:00 2001 From: zer0dot Date: Mon, 17 Mar 2025 14:46:08 -0400 Subject: [PATCH 12/18] chore: slight cleanup, add missing test line --- account-kit/smart-contracts/src/ma-v2/client/client.test.ts | 4 +++- account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) 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 7a67f8443c..22b749779d 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 @@ -413,10 +413,12 @@ describe("MA v2 Tests", async () => { uo.signature = concatHex([signaturePrepend, uo.signature as Hex]); // Send the raw UserOp - await sessionKeyClient.sendRawUserOperation( + const result = await sessionKeyClient.sendRawUserOperation( uo, provider.account.getEntryPoint().address ); + + await provider.waitForUserOperationTransaction({ hash: result }); }); it("uninstalls a session key", async () => { diff --git a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts index 9f7c48aff2..d37794e498 100644 --- a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts +++ b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts @@ -196,9 +196,7 @@ export const DeferredActionBuilder = { const unsignedUo = (await args.client.buildUserOperation({ uo: args.uo, overrides: { - nonce: args.nonceOverride, // FIX: Currently, we aren't setting the deferred validation flag in the nonce key, instead we're setting it in - // the returned nonce itself. This means sequential nonces will be incorrect. - // dummySignature: "0x", + nonce: args.nonceOverride, }, })) as UserOperationRequest_v7; From 60258a6cb5ea0e01198a05d88e18360a0985f0d4 Mon Sep 17 00:00:00 2001 From: zer0dot Date: Mon, 17 Mar 2025 19:15:42 -0400 Subject: [PATCH 13/18] refactor: refactor to nullish coalescing operator --- .../src/actions/smartAccount/internal/initUserOperation.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts b/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts index 2e9b779ea2..2a07325362 100644 --- a/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts +++ b/aa-sdk/core/src/actions/smartAccount/internal/initUserOperation.ts @@ -63,9 +63,7 @@ export async function _initUserOperation< const signature = account.getDummySignature(); const nonce = - overrides?.nonce !== undefined - ? overrides.nonce - : account.getAccountNonce(overrides?.nonceKey); + overrides?.nonce ?? account.getAccountNonce(overrides?.nonceKey); const struct = entryPoint.version === "0.6.0" From 8a9bbdd5fc1f196c591dde5e99262e648b3fcecc Mon Sep 17 00:00:00 2001 From: zer0dot Date: Thu, 20 Mar 2025 14:12:33 -0400 Subject: [PATCH 14/18] feat: first port of typed data builder to action --- .../src/ma-v2/actions/DeferralActions.ts | 149 ++++++++++++++++++ .../src/ma-v2/client/client.test.ts | 107 ++++++++++++- 2 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 account-kit/smart-contracts/src/ma-v2/actions/DeferralActions.ts 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..bf22936edc --- /dev/null +++ b/account-kit/smart-contracts/src/ma-v2/actions/DeferralActions.ts @@ -0,0 +1,149 @@ +import { + AccountNotFoundError, + IncompatibleClientError, + isSmartAccountClient, + EntityIdOverrideError, + type GetEntryPointFromAccount, + type SendUserOperationResult, + type UserOperationOverridesParameter, + type SmartAccountSigner, + InvalidNonceKeyError, + EntryPointNotFoundError, +} from "@aa-sdk/core"; +import { + type Address, + type Hex, + encodeFunctionData, + concatHex, + zeroAddress, + maxUint152, + getContract, +} 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 CreateTypedDataParams = { + callData: Hex; + deadline: number; + entityId: number; + isGlobalValidation: boolean; + nonceKeyOverride?: bigint; +}; + +export type DeferralActions< + TSigner extends SmartAccountSigner = SmartAccountSigner +> = { + createDeferredActionTypedDataObject: ( + args: CreateTypedDataParams + ) => Promise; +}; + +/** + * Provides validation installation and uninstallation functionalities for a MA v2 client, ensuring compatibility with `SmartAccountClient`. + * + * @example + * ```ts + + * + * ``` + * + * @param {ModularAccountV2Client} client - The client instance which provides account and sendUserOperation functionality. + * @returns {object} - An object containing two methods, `installValidation` and `uninstallValidation`. + */ +export const deferralActions: < + TSigner extends SmartAccountSigner = SmartAccountSigner +>( + client: ModularAccountV2Client +) => DeferralActions = ( + client: ModularAccountV2Client +): DeferralActions => { + const createDeferredActionTypedDataObject = async ({ + callData, + deadline, + entityId, + isGlobalValidation, + nonceKeyOverride, + }: CreateTypedDataParams): 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, + }; + }; + return { + createDeferredActionTypedDataObject, + }; +}; 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 22b749779d..71c7c19624 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 @@ -21,7 +21,6 @@ import { testActions, type TestActions, concatHex, - toHex, type Hex, } from "viem"; import { HookType } from "../actions/common/types.js"; @@ -60,6 +59,7 @@ import { } from "@account-kit/infra"; import { getMAV2UpgradeToData } from "@account-kit/smart-contracts"; import { DeferredActionBuilder } from "../deferredActionUtils.js"; +import { deferralActions } from "../actions/DeferralActions.js"; // Note: These tests maintain a shared state to not break the local-running rundler by desyncing the chain. describe("MA v2 Tests", async () => { @@ -384,6 +384,111 @@ describe("MA v2 Tests", async () => { nonce: nonceOverride, }); + const unsignedUo = + await DeferredActionBuilder.buildUserOperationWithDeferredAction({ + client: provider, + uo: { target, data: "0x" }, + signaturePrepend, + nonceOverride, + }); + + console.log(unsignedUo); + + // 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.only("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 using the session key client so the nonce uses the session key as the UO validation + // this installation will however be validated with the owner (fallback) validation + // const { typedData, nonceOverride } = + // await DeferredActionBuilder.createTypedDataObject({ + // client: provider, + // callData: encodedInstallData, + // deadline: 0, + // entityId: sessionKeyEntityId, + // isGlobalValidation: isGlobalValidation, + // }); + + const { typedData, nonceOverride } = + await provider.createDeferredActionTypedDataObject({ + callData: encodedInstallData, + deadline: 0, + entityId: sessionKeyEntityId, + isGlobalValidation: isGlobalValidation, + }); + + // Sign the typed data using the owner (fallback) validation, we must use the inner signTypedData method to bypass 6492 for deferred actions + // Prepend 0x00 for the EOA_TYPE_SIGNATURE byte + const deferredValidationSig = concatHex([ + "0x00", + await provider.account.signTypedData(typedData), + ]); + + // Build the full hex to prepend to the UO signature + const signaturePrepend = DeferredActionBuilder.buildDigest({ + typedData: typedData, + sig: deferredValidationSig, + nonce: nonceOverride, + }); + const unsignedUo = await DeferredActionBuilder.buildUserOperationWithDeferredAction({ client: provider, From 8efc4741696c78a87e91a88aebfdd7df35d96bb4 Mon Sep 17 00:00:00 2001 From: zer0dot Date: Thu, 20 Mar 2025 15:40:18 -0400 Subject: [PATCH 15/18] feat: deferralActions client extension --- .../src/ma-v2/actions/DeferralActions.ts | 126 +++++++++++++++--- .../src/ma-v2/client/client.test.ts | 17 +-- 2 files changed, 112 insertions(+), 31 deletions(-) diff --git a/account-kit/smart-contracts/src/ma-v2/actions/DeferralActions.ts b/account-kit/smart-contracts/src/ma-v2/actions/DeferralActions.ts index bf22936edc..efc5c3a75a 100644 --- a/account-kit/smart-contracts/src/ma-v2/actions/DeferralActions.ts +++ b/account-kit/smart-contracts/src/ma-v2/actions/DeferralActions.ts @@ -1,23 +1,21 @@ import { AccountNotFoundError, - IncompatibleClientError, - isSmartAccountClient, - EntityIdOverrideError, - type GetEntryPointFromAccount, - type SendUserOperationResult, - type UserOperationOverridesParameter, type SmartAccountSigner, InvalidNonceKeyError, EntryPointNotFoundError, + type UserOperationCallData, + type BatchUserOperationCallData, + type UserOperationRequest_v7, } from "@aa-sdk/core"; import { type Address, type Hex, - encodeFunctionData, concatHex, - zeroAddress, maxUint152, getContract, + encodePacked, + size, + toHex, } from "viem"; import type { ModularAccountV2Client } from "../client/client.js"; @@ -54,33 +52,41 @@ export type CreateTypedDataParams = { nonceKeyOverride?: bigint; }; -export type DeferralActions< - TSigner extends SmartAccountSigner = SmartAccountSigner -> = { +export type BuildDigestParams = { + typedData: DeferredActionTypedData; + sig: Hex; +}; + +export type BuildUserOperationWithDeferredActionParams = { + uo: UserOperationCallData | BatchUserOperationCallData; + signaturePrepend: Hex; + nonceOverride: bigint; +}; + +export type DeferralActions = { createDeferredActionTypedDataObject: ( args: CreateTypedDataParams ) => Promise; + buildDigest: (args: BuildDigestParams) => Hex; + buildUserOperationWithDeferredAction: ( + args: BuildUserOperationWithDeferredActionParams + ) => Promise; }; /** - * Provides validation installation and uninstallation functionalities for a MA v2 client, ensuring compatibility with `SmartAccountClient`. + * Provides deferred action functionalities for a MA v2 client, ensuring compatibility with `SmartAccountClient`. * * @example * ```ts - - * + * // Example usage will be provided * ``` * * @param {ModularAccountV2Client} client - The client instance which provides account and sendUserOperation functionality. - * @returns {object} - An object containing two methods, `installValidation` and `uninstallValidation`. + * @returns {object} - An object containing three methods: `createDeferredActionTypedDataObject`, `buildDigest`, and `buildUserOperationWithDeferredAction`. */ -export const deferralActions: < - TSigner extends SmartAccountSigner = SmartAccountSigner ->( +export const deferralActions: ( client: ModularAccountV2Client -) => DeferralActions = ( - client: ModularAccountV2Client -): DeferralActions => { +) => DeferralActions = (client: ModularAccountV2Client): DeferralActions => { const createDeferredActionTypedDataObject = async ({ callData, deadline, @@ -143,7 +149,85 @@ export const deferralActions: < nonceOverride: nonceOverride, }; }; + + /** + * Creates the digest which must be prepended to the userOp signature. + * + * @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 buildDigest = ({ typedData, sig }: BuildDigestParams): 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, + buildDigest, + 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 71c7c19624..251dfae4e0 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 @@ -423,7 +423,7 @@ describe("MA v2 Tests", async () => { await provider.waitForUserOperationTransaction({ hash: result }); }); - it.only("installs a session key via deferred action signed by the owner and has it sign a UO", 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); @@ -483,19 +483,16 @@ describe("MA v2 Tests", async () => { ]); // Build the full hex to prepend to the UO signature - const signaturePrepend = DeferredActionBuilder.buildDigest({ + const signaturePrepend = provider.buildDigest({ typedData: typedData, sig: deferredValidationSig, - nonce: nonceOverride, }); - const unsignedUo = - await DeferredActionBuilder.buildUserOperationWithDeferredAction({ - client: provider, - uo: { target, data: "0x" }, - signaturePrepend, - nonceOverride, - }); + 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({ From 7287c539fb44fe94bef98de6c0419c9f449a8bd6 Mon Sep 17 00:00:00 2001 From: zer0dot Date: Thu, 20 Mar 2025 18:59:09 -0400 Subject: [PATCH 16/18] feat: refactor deferred actions to client action, streamline use --- .../src/actions/smartAccount/signTypedData.ts | 8 + .../src/ma-v2/account/nativeSMASigner.ts | 7 +- .../src/ma-v2/actions/DeferralActions.ts | 27 ++- .../src/ma-v2/client/client.test.ts | 139 +++++++----- .../src/ma-v2/deferredActionUtils.ts | 208 ------------------ .../single-signer-validation/signer.ts | 11 +- .../src/ma-v2/modules/utils.ts | 6 + 7 files changed, 124 insertions(+), 282 deletions(-) delete mode 100644 account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts diff --git a/aa-sdk/core/src/actions/smartAccount/signTypedData.ts b/aa-sdk/core/src/actions/smartAccount/signTypedData.ts index 274cbc889a..0a974494f5 100644 --- a/aa-sdk/core/src/actions/smartAccount/signTypedData.ts +++ b/aa-sdk/core/src/actions/smartAccount/signTypedData.ts @@ -38,5 +38,13 @@ export const signTypedData: < throw new AccountNotFoundError(); } + // We must bypass 6492 if we're signing a deferred action + if ( + typedData.primaryType === "DeferredAction" && + typedData.domain?.verifyingContract === account.address + ) { + return account.signTypedData(typedData); + } + return account.signTypedDataWith6492(typedData); }; 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 index efc5c3a75a..9508800fba 100644 --- a/account-kit/smart-contracts/src/ma-v2/actions/DeferralActions.ts +++ b/account-kit/smart-contracts/src/ma-v2/actions/DeferralActions.ts @@ -1,6 +1,5 @@ import { AccountNotFoundError, - type SmartAccountSigner, InvalidNonceKeyError, EntryPointNotFoundError, type UserOperationCallData, @@ -44,7 +43,7 @@ export type DeferredActionReturnData = { nonceOverride: bigint; }; -export type CreateTypedDataParams = { +export type CreateDeferredActionTypedDataParams = { callData: Hex; deadline: number; entityId: number; @@ -52,7 +51,7 @@ export type CreateTypedDataParams = { nonceKeyOverride?: bigint; }; -export type BuildDigestParams = { +export type BuildDeferredActionDigestParams = { typedData: DeferredActionTypedData; sig: Hex; }; @@ -65,9 +64,9 @@ export type BuildUserOperationWithDeferredActionParams = { export type DeferralActions = { createDeferredActionTypedDataObject: ( - args: CreateTypedDataParams + args: CreateDeferredActionTypedDataParams ) => Promise; - buildDigest: (args: BuildDigestParams) => Hex; + buildDeferredActionDigest: (args: BuildDeferredActionDigestParams) => Hex; buildUserOperationWithDeferredAction: ( args: BuildUserOperationWithDeferredActionParams ) => Promise; @@ -76,13 +75,8 @@ export type DeferralActions = { /** * Provides deferred action functionalities for a MA v2 client, ensuring compatibility with `SmartAccountClient`. * - * @example - * ```ts - * // Example usage will be provided - * ``` - * * @param {ModularAccountV2Client} client - The client instance which provides account and sendUserOperation functionality. - * @returns {object} - An object containing three methods: `createDeferredActionTypedDataObject`, `buildDigest`, and `buildUserOperationWithDeferredAction`. + * @returns {object} - An object containing three methods: `createDeferredActionTypedDataObject`, `buildDeferredActionDigest`, and `buildUserOperationWithDeferredAction`. */ export const deferralActions: ( client: ModularAccountV2Client @@ -93,7 +87,7 @@ export const deferralActions: ( entityId, isGlobalValidation, nonceKeyOverride, - }: CreateTypedDataParams): Promise => { + }: CreateDeferredActionTypedDataParams): Promise => { if (!client.account) { throw new AccountNotFoundError(); } @@ -153,12 +147,17 @@ export const deferralActions: ( /** * 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 buildDigest = ({ typedData, sig }: BuildDigestParams): Hex => { + const buildDeferredActionDigest = ({ + typedData, + sig, + }: BuildDeferredActionDigestParams): Hex => { const signerEntity = client.account.signerEntity; const validationLocator = (BigInt(signerEntity.entityId) << 8n) | @@ -227,7 +226,7 @@ export const deferralActions: ( return { createDeferredActionTypedDataObject, - buildDigest, + 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 251dfae4e0..110a64f164 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 @@ -58,8 +58,8 @@ import { alchemyGasAndPaymasterAndDataMiddleware, } from "@account-kit/infra"; import { getMAV2UpgradeToData } from "@account-kit/smart-contracts"; -import { DeferredActionBuilder } from "../deferredActionUtils.js"; import { deferralActions } from "../actions/DeferralActions.js"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; // Note: These tests maintain a shared state to not break the local-running rundler by desyncing the chain. describe("MA v2 Tests", async () => { @@ -327,9 +327,9 @@ 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 - ); + let provider = (await givenConnectedProvider({ signer })) + .extend(installValidationActions) + .extend(deferralActions); await setBalance(client, { address: provider.getAddress(), @@ -359,40 +359,33 @@ describe("MA v2 Tests", async () => { hooks: [], }); - // Build the typed data we need for the deferred action using the session key client so the nonce uses the session key as the UO validation - // this installation will however be validated with the owner (fallback) validation + // Build the typed data we need for the deferred action (provider/client only used for account address & entrypoint) const { typedData, nonceOverride } = - await DeferredActionBuilder.createTypedDataObject({ - client: provider, + await provider.createDeferredActionTypedDataObject({ callData: encodedInstallData, deadline: 0, entityId: sessionKeyEntityId, isGlobalValidation: isGlobalValidation, }); - // Sign the typed data using the owner (fallback) validation, we must use the inner signTypedData method to bypass 6492 for deferred actions - // Prepend 0x00 for the EOA_TYPE_SIGNATURE byte - const deferredValidationSig = concatHex([ - "0x00", - await provider.account.signTypedData(typedData), - ]); + // Sign the typed data using the owner (fallback) validation + const deferredValidationSig = await provider.signTypedData({ + typedData: typedData, + }); // Build the full hex to prepend to the UO signature - const signaturePrepend = DeferredActionBuilder.buildDigest({ + // This MUST be done with the *same* client that has signed the typed data + const signaturePrepend = provider.buildDeferredActionDigest({ typedData: typedData, sig: deferredValidationSig, - nonce: nonceOverride, }); - const unsignedUo = - await DeferredActionBuilder.buildUserOperationWithDeferredAction({ - client: provider, - uo: { target, data: "0x" }, - signaturePrepend, - nonceOverride, - }); - - console.log(unsignedUo); + // 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({ @@ -423,7 +416,7 @@ describe("MA v2 Tests", async () => { await provider.waitForUserOperationTransaction({ hash: result }); }); - it("installs a session key via deferred action signed by the owner and has it sign a UO", async () => { + 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); @@ -433,18 +426,16 @@ describe("MA v2 Tests", async () => { value: parseEther("2"), }); - // Test variables const sessionKeyEntityId = 1; - const isGlobalValidation = true; - // Encode install data to defer - let encodedInstallData = await provider.encodeInstallValidation({ + // First, install a session key + let sessionKeyInstallResult = await provider.installValidation({ validationConfig: { moduleAddress: getDefaultSingleSignerValidationModuleAddress( provider.chain ), entityId: sessionKeyEntityId, - isGlobal: isGlobalValidation, + isGlobal: true, isSignatureValidation: true, isUserOpValidation: true, }, @@ -456,38 +447,74 @@ describe("MA v2 Tests", async () => { hooks: [], }); - // Build the typed data we need for the deferred action using the session key client so the nonce uses the session key as the UO validation - // this installation will however be validated with the owner (fallback) validation - // const { typedData, nonceOverride } = - // await DeferredActionBuilder.createTypedDataObject({ - // client: provider, - // callData: encodedInstallData, - // deadline: 0, - // entityId: sessionKeyEntityId, - // isGlobalValidation: isGlobalValidation, - // }); + 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: sessionKeyEntityId, + entityId: newSessionKeyEntityId, isGlobalValidation: isGlobalValidation, }); - // Sign the typed data using the owner (fallback) validation, we must use the inner signTypedData method to bypass 6492 for deferred actions - // Prepend 0x00 for the EOA_TYPE_SIGNATURE byte - const deferredValidationSig = concatHex([ - "0x00", - await provider.account.signTypedData(typedData), - ]); + // Sign the typed data using the first session key + const deferredValidationSig = await sessionKeyClient.signTypedData({ + typedData: typedData, + }); // Build the full hex to prepend to the UO signature - const signaturePrepend = provider.buildDigest({ + // 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, @@ -495,27 +522,27 @@ describe("MA v2 Tests", async () => { }); // Initialize the session key client corresponding to the session key we will install in the deferred action - let sessionKeyClient = await createModularAccountV2Client({ + let newSessionKeyClient = await createModularAccountV2Client({ chain: instance.chain, - signer: sessionKey, + signer: newSessionKey, transport: custom(instance.getClient()), accountAddress: provider.getAddress(), signerEntity: { - entityId: sessionKeyEntityId, + entityId: newSessionKeyEntityId, isGlobalValidation: isGlobalValidation, }, }); - // Sign the UO with the session key - const uo = await sessionKeyClient.signUserOperation({ + // 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 - const result = await sessionKeyClient.sendRawUserOperation( + // Send the raw UserOp (provider/client only used for account address & entrypoint) + const result = await provider.sendRawUserOperation( uo, provider.account.getEntryPoint().address ); diff --git a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts b/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts deleted file mode 100644 index d37794e498..0000000000 --- a/account-kit/smart-contracts/src/ma-v2/deferredActionUtils.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { - AccountNotFoundError, - EntryPointNotFoundError, - InvalidNonceKeyError, - type Address, - type BatchUserOperationCallData, - type UserOperationCallData, - type UserOperationRequest_v7, -} from "@aa-sdk/core"; -import { - concatHex, - encodePacked, - getContract, - type Hex, - maxUint152, - size, - toHex, -} from "viem"; -import type { ModularAccountV2Client } from "./client/client"; - -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; - }; -}; - -type DeferredActionReturnData = { - typedData: DeferredActionTypedData; - nonceOverride: bigint; -}; - -export const DeferredActionBuilder = { - /** - * Creates the typed data object ready for signing and the nonce for the given deferred action. - * - * @param {object} args The argument object containing the following: - * @param {ModularAccountV2Client} args.client A client associated with the sender account, only needed to access the account address and entrypoint - * @param {Hex} args.callData The call data to defer - * @param {number} args.deadline The deadline to include in this typed data, or zero for no deadline - * @param {number} args.entityId The entityId to use with the entire userOp, generally this will be the entity ID of the session key being installed in the deferred action, used to build the nonce - * @param {boolean} args.isGlobalValidation Whether the validation to use with the entire userOp is global - * @param {bigint} args.nonceKeyOverride The nonce key override for the entire UserOp if needed - * @returns {Promise} Object containing the typed data object and nonce override for the deferred action. - */ - createTypedDataObject: async (args: { - client: ModularAccountV2Client; - callData: Hex; - deadline: number; - entityId: number; - isGlobalValidation: boolean; - nonceKeyOverride?: bigint; - }): Promise => { - if (!args.client.account) { - throw new AccountNotFoundError(); - } - - const baseNonceKey = args.nonceKeyOverride || 0n; - if (baseNonceKey > maxUint152) { - throw new InvalidNonceKeyError(baseNonceKey); - } - - const entryPoint = args.client.account.getEntryPoint(); - if (entryPoint === undefined) { - throw new EntryPointNotFoundError(args.client.chain, "0.7.0"); - } - - const entryPointContract = getContract({ - address: entryPoint.address, - abi: entryPoint.abi, - client: args.client, - }); - - // 2 = deferred action flags 0b10 - // 1 = isGlobal validation flag 0b01 - const fullNonceKey: bigint = - ((baseNonceKey << 40n) + (BigInt(args.entityId) << 8n)) | - 2n | - (args.isGlobalValidation ? 1n : 0n); - - const nonceOverride = (await entryPointContract.read.getNonce([ - args.client.account.address, - fullNonceKey, - ])) as bigint; - - return { - typedData: { - domain: { - chainId: await args.client.getChainId(), - verifyingContract: args.client.account.address, - }, - types: { - DeferredAction: [ - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint48" }, - { name: "call", type: "bytes" }, - ], - }, - primaryType: "DeferredAction", - message: { - nonce: nonceOverride, - deadline: args.deadline, - call: args.callData, - }, - }, - nonceOverride: nonceOverride, - }; - }, - - /** - * Creates the digest which must be prepended to the userOp signature. - * - * @param {object} args The argument object containing the following: - * @param {DeferredActionTypedData} args.typedData The typed data object for the deferred action - * @param {bigint} args.nonce The nonce to use for the entire UserOp - * @param {Hex} args.sig The signature to include in the digest - * @returns {Hex} The encoded digest to be prepended to the userOp signature - */ - buildDigest: (args: { - typedData: DeferredActionTypedData; - nonce: bigint; - sig: Hex; - }): Hex => { - // nonce used to determine validation locator - const validationLocator = 1n; // fallback validation with isGlobal set to true - - let encodedData = encodePacked( - ["uint168", "uint48", "bytes"], - [ - validationLocator, - args.typedData.message.deadline, - args.typedData.message.call, - ] - ); - - const encodedDataLength = size(encodedData); - - const sigLength = size(args.sig); - - encodedData = concatHex([ - toHex(encodedDataLength, { size: 4 }), - encodedData, - toHex(sigLength, { size: 4 }), - args.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 {ModularAccountV2Client} args.client A client associated with the sender account - * @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 - * @returns {Promise} The unsigned user operation request with the deferred action - * @throws {string} If client.account is undefined - */ - buildUserOperationWithDeferredAction: async (args: { - client: ModularAccountV2Client; - uo: UserOperationCallData | BatchUserOperationCallData; - signaturePrepend: Hex; - nonceOverride: bigint; - }): Promise => { - // Pre-fetch the dummy sig so we can override `provider.account.getDummySignature()` - if (args.client.account === undefined) { - throw new AccountNotFoundError(); - } - - // Pre-fetch the dummy sig so we can override `provider.account.getDummySignature()` - const dummySig = await args.client.account.getDummySignature(); - - // Cache the previous dummy signature getter - const previousDummySigGetter = args.client.account.getDummySignature; - - // Override provider.account.getDummySignature() so `provider.buildUserOperation()` uses the prepended hex and the dummy signature during gas estimation - args.client.account.getDummySignature = () => { - return concatHex([args.signaturePrepend, dummySig as Hex]); - }; - - const unsignedUo = (await args.client.buildUserOperation({ - uo: args.uo, - overrides: { - nonce: args.nonceOverride, - }, - })) as UserOperationRequest_v7; - - // Restore the dummy signature getter - args.client.getDummySignature = previousDummySigGetter; - - return unsignedUo; - }, -}; 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..8dda40f615 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,12 @@ import { sepolia, } from "@account-kit/infra"; +export enum SignatureType { + EOA = "0x00", + CONTRACT = "0x01", + CONTRACT_WITH_ADDR = "0x02", +} + /** * 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. * From 789c064ab3486bab00080cd81c82b14752e61b2a Mon Sep 17 00:00:00 2001 From: zer0dot Date: Thu, 20 Mar 2025 19:11:41 -0400 Subject: [PATCH 17/18] refactor: undo aa-sdk change, require using inner signTypedData function for deferred actions --- .../core/src/actions/smartAccount/signTypedData.ts | 8 -------- .../src/ma-v2/client/client.test.ts | 14 +++++++------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/aa-sdk/core/src/actions/smartAccount/signTypedData.ts b/aa-sdk/core/src/actions/smartAccount/signTypedData.ts index 0a974494f5..274cbc889a 100644 --- a/aa-sdk/core/src/actions/smartAccount/signTypedData.ts +++ b/aa-sdk/core/src/actions/smartAccount/signTypedData.ts @@ -38,13 +38,5 @@ export const signTypedData: < throw new AccountNotFoundError(); } - // We must bypass 6492 if we're signing a deferred action - if ( - typedData.primaryType === "DeferredAction" && - typedData.domain?.verifyingContract === account.address - ) { - return account.signTypedData(typedData); - } - return account.signTypedDataWith6492(typedData); }; 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 110a64f164..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 @@ -368,10 +368,10 @@ describe("MA v2 Tests", async () => { isGlobalValidation: isGlobalValidation, }); - // Sign the typed data using the owner (fallback) validation - const deferredValidationSig = await provider.signTypedData({ - typedData: typedData, - }); + // 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 @@ -503,9 +503,9 @@ describe("MA v2 Tests", async () => { }); // Sign the typed data using the first session key - const deferredValidationSig = await sessionKeyClient.signTypedData({ - typedData: typedData, - }); + 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 From 7f1509411a63b8eb2035693acc1ca41800be4e60 Mon Sep 17 00:00:00 2001 From: zer0dot Date: Fri, 21 Mar 2025 11:48:27 -0400 Subject: [PATCH 18/18] chore: remove unused flag --- account-kit/smart-contracts/src/ma-v2/modules/utils.ts | 1 - 1 file changed, 1 deletion(-) 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 8dda40f615..2e67815cbb 100644 --- a/account-kit/smart-contracts/src/ma-v2/modules/utils.ts +++ b/account-kit/smart-contracts/src/ma-v2/modules/utils.ts @@ -15,7 +15,6 @@ import { export enum SignatureType { EOA = "0x00", CONTRACT = "0x01", - CONTRACT_WITH_ADDR = "0x02", } /**