Skip to content
Open
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
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
119 changes: 118 additions & 1 deletion src/contracts/modules/commons/ModuleAuthDynamic.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,136 @@ 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 immutableSignerContractSigned;
}

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 signer addresses from verified signatures, and validates them.
* Only FLAG_SIGNATURE and FLAG_DYNAMIC_SIGNATURE are supported - FLAG_ADDRESS is intentionally not
* supported to prevent attackers from including addresses without providing valid signatures.
*
* For each verified signature, the extracted address is compared against IMMUTABLE_SIGNER_CONTRACT.
* If a match is found after signature verification, it is recorded.
*
* Special case: If this is the first transaction (nonce was 0, now 1 after increment), the immutable
* signer contract has provided a valid signature, AND the weight threshold is met, 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 while preventing unauthorized bootstrap attacks.
*/
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,
immutableSignerContractSigned: 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);

// Note: FLAG_ADDRESS is intentionally not supported in this module to prevent
// attackers from including the immutable signer address without providing a valid signature.
// Only FLAG_SIGNATURE and FLAG_DYNAMIC_SIGNATURE are allowed.
if (flag == FLAG_SIGNATURE) {
// Read single signature and recover signer
bytes memory signature;
(signature, state.rindex) = _signature.readBytes66(state.rindex);
addr = recoverSigner(_hash, signature);

// Accumulate total weight of the signature
state.totalWeight += addrWeight;

// Check if this signer is the immutable signer contract (only after signature verification)
if (addr == IMMUTABLE_SIGNER_CONTRACT) {
state.immutableSignerContractSigned = true;
}
} 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");

// Accumulate total weight of the signature
state.totalWeight += addrWeight;

// Check if this signer is the immutable signer contract (only after signature verification)
if (addr == IMMUTABLE_SIGNER_CONTRACT) {
state.immutableSignerContractSigned = true;
}
} else {
revert("ModuleAuthDynamic#_signatureValidation INVALID_FLAG");
}

// 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
// has provided a valid signature. The immutable signer must have actually signed (not just be listed
// as an address) and the total weight must meet the threshold to prevent unauthorized bootstrap attacks.
// 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.immutableSignerContractSigned && state.totalWeight >= threshold) {
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