AnchorVaultCoin logo

AnchorVault AnchorVaultCoin Contract Security Review Report

July 2026

Overview

This report covers the security review for AnchorVault protocol, a non-custodial multi- asset vault. Our security assessment was a full review of the AnchorVaultCoin contract code, spanning a total of 1 week. During our review, we did not identify any major severity vulnerabilities. We did identify some minor severity vulnerabilities and code optimizations. All reported issues were fixed by the development team and subsequently verified by us. We can confidently say that the overall security and code quality have increased af ter completion of our audit.

Scope

The analyzed resources are located on:

https://github.com/anchorvaultcoin-hash/anchor-vault-v45/blob/0827e11860abc0d1b27b86afb8f5cd9bb73db094/src/AnchorVaultV45.sol

The issues described in this report were fixed in the following commit:

https://github.com/anchorvaultcoin-hash/anchor-vault-v45/blob/6fead3f2f325a1f57a23efd91cfe5f76c7727528/src/AnchorVaultCoin.sol

Summary

Total number of findings
4

Weaknesses

This section contains the list of discovered weaknesses.

ANCV1-1 | PUBLIC PROOF VALIDATION ALLOWS PROOF TOKEN GRIEFING

Severity:

Medium

Status:

Fixed

Path:

src/AnchorVaultV45.sol:_closeTransfer#L735-L740

Description:

The secure transfer flow lets a vault owner lock a vault for a recipient, who can then confirm or reject the transfer. initSecureTransfer marks the source vault as pending, and confirmSecureTransfer moves it to the recipient.

Each pending transfer should have exclusive control over its source vault. Closing an older transfer must not unlock a vault that has since been locked by a newer transfer, and confirmation should only succeed while the expected source vault still exists.

However, a recipient conflict unlocks the source vault but leaves the transfer record in CONFLICT status. Such a record can still be passed to cancelSecureTransfer or reclaimExpiredTransfer. Both functions call _closeTransfer, which blindly sets the shared vault's status to active without checking whether a newer transfer now owns the lock:

function _closeTransfer(uint256 /*transferId*/, SecureTransfer storage st, uint8 newStatus) internal {
    address token = vaults[st.from][st.vaultId].token;
    vaults[st.from][st.vaultId].status = 0;
    pendingIncomingTransfer[st.to][token] = 0;
    st.status = newStatus;
}

This lets a malicious sender unlock and quick-transfer the vault while a newer secure transfer still appears pending. If the new recipient confirms, the contract reads the deleted source storage and creates a zero-value vault whose token is address(0). It also clears the incoming slot for address(0) instead of the real token, permanently blocking the recipient from receiving future vault transfers for that token.

An attack can happen as follows:

  1. The attacker starts a secure transfer to a helper, then makes the helper open a vault for the same token and confirm. This triggers the conflict branch, unlocks the source vault, and leaves the old record in CONFLICT status.
  2. The attacker starts a new secure transfer of the same vault to the victim.
  3. The attacker cancels the old conflict record. _closeTransfer unlocks the vault even though it now backs the victim's pending transfer.
  4. The attacker quick-transfers the unlocked vault to another address, deleting the original source storage.
  5. The victim confirms the pending transfer. The call succeeds but delivers a zero-value, zero-token vault, and the victim's incoming slot for the real token remains permanently occupied.

Remediation:

Bind each vault lock to a specific transfer ID and only unlock the vault or clear an incoming slot when it still belongs to that transfer. Confirmation should also verify that the source vault exists, remains locked by the same transfer, and contains the expected token and value.

ANCV1-2 | GRIEFING ATTACK ALLOWS UNAUTHORIZED VAULT CREATION FOR ARBITRARY USERS

Severity:

Medium

Status:

Fixed

Path:

src/AnchorVaultV45.sol#L568-L598

Description:

Function transferVault() is designed for the sender to set up a new vault under the address to and transfer the vault token to the new vault. It requires a signature from the sender's vault mainAuthKey to perform the action. However, it does not require any signature from the to address, which allows anyone to create a vault for an arbitrary address with sender-chosen mainAuthKey and recoverAuthKey. This creates a flaw where an attacker can front-run a user and create a vault on their behalf with arbitrary keys.

function transferVault(
    uint256 vid, address to, address newMainKey, address newRecoveryKey,
    uint256 deadline, bytes calldata sig
) external nonReentrant whenNotPaused vaultExists(msg.sender, vid) {
    ...
    
    bytes32 sh = keccak256(abi.encode(TRANSFER_TYPEHASH, msg.sender, vid, to, newMainKey, newRecoveryKey, v.nonce, deadline));
    _checkSig(v, sh, deadline, sig, v.mainAuthKey, true);
    
    ...
    
    uint256 newId = _createReceivedVault(to, token, net, v.level, newMainKey, newRecoveryKey, v.name);
    
    ...
}

Consider the following scenario where a user with address 0xA wants to open a vault for the USDC token and then deposit 1M USDC into that vault. The steps that 0xA performs are:

  1. Get the vault ID by calculating vid = userVaultCount[0xA] + 1.
  2. Trigger openVault() without checking whether the transaction has succeeded.
  3. Deposit 1M USDC into the vault with ID vid obtained in step 1. An attacker can inspect these actions and front-run by executing a malicious action between steps 1 and 2. Specifically, the attacker calls transferVault() with:
  • to = 0xA
  • newMainKey = 0xAttackerMain
  • newRecoveryAuthKey = 0xAttackerRecovery By specifying arbitrary newMainKey and newRecoveryAuthKey values, the attacker can create a vault on behalf of the user using attacker-controlled keys. Because the vault id that transferVault() function creates for the to address is still vid getting from step 1, hence the 1M USDC is still deposited to the vault. As a result, the attacker forces user 0xA into a state where they cannot perform any vault operations (because they do not have the required authorization keys) except panicWithdraw(), which incurs a 20% penalty on their funds (200k USDC in this example).

Remediation:

Consider requiring the signature from the address to in the function transferVault().

ANCV1-4 | TIER-BASED DEPOSIT FEES CAN BE REDUCED BY WITHDRAWING AND REOPENING

Severity:

Low

Status:

Fixed

Description:

Deposits made through depositToVault are charged according to the vault tier: 50 bps for SAFE, 150 bps for VAULT, and 200 bps for FORTRESS. In contrast, openVault charges a fixed 20 bps regardless of the selected tier.

uint256 public constant SAFE_DEPOSIT_FEE_BPS = 50;
uint256 public constant VAULT_DEPOSIT_FEE_BPS = 150;
uint256 public constant FORTRESS_DEPOSIT_FEE_BPS = 200;
 
uint256 public constant OPEN_VAULT_FEE_BPS = 20;
uint256 public constant WITHDRAW_FEE_BPS = 50;

A full withdrawal clears the active vault slot, allowing the owner to combine the withdrawn amount with new funds and reopen a vault instead of topping up the existing one. When the new deposit is sufficiently large compared with the existing principal, this route collects fewer fees for every tier. The difference is greatest for FORTRESS because its direct top-up rate is 200 bps.

For example, with 100,000 ANCR in a FORTRESS and a planned 100,000 ANCR top-up, a direct deposit charges 2,000 ANCR. Withdrawing and reopening charges 899 ANCR in total 500 ANCR for the withdrawal and 399 ANCR for reopening reducing the collected fees by 1,101 ANCR.

Remediation:

  • Apply the selected vault tier's deposit fee when calculating fees in openVault.
  • If the discounted opening rate is intentional, cap its eligible amount and preserve eligibility across full withdrawals and subsequent openings.

ANCV1-3 | THE MINIMUM DEPOSIT AMOUNT IS CHECKED TWICE WHEN DEPOSITING INTO A VAULT

Severity:

Informational

Status:

Fixed

Path:

src/AnchorVaultV45.sol#L520, src/AnchorVaultV45.sol#L446

Description:

In the depositToVault() function, the minimum deposit requirement is checked twice:

  • The first check validates the raw input amount at line 520.
  • The second check validates the actual amount received (net) at line 524.
function depositToVault(uint256 vid, uint256 amount)
    external nonReentrant whenNotPaused vaultExists(msg.sender, vid)
{
    ...
    if (amount < minDep) revert DepositBelowMinimum();
    ...
    if (net < minDep) revert DepositBelowMinimum();
    ...
}

Since net is always less than or equal to the input amount (due to protocol fees and potential Fee-on-Transfer token deductions), the first check is redundant. The actual amount credited to the vault is what ultimately matters for enforcing the minimum deposit requirement.

The same issue exists in the openVault() function.

Remediation:

Consider removing the minimum deposit check on the input amount and enforcing the requirement only against the actual amount received (net). This simplifies the logic and ensures the validation is based on the amount that is effectively deposited into the vault.

Table of contents