@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
Markdown
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.
```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);
}
}
```
| 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
}
```
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]
```
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!
```
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.
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
}
```
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
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
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)
}
```
```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;
```
```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;
}
```
```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);
}
}
```
```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();
}
}
}
```
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);
}
}
```
<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;
```
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);
}
```
```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;
}
```
```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);
}
```
```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
}
```
```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
```
```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;
}
```
```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)