Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 49 additions & 25 deletions packages/airaccount/src/server/services/transfer-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down