Skip to content

[weighted-validator] Two bugs on first UserOp for undeployed SA (1-of-1) #264

Description

@TarasTesler

Environment

  • Package: @zerodev/weighted-validator@5.5.1
  • SDK: @zerodev/sdk@5.5.10, permissionless@0.3.5, viem@2.49.3
  • Kernel v0.3.1, EntryPoint v0.7
  • Config: threshold: 1, signers: [{ publicKey: eoaAddress, weight: 1 }], V0_0_2_PATCHED
  • Chain: verified on Base, Arbitrum, Ethereum mainnet

Summary

The vanilla 1-of-1 weighted-validator setup crashes on the SA's first outgoing UserOp with two distinct bugs. Both surface only when the SA hasn't been deployed yet (i.e. the very first sendUserOperation on a freshly-derived SA).

Bug 1 — getStubSignature crashes when userOperation.signature is undefined

In toWeightedValidatorPlugin.ts (currently line ~230):

async getStubSignature(userOperation) {
    let signatures = []
    if (userOperation.signature !== "0x") {
        console.log(userOperation.signature) // <-- also a stray debug log
        signatures = decodeSignatures(userOperation.signature)
    }
    ...
}

On the first call from prepareUserOperation, userOperation.signature is undefined, not "0x". The !== "0x" guard passes through, and decodeSignatures(undefined)decodeAbiParameters(schema, undefined)createCursor(undefined)undefined.buffer throws:

TypeError: undefined is not an object (evaluating 'bytes.buffer')
    at createCursor (viem/utils/cursor.js)
    at decodeAbiParameters (viem/utils/abi/decodeAbiParameters.js)
    at decodeSignatures (@zerodev/weighted-validator/utils.js)
    at getStubSignature (@zerodev/weighted-validator/toWeightedValidatorPlugin.js:230)
    at prepareUserOperation (viem/account-abstraction)

Fix:

if (userOperation.signature && userOperation.signature !== "0x") {
    signatures = decodeSignatures(userOperation.signature)
}

Also please drop the console.log on the line above.

Bug 2 — signUserOperation appends real sig to stub instead of replacing

In the same file (currently line ~210):

async signUserOperation(userOperation) {
    let signatures = []
    if (userOperation.signature !== "0x") {
        signatures = decodeSignatures(userOperation.signature)
    }
    // ... hash + sign ...
    return encodeSignatures([...signatures, lastSignature])
}

For solo-signer flows (or any flow where sendUserOperation on the kernelClient does prepare + sign in one shot), userOperation.signature at this point holds the stub from getStubSignature, not signatures from other co-signers. The code decodes the stub and appends the real sig, so the tx broadcasts with [stubSig, realSig] — two signatures both from the same signer index.

On-chain validateUserOp reverts:

AA23 reverted 0x52bf9848

The custom-error selector suggests the WeightedValidator contract rejects the duplicate signer / oversized signature array.

Proposed fix: for the solo path, don't decode existing sigs. Multi-party collaboration should use the separate sendUserOperationWithSignatures action (which is what the docs at multisig / advanced describe). Simplest patch:

async signUserOperation(userOperation) {
    const userOpHash = getUserOperationHash({
        userOperation: { ...userOperation, signature: "0x" } as UserOperation<entryPointVersion>,
        entryPointAddress: entryPoint.address,
        entryPointVersion: entryPoint.version,
        chainId: chainId
    })
    const lastSignature = await account.signMessage({ message: { raw: userOpHash } })
    return encodeSignatures([lastSignature])
}

If you want to preserve multi-party collaboration through this same code path, the guard needs to distinguish "sig field contains our own stub" from "sig field contains a co-signer's real sig" — the current code has no way to tell them apart.

Reproduction

Any 1-of-1 setup:

const validator = await createWeightedValidator(publicClient, {
    signer: await toECDSASigner({ signer: privateKeyToAccount(pk) }),
    config: { threshold: 1, signers: [{ publicKey: owner.address, weight: 1 }] },
    entryPoint: { address: ENTRY_POINT_V07, version: '0.7' },
    kernelVersion: KERNEL_V3_1,
    validatorContractVersion: WeightedValidatorContractVersion.V0_0_2_PATCHED,
})
const account = await createKernelAccount(publicClient, {
    plugins: { sudo: validator },
    entryPoint: { address: ENTRY_POINT_V07, version: '0.7' },
    kernelVersion: KERNEL_V3_1,
})
const client = createKernelAccountClient({ account, chain, bundlerTransport, paymaster })
await client.sendUserOperation({ callData: /* any call */ })  // <-- crashes

Workaround

We're running a patch-package patch against 5.5.1 with both fixes above until upstream ships. Happy to open a PR if that's helpful.

Also worth clarifying (bonus, links to #254)

Docs at https://docs.zerodev.app/sdk/advanced/multisig import from @zerodev/weighted-validator, but getUpdateConfigCall() only exists in @zerodev/weighted-ecdsa-validator. See #254 — same confusion pointed us at @zerodev/weighted-validator and then this bug set. If the two packages are meant to converge, prioritizing that would prevent new users from tripping the wrong one.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions