diff --git a/packages/airaccount/src/server/__tests__/paymaster-manager.test.ts b/packages/airaccount/src/server/__tests__/paymaster-manager.test.ts index f1a11033..7c3444c1 100644 --- a/packages/airaccount/src/server/__tests__/paymaster-manager.test.ts +++ b/packages/airaccount/src/server/__tests__/paymaster-manager.test.ts @@ -192,5 +192,24 @@ describe("PaymasterManager", () => { ); expect(data).toBe("0x"); }); + + it("v0.7 PaymasterV4 path: encodes verGas and postGas in correct byte positions", async () => { + // Without a live RPC the SuperPaymaster/token() calls fail and fall through to the + // PaymasterV4 branch, which uses hardcoded gas limits (0x30000 each). + // This test validates the packed-data byte layout. + const addr = "0x1234567890AbcDeF1234567890abcDEf12345678"; + const v07 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"; + + const data = await pm.getPaymasterData("user-1", "custom-user-provided", {}, v07, addr); + + // Layout: 0x | address(40 hex) | verGas(32 hex) | postGas(32 hex) + // indices: 2 42 74 106 + const verGas = BigInt("0x" + data.slice(42, 74)); + const postGas = BigInt("0x" + data.slice(74, 106)); + expect(verGas).toBe(BigInt(0x30000)); + expect(postGas).toBe(BigInt(0x30000)); + // No token appended when token() is unavailable + expect(data.length).toBe(106); + }); }); }); diff --git a/packages/airaccount/src/server/__tests__/transfer-manager.test.ts b/packages/airaccount/src/server/__tests__/transfer-manager.test.ts index c1f87b9e..f05bd224 100644 --- a/packages/airaccount/src/server/__tests__/transfer-manager.test.ts +++ b/packages/airaccount/src/server/__tests__/transfer-manager.test.ts @@ -1,6 +1,7 @@ +import { ethers } from "ethers"; import { MemoryStorage } from "../adapters/memory-storage"; import { TransferRecord } from "../interfaces/storage-adapter"; -import { TransferManager } from "../services/transfer-manager"; +import { TransferManager, detectSignatureStrategy } from "../services/transfer-manager"; import { SilentLogger } from "../interfaces/logger"; /** @@ -125,6 +126,61 @@ describe("TransferManager", () => { }); }); + describe("detectSignatureStrategy", () => { + const ACCOUNT = "0x1234567890123456789012345678901234567890"; + + function makeProvider(opts: { + code: string; + validatorResult?: string; + validatorThrows?: boolean; + }): ethers.Provider { + return { + getCode: vi.fn().mockResolvedValue(opts.code), + call: vi.fn().mockImplementation(() => { + if (opts.validatorThrows) throw new Error("revert"); + // ABI-encode address return value (32-byte zero-padded) + return opts.validatorResult ?? ethers.zeroPadValue(ethers.ZeroAddress, 32); + }), + getNetwork: vi.fn().mockResolvedValue({ chainId: 1n }), + } as unknown as ethers.Provider; + } + + it("undeployed account → useECDSA=true, isCompositeValidator=true", async () => { + const provider = makeProvider({ code: "0x" }); + const result = await detectSignatureStrategy(provider, ACCOUNT); + expect(result.useECDSA).toBe(true); + expect(result.isCompositeValidator).toBe(true); + }); + + it("deployed compositeValidator with no validator set → useECDSA=true, isCompositeValidator=true", async () => { + const provider = makeProvider({ + code: "0x608060", + validatorResult: ethers.zeroPadValue(ethers.ZeroAddress, 32), + }); + const result = await detectSignatureStrategy(provider, ACCOUNT); + expect(result.useECDSA).toBe(true); + expect(result.isCompositeValidator).toBe(true); + }); + + it("deployed compositeValidator with validator set → useECDSA=false, isCompositeValidator=true", async () => { + const validatorAddr = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + const provider = makeProvider({ + code: "0x608060", + validatorResult: ethers.zeroPadValue(validatorAddr, 32), + }); + const result = await detectSignatureStrategy(provider, ACCOUNT); + expect(result.useECDSA).toBe(false); + expect(result.isCompositeValidator).toBe(true); + }); + + it("validator() call throws → useECDSA=true, isCompositeValidator=false (no algId prefix)", async () => { + const provider = makeProvider({ code: "0x608060", validatorThrows: true }); + const result = await detectSignatureStrategy(provider, ACCOUNT); + expect(result.useECDSA).toBe(true); + expect(result.isCompositeValidator).toBe(false); + }); + }); + describe("getTransferHistory", () => { it("should return empty result for user with no transfers", async () => { const manager = makeManager(); diff --git a/packages/airaccount/src/server/services/paymaster-manager.ts b/packages/airaccount/src/server/services/paymaster-manager.ts index 8070b9ae..d5678cd0 100644 --- a/packages/airaccount/src/server/services/paymaster-manager.ts +++ b/packages/airaccount/src/server/services/paymaster-manager.ts @@ -169,7 +169,8 @@ export class PaymasterManager { if (isSuperPaymaster) { const verGas = BigInt(80000); - const postGas = BigInt(100000); + // recordXPNTsDebt + event emit in postOp observed ~117k gas on Sepolia; 300k gives safe headroom. + const postGas = BigInt(300_000); const maxRate = (BigInt(1) << BigInt(256)) - BigInt(1); return ethers.concat([ formattedAddress, diff --git a/packages/airaccount/src/server/services/transfer-manager.ts b/packages/airaccount/src/server/services/transfer-manager.ts index b20ba1eb..5a9aae4d 100644 --- a/packages/airaccount/src/server/services/transfer-manager.ts +++ b/packages/airaccount/src/server/services/transfer-manager.ts @@ -8,13 +8,44 @@ import { TokenService } from "./token-service"; import { IStorageAdapter } from "../interfaces/storage-adapter"; import { ISignerAdapter, PasskeyAssertionContext } from "../interfaces/signer-adapter"; import { LegacyPasskeyAssertion } from "./kms-signer"; -import { EntryPointVersion } from "../constants/entrypoint"; +import { EntryPointVersion, ALG_ID } from "../constants/entrypoint"; import { ILogger, ConsoleLogger } from "../interfaces/logger"; import { PaymasterPriceStalenessError } from "./paymaster-manager"; import { UserOperation, PackedUserOperation } from "../../core/types"; import { ERC4337Utils } from "../../core/erc4337"; import { TierLevel } from "../../core/tier"; +// ── Signature strategy detection ───────────────────────────────── + +/** + * Determines whether to use plain ECDSA and whether the account is a compositeValidator. + * Exported for unit testing. + */ +export async function detectSignatureStrategy( + provider: ethers.Provider, + accountAddress: string +): Promise<{ useECDSA: boolean; isCompositeValidator: boolean }> { + try { + const accountCode = await provider.getCode(accountAddress); + if (accountCode === "0x") { + // AirAccount factory invariant: all counterfactual addresses are compositeValidator deployments. + return { useECDSA: true, isCompositeValidator: true }; + } + const acc = new ethers.Contract( + accountAddress, + ["function validator() view returns (address)"], + provider + ); + const v = (await acc.validator()) as string; + // validator() exists → confirmed compositeValidator account. + return { useECDSA: v === ethers.ZeroAddress, isCompositeValidator: true }; + } catch { + // Covers both getCode() and validator() failures (network error or non-compositeValidator account). + // Use raw ECDSA (no algId prefix) to avoid AA24 on non-compositeValidator accounts. + return { useECDSA: true, isCompositeValidator: false }; + } +} + // ── Public DTOs ─────────────────────────────────────────────────── export interface ExecuteTransferParams { @@ -146,34 +177,28 @@ export class TransferManager { ? { assertion: params.passkeyAssertion } : undefined; - // M4 accounts: check if validator is set; if not, use ECDSA instead of BLS + // Detect whether this is a compositeValidator account (has validator() fn) or plain ECDSA. + // Only compositeValidator accounts expect the algId prefix in the signature. let useECDSA = false; + let isCompositeValidator = false; if (version === EntryPointVersion.V0_7 || version === EntryPointVersion.V0_8) { - try { - const provider = this.ethereum.getProvider(); - const accountCode = await provider.getCode(account.address); - if (accountCode === "0x") { - useECDSA = true; - } else { - const acc = new ethers.Contract( - account.address, - ["function validator() view returns (address)"], - provider - ); - const v = await acc.validator(); - if (v === ethers.ZeroAddress) useECDSA = true; - } - } catch { - useECDSA = true; - } + const provider = this.ethereum.getProvider(); + ({ useECDSA, isCompositeValidator } = await detectSignatureStrategy( + provider, + account.address + )); } if (useECDSA) { - // M4 ECDSA path: raw 65-byte sig, no validator needed - this.logger.log("M4: using ECDSA signature (validator not set)"); const signer = await this.signer.getSigner(userId, assertionCtx); const ecdsaSig = await signer.signMessage(ethers.getBytes(userOpHash)); - userOp.signature = ecdsaSig; + if (isCompositeValidator) { + this.logger.log("ECDSA path for compositeValidator: prepending algId prefix"); + userOp.signature = ethers.concat([ethers.toBeHex(ALG_ID.ECDSA, 1), ecdsaSig]); + } else { + this.logger.log("ECDSA path for non-compositeValidator: raw signature"); + userOp.signature = ecdsaSig; + } } else if (params.useAirAccountTiering && this.guardChecker) { // AirAccount tiered signature routing const transferValue = params.tokenAddress ? 0n : ethers.parseEther(params.amount); @@ -196,11 +221,10 @@ export class TransferManager { ctx: assertionCtx, }); } else { - // BLS triple signature with algId 0x01 prefix for M4 account routing + // BLS accounts are always compositeValidator by design — algId prefix applied unconditionally. const blsData = await this.blsService.generateBLSSignature(userId, userOpHash, assertionCtx); const packedBls = await this.blsService.packSignature(blsData); - // Prepend algId=0x01 byte for M4 _validateSignature routing - userOp.signature = "0x01" + packedBls.slice(2); + userOp.signature = ethers.concat([ethers.toBeHex(ALG_ID.BLS, 1), packedBls]); } // Create transfer record