UNPKG

@btc-vision/btc-runtime

Version:

Bitcoin L1 Smart Contract Runtime for OP_NET. Build decentralized applications on Bitcoin using AssemblyScript and WebAssembly. Fully audited.

786 lines (608 loc) 21.4 kB
# ReentrancyGuard ReentrancyGuard protects your contracts from reentrancy attacks, one of the most common smart contract vulnerabilities. It prevents a contract from being called back into itself before the first call completes. ## Overview ```typescript import { ReentrancyGuard, ReentrancyLevel, } from '@btc-vision/btc-runtime/runtime'; @final export class MyContract extends ReentrancyGuard { protected readonly reentrancyLevel: ReentrancyLevel = ReentrancyLevel.STANDARD; public constructor() { super(); } @method() @returns({ name: 'success', type: ABIDataTypes.BOOL }) public withdraw(calldata: Calldata): BytesWriter { // Protected automatically by ReentrancyGuard const amount = this.balances.get(Blockchain.tx.sender); this.balances.set(Blockchain.tx.sender, u256.Zero); this.sendFunds(Blockchain.tx.sender, amount); return new BytesWriter(0); } } ``` ## OpenZeppelin vs OP_NET ReentrancyGuard | Feature | OpenZeppelin (Solidity) | OP_NET ReentrancyGuard | |---------|-------------------------|----------------------| | Protection Scope | Per-function (`nonReentrant` modifier) | All methods by default | | Opt-in/Opt-out | Opt-in per function | Opt-out via `isSelectorExcluded` | | Lock Type | Boolean lock | Boolean lock (STANDARD) / Depth counter (CALLBACK) | | Callback Support | No (always blocks) | No (both modes block reentry) | | Storage | Persistent storage | Persistent storage | ## What is Reentrancy? ### The Attack ```typescript // Vulnerable contract public withdraw(): void { const balance = balances.get(sender); // 1. External call BEFORE state update sendFunds(sender, balance); // Attacker's receive function calls withdraw() again // balance is still the original amount! // 2. State update happens too late balances.set(sender, u256.Zero); } ``` Attack flow: ``` 1. Attacker calls withdraw() 2. Contract sends funds to attacker 3. Attacker's receive function calls withdraw() again 4. Balance hasn't been updated yet, so attacker withdraws again 5. Repeat until contract is drained ``` ### The Defense ReentrancyGuard prevents this by locking the contract during execution: ```typescript // Protected contract public withdraw(): void { // ReentrancyGuard: Check and set lock // If already locked, transaction reverts const balance = balances.get(sender); balances.set(sender, u256.Zero); // State update sendFunds(sender, balance); // External call // ReentrancyGuard: Release lock } ``` ## Guard Mechanism The following diagram shows how the guard checks and manages reentrancy depth: ```mermaid --- config: theme: dark --- flowchart LR A[👤 User submits TX] --> B[onExecutionStarted] B --> C{Check lock/depth} C -->|STANDARD: locked| D[Revert] C -->|CALLBACK: depth >= 1| E[Revert] C -->|Valid| F[Set lock/Increment depth] F --> G[Execute method] G --> H{External call?} H -->|Yes| I{Callback attempt?} I -->|Yes| C I -->|No| J[Complete] H -->|No| J J --> K[onExecutionCompleted] K --> L[Decrement depth] ``` ## Vulnerable Contract Attack The following sequence diagram shows how a reentrancy attack works against an unprotected contract: ```mermaid sequenceDiagram participant Attacker as Attacker Wallet participant Blockchain as Bitcoin L1 participant VM as WASM Runtime participant Vulnerable as Vulnerable Contract<br/>(NO ReentrancyGuard) participant Storage as Storage Pointers participant MaliciousContract as Attacker's Contract<br/>(Malicious Receiver) Note over Vulnerable: VULNERABLE: No reentrancy protection Attacker->>Blockchain: Submit withdraw() TX Blockchain->>VM: Execute transaction VM->>Vulnerable: Call withdraw() activate Vulnerable Vulnerable->>Storage: Read balances[attacker] Storage-->>Vulnerable: balance = 100 BTC Note over Vulnerable: Step 1: Read balance Note over Vulnerable: CRITICAL ERROR:<br/>External call BEFORE state update! Vulnerable->>MaliciousContract: send(100 BTC) activate MaliciousContract Note over MaliciousContract: Receive callback triggered MaliciousContract->>Blockchain: Call withdraw() AGAIN Blockchain->>VM: Execute nested call VM->>Vulnerable: withdraw() RE-ENTRY activate Vulnerable Vulnerable->>Storage: Read balances[attacker] Storage-->>Vulnerable: balance = 100 BTC Note over Vulnerable: STILL 100!<br/>Not updated yet! Vulnerable->>MaliciousContract: send(100 BTC) AGAIN Note over MaliciousContract: Received 100 BTC (2nd time) Vulnerable->>Storage: balances[attacker] = 0 Note over Storage: Update too late (nested call) deactivate Vulnerable Note over MaliciousContract: Attack successful!<br/>Received 200 BTC total deactivate MaliciousContract Vulnerable->>Storage: balances[attacker] = 0 Note over Storage: Update happens (original call)<br/>but damage already done! Vulnerable->>VM: Return success deactivate Vulnerable VM->>Blockchain: Commit state Blockchain->>Attacker: Transaction complete Note over Attacker: Stole 100 BTC<br/>by exploiting reentrancy! ``` ## Protected Contract Defense The following sequence diagram shows how ReentrancyGuard blocks the same attack: ```mermaid sequenceDiagram participant Attacker as Attacker Wallet participant Blockchain as Bitcoin L1 participant VM as WASM Runtime participant Protected as Protected Contract<br/>(WITH ReentrancyGuard) participant Guard as ReentrancyGuard<br/>Persistent Storage participant Storage as Storage Pointers participant MaliciousContract as Attacker's Contract Note over Protected: PROTECTED: ReentrancyLevel.STANDARD Attacker->>Blockchain: Submit withdraw() TX Blockchain->>VM: Execute transaction VM->>Protected: Call withdraw() activate Protected Protected->>Protected: onExecutionStarted hook Protected->>Guard: nonReentrantBefore() Guard->>Guard: Read _locked Note over Guard: _locked = false (unlocked) Guard->>Guard: Set _locked = true Note over Guard: LOCK ACQUIRED Protected->>Storage: Read balances[attacker] Storage-->>Protected: balance = 100 BTC Protected->>Storage: balances[attacker] = 0 Note over Storage: STATE UPDATED FIRST!<br/>Checks-Effects-Interactions pattern Protected->>MaliciousContract: send(100 BTC) activate MaliciousContract Note over MaliciousContract: Receive callback triggered MaliciousContract->>Blockchain: Call withdraw() AGAIN Blockchain->>VM: Execute nested call attempt VM->>Protected: withdraw() RE-ENTRY ATTEMPT activate Protected Protected->>Protected: onExecutionStarted hook Protected->>Guard: nonReentrantBefore() Guard->>Guard: Read _locked Note over Guard: _locked = true (LOCKED!) alt Lock check fails Guard->>Protected: REVERT('ReentrancyGuard: LOCKED') Protected->>VM: Revert transaction deactivate Protected VM->>MaliciousContract: Call reverted Note over MaliciousContract: ATTACK BLOCKED!<br/>No funds stolen end deactivate MaliciousContract Protected->>Protected: Continue execution Protected->>Protected: onExecutionCompleted hook Protected->>Guard: nonReentrantAfter() Guard->>Guard: Set _locked = false Note over Guard: LOCK RELEASED Protected->>VM: Return success deactivate Protected VM->>Blockchain: Commit state changes Note over Storage: Only 1 withdrawal processed Blockchain->>Attacker: Transaction success Note over Attacker: Received only 100 BTC<br/>Attack prevented! ``` ## Choosing a Guard Mode Use this decision diagram to select the appropriate reentrancy level: ```mermaid --- config: theme: dark --- flowchart LR A{External calls?} -->|No| B[No guard needed] A -->|Yes| C{Need depth tracking?} C -->|No| D[Use STANDARD mode] C -->|Yes| E[Use CALLBACK mode] ``` Note: Both modes block reentrancy. STANDARD uses a boolean lock; CALLBACK uses a depth counter. ## Guard Modes The following state diagram shows how the reentrancy lock transitions between states: ```mermaid --- config: theme: dark --- stateDiagram-v2 [*] --> Unlocked: Contract idle state "STANDARD Mode" as Standard { Unlocked --> Locked: onExecutionStarted Locked --> Unlocked: onExecutionCompleted Locked --> Reverted: Reentry attempt Reverted --> [*]: Transaction fails } state "CALLBACK Mode" as Callback { [*] --> Depth0 Depth0 --> Depth1: First call Depth1 --> Reverted2: Any reentry attempt Reverted2 --> [*]: Transaction fails Depth1 --> Depth0: First call completes } ``` ### STANDARD Mode Strict mutual exclusion - no re-entry allowed at all. ```typescript @final export class SecureVault extends ReentrancyGuard { protected readonly reentrancyLevel: ReentrancyLevel = ReentrancyLevel.STANDARD; public constructor() { super(); } @method({ name: 'amount', type: ABIDataTypes.UINT256 }) @returns({ name: 'success', type: ABIDataTypes.BOOL }) public deposit(calldata: Calldata): BytesWriter { // Cannot be re-entered // ... } @method() @returns({ name: 'success', type: ABIDataTypes.BOOL }) public withdraw(calldata: Calldata): BytesWriter { // Cannot be re-entered // deposit() also blocked while this runs // ... } } ``` **Use STANDARD when:** - Handling funds/assets - Complex multi-step operations - Any operation where re-entry could cause issues ### CALLBACK Mode Uses depth tracking instead of a simple boolean lock. Currently configured to reject any reentry (depth >= 1 triggers revert). ```typescript @final export class TokenWithCallbacks extends ReentrancyGuard { protected readonly reentrancyLevel: ReentrancyLevel = ReentrancyLevel.CALLBACK; public constructor() { super(); } @method( { name: 'from', type: ABIDataTypes.ADDRESS }, { name: 'to', type: ABIDataTypes.ADDRESS }, { name: 'tokenId', type: ABIDataTypes.UINT256 }, ) @returns({ name: 'success', type: ABIDataTypes.BOOL }) @emit('Transfer') public safeTransfer(calldata: Calldata): BytesWriter { // Transfer token this._transfer(from, to, tokenId); // Notify receiver (might call back) this.onTokenReceived(to, from, tokenId); // Note: With current implementation, any reentry is rejected return new BytesWriter(0); } } ``` **Use CALLBACK when:** - You need depth-based tracking instead of a simple boolean lock - You want differentiated error messages (Max depth exceeded vs LOCKED) - Note: Current implementation rejects any reentry at depth >= 1 ## How It Works ### Internal State The reentrancy guard uses a boolean lock and depth counter stored in storage: ```mermaid --- config: theme: dark --- stateDiagram-v2 [*] --> depth_0: Transaction starts depth_0 --> depth_1: Method entry (lock acquired) state depth_1 { [*] --> Executing Executing --> ExternalCall: Call other contract ExternalCall --> CallbackCheck: Contract calls back } depth_1 --> depth_0: Method exit (lock released) depth_1 --> Revert: Method exit (error) state "Callback Handling" as CallbackHandling { CallbackCheck --> Blocked: Both modes block reentry Blocked --> Revert } depth_0 --> [*]: Transaction ends Revert --> [*]: Transaction reverted ``` ```typescript // ReentrancyGuard uses storage for the lock state // _locked: StoredBoolean - tracks if guard is engaged // _reentrancyDepth: StoredU256 - tracks call depth in CALLBACK mode // The ReentrancyLevel enum (defined in ReentrancyGuard.ts): enum ReentrancyLevel { STANDARD = 0, // Strict single entry, uses boolean lock CALLBACK = 1 // Uses depth counter (still blocks reentrancy at depth >= 1) } ``` ### STANDARD Mode Logic ```typescript // On method entry (nonReentrantBefore): if (this._locked.value) { throw new Revert('ReentrancyGuard: LOCKED'); } this._locked.value = true; // ... execute method ... // On method exit (nonReentrantAfter): this._locked.value = false; ``` ### CALLBACK Mode Logic ```typescript // On method entry (nonReentrantBefore): const currentDepth = this._reentrancyDepth.value; // Maximum depth of 1 (original call only, rejects any callback reentry) if (currentDepth >= u256.One) { throw new Revert('ReentrancyGuard: Max depth exceeded'); } this._reentrancyDepth.value = SafeMath.add(currentDepth, u256.One); // Use locked flag for first entry if (currentDepth.isZero()) { this._locked.value = true; } // On method exit (nonReentrantAfter): const currentDepth = this._reentrancyDepth.value; if (currentDepth.isZero()) { throw new Revert('ReentrancyGuard: Depth underflow'); } const newDepth = SafeMath.sub(currentDepth, u256.One); this._reentrancyDepth.value = newDepth; // Clear locked flag when fully exited if (newDepth.isZero()) { this._locked.value = false; } ``` ## Usage Patterns ### Basic Protection ```typescript @final export class ProtectedContract extends ReentrancyGuard { protected readonly reentrancyLevel: ReentrancyLevel = ReentrancyLevel.STANDARD; public constructor() { super(); } @method() @returns({ name: 'success', type: ABIDataTypes.BOOL }) public sensitiveOperation(calldata: Calldata): BytesWriter { // All public methods are automatically protected // No additional code needed return new BytesWriter(0); } } ``` ### Combined with Other Bases ```typescript // ReentrancyGuard with OP20 @final export class SecureToken extends OP20 { // OP20 doesn't extend ReentrancyGuard // You need to implement protection manually private locked: bool = false; private nonReentrant(): void { if (this.locked) { throw new Revert('Reentrant call'); } this.locked = true; } private releaseGuard(): void { this.locked = false; } @method() @returns({ name: 'success', type: ABIDataTypes.BOOL }) public customWithdraw(calldata: Calldata): BytesWriter { this.nonReentrant(); try { // ... operation ... return new BytesWriter(0); } finally { this.releaseGuard(); } } } ``` ### Excluded Methods The base `ReentrancyGuard` automatically excludes standard token receiver callbacks from reentrancy checks: ```typescript // Built-in exclusions in ReentrancyGuard base class: // - ON_OP20_RECEIVED_SELECTOR // - ON_OP721_RECEIVED_SELECTOR ``` You can override `isSelectorExcluded` to add custom exclusions: ```typescript @final export class MyContract extends ReentrancyGuard { protected readonly reentrancyLevel: ReentrancyLevel = ReentrancyLevel.STANDARD; public constructor() { super(); } // Override to exclude specific selectors protected override isSelectorExcluded(selector: Selector): boolean { // Define selectors for view functions const BALANCE_OF_SELECTOR: u32 = encodeSelector('balanceOf'); const TOTAL_SUPPLY_SELECTOR: u32 = encodeSelector('totalSupply'); // View functions don't need protection if (selector === BALANCE_OF_SELECTOR) return true; if (selector === TOTAL_SUPPLY_SELECTOR) return true; return super.isSelectorExcluded(selector); } } ``` ## Solidity Comparison <table> <tr> <th>OpenZeppelin ReentrancyGuard</th> <th>OP_NET ReentrancyGuard</th> </tr> <tr> <td> ```solidity import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; contract MyContract is ReentrancyGuard { function withdraw() external nonReentrant { // Protected } function deposit() external { // NOT protected (no modifier) } } ``` </td> <td> ```typescript @final export class MyContract extends ReentrancyGuard { protected readonly reentrancyLevel: ReentrancyLevel = ReentrancyLevel.STANDARD; public constructor() { super(); } @method() @returns({ name: 'success', type: ABIDataTypes.BOOL }) public withdraw(calldata: Calldata): BytesWriter { // Protected automatically } @method({ name: 'amount', type: ABIDataTypes.UINT256 }) @returns({ name: 'success', type: ABIDataTypes.BOOL }) public deposit(calldata: Calldata): BytesWriter { // Also protected automatically } } ``` </td> </tr> </table> Key differences: - Solidity: Explicit `nonReentrant` modifier per function - OP_NET: All methods protected by default (opt-out via `isSelectorExcluded`) ## Best Practices ### 1. Use STANDARD Mode by Default ```typescript // Default to strictest protection protected readonly reentrancyLevel: ReentrancyLevel = ReentrancyLevel.STANDARD; // Only use CALLBACK when specifically needed protected readonly reentrancyLevel: ReentrancyLevel = ReentrancyLevel.CALLBACK; ``` ### 2. Follow Checks-Effects-Interactions Pattern Even with ReentrancyGuard, use this pattern: ```typescript @method({ name: 'amount', type: ABIDataTypes.UINT256 }) @returns({ name: 'success', type: ABIDataTypes.BOOL }) public withdraw(calldata: Calldata): BytesWriter { const amount = calldata.readU256(); // 1. CHECKS - Validate inputs if (amount.isZero()) { throw new Revert('Amount is zero'); } const balance = this.balances.get(Blockchain.tx.sender); if (balance < amount) { throw new Revert('Insufficient balance'); } // 2. EFFECTS - Update state this.balances.set(Blockchain.tx.sender, SafeMath.sub(balance, amount)); // 3. INTERACTIONS - External calls last this.sendFunds(Blockchain.tx.sender, amount); return new BytesWriter(0); } ``` ### 3. Protect All State-Changing Functions ```typescript // View functions can be excluded protected override isSelectorExcluded(selector: Selector): boolean { // Define selectors for read-only functions const BALANCE_OF_SELECTOR: u32 = encodeSelector('balanceOf'); const NAME_SELECTOR: u32 = encodeSelector('name'); const SYMBOL_SELECTOR: u32 = encodeSelector('symbol'); // Only exclude read-only functions if (selector === BALANCE_OF_SELECTOR) return true; if (selector === NAME_SELECTOR) return true; if (selector === SYMBOL_SELECTOR) return true; // All state-changing functions stay protected return false; } ``` ### 4. Be Careful with External Calls ```typescript // Both STANDARD and CALLBACK modes block reentrancy // Always update state before making external calls @method( { name: 'from', type: ABIDataTypes.ADDRESS }, { name: 'to', type: ABIDataTypes.ADDRESS }, { name: 'tokenId', type: ABIDataTypes.UINT256 }, ) @returns({ name: 'success', type: ABIDataTypes.BOOL }) @emit('Transfer') public safeTransfer(calldata: Calldata): BytesWriter { // Update state BEFORE external call this._transfer(from, to, tokenId); // External call - if it tries to re-enter, ReentrancyGuard blocks it this.notifyReceiver(to, from, tokenId); return new BytesWriter(0); } ``` ## Common Mistakes ### 1. Forgetting External Calls ```typescript // WRONG: Hidden external call public process(): void { oracle.updatePrice(); // This could call back! // ... } // CORRECT: Aware of all external interactions public process(): void { // ReentrancyGuard protects this oracle.updatePrice(); // Even if oracle calls back, it will revert } ``` ### 2. State Before Guard ```typescript // WRONG: State read before protection takes effect public getValue(): u256 { const value = storage.get(key); // Reads before guard return value; } // In OP_NET, the guard is checked at method entry, // so this isn't an issue - just be aware of it ``` ### 3. Over-Exclusion ```typescript // WRONG: Excluding too many functions protected override isSelectorExcluded(selector: Selector): boolean { const TRANSFER_SELECTOR: u32 = encodeSelector('transfer'); // DON'T exclude state-changing functions! if (selector === TRANSFER_SELECTOR) return true; // DANGEROUS return false; } ``` ## Testing Reentrancy ```typescript // Test contract that attempts reentrancy @final export class AttackerContract extends OP_NET { private targetContract: Address; private attackCount: u32 = 0; @method({ name: 'target', type: ABIDataTypes.ADDRESS }) @returns({ name: 'success', type: ABIDataTypes.BOOL }) public attack(calldata: Calldata): BytesWriter { this.targetContract = calldata.readAddress(); // Call target Blockchain.call(this.targetContract, encodeWithdraw(), true); return new BytesWriter(0); } // Called when receiving funds public onReceive(): void { if (this.attackCount < 10) { this.attackCount++; // Try to re-enter Blockchain.call(this.targetContract, encodeWithdraw(), false); // With ReentrancyGuard, this will fail } } } ``` --- **Navigation:** - Previous: [OP721 NFT](./op721-nft.md) - Next: [Address Type](../types/address.md)