BVGS smart contracts
Etherscan
View the deployed and verified BVGS contracts on Etherscan for complete transparency.
GitHub
View the full source code on GitHub.
Smart contracts
The BVGS platform is built on a set of three core smart contracts that work together to provide secure asset management:
- BVGS Contract: The primary contract that extends ERC721 to create soul-bound NFTs that can securely hold assets
- Deposits & Withdrawals: Manage depositing and withdrawing ETH, ERC20 tokens, and ERC721 tokens within BVGS NFTs with multi-signature security
- Signature Verification: Handles cryptographic authorization for all bag operations using EIP-712 signatures for enhanced security
Together, these contracts implement a unique "bagging" system that allows crypto assets to be securely stored within an NFT while maintaining high security through advanced cryptographic signature verification and key fractionalization.
BVGS
The primary BVGS smart contract that extends ERC721 and implements the Bagging functionality.
// SPDX-License-Identifier: BUSL-1.1
// Copyright © 2025 BVGS. All Rights Reserved.
// You may use, modify, and share this code for NON-COMMERCIAL purposes only.
// Commercial use requires written permission from BVGS.
pragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "./Withdrawals.sol";
import "./SignatureVerification.sol";
/* ───────────────────────────── ERC-5192 / Soulbound standard ──────────────────────────────── */
interface IERC5192 {
/// Emitted exactly once when a token becomes locked (non-transferable).
event Locked(uint256 tokenId);
/// Emitted if a token ever becomes unlocked (not used, but declared for ERC-5192 compliance).
event Unlocked(uint256 tokenId);
/// MUST always return true for every existing BVGS NFT.
function locked(uint256 tokenId) external view returns (bool);
}
/**
* @title BVGS
* @dev Core soul-bound ERC-721 contract for BVGS NFT creation and bagging flows
* Implements ERC-5192 (soulbound standard) for non-transferability.
* Inherits the Withdrawals smart contract which inherits Deposits and SignatureVerification contracts.
*/
contract BVGS is ERC721, Ownable, Withdrawals, IERC5192 {
/// @dev Next token ID to mint (auto-incremented per mint.
uint256 private _nextId;
/* ─────────── Custom errors ───────── */
error ZeroTokenAddress();
error ArrayLengthMismatch();
error EthValueMismatch();
error DefaultURIAlreadySet();
error NoURI();
error TransfersDisabled();
error UseDepositETH();
error FallbackNotAllowed();
/* ───────────────────────── Metadata storage ────────────────────────── */
string private _defaultMetadataURI;
bool private _defaultURISet;
mapping(uint256 => string) private _tokenMetadataURIs;
/// Emitted whenever a per-token metadata URI is set/updated.
event TokenMetadataURISet(uint256 indexed tokenId, bytes32 indexed referenceId);
event Bagged(uint256 indexed tokenId, bytes32 indexed referenceId);
/* ─────────────────────────── Constructor ───────────────────────────── */
/**
* @dev Deploys the contract and initializes the EIP-712 domain used for
* signature authorization in SignatureVerification.
*/
constructor()
ERC721("bvgs", "BVGS")
SignatureVerification(address(this))
{}
/* ─────────── Bagging callback (burn underlying ERC-721) ───────────── */
function _burnBagNFT(uint256 tokenId) internal override {
_burn(tokenId);
}
/* ───────────────────────── Minting + bagging flows ───────────────────────── */
/**
* @notice Mint a new BVGS NFT and deposit ETH.
* @param to The address that will receive the newly minted BVGS NFT.
* @param bvgsPublicKey The public key used for on-chain signature verification.
* @param referenceId An external reference ID for off-chain tracking of this deposit.
*
* Requirements:
* - `to` must not be the zero address.
* - `bvgsPublicKey` must not be the zero address.
* - `msg.value` > 0 to deposit ETH.
*/
function bagETH(
address to,
address bvgsPublicKey,
bytes32 referenceId
) external payable nonReentrant {
if (to == address(0)) revert ZeroAddress();
if (bvgsPublicKey == address(0)) revert ZeroKey();
uint256 tokenId = _nextId++;
if (to.code.length == 0) {
_mint(to, tokenId);
} else {
_safeMint(to, tokenId);
}
emit Locked(tokenId);
initialize(tokenId, bvgsPublicKey);
_depositETH(tokenId, msg.value);
emit Bagged(tokenId, referenceId);
}
/**
* @notice Mint a new BVGS NFT and deposit ERC20 tokens.
* @param to The recipient of the newly minted BVGS NFT.
* @param bvgsPublicKey The public key used for off-chain signature verification.
* @param tokenAddress The ERC20 token contract address to deposit.
* @param amount The amount of ERC20 tokens to deposit.
* @param referenceId An external reference ID for off-chain tracking.
*
* Requirements:
* - `to` and `bvgsPublicKey` must not be the zero address.
* - `tokenAddress` must not be the zero address.
* - `amount` must be greater than zero.
*/
function bagERC20(
address to,
address bvgsPublicKey,
address tokenAddress,
uint256 amount,
bytes32 referenceId
) external nonReentrant {
if (to == address(0)) revert ZeroAddress();
if (bvgsPublicKey == address(0)) revert ZeroKey();
if (tokenAddress == address(0)) revert ZeroTokenAddress();
if (amount == 0) revert ZeroAmount();
uint256 tokenId = _nextId++;
if (to.code.length == 0) {
_mint(to, tokenId);
} else {
_safeMint(to, tokenId);
}
emit Locked(tokenId);
initialize(tokenId, bvgsPublicKey);
_depositERC20(tokenId, tokenAddress, amount);
emit Bagged(tokenId, referenceId);
}
/**
* @notice Mint a new BVGS NFT and deposit a single ERC721.
* @param to The recipient of the newly minted BVGS NFT.
* @param bvgsPublicKey The public key used for off-chain signature verification.
* @param nftContract The ERC721 contract address to deposit.
* @param externalNftTokenId The token ID of the ERC721 to deposit.
* @param referenceId An external reference ID for off-chain tracking.
*
* Requirements:
* - `to`, `bvgsPublicKey`, and `nftContract` must not be the zero address.
*/
function bagERC721(
address to,
address bvgsPublicKey,
address nftContract,
uint256 externalNftTokenId,
bytes32 referenceId
) external nonReentrant {
if (to == address(0)) revert ZeroAddress();
if (bvgsPublicKey == address(0)) revert ZeroKey();
if (nftContract == address(0)) revert ZeroTokenAddress();
uint256 tokenId = _nextId++;
if (to.code.length == 0) {
_mint(to, tokenId);
} else {
_safeMint(to, tokenId);
}
emit Locked(tokenId);
initialize(tokenId, bvgsPublicKey);
_depositERC721(tokenId, nftContract, externalNftTokenId);
emit Bagged(tokenId, referenceId);
}
/**
* @notice Mint a new BVGS NFT and perform a batch deposit of ETH, ERC20s, and ERC721s.
* @param to The recipient of the newly minted BVGS NFT.
* @param bvgsPublicKey The public key used for off-chain signature verification.
* @param amountETH The amount of ETH to deposit.
* @param tokenAddresses ERC20 token contract addresses to deposit.
* @param tokenAmounts Corresponding amounts of each ERC20 to deposit.
* @param nftContracts ERC721 contract addresses to deposit.
* @param nftTokenIds Corresponding token IDs of each ERC721 to deposit.
* @param referenceId An external reference ID for off-chain tracking.
*
* Requirements:
* - `to` and `bvgsPublicKey` must not be zero addresses.
* - `tokenAddresses.length == tokenAmounts.length`.
* - `nftContracts.length == nftTokenIds.length`.
* - `msg.value == amountETH`.
*/
function bagBatch(
address to,
address bvgsPublicKey,
uint256 amountETH,
address[] calldata tokenAddresses,
uint256[] calldata tokenAmounts,
address[] calldata nftContracts,
uint256[] calldata nftTokenIds,
bytes32 referenceId
) external payable nonReentrant {
if (to == address(0)) revert ZeroAddress();
if (bvgsPublicKey == address(0)) revert ZeroKey();
if (
tokenAddresses.length != tokenAmounts.length ||
nftContracts.length != nftTokenIds.length
) revert ArrayLengthMismatch();
if (msg.value != amountETH) revert EthValueMismatch();
uint256 tokenId = _nextId++;
if (to.code.length == 0) {
_mint(to, tokenId);
} else {
_safeMint(to, tokenId);
}
emit Locked(tokenId);
initialize(tokenId, bvgsPublicKey);
_batchDeposit(
tokenId,
amountETH,
tokenAddresses,
tokenAmounts,
nftContracts,
nftTokenIds
);
emit Bagged(tokenId, referenceId);
}
/* ──────────────────────── Default metadata management ──────────────────────── */
/**
* @notice Sets the default metadata URI for all BVGS NFTs (only once).
* @param newDefaultURI The base metadata URI to use for tokens without custom URIs.
* @dev Can only be called by the contract owner, and only once.
*
* Requirements:
* - `_defaultURISet` must be false.
*/
function setDefaultMetadataURI(string memory newDefaultURI)
external onlyOwner
{
if (_defaultURISet) revert DefaultURIAlreadySet();
_defaultMetadataURI = newDefaultURI;
_defaultURISet = true;
}
/* ───────────────────────── Token-gated + EIP-712 secured metadata management ────────────────────────── */
/**
* @notice Sets or updates a custom metadata URI for a specific BVGS NFT.
* @param tokenId The ID of the BVGS NFT to update.
* @param messageHash The EIP-712 digest that was signed.
* @param signature The EIP-712 signature by the active BVGS key.
* @param newMetadataURI The new metadata URI to assign.
* @param referenceId An external reference ID for off-chain tracking.
* @param signatureExpiry UNIX timestamp until which the signature is valid.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
* - `signature` must be valid and unexpired.
*/
function setTokenMetadataURI(
uint256 tokenId,
bytes32 messageHash,
bytes memory signature,
string memory newMetadataURI,
bytes32 referenceId,
uint256 signatureExpiry
) external nonReentrant {
if (!_exists(tokenId)) revert NonexistentToken();
if (ownerOf(tokenId) != msg.sender) revert NotOwner();
if (block.timestamp > signatureExpiry) revert SignatureExpired();
bytes memory data = abi.encode(
tokenId, newMetadataURI, referenceId, msg.sender, signatureExpiry
);
verifySignature(
tokenId, messageHash, signature,
address(0), OperationType.SET_TOKEN_URI, data
);
_tokenMetadataURIs[tokenId] = newMetadataURI;
emit TokenMetadataURISet(tokenId, referenceId);
}
/**
* @notice Returns the metadata URI for a BVGS NFT.
* @param tokenId The ID of the token to query.
* @return The custom URI if set; otherwise the default URI.
* @dev Reverts if neither custom nor default URI is available.
*/
function tokenURI(uint256 tokenId)
public view override(ERC721)
returns (string memory)
{
if (!_exists(tokenId)) revert NonexistentToken();
string memory custom = _tokenMetadataURIs[tokenId];
if (bytes(custom).length > 0) return custom;
if (bytes(_defaultMetadataURI).length > 0) return _defaultMetadataURI;
revert NoURI();
}
/* ────────────────────── Soul-bound mechanics (ERC-5192) ────────────── */
/**
* @notice Always returns true for existing BVGS NFTs (soul‐bound).
* @param tokenId The ID of the BVGS NFT.
* @return Always true.
* @dev Reverts if token does not exist.
*/
function locked(uint256 tokenId) external view override returns (bool) {
if (!_exists(tokenId)) revert NonexistentToken();
return true;
}
/// Disable any transfer—soul‐bound enforcement.
function _transfer(address, address, uint256) internal pure override {
revert TransfersDisabled();
}
/* ─────────────────── ERC-721 standard overrides ────────────────────── */
/// Clears custom metadata on burn.
function _burn(uint256 tokenId)
internal override(ERC721)
{
super._burn(tokenId);
delete _tokenMetadataURIs[tokenId];
}
function supportsInterface(bytes4 interfaceId)
public view override(ERC721)
returns (bool)
{
if (interfaceId == 0xb45a3c0e) return true; // IERC5192
return super.supportsInterface(interfaceId);
}
/* ───────────────────────── Fallback handlers ───────────────────────── */
receive() external payable { revert UseDepositETH(); }
fallback() external payable { revert FallbackNotAllowed(); }
}
Deposits
Abstract contract that manages deposits of ETH, ERC20 tokens, and ERC721 tokens into BVGS NFTs.
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "./SignatureVerification.sol";
/**
* @title Deposits
* @dev Internal ETH/ERC20/ERC721 deposit and bookkeeping logic.
* Inherits SignatureVerification for key access and ReentrancyGuard for safety.
*/
abstract contract Deposits is SignatureVerification, IERC721Receiver, ReentrancyGuard {
using SafeERC20 for IERC20;
/* ───────── Events ───────── */
event Deposited (uint256 indexed tokenId, bytes32 indexed referenceId);
/* ───────── Errors ───────── */
error NonexistentToken();
error ZeroAddress();
error ZeroAmount();
error MismatchedInputs();
error ETHMismatch();
/* ───────── Storage ───────── */
mapping(uint256 => uint256) internal _baggedETH;
// ERC-20
mapping(uint256 => mapping(address => uint256)) internal _erc20Balances;
mapping(uint256 => address[]) internal _erc20TokenAddresses;
mapping(uint256 => mapping(address => bool)) internal _erc20Known;
// ERC-721
struct BaggedNFT { address nftContract; uint256 nftTokenId; }
mapping(uint256 => bytes32[]) internal _nftKeys;
mapping(uint256 => mapping(bytes32 => BaggedNFT)) internal _nftData;
mapping(uint256 => mapping(bytes32 => bool)) internal _nftKnown;
/* ───────── Guards ───────── */
function _requireOwnsBag(uint256 tokenId) internal view {
if (_erc721.ownerOf(tokenId) != msg.sender) revert NotOwner();
}
function _requireExists(uint256 tokenId) internal view {
address owner;
try _erc721.ownerOf(tokenId) returns (address o) { owner = o; } catch { revert NonexistentToken(); }
if (owner == address(0)) revert NonexistentToken();
}
/* ───────── IERC721Receiver ───────── */
function onERC721Received(address, address, uint256, bytes calldata)
public pure override returns (bytes4)
{ return this.onERC721Received.selector; }
/* ══════════════════ USER-FACING DEPOSIT WRAPPERS ══════════════════ */
/*
* @notice Deposit ETH into a BVGS NFT.
* @param tokenId The ID of the BVGS NFT.
* @param referenceId External reference ID for off-chain tracking.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
* - `msg.value` must be > 0.
*/
function depositETH(uint256 tokenId, bytes32 referenceId)
external payable nonReentrant
{
_requireOwnsBag(tokenId);
if (msg.value == 0) revert ZeroAmount();
_depositETH(tokenId, msg.value);
emit Deposited(tokenId, referenceId);
}
/*
* @notice Deposit ERC-20 tokens into a BVGS NFT.
* @param tokenId The ID of the BVGS NFT.
* @param tokenAddress The ERC-20 token contract address.
* @param amount The amount of tokens to deposit.
* @param referenceId External reference ID for off-chain tracking.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
* - `tokenAddress` must not be the zero address.
* - `amount` must be greater than zero.
*/
function depositERC20(
uint256 tokenId,
address tokenAddress,
uint256 amount,
bytes32 referenceId
) external nonReentrant {
_requireOwnsBag(tokenId);
if (tokenAddress == address(0)) revert ZeroAddress();
if (amount == 0) revert ZeroAmount();
_depositERC20(tokenId, tokenAddress, amount);
emit Deposited(tokenId, referenceId);
}
/*
* @notice Deposit an ERC-721 NFT into a BVGS NFT.
* @param tokenId The ID of the BVGS NFT.
* @param nftContract The ERC-721 contract address.
* @param nftTokenId The token ID of the ERC-721 to deposit.
* @param referenceId External reference ID for off-chain tracking.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
* - `nftContract` must not be the zero address.
*/
function depositERC721(
uint256 tokenId,
address nftContract,
uint256 nftTokenId,
bytes32 referenceId
) external nonReentrant {
_requireOwnsBag(tokenId);
if (nftContract == address(0)) revert ZeroAddress();
_depositERC721(tokenId, nftContract, nftTokenId);
emit Deposited(tokenId, referenceId);
}
/*
* @notice Batch-deposit ETH, multiple ERC-20s, and multiple ERC-721s in one call.
* @param tokenId The ID of the BVGS NFT.
* @param amountETH The ETH amount to deposit.
* @param tokenAddresses The list of ERC-20 token addresses.
* @param tokenAmounts The corresponding ERC-20 token amounts.
* @param nftContracts The list of ERC-721 contract addresses.
* @param nftTokenIds The corresponding ERC-721 token IDs.
* @param referenceId External reference ID for off-chain tracking.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
* - `tokenAddresses.length` must equal `tokenAmounts.length`.
* - `nftContracts.length` must equal `nftTokenIds.length`.
* - `msg.value` must equal `amountETH`.
*/
function batchDeposit(
uint256 tokenId,
uint256 amountETH,
address[] calldata tokenAddresses,
uint256[] calldata tokenAmounts,
address[] calldata nftContracts,
uint256[] calldata nftTokenIds,
bytes32 referenceId
) external payable nonReentrant {
_requireOwnsBag(tokenId);
if (msg.value != amountETH) revert ETHMismatch();
_batchDeposit(tokenId, amountETH, tokenAddresses, tokenAmounts, nftContracts, nftTokenIds);
emit Deposited(tokenId, referenceId);
}
/* ══════════════════ INTERNAL DEPOSIT HELPERS ══════════════════ */
function _depositETH(uint256 tokenId, uint256 amountETH) internal {
if (amountETH == 0) return;
_baggedETH[tokenId] += amountETH;
}
function _depositERC20(
uint256 tokenId,
address tokenAddress,
uint256 amount
) internal {
IERC20 t = IERC20(tokenAddress);
// Register token if new
if (!_erc20Known[tokenId][tokenAddress]) {
_erc20TokenAddresses[tokenId].push(tokenAddress);
_erc20Known[tokenId][tokenAddress] = true;
}
// Pull tokens
uint256 beforeBal = t.balanceOf(address(this));
t.safeTransferFrom(msg.sender, address(this), amount);
uint256 afterBal = t.balanceOf(address(this));
// Book the delta
// Ensures assets booked match assets received regardless of input
uint256 delta = afterBal > beforeBal ? afterBal - beforeBal : 0;
if (delta == 0) revert ZeroAmount();
_erc20Balances[tokenId][tokenAddress] += delta;
}
function _depositERC721(
uint256 tokenId,
address nftContract,
uint256 nftTokenId
) internal {
// Register NFT
bytes32 key = keccak256(abi.encodePacked(nftContract, nftTokenId));
if (!_nftKnown[tokenId][key]) {
_nftKeys[tokenId].push(key);
_nftKnown[tokenId][key] = true;
}
_nftData[tokenId][key] = BaggedNFT(nftContract, nftTokenId);
// Pull token
IERC721(nftContract).safeTransferFrom(msg.sender, address(this), nftTokenId);
}
function _batchDeposit(
uint256 tokenId,
uint256 amountETH,
address[] calldata tokenAddresses,
uint256[] calldata tokenAmounts,
address[] calldata nftContracts,
uint256[] calldata nftTokenIds
) internal {
if (tokenAddresses.length != tokenAmounts.length || nftContracts.length != nftTokenIds.length) revert MismatchedInputs();
if (amountETH > 0) _baggedETH[tokenId] += amountETH;
uint256 erc20Count = tokenAddresses.length;
for (uint256 i; i < erc20Count; ) {
_depositERC20(tokenId, tokenAddresses[i], tokenAmounts[i]);
unchecked { ++i; }
}
uint256 nftCount = nftContracts.length;
for (uint256 i; i < nftCount; ) {
_depositERC721(tokenId, nftContracts[i], nftTokenIds[i]);
unchecked { ++i; }
}
}
/* ══════════════════ INTERNAL BOOK-KEEPING UTILITIES ══════════════════ */
/*
* @dev Remove an ERC20 token address from the bag's tracking array using swap & pop.
* @param tokenId The BVGS NFT ID.
* @param tokenAddress The ERC20 token address to remove.
*/
function _removeERC20Token(uint256 tokenId, address tokenAddress) internal {
address[] storage tokenAddresses = _erc20TokenAddresses[tokenId];
uint256 len = tokenAddresses.length;
for (uint256 i; i < len; ) {
if (tokenAddresses[i] == tokenAddress) {
tokenAddresses[i] = tokenAddresses[len - 1];
tokenAddresses.pop();
break;
}
unchecked { ++i; }
}
}
/*
* @dev Remove an ERC721 key from the bag's tracking array using swap & pop.
* @param tokenId The BVGS NFT ID.
* @param key The keccak256(nftContract, tokenId) to remove.
*/
function _removeNFTKey(uint256 tokenId, bytes32 key) internal {
bytes32[] storage keys = _nftKeys[tokenId];
uint256 len = keys.length;
for (uint256 i = 0; i < len; ) {
if (keys[i] == key) {
keys[i] = keys[len - 1];
keys.pop();
break;
}
unchecked { ++i; }
}
}
}
Withdrawals
Abstract contract that manages withdrawals from BVGS NFTs with signature-based authorization.
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "./Deposits.sol";
/**
* @title Withdrawals
* @dev Signature-gated unbag, burn, key-rotation, plus view helpers.
* Inherits Deposits for storage & deposit helpers, and
* SignatureVerification for EIP-712 auth.
*/
abstract contract Withdrawals is Deposits {
using SafeERC20 for IERC20;
/* ───────── Events ───────── */
event Unbagged (uint256 indexed tokenId, bytes32 indexed referenceId);
event BagBurned (uint256 indexed tokenId, bytes32 indexed referenceId);
event KeyRotated(uint256 indexed tokenId, bytes32 indexed referenceId);
/* ───────── Errors ───────── */
error SignatureExpired();
error NoETHBagged();
error InsufficientTokenBalance();
error NFTNotFound();
error EthTransferFailed();
/* ══════════════════ USER-FACING UNBAGGING METHODS ══════════════════ */
/*
* @notice Withdraw ETH from a BVGS NFT, authorized via EIP-712 signature.
* @param tokenId The ID of the BVGS NFT.
* @param messageHash The EIP-712 digest that was signed.
* @param signature The EIP-712 signature by the active BVGS key.
* @param amountETH The amount of ETH to withdraw.
* @param recipient The address receiving the ETH.
* @param referenceId External reference ID for off-chain tracking.
* @param signatureExpiry UNIX timestamp after which the signature is invalid.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
* - `recipient` must not be the zero address.
* - `block.timestamp` must be ≤ `signatureExpiry`.
* - BVGS NFT must have ≥ `amountETH` ETH
*/
function unbagETH(
uint256 tokenId,
bytes32 messageHash,
bytes memory signature,
uint256 amountETH,
address recipient,
bytes32 referenceId,
uint256 signatureExpiry
) external nonReentrant {
_requireOwnsBag(tokenId);
if (recipient == address(0)) revert ZeroAddress();
if (block.timestamp > signatureExpiry) revert SignatureExpired();
// 1) Verify
bytes memory data = abi.encode(tokenId, amountETH, recipient, referenceId, msg.sender, signatureExpiry);
verifySignature(tokenId, messageHash, signature, address(0), OperationType.UNBAG_ETH, data);
// 2) Effects
uint256 currentBal = _baggedETH[tokenId];
if (currentBal < amountETH) revert NoETHBagged();
_baggedETH[tokenId] = currentBal - amountETH;
// 3) Interaction
(bool success, ) = payable(recipient).call{value: amountETH}("");
if (!success) revert EthTransferFailed();
emit Unbagged(tokenId, referenceId);
}
/*
* @notice Withdraw an ERC-20 token from a BVGS NFT, authorized via EIP-712 signature.
* @param tokenId The ID of the BVGS NFT.
* @param messageHash The EIP-712 digest that was signed.
* @param signature The EIP-712 signature by the active BVGS key.
* @param tokenAddress The ERC-20 token address to withdraw.
* @param amount The amount of tokens to withdraw.
* @param recipient The address receiving the tokens.
* @param referenceId External reference ID for off-chain tracking.
* @param signatureExpiry UNIX timestamp after which the signature is invalid.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
* - `recipient` must not be the zero address.
* - `block.timestamp` must be ≤ `signatureExpiry`.
* - BVGS NFT must have ≥ `amount` balance of `tokenAddress`.
*/
function unbagERC20(
uint256 tokenId,
bytes32 messageHash,
bytes memory signature,
address tokenAddress,
uint256 amount,
address recipient,
bytes32 referenceId,
uint256 signatureExpiry
) external nonReentrant {
_requireOwnsBag(tokenId);
if (recipient == address(0)) revert ZeroAddress();
if (block.timestamp > signatureExpiry) revert SignatureExpired();
// 1) Verify
bytes memory data = abi.encode(tokenId, tokenAddress, amount, recipient, referenceId, msg.sender, signatureExpiry);
verifySignature(tokenId, messageHash, signature, address(0), OperationType.UNBAG_ERC20, data);
// 2) Effects
mapping(address => uint256) storage balMap = _erc20Balances[tokenId];
uint256 bal = balMap[tokenAddress];
if (bal < amount) revert InsufficientTokenBalance();
unchecked {
balMap[tokenAddress] = bal - amount;
}
if (balMap[tokenAddress] == 0) {
// full storage refund for setting slot from non-zero → zero
delete balMap[tokenAddress];
delete _erc20Known[tokenId][tokenAddress];
_removeERC20Token(tokenId, tokenAddress);
}
// 3) Interaction
IERC20(tokenAddress).safeTransfer(recipient, amount);
emit Unbagged(tokenId, referenceId);
}
/*
* @notice Withdraw an ERC-721 token from a BVGS NFT, authorized via EIP-712 signature.
* @param tokenId The ID of the BVGS NFT.
* @param messageHash The EIP-712 digest that was signed.
* @param signature The EIP-712 signature by the active BVGS key.
* @param nftContract The ERC-721 contract address.
* @param nftTokenId The token ID of the ERC-721 to withdraw.
* @param recipient The address receiving the NFT.
* @param referenceId External reference ID for off-chain tracking.
* @param signatureExpiry UNIX timestamp after which the signature is invalid.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
* - `recipient` must not be the zero address.
* - `block.timestamp` must be ≤ `signatureExpiry`.
* - The specified NFT must be bagged in this BVGS NFT.
*/
function unbagERC721(
uint256 tokenId,
bytes32 messageHash,
bytes memory signature,
address nftContract,
uint256 nftTokenId,
address recipient,
bytes32 referenceId,
uint256 signatureExpiry
) external nonReentrant {
_requireOwnsBag(tokenId);
if (recipient == address(0)) revert ZeroAddress();
if (block.timestamp > signatureExpiry) revert SignatureExpired();
// 1) Verify
bytes memory data = abi.encode(tokenId, nftContract, nftTokenId, recipient, referenceId, msg.sender, signatureExpiry);
verifySignature(tokenId, messageHash, signature, address(0), OperationType.UNBAG_NFT, data);
bytes32 key = keccak256(abi.encodePacked(nftContract, nftTokenId));
if (!_nftKnown[tokenId][key]) revert NFTNotFound();
// 2) Effects
delete _nftData[tokenId][key];
_nftKnown[tokenId][key] = false;
_removeNFTKey(tokenId, key);
// 3) Interaction
IERC721(nftContract).safeTransferFrom(address(this), recipient, nftTokenId);
emit Unbagged(tokenId, referenceId);
}
/*
* @notice Batch withdrawal of ETH, ERC-20s, and ERC-721s with a single signature.
* @param tokenId The ID of the BVGS NFT.
* @param messageHash The EIP-712 digest that was signed.
* @param signature The EIP-712 signature by the active BVGS key.
* @param amountETH The amount of ETH to withdraw.
* @param tokenAddresses The list of ERC-20 token addresses.
* @param tokenAmounts The corresponding amounts of each ERC-20.
* @param nftContracts The list of ERC-721 contract addresses.
* @param nftTokenIds The corresponding ERC-721 token IDs.
* @param recipient The address receiving all assets.
* @param referenceId External reference ID for off-chain tracking.
* @param signatureExpiry UNIX timestamp after which the signature is invalid.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
* - `recipient` must not be the zero address.
* - `block.timestamp` must be ≤ `signatureExpiry`.
* - `tokenAddresses.length` must equal `tokenAmounts.length`.
* - `nftContracts.length` must equal `nftTokenIds.length`.
* - BVGS NFT must have ≥ `amountETH` ETH and sufficient balances for each asset.
*/
function batchUnbag(
uint256 tokenId,
bytes32 messageHash,
bytes memory signature,
uint256 amountETH,
address[] calldata tokenAddresses,
uint256[] calldata tokenAmounts,
address[] calldata nftContracts,
uint256[] calldata nftTokenIds,
address recipient,
bytes32 referenceId,
uint256 signatureExpiry
) external nonReentrant {
_requireOwnsBag(tokenId);
if (recipient == address(0)) revert ZeroAddress();
if (block.timestamp > signatureExpiry) revert SignatureExpired();
if (tokenAddresses.length != tokenAmounts.length ||
nftContracts.length != nftTokenIds.length
) revert MismatchedInputs();
// 1) Verify
bytes memory data = abi.encode(
tokenId, amountETH, tokenAddresses, tokenAmounts, nftContracts, nftTokenIds, recipient, referenceId, msg.sender, signatureExpiry
);
verifySignature(tokenId, messageHash, signature, address(0), OperationType.BATCH_UNBAG, data);
// 2/3) Effects + Interactions for each asset type
if (amountETH > 0) {
uint256 currentBal = _baggedETH[tokenId];
if (currentBal < amountETH) revert NoETHBagged();
_baggedETH[tokenId] = currentBal - amountETH;
(bool success, ) = payable(recipient).call{value: amountETH}("");
if (!success) revert EthTransferFailed();
}
// — ERC-20s —
mapping(address => uint256) storage balMap = _erc20Balances[tokenId];
for (uint256 i; i < tokenAddresses.length; ) {
address tok = tokenAddresses[i];
uint256 amt = tokenAmounts[i];
uint256 bal = balMap[tok];
if (bal < amt) revert InsufficientTokenBalance();
unchecked {
balMap[tok] = bal - amt;
}
if (balMap[tok] == 0) {
delete balMap[tok];
delete _erc20Known[tokenId][tok];
_removeERC20Token(tokenId, tok);
}
IERC20(tok).safeTransfer(recipient, amt);
unchecked { ++i; }
}
// — ERC-721s —
for (uint256 i; i < nftContracts.length; ) {
bytes32 key = keccak256(abi.encodePacked(nftContracts[i], nftTokenIds[i]));
if (!_nftKnown[tokenId][key]) revert NFTNotFound();
delete _nftData[tokenId][key];
_nftKnown[tokenId][key] = false;
_removeNFTKey(tokenId, key);
IERC721(nftContracts[i]).safeTransferFrom(address(this), recipient, nftTokenIds[i]);
unchecked { ++i; }
}
emit Unbagged(tokenId, referenceId);
}
/* ══════════════════ Key-rotation ══════════════════ */
/*
* @notice Rotate the off-chain authorization key for a BVGS NFT.
* @param tokenId The ID of the BVGS NFT.
* @param messageHash The EIP-712 digest that was signed.
* @param signature The EIP-712 signature by the active BVGS key.
* @param newPublicKey The new authorized BVGS public key.
* @param referenceId External reference ID for off-chain tracking.
* @param signatureExpiry UNIX timestamp after which the signature is invalid.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
* - `block.timestamp` must be ≤ `signatureExpiry`.
*/
function rotateBvgsKey(
uint256 tokenId,
bytes32 messageHash,
bytes memory signature,
address newPublicKey,
bytes32 referenceId,
uint256 signatureExpiry
) external nonReentrant {
_requireOwnsBag(tokenId);
if (block.timestamp > signatureExpiry) revert SignatureExpired();
bytes memory data = abi.encode(
tokenId, newPublicKey, referenceId, msg.sender, signatureExpiry
);
verifySignature(
tokenId, messageHash, signature,
newPublicKey, OperationType.ROTATE_KEY, data
);
emit KeyRotated(tokenId, referenceId);
}
/* ══════════════════ Burn ══════════════════ */
/* abstract hook for BVGS to burn its own ERC-721 */
function _burnBagNFT(uint256 id) internal virtual;
/*
* @notice Authenticated burn of a BVGS NFT, clearing all assets and burning the NFT.
* @param tokenId The ID of the BVGS NFT.
* @param messageHash The EIP-712 digest that was signed.
* @param signature The EIP-712 signature by the active BVGS key.
* @param referenceId External reference ID for off-chain tracking.
* @param signatureExpiry UNIX timestamp after which the signature is invalid.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
* - `block.timestamp` must be ≤ `signatureExpiry`.
*/
function burnBag(
uint256 tokenId,
bytes32 messageHash,
bytes memory signature,
bytes32 referenceId,
uint256 signatureExpiry
) external nonReentrant {
_requireOwnsBag(tokenId);
if (block.timestamp > signatureExpiry) revert SignatureExpired();
bytes memory data = abi.encode(
tokenId, referenceId, msg.sender, signatureExpiry
);
verifySignature(
tokenId, messageHash, signature,
address(0), OperationType.BURN_BAG, data
);
_finalizeBurn(tokenId);
emit BagBurned(tokenId, referenceId);
}
/**
* @dev Internal helper called by `burnBag`.
* - Wipes all ETH / ERC20 / ERC721 bookkeeping for the vault.
* - Delegates the actual ERC-721 burn to `_burnBagNFT` (implemented in BVGS).
*/
function _finalizeBurn(uint256 tokenId) internal {
/* ---- ETH ---- */
delete _baggedETH[tokenId];
/* ---- ERC-20 balances ---- */
address[] storage toks = _erc20TokenAddresses[tokenId];
for (uint256 i; i < toks.length; ) {
address t = toks[i];
delete _erc20Balances[tokenId][t];
delete _erc20Known[tokenId][t];
unchecked { ++i; }
}
delete _erc20TokenAddresses[tokenId];
/* ---- ERC-721 bookkeeping ---- */
bytes32[] storage keys = _nftKeys[tokenId];
for (uint256 i; i < keys.length;) {
bytes32 k = keys[i];
delete _nftData[tokenId][k];
delete _nftKnown[tokenId][k];
unchecked { ++i; }
}
delete _nftKeys[tokenId];
/* ---- finally burn the NFT itself ---- */
_burnBagNFT(tokenId);
}
/* ══════════════════ View helper ══════════════════ */
/*
* @notice Returns the full contents of a BVGS NFT: ETH, ERC-20 balances, and ERC-721s.
* @param tokenId The ID of the BVGS NFT.
* @return bagETH The ETH amount held.
* @return erc20Tokens Array of (tokenAddress, balance) for each ERC-20.
* @return nfts Array of BaggedNFT structs representing each ERC-721.
*
* Requirements:
* - `tokenId` must exist and caller must be its owner.
*/
struct BaggedERC20 { address tokenAddress; uint256 balance; }
function getFullBag(uint256 tokenId)
external view
returns (
uint256 bagETH,
BaggedERC20[] memory erc20Tokens,
BaggedNFT[] memory nftContracts
)
{
_requireExists(tokenId);
if (_erc721.ownerOf(tokenId) != msg.sender) revert NotOwner();
bagETH = _baggedETH[tokenId];
// ERC-20s
address[] storage tokenAddresses = _erc20TokenAddresses[tokenId];
erc20Tokens = new BaggedERC20[](tokenAddresses.length);
for (uint256 i; i < tokenAddresses.length; ) {
erc20Tokens[i] = BaggedERC20({
tokenAddress: tokenAddresses[i],
balance: _erc20Balances[tokenId][tokenAddresses[i]]
});
unchecked { ++i; }
}
// ERC-721s
bytes32[] storage nftList = _nftKeys[tokenId];
uint256 count;
for (uint256 i; i < nftList.length; ) {
if (_nftKnown[tokenId][nftList[i]]) count++;
unchecked { ++i; }
}
nftContracts = new BaggedNFT[](count);
uint256 idx;
for (uint256 i; i < nftList.length; ) {
if (_nftKnown[tokenId][nftList[i]]) {
nftContracts[idx++] = _nftData[tokenId][nftList[i]];
}
unchecked { ++i; }
}
}
}
Signature verification
Provides signature-based authorization for contract operations using EIP-712.
// SPDX-License-Identifier: BUSL-1.1
// Copyright © 2025 BVGS. All Rights Reserved.
// You may use, modify, and share this code for NON-COMMERCIAL purposes only.
// Commercial use requires written permission from BVGS.
pragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
/**
* @title SignatureVerification
* @notice Provides signature-based authorization for Bagging contract operations using EIP‑712.
* @dev Each BVGS NFT references an active BVGS public key that must sign operations.
* The contract stores a nonce to prevent replay attacks.
*/
contract SignatureVerification is EIP712 {
using ECDSA for bytes32;
/// @notice Enumerates the possible operations that require BVGS key authorization.
enum OperationType {
ROTATE_KEY,
UNBAG_ETH,
UNBAG_ERC20,
UNBAG_NFT,
BURN_BAG,
SET_TOKEN_URI,
BATCH_UNBAG
}
/// @dev Gas-cheap pointer to the BVGS ERC-721 (set once in constructor).
ERC721 immutable _erc721;
/**
* @dev Stores authorization data for each BVGS NFT.
* @param nonce Monotonically increasing value to prevent signature replay.
* @param activeBvgsPublicKey The public key currently authorized to sign operations for this BVGS NFT.
*/
struct TokenAuth {
address activeBvgsPublicKey;
uint96 nonce;
}
/// @dev Mapping from BVGS NFT token ID to its TokenAuth.
mapping(uint256 => TokenAuth) private _tokenAuth;
/* ─────────────────── Errors ────────────────────── */
error NotOwner();
error InvalidMessageHash();
error InvalidSignature();
error AlreadyInitialized();
error ZeroKey();
/* ─────────────────── EIP-712 setup ───────────────────── */
/**
* @dev Typehash for the operation, including tokenId, nonce, opType (as uint8),
* and a bytes32 hash of the data.
*/
bytes32 private constant OPERATION_TYPEHASH =
keccak256("Operation(uint256 tokenId,uint256 nonce,uint8 opType,bytes32 dataHash)");
/**
* @notice Constructor that sets the reference to the ERC721 contract for BVGS NFT ownership checks.
* @param erc721Address The address of the ERC721 contract that mints/owns the BVGS NFTs.
*/
constructor(address erc721Address) EIP712("BVGS", "1") {
_erc721 = ERC721(erc721Address);
}
/**
* @notice Initializes the BVGS NFT data with a public key and nonce.
* @dev Intended to be called once upon minting a new BVGS NFT.
* @param tokenId The ID of the BVGS NFT being initialized.
* @param bvgsPublicKey The public key that will sign operations for this BVGS NFT.
*/
function initialize(uint256 tokenId, address bvgsPublicKey) internal {
if (_tokenAuth[tokenId].activeBvgsPublicKey != address(0)) {
revert AlreadyInitialized();
}
_tokenAuth[tokenId].activeBvgsPublicKey = bvgsPublicKey;
_tokenAuth[tokenId].nonce = 1;
}
/**
* @notice Modifier that checks the caller is the owner of the specified token.
* @param tokenId The ID of the BVGS NFT to check ownership against.
*/
modifier onlyTokenOwner(uint256 tokenId) {
if (_erc721.ownerOf(tokenId) != msg.sender) revert NotOwner();
_;
}
/**
* @notice Verifies an EIP‑712 signature for a specific operation.
* @param tokenId The ID of the BVGS NFT.
* @param messageHash The EIP‑712 digest that was signed.
* @param signature The BVGS NFT private key signature to verify.
* @param newBvgsPublicKey The new BVGS NFT public key (if rotating the key).
* @param opType The operation being authorized.
* @param data Encoded parameters for the specific operation.
*
* Requirements:
* - The EIP-712 message digest must match `messageHash`.
* - The signature must be valid for the current, active BVGS NFT public key.
* - On successful verification, the nonce increments.
* - If `opType` is `ROTATE_KEY`, the BVGS NFT public key is updated to `newBvgsPublicKey`.
*/
function verifySignature(
uint256 tokenId,
bytes32 messageHash,
bytes memory signature,
address newBvgsPublicKey,
OperationType opType,
bytes memory data
) internal {
TokenAuth storage tokenAuth = _tokenAuth[tokenId];
// Compute the hash of the operation data.
bytes32 dataHash = keccak256(data);
bytes32 structHash = keccak256(
abi.encode(
OPERATION_TYPEHASH,
tokenId,
tokenAuth.nonce,
uint8(opType),
dataHash
)
);
bytes32 expectedHash = _hashTypedDataV4(structHash);
if (messageHash != expectedHash) {
revert InvalidMessageHash();
}
address signer = expectedHash.recover(signature);
if (signer != tokenAuth.activeBvgsPublicKey) {
revert InvalidSignature();
}
// Increment nonce after successful verification.
tokenAuth.nonce++;
// If rotating the key, update the active BVGS public key.
if (opType == OperationType.ROTATE_KEY && newBvgsPublicKey != address(0)) {
if (newBvgsPublicKey == address(0)) revert ZeroKey();
tokenAuth.activeBvgsPublicKey = newBvgsPublicKey;
}
}
/* ─────────────────── Token-gated view functions ────────────────────── */
/**
* @notice Retrieves the current BVGS public key for the given BVGS NFT.
* @param tokenId The ID of the BVGS NFT.
* @return The currently active BVGS public key.
*
* Requirements:
* - Caller must be the owner of `tokenId`.
*/
function getActiveBvgsPublicKeyForToken(uint256 tokenId)
external
view
onlyTokenOwner(tokenId)
returns (address)
{
return _tokenAuth[tokenId].activeBvgsPublicKey;
}
/**
* @notice Retrieves the current nonce for the given BVGS NFT.
* @param tokenId The ID of the BVGS NFT.
* @return The current nonce used for signature verification.
*
* Requirements:
* - Caller must be the owner of `tokenId`.
*/
function getNonce(uint256 tokenId)
external
view
onlyTokenOwner(tokenId)
returns (uint256)
{
return uint256(_tokenAuth[tokenId].nonce);
}
}