diff --git a/README.md b/README.md index 9132885..45b3af2 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,27 @@ # Sova Contracts -This repository contains the predeploy contracts for the Sova Network. +This repository contains the predeploy contracts for the Sova Network. This repo serves as the source of truth for on chain deployments. -The Sova Network enables smart contract to directly interact with the Bitcoin blockchain. This interaction is done through the use of custom precompiles and predeployed contracts. This feature set allows smart contract to do things like broadcast transactions, decode payloads, verify signatures, get block height and more! +The Sova Network enables smart contracts to directly interact with the Bitcoin blockchain. Smart contract interaction is done through the use of custom precompiles and predeployed contracts. These features allow smart contracts to safely broadcast BTC transactions, decode BTC payloads, get BTC block height. As the network matures this feature set will continue to expand. ## Details The Sova precompiles provide built-in Bitcoin transaction validation, broadcast capabilities, and UTXO management with safeguards against double-spending and replay attacks. These features power the SovaBTC.sol predeploy contract and are available to any developer through the SovaBitcoin.sol library. Our goal is to make it as easy as possible to add native Bitcoin functionality to your Sova smart contracts. ## Contracts -- **SovaL1Block** (`0x2100000000000000000000000000000000000015`) - Bitcoin state tracking -- **SovaBTC** (`0x2100000000000000000000000000000000000020`) - Bitcoin-backed ERC20 token -- **SovaBitcoin** - Library for Bitcoin precompile interactions -- **UBTC20** - Abstract base contract extending ERC20 with pending transaction states and slot locking. Prevents transfers during pending Bitcoin operations and handles deferred accounting for cross-chain finalization. -## Build - -```shell -# Build the project -forge build -``` +- **SovaL1Block** `0x2100000000000000000000000000000000000015` +- **SovaBTC** `0x2100000000000000000000000000000000000020` +- **SovaBitcoin** - Library for Bitcoin precompile interactions. +- **UBTC20** - Base contract extending ERC20 with pending transaction states and Bitcoin finality. Prevents transfers during pending Bitcoin operations and handles deferred accounting for cross-chain finalization. ## Deployed Bytecode verification Generate the deployed byte code locally to verify the predeploy contract code used on the Sova Network. ```shell -# uBTC.sol -forge inspect src/UBTC.sol:UBTC deployedBytecode +# SovaBTC.sol +forge inspect src/SovaBTC.sol:SovaBTC deployedBytecode # SovaL1Block.sol forge inspect src/SovaL1Block.sol:SovaL1Block deployedBytecode diff --git a/src/SovaBTC.sol b/src/SovaBTC.sol index 44f4dd3..79a603f 100644 --- a/src/SovaBTC.sol +++ b/src/SovaBTC.sol @@ -17,23 +17,24 @@ import "./UBTC20.sol"; * @author Sova Labs * * Bitcoin meets ERC20. Bitcoin meets composability. + * + * @notice Uses sol compiler 0.8.15 for compatibility with Optimism Bedrock contracts + * that Sova Network used for chain artifacts creation. + * Ref: https://github.com/SovaNetwork/optimism/tree/op-deployer-v0.3.3-mainnet */ contract SovaBTC is ISovaBTC, UBTC20, Ownable, ReentrancyGuard { /// @notice Minimum deposit amount in satoshis uint64 public minDepositAmount; - /// @notice Maximum deposit amount in satoshis - uint64 public maxDepositAmount; - - /// @notice Maximum gas limit amount in satoshis - uint64 public maxGasLimitAmount; - /// @notice Pause state of the contract bool private _paused; /// @notice Mapping to track Bitcoin txids that have been used for deposits mapping(bytes32 => bool) private usedTxids; + /// @notice Mapping to track authorized withdraw signers + mapping(address => bool) public withdrawSigners; + error InsufficientDeposit(); error InsufficientInput(); error InsufficientAmount(); @@ -53,38 +54,73 @@ contract SovaBTC is ISovaBTC, UBTC20, Ownable, ReentrancyGuard { error TransactionAlreadyUsed(); error PendingDepositExists(); error PendingWithdrawalExists(); + error PendingUserWithdrawalRequestExists(); + error UnauthorizedWithdrawSigner(); + error SignerAlreadyExists(); + error SignerDoesNotExist(); event Deposit(address caller, bytes32 txid, uint256 amount); - event Withdraw(address caller, bytes32 txid, uint256 amount); + event WithdrawSignaled( + address indexed user, uint256 amount, uint64 btcGasBid, uint64 operatorFee, string destination + ); + event Withdraw(address user, bytes32 txid, uint256 totalAmount); event MinDepositAmountUpdated(uint64 oldAmount, uint64 newAmount); event MaxDepositAmountUpdated(uint64 oldAmount, uint64 newAmount); event MaxGasLimitAmountUpdated(uint64 oldAmount, uint64 newAmount); event ContractPausedByOwner(address indexed account); event ContractUnpausedByOwner(address indexed account); + event WithdrawSignerAdded(address indexed signer); + event WithdrawSignerRemoved(address indexed signer); + + constructor() Ownable() { + _initializeOwner(msg.sender); + + minDepositAmount = 10_000; // 10,000 sat = 0.0001 BTC + + _paused = false; + + // Set initial withdrawal signer + withdrawSigners[0xd94FcA65c01b7052469A653dB466cB91d8782125] = true; + emit WithdrawSignerAdded(0xd94FcA65c01b7052469A653dB466cB91d8782125); + } + + /// Modifiers modifier whenNotPaused() { + _whenNotPaused(); + _; + } + + function _whenNotPaused() internal view { if (_paused) { revert ContractPaused(); } - _; } modifier whenPaused() { + _whenPaused(); + _; + } + + function _whenPaused() internal view { if (!_paused) { revert ContractNotPaused(); } - _; } - constructor() Ownable() { - _initializeOwner(msg.sender); + modifier onlyWithdrawSigner() { + _onlyWithdrawSigner(); + _; + } - minDepositAmount = 10_000; // (starts at 10,000 sats) - maxDepositAmount = 100_000_000_000; // (starts at 1000 BTC = 100 billion sats) - maxGasLimitAmount = 50_000_000; // (starts at 0.5 BTC = 50,000,000 sats) - _paused = false; + function _onlyWithdrawSigner() internal view { + if (!withdrawSigners[msg.sender]) { + revert UnauthorizedWithdrawSigner(); + } } + /// External + function isPaused() external view returns (bool) { return _paused; } @@ -126,9 +162,6 @@ contract SovaBTC is ISovaBTC, UBTC20, Ownable, ReentrancyGuard { if (amount < minDepositAmount) { revert DepositBelowMinimum(); } - if (amount > maxDepositAmount) { - revert DepositAboveMaximum(); - } // Validate the BTC transaction and extract metadata SovaBitcoin.BitcoinTx memory btcTx = SovaBitcoin.isValidDeposit(signedTx, amount, voutIndex); @@ -152,25 +185,23 @@ contract SovaBTC is ISovaBTC, UBTC20, Ownable, ReentrancyGuard { } /** - * @notice Withdraws Bitcoin by burning uBTC tokens. Then triggering the signing, and broadcasting - * of a Bitcoin transaction. + * @notice Signals a withdrawal request by saving the withdrawal request. * - * @dev For obvious reasons the UBTC_SIGN_TX_BYTES precompile is a sensitive endpoint. It is enforced - * in the execution client that only this contract can all that precompile functionality. - * @dev The hope is that in the future more SIGN_TX_BYTES endpoints can be added to the network - * and the backends are controlled by 3rd party signer entities. + * @dev The user must have enough sovaBTC to cover the amount + gas limit + operatorFee fee. * - * @param amount The amount of satoshis to withdraw - * @param btcGasLimit Specified gas limit for the Bitcoin transaction (in satoshis) - * @param btcBlockHeight The current BTC block height. This is used to source spendable Bitcoin UTXOs - * @param dest The destination Bitcoin address (bech32) + * @param amount The amount of satoshis to withdraw (excluding gas) + * @param btcGasLimit The gas limit bid for the Bitcoin transaction in satoshis + * @param operatorFee The fee offered to the operator for processing the withdrawal in satoshis + * @param dest The Bitcoin address to send the withdrawn BTC to */ - function withdraw(uint64 amount, uint64 btcGasLimit, uint64 btcBlockHeight, string calldata dest) + function signalWithdraw(uint64 amount, uint64 btcGasLimit, uint64 operatorFee, string calldata dest) external - nonReentrant whenNotPaused { - // Input validation + if (bytes(dest).length == 0) { + revert EmptyDestination(); + } + if (amount == 0) { revert ZeroAmount(); } @@ -179,26 +210,61 @@ contract SovaBTC is ISovaBTC, UBTC20, Ownable, ReentrancyGuard { revert ZeroGasLimit(); } - if (btcGasLimit > maxGasLimitAmount) { - revert GasLimitTooHigh(); - } - - uint256 totalRequired = amount + btcGasLimit; + uint256 totalRequired = amount + btcGasLimit + operatorFee; if (balanceOf(msg.sender) < totalRequired) { revert InsufficientAmount(); } - if (_pendingWithdrawals[msg.sender].amount > 0) revert PendingWithdrawalExists(); + // check if user already has a pending withdrawal + if (_pendingWithdrawals[msg.sender].amount > 0) { + revert PendingWithdrawalExists(); + } + + // Store the withdraw request + _pendingUserWithdrawRequests[msg.sender] = UserWithdrawRequest({ + amount: amount, btcGasLimit: btcGasLimit, operatorFee: operatorFee, destination: dest + }); + + emit WithdrawSignaled(msg.sender, amount, btcGasLimit, operatorFee, dest); + } + + /** + * @notice Processes a withdrawal signal by broadcasting a signed Bitcoin transaction. + * + * @dev Only authorized withdraw signers can call this function. + * + * @param user The address of the user who signaled the withdrawal + * @param signedTx The signed Bitcoin transaction to broadcast + */ + function withdraw(address user, bytes calldata signedTx, uint64 amount, uint64 btcGasLimit, uint64 operatorFee) + external + whenNotPaused + onlyWithdrawSigner + { + // Check if user has a pending withdrawal already + if (_pendingWithdrawals[user].amount > 0) revert PendingWithdrawalExists(); + + // decode signed tx so that we know it is a valid bitcoin tx + SovaBitcoin.BitcoinTx memory btcTx = SovaBitcoin.decodeBitcoinTx(signedTx); + + uint256 totalAmount = amount + uint256(btcGasLimit) + uint256(operatorFee); + + if (balanceOf(user) < totalAmount) revert InsufficientAmount(); // Track pending withdrawal - _setPendingWithdrawal(msg.sender, totalRequired); + _setPendingWithdrawal(user, totalAmount); - // Call Bitcoin precompile to construct the BTC tx and lock the slot - bytes32 btcTxid = SovaBitcoin.vaultSpend(msg.sender, amount, btcGasLimit, btcBlockHeight, dest); + // Clear the withdraw request + delete _pendingUserWithdrawRequests[user]; + + // Broadcast the signed BTC tx + SovaBitcoin.broadcastBitcoinTx(signedTx); - emit Withdraw(msg.sender, btcTxid, amount); + emit Withdraw(user, btcTx.txid, totalAmount); } + /// Admin + /** * @notice Admin function to burn tokens from a specific wallet * @@ -215,10 +281,6 @@ contract SovaBTC is ISovaBTC, UBTC20, Ownable, ReentrancyGuard { * @param _minAmount New minimum deposit amount in satoshis */ function setMinDepositAmount(uint64 _minAmount) external onlyOwner { - if (_minAmount >= maxDepositAmount) { - revert InvalidDepositLimits(); - } - uint64 oldAmount = minDepositAmount; minDepositAmount = _minAmount; @@ -226,52 +288,48 @@ contract SovaBTC is ISovaBTC, UBTC20, Ownable, ReentrancyGuard { } /** - * @notice Admin function to set the maximum deposit amount - * - * @param _maxAmount New maximum deposit amount in satoshis + * @notice Admin function to pause the contract */ - function setMaxDepositAmount(uint64 _maxAmount) external onlyOwner { - if (_maxAmount <= minDepositAmount) { - revert InvalidDepositLimits(); - } - - uint64 oldAmount = maxDepositAmount; - maxDepositAmount = _maxAmount; + function pause() external onlyOwner whenNotPaused { + _paused = true; - emit MaxDepositAmountUpdated(oldAmount, _maxAmount); + emit ContractPausedByOwner(msg.sender); } /** - * @notice Admin function to set the maximum gas limit amount - * - * @param _maxGasLimitAmount New maximum gas limit amount in satoshis + * @notice Admin function to unpause the contract */ - function setMaxGasLimitAmount(uint64 _maxGasLimitAmount) external onlyOwner { - if (_maxGasLimitAmount == 0) { - revert ZeroAmount(); - } - - uint64 oldAmount = maxGasLimitAmount; - maxGasLimitAmount = _maxGasLimitAmount; + function unpause() external onlyOwner whenPaused { + _paused = false; - emit MaxGasLimitAmountUpdated(oldAmount, _maxGasLimitAmount); + emit ContractUnpausedByOwner(msg.sender); } /** - * @notice Admin function to pause the contract + * @notice Admin function to add a withdraw signer + * + * @param signer The address to add as a withdraw signer */ - function pause() external onlyOwner whenNotPaused { - _paused = true; + function addWithdrawSigner(address signer) external onlyOwner { + if (withdrawSigners[signer]) { + revert SignerAlreadyExists(); + } - emit ContractPausedByOwner(msg.sender); + withdrawSigners[signer] = true; + emit WithdrawSignerAdded(signer); } /** - * @notice Admin function to unpause the contract + * @notice Admin function to remove a withdraw signer + * + * @param signer The address to remove as a withdraw signer */ - function unpause() external onlyOwner whenPaused { - _paused = false; + function removeWithdrawSigner(address signer) external onlyOwner { + if (!withdrawSigners[signer]) { + revert SignerDoesNotExist(); + } - emit ContractUnpausedByOwner(msg.sender); + withdrawSigners[signer] = false; + emit WithdrawSignerRemoved(signer); } } diff --git a/src/UBTC20.sol b/src/UBTC20.sol index f8f55b8..f3c6b87 100644 --- a/src/UBTC20.sol +++ b/src/UBTC20.sol @@ -9,22 +9,35 @@ abstract contract UBTC20 is ERC20 { uint256 timestamp; } + struct UserWithdrawRequest { + uint256 amount; + uint64 btcGasLimit; + uint64 operatorFee; + string destination; + } + mapping(address => Pending) internal _pendingDeposits; mapping(address => Pending) internal _pendingWithdrawals; + mapping(address => UserWithdrawRequest) internal _pendingUserWithdrawRequests; + error PendingTransactionExists(); /* --------------------------- MODIFIERS ---------------------------- */ /// @notice Modifier to prevent transfers when user has a pending deposit or withdrawal. modifier noPendingTransactions(address user) { + _noPendingTransactions(user); + _; + } + + function _noPendingTransactions(address user) internal view { if (_pendingDeposits[user].amount > 0 || _pendingWithdrawals[user].amount > 0) { revert PendingTransactionExists(); } - _; } - /* ------------------------------ GETTERS ------------------------------ */ + /* ------------------------------- VIEW ------------------------------- */ function pendingDepositAmountOf(address user) public view returns (uint256) { return _pendingDeposits[user].amount; @@ -42,7 +55,23 @@ abstract contract UBTC20 is ERC20 { return _pendingWithdrawals[user].timestamp; } - /* ----------------------------- OVERRIDES ------------------------------ */ + function pendingUserWithdrawRequestAmountOf(address user) public view returns (uint256) { + return _pendingUserWithdrawRequests[user].amount; + } + + function pendingUserWithdrawRequestBtcGasLimitOf(address user) public view returns (uint64) { + return _pendingUserWithdrawRequests[user].btcGasLimit; + } + + function pendingUserWithdrawRequestOperatorFeeOf(address user) public view returns (uint64) { + return _pendingUserWithdrawRequests[user].operatorFee; + } + + function pendingUserWithdrawRequestDestinationOf(address user) public view returns (string memory) { + return _pendingUserWithdrawRequests[user].destination; + } + + /* ----------------------------- ERC20 OVERRIDES ------------------------------ */ /// @notice Override transfer to prevent transfers during pending states function transfer(address to, uint256 amount) public override noPendingTransactions(msg.sender) returns (bool) { @@ -59,7 +88,7 @@ abstract contract UBTC20 is ERC20 { return super.transferFrom(from, to, amount); } - /* ------------------------------- INTERNAL ------------------------------- */ + /* -------------------------------- INTERNAL -------------------------------- */ /** * @notice Deferred accounting mechanism. The 'pending' mechanics are enforced diff --git a/src/interfaces/ISovaBTC.sol b/src/interfaces/ISovaBTC.sol index 851c015..91aa959 100644 --- a/src/interfaces/ISovaBTC.sol +++ b/src/interfaces/ISovaBTC.sol @@ -2,17 +2,18 @@ pragma solidity 0.8.15; interface ISovaBTC { - /// @notice Bitcoin-specific functions function depositBTC(uint64 amount, bytes calldata signedTx, uint8 voutIndex) external; - function withdraw(uint64 amount, uint64 btcGasLimit, uint64 btcBlockHeight, string calldata dest) external; + function signalWithdraw(uint64 amount, uint64 btcGasLimit, uint64 operatorFee, string calldata dest) external; + function withdraw(address user, bytes calldata signedTx, uint64 amount, uint64 btcGasLimit, uint64 operatorFee) + external; function isTransactionUsed(bytes32 txid) external view returns (bool); function isPaused() external view returns (bool); /// @notice Admin functions function adminBurn(address wallet, uint256 amount) external; function setMinDepositAmount(uint64 _minAmount) external; - function setMaxDepositAmount(uint64 _maxAmount) external; - function setMaxGasLimitAmount(uint64 _maxGasLimitAmount) external; function pause() external; function unpause() external; + function addWithdrawSigner(address signer) external; + function removeWithdrawSigner(address signer) external; } diff --git a/src/lib/SovaBitcoin.sol b/src/lib/SovaBitcoin.sol index 8a7dbba..75e225f 100644 --- a/src/lib/SovaBitcoin.sol +++ b/src/lib/SovaBitcoin.sol @@ -134,27 +134,4 @@ library SovaBitcoin { return btcTx; } - - /** - * @notice Calls the Vault Spend precompile to construct and broadcast a Bitcoin transaction - * - * @param caller The address of the caller (EVM address) - * @param amount The amount of satoshis to withdraw - * @param btcGasLimit Specified gas limit for the Bitcoin transaction (in satoshis) - * @param btcBlockHeight The current BTC block height. This is used to source spendable Bitcoin UTXOs - * @param dest The destination Bitcoin address (bech32) - * - * @return btcTxid Txid of the constructed Bitcoin transaction - */ - function vaultSpend(address caller, uint64 amount, uint64 btcGasLimit, uint64 btcBlockHeight, string calldata dest) - internal - returns (bytes32 btcTxid) - { - bytes memory inputData = abi.encode(caller, amount, btcGasLimit, btcBlockHeight, dest); - - (bool success, bytes memory returndata) = VAULT_SPEND_PRECOMPILE_ADDRESS.call(inputData); - if (!success) revert PrecompileCallFailed(); - - return bytes32(returndata); - } }