Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ scripts/*_output*.json
.env.devnet
.env.testnet
.env.mainnet

lib/
513 changes: 513 additions & 0 deletions audits/202412-threat-model-bootstrap-flow-v1.md

Large diffs are not rendered by default.

18 changes: 10 additions & 8 deletions hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,18 @@ loadAndValidateEnvironment();

const config: HardhatUserConfig = {
solidity: {
compilers: [{ version: '0.8.17' }],
settings: {
optimizer: {
enabled: true,
runs: 999999,
details: {
yul: true
compilers: [{
version: '0.8.17',
settings: {
optimizer: {
enabled: true,
runs: 999999,
details: {
yul: true
}
}
}
}
}]
},
paths: {
root: 'src',
Expand Down
12 changes: 6 additions & 6 deletions scripts/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,14 @@ async function main(): Promise<EnvironmentInfo> {
// 4. Deploy startup wallet impl (PNR)
const startupWalletImpl = await deployContractViaCREATE2(env, wallets, 'StartupWalletImpl', [walletImplLocator.address]);

// --- Step 4: Deployed using CREATE2 Factory.
// 5. Deploy main module dynamic auth (CFC)
const mainModuleDynamicAuth = await deployContractViaCREATE2(env, wallets, 'MainModuleDynamicAuth', [factory.address, startupWalletImpl.address]);

// --- Step 5: Deployed using Passport Nonce Reserver.
// 6. Deploy immutable signer (PNR)
// --- Step 4: Deployed using Passport Nonce Reserver.
// 5. Deploy immutable signer (PNR)
const immutableSigner = await deployContractViaCREATE2(env, wallets, 'ImmutableSigner', [signerRootAdminPubKey, signerAdminPubKey, signerAddress]);

// --- Step 5: Deployed using CREATE2 Factory.
// 6. Deploy main module dynamic auth (CFC)
const mainModuleDynamicAuth = await deployContractViaCREATE2(env, wallets, 'MainModuleDynamicAuth', [factory.address, startupWalletImpl.address, immutableSigner.address]);

// --- Step 6: Deployed using alternate wallet (?)
// Fund the implementation changer
// WARNING: If the deployment fails at this step, DO NOT RERUN without commenting out the code a prior which deploys the contracts.
Expand Down
4 changes: 3 additions & 1 deletion scripts/step4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ async function step4(): Promise<EnvironmentInfo> {
const { network } = env;
const factoryAddress = '0x8Fa5088dF65855E0DaF87FA6591659893b24871d';
const startupWalletImplAddress = '0x8FD900677aabcbB368e0a27566cCd0C7435F1926';
const immutableSignerAddress = '0xcff469E561D9dCe5B1185CD2AC1Fa961F8fbDe61';

console.log(`[${network}] Starting deployment...`);
console.log(`[${network}] Factory address ${factoryAddress}`);
console.log(`[${network}] StartupWalletImpl address ${startupWalletImplAddress}`);
console.log(`[${network}] ImmutableSigner address ${immutableSignerAddress}`);

await waitForInput();

Expand All @@ -25,7 +27,7 @@ async function step4(): Promise<EnvironmentInfo> {

// --- Step 4: Deployed using CREATE2 Factory.
// Deploy main module dynamic auth (CFC)
const mainModuleDynamicAuth = await deployContractViaCREATE2(env, wallets, 'MainModuleDynamicAuth', [factoryAddress, startupWalletImplAddress]);
const mainModuleDynamicAuth = await deployContractViaCREATE2(env, wallets, 'MainModuleDynamicAuth', [factoryAddress, startupWalletImplAddress, immutableSignerAddress]);

fs.writeFileSync('step4.json', JSON.stringify({
factoryAddress: factoryAddress,
Expand Down
4 changes: 2 additions & 2 deletions src/contracts/mocks/MainModuleMockV1.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import "../modules/MainModuleDynamicAuth.sol";

contract MainModuleMockV1 is MainModuleDynamicAuth {
// solhint-disable-next-line no-empty-blocks
constructor(address _factory, address _startup) MainModuleDynamicAuth(_factory, _startup) {}
constructor(address _factory, address _startupWalletImpl, address _immutableSignerContract) MainModuleDynamicAuth(_factory, _startupWalletImpl, _immutableSignerContract) {}


}
}
2 changes: 1 addition & 1 deletion src/contracts/mocks/MainModuleMockV2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import "../modules/MainModuleDynamicAuth.sol";

contract MainModuleMockV2 is MainModuleDynamicAuth {
// solhint-disable-next-line no-empty-blocks
constructor(address _factory, address _startup) MainModuleDynamicAuth(_factory, _startup) {}
constructor(address _factory, address _startupWalletImpl, address _immutableSignerContract) MainModuleDynamicAuth(_factory, _startupWalletImpl, _immutableSignerContract) {}

function version() external pure override returns (uint256) {
return 2;
Expand Down
2 changes: 1 addition & 1 deletion src/contracts/mocks/MainModuleMockV3.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import "../modules/MainModuleDynamicAuth.sol";

contract MainModuleMockV3 is MainModuleDynamicAuth {
// solhint-disable-next-line no-empty-blocks
constructor(address _factory, address _startup) MainModuleDynamicAuth(_factory, _startup) {}
constructor(address _factory, address _startupWalletImpl, address _immutableSignerContract) MainModuleDynamicAuth(_factory, _startupWalletImpl, _immutableSignerContract) {}

function version() external pure override returns (uint256) {
return 3;
Expand Down
2 changes: 1 addition & 1 deletion src/contracts/modules/MainModuleDynamicAuth.sol
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ contract MainModuleDynamicAuth is
{

// solhint-disable-next-line no-empty-blocks
constructor(address _factory, address _startup) ModuleAuthDynamic (_factory, _startup) { }
constructor(address _factory, address _startupWalletImpl, address _immutableSignerContract) ModuleAuthDynamic (_factory, _startupWalletImpl, _immutableSignerContract) { }


/**
Expand Down
10 changes: 5 additions & 5 deletions src/contracts/modules/commons/ModuleAuth.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import "./ModuleERC165.sol";
abstract contract ModuleAuth is IModuleAuth, ModuleERC165, SignatureValidator, IERC1271Wallet {
using LibBytes for bytes;

uint256 private constant FLAG_SIGNATURE = 0;
uint256 private constant FLAG_ADDRESS = 1;
uint256 private constant FLAG_DYNAMIC_SIGNATURE = 2;
uint256 internal constant FLAG_SIGNATURE = 0;
uint256 internal constant FLAG_ADDRESS = 1;
uint256 internal constant FLAG_DYNAMIC_SIGNATURE = 2;

bytes4 private constant SELECTOR_ERC1271_BYTES_BYTES = 0x20c13b0b;
bytes4 private constant SELECTOR_ERC1271_BYTES32_BYTES = 0x1626ba7e;
Expand Down Expand Up @@ -49,7 +49,7 @@ abstract contract ModuleAuth is IModuleAuth, ModuleERC165, SignatureValidator, I
bytes32 _hash,
bytes memory _signature
)
internal override returns (bool)
internal virtual override returns (bool)
{
(bool verified, bool needsUpdate, bytes32 imageHash) = _signatureValidationWithUpdateCheck(_hash, _signature);
if (needsUpdate) {
Expand All @@ -74,7 +74,7 @@ abstract contract ModuleAuth is IModuleAuth, ModuleERC165, SignatureValidator, I
bytes32 _hash,
bytes memory _signature
)
internal view returns (bool, bool, bytes32)
internal view virtual returns (bool, bool, bytes32)
{
(
uint16 threshold, // required threshold signature
Expand Down
108 changes: 107 additions & 1 deletion src/contracts/modules/commons/ModuleAuthDynamic.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,125 @@ pragma solidity 0.8.17;

import "./ModuleAuthUpgradable.sol";
import "./ImageHashKey.sol";
import "./ModuleStorage.sol";
import "./NonceKey.sol";
import "../../Wallet.sol";

import "../../utils/LibBytes.sol";

abstract contract ModuleAuthDynamic is ModuleAuthUpgradable {
using LibBytes for bytes;

/// @dev Struct to hold signature validation state to avoid stack too deep errors
struct SignatureValidationState {
uint256 rindex;
bytes32 imageHash;
uint256 totalWeight;
bool immutableSignerContractFound;
}

bytes32 public immutable INIT_CODE_HASH;
address public immutable FACTORY;
address public immutable IMMUTABLE_SIGNER_CONTRACT;

constructor(address _factory, address _startupWalletImpl) {
constructor(address _factory, address _startupWalletImpl, address _immutableSignerContract) {
require(_immutableSignerContract != address(0), "ModuleAuthDynamic#constructor: INVALID_SIGNER_ADDRESS");
// Build init code hash of the deployed wallets using that module
bytes32 initCodeHash = keccak256(abi.encodePacked(Wallet.creationCode, uint256(uint160(_startupWalletImpl))));

INIT_CODE_HASH = initCodeHash;
FACTORY = _factory;
IMMUTABLE_SIGNER_CONTRACT = _immutableSignerContract;
}

/**
* @notice Verify signature and determine if image hash needs updating
* @param _hash Hashed signed message
* @param _signature Packed signature data containing threshold, flags, weights, and addresses/signatures
* @return verified True if the signature is valid and weight threshold is met
* @return needsUpdate True if the image hash needs to be stored (first transaction)
* @return imageHash The computed image hash from the signature
*
* @dev This function parses the signature, recovers/reads signer addresses, and validates them.
* For defensive validation, each extracted address is compared against IMMUTABLE_SIGNER_CONTRACT.
* If a match is found, it is recorded.
*
* Special case: If this is the first transaction (nonce was 0, now 1 after increment) and the immutable
* signer contract is one of the signers, the signature is automatically validated and approved without
* checking the stored image hash. This allows the immutable signer to bootstrap the wallet on first use.
*/
function _signatureValidationWithUpdateCheck(
bytes32 _hash,
bytes memory _signature
)
internal view override returns (bool, bool, bytes32)
{
(
uint16 threshold, // required threshold signature
uint256 rindex // read index
) = _signature.readFirstUint16();

SignatureValidationState memory state = SignatureValidationState({
Copy link
Contributor

Choose a reason for hiding this comment

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

Storing these values in memory rather than on the stack will make the code more expensive to run

Copy link
Author

Choose a reason for hiding this comment

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

Had to go down this path to workaround the "stack too deep" issue 🤔 Any suggestions on alternate approaches?

Copy link
Contributor

Choose a reason for hiding this comment

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

Use local variables and change the FLAG_DYNAMIC_SIGNATURE block to:
// Read signer
(addr, rindex) = _signature.readAddress(rindex);

    bytes memory signature;
    {
      // Read signature size
      uint256 size;
      (size, rindex) = _signature.readUint16(rindex);

      // Read dynamic size signature
      (signature, rindex) = _signature.readBytes(rindex, size);
    }
    require(isValidSignature(_hash, addr, signature), "ModuleAuthDynamic#_signatureValidation: INVALID_SIGNATURE");

Doing this helps the compiler by providing scope to size.

Copy link
Author

Choose a reason for hiding this comment

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

I wasn't able to resolve the stack issue with block scoping, have retained the memory variable approach for now.

Copy link
Contributor

Choose a reason for hiding this comment

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

When I retried, I couldn't get that specific fix to work. I am not sure why.

Copy link
Contributor

Choose a reason for hiding this comment

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

I have created a PR which takes an alternative approach to allow the variables to be on the stack rather than in memory. Approve it and merge it into this one if you are happy with the approach.
#76

rindex: rindex,
imageHash: bytes32(uint256(threshold)),
totalWeight: 0,
immutableSignerContractFound: false
});

// Iterate until the image is completed
while (state.rindex < _signature.length) {
// Read next item type and addrWeight
uint256 flag; uint256 addrWeight; address addr;
(flag, addrWeight, state.rindex) = _signature.readUint8Uint8(state.rindex);

if (flag == FLAG_ADDRESS) {
// Read plain address
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we only use FLAG_SIGNATURE and FLAG_DYNAMIC_SIGNATURE. If I am correct, then we should remove the FLAG_ADDRESS option.

At attacker could create two valid signatures that are FLAG_SIGNATURE, and include the Immutable signer as a FLAG_ADDRESS. The checks below would deem the signature valid and bootstrap the wallet at line 109 and 120.

Copy link
Contributor

Choose a reason for hiding this comment

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

They don't need to create two valid signatures, as we don't check the weight. Hence, they could just create a _signature block that had Immutable signer as a FLAG_ADDRESS, and then they could set the image hash to a value of their choosing.

Copy link
Author

@naveen-imtb naveen-imtb Dec 11, 2025

Choose a reason for hiding this comment

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

You're right @drinkcoffee

This is a critical bug. I've updated the contract with the following changes:

  • Removed FLAG_ADDRESS support - This prevents attackers from including the immutable signer address in the signature without actually providing a valid signature from that address.
  • Required verified signatures from the Immutable signer contract - The immutable signer must now actually sign the transaction (via FLAG_SIGNATURE or FLAG_DYNAMIC_SIGNATURE) rather than just being listed as an address. The immutableSignerContractSigned flag is only set to true after the signature has been cryptographically verified.

These changes ensure that the bootstrap flow can only be triggered when the Immutable signer contract has genuinely signed the transaction, preventing unauthorized bootstrap attacks.

(addr, state.rindex) = _signature.readAddress(state.rindex);
} else if (flag == FLAG_SIGNATURE) {
// Read single signature and recover signer
bytes memory signature;
(signature, state.rindex) = _signature.readBytes66(state.rindex);
addr = recoverSigner(_hash, signature);

// Acumulate total weight of the signature
state.totalWeight += addrWeight;
} else if (flag == FLAG_DYNAMIC_SIGNATURE) {
// Read signer
(addr, state.rindex) = _signature.readAddress(state.rindex);

// Read signature size
uint256 size;
(size, state.rindex) = _signature.readUint16(state.rindex);

// Read dynamic size signature
bytes memory signature;
(signature, state.rindex) = _signature.readBytes(state.rindex, size);
require(isValidSignature(_hash, addr, signature), "ModuleAuthDynamic#_signatureValidation: INVALID_SIGNATURE");

// Acumulate total weight of the signature
state.totalWeight += addrWeight;
} else {
revert("ModuleAuthDynamic#_signatureValidation INVALID_FLAG");
}

// Check if this signer is the immutable signer contract
if (addr == IMMUTABLE_SIGNER_CONTRACT) {
state.immutableSignerContractFound = true;
}

// Write weight and address to image
state.imageHash = keccak256(abi.encode(state.imageHash, addrWeight, addr));
}

// Check if this is the first transaction (nonce was 0 before increment) and immutable signer contract is one of the signers
// Note: _validateNonce increments the nonce before _signatureValidation is called, so we check for 1, not 0
uint256 currentNonce = uint256(ModuleStorage.readBytes32Map(NonceKey.NONCE_KEY, bytes32(uint256(0))));
if (currentNonce == 1 && state.immutableSignerContractFound) {
return (true, true, state.imageHash);
}

(bool verified, bool needsUpdate) = _isValidImage(state.imageHash);
Copy link
Contributor

Choose a reason for hiding this comment

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

The CFA will match the calculated image hash: a combination of the user key's address and the Immutable key's address. When an alternative user secret provider is used, the user's key will be different, so the address will be different, and hence the calculated image hash and then CFA will be different.
If this is the case, then the _isValidImage check will fail here: https://github.com/immutable/wallet-contracts/blob/main/src/contracts/modules/commons/ModuleAuthDynamic.sol#L35

Copy link
Contributor

Choose a reason for hiding this comment

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

What am I missing in my understanding?

Copy link
Author

Choose a reason for hiding this comment

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

In the scenario where a user's CFA was generated with signer A but the user is now associated with signer B (e.g., due to using different identity providers on different chains), the _isValidImage check will not be invoked on first transaction.
Instead, during the first transaction (when currentNonce == 1), the contract performs an early exit after validating that the Immutable Signer is present among the signers.
At line 158-160:

if (currentNonce == 1 && state.immutableSignerContractFound) {  
    return (true, true, state.imageHash);
}

This bootstrap flow bypasses the _isValidImage check entirely. The computed image hash (derived from the new signer configuration, including signer B) is returned and subsequently stored via updateImageHashInternal().
This is the intentional design of the bootstrap flow - it allows the Immutable Signer to initialize the wallet with a different user key configuration while preserving the original CFA.
The _isValidImage check at line 162 is only reached for subsequent transactions (nonce ≥ 2), where it validates against the already-stored image hash.

Copy link
Contributor

Choose a reason for hiding this comment

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

That makes sense. Thank you for the detailed explanation.

return ((state.totalWeight >= threshold && verified), needsUpdate, state.imageHash);
}

/**
Expand Down
7 changes: 6 additions & 1 deletion tests/ERC165.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
MainModuleDynamicAuth,
StartupWalletImpl,
LatestWalletImplLocator,
ImmutableSigner,
Factory,
Factory__factory,
MainModule__factory,
Expand All @@ -16,6 +17,7 @@ import {
ERC165CheckerMock__factory,
StartupWalletImpl__factory,
LatestWalletImplLocator__factory,
ImmutableSigner__factory,
} from '../src'

ethers.utils.Logger.setLogLevel(ethers.utils.Logger.levels.ERROR)
Expand Down Expand Up @@ -51,6 +53,7 @@ contract('ERC165', () => {
let moduleDynamicAuth: MainModuleDynamicAuth
let startupWalletImpl: StartupWalletImpl
let moduleLocator: LatestWalletImplLocator
let immutableSigner: ImmutableSigner

let owner: ethers.Wallet
let wallet: MainModule
Expand All @@ -70,10 +73,12 @@ contract('ERC165', () => {
// Startup and Locator
moduleLocator = await new LatestWalletImplLocator__factory().connect(signer).deploy(await signer.getAddress(), await signer.getAddress())
startupWalletImpl = await new StartupWalletImpl__factory().connect(signer).deploy(moduleLocator.address)
// Deploy ImmutableSigner
immutableSigner = await new ImmutableSigner__factory().connect(signer).deploy(await signer.getAddress(), await signer.getAddress(), await signer.getAddress())
// Deploy MainModule
mainModule = await new MainModule__factory().connect(signer).deploy(factory.address)
moduleUpgradable = await new MainModuleUpgradable__factory().connect(signer).deploy()
moduleDynamicAuth = await new MainModuleDynamicAuth__factory().connect(signer).deploy(factory.address, startupWalletImpl.address)
moduleDynamicAuth = await new MainModuleDynamicAuth__factory().connect(signer).deploy(factory.address, startupWalletImpl.address, immutableSigner.address)
// Deploy ERC165 Checker
erc165checker = await new ERC165CheckerMock__factory().connect(signer).deploy()

Expand Down
8 changes: 4 additions & 4 deletions tests/ImmutableDeployment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('E2E Immutable Wallet Deployment', () => {
// Nonce 5
mainModuleDynamicAuth = await new MainModuleDynamicAuth__factory()
.connect(contractDeployerEOA)
.deploy(factory.address, startupWallet.address)
.deploy(factory.address, startupWallet.address, immutableSigner.address)

// Setup the latest implementation address
await moduleLocator
Expand Down Expand Up @@ -283,7 +283,7 @@ describe('E2E Immutable Wallet Deployment', () => {
)

await expect(wallet.execute([transaction], nonce, signature)).to.be.revertedWith(
'ModuleAuth#_signatureValidation: INVALID_SIGNATURE'
'ModuleAuthDynamic#_signatureValidation: INVALID_SIGNATURE'
)
})

Expand Down Expand Up @@ -336,7 +336,7 @@ describe('E2E Immutable Wallet Deployment', () => {
)

await expect(wallet.execute([transaction], nonce, signature)).to.be.revertedWith(
'ModuleAuth#_signatureValidation: INVALID_SIGNATURE'
'ModuleAuthDynamic#_signatureValidation: INVALID_SIGNATURE'
)
})

Expand Down Expand Up @@ -393,7 +393,7 @@ describe('E2E Immutable Wallet Deployment', () => {
)

await expect(wallet.execute([transaction], nonce, signature)).to.be.revertedWith(
'ModuleAuth#_signatureValidation: INVALID_SIGNATURE'
'ModuleAuthDynamic#_signatureValidation: INVALID_SIGNATURE'
)
})
})
Loading
Loading