Skip to content

SafeMultiChainSigAccount, Simple7702AccountV09 and AllowAllPaymaster#62

Merged
sherifahmed990 merged 78 commits intomainfrom
dev
Mar 23, 2026
Merged

SafeMultiChainSigAccount, Simple7702AccountV09 and AllowAllPaymaster#62
sherifahmed990 merged 78 commits intomainfrom
dev

Conversation

@sherifahmed990
Copy link
Copy Markdown
Member

@sherifahmed990 sherifahmed990 commented Jan 14, 2026

Summary by CodeRabbit

  • New Features
    • Safe v9 and Safe v1.5.0 support, experimental multi‑chain signatures (Merkle proofs) and multi‑chain account flows; Simple7702 v0.9 and EIP‑7702 tooling; expanded paymaster ecosystem (token & sponsor flows, AllowAll, Candide, WorldId); improved bundler client, gas estimation, Tenderly simulation, merkle & multisend utilities, enhanced account factory helpers and allowance/social‑recovery workflows.
  • Documentation
    • README: recipes, bundler/paymaster guidance, token‑paymaster steps, version table, AI agent snippet, formatting fixes.
  • Bug Fixes
    • Numerous typos and wording corrections.
  • Chores
    • Package version bumped; CI build workflow added.

sherifahmed990 and others added 17 commits December 16, 2025 05:15
- Fix operator precedence in factory/factoryData validation (utilsTenderly.ts)
- Fix inverted gas calculation logic for paymaster detection (utils.ts)
- Replace locale-sensitive toLocaleLowerCase() with toLowerCase() for addresses
- Remove unreachable dead code in owner index validation
- Replace fragile magic number checks with proper ENTRYPOINT_V9 constant comparison

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add missing `new` keyword before RangeError throws (95 occurrences)
- Fix typo: safeModuleSetupddress -> safeModuleSetupAddress (25+ locations)
- Fix wrong Promise array in Simple7702Account.ts:259-265
  (was using gasPriceOp instead of nonceOp)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@Sednaoui
Copy link
Copy Markdown
Member

Export UserOperationV9 from main entry point (currently requires importing from abstractionkit/dist/types)

@sherifahmed990
Copy link
Copy Markdown
Member Author

Export UserOperationV9 from main entry point (currently requires importing from abstractionkit/dist/types)

fixed

@Sednaoui
Copy link
Copy Markdown
Member

There's a bug where WebAuthnSignatureOverrides aren't being passed through properly in formatSignaturesToUseroperationsSignatures.

I am using the isInit override for creating a passkeys example, and it is not propagated to the internal formatSignaturesToUseroperationSignature calls.

@Sednaoui
Copy link
Copy Markdown
Member

Sednaoui commented Jan 28, 2026

MultiChainSignatureMerkleTreeRootTypedDataDomain returns types that require casting to work with viem's signTypedData:

// Current - requires awkward casting                                                                                                                                                       
const signature = await walletClient.signTypedData({                                                                                                                                        
    domain: eip712Data.domain as Parameters<typeof walletClient.signTypedData>[0]['domain'],                                                                                                
    types: eip712Data.types,                                                                                                                                                                
    primaryType: 'MerkleTreeRoot',                                                                                                                                                          
    message: eip712Data.messageValue as unknown as Record<string, unknown>                                                                                                                  
});                                                                                                                                                                                         

We can either:

  • Document that primaryType should be 'MerkleTreeRoot' (the type name CrossChainSignatureMerkleTreeRootTypedMessageValue suggests otherwise)
  • we can consider making return types compatible with viem's expected types, or provide a helper method

@Sednaoui
Copy link
Copy Markdown
Member

Sednaoui commented Jan 28, 2026

missing JSDoc examples for multichain signing methods

@Sednaoui
Copy link
Copy Markdown
Member

Sednaoui commented Jan 29, 2026

for the EIP-712 Type, developers need to know the correct primary type for EIP-712 signing. There's no way for developers to know it is 'MerkleTreeRoot'.

So I suggest to export it as constant MULTI_CHAIN_SIGNATURE_PRIMARY_TYPE

@Sednaoui
Copy link
Copy Markdown
Member

Sednaoui commented Feb 12, 2026

@sherifahmed990 while doing a demo with SafeMultiChainSigAccount, the second operation when signing with passkeys always fails. I am not sure where is the issue, it might be similar to what we've found with the missing overrides. I will share with you script

sherifahmed990 and others added 7 commits March 19, 2026 11:55
The default AllowanceModule address changed from v0.1.0 to v1.0.0 due to
a bug in the old contract. Export the old address as
ALLOWANCE_MODULE_V0_1_0_ADDRESS so consumers with active allowances on the
legacy module can still interact with it. Add a breaking-change note to
the class JSDoc.
- getDelegatedAddress(address, rpc): checks EIP-7702 delegation via
  eth_getCode, returns checksummed delegatee address or null
- Case-insensitive hex check for EIP-7702 prefix
- createRevokeDelegationAuthorization (internal): creates signed
  zero-address authorization for revoking delegation
- isDelegatedToThisAccount(rpc): checks if EOA is delegated to
  this account's expected delegatee address
- createRevokeDelegationTransaction(privateKey, rpc, overrides?):
  builds signed type-4 transaction for revoking delegation, with
  guard that verifies delegation state before revoking
- Auto-skip eip7702Auth in createUserOperation when already delegated
  to the correct address (parallel eth_getCode check, zero added latency)
- Delegation pre-check is best-effort (catches errors, proceeds as
  if not delegated) to avoid breaking createUserOperation on flaky RPC
V0.8: delegate (sponsored), auto-check skip, revoke, revoke-when-not-delegated
V0.9: delegate, auto-check skip, revoke
@Sednaoui
Copy link
Copy Markdown
Member

Sednaoui commented Mar 19, 2026

formatSignaturesToUseroperationsSignatures and getMultiChainSingleSignatureUserOperationsEip712Hash are using different default safe4337ModuleAddress values:

  • getMultiChainSingleSignatureUserOperationsEip712Hash correctly defaults to ExperimentalSafeMultiChainSigAccount.DEFAULT_SAFE_4337_MODULE_ADDRESS (0x22939E...)

  • formatSignaturesToUseroperationsSignatures calls getUserOperationEip712Hash_V9 without overriding the address, so it falls through to the base SafeAccount default (0xee8005d7...)

  • The instance method signUserOperations() avoids this because it passes this.safe4337ModuleAddress explicitly everywhere. But the static method path (which is the only option for WebAuthn since signing requires browser interaction) hits the mismatch.

The fix should be in formatSignaturesToUseroperationsSignatures. it should default to DEFAULT_SAFE_4337_MODULE_ADDRESS when safe4337ModuleAddress isn't provided, same asgetMultiChainSingleSignatureUserOperationsEip712Hash does.

When isInit: false, formatSignaturesToUseroperationsSignatures computes the WebAuthn signer address using the base class defaults , including the eip7212WebAuthnPrecompileVerifier and eip7212WebAuthnContractVerifier

Copy link
Copy Markdown
Member

@Sednaoui Sednaoui left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comments

andrewwahid and others added 5 commits March 23, 2026 10:00
The pre-test cleanup now detects which delegatee the account is
currently delegated to and uses the matching account class to revoke,
instead of always using Simple7702Account (v0.8).
…ress in multi-chain EIP-712 helper

Bundler.sendUserOperation and estimateUserOperationGas now accept
UserOperationV9, matching what SafeAccount passes. The static
getMultiChainSingleSignatureUserOperationsEip712Data now forwards
entrypointAddress to getUserOperationEip712Hash_V9 so it no longer
silently defaults to ENTRYPOINT_V9.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
With only one UserOperation, generateMerkleProofs produces a 32-byte
proof that fails the merkleProofLength < 128 check in
formatSignaturesToUseroperationSignature. Skip the Merkle tree for
length==1 and format with isMultiChainSignature and depth 0, matching
the signUserOperations instance method behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…atures

The static method was falling through to base SafeAccount defaults for
safe4337ModuleAddress, eip7212WebAuthnPrecompileVerifier, and
eip7212WebAuthnContractVerifier instead of using the
ExperimentalSafeMultiChainSigAccount values. This caused hash mismatches
for the WebAuthn external-signer path. Resolve defaults up front and
propagate them to all code paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sherifahmed990
Copy link
Copy Markdown
Member Author

  1. formatSignaturesToUseroperationsSignatures (the static external-signer API) throws RangeError("invalid multiChainMerkleProof length.") when called with a single operation.

With 1 hash, generateMerkleProofs produces a proof string that is just the root (32 bytes = 64 hex chars). This proof is passed as multiChainMerkleProof to formatSignaturesToUseroperationSignature, where the length check merkleProofLength < 128 fails and throws.

Meanwhile, signUserOperations with 1 op takes a different path — it calls signUserOperation which sets isMultiChainSignature: true with no multiChainMerkleProof, correctly encoding depth 0.

Fix: add a length==1 special case in formatSignaturesToUseroperationsSignatures that skips the Merkle tree and formats with depth 0, matching the signUserOperations behavior.

*/
public static formatSignaturesToUseroperationsSignatures(
userOperationsToSign: UserOperationToSign[],
signerSignaturePairs: SignerSignaturePair[],
overrides: WebAuthnSignatureOverrides = {},
): string[] {
if (userOperationsToSign.length < 1) {
throw new RangeError("There should be at least one userOperationsToSign");
}
const userOperationsHashes: string[] = [];
userOperationsToSign.forEach(
(userOperationsToSign, _index) => {
const userOperationHash = SafeAccount.getUserOperationEip712Hash_V9(
userOperationsToSign.userOperation,
userOperationsToSign.chainId,
{
validAfter: userOperationsToSign.validAfter,
validUntil: userOperationsToSign.validUntil,
safe4337ModuleAddress: overrides.safe4337ModuleAddress,
},
);
userOperationsHashes.push(userOperationHash);
});
const [_root, proofs] = generateMerkleProofs(userOperationsHashes);
const userOpSignatures: string[] = [];
userOperationsToSign.forEach(
(_userOperationsToSign, index) => {
userOpSignatures.push(
SafeAccount.formatSignaturesToUseroperationSignature(
signerSignaturePairs,
{
...overrides,
isMultiChainSignature:true,
multiChainMerkleProof: proofs[index],
},
)
);
});
return userOpSignatures;
}

if(overrides.isMultiChainSignature){
if(overrides.multiChainMerkleProof != null){
const merkleProofLength =
overrides.multiChainMerkleProof.slice(2).length; // wihout 0x prefix
if(
// 1 byte has a length of 2 hex chars
// minimum proof consist of at least two hashes, 2 * 2 * 32 = 128
merkleProofLength < 128 ||
// a valid proof length should be a multiple of 2 * 32 = 64
merkleProofLength % 64 != 0
){
throw new RangeError("invalid multiChainMerkleProof length.");
}
const merkleTreeDepth = (merkleProofLength / 64) - 1;
let merkleTreeDepthHex = merkleTreeDepth.toString(16);
// create a 0x prefixed hex with an even length of chars
if(merkleTreeDepthHex.length % 2 == 0){
merkleTreeDepthHex = "0x" + merkleTreeDepthHex;
}else{
merkleTreeDepthHex = "0x0" + merkleTreeDepthHex;
}
return solidityPacked(
["bytes1", "uint48", "uint48", "bytes"],
[
merkleTreeDepthHex,
validAfter,
validUntil,
overrides.multiChainMerkleProof + signature.slice(2)
],
);
}else{
//no proof means a single useroperation
return solidityPacked(
["bytes1", "uint48", "uint48", "bytes"],
[
"0x00", // single useroperation - merkle depth is 0
validAfter,
validUntil,
signature
],
);
}

3baff37

@sherifahmed990
Copy link
Copy Markdown
Member Author

  1. formatSignaturesToUseroperationsSignatures (the static external-signer API) throws RangeError("invalid multiChainMerkleProof length.") when called with a single operation.

With 1 hash, generateMerkleProofs produces a proof string that is just the root (32 bytes = 64 hex chars). This proof is passed as multiChainMerkleProof to formatSignaturesToUseroperationSignature, where the length check merkleProofLength < 128 fails and throws.

Meanwhile, signUserOperations with 1 op takes a different path — it calls signUserOperation which sets isMultiChainSignature: true with no multiChainMerkleProof, correctly encoding depth 0.

Fix: add a length==1 special case in formatSignaturesToUseroperationsSignatures that skips the Merkle tree and formats with depth 0, matching the signUserOperations behavior.

*/
public static formatSignaturesToUseroperationsSignatures(
userOperationsToSign: UserOperationToSign[],
signerSignaturePairs: SignerSignaturePair[],
overrides: WebAuthnSignatureOverrides = {},
): string[] {
if (userOperationsToSign.length < 1) {
throw new RangeError("There should be at least one userOperationsToSign");
}
const userOperationsHashes: string[] = [];
userOperationsToSign.forEach(
(userOperationsToSign, _index) => {
const userOperationHash = SafeAccount.getUserOperationEip712Hash_V9(
userOperationsToSign.userOperation,
userOperationsToSign.chainId,
{
validAfter: userOperationsToSign.validAfter,
validUntil: userOperationsToSign.validUntil,
safe4337ModuleAddress: overrides.safe4337ModuleAddress,
},
);
userOperationsHashes.push(userOperationHash);
});
const [_root, proofs] = generateMerkleProofs(userOperationsHashes);
const userOpSignatures: string[] = [];
userOperationsToSign.forEach(
(_userOperationsToSign, index) => {
userOpSignatures.push(
SafeAccount.formatSignaturesToUseroperationSignature(
signerSignaturePairs,
{
...overrides,
isMultiChainSignature:true,
multiChainMerkleProof: proofs[index],
},
)
);
});
return userOpSignatures;
}

if(overrides.isMultiChainSignature){
if(overrides.multiChainMerkleProof != null){
const merkleProofLength =
overrides.multiChainMerkleProof.slice(2).length; // wihout 0x prefix
if(
// 1 byte has a length of 2 hex chars
// minimum proof consist of at least two hashes, 2 * 2 * 32 = 128
merkleProofLength < 128 ||
// a valid proof length should be a multiple of 2 * 32 = 64
merkleProofLength % 64 != 0
){
throw new RangeError("invalid multiChainMerkleProof length.");
}
const merkleTreeDepth = (merkleProofLength / 64) - 1;
let merkleTreeDepthHex = merkleTreeDepth.toString(16);
// create a 0x prefixed hex with an even length of chars
if(merkleTreeDepthHex.length % 2 == 0){
merkleTreeDepthHex = "0x" + merkleTreeDepthHex;
}else{
merkleTreeDepthHex = "0x0" + merkleTreeDepthHex;
}
return solidityPacked(
["bytes1", "uint48", "uint48", "bytes"],
[
merkleTreeDepthHex,
validAfter,
validUntil,
overrides.multiChainMerkleProof + signature.slice(2)
],
);
}else{
//no proof means a single useroperation
return solidityPacked(
["bytes1", "uint48", "uint48", "bytes"],
[
"0x00", // single useroperation - merkle depth is 0
validAfter,
validUntil,
signature
],
);
}

c1d5cca

@sherifahmed990
Copy link
Copy Markdown
Member Author

formatSignaturesToUseroperationsSignatures and getMultiChainSingleSignatureUserOperationsEip712Hash are using different default safe4337ModuleAddress values:

  • getMultiChainSingleSignatureUserOperationsEip712Hash correctly defaults to ExperimentalSafeMultiChainSigAccount.DEFAULT_SAFE_4337_MODULE_ADDRESS (0x22939E...)
  • formatSignaturesToUseroperationsSignatures calls getUserOperationEip712Hash_V9 without overriding the address, so it falls through to the base SafeAccount default (0xee8005d7...)
  • The instance method signUserOperations() avoids this because it passes this.safe4337ModuleAddress explicitly everywhere. But the static method path (which is the only option for WebAuthn since signing requires browser interaction) hits the mismatch.

The fix should be in formatSignaturesToUseroperationsSignatures. it should default to DEFAULT_SAFE_4337_MODULE_ADDRESS when safe4337ModuleAddress isn't provided, same asgetMultiChainSingleSignatureUserOperationsEip712Hash does.

When isInit: false, formatSignaturesToUseroperationsSignatures computes the WebAuthn signer address using the base class defaults , including the eip7212WebAuthnPrecompileVerifier and eip7212WebAuthnContractVerifier

b11d4a6

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants