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.

530 lines (411 loc) 16.5 kB
# Contract Updates OP_NET provides a native bytecode replacement mechanism that allows contracts to update their execution logic while preserving their address and storage state. This guide covers the update mechanism, security considerations, and the timelock pattern for safe updates. ## Overview Unlike Ethereum's proxy patterns or Solana's upgrade authority model, OP_NET enables contracts to replace their own bytecode through a VM opcode. The mechanism uses an address-based replacement model where new bytecode is deployed to a temporary contract, and the target contract references that address to perform the update. ```typescript import { Blockchain } from '@btc-vision/btc-runtime/runtime'; // Deploy new bytecode as a separate contract first // Then update the current contract's bytecode Blockchain.updateContractFromExisting(newBytecodeAddress); // New bytecode takes effect at the next block ``` ## How It Works ```mermaid sequenceDiagram participant Owner as Contract Owner participant Target as Target Contract participant Source as Source Contract<br/>(New Bytecode) participant VM as OP_NET VM Note over Owner: Step 1: Deploy new bytecode Owner->>VM: Deploy new contract with updated code VM->>Source: Create contract at new address Note over Owner: Step 2: Call update function Owner->>Target: update(sourceAddress) Target->>Target: Validate permissions Target->>Target: Validate source is a contract Note over Target,VM: Step 3: Execute bytecode replacement Target->>VM: updateContractFromExisting(sourceAddress) VM->>Source: Read bytecode VM->>VM: Schedule replacement for next block Note over VM: Block boundary VM->>Target: Replace bytecode Note over Target: Step 4: New code active Owner->>Target: Any call now executes new bytecode ``` ## Basic Usage ### The updateContractFromExisting Method ```typescript /** * Updates this contract's bytecode from an existing deployed contract. * New bytecode takes effect at the next block. * * @param sourceAddress - Address of the contract containing the new bytecode * @param calldata - Optional calldata passed to the VM (default: empty) */ Blockchain.updateContractFromExisting( sourceAddress: Address, calldata?: BytesWriter | null ): void ``` ### Simple Update Function ```typescript import { OP_NET, Blockchain, Address, Calldata, BytesWriter, encodeSelector, Revert, } from '@btc-vision/btc-runtime/runtime'; @final export class MyContract extends OP_NET { public override execute(method: Selector, calldata: Calldata): BytesWriter { switch (method) { case encodeSelector('update(address)'): return this.update(calldata); default: return super.execute(method, calldata); } } private update(calldata: Calldata): BytesWriter { // Only deployer can update this.onlyDeployer(Blockchain.tx.sender); const sourceAddress = calldata.readAddress(); // Validate source is a deployed contract if (!Blockchain.isContract(sourceAddress)) { throw new Revert('Source must be a deployed contract'); } // Perform update - takes effect next block Blockchain.updateContractFromExisting(sourceAddress); return new BytesWriter(0); } } ``` ## The Timelock Pattern Immediate updates are risky because users have no time to react to potentially malicious changes. The timelock pattern addresses this by requiring a delay between submitting and applying an update. ### Why Use a Timelock? 1. **User Protection**: Users can monitor for pending updates and exit if they distrust changes 2. **Attack Prevention**: Prevents instant malicious updates 3. **Transparency**: All pending updates are visible on-chain ### Timelock Flow ```mermaid sequenceDiagram participant Owner as Contract Owner participant Contract as Updatable Contract participant Users as Users/Indexers Note over Owner: Day 1: Submit update Owner->>Contract: submitUpdate(sourceAddress) Contract->>Contract: Store pending address + block Contract-->>Users: Emit UpdateSubmitted event Note over Users: Users can monitor and exit Users->>Users: Review pending update Users->>Users: Exit if distrustful Note over Owner: Day 2+: Apply after delay Owner->>Contract: applyUpdate(sourceAddress, updateCalldata) Contract->>Contract: Verify delay elapsed Contract->>Contract: Verify address matches Contract->>Contract: Execute update Contract-->>Users: Emit UpdateApplied event ``` ### Using the Updatable Base Class ```typescript import { Updatable, Calldata, BytesWriter, encodeSelector, Selector, ADDRESS_BYTE_LENGTH, } from '@btc-vision/btc-runtime/runtime'; @final export class MyUpdatableContract extends Updatable { // Set update delay: 144 blocks = ~24 hours protected readonly updateDelay: u64 = 144; public override execute(method: Selector, calldata: Calldata): BytesWriter { switch (method) { case encodeSelector('submitUpdate'): return this.submitUpdate(calldata.readAddress()); case encodeSelector('applyUpdate'): { const sourceAddress = calldata.readAddress(); const updateCalldata = calldata.readBytesWithLength(); return this.applyUpdate(sourceAddress, updateCalldata); } case encodeSelector('cancelUpdate'): return this.cancelUpdate(); // ... other methods default: return super.execute(method, calldata); } } } ``` ### Using the UpdatablePlugin If you don't want to extend a base class (for example, if you're already extending `OP20` or `OP721`), use the `UpdatablePlugin` instead. The plugin system is fully automatic - just register the plugin in your constructor: ```typescript import { OP20, UpdatablePlugin, } from '@btc-vision/btc-runtime/runtime'; @final export class MyToken extends OP20 { public constructor() { super(); // Register the plugin - 144 blocks = ~24 hours (default) this.registerPlugin(new UpdatablePlugin(144)); } // No need to modify execute() - update methods are handled automatically! } ``` The plugin automatically handles these methods: - `submitUpdate(address)` - Submit update for timelock - `applyUpdate(address,bytes)` - Apply update after delay - `cancelUpdate()` - Cancel pending update - `pendingUpdate()` - Get pending update info - `updateDelay()` - Get configured delay **How it works:** When your contract's `execute()` falls through to `super.execute()`, the base `OP_NET` class automatically checks all registered plugins before throwing "Method not found". ### Common Delay Values | Delay | Blocks | Use Case | |-------|--------|----------| | ~1 hour | 6 | Emergency patches (not recommended for production) | | ~24 hours | 144 | Standard updates (default) | | ~1 week | 1008 | Critical infrastructure | | ~1 month | 4320 | Governance-controlled contracts | ### Update Events The `Updatable` contract emits events for monitoring: ```typescript // Emitted when an update is submitted class UpdateSubmittedEvent { sourceAddress: Address; // New bytecode contract submitBlock: u64; // Block when submitted effectiveBlock: u64; // Block when update can be applied } // Emitted when an update is applied class UpdateAppliedEvent { sourceAddress: Address; // New bytecode contract appliedAtBlock: u64; // Block when applied } // Emitted when a pending update is cancelled class UpdateCancelledEvent { sourceAddress: Address; // Cancelled source contract cancelledAtBlock: u64; // Block when cancelled } ``` ## Storage Compatibility Storage layout compatibility across bytecode versions is the developer's responsibility. The VM does not validate or migrate storage between versions. ### What Persists - Contract address (unchanged) - All storage slots (unchanged) - Contract deployer ### What Changes - Execution logic (bytecode) ### Storage Layout Rules ```typescript // Version 1 class MyContractV1 extends OP_NET { private balancePointer: u16 = Blockchain.nextPointer; // Pointer 1 private allowancePointer: u16 = Blockchain.nextPointer; // Pointer 2 } // Version 2 - CORRECT: Add new pointers at the end class MyContractV2 extends OP_NET { private balancePointer: u16 = Blockchain.nextPointer; // Pointer 1 (same) private allowancePointer: u16 = Blockchain.nextPointer; // Pointer 2 (same) private newFeaturePointer: u16 = Blockchain.nextPointer; // Pointer 3 (new) } // Version 2 - WRONG: Changing order breaks storage class MyContractV2Bad extends OP_NET { private newFeaturePointer: u16 = Blockchain.nextPointer; // Pointer 1 (was balance!) private balancePointer: u16 = Blockchain.nextPointer; // Pointer 2 (was allowance!) private allowancePointer: u16 = Blockchain.nextPointer; // Pointer 3 (new slot) } ``` ### Best Practices 1. **Never remove or reorder existing pointers** 2. **Always add new pointers at the end** 3. **Document pointer assignments** 4. **Test updates thoroughly on testnet** ## The onUpdate Lifecycle Hook When a contract's bytecode is updated via `updateContractFromExisting`, the VM calls the `onUpdate` hook on the **new** bytecode. This allows the new contract version to perform migrations, initialize new storage, or validate the update. ### Basic Usage ```typescript @final export class MyContractV2 extends OP_NET { // New storage pointer added in V2 private newFeaturePointer: u16 = Blockchain.nextPointer; private _newFeature: StoredU256; public constructor() { super(); this._newFeature = new StoredU256(this.newFeaturePointer, EMPTY_POINTER); } public override onUpdate(calldata: Calldata): void { super.onUpdate(calldata); // Call plugins first // Initialize new storage with default value if (this._newFeature.value === u256.Zero) { this._newFeature.value = u256.fromU64(100); } } } ``` ### Version-Based Migrations For complex migrations, pass a version number in the calldata: ```typescript public override onUpdate(calldata: Calldata): void { super.onUpdate(calldata); // Read migration version from calldata const fromVersion = calldata.readU64(); if (fromVersion === 1) { this.migrateFromV1(); } else if (fromVersion === 2) { this.migrateFromV2(); } } private migrateFromV1(): void { // Migration logic from V1 to V3 } private migrateFromV2(): void { // Migration logic from V2 to V3 } ``` Then pass the version when upgrading: ```typescript // In the update transaction const migrationData = new BytesWriter(8); migrationData.writeU64(1); // Migrating from version 1 Blockchain.updateContractFromExisting(newBytecodeAddress, migrationData); ``` ### Plugin Support Plugins can also implement `onUpdate` to perform their own migration logic: ```typescript class MyPlugin extends Plugin { public override onUpdate(calldata: Calldata): void { // Plugin-specific migration logic } } ``` ### Important Notes 1. **onUpdate runs on new bytecode**: The hook executes using the new contract's code, not the old one 2. **Storage is shared**: The new code reads/writes to the same storage slots as the old code 3. **Call super.onUpdate()**: Always call `super.onUpdate(calldata)` to ensure plugins are notified 4. **Empty calldata**: If no calldata was passed to `updateContractFromExisting`, an empty reader is provided ## Security Considerations ### Source Contract Validation Always validate that the source address is an existing deployed contract: ```typescript if (!Blockchain.isContract(sourceAddress)) { throw new Revert('Source must be a deployed contract'); } ``` This prevents an attacker from: 1. Submitting a not-yet-deployed address 2. Deploying malicious bytecode just before applying 3. Front-running the update ### Address Verification at Apply The `applyUpdate` function requires the address parameter to match the pending update: ```typescript if (!sourceAddress.equals(this.pendingUpdateAddress)) { throw new Revert('Address does not match pending update'); } ``` This prevents front-running attacks where an attacker tries to substitute a different contract. ### Permission Model The VM does not enforce any specific permission model. Implement appropriate access control: ```typescript // Simple: Deployer only this.onlyDeployer(Blockchain.tx.sender); // Advanced: Multisig or governance if (!this.isAuthorizedUpdater(Blockchain.tx.sender)) { throw new Revert('Not authorized'); } ``` ### Activation Boundary When `updateContractFromExisting` is called: - Transactions in the **same block** execute against **old bytecode** - Transactions in **subsequent blocks** execute against **new bytecode** This provides a clean transition with no mid-block ambiguity. ## Comparison with Other Platforms | Feature | OP_NET | Ethereum | Solana | |---------|-------|----------|--------| | Mechanism | VM opcode | Proxy + delegatecall | Upgrade authority | | Delay | Contract-level (recommended) | Contract-level only | None (instant) | | Storage | Same address, slots persist | Proxy storage, collision risk | Account data persists | | Permission | Contract-defined | Proxy admin | Single authority key | | Complexity | Low (single opcode) | High (proxy patterns) | Low (simple authority) | ## Complete Example ```typescript import { Updatable, Blockchain, Calldata, BytesWriter, encodeSelector, Selector, StoredU256, EMPTY_POINTER, ADDRESS_BYTE_LENGTH, } from '@btc-vision/btc-runtime/runtime'; @final export class UpdatableVault extends Updatable { // 1-week update delay for security protected readonly updateDelay: u64 = 1008; // Storage pointers - never reorder these private totalDepositedPointer: u16 = Blockchain.nextPointer; private totalDeposited: StoredU256; public constructor() { super(); this.totalDeposited = new StoredU256( this.totalDepositedPointer, EMPTY_POINTER ); } public override execute(method: Selector, calldata: Calldata): BytesWriter { switch (method) { // Update methods case encodeSelector('submitUpdate'): return this.submitUpdate(calldata.readAddress()); case encodeSelector('applyUpdate'): { const sourceAddress = calldata.readAddress(); const updateCalldata = calldata.readBytesWithLength(); return this.applyUpdate(sourceAddress, updateCalldata); } case encodeSelector('cancelUpdate'): return this.cancelUpdate(); // View methods for update status case encodeSelector('pendingUpdate'): return this.getPendingUpdate(); case encodeSelector('updateEffectiveBlock'): return this.getUpdateEffectiveBlock(); // Business logic case encodeSelector('deposit'): return this.deposit(calldata); default: return super.execute(method, calldata); } } private getPendingUpdate(): BytesWriter { const response = new BytesWriter(40); response.writeAddress(this.pendingUpdateAddress); response.writeU64(this.pendingUpdateBlock); return response; } private getUpdateEffectiveBlock(): BytesWriter { const response = new BytesWriter(8); response.writeU64(this.updateEffectiveBlock); return response; } private deposit(calldata: Calldata): BytesWriter { // Business logic... return new BytesWriter(0); } } ``` ## Update Workflow 1. **Develop and test new version** on testnet 2. **Deploy new bytecode** as a separate contract 3. **Submit update** with `submitUpdate(newAddress)` 4. **Wait for delay** (users can exit during this period) 5. **Apply update** with `applyUpdate(newAddress, updateCalldata)` 6. **Verify** new functionality works correctly --- **Navigation:** - Previous: [Bitcoin Scripts](./bitcoin-scripts.md) - Next: [Plugins](./plugins.md)