Q2 2026 Security & Compliance Report67 incidents, $764M in losses, 88% from operational failures.
Get the report →

Audit name:

[SCA] Bullbit | EVM Bridge| Aug2026

Date:

Sep 8, 2026

Table of Content

→Introduction
→Audit Summary
→System Overview
→Potential Risks
→Findings
→Appendix 1. Definitions
→Appendix 2. Scope
→Appendix 3. Additional Valuables
→Disclaimer

Want a comprehensive audit report like this?

Introduction

We express our gratitude to the Bullbit team for the collaborative engagement that enabled the execution of this Smart Contract Security Assessment.

A custody bridge moves USDC and BUBI between Base and XChain. Deposits land in deterministic per-user wallets and are swept into a pooled vault; withdrawals are paid only when a whitelisted relayer submits a claim with an on-chain 2-of-3 validator attestation.

Document

NameSmart Contract Code Review and Security Analysis Report for Bullbit
Audited ByIvan Bondar
Approved ByKornel Światłowski
Websitehttps://bullbit.ai/→
Changelog04/09/2026 - Preliminary Report
08/09/2026 - Final Report
PlatformBase
LanguageSolidity
TagsSignatures; Proxy; Factory; Bridge; Upgradable; Vault
Methodologyhttps://docs.hacken.io/methodologies/smart-contracts→

Review Scope

Repositoryhttps://github.com/bullbit/EVM-Bridge-Contract→
Initial Commit757e0a5
Final Commitb470c8f

Audit Summary

2Total Findings
2Resolved
0Accepted
0Mitigated

The system users should acknowledge all the risks summed up in the risks section of the report

Documentation quality

  • On-chain deposit, withdraw, roles, and upgrade behaviour are described.

  • Off-chain bridge requirements (mint/burn, signer, backend policy) are only partly specified.

  • A technical description of architecture, storage, and deployment is provided.

Code quality

  • The development environment is configured.

  • Scoped contracts follow a consistent style with NatSpec and an extensive test suite.

Test coverage

Code coverage of the project is 100% (branch coverage).

  • Deployment and basic user interactions are covered with tests.

  • Negative cases coverage is present.

  • Interactions by several users are tested thoroughly.

System Overview

The architecture is split into a deposit subsystem and a withdrawal subsystem. DepositFactory and Vault are UUPS upgradeable contracts that use ERC-7201 namespaced storage, Ownable2Step ownership, Pausable, and ReentrancyGuard. Both call _disableInitializers in the implementation constructor and accept only an owner in initialize, so initialization calldata can be identical across chains and proxy addresses can be reproduced when the documented deploy path is followed. The factory vault pointer and coldVault are assigned after deployment. Ownership cannot be renounced. Owner actions, including _authorizeUpgrade, execute immediately with no timelock. DepositForwarder is a non-upgradeable ERC-20 wallet. Instances are deployed with CREATE2 from the frozen ForwarderInitcode CREATION_CODE and the factory proxy address. The CREATE2 salt is destRecipient. The DepositForwarder source is imported for calls and is not used as deployment bytecode. Digest supplies the withdraw hash that Vault verifies so Solidity stays aligned with the documented Go signer. Token movement uses the ERC-20 IERC20 interface and SafeERC20 transfers. Native ETH is not handled.

On the deposit path, deployWallet is limited to the operator or owner and to proxy execution via onlyProxy. The factory records isChild and walletToDestRecipient. The owner configures setVault and setSupportedToken. While the factory is unpaused and a vault is set, sweep is permissionless: a registered child, a whitelisted token, and a non-zero forwarder balance are required. forward then transfers the full IERC20 balance to the vault, and Swept records the vault balance delta. sweepMany skips members with a zero token balance, invokes sweep through try/catch, and emits SweepSkipped when a member fails. rescue lets the operator or owner move a non-whitelisted token out of a child to a non-zero to. pause may be invoked by the operator or owner. unpause is owner-only. Pause applies to sweep and sweepMany only.

On the withdrawal path, Vault holds pooled IERC20 inventory and pays releaseWithAttestation. The caller must be a releaseRelayer. Claim chainId must equal block.chainid. Signatures are recovered with ecrecover over Digest digestOf (domain, chain id, vault proxy, request id, token, recipient, amount). Recovered addresses must be strictly increasing. Accumulated member power must meet powerThreshold. An unset valset (powerThreshold of zero) reverts with InsufficientPower. setValset is owner-only and enforces a non-empty set of at most 100 members, unique non-zero attestors and powers, a threshold in [ceil(2 * sum / 3), sum], and no member that can meet the threshold alone. The signature array is capped at 100. The requestId is written to released before safeTransfer. A zero recipient finalizes the claim without a transfer. A duplicate requestId emits ReleaseSkipped and returns. No on-chain amount caps, screening, or rate limits are implemented. Releases may be paused by the operator or owner, and tokens may be consolidated to coldVault. consolidate remains available while paused. The owner sets coldVault, the valset, relayers, and the operator.

Files in Scope

  • Vault.sol: Pooled ERC-20 custody hub that pays withdrawal claims. releaseWithAttestation accepts a relayer-submitted claim and a power-weighted attestation. setValset replaces the attestor set. consolidate sends tokens to the owner-set coldVault. pause and unpause are asymmetric. The owner configures operator, relayers, and cold vault and authorizes UUPS upgrades.

  • DepositFactory.sol: CREATE2 factory for per-user DepositForwarder wallets and the sweep path into Vault. deployWallet materializes a child. sweep and sweepMany are permissionless consolidation entry points. rescue extracts non-whitelisted tokens. The owner sets the vault and supported tokens and authorizes UUPS upgrades. pause blocks sweeps only.

  • DepositForwarder.sol: Minimal per-user ERC-20 receiver with an immutable FACTORY. forward transfers the full token balance to a non-zero destination and reverts unless called by the factory.

  • Digest.sol: Library that is the single source for the withdraw digest used by Vault. digestOf returns keccak256 of abi.encode over DOMAIN_WITHDRAW, chain id, vault address, request id, token, recipient, and amount.

  • ForwarderInitcode.sol: Library that stores frozen DepositForwarder creation bytecode in CREATION_CODE. DepositFactory uses this literal in computeAddress and deployWallet so deposit addresses remain stable across factory implementation upgrades.

Privileged roles

Vaultsol

  • owner (inherited from Ownable2StepUpgradeable): Validator set, release relayers, operator, cold vault, pause lift, UUPS upgrades, and ownership transfer are controlled. All operator actions are available to this role.

    • Can call setValset to replace the full attestor set and power threshold.

    • Can call setReleaseRelayer to grant or revoke release relayer status.

    • Can call updateOperator to set the operator, or to clear it with the zero address so that operator functions remain available only to the owner.

    • Can call setColdVault to set the cold vault destination used by consolidate.

    • Can call unpause to resume releaseWithAttestation.

    • Can call consolidate to transfer tokens from the vault to the cold vault. This remains available while paused.

    • Can call pause to halt releaseWithAttestation.

    • Can call transferOwnership to nominate a pendingOwner, or to cancel a pending transfer by nominating the zero address.

    • Can call upgradeToAndCall (inherited from UUPSUpgradeable, authorized by _authorizeUpgrade) to replace the proxy implementation.

    • Can call renounceOwnership, which always reverts. Ownership cannot be removed.

  • operator: Releases can be paused and tokens can be moved to the cold vault.

    • Can call consolidate to transfer tokens from the vault to the cold vault. This remains available while paused.

    • Can call pause to halt releaseWithAttestation.

  • release relayer: Attested withdraw claims can be submitted for payout.

    • Can call releaseWithAttestation while not paused to mark a claim released and transfer tokens to the claim recipient, or to complete a claim without a transfer when the recipient is the zero address.

  • attestor: Withdraw claims are authorized by ECDSA signatures counted toward the power threshold.

    • Can authorize releaseWithAttestation by signing the withdraw digest. Release succeeds only when accumulated attestor power meets powerThreshold.

  • pendingOwner (inherited from Ownable2StepUpgradeable): A pending ownership transfer can be completed.

    • Can call acceptOwnership to become the owner.

DepositFactorysol

  • owner (inherited from Ownable2StepUpgradeable): Vault address, supported tokens, operator, pause lift, UUPS upgrades, and ownership transfer are controlled. All operator actions are available to this role.

    • Can call setVault to set the Vault address that receives swept deposits.

    • Can call setSupportedToken to add or remove a token from the sweep whitelist.

    • Can call updateOperator to set the operator, or to clear it with the zero address so that operator functions remain available only to the owner.

    • Can call unpause to resume sweep and sweepMany.

    • Can call deployWallet (gated by onlyProxy) to deploy a DepositForwarder via CREATE2, record the destination recipient, and mark the address as a child.

    • Can call rescue to forward a token that is not on the supported whitelist from a child DepositForwarder to a chosen recipient.

    • Can call pause to halt sweep and sweepMany.

    • Can call transferOwnership to nominate a pendingOwner, or to cancel a pending transfer by nominating the zero address.

    • Can call upgradeToAndCall (inherited from UUPSUpgradeable, authorized by _authorizeUpgrade) to replace the proxy implementation.

    • Can call renounceOwnership, which always reverts. Ownership cannot be removed.

  • operator: Forwarders can be deployed, unsupported tokens can be rescued, and sweeps can be paused.

    • Can call deployWallet (gated by onlyProxy) to deploy a DepositForwarder via CREATE2, record the destination recipient, and mark the address as a child.

    • Can call rescue to forward a token that is not on the supported whitelist from a child DepositForwarder to a chosen recipient.

    • Can call pause to halt sweep and sweepMany.

  • pendingOwner (inherited from Ownable2StepUpgradeable): A pending ownership transfer can be completed.

    • Can call acceptOwnership to become the owner.

  • anyone: Supported-token balances can be swept from registered children while the factory is unpaused.

    • Can call sweep while not paused to forward a supported token's full child balance to the configured vault and emit Swept with the vault balance delta.

    • Can call sweepMany while not paused to iterate a caller-supplied forwarder list, skip zero balances, and isolate per-member sweep failures with SweepSkipped.

DepositForwardersol

  • FACTORY: The full ERC20 balance of a token can be transferred to a destination.

    • Can call forward to transfer the entire ERC20 balance of a token to a destination that is not the zero address.

Potential Risks

Control Risks from Whitelisted Users: Vault setReleaseRelayer writes a boolean releaseRelayer map that is the only caller gate for releaseWithAttestation. setValset and updateOperator do not clear or disable existing relayers. A previously enabled relayer remains able to submit any claim whose recovered signers still meet powerThreshold on the current valset, including after an environment rotation that only added a new relayer.

Unrestricted State Modification by Owner: The Vault owner can replace the entire attestor set and threshold via setValset, retarget the incident drain via setColdVault, and rewrite releaseRelayer entries. The DepositFactory owner can retarget setVault and flip setSupportedToken. setColdVault and setVault reject only address(0), and setSupportedToken applies no token-behavior checks. One owner transaction can redirect where swept deposits accrue or where consolidate sends pooled balances.

Absence of Timelock Mechanisms for Critical Operations: No in-scope contract implements a delay, queue, or cancellation window. Vault setValset, setReleaseRelayer, setColdVault, updateOperator, unpause, and _authorizeUpgrade, and DepositFactory setVault, setSupportedToken, updateOperator, unpause, and _authorizeUpgrade, all take effect in the same transaction that passes onlyOwner. Observers have no on-chain interval in which to react before a new implementation, attestor set, or custody destination is live.

Forced Execution Without User Consent: DepositFactory rescue lets operator or owner call DepositForwarder forward on any isChild wallet for a token that is not in supportedTokens, sending the full balance to an arbitrary to. The destRecipient bound to that CREATE2 wallet does not sign or approve the movement. rescue is not gated by whenNotPaused. Unsupported tokens sitting in a user forwarder can be redirected to an operator-chosen address while the supported-token sweep path remains closed for that asset.

Treasury or Rescue Authority: Vault consolidate transfers an arbitrary token and amount from the pooled vault to the owner-set coldVault, is callable by that contract's operator or owner, and is not gated by whenNotPaused. During an incident pause that blocks releaseWithAttestation, the Vault operator can still empty vault balances toward coldVault. The destination is fixed to coldVault, which only the Vault owner can set.

Single Points of Failure and Control: Protocol safety and liveness rest on the Vault and DepositFactory owner and operator keys plus the owner-installed valset. Private-key custody for those roles and for attestor keys cannot be verified from the in-scope bytecode. Loss of an owner key without a pending two-step transfer permanently bricks that contract's administration, because renounceOwnership reverts with RenounceDisabled and every admin path remains bound to owner.

Flexibility and Risk in Contract Upgrades: Vault and DepositFactory inherit UUPSUpgradeable. Implementation changes rewrite releaseWithAttestation, setValset, sweep, deployWallet, and related admin functions behind the same proxy addresses. DepositForwarder instances are not proxies and execute the frozen ForwarderInitcode CREATION_CODE. Users who have already transferred USDC or BUBI into a DepositForwarder, or who hold an off-chain withdraw attestation, have no protocol-enforced window to exit before new Vault or DepositFactory logic is active at those unchanged proxy addresses.

Upgrade Path Dependency: Both contracts store state in ERC-7201 namespaces (VaultStorage at VAULT_STORAGE_LOCATION, FactoryStorage at FACTORY_STORAGE_LOCATION) and initialize once via initializer. Subsequent implementations must append fields without reordering those structs. initialize cannot be replayed; a later implementation that needs fresh init would have to add its own reinitializer. ForwarderInitcode CREATION_CODE and Digest digestOf, including DOMAIN_WITHDRAW, are documented as byte-frozen for CREATE2 and Go signer parity. Regenerating the initcode literal or changing digest encoding would move every computeAddress result or invalidate outstanding withdraw signatures.

Incompatibility with Pending State During Upgrade: Withdrawals are not queued on-chain. They exist as off-chain signatures over Digest digestOf (DOMAIN_WITHDRAW, chainId, vault, requestId, token, to, amount) and a released map keyed by requestId. Vault comments state that the digest pins address(this) so claims survive implementation swaps. An upgrade that changes DOMAIN_WITHDRAW, field order, or the ERC-7201 layout of released, members, or power would make already signed payloads fail ecrecover accumulation or misread claim status, leaving those attested withdrawals without a matching EVM payout until a compatible implementation and fresh signatures exist.

Release path depends on relayer and attestors: releaseWithAttestation reverts with NotRelayer unless msg.sender is in releaseRelayer, and it reverts with InsufficientPower unless recovered signers reach powerThreshold on the current power map. Caps, screening, and rate limits are stated to live off-chain. If every whitelisted relayer is offline, or if attestors do not produce a strictly ascending signature set that meets the threshold, vault balances remain locked even though whenNotPaused is clear and tokens are present.

No on-chain XChain reconciliation: DepositFactory stores walletToDestRecipient as an opaque bytes32 and emits it on Swept. Vault Claim values (requestId, token, to, amount) are supplied by the relayer and checked only against block.chainid, the current valset, and Digest digestOf. Nothing in the five in-scope contracts reads XChain state, proves a remote burn or mint, or binds destRecipient to a verified remote format. Off-chain minters that credit the wrong destRecipient, or attestors that sign a Claim that does not match the remote burn, produce EVM custody movement that the contracts cannot reconcile.

Pooled vault without a live deposit ledger: Vault does not record per-user or per-request deposit balances. sweep emits Swept with the vault's balanceOf delta after DepositForwarder forward, and comments state that the token Transfer log remains the canonical mint source. Fee-on-transfer, rebasing, or blacklisting behavior in USDC or BUBI can make that delta diverge from the amount that left the forwarder. Off-chain minting that ignores the delta or the Transfer log would credit XChain amounts the vault did not receive, while releaseWithAttestation will still pay any attested amount of c.token the vault holds.

Attestations lack freshness constraints: Digest digestOf hashes DOMAIN_WITHDRAW, chainId, vault, requestId, token, to, and amount only. Vault releaseWithAttestation does not check a deadline, nonce, or block timestamp. A signature set that meets the valset in force at submission time remains usable until released[requestId] is set or setValset zeros those attestors' power. A whitelisted relayer can delay submission of an already signed claim, and there is no on-chain staleness bound other than a later valset rotation.

Findings

F-2026-1920Missing Low-S Check In Signature Parser Allows Malleable Encoding
Status
fixed
Severity

Observation
F-2026-1920Digest Helper Ignores The Claim Chain Identifier
Status
fixed
Severity

Observation
Code
―
Title
Status
Severity
F-2026-1920Missing Low-S Check In Signature Parser Allows Malleable Encoding
fixed

Observation
F-2026-1920Digest Helper Ignores The Claim Chain Identifier
fixed

Observation
1-2 of 2 findings

Identify vulnerabilities in your smart contracts.

Appendix 1. Definitions

Severities

When auditing smart contracts, Hacken is using a risk-based approach that considers Likelihood, Impact, Exploitability and Complexity metrics to evaluate findings and score severities.

Reference on how risk scoring is done is available through the repository in our Github organization:

Severity

Description

Critical
Critical vulnerabilities are usually straightforward to exploit and can lead to the loss of user funds or contract state manipulation.

High
High vulnerabilities are usually harder to exploit, requiring specific conditions, or have a more limited scope, but can still lead to the loss of user funds or contract state manipulation.

Medium
Medium vulnerabilities are usually limited to state manipulations and, in most cases, cannot lead to asset loss. Contradictions and requirements violations. Major deviations from best practices are also in this category.

Low
Major deviations from best practices or major Gas inefficiency. These issues will not have a significant impact on code execution.
  • Severity

    Critical

    Description

    Critical vulnerabilities are usually straightforward to exploit and can lead to the loss of user funds or contract state manipulation.

    Severity

    High

    Description

    High vulnerabilities are usually harder to exploit, requiring specific conditions, or have a more limited scope, but can still lead to the loss of user funds or contract state manipulation.

    Severity

    Medium

    Description

    Medium vulnerabilities are usually limited to state manipulations and, in most cases, cannot lead to asset loss. Contradictions and requirements violations. Major deviations from best practices are also in this category.

    Severity

    Low

    Description

    Major deviations from best practices or major Gas inefficiency. These issues will not have a significant impact on code execution.

Potential Risks

The "Potential Risks" section identifies issues that are not direct security vulnerabilities but could still affect the project’s performance, reliability, or user trust. These risks arise from design choices, architectural decisions, or operational practices that, while not immediately exploitable, may lead to problems under certain conditions. Additionally, potential risks can impact the quality of the audit itself, as they may involve external factors or components beyond the scope of the audit, leading to incomplete assessments or oversight of key areas. This section aims to provide a broader perspective on factors that could affect the project's long-term security, functionality, and the comprehensiveness of the audit findings.

Appendix 2. Scope

The scope of the project includes the following smart contracts from the provided repository:

Scope Details

Repositoryhttps://github.com/bullbit/EVM-Bridge-Contract→
Initial Commit757e0a5ccefb9f722434771ae3e3e4afa53c721b
Final Commitb470c8fcc1ee87c0dd003d44a20afcc22e931b0a
WhitepaperN/A
RequirementsREADME.md; NatSpec
Technical RequirementsREADME.md; NatSpec
  • Scope Details

    Initial Commit
    757e0a5ccefb9f722434771ae3e3e4afa53c721b
    Final Commit
    b470c8fcc1ee87c0dd003d44a20afcc22e931b0a
    Whitepaper
    N/A
    Requirements
    README.md; NatSpec
    Technical Requirements
    README.md; NatSpec

Assets in Scope

src
BridgeGovernance.sol - src › BridgeGovernance.sol
DepositFactory.sol - src › DepositFactory.sol
libraries
Digest.sol - src › libraries › Digest.sol
ForwarderInitcode.sol - src › libraries › ForwarderInitcode.sol
Vault.sol - src › Vault.sol
DepositForwarder.sol - src › DepositForwarder.sol

Appendix 3. Additional Valuables

Verification of System Invariants

During the audit of Bullbit, Hacken followed its methodology by performing fuzz-testing on the project's main functions. Foundry, a tool used for fuzz-testing, was employed to check how the protocol behaves under various inputs. Due to the complex and dynamic interactions within the protocol, unexpected edge cases might arise. Therefore, it was important to use fuzz-testing to ensure that several system invariants hold true in all situations.

Fuzz-testing allows the input of many random data points into the system, helping to identify issues that regular testing might miss. A specific Foundry fuzzing suite was prepared for this task, and throughout the assessment, 5 invariants were tested. This thorough testing ensured that the system works correctly even with unexpected or unusual inputs.

Invariant

Test Result

Run Count

Bridged ERC-20 conserved: forwarders + vault + cold + released-to-recipients equals mintedPassed256 (6400 calls)
Unsupported ERC-20 conserved: forwarders + rescued equals mintedPassed256 (6400 calls)
Every deployed wallet matches computeAddress, isChild, and walletToDestRecipientPassed256 (6400 calls)
Attestors dropped by a legal setValset have powerOf 0Passed256 (6400 calls)
Vault outflow: vault + cold + attested payouts equals swept-in (invalid release does not move vault)Passed256 (6400 calls)
  • Invariant

    Bridged ERC-20 conserved: forwarders + vault + cold + released-to-recipients equals minted

    Test Result

    Passed

    Run Count

    256 (6400 calls)

    Invariant

    Unsupported ERC-20 conserved: forwarders + rescued equals minted

    Test Result

    Passed

    Run Count

    256 (6400 calls)

    Invariant

    Every deployed wallet matches computeAddress, isChild, and walletToDestRecipient

    Test Result

    Passed

    Run Count

    256 (6400 calls)

    Invariant

    Attestors dropped by a legal setValset have powerOf 0

    Test Result

    Passed

    Run Count

    256 (6400 calls)

    Invariant

    Vault outflow: vault + cold + attested payouts equals swept-in (invalid release does not move vault)

    Test Result

    Passed

    Run Count

    256 (6400 calls)

Additional Recommendations

The smart contracts in the scope of this audit could benefit from the introduction of automatic emergency actions for critical activities, such as unauthorized operations like ownership changes or proxy upgrades, as well as unexpected fund manipulations, including large withdrawals or minting events. Adding such mechanisms would enable the protocol to react automatically to unusual activity, ensuring that the contract remains secure and functions as intended.

To improve functionality, these emergency actions could be designed to trigger under specific conditions, such as:

  • Detecting changes to ownership or critical permissions.

  • Monitoring large or unexpected transactions and minting events.

  • Pausing operations when irregularities are identified.

These enhancements would provide an added layer of security, making the contract more robust and better equipped to handle unexpected situations while maintaining smooth operations.

Frameworks and Methodologies

This security assessment was conducted in alignment with recognised penetration testing standards, methodologies and guidelines, including the NIST SP 800-115 – Technical Guide to Information Security Testing and Assessment →, and the Penetration Testing Execution Standard (PTES) →, These assets provide a structured foundation for planning, executing, and documenting technical evaluations such as vulnerability assessments, exploitation activities, and security code reviews. Hacken’s internal penetration testing methodology extends these principles to Web2 and Web3 environments to ensure consistency, repeatability, and verifiable outcomes.

Disclaimer