diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..13351d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,90 @@ +name: Bug Report +description: Report a bug in unit-test-framework +title: "[Bug]: " +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug. + + **Security Notice**: If this is a security vulnerability, DO NOT create a public issue. Report it privately via [GitHub Security Advisories](https://github.com/btc-vision/unit-test-framework/security/advisories/new). + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of the bug. + placeholder: Describe the bug... + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? + validations: + required: true + + - type: input + id: version + attributes: + label: unit-test-framework Version + description: What version of @btc-vision/unit-test-framework are you using? + placeholder: "1.0.0-alpha.0" + validations: + required: true + + - type: input + id: node-version + attributes: + label: Node.js Version + description: What version of Node.js are you using? + placeholder: "22.0.0" + validations: + required: true + + - type: textarea + id: code + attributes: + label: Code Example + description: Minimal code that reproduces the issue. + render: typescript + + - type: textarea + id: logs + attributes: + label: Relevant Logs + description: Please copy and paste any relevant log output. + render: shell + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched existing issues to ensure this bug hasn't been reported + required: true + - label: This is NOT a security vulnerability (those should be reported privately) + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..224bfcb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Security Vulnerability + url: https://github.com/btc-vision/unit-test-framework/security/advisories/new + about: Report a security vulnerability privately. DO NOT create a public issue for security vulnerabilities. + - name: OPNet Website + url: https://opnet.org/ + about: Visit the OPNet homepage for documentation and support. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..36014cd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'enhancement' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f509082 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,43 @@ +## Description + + +## Type of Change + + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Performance improvement +- [ ] Refactoring (no functional changes) +- [ ] Documentation update +- [ ] CI/CD changes +- [ ] Dependencies update + +## Checklist + +### Build & Tests +- [ ] `npm install` completes without errors +- [ ] `npm test` passes all tests + +### Code Quality +- [ ] Code follows the project's coding standards +- [ ] No new compiler warnings introduced +- [ ] Error handling is appropriate + +### Documentation +- [ ] Code comments added for complex logic +- [ ] Public APIs are documented +- [ ] README updated (if applicable) + +### Security +- [ ] No sensitive data (keys, credentials) committed +- [ ] No new security vulnerabilities introduced + +## Testing + + +## Related Issues + + +--- +By submitting this PR, I confirm that my contribution is made under the terms of the project's license. diff --git a/.github/changelog-config.json b/.github/changelog-config.json new file mode 100644 index 0000000..96ccf37 --- /dev/null +++ b/.github/changelog-config.json @@ -0,0 +1,49 @@ +{ + "categories": [ + { + "title": "### Breaking Changes", + "labels": ["breaking", "breaking-change", "BREAKING-CHANGE"] + }, + { + "title": "### Features", + "labels": ["feature", "feat", "enhancement"] + }, + { + "title": "### Bug Fixes", + "labels": ["bug", "fix", "bugfix"] + }, + { + "title": "### Security", + "labels": ["security"] + }, + { + "title": "### Performance", + "labels": ["performance", "perf"] + }, + { + "title": "### Documentation", + "labels": ["documentation", "docs"] + }, + { + "title": "### Dependencies", + "labels": ["dependencies", "deps"] + }, + { + "title": "### Other Changes", + "labels": [] + } + ], + "sort": { + "order": "ASC", + "on_property": "mergedAt" + }, + "template": "#{{CHANGELOG}}", + "pr_template": "- #{{TITLE}} (#{{NUMBER}}) by @#{{AUTHOR}}", + "empty_template": "- No changes", + "max_tags_to_fetch": 200, + "max_pull_requests": 200, + "max_back_track_time_days": 365, + "tag_resolver": { + "method": "semver" + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4ea7b5..1d19779 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,12 @@ -name: CI for UNIT-TEST-FRAMEWORK +name: CI for Unit Test Framework on: push: tags: - - '*' + - 'v*' jobs: - build_and_publish: + publish: runs-on: ubuntu-latest permissions: @@ -15,10 +15,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '24.x' registry-url: 'https://registry.npmjs.org' @@ -26,22 +28,176 @@ jobs: - name: Update npm run: npm install -g npm@latest + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Install dependencies run: npm install - name: Build the project run: npm run build + - name: Build test contracts + run: npm run build:test-contract + + - name: Run tests + run: npm test + + - name: Determine npm tag + id: npm_tag + run: | + VERSION="${{ github.ref_name }}" + if [[ "$VERSION" == *"-alpha"* ]]; then + echo "tag=alpha" >> $GITHUB_OUTPUT + elif [[ "$VERSION" == *"-beta"* ]]; then + echo "tag=beta" >> $GITHUB_OUTPUT + elif [[ "$VERSION" == *"-rc"* ]]; then + echo "tag=rc" >> $GITHUB_OUTPUT + else + echo "tag=latest" >> $GITHUB_OUTPUT + fi + echo "Determined npm tag: $(cat $GITHUB_OUTPUT | grep tag | cut -d= -f2)" + - name: Publish to npm - run: npm publish --access public + run: npm publish --access public --tag ${{ steps.npm_tag.outputs.tag }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + release: + runs-on: ubuntu-latest + needs: publish + + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Generate changelog + id: changelog + uses: mikepenz/release-changelog-builder-action@v6 + with: + configurationJson: | + { + "categories": [ + { + "title": "### Breaking Changes", + "labels": ["breaking", "breaking-change", "BREAKING-CHANGE"] + }, + { + "title": "### Features", + "labels": ["feature", "feat", "enhancement"] + }, + { + "title": "### Bug Fixes", + "labels": ["bug", "fix", "bugfix"] + }, + { + "title": "### Security", + "labels": ["security"] + }, + { + "title": "### Performance", + "labels": ["performance", "perf"] + }, + { + "title": "### Documentation", + "labels": ["documentation", "docs"] + }, + { + "title": "### Dependencies", + "labels": ["dependencies", "deps"] + }, + { + "title": "### Other Changes", + "labels": [] + } + ], + "sort": { + "order": "ASC", + "on_property": "mergedAt" + }, + "template": "#{{CHANGELOG}}", + "pr_template": "- #{{TITLE}} ([##{{NUMBER}}](#{{URL}})) by @#{{AUTHOR}}", + "empty_template": "- No changes", + "max_tags_to_fetch": 200, + "max_pull_requests": 200, + "max_back_track_time_days": 365, + "tag_resolver": { + "method": "semver" + } + } + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update CHANGELOG.md + env: + CHANGELOG_CONTENT: ${{ steps.changelog.outputs.changelog }} + run: | + TAG="${{ github.ref_name }}" + DATE=$(date +%Y-%m-%d) + NEW_HEADER="## [$TAG] - $DATE" + + # Write changelog content to temp file using env var (avoids shell escaping) + printf '%s\n' "$CHANGELOG_CONTENT" > /tmp/new_entry.md + + if [ -f CHANGELOG.md ]; then + # Create new file with entry inserted after header line + { + head -n 1 CHANGELOG.md + echo "" + echo "$NEW_HEADER" + echo "" + cat /tmp/new_entry.md + echo "" + tail -n +2 CHANGELOG.md + } > /tmp/CHANGELOG_NEW.md + mv /tmp/CHANGELOG_NEW.md CHANGELOG.md + else + # Create new CHANGELOG.md + { + echo "# Changelog" + echo "" + echo "All notable changes to this project will be documented in this file." + echo "" + echo "This changelog is automatically generated from merged pull requests." + echo "" + echo "$NEW_HEADER" + echo "" + cat /tmp/new_entry.md + } > CHANGELOG.md + fi + + rm -f /tmp/new_entry.md + + - name: Commit changelog + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git diff --staged --quiet || git commit -m "docs: update CHANGELOG.md for ${{ github.ref_name }}" + git push origin HEAD:main || echo "No changes to push or push failed" + + - name: Determine if prerelease + id: prerelease + run: | + VERSION="${{ github.ref_name }}" + if [[ "$VERSION" == *"-alpha"* ]] || [[ "$VERSION" == *"-beta"* ]] || [[ "$VERSION" == *"-rc"* ]]; then + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi - name: Create GitHub Release - uses: actions/create-release@v1 + uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} - release_name: Release ${{ github.ref_name }} - body: | - Automated release for UNIT-TEST-FRAMEWORK. + name: Release ${{ github.ref_name }} + body: ${{ steps.changelog.outputs.changelog }} + prerelease: ${{ steps.prerelease.outputs.is_prerelease == 'true' }} + generate_release_notes: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..e7f1d90 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,44 @@ +name: PR Validation + +permissions: + contents: read + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Update npm + run: npm install -g npm@latest + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: npm install + + - name: Build the project + run: npm run build + + - name: Build test contracts + run: npm run build:test-contract + + - name: Run tests + run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cb85314 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +This changelog is automatically generated from merged pull requests. diff --git a/README.md b/README.md index 97c0210..b10db2e 100644 --- a/README.md +++ b/README.md @@ -1,270 +1,150 @@ -# OP_NET Smart Contract Testing Framework +# OPNet Unit Test Framework ![Bitcoin](https://img.shields.io/badge/Bitcoin-000?style=for-the-badge&logo=bitcoin&logoColor=white) +![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) ![AssemblyScript](https://img.shields.io/badge/assembly%20script-%23000000.svg?style=for-the-badge&logo=assemblyscript&logoColor=white) ![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) -![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) ![NodeJS](https://img.shields.io/badge/Node%20js-339933?style=for-the-badge&logo=nodedotjs&logoColor=white) ![NPM](https://img.shields.io/badge/npm-CB3837?style=for-the-badge&logo=npm&logoColor=white) -![Gulp](https://img.shields.io/badge/GULP-%23CF4647.svg?style=for-the-badge&logo=gulp&logoColor=white) -![ESLint](https://img.shields.io/badge/ESLint-4B3263?style=for-the-badge&logo=eslint&logoColor=white) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) -This repository provides a robust framework for developing and testing smart contracts on the OPNet blockchain. The -framework includes essential tools and guidelines for ensuring your contracts are functional, secure, and performant. - -## Introduction - -The **OP_NET Smart Contract Testing Framework** is designed to facilitate the development and testing of smart -contracts. It includes utilities, test cases, and a structured environment to ensure that your contracts -work as intended under various conditions. - -## Requirements - -Ensure the following are installed before using the framework: - -- [Node.js](https://nodejs.org/) -- [npm](https://www.npmjs.com/) or [Yarn](https://yarnpkg.com/) -- [TypeScript](https://www.typescriptlang.org/) -- [Rust](https://www.rust-lang.org/) +The official unit testing framework for OPNet smart contracts. Test your AssemblyScript contracts against the real OP_VM runtime with full TypeScript support, gas metering, state management, and built-in helpers for OP20/OP721 tokens. ## Installation -Clone the repository and install the dependencies: - ```bash -git clone https://github.com/@btc-vision/unit-test-framework.git -cd unit-test-framework -npm install +npm install @btc-vision/unit-test-framework ``` -## Compiling Contracts and Tests +## Documentation -Before running the tests, you need to compile your contracts and test files. Use the following command: +Check out the full documentation in [`/docs`](./docs)! -```bash -npm run build -``` +- [Getting Started](./docs/getting-started/quick-start.md) - Installation & first test +- [Writing Tests](./docs/writing-tests/basic-tests.md) - Test patterns & lifecycle +- [Built-in Contracts](./docs/built-in-contracts/op20.md) - OP20, OP721, OP721Extended helpers +- [Assertions](./docs/api-reference/assertions.md) - Static & fluent assertion API +- [Blockchain API](./docs/api-reference/blockchain.md) - Blockchain simulator +- [Contract Runtime](./docs/api-reference/contract-runtime.md) - Custom contract wrappers +- [Advanced Topics](./docs/advanced/cross-contract-calls.md) - Upgrades, signatures, gas profiling, consensus rules +- [Examples](./docs/examples/nativeswap-testing.md) - Real-world test examples +- [API Reference](./docs/api-reference/types-interfaces.md) - Full type reference -Or, alternatively: +## Quick Start -```bash -gulp -``` - -This will compile your TypeScript files into JavaScript, and the output will be located in the `build/` directory. +```typescript +import { opnet, OPNetUnit, Assert, Blockchain, OP20 } from '@btc-vision/unit-test-framework'; +import { Address } from '@btc-vision/transaction'; -### Advanced Compilation +await opnet('My Token Tests', async (vm: OPNetUnit) => { + let token: OP20; -For more advanced documentation please click [here](docs/README.md). + const deployer: Address = Blockchain.generateRandomAddress(); + const receiver: Address = Blockchain.generateRandomAddress(); -### Example Test File + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); -Here's an example of what your test file might look like: + token = new OP20({ + address: Blockchain.generateRandomAddress(), + deployer: deployer, + file: './path/to/MyToken.wasm', + decimals: 18, + }); -```typescript -import { opnet, OPNetUnit } from '../opnet/unit/OPNetUnit.js'; -import { Assert } from '../opnet/unit/Assert.js'; -import { MyCustomContract } from '../contracts/MyCustomContract.ts'; + Blockchain.register(token); + await token.init(); -await opnet('MyCustomContract Tests', async (vm: OPNetUnit) => { - vm.beforeEach(async () => { - // Initialize your contract here... + Blockchain.msgSender = deployer; + Blockchain.txOrigin = deployer; }); - vm.afterEach(async () => { - // Clean up after each test... + vm.afterEach(() => { + token.dispose(); + Blockchain.dispose(); }); - await vm.it('should correctly execute a function', async () => { - // Your test logic here... - Assert.expect(someValue).toEqual(expectedValue); + await vm.it('should mint tokens', async () => { + await token.mint(receiver, 1000); + + const balance = await token.balanceOf(receiver); + Assert.expect(balance).toEqual(Blockchain.expandToDecimal(1000, 18)); }); }); ``` -## Example Contracts - -Here's an example of a basic contract that users must implement to interact with their own contracts: - -```typescript -import { CallResponse, ContractRuntime } from '../opnet/modules/ContractRuntime.js'; -import { Address, BinaryReader, BinaryWriter } from '@btc-vision/transaction'; +Run with: -export class MyCustomContract extends ContractRuntime { - // Implementation details... -} +```bash +npx tsx test/my-token.test.ts ``` -### Contract Implementation Example +## Requirements -Let's create a simple token contract that follows the OP_20 standard (similar to ERC20 in Ethereum). This contract will -allow minting, transferring, and checking the balance of tokens. +- Node.js >= 22 +- Rust toolchain (for building `@btc-vision/op-vm`) -**File: `/src/contracts/SimpleToken.ts`** +## Development -```typescript -import { ContractRuntime, CallResponse } from '../opnet/modules/ContractRuntime.js'; -import { Address, BinaryReader, BinaryWriter } from '@btc-vision/transaction'; -import { Blockchain } from '../blockchain/Blockchain.js'; - -export class SimpleToken extends ContractRuntime { - private readonly mintSelector: number = Number(`0x${this.abiCoder.encodeSelector('mint()')}`); - private readonly transferSelector: number = Number(`0x${this.abiCoder.encodeSelector('transfer()')}`); - private readonly balanceOfSelector: number = Number(`0x${this.abiCoder.encodeSelector('balanceOf()')}`); - - constructor( - address: Address, - public readonly decimals: number, - gasLimit: bigint = 300_000_000_000n, - ) { - super(address, 'bcrt1pe0slk2klsxckhf90hvu8g0688rxt9qts6thuxk3u4ymxeejw53gs0xjlhn', gasLimit); - this.preserveState(); - } - - public async mint(to: Address, amount: bigint): Promise { - const calldata = new BinaryWriter(); - calldata.writeAddress(to); - calldata.writeU256(amount); - - const result = await this.readMethod( - this.mintSelector, - Buffer.from(calldata.getBuffer()), - this.deployer, - this.deployer, - ); - - if (!result.response) { - this.dispose(); - throw result.error; - } - - const reader = new BinaryReader(result.response); - if (!reader.readBoolean()) { - throw new Error('Mint failed'); - } - } - - public async transfer(from: Address, to: Address, amount: bigint): Promise { - const calldata = new BinaryWriter(); - calldata.writeAddress(to); - calldata.writeU256(amount); - - const result = await this.readMethod( - this.transferSelector, - Buffer.from(calldata.getBuffer()), - from, - from, - ); - - if (!result.response) { - this.dispose(); - throw result.error; - } - - const reader = new BinaryReader(result.response); - if (!reader.readBoolean()) { - throw new Error('Transfer failed'); - } - } - - public async balanceOf(owner: Address): Promise { - const calldata = new BinaryWriter(); - calldata.writeAddress(owner); - - const result = await this.readMethod( - this.balanceOfSelector, - Buffer.from(calldata.getBuffer()), - ); - - if (!result.response) { - this.dispose(); - throw result.error; - } - - const reader = new BinaryReader(result.response); - return reader.readU256(); - } -} +```bash +git clone https://github.com/btc-vision/unit-test-framework.git +cd unit-test-framework +npm install +npm run build ``` -### Unit Test Example - -Now let's create a unit test for the `SimpleToken` contract. We'll test minting tokens, transferring tokens, and -checking the balance. - -**File: `/src/tests/simpleTokenTest.ts`** - -```typescript -import { opnet, OPNetUnit } from '../opnet/unit/OPNetUnit.js'; -import { Assert } from '../opnet/unit/Assert.js'; -import { Blockchain } from '../blockchain/Blockchain.js'; -import { SimpleToken } from '../contracts/SimpleToken.js'; -import { Address } from '@btc-vision/transaction'; - -const decimals = 18; -const totalSupply = 1000000n * (10n ** BigInt(decimals)); -const deployer: Address = Blockchain.generateRandomAddress(); -const receiver: Address = Blockchain.generateRandomAddress(); - -await opnet('SimpleToken Contract', async (vm: OPNetUnit) => { - let token: SimpleToken; - - vm.beforeEach(async () => { - Blockchain.dispose(); - token = new SimpleToken(deployer, decimals); - Blockchain.register(token); - - await Blockchain.init(); - }); +### Running Tests - vm.afterEach(async () => { - token.dispose(); - }); +```bash +# Run the full test suite (builds test contracts + runs all tests) +npm test + +# Run a specific test file +npm run test:update-from-address +npm run test:storage +npm run test:gas +npm run test:ecdsa +``` - await vm.it('should mint tokens correctly', async () => { - await token.mint(receiver, totalSupply); +### Test Contracts - const balance = await token.balanceOf(receiver); - Assert.expect(balance).toEqual(totalSupply); - }); +End-to-end test contracts live in `test/e2e/contracts/`. Each contract has: +- `contract/` - AssemblyScript source and `asconfig.json` +- `runtime/` - TypeScript `ContractRuntime` wrapper - await vm.it('should transfer tokens correctly', async () => { - await token.mint(deployer, totalSupply); +Build test contracts separately: - const transferAmount = 100000n * (10n ** BigInt(decimals)); - await token.transfer(deployer, receiver, transferAmount); +```bash +npm run build:test-contract +``` - const balanceDeployer = await token.balanceOf(deployer); - const balanceReceiver = await token.balanceOf(receiver); +## Contributing - Assert.expect(balanceDeployer).toEqual(totalSupply - transferAmount); - Assert.expect(balanceReceiver).toEqual(transferAmount); - }); +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: `npm test` +5. Submit a pull request - await vm.it('should return correct balances', async () => { - await token.mint(receiver, totalSupply); +## Reporting Issues - const balance = await token.balanceOf(receiver); - Assert.expect(balance).toEqual(totalSupply); +- **Bugs**: Open an issue on GitHub +- **Security**: See [SECURITY.md](./SECURITY.md) - do not open public issues for vulnerabilities - const balanceDeployer = await token.balanceOf(deployer); - Assert.expect(balanceDeployer).toEqual(0n); - }); -}); -``` +## Changelog -## Contributing +See [CHANGELOG.md](./CHANGELOG.md) for release history. -Contributions are welcome! To contribute: +## License -1. Fork the repository. -2. Create a new branch (`git checkout -b feature/your-feature`). -3. Commit your changes (`git commit -am 'Add new feature'`). -4. Push to the branch (`git push origin feature/your-feature`). -5. Open a Pull Request. +[Apache-2.0](LICENSE) -## License +## Links -This project is licensed under the Apache-2.0 License - see the [LICENSE](LICENSE) file for details. +- [OPNet](https://opnet.org) +- [Documentation](./docs/) +- [GitHub](https://github.com/btc-vision/unit-test-framework) +- [npm](https://www.npmjs.com/package/@btc-vision/unit-test-framework) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..671d122 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,39 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in the OPNet Unit Test Framework, **please do NOT open a public issue**. + +Instead, report it privately through one of these channels: + +1. **GitHub Security Advisories** (preferred): [Report a vulnerability](https://github.com/btc-vision/unit-test-framework/security/advisories/new) +2. **Email**: security@opnet.org + +### What to include + +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +### Response timeline + +- **Acknowledgment**: Within 48 hours +- **Initial assessment**: Within 1 week +- **Fix and disclosure**: Coordinated with reporter + +## Scope + +This policy covers: + +- The `@btc-vision/unit-test-framework` npm package +- The OP_VM integration layer (`ContractRuntime`, `RustContract`) +- State management (`StateHandler`, `BytecodeManager`) +- Gas accounting and consensus rule enforcement +- Contract upgrade mechanisms (`updateFromAddress`, `applyPendingBytecodeUpgrade`) + +## Out of Scope + +- Vulnerabilities in dependencies (report to the respective project) +- Issues in the Rust `@btc-vision/op-vm` crate (report to [op-vm](https://github.com/btc-vision/op-vm)) +- Issues in `@btc-vision/btc-runtime` (report to [btc-runtime](https://github.com/btc-vision/btc-runtime)) diff --git a/docs/README.md b/docs/README.md index b4f608d..4e38b43 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,46 @@ -# Documentation Index +# OPNet Unit Test Framework Documentation -## Table of Contents +Welcome to the documentation for `@btc-vision/unit-test-framework`. This framework lets you test OPNet smart contracts against the real OP_VM runtime in a local environment. -OUTDATED +## Documentation Structure + +### Getting Started + +- [Installation](./getting-started/installation.md) - Setup and requirements +- [Quick Start](./getting-started/quick-start.md) - Write your first test + +### Writing Tests + +- [Basic Tests](./writing-tests/basic-tests.md) - Test structure and lifecycle hooks +- [OP20 Token Tests](./writing-tests/op20-tokens.md) - Testing fungible tokens +- [OP721 NFT Tests](./writing-tests/op721-nfts.md) - Testing non-fungible tokens +- [Custom Contracts](./writing-tests/custom-contracts.md) - Building typed contract wrappers + +### Built-in Contract Helpers + +- [OP20](./built-in-contracts/op20.md) - Fungible token helper +- [OP721](./built-in-contracts/op721.md) - NFT helper +- [OP721Extended](./built-in-contracts/op721-extended.md) - Extended NFT with reservation minting + +### Advanced Topics + +- [Cross-Contract Calls](./advanced/cross-contract-calls.md) - Multi-contract testing +- [Upgradeable Contracts](./advanced/upgradeable-contracts.md) - Testing contract upgrades +- [Transaction Simulation](./advanced/transaction-simulation.md) - Bitcoin transaction inputs/outputs +- [Signature Verification](./advanced/signature-verification.md) - ML-DSA, Schnorr, ECDSA +- [State Management](./advanced/state-management.md) - State overrides and block replay +- [Gas Profiling](./advanced/gas-profiling.md) - Measuring gas consumption +- [Consensus Rules](./advanced/consensus-rules.md) - Configuring consensus flags + +### Examples + +- [NativeSwap Testing](./examples/nativeswap-testing.md) - Complex DeFi contract tests +- [Block Replay](./examples/block-replay.md) - Replaying mainnet transactions + +### API Reference + +- [Assertions](./api-reference/assertions.md) - Assert & Assertion classes +- [Blockchain](./api-reference/blockchain.md) - Blockchain singleton +- [Contract Runtime](./api-reference/contract-runtime.md) - ContractRuntime base class +- [Types & Interfaces](./api-reference/types-interfaces.md) - All exported types +- [Utilities](./api-reference/utilities.md) - Helper functions and constants diff --git a/docs/advanced/consensus-rules.md b/docs/advanced/consensus-rules.md new file mode 100644 index 0000000..fae6193 --- /dev/null +++ b/docs/advanced/consensus-rules.md @@ -0,0 +1,171 @@ +# Consensus Rules + +Configure consensus flags that affect VM behavior during tests. + +--- + +## ConsensusRules Class + +`ConsensusRules` is a bitfield class for manipulating consensus flags: + +```typescript +import { ConsensusRules, ConsensusManager } from '@btc-vision/unit-test-framework'; +``` + +### Available Flags + +| Flag | Value | Description | +|------|-------|-------------| +| `NONE` | `0b00000000` | No flags | +| `ALLOW_CLASSICAL_SIGNATURES` | `0b00000001` | Allow Schnorr/ECDSA (non-quantum) | +| `UPDATE_CONTRACT_BY_ADDRESS` | `0b00000010` | Allow contract upgrades | +| `RESERVED_FLAG_2` | `0b00000100` | Reserved for future use | + +### Creating Rules + +```typescript +// Empty rules +const rules = ConsensusRules.new(); + +// From bigint +const rules = ConsensusRules.fromBigint(0b00000011n); + +// Combine multiple flags +const rules = ConsensusRules.combine([ + ConsensusRules.ALLOW_CLASSICAL_SIGNATURES, + ConsensusRules.UPDATE_CONTRACT_BY_ADDRESS, +]); +``` + +### Manipulating Flags + +```typescript +const rules = ConsensusRules.new(); + +// Insert a flag +rules.insertFlag(ConsensusRules.ALLOW_CLASSICAL_SIGNATURES); + +// Check a flag +rules.containsFlag(ConsensusRules.ALLOW_CLASSICAL_SIGNATURES); // true + +// Remove a flag +rules.removeFlag(ConsensusRules.ALLOW_CLASSICAL_SIGNATURES); + +// Toggle a flag +rules.toggleFlag(ConsensusRules.UPDATE_CONTRACT_BY_ADDRESS); + +// Set conditionally +rules.setFlag(ConsensusRules.ALLOW_CLASSICAL_SIGNATURES, true); + +// Check if signatures are allowed +rules.unsafeSignaturesAllowed(); // true if ALLOW_CLASSICAL_SIGNATURES is set +``` + +### Set Operations + +```typescript +const a = ConsensusRules.combine([ConsensusRules.ALLOW_CLASSICAL_SIGNATURES]); +const b = ConsensusRules.combine([ConsensusRules.UPDATE_CONTRACT_BY_ADDRESS]); + +const union = a.union(b); +const intersection = a.intersection(b); +const difference = a.difference(b); +const complement = a.complement(); + +// Check relationships +a.intersects(b); // false +a.isEmpty(); // false +a.equals(b); // false +``` + +### Serialization + +```typescript +const bigint = rules.asBigInt(); +const bytes = rules.toBeBytes(); +const binary = rules.toBinaryString(); +const clone = rules.clone(); +``` + +--- + +## ConsensusManager + +Global singleton for managing active consensus flags: + +```typescript +import { ConsensusManager } from '@btc-vision/unit-test-framework'; + +// Reset to defaults (ALLOW_CLASSICAL_SIGNATURES + UPDATE_CONTRACT_BY_ADDRESS) +ConsensusManager.default(); + +// Get current flags +const flags = ConsensusManager.getFlags(); +``` + +--- + +## Consensus Configuration + +Access the full consensus object: + +```typescript +import { configs } from '@btc-vision/unit-test-framework'; + +const consensus = configs.CONSENSUS; + +// Gas configuration +consensus.GAS.TRANSACTION_MAX_GAS; +consensus.GAS.EMULATION_MAX_GAS; +consensus.GAS.TARGET_GAS; +consensus.GAS.SAT_TO_GAS_RATIO; +consensus.GAS.PANIC_GAS_COST; +consensus.GAS.COST.COLD_STORAGE_LOAD; + +// Transaction limits +consensus.TRANSACTIONS.MAXIMUM_CALL_DEPTH; +consensus.TRANSACTIONS.MAXIMUM_DEPLOYMENT_DEPTH; +consensus.TRANSACTIONS.REENTRANCY_GUARD; +consensus.TRANSACTIONS.STORAGE_COST_PER_BYTE; + +// Event limits +consensus.TRANSACTIONS.EVENTS.MAXIMUM_EVENT_LENGTH; +consensus.TRANSACTIONS.EVENTS.MAXIMUM_TOTAL_EVENT_LENGTH; +consensus.TRANSACTIONS.EVENTS.MAXIMUM_EVENT_NAME_LENGTH; + +// Contract limits +consensus.CONTRACTS.MAXIMUM_CONTRACT_SIZE_COMPRESSED; +consensus.CONTRACTS.MAXIMUM_CALLDATA_SIZE_COMPRESSED; + +// VM / UTXO config +consensus.VM.UTXOS.MAXIMUM_INPUTS; +consensus.VM.UTXOS.MAXIMUM_OUTPUTS; +consensus.VM.UTXOS.OP_RETURN.ENABLED; +consensus.VM.UTXOS.OP_RETURN.MAXIMUM_SIZE; + +// Network +consensus.NETWORK.MAXIMUM_TRANSACTION_BROADCAST_SIZE; +``` + +--- + +## Using in Tests + +```typescript +import { configs } from '@btc-vision/unit-test-framework'; +const { CONSENSUS } = configs; + +await vm.it('should respect max call depth', async () => { + // Should succeed at max depth - 1 + await contract.recursiveCall(CONSENSUS.TRANSACTIONS.MAXIMUM_CALL_DEPTH - 1); + + // Should fail at max depth + await Assert.expect(async () => { + await contract.recursiveCall(CONSENSUS.TRANSACTIONS.MAXIMUM_CALL_DEPTH); + }).toThrow(); +}); +``` + +--- + +[<- Previous: Gas Profiling](./gas-profiling.md) | [Next: NativeSwap Testing ->](../examples/nativeswap-testing.md) diff --git a/docs/advanced/cross-contract-calls.md b/docs/advanced/cross-contract-calls.md new file mode 100644 index 0000000..ba327b3 --- /dev/null +++ b/docs/advanced/cross-contract-calls.md @@ -0,0 +1,171 @@ +# Cross-Contract Calls + +When testing contracts that call other contracts, all contracts must be registered with the `Blockchain` singleton. The VM automatically routes cross-contract calls. + +--- + +## Setup + +Register all contracts that participate in cross-contract calls: + +```typescript +import { opnet, OPNetUnit, Assert, Blockchain, OP20 } from '@btc-vision/unit-test-framework'; + +await opnet('Cross-Contract Tests', async (vm: OPNetUnit) => { + let tokenA: OP20; + let tokenB: OP20; + let swap: MySwapContract; + + const deployer = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + // Register all contracts + tokenA = new OP20({ + address: Blockchain.generateRandomAddress(), + deployer, file: './bytecodes/TokenA.wasm', decimals: 18, + }); + tokenB = new OP20({ + address: Blockchain.generateRandomAddress(), + deployer, file: './bytecodes/TokenB.wasm', decimals: 18, + }); + swap = new MySwapContract(deployer, Blockchain.generateRandomAddress()); + + Blockchain.register(tokenA); + Blockchain.register(tokenB); + Blockchain.register(swap); + + await tokenA.init(); + await tokenB.init(); + await swap.init(); + + Blockchain.msgSender = deployer; + Blockchain.txOrigin = deployer; + }); + + vm.afterEach(() => { + tokenA.dispose(); + tokenB.dispose(); + swap.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should swap tokens', async () => { + // Mint tokens to the swap contract + await tokenA.mintRaw(swap.address, Blockchain.expandTo18Decimals(10000)); + await tokenB.mintRaw(swap.address, Blockchain.expandTo18Decimals(10000)); + + // Execute swap - internally calls tokenA and tokenB + await swap.doSwap(tokenA.address, tokenB.address, 100n); + + // Verify balances changed + const balanceA = await tokenA.balanceOf(swap.address); + const balanceB = await tokenB.balanceOf(swap.address); + + vm.info(`Token A: ${balanceA}`); + vm.info(`Token B: ${balanceB}`); + }); +}); +``` + +--- + +## How It Works + +```mermaid +sequenceDiagram + participant Test as Test Code + participant Swap as SwapContract + participant VM as OP_VM + participant TokenA as TokenA + participant TokenB as TokenB + + Test->>Swap: doSwap(tokenA, tokenB, amount) + Swap->>VM: call(tokenA, transfer(...)) + VM->>TokenA: onCall(calldata) + TokenA-->>VM: result + VM-->>Swap: transfer result + Swap->>VM: call(tokenB, transfer(...)) + VM->>TokenB: onCall(calldata) + TokenB-->>VM: result + VM-->>Swap: transfer result + Swap-->>Test: swap complete +``` + +When a contract executes a `call` instruction, the VM: + +1. Looks up the target address in `Blockchain`'s contract registry +2. Invokes `onCall()` on the target `ContractRuntime` +3. Returns the result to the calling contract + +--- + +## Call Stack Tracking + +The `callStack` in `CallResponse` tracks the full chain of calls: + +```typescript +await vm.it('should track call stack', async () => { + Blockchain.traceCalls = true; + + const response = await swap.doSwap(tokenA.address, tokenB.address, 100n); + + // callStack contains all contracts involved + for (const addr of response.callStack) { + vm.info(`Called: ${addr.toHex()}`); + } +}); +``` + +--- + +## State Isolation + +Each registered contract maintains its own storage. Cross-contract calls modify the target contract's state: + +```typescript +await vm.it('should modify both contract states', async () => { + const aliceBalanceBefore = await tokenA.balanceOf(alice); + + await swap.doSwap(tokenA.address, tokenB.address, 100n); + + const aliceBalanceAfter = await tokenA.balanceOf(alice); + // Balance changed because swap called tokenA.transfer internally +}); +``` + +--- + +## State Backup/Restore + +Use backup/restore for scenarios where you need to test different paths without re-initializing: + +```typescript +await vm.it('should compare two scenarios', async () => { + // Setup initial state + await tokenA.mintRaw(alice, 1000n); + + // Backup + Blockchain.backupStates(); + + // Scenario A + await swap.doSwap(tokenA.address, tokenB.address, 100n); + const balanceA = await tokenA.balanceOf(alice); + + // Restore to pre-swap state + Blockchain.restoreStates(); + + // Scenario B + await swap.doSwap(tokenA.address, tokenB.address, 200n); + const balanceB = await tokenA.balanceOf(alice); + + Assert.expect(balanceA).toNotEqual(balanceB); +}); +``` + +--- + +[<- Previous: Utilities](../api-reference/utilities.md) | [Next: Upgradeable Contracts ->](./upgradeable-contracts.md) diff --git a/docs/advanced/gas-profiling.md b/docs/advanced/gas-profiling.md new file mode 100644 index 0000000..c3235b3 --- /dev/null +++ b/docs/advanced/gas-profiling.md @@ -0,0 +1,141 @@ +# Gas Profiling + +Measure and optimize gas consumption of your contracts. + +--- + +## Enable Gas Tracing + +```typescript +// In your test setup +Blockchain.traceGas = true; +Blockchain.traceDeployments = true; +Blockchain.traceCalls = true; +Blockchain.tracePointers = true; +``` + +This enables detailed logging of gas consumption per call, storage operations, and deployments. + +--- + +## Measuring Gas + +Every `CallResponse` includes `usedGas`: + +```typescript +await vm.it('should measure gas for basic execution', async () => { + const response = await contract.execute({ + calldata: calldata.getBuffer(), + }); + + vm.info(`Gas used: ${response.usedGas}`); + Assert.expect(response.usedGas).toBeGreaterThan(0n); +}); +``` + +### Compare Gas Costs + +```typescript +await vm.it('should compare gas costs', async () => { + // Simple read + const readResponse = await contract.getValue(); + vm.info(`Read gas: ${readResponse.usedGas}`); + + // Storage write (more expensive) + const writeResponse = await contract.setValue(42n); + vm.info(`Write gas: ${writeResponse.usedGas}`); + + Assert.expect(writeResponse.usedGas).toBeGreaterThan(readResponse.usedGas); +}); +``` + +### Assert Gas Bounds + +```typescript +await vm.it('should stay within gas budget', async () => { + const response = await contract.expensiveOperation(); + + // Ensure operation doesn't exceed budget + Assert.expect(response.usedGas).toBeLessThan(1_000_000_000n); +}); +``` + +--- + +## Gas Conversion Utilities + +Convert gas to real-world costs: + +```typescript +import { gas2Sat, gas2BTC, gas2USD } from '@btc-vision/unit-test-framework'; + +await vm.it('should calculate operation cost', async () => { + const response = await contract.execute({ calldata }); + + const sats = gas2Sat(response.usedGas); + const btc = gas2BTC(response.usedGas); + const usd = gas2USD(response.usedGas, 100_000); // $100k BTC + + vm.info(`Gas: ${response.usedGas}`); + vm.info(`Cost: ${sats} sats / ${btc.toFixed(8)} BTC / $${usd.toFixed(4)}`); +}); +``` + +--- + +## Gas for Storage Operations + +Storage writes are significantly more expensive than reads: + +```typescript +await vm.it('should measure storage gas costs', async () => { + // New storage slot (cold write - most expensive) + const coldWrite = await contract.storeNewSlot(key, value); + vm.info(`Cold storage write: ${coldWrite.usedGas}`); + + // Existing slot (warm write - cheaper) + const warmWrite = await contract.storeExistingSlot(key, newValue); + vm.info(`Warm storage write: ${warmWrite.usedGas}`); + + Assert.expect(coldWrite.usedGas).toBeGreaterThan(warmWrite.usedGas); +}); +``` + +--- + +## Deployment Gas + +Track gas used during contract deployment: + +```typescript +Blockchain.traceDeployments = true; + +await vm.it('should measure deployment gas', async () => { + const contract = new MyContract(deployer, address); + Blockchain.register(contract); + await contract.init(); + + vm.info(`Deployment gas: ${contract.gasUsed}`); +}); +``` + +--- + +## Consensus Gas Limits + +Access gas-related consensus parameters: + +```typescript +import { configs } from '@btc-vision/unit-test-framework'; + +const maxGas = configs.CONSENSUS.GAS.TRANSACTION_MAX_GAS; +const satToGas = configs.CONSENSUS.GAS.SAT_TO_GAS_RATIO; +const targetGas = configs.CONSENSUS.GAS.TARGET_GAS; + +vm.info(`Max gas per tx: ${maxGas}`); +vm.info(`Sat-to-gas ratio: ${satToGas}`); +``` + +--- + +[<- Previous: State Management](./state-management.md) | [Next: Consensus Rules ->](./consensus-rules.md) diff --git a/docs/advanced/signature-verification.md b/docs/advanced/signature-verification.md new file mode 100644 index 0000000..18b1b13 --- /dev/null +++ b/docs/advanced/signature-verification.md @@ -0,0 +1,191 @@ +# Signature Verification + +Test ML-DSA (post-quantum), Schnorr, and ECDSA signature verification in contracts. + +--- + +## ML-DSA Signatures + +ML-DSA (formerly CRYSTALS-Dilithium) provides quantum-resistant signatures: + +```typescript +import { MessageSigner, BinaryWriter } from '@btc-vision/transaction'; +import { Blockchain } from '@btc-vision/unit-test-framework'; + +await vm.it('should verify ML-DSA signature', async () => { + const wallet = Blockchain.generateRandomWallet(); + + // Create and sign message + const message = new BinaryWriter(); + message.writeString('Hello, World!'); + + const signature = MessageSigner.signMLDSAMessage( + wallet.mldsaKeypair, + message.getBuffer(), + ); + + vm.info(`Security Level: ${signature.securityLevel}`); + vm.info(`Signature length: ${signature.signature.length} bytes`); + + // Pass to contract for verification + const result = await contract.verifySignature( + signature.signature, + wallet.address, + wallet.address, + ); + + Assert.expect(result.result).toEqual(true); + vm.success(`Gas used: ${result.gas}`); +}); +``` + +--- + +## Schnorr Signatures + +Schnorr signatures are used for standard Bitcoin signing: + +```typescript +await vm.it('should verify Schnorr signature', async () => { + const wallet = Blockchain.generateRandomWallet(); + + const message = new BinaryWriter(); + message.writeString('Test message'); + const msgBuffer = message.getBuffer(); + + // Sign with tweaked key + const signature = MessageSigner.tweakAndSignMessage( + wallet.keypair, + msgBuffer, + Blockchain.network, + ); + + // Verify locally first + const localValid = MessageSigner.tweakAndVerifySignature( + wallet.keypair.publicKey, + msgBuffer, + signature.signature, + ); + Assert.expect(localValid).toEqual(true); + + // Verify in contract + const result = await contract.verifySignatureSchnorr( + signature.signature, + wallet.address, + wallet.address, + ); + + Assert.expect(result.result).toEqual(true); +}); +``` + +--- + +## ECDSA Signatures + +ECDSA verification supports both Ethereum-style (ecrecover with recovery ID) and Bitcoin-style (direct verify): + +```typescript +import { createHash } from 'crypto'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; + +function sha256(data: Uint8Array): Uint8Array { + return new Uint8Array(createHash('sha256').update(data).digest()); +} + +// Generate keypair from seed +function generateKeyPair(seed: string) { + const privateKey = sha256(new TextEncoder().encode(seed)); + const compressed = secp256k1.getPublicKey(privateKey, true); + const uncompressed = secp256k1.getPublicKey(privateKey, false); + return { privateKey, compressed, uncompressed, raw: uncompressed.slice(1) }; +} +``` + +### Ethereum-style (ecrecover) + +```typescript +function signEthereum(rawMessage: Uint8Array, privateKey: Uint8Array): Uint8Array { + const sigBytes = secp256k1.sign(rawMessage, privateKey); + const pub33 = secp256k1.getPublicKey(privateKey, true); + + // Find recovery id + for (let v = 0; v <= 1; v++) { + const recSig = new Uint8Array(65); + recSig[0] = v; + recSig.set(sigBytes, 1); + try { + const recovered = secp256k1.recoverPublicKey(recSig, rawMessage); + if (Buffer.from(recovered).equals(Buffer.from(pub33))) { + const ethSig = new Uint8Array(65); + ethSig.set(sigBytes.slice(0, 32), 0); // r + ethSig.set(sigBytes.slice(32, 64), 32); // s + ethSig[64] = v; // v + return ethSig; + } + } catch { /* not this v */ } + } + throw new Error('Could not find recovery id'); +} + +await vm.it('should verify Ethereum ECDSA', async () => { + const key = generateKeyPair('test-key'); + const message = new TextEncoder().encode('Hello, ECDSA!'); + const hash = sha256(message); + const sig = signEthereum(message, key.privateKey); + + const { result } = await contract.verifyECDSAEthereum(key.compressed, sig, hash); + Assert.expect(result).toEqual(true); +}); +``` + +### Bitcoin-style (direct verify) + +```typescript +function signBitcoin(rawMessage: Uint8Array, privateKey: Uint8Array): Uint8Array { + return new Uint8Array(secp256k1.sign(rawMessage, privateKey)); +} + +await vm.it('should verify Bitcoin ECDSA', async () => { + const key = generateKeyPair('test-key'); + const message = new TextEncoder().encode('Hello, ECDSA!'); + const hash = sha256(message); + const sig = signBitcoin(message, key.privateKey); + + const { result } = await contract.verifyECDSABitcoin(key.compressed, sig, hash); + Assert.expect(result).toEqual(true); +}); +``` + +### Public Key Formats + +ECDSA verification accepts three public key formats: + +| Format | Length | Description | +|--------|--------|-------------| +| Compressed | 33 bytes | `02/03` prefix + x coordinate | +| Uncompressed | 65 bytes | `04` prefix + x + y coordinates | +| Raw | 64 bytes | x + y coordinates (no prefix) | + +--- + +## ML-DSA Key Registration + +For contracts that look up ML-DSA public keys by address: + +```typescript +const wallet = Blockchain.generateRandomWallet(); + +// Register the public key +Blockchain.registerMLDSAPublicKey( + wallet.address, + wallet.mldsaKeypair.publicKey, +); + +// Look up later +const pubKey = Blockchain.getMLDSAPublicKey(wallet.address); +``` + +--- + +[<- Previous: Transaction Simulation](./transaction-simulation.md) | [Next: State Management ->](./state-management.md) diff --git a/docs/advanced/state-management.md b/docs/advanced/state-management.md new file mode 100644 index 0000000..69c9485 --- /dev/null +++ b/docs/advanced/state-management.md @@ -0,0 +1,131 @@ +# State Management + +Advanced state manipulation for debugging, block replay, and test isolation. + +--- + +## StateHandler + +The `StateHandler` singleton manages global contract state across the VM: + +```typescript +import { StateHandler } from '@btc-vision/unit-test-framework'; +``` + +### Override States + +Load state from external sources (e.g., mainnet state dumps): + +```typescript +import { FastMap } from '@btc-vision/transaction'; + +// Create a state map (pointer -> value) +const states = new FastMap(); +states.set(0x01n, 0x42n); +states.set(0x02n, 0x100n); + +// Override contract state +StateHandler.overrideStates(contractAddress, states); +StateHandler.overrideDeployment(contractAddress); +``` + +### Read Global State + +```typescript +// Check if a pointer exists +const exists = StateHandler.globalHas(contractAddress, pointer); + +// Read a value +const value = StateHandler.globalLoad(contractAddress, pointer); +``` + +### Temporary States + +Used internally for cross-contract calls. Temporary states are merged to global after successful execution: + +```typescript +// Set temporary states (for transaction simulation) +StateHandler.setTemporaryStates(contractAddress, tempStates); + +// Merge all temporary states to global +StateHandler.pushAllTempStatesToGlobal(); + +// Clear temporary states for a contract +StateHandler.clearTemporaryStates(contractAddress); +``` + +### Reset + +```typescript +// Reset one contract +StateHandler.resetGlobalStates(contractAddress); + +// Reset everything +StateHandler.purgeAll(); +``` + +--- + +## Contract State Backup/Restore + +`ContractRuntime` and `Blockchain` both support backup/restore: + +```typescript +// Backup state of a single contract +contract.backupStates(); +// ... make changes ... +contract.restoreStates(); + +// Backup state of ALL registered contracts +Blockchain.backupStates(); +// ... make changes ... +Blockchain.restoreStates(); +``` + +This is useful for testing multiple scenarios from the same starting state without re-initializing. + +--- + +## State Override via Execute + +Control whether state changes persist per call: + +```typescript +// State changes persist (default) +await contract.execute({ + calldata: buffer, + saveStates: true, +}); + +// State changes discarded after call +await contract.execute({ + calldata: buffer, + saveStates: false, +}); +``` + +--- + +## Applying State from Cross-Contract Calls + +When a contract call modifies another contract's state, use `applyStatesOverride`: + +```typescript +const response = await contractA.execute({ calldata }); + +// Apply the state changes (events, call stack, etc.) to contractB +contractB.applyStatesOverride({ + events: response.events, + callStack: response.callStack, + touchedAddresses: response.touchedAddresses, + touchedBlocks: response.touchedBlocks, + totalEventLength: response.events.length, + loadedPointers: 0n, + storedPointers: 0n, + memoryPagesUsed: response.memoryPagesUsed, +}); +``` + +--- + +[<- Previous: Signature Verification](./signature-verification.md) | [Next: Gas Profiling ->](./gas-profiling.md) diff --git a/docs/advanced/transaction-simulation.md b/docs/advanced/transaction-simulation.md new file mode 100644 index 0000000..ee240f8 --- /dev/null +++ b/docs/advanced/transaction-simulation.md @@ -0,0 +1,119 @@ +# Transaction Simulation + +Simulate Bitcoin transactions with inputs and outputs to test contracts that read UTXO data. + +--- + +## Creating Transactions + +```typescript +import { + Transaction, TransactionInput, TransactionOutput, + generateTransactionId, generateEmptyTransaction, +} from '@btc-vision/unit-test-framework'; +``` + +### Empty Transaction + +```typescript +const tx = generateEmptyTransaction(); +Blockchain.transaction = tx; +``` + +### Custom Transaction + +```typescript +const tx = new Transaction( + generateTransactionId(), + [ + new TransactionInput({ + txHash: new Uint8Array(32), + outputIndex: 0, + scriptSig: new Uint8Array(0), + flags: 0, + }), + ], + [], +); + +// Add outputs +tx.addOutput(100000n, receiverAddress.p2tr(Blockchain.network)); +tx.addOutput(50000n, undefined, opReturnScript); + +// Set as current transaction +Blockchain.transaction = tx; +``` + +### Adding Inputs + +```typescript +// Simple input +tx.addInput( + new Uint8Array(32), // Previous tx hash + 0, // Output index + new Uint8Array(0), // Script sig +); + +// Input with flags +tx.addInputWithFlags(new TransactionInput({ + txHash: prevTxHash, + outputIndex: 1, + scriptSig: new Uint8Array(0), + flags: 0b00000001, // coinbase flag + coinbase: Buffer.from('coinbase data'), +})); +``` + +### Adding Outputs + +```typescript +// Output to address +tx.addOutput(100000n, recipientAddress); + +// Output with script +tx.addOutputWithFlags(new TransactionOutput({ + index: 2, + value: 0n, + scriptPubKey: opReturnData, + flags: 0b00000100, // OP_RETURN flag +})); +``` + +--- + +## Testing with Transactions + +Contracts that read transaction inputs/outputs (e.g., for UTXO verification) need a transaction context: + +```typescript +await vm.it('should process transaction outputs', async () => { + const tx = generateEmptyTransaction(); + + // Add recipient outputs + tx.addOutput(50000n, alice.p2tr(Blockchain.network)); + tx.addOutput(30000n, bob.p2tr(Blockchain.network)); + + Blockchain.transaction = tx; + + // Contract can now read inputs() and outputs() + const response = await contract.processTransaction(); + Assert.expect(response.usedGas).toBeGreaterThan(0n); +}); +``` + +--- + +## Serialization + +Transactions serialize inputs and outputs for the VM: + +```typescript +const inputBytes = tx.serializeInputs(); +const outputBytes = tx.serializeOutputs(); +``` + +This is handled automatically by the framework when `Blockchain.transaction` is set. + +--- + +[<- Previous: Upgradeable Contracts](./upgradeable-contracts.md) | [Next: Signature Verification ->](./signature-verification.md) diff --git a/docs/advanced/upgradeable-contracts.md b/docs/advanced/upgradeable-contracts.md new file mode 100644 index 0000000..2eedf0a --- /dev/null +++ b/docs/advanced/upgradeable-contracts.md @@ -0,0 +1,200 @@ +# Upgradeable Contracts + +OPNet supports contract upgrades via `updateFromAddress`. The new bytecode is sourced from another registered contract and takes effect on the next block. + +--- + +## How Upgrades Work + +```mermaid +sequenceDiagram + participant Test as Test + participant V1 as ContractV1 + participant V2Source as V2 Source + participant BC as Blockchain + + Test->>V1: upgrade(v2SourceAddress) + Note over V1: Upgrade queued + Test->>BC: mineBlock() + Note over V1: Bytecode replaced with V2 + Test->>V1: getValue() + V1-->>Test: Returns V2 behavior +``` + +Key points: +- Upgrades are **queued**, not immediate +- The new bytecode takes effect after `Blockchain.mineBlock()` +- Storage is **preserved** across upgrades +- Only **one upgrade per block** is allowed +- The contract **address stays the same** +- Cross-contract calls are **blocked** after upgrading in the same execution + +--- + +## Internal Mechanism + +The upgrade process has two phases: + +### Phase 1: Upgrade Request (`updateFromAddress`) + +When a contract calls `updateFromAddress`: + +1. A **temporary WASM instance** is created with the **current** bytecode (using `bypassCache: true` to avoid reusing the paused instance) +2. `onUpdate(calldata)` is called on this temporary instance, giving the current contract a chance to run migration logic +3. If `onUpdate` succeeds, the new bytecode is **queued** as a pending upgrade for the current block +4. The `_hasUpgradedInCurrentExecution` flag is set, **blocking** any further cross-contract calls (`Blockchain.call`) in the same transaction + +### Phase 2: Bytecode Swap (`applyPendingBytecodeUpgrade`) + +On the **next block** (when `Blockchain.blockNumber > pendingBytecodeBlock`): + +1. The bytecode is swapped to the new version +2. A **temporary WASM instance** is created with the **new** bytecode (using `bypassCache: true` to ensure the fresh bytecode is loaded without hitting the module cache) +3. `onUpdate(calldata)` is called on the new bytecode, allowing the new version to run its own initialization/migration logic +4. If `onUpdate` fails on the new bytecode, the **upgrade is reverted** back to the previous bytecode +5. The pending upgrade state is cleared + +### Response Format + +The `updateFromAddress` response uses the format: `[bytecodeLength(4) | executionCost(8) | exitStatus(4) | exitData]` (no address prefix, unlike `deployContractAtAddress`). + +--- + +## Setup + +You need two contracts: the main contract and a source contract that holds the V2 bytecode: + +```typescript +import { Address } from '@btc-vision/transaction'; +import { BytecodeManager, ContractRuntime, opnet, OPNetUnit, Assert, Blockchain } from '@btc-vision/unit-test-framework'; +import { UpgradeableContractRuntime } from './UpgradeableContractRuntime.js'; + +// Source contract just provides bytecode +class V2SourceContract extends ContractRuntime { + constructor(deployer: Address, address: Address) { + super({ address, deployer, gasLimit: 150_000_000_000n }); + } + + protected handleError(error: Error): Error { + return new Error(`(V2Source) ${error.message}`); + } + + protected defineRequiredBytecodes(): void { + BytecodeManager.loadBytecode('./bytecodes/ContractV2.wasm', this.address); + } +} +``` + +--- + +## Test Examples + +### Basic Upgrade + +```typescript +await opnet('Upgrade Tests', async (vm: OPNetUnit) => { + let contract: UpgradeableContractRuntime; + let v2Source: V2SourceContract; + + const deployer = Blockchain.generateRandomAddress(); + const contractAddress = Blockchain.generateRandomAddress(); + const v2Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + contract = new UpgradeableContractRuntime(deployer, contractAddress); + v2Source = new V2SourceContract(deployer, v2Address); + + Blockchain.register(contract); + Blockchain.register(v2Source); + + await contract.init(); + await v2Source.init(); + + Blockchain.txOrigin = deployer; + Blockchain.msgSender = deployer; + }); + + vm.afterEach(() => { + contract.dispose(); + v2Source.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should not apply upgrade on same block', async () => { + Assert.expect(await contract.getValue()).toEqual(1); + + await contract.upgrade(v2Address); + + // Same block: still V1 behavior + Assert.expect(await contract.getValue()).toEqual(1); + }); + + await vm.it('should apply upgrade after mining', async () => { + await contract.upgrade(v2Address); + Blockchain.mineBlock(); + + // Now V2 behavior + Assert.expect(await contract.getValue()).toEqual(2); + }); +}); +``` + +### Storage Persistence + +```typescript +await vm.it('should preserve storage across upgrade', async () => { + const key = new Uint8Array(32); + key[31] = 42; + const value = new Uint8Array(32); + value[31] = 99; + + // Store value with V1 + await contract.storeValue(key, value); + + // Upgrade to V2 + await contract.upgrade(v2Address); + Blockchain.mineBlock(); + + // Value persists with V2 + const loaded = await contract.loadValue(key); + Assert.expect(loaded).toDeepEqual(value); +}); +``` + +### Error Handling + +```typescript +await vm.it('should revert for non-existent source', async () => { + const fakeAddress = Blockchain.generateRandomAddress(); + + await Assert.expect(async () => { + await contract.upgrade(fakeAddress); + }).toThrow(); +}); + +await vm.it('should reject second upgrade in same block', async () => { + await contract.upgrade(v2Address); + + await Assert.expect(async () => { + await contract.upgrade(v2Address); + }).toThrow(); +}); +``` + +### Gas Tracking + +```typescript +await vm.it('should measure upgrade gas cost', async () => { + const response = await contract.upgrade(v2Address); + Assert.expect(response.usedGas).toBeGreaterThan(0n); + vm.info(`Upgrade gas: ${response.usedGas}`); +}); +``` + +--- + +[<- Previous: Cross-Contract Calls](./cross-contract-calls.md) | [Next: Transaction Simulation ->](./transaction-simulation.md) diff --git a/docs/api-reference/assertions.md b/docs/api-reference/assertions.md new file mode 100644 index 0000000..8b97071 --- /dev/null +++ b/docs/api-reference/assertions.md @@ -0,0 +1,242 @@ +# Assertions API Reference + +The framework provides two assertion styles: **static methods** on `Assert` and a **fluent API** via `Assert.expect()`. + +**Import:** `import { Assert, Assertion } from '@btc-vision/unit-test-framework'` + +--- + +## Static Methods + +### `Assert.equal(actual, expected, message?)` + +```typescript +static equal(actual: T, expected: T, message?: string): void +``` + +Strict equality check. Throws if `actual !== expected`. + +### `Assert.notEqual(actual, unexpected, message?)` + +```typescript +static notEqual(actual: T, unexpected: T, message?: string): void +``` + +### `Assert.deepEqual(actual, expected, message?)` + +```typescript +static deepEqual(actual: T, expected: T, message?: string): void +``` + +Deep structural equality. Works with objects, arrays, `Uint8Array`, nested structures. + +### `Assert.toBeCloseTo(actual, expected, tolerance, message?)` + +```typescript +static toBeCloseTo(actual: bigint, expected: bigint, tolerance: bigint, message?: string): void +``` + +Checks that `abs(actual - expected) <= tolerance`. + +### `Assert.toBeGreaterThan(actual, expected, message?)` + +```typescript +static toBeGreaterThan(actual: bigint, expected: bigint, message?: string): void +``` + +### `Assert.toBeGreaterThanOrEqual(actual, expected, message?)` + +```typescript +static toBeGreaterThanOrEqual(actual: bigint, expected: bigint, message?: string): void +``` + +### `Assert.toBeLessThanOrEqual(actual, expected, message?)` + +```typescript +static toBeLessThanOrEqual(actual: bigint, expected: bigint, message?: string): void +``` + +### `Assert.addressArrayEqual(actual, expected, message?)` + +```typescript +static addressArrayEqual(actual: Address[], expected: Address[], message?: string): void +``` + +Compares two arrays of `Address` objects for equality. + +### `Assert.throws(fn, expectedError?)` + +```typescript +static throws(fn: () => void, expectedError?: string | RegExp): void +``` + +Asserts that `fn()` throws. When `expectedError` is a string, uses **exact match** against the error message. When it is a `RegExp`, tests the pattern. + +### `Assert.throwsAsync(fn, expectedError?)` + +```typescript +static async throwsAsync(fn: () => Promise, expectedError?: string | RegExp): Promise +``` + +Async version of `throws`. Same exact-match behavior for strings. + +--- + +## Fluent API + +Create assertions with `Assert.expect(value)`: + +```typescript +Assert.expect(actualValue).toEqual(expectedValue); +``` + +### `toEqual(expected)` + +```typescript +toEqual(expected: unknown): void +``` + +Strict equality. Works with primitives, `bigint`, and objects. + +### `toNotEqual(unexpected)` + +```typescript +toNotEqual(unexpected: unknown): void +``` + +### `toDeepEqual(expected)` + +```typescript +toDeepEqual(expected: unknown): void +``` + +Deep structural equality. + +### `toBeDefined()` + +```typescript +toBeDefined(): void +``` + +Asserts value is not `undefined`. + +### `toBeUndefined()` + +```typescript +toBeUndefined(): void +``` + +### `toBeGreaterThan(expected)` + +```typescript +toBeGreaterThan(expected: number | bigint): void +``` + +### `toBeGreaterThanOrEqual(expected)` + +```typescript +toBeGreaterThanOrEqual(expected: number | bigint): void +``` + +### `toBeLessThan(expected)` + +```typescript +toBeLessThan(expected: number | bigint): void +``` + +### `toBeLessThanOrEqual(expected)` + +```typescript +toBeLessThanOrEqual(expected: number | bigint): void +``` + +### `toEqualAddress(address)` + +```typescript +toEqualAddress(address: Address): void +``` + +Compares two `Address` objects. + +### `toEqualAddressList(expected)` + +```typescript +toEqualAddressList(expected: Address[]): void +``` + +Compares two arrays of `Address` objects. + +### `toThrow(expectedError?)` + +```typescript +async toThrow(expectedError?: string | RegExp): Promise +``` + +Asserts that the value (an async function) throws when called. When `expectedError` is a string, uses **substring match** (`error.message.includes(expectedError)`). When it is a `RegExp`, tests the pattern. + +> **Note:** This differs from the static `Assert.throws()`, which uses **exact match** for strings. + +```typescript +// Match any error +await Assert.expect(async () => { + await contract.invalidCall(); +}).toThrow(); + +// Match by substring (not exact) +await Assert.expect(async () => { + await contract.invalidCall(); +}).toThrow('out of gas'); + +// Match by regex +await Assert.expect(async () => { + await contract.invalidCall(); +}).toThrow(/not authorized/); +``` + +### `toNotThrow()` + +```typescript +async toNotThrow(): Promise +``` + +Asserts that the value (an async function) does NOT throw. + +--- + +## Examples + +```typescript +import { Assert } from '@btc-vision/unit-test-framework'; + +// Primitive equality +Assert.expect(42n).toEqual(42n); +Assert.expect('hello').toEqual('hello'); + +// Comparison +Assert.expect(100n).toBeGreaterThan(50n); +Assert.expect(response.usedGas).toBeLessThan(1_000_000_000n); + +// Address comparison +Assert.expect(owner).toEqualAddress(expectedOwner); + +// Deep equality (Uint8Array, objects) +Assert.expect(hash).toDeepEqual(expectedHash); + +// Defined/undefined +Assert.expect(result).toBeDefined(); +Assert.expect(missing).toBeUndefined(); + +// Error testing +await Assert.expect(async () => { + await contract.unauthorizedCall(); +}).toThrow('not authorized'); + +// Static methods +Assert.equal(value, 42n); +Assert.toBeCloseTo(actual, expected, 100n); +Assert.deepEqual(obj, expectedObj); +``` + +--- + +[<- Previous: OP721Extended](../built-in-contracts/op721-extended.md) | [Next: Blockchain API ->](./blockchain.md) diff --git a/docs/api-reference/blockchain.md b/docs/api-reference/blockchain.md new file mode 100644 index 0000000..f5bb108 --- /dev/null +++ b/docs/api-reference/blockchain.md @@ -0,0 +1,340 @@ +# Blockchain API Reference + +The `Blockchain` singleton simulates the OPNet blockchain environment. It manages contracts, blocks, addresses, and transaction context. + +**Import:** `import { Blockchain } from '@btc-vision/unit-test-framework'` + +--- + +## Initialization & Cleanup + +### `init()` + +```typescript +async init(): Promise +``` + +Initializes the blockchain VM. **Must be called before any contract interaction.** + +### `dispose()` + +```typescript +dispose(): void +``` + +Resets the current execution state. Call in `afterEach`. + +### `cleanup()` + +```typescript +cleanup(): void +``` + +Full cleanup including VM bindings. Call in `afterAll`. + +### `clearContracts()` + +```typescript +clearContracts(): void +``` + +Unregisters all contracts from the blockchain. + +--- + +## Block Management + +### `blockNumber` + +```typescript +get blockNumber(): bigint +set blockNumber(blockNumber: bigint) +``` + +Current block height. Default: `1n`. + +### `medianTimestamp` + +```typescript +get medianTimestamp(): bigint +set medianTimestamp(timestamp: bigint) +``` + +Median block timestamp. + +### `mineBlock()` + +```typescript +mineBlock(): void +``` + +Advances `blockNumber` by 1. Used to test block-boundary behavior like upgrades: + +```typescript +await contract.upgrade(v2Address); +Blockchain.mineBlock(); +// Upgrade now takes effect +const value = await contract.getValue(); +``` + +--- + +## Transaction Context + +### `msgSender` + +```typescript +get msgSender(): Address +set msgSender(sender: Address) +``` + +The `msg.sender` for contract calls. + +### `txOrigin` + +```typescript +get txOrigin(): Address +set txOrigin(from: Address) +``` + +The `tx.origin` for contract calls. Must be a valid tweaked public key. + +### `transaction` + +```typescript +get transaction(): Transaction | null +set transaction(tx: Transaction | null) +``` + +The current Bitcoin transaction context (inputs/outputs). + +--- + +## Address Generation + +### `generateRandomAddress()` + +```typescript +generateRandomAddress(): Address +``` + +Generates a cryptographically random OPNet address. + +### `generateRandomWallet()` + +```typescript +generateRandomWallet(): Wallet +``` + +Generates a full wallet with both ECDSA and ML-DSA keypairs: + +```typescript +const wallet = Blockchain.generateRandomWallet(); +wallet.address; // Address +wallet.keypair; // Secp256k1 keypair +wallet.mldsaKeypair; // ML-DSA keypair +wallet.quantumPublicKeyHex; // ML-DSA public key (hex) +``` + +### `generateAddress(deployer, salt, from)` + +```typescript +generateAddress(deployer: Address, salt: Buffer, from: Address): Address +``` + +Deterministic address generation (CREATE2-style). + +### `DEAD_ADDRESS` + +```typescript +readonly DEAD_ADDRESS: Address +``` + +The dead/burn address (`Address.dead()`). + +--- + +## Contract Registry + +### `register(contract)` + +```typescript +register(contract: ContractRuntime): void +``` + +Registers a contract with the blockchain. Required for contract-to-contract calls: + +```typescript +const tokenA = new OP20({ ... }); +const swap = new MySwapContract(deployer, swapAddr); + +Blockchain.register(tokenA); +Blockchain.register(swap); +``` + +### `unregister(contract)` + +```typescript +unregister(contract: ContractRuntime): void +``` + +### `getContract(address)` + +```typescript +getContract(address: Address): ContractRuntime +``` + +Looks up a registered contract by address. + +### `isContract(address)` + +```typescript +isContract(address: Address): boolean +``` + +Returns `true` if the address has a registered contract. + +--- + +## Decimal Utilities + +### `expandTo18Decimals(n)` + +```typescript +expandTo18Decimals(n: number): bigint +``` + +Converts `n` to `n * 10^18`. Example: `100` -> `100_000_000_000_000_000_000n`. + +### `expandToDecimal(n, decimals)` + +```typescript +expandToDecimal(n: number, decimals: number): bigint +``` + +Converts `n` to `n * 10^decimals`. + +### `decodeFrom18Decimals(n)` + +```typescript +decodeFrom18Decimals(n: bigint): number +``` + +Converts `n / 10^18` to a number. + +### `decodeFromDecimal(n, decimals)` + +```typescript +decodeFromDecimal(n: bigint, decimals: number): number +``` + +### `encodePrice(reserve0, reserve1)` + +```typescript +encodePrice(reserve0: bigint, reserve1: bigint): [bigint, bigint] +``` + +Encodes a price from AMM reserves (UQ112x112 format). + +--- + +## State Management + +### `backupStates()` + +```typescript +backupStates(): void +``` + +Saves the current state of all registered contracts. + +### `restoreStates()` + +```typescript +restoreStates(): void +``` + +Restores previously backed-up states. + +--- + +## ML-DSA Key Management + +### `registerMLDSAPublicKey(address, publicKey)` + +```typescript +registerMLDSAPublicKey(address: Address, publicKey: Uint8Array): void +``` + +Registers an ML-DSA public key for signature verification. + +### `getMLDSAPublicKey(address)` + +```typescript +getMLDSAPublicKey(address: Address): Uint8Array | undefined +``` + +--- + +## Tracking & Debugging + +Enable tracing to get detailed output during test execution: + +```typescript +// Gas tracing - logs gas usage per call +Blockchain.traceGas = true; +Blockchain.enableGasTracking(); +Blockchain.disableGasTracking(); + +// Storage pointer tracing +Blockchain.tracePointers = true; +Blockchain.enablePointerTracking(); +Blockchain.disablePointerTracking(); + +// Contract call tracing +Blockchain.traceCalls = true; +Blockchain.enableCallTracking(); +Blockchain.disableCallTracking(); + +// Deployment tracing +Blockchain.traceDeployments = true; +``` + +### `simulateRealEnvironment` + +```typescript +simulateRealEnvironment: boolean // Default: false +``` + +When `true`, simulates a more realistic execution environment. + +### `changeNetwork(network)` + +```typescript +changeNetwork(network: Network): void +``` + +Switch to a different Bitcoin network (e.g., mainnet, testnet). + +--- + +## Properties Summary + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `network` | `Network` | `regtest` | Bitcoin network | +| `blockNumber` | `bigint` | `1n` | Current block height | +| `medianTimestamp` | `bigint` | `BigInt(Date.now())` | Block median time | +| `msgSender` | `Address` | `Address.dead()` | Call sender | +| `txOrigin` | `Address` | `Address.dead()` | Transaction origin | +| `transaction` | `Transaction \| null` | `null` | Current BTC transaction | +| `traceGas` | `boolean` | `false` | Gas logging | +| `tracePointers` | `boolean` | `false` | Storage pointer logging | +| `traceCalls` | `boolean` | `false` | Call logging | +| `traceDeployments` | `boolean` | `false` | Deployment logging | +| `simulateRealEnvironment` | `boolean` | `false` | Realistic mode | +| `DEAD_ADDRESS` | `Address` | `Address.dead()` | Burn address | + +--- + +[<- Previous: Assertions](./assertions.md) | [Next: Contract Runtime ->](./contract-runtime.md) diff --git a/docs/api-reference/contract-runtime.md b/docs/api-reference/contract-runtime.md new file mode 100644 index 0000000..68fbbd3 --- /dev/null +++ b/docs/api-reference/contract-runtime.md @@ -0,0 +1,285 @@ +# Contract Runtime API Reference + +`ContractRuntime` is the base class for all contract wrappers. Extend it to create typed interactions with your compiled WASM contracts. + +**Import:** `import { ContractRuntime, CallResponse } from '@btc-vision/unit-test-framework'` + +--- + +## Constructor + +```typescript +protected constructor(details: ContractDetails) +``` + +```typescript +interface ContractDetails { + readonly address: Address; + readonly deployer: Address; + readonly gasLimit?: bigint; // Default: 100_000_000_000_000n + readonly gasUsed?: bigint; // Starting gas consumed + readonly memoryPagesUsed?: bigint; // Starting memory pages + readonly deploymentCalldata?: Buffer; // Calldata for onDeploy + readonly bytecode?: Buffer; // Override bytecode directly +} +``` + +--- + +## Public Properties + +| Property | Type | Description | +|----------|------|-------------| +| `address` | `Address` | Contract address | +| `deployer` | `Address` | Deployer address | +| `gasUsed` | `bigint` | Cumulative gas consumed | +| `memoryPagesUsed` | `bigint` | WASM memory pages used | +| `loadedPointers` | `bigint` | Storage pointers loaded | +| `storedPointers` | `bigint` | Storage pointers written | + +--- + +## Lifecycle Methods + +### `init()` + +```typescript +async init(): Promise +``` + +Loads bytecode for the contract by calling `defineRequiredBytecodes()`. Does **not** instantiate the VM or run deployment — those happen lazily on first `execute()` call. **Must be called after creating the runtime and before any execution.** + +### `dispose()` + +```typescript +dispose(): void +``` + +Frees VM resources. Call in `afterEach`. + +### `delete()` + +```typescript +delete(): void +``` + +Permanently deletes the contract instance. + +### `deployContract(pushStates)` + +```typescript +async deployContract(pushStates: boolean = true): Promise +``` + +Explicitly runs the contract's `onDeploy` handler. Skips if the contract is already deployed (checked via `StateHandler.isDeployed`). + +--- + +## Execution + +### `execute(params)` + +```typescript +async execute(params: ExecutionParameters): Promise +``` + +Executes a contract call: + +```typescript +interface ExecutionParameters { + readonly calldata: Buffer | Uint8Array; // Encoded function call + readonly sender?: Address; // Override msg.sender + readonly txOrigin?: Address; // Override tx.origin + readonly gasUsed?: bigint; // Starting gas + readonly memoryPagesUsed?: bigint; // Starting memory pages + readonly saveStates?: boolean; // Persist state changes +} +``` + +### `onCall(params)` + +```typescript +async onCall(params: ExecutionParameters): Promise +``` + +Handles incoming calls from other contracts (used internally by the VM for cross-contract calls). + +--- + +## CallResponse + +```typescript +class CallResponse { + status: number; // 0 = success, 1 = revert + response: Uint8Array; // Raw response bytes to decode + error?: Error; // Error if reverted + events: NetEvent[]; // Emitted events + usedGas: bigint; // Total gas consumed + memoryPagesUsed: bigint; // WASM pages used + callStack: AddressStack; // Contract call chain + touchedAddresses: AddressSet; // Addresses accessed + touchedBlocks: Set; // Block numbers queried +} +``` + +--- + +## State Management + +### `setEnvironment(...)` + +```typescript +setEnvironment( + msgSender?: Address, + txOrigin?: Address, + currentBlock?: bigint, + deployer?: Address, + address?: Address +): void +``` + +Updates the execution environment for this contract. + +### `applyStatesOverride(override)` + +```typescript +applyStatesOverride(override: StateOverride): void +``` + +Applies state from another execution context: + +```typescript +interface StateOverride { + events: NetEvent[]; + callStack: AddressStack; + touchedAddresses: AddressSet; + touchedBlocks: Set; + totalEventLength: number; + loadedPointers: bigint; + storedPointers: bigint; + memoryPagesUsed: bigint; +} +``` + +### `backupStates()` / `restoreStates()` + +```typescript +backupStates(): void +restoreStates(): void +``` + +Save and restore contract state for test isolation. + +### `resetStates()` + +```typescript +resetStates(): void +``` + +Resets all contract state. + +--- + +## Protected Members + +These are available when extending `ContractRuntime`: + +| Member | Type | Description | +|--------|------|-------------| +| `abiCoder` | `ABICoder` | ABI encoder for selectors | +| `states` | `FastMap` | Persistent storage | +| `transient` | `FastMap` | Transient storage | +| `deploymentStates` | `FastMap` | Deployment state | +| `events` | `NetEvent[]` | Emitted events | +| `gasMax` | `bigint` | Gas limit (`100_000_000_000_000n`) | +| `deployedContracts` | `AddressMap` | Contracts deployed by this contract | +| `_bytecode` | `Buffer \| undefined` | Raw bytecode | + +### Protected Methods + +```typescript +// Optional override: custom error wrapping +// Default returns: new Error(`(in: ${this.constructor.name}) OP_NET: ${error}`) +protected handleError(error: Error): Error + +// Optional override: load WASM bytecode +// Default uses `bytecode` from ContractDetails if provided, otherwise throws "Not implemented". +// Override this to call BytecodeManager.loadBytecode() for file-based loading. +protected defineRequiredBytecodes(): void + +// Execute and throw on error +protected async executeThrowOnError(params: ExecutionParameters): Promise + +// Calculate gas cost for saves +protected calculateGasCostSave(response: CallResponse): bigint +``` + +--- + +## BytecodeManager + +**Import:** `import { BytecodeManager } from '@btc-vision/unit-test-framework'` + +Singleton that manages WASM bytecode loading: + +```typescript +// Load bytecode from file +BytecodeManager.loadBytecode('./path/to/Contract.wasm', contractAddress); + +// Get loaded bytecode +const bytecode = BytecodeManager.getBytecode(contractAddress); + +// Set bytecode directly (no-op if already set for this address) +BytecodeManager.setBytecode(contractAddress, buffer); + +// Force-set (overwrites existing, use when you need to replace bytecode) +BytecodeManager.forceSetBytecode(contractAddress, buffer); + +// Get filename for address +const filename = BytecodeManager.getFileName(contractAddress); + +// Clear all loaded bytecodes +BytecodeManager.clear(); +``` + +--- + +## Usage Pattern + +```typescript +export class MyContract extends ContractRuntime { + private readonly mySelector = this.getSelector('myMethod(uint256)'); + + constructor(deployer: Address, address: Address) { + super({ address, deployer, gasLimit: 150_000_000_000n }); + } + + public async myMethod(value: bigint): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.mySelector); + calldata.writeU256(value); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + if (response.error) throw this.handleError(response.error); + if (!response.response) throw new Error('No response'); + + return new BinaryReader(response.response).readU256(); + } + + protected handleError(error: Error): Error { + return new Error(`(MyContract) ${error.message}`); + } + + protected defineRequiredBytecodes(): void { + BytecodeManager.loadBytecode('./MyContract.wasm', this.address); + } + + private getSelector(sig: string): number { + return Number(`0x${this.abiCoder.encodeSelector(sig)}`); + } +} +``` + +--- + +[<- Previous: Blockchain](./blockchain.md) | [Next: Types & Interfaces ->](./types-interfaces.md) diff --git a/docs/api-reference/types-interfaces.md b/docs/api-reference/types-interfaces.md new file mode 100644 index 0000000..357e8f5 --- /dev/null +++ b/docs/api-reference/types-interfaces.md @@ -0,0 +1,451 @@ +# Types & Interfaces Reference + +Complete reference for all exported TypeScript types and interfaces. + +--- + +## CallResponse + +```typescript +interface ICallResponse { + exitData: ExitDataResponse; + events: NetEvent[]; + callStack: AddressStack; + touchedAddresses: AddressSet; + touchedBlocks: Set; + memoryPagesUsed: bigint; +} + +class CallResponse { + status: number; // 0 = success, 1 = revert + response: Uint8Array; // Raw response bytes + error?: Error; // Populated automatically on revert + events: NetEvent[]; + callStack: AddressStack; + touchedAddresses: AddressSet; + touchedBlocks: Set; + memoryPagesUsed: bigint; + usedGas: bigint; +} +``` + +--- + +## ContractDetails + +```typescript +interface ContractDetails { + readonly address: Address; + readonly deployer: Address; + readonly gasLimit?: bigint; + readonly gasUsed?: bigint; + readonly memoryPagesUsed?: bigint; + readonly deploymentCalldata?: Buffer; + readonly bytecode?: Buffer; +} +``` + +--- + +## ExecutionParameters + +```typescript +interface ExecutionParameters { + readonly calldata: Buffer | Uint8Array; + readonly sender?: Address; + readonly txOrigin?: Address; + readonly gasUsed?: bigint; + readonly memoryPagesUsed?: bigint; + readonly saveStates?: boolean; +} +``` + +--- + +## StateOverride + +```typescript +interface StateOverride { + events: NetEvent[]; + callStack: AddressStack; + touchedAddresses: AddressSet; + touchedBlocks: Set; + totalEventLength: number; + loadedPointers: bigint; + storedPointers: bigint; + memoryPagesUsed: bigint; +} +``` + +--- + +## Transaction Types + +```typescript +interface ITransactionInput { + readonly txHash: Uint8Array; + readonly outputIndex: number; + readonly scriptSig: Uint8Array; + readonly flags: number; + readonly coinbase?: Buffer; +} + +interface ITransactionOutput { + readonly index: number; + readonly to?: string; + readonly value: bigint; + readonly scriptPubKey?: Uint8Array; + readonly flags: number; +} + +class TransactionInput { + readonly txHash: Uint8Array; + readonly outputIndex: number; + readonly scriptSig: Uint8Array; + readonly flags: number; + readonly coinbase?: Buffer; + constructor(params: ITransactionInput) +} + +class TransactionOutput { + readonly index: number; + readonly to?: string; + readonly value: bigint; + readonly scriptPubKey: Uint8Array | undefined; + readonly flags: number; + constructor(params: ITransactionOutput) +} + +class Transaction { + readonly id: Uint8Array; + readonly inputs: TransactionInput[]; + readonly outputs: TransactionOutput[]; + constructor(id: Uint8Array, inputs: TransactionInput[], outputs: TransactionOutput[], addDefault?: boolean) + addOutput(value: bigint, receiver: string | undefined, scriptPubKey?: Uint8Array): void + addInput(txHash: Uint8Array, outputIndex: number, scriptSig: Uint8Array): void + addInputWithFlags(input: TransactionInput): void + addOutputWithFlags(output: TransactionOutput): void + serializeInputs(): Uint8Array + serializeOutputs(): Uint8Array +} +``` + +--- + +## RustContractBinding + +The interface for VM bindings between TypeScript and the Rust VM: + +```typescript +interface RustContractBinding { + readonly id: bigint; + readonly load: (data: Buffer) => Promise; + readonly store: (data: Buffer) => Promise; + readonly tLoad: (data: Buffer) => Promise; + readonly tStore: (data: Buffer) => Promise; + readonly call: (data: Buffer) => Promise; + readonly deployContractAtAddress: (data: Buffer) => Promise; + readonly updateFromAddress: (data: Buffer) => Promise; + readonly loadMLDSA: (data: Buffer) => Promise; + readonly log: (data: Buffer) => void; + readonly emit: (data: Buffer) => void; + readonly inputs: () => Promise; + readonly outputs: () => Promise; + readonly accountType: (data: Buffer) => Promise; + readonly blockHash: (blockNumber: bigint) => Promise; +} +``` + +--- + +## ContractParameters + +```typescript +interface ContractParameters extends Omit { + readonly address: string; + readonly bytecode: Buffer; + readonly gasMax: bigint; + readonly gasUsed: bigint; + readonly memoryPagesUsed: bigint; + readonly network: BitcoinNetworkRequest; + readonly isDebugMode: boolean; + readonly bypassCache?: boolean; + readonly contractManager: ContractManager; +} +``` + +--- + +## Consensus Types + +```typescript +interface IOPNetConsensus { + readonly CONSENSUS: T; + readonly CONSENSUS_NAME: string; + readonly GENERIC: { + readonly ENABLED_AT_BLOCK: bigint; + readonly NEXT_CONSENSUS: Consensus; + readonly NEXT_CONSENSUS_BLOCK: bigint; + readonly IS_READY_FOR_NEXT_CONSENSUS: boolean; + readonly ALLOW_LEGACY: boolean; + }; + readonly POW: { + readonly PREIMAGE_LENGTH: number; + }; + readonly CONTRACTS: { + readonly MAXIMUM_CONTRACT_SIZE_COMPRESSED: number; + readonly MAXIMUM_CALLDATA_SIZE_COMPRESSED: number; + }; + readonly COMPRESSION: { + readonly MAX_DECOMPRESSED_SIZE: number; + }; + readonly GAS: { + readonly COST: { readonly COLD_STORAGE_LOAD: bigint }; + readonly GAS_PENALTY_FACTOR: bigint; + readonly TARGET_GAS: bigint; + readonly SMOOTH_OUT_GAS_INCREASE: bigint; + readonly MAX_THEORETICAL_GAS: bigint; + readonly TRANSACTION_MAX_GAS: bigint; + readonly EMULATION_MAX_GAS: bigint; + readonly PANIC_GAS_COST: bigint; + readonly SAT_TO_GAS_RATIO: bigint; + readonly MIN_BASE_GAS: number; + readonly SMOOTHING_FACTOR: number; + readonly ALPHA1: number; + readonly ALPHA2: number; + readonly U_TARGET: number; + }; + readonly TRANSACTIONS: { + readonly EVENTS: { + readonly MAXIMUM_EVENT_LENGTH: number; + readonly MAXIMUM_TOTAL_EVENT_LENGTH: number; + readonly MAXIMUM_EVENT_NAME_LENGTH: number; + }; + readonly MAXIMUM_RECEIPT_LENGTH: number; + readonly MAXIMUM_DEPLOYMENT_DEPTH: number; + readonly MAXIMUM_CALL_DEPTH: number; + readonly STORAGE_COST_PER_BYTE: bigint; + readonly REENTRANCY_GUARD: boolean; + readonly SKIP_PROOF_VALIDATION_FOR_EXECUTION_BEFORE_TRANSACTION: boolean; + readonly ENABLE_ACCESS_LIST: boolean; + }; + readonly VM: { + readonly CURRENT_DEPLOYMENT_VERSION: number; + readonly UTXOS: { + readonly MAXIMUM_INPUTS: number; + readonly MAXIMUM_OUTPUTS: number; + readonly WRITE_FLAGS: boolean; + readonly INPUTS: { readonly WRITE_COINBASE: boolean }; + readonly OUTPUTS: { readonly WRITE_SCRIPT_PUB_KEY: boolean }; + readonly OP_RETURN: { readonly ENABLED: boolean; readonly MAXIMUM_SIZE: number }; + }; + }; + readonly NETWORK: { + readonly MAXIMUM_TRANSACTION_BROADCAST_SIZE: number; + readonly PSBT_MAXIMUM_TRANSACTION_BROADCAST_SIZE: number; + }; + readonly PSBT: { + readonly MINIMAL_PSBT_ACCEPTANCE_FEE_VB_PER_SAT: bigint; + }; +} + +type IOPNetConsensusObj = { [key in Consensus]?: IOPNetConsensus }; +``` + +--- + +## ML-DSA Types + +```typescript +enum MLDSASecurityLevel { + Level2 = 0, + Level3 = 1, + Level5 = 2, +} + +enum MLDSAPublicKeyMetadata { + MLDSA44 = 1312, + MLDSA65 = 1952, + MLDSA87 = 2592, +} +``` + +### Constants + +```typescript +const MLDSA44_PUBLIC_KEY_LEN = 1312; +const MLDSA65_PUBLIC_KEY_LEN = 1952; +const MLDSA87_PUBLIC_KEY_LEN = 2592; +const MLDSA44_PRIVATE_KEY_LEN = 2560; +const MLDSA65_PRIVATE_KEY_LEN = 4032; +const MLDSA87_PRIVATE_KEY_LEN = 4896; +const MLDSA44_SIGNATURE_LEN = 2420; +const MLDSA65_SIGNATURE_LEN = 3309; +const MLDSA87_SIGNATURE_LEN = 4627; +``` + +--- + +## OP20 Event Types + +```typescript +interface TransferredEvent { + readonly operator: Address; + readonly from: Address; + readonly to: Address; + readonly value: bigint; +} + +interface MintedEvent { + readonly to: Address; + readonly value: bigint; +} + +interface BurnedEvent { + readonly from: Address; + readonly value: bigint; +} + +interface ApprovedEvent { + readonly owner: Address; + readonly spender: Address; + readonly value: bigint; +} + +interface OP20Metadata { + readonly name: string; + readonly symbol: string; + readonly decimals: number; + readonly totalSupply: bigint; + readonly maximumSupply: bigint; + readonly icon: string; + readonly domainSeparator: Uint8Array; +} +``` + +--- + +## OP721 Event Types + +```typescript +interface TransferredEventNFT { + readonly operator: Address; + readonly from: Address; + readonly to: Address; + readonly tokenId: bigint; +} + +interface ApprovedEventNFT { + readonly owner: Address; + readonly approved: Address; + readonly tokenId: bigint; +} + +interface ApprovedForAllEvent { + readonly owner: Address; + readonly operator: Address; + readonly approved: boolean; +} + +interface URIEvent { + readonly uri: string; + readonly tokenId: bigint; +} +``` + +--- + +## OP721Extended Event Types + +```typescript +interface ReservationCreatedEvent { + readonly user: Address; + readonly amount: bigint; + readonly blockNumber: bigint; + readonly feePaid: bigint; +} + +interface ReservationClaimedEvent { + readonly user: Address; + readonly amount: bigint; + readonly startTokenId: bigint; +} + +interface ReservationExpiredEvent { + readonly blockNumber: bigint; + readonly totalExpired: bigint; +} + +interface MintStatusChangedEvent { + readonly enabled: boolean; +} + +interface OP721ExtendedStatus { + readonly minted: bigint; + readonly reserved: bigint; + readonly available: bigint; + readonly maxSupply: bigint; + readonly blocksWithReservations: number; + readonly pricePerToken: bigint; + readonly reservationFeePercent: bigint; + readonly minReservationFee: bigint; +} +``` + +--- + +## Full Export List + +```typescript +import { + // Test Runner + opnet, OPNetUnit, + + // Assertions + Assert, Assertion, + + // Blockchain + Blockchain, + + // Contract Runtime + ContractRuntime, CallResponse, BytecodeManager, + + // VM Internals + RustContract, StateHandler, + + // Interfaces + ContractDetails, ExecutionParameters, StateOverride, + RustContractBinding, ContractParameters, + + // Token Helpers + OP20, OP721, OP721Extended, + + // Transaction + Transaction, TransactionInput, TransactionOutput, + generateTransactionId, generateEmptyTransaction, + + // Consensus + ConsensusRules, ConsensusManager, + RoswellConsensus, ConsensusMetadata, + + // Consensus Interfaces + IOPNetConsensus, IOPNetConsensusObj, + + // ML-DSA + MLDSAMetadata, MLDSASecurityLevel, MLDSAPublicKeyMetadata, + MLDSAPublicKeyCache, + + // Utilities + gas2Sat, sat2BTC, gas2BTC, gas2USD, + + // Benchmarking + CustomMap, runBenchmarks, + + // Configuration + configs, +} from '@btc-vision/unit-test-framework'; +``` + +--- + +[<- Previous: Contract Runtime](./contract-runtime.md) | [Next: Utilities ->](./utilities.md) diff --git a/docs/api-reference/utilities.md b/docs/api-reference/utilities.md new file mode 100644 index 0000000..cf78c08 --- /dev/null +++ b/docs/api-reference/utilities.md @@ -0,0 +1,210 @@ +# Utilities Reference + +Helper functions, singletons, and configuration constants. + +--- + +## Transaction Utilities + +**Import:** `import { generateEmptyTransaction, gas2Sat, sat2BTC, gas2BTC, gas2USD } from '@btc-vision/unit-test-framework'` + +### `generateEmptyTransaction(addDefault)` + +```typescript +function generateEmptyTransaction(addDefault: boolean = true): Transaction +``` + +Creates an empty `Transaction` with a random ID. When `addDefault` is `true` (default), adds a default output. + +### `generateTransactionId()` + +```typescript +function generateTransactionId(): Uint8Array +``` + +Returns a random 32-byte transaction ID. + +### `gas2Sat(gas)` + +```typescript +function gas2Sat(gas: bigint): bigint +``` + +Converts gas units to satoshis by dividing by `1_000_000n` (hardcoded). + +### `sat2BTC(satoshis)` + +```typescript +function sat2BTC(satoshis: bigint): number +``` + +Converts satoshis to BTC (divides by 100_000_000). + +### `gas2BTC(gas)` + +```typescript +function gas2BTC(gas: bigint): number +``` + +Converts gas directly to BTC. + +### `gas2USD(gas, btcPrice?)` + +```typescript +function gas2USD(gas: bigint, btcPrice?: number): number +``` + +Converts gas to USD. Default BTC price is `$78,000`. + +### Example + +```typescript +const response = await contract.execute({ calldata }); + +const sats = gas2Sat(response.usedGas); +const btc = gas2BTC(response.usedGas); +const usd = gas2USD(response.usedGas, 100_000); // $100k BTC + +vm.info(`Gas: ${response.usedGas} = ${sats} sats = ${btc} BTC = $${usd.toFixed(4)}`); +``` + +--- + +## StateHandler + +**Import:** `import { StateHandler } from '@btc-vision/unit-test-framework'` + +Singleton for managing global contract state across the VM: + +```typescript +// Override states for a contract +StateHandler.overrideStates(contractAddress, statesMap); + +// Mark a contract as deployed +StateHandler.overrideDeployment(contractAddress); + +// Read global state +const value = StateHandler.globalLoad(contractAddress, pointer); +const exists = StateHandler.globalHas(contractAddress, pointer); + +// Temporary state management (for cross-contract calls) +StateHandler.setTemporaryStates(contractAddress, tempStates); +StateHandler.clearTemporaryStates(contractAddress); +StateHandler.pushAllTempStatesToGlobal(); + +// Reset +StateHandler.resetGlobalStates(contractAddress); +StateHandler.purgeAll(); +``` + +--- + +## ConsensusRules + +**Import:** `import { ConsensusRules, ConsensusManager } from '@btc-vision/unit-test-framework'` + +Bitfield flags for consensus configuration: + +```typescript +// Static flags +ConsensusRules.NONE // 0b00000000 +ConsensusRules.ALLOW_CLASSICAL_SIGNATURES // 0b00000001 +ConsensusRules.UPDATE_CONTRACT_BY_ADDRESS // 0b00000010 + +// Create and manipulate +const rules = ConsensusRules.new(); +rules.insertFlag(ConsensusRules.ALLOW_CLASSICAL_SIGNATURES); +rules.containsFlag(ConsensusRules.ALLOW_CLASSICAL_SIGNATURES); // true +rules.unsafeSignaturesAllowed(); // true + +// Combine flags +const combined = ConsensusRules.combine([ + ConsensusRules.ALLOW_CLASSICAL_SIGNATURES, + ConsensusRules.UPDATE_CONTRACT_BY_ADDRESS, +]); + +// Global consensus manager +ConsensusManager.default(); // Resets to default flags +const flags = ConsensusManager.getFlags(); +``` + +--- + +## MLDSAMetadata + +**Import:** `import { MLDSAMetadata, MLDSASecurityLevel, MLDSAPublicKeyMetadata } from '@btc-vision/unit-test-framework'` + +Utility class for ML-DSA key metadata: + +```typescript +// Convert between security levels and metadata +const metadata = MLDSAMetadata.fromLevel(MLDSASecurityLevel.Level2); +const level = MLDSAMetadata.toLevel(MLDSAPublicKeyMetadata.MLDSA44); + +// Key/signature lengths +const pubKeyLen = MLDSAPublicKeyMetadata.MLDSA44; // 1312 +const privKeyLen = MLDSAMetadata.privateKeyLen(metadata); // 2560 +const sigLen = MLDSAMetadata.signatureLen(metadata); // 2420 + +// Name and validation +const name = MLDSAMetadata.name(metadata); // "ML-DSA-44" +const valid = MLDSAMetadata.isValid(1312); // true +``` + +--- + +## Configuration + +**Import:** `import { configs } from '@btc-vision/unit-test-framework'` + +```typescript +configs.NETWORK; // networks.regtest +configs.VERSION_NAME; // Consensus.Roswell +configs.CONSENSUS; // Full IOPNetConsensus object +configs.TRACE_GAS; // false +configs.TRACE_POINTERS; // false +configs.TRACE_CALLS; // false +configs.TRACE_DEPLOYMENTS; // false +``` + +### Consensus Constants + +Access consensus parameters through `configs.CONSENSUS`: + +```typescript +// Gas +configs.CONSENSUS.GAS.TRANSACTION_MAX_GAS; +configs.CONSENSUS.GAS.EMULATION_MAX_GAS; +configs.CONSENSUS.GAS.TARGET_GAS; +configs.CONSENSUS.GAS.SAT_TO_GAS_RATIO; +configs.CONSENSUS.GAS.PANIC_GAS_COST; +configs.CONSENSUS.GAS.COST.COLD_STORAGE_LOAD; + +// Transaction limits +configs.CONSENSUS.TRANSACTIONS.MAXIMUM_CALL_DEPTH; +configs.CONSENSUS.TRANSACTIONS.MAXIMUM_DEPLOYMENT_DEPTH; +configs.CONSENSUS.TRANSACTIONS.REENTRANCY_GUARD; +configs.CONSENSUS.TRANSACTIONS.STORAGE_COST_PER_BYTE; + +// Event limits +configs.CONSENSUS.TRANSACTIONS.EVENTS.MAXIMUM_EVENT_LENGTH; +configs.CONSENSUS.TRANSACTIONS.EVENTS.MAXIMUM_TOTAL_EVENT_LENGTH; +configs.CONSENSUS.TRANSACTIONS.EVENTS.MAXIMUM_EVENT_NAME_LENGTH; + +// Contract limits +configs.CONSENSUS.CONTRACTS.MAXIMUM_CONTRACT_SIZE_COMPRESSED; +configs.CONSENSUS.CONTRACTS.MAXIMUM_CALLDATA_SIZE_COMPRESSED; + +// VM / UTXO +configs.CONSENSUS.VM.UTXOS.MAXIMUM_INPUTS; +configs.CONSENSUS.VM.UTXOS.MAXIMUM_OUTPUTS; +configs.CONSENSUS.VM.UTXOS.OP_RETURN.ENABLED; +configs.CONSENSUS.VM.UTXOS.OP_RETURN.MAXIMUM_SIZE; + +// Network +configs.CONSENSUS.NETWORK.MAXIMUM_TRANSACTION_BROADCAST_SIZE; +``` + +--- + +[<- Previous: Types & Interfaces](./types-interfaces.md) | [Next: Cross-Contract Calls ->](../advanced/cross-contract-calls.md) diff --git a/docs/built-in-contracts/op20.md b/docs/built-in-contracts/op20.md new file mode 100644 index 0000000..1ada5cb --- /dev/null +++ b/docs/built-in-contracts/op20.md @@ -0,0 +1,268 @@ +# OP20 API Reference + +Complete API reference for the built-in `OP20` fungible token helper. + +**Import:** `import { OP20 } from '@btc-vision/unit-test-framework'` +**Extends:** `ContractRuntime` + +--- + +## Constructor + +```typescript +new OP20(details: OP20Interface) +``` + +### OP20Interface + +```typescript +interface OP20Interface extends ContractDetails { + readonly file: string; // Path to compiled .wasm file + readonly decimals: number; // Token decimals (e.g. 8, 18) +} +``` + +### Example + +```typescript +const token = new OP20({ + address: Blockchain.generateRandomAddress(), + deployer: deployer, + file: './bytecodes/MyToken.wasm', + decimals: 18, + gasLimit: 150_000_000_000n, // Optional +}); +``` + +--- + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `file` | `string` | Path to the WASM bytecode file | +| `decimals` | `number` | Token decimal places | +| `address` | `Address` | Contract address | +| `deployer` | `Address` | Deployer address | +| `gasUsed` | `bigint` | Cumulative gas used | + +--- + +## Read Methods + +### `totalSupply()` + +```typescript +async totalSupply(): Promise +``` + +Returns the total token supply in raw units. + +### `balanceOf(owner)` + +```typescript +async balanceOf(owner: Address): Promise +``` + +Returns the token balance of `owner` in raw units. + +### `balanceOfNoDecimals(owner)` + +```typescript +async balanceOfNoDecimals(owner: Address): Promise +``` + +Returns the token balance of `owner` divided by `10^decimals`. + +### `allowance(owner, spender)` + +```typescript +async allowance(owner: Address, spender: Address): Promise +``` + +Returns the allowance `spender` has to spend from `owner`. + +### `nonceOf(owner)` + +```typescript +async nonceOf(owner: Address): Promise +``` + +Returns the signature nonce for `owner`. + +### `metadata()` + +```typescript +async metadata(): Promise<{ metadata: OP20Metadata; response: CallResponse }> +``` + +Returns token metadata: + +```typescript +interface OP20Metadata { + readonly name: string; + readonly symbol: string; + readonly decimals: number; + readonly totalSupply: bigint; + readonly maximumSupply: bigint; + readonly icon: string; + readonly domainSeparator: Uint8Array; +} +``` + +### `domainSeparator()` + +```typescript +async domainSeparator(): Promise +``` + +Returns the EIP-712-style domain separator (32 bytes). + +--- + +## Write Methods + +### `mint(to, amount)` + +```typescript +async mint(to: Address, amount: number): Promise +``` + +Mints `amount` tokens (in whole units, automatically expanded by `10^decimals`). + +### `mintRaw(to, amount)` + +```typescript +async mintRaw(to: Address, amount: bigint): Promise +``` + +Mints `amount` tokens in raw units (no decimal expansion). + +### `safeTransfer(from, to, amount)` + +```typescript +async safeTransfer(from: Address, to: Address, amount: bigint, data?: Uint8Array): Promise +``` + +Transfers `amount` raw tokens from `from` to `to`. + +### `safeTransferFrom(from, to, amount)` + +```typescript +async safeTransferFrom(from: Address, to: Address, amount: bigint, data?: Uint8Array): Promise +``` + +Transfers `amount` from `from` on behalf of the sender (requires allowance). + +### `burn(from, amount)` + +```typescript +async burn(from: Address, amount: bigint): Promise +``` + +Burns `amount` raw tokens from `from`. + +### `airdrop(map)` + +```typescript +async airdrop(map: AddressMap): Promise +``` + +Batch mints to multiple addresses. + +### `increaseAllowance(owner, spender, amount)` + +```typescript +async increaseAllowance(owner: Address, spender: Address, amount: bigint): Promise +``` + +Increases `spender`'s allowance from `owner` by `amount`. + +### `decreaseAllowance(owner, spender, amount)` + +```typescript +async decreaseAllowance(owner: Address, spender: Address, amount: bigint): Promise +``` + +Decreases `spender`'s allowance from `owner` by `amount`. + +### `increaseAllowanceBySignature(...)` + +```typescript +async increaseAllowanceBySignature( + owner: Address, spender: Address, amount: bigint, + deadline: bigint, signature: Uint8Array +): Promise +``` + +### `decreaseAllowanceBySignature(...)` + +```typescript +async decreaseAllowanceBySignature( + owner: Address, spender: Address, amount: bigint, + deadline: bigint, signature: Uint8Array +): Promise +``` + +--- + +## Static Event Decoders + +### `decodeTransferredEvent(data)` + +```typescript +static decodeTransferredEvent(data: Buffer | Uint8Array): TransferredEvent +``` + +```typescript +interface TransferredEvent { + readonly operator: Address; + readonly from: Address; + readonly to: Address; + readonly value: bigint; +} +``` + +### `decodeMintedEvent(data)` + +```typescript +static decodeMintedEvent(data: Buffer | Uint8Array): MintedEvent +``` + +```typescript +interface MintedEvent { + readonly to: Address; + readonly value: bigint; +} +``` + +### `decodeBurnedEvent(data)` + +```typescript +static decodeBurnedEvent(data: Buffer | Uint8Array): BurnedEvent +``` + +```typescript +interface BurnedEvent { + readonly from: Address; + readonly value: bigint; +} +``` + +### `decodeApprovedEvent(data)` + +```typescript +static decodeApprovedEvent(data: Buffer | Uint8Array): ApprovedEvent +``` + +```typescript +interface ApprovedEvent { + readonly owner: Address; + readonly spender: Address; + readonly value: bigint; +} +``` + +--- + +[<- Previous: Documentation Index](../README.md) | [Next: OP721 ->](./op721.md) diff --git a/docs/built-in-contracts/op721-extended.md b/docs/built-in-contracts/op721-extended.md new file mode 100644 index 0000000..de81e19 --- /dev/null +++ b/docs/built-in-contracts/op721-extended.md @@ -0,0 +1,213 @@ +# OP721Extended API Reference + +Extended NFT helper with reservation-based minting. Inherits all methods from [OP721](./op721.md). + +**Import:** `import { OP721Extended } from '@btc-vision/unit-test-framework'` +**Extends:** `OP721` + +--- + +## Constructor + +```typescript +new OP721Extended(details: ExtendedOP721Configuration) +``` + +```typescript +interface ExtendedOP721Configuration extends OP721Interface { + readonly mintPrice?: bigint; // Default: 100000n + readonly reservationFeePercent?: bigint; // Default: 15n + readonly minReservationFee?: bigint; // Default: 1000n + readonly reservationBlocks?: bigint; // Default: 5n + readonly graceBlocks?: bigint; // Default: 1n + readonly maxReservationAmount?: number; // Default: 20 +} +``` + +### Example + +```typescript +const nft = new OP721Extended({ + address: contractAddress, + deployer: deployer, + file: './bytecodes/MyNFT.wasm', + mintPrice: 50000n, + reservationFeePercent: 10n, + maxReservationAmount: 50, +}); +``` + +--- + +## Properties + +| Property | Type | Default | +|----------|------|---------| +| `MINT_PRICE` | `bigint` | `100000n` | +| `RESERVATION_FEE_PERCENT` | `bigint` | `15n` | +| `MIN_RESERVATION_FEE` | `bigint` | `1000n` | +| `RESERVATION_BLOCKS` | `bigint` | `5n` | +| `GRACE_BLOCKS` | `bigint` | `1n` | +| `MAX_RESERVATION_AMOUNT` | `number` | `20` | + +--- + +## Admin Methods + +### `setMintEnabled(enabled)` + +```typescript +async setMintEnabled(enabled: boolean): Promise +``` + +### `isMintEnabled()` + +```typescript +async isMintEnabled(): Promise +``` + +--- + +## Reservation Methods + +### `reserve(quantity, sender)` + +```typescript +async reserve(quantity: bigint, sender: Address): Promise +``` + +```typescript +interface ReservationResponse { + readonly remainingPayment: bigint; + readonly reservationBlock: bigint; +} +``` + +### `claim(sender)` + +```typescript +async claim(sender: Address): Promise +``` + +```typescript +interface ClaimResponse { + readonly startTokenId: bigint; + readonly amountClaimed: bigint; +} +``` + +### `purgeExpired()` + +```typescript +async purgeExpired(): Promise +``` + +```typescript +interface PurgeResponse { + readonly totalPurged: bigint; + readonly blocksProcessed: number; +} +``` + +### `getStatus()` + +```typescript +async getStatus(): Promise +``` + +```typescript +interface OP721ExtendedStatus { + readonly minted: bigint; + readonly reserved: bigint; + readonly available: bigint; + readonly maxSupply: bigint; + readonly blocksWithReservations: number; + readonly pricePerToken: bigint; + readonly reservationFeePercent: bigint; + readonly minReservationFee: bigint; +} +``` + +### `getReservationInfo()` + +```typescript +async getReservationInfo(): Promise<{ + status: OP721ExtendedStatus; + constants: { + mintPrice: string; + reservationFeePercent: string; + minReservationFee: string; + reservationBlocks: number; + graceBlocks: number; + maxReservationAmount: number; + totalExpiryBlocks: number; + }; +}> +``` + +### `onOP721Received(...)` + +```typescript +async onOP721Received( + operator: Address, from: Address, tokenId: bigint, + data: Uint8Array, receiver: Address +): Promise +``` + +--- + +## Utility Methods (Non-async) + +```typescript +calculateReservationFee(quantity: bigint): bigint +calculateRemainingPayment(quantity: bigint): bigint +isReservationExpired(reservationBlock: bigint, currentBlock: bigint): boolean +getBlocksUntilExpiry(reservationBlock: bigint, currentBlock: bigint): bigint +static formatBTC(sats: bigint): string +``` + +--- + +## Static Event Decoders + +### `decodeReservationCreatedEvent(data)` + +```typescript +interface ReservationCreatedEvent { + readonly user: Address; + readonly amount: bigint; + readonly blockNumber: bigint; + readonly feePaid: bigint; +} +``` + +### `decodeReservationClaimedEvent(data)` + +```typescript +interface ReservationClaimedEvent { + readonly user: Address; + readonly amount: bigint; + readonly startTokenId: bigint; +} +``` + +### `decodeReservationExpiredEvent(data)` + +```typescript +interface ReservationExpiredEvent { + readonly blockNumber: bigint; + readonly totalExpired: bigint; +} +``` + +### `decodeMintStatusChangedEvent(data)` + +```typescript +interface MintStatusChangedEvent { + readonly enabled: boolean; +} +``` + +--- + +[<- Previous: OP721](./op721.md) | [Next: Assertions API ->](../api-reference/assertions.md) diff --git a/docs/built-in-contracts/op721.md b/docs/built-in-contracts/op721.md new file mode 100644 index 0000000..a71fec5 --- /dev/null +++ b/docs/built-in-contracts/op721.md @@ -0,0 +1,184 @@ +# OP721 API Reference + +Complete API reference for the built-in `OP721` non-fungible token helper. + +**Import:** `import { OP721 } from '@btc-vision/unit-test-framework'` +**Extends:** `ContractRuntime` + +--- + +## Constructor + +```typescript +new OP721(details: OP721Interface) +``` + +```typescript +interface OP721Interface extends ContractDetails { + readonly file: string; // Path to compiled .wasm file +} +``` + +### Example + +```typescript +const nft = new OP721({ + address: Blockchain.generateRandomAddress(), + deployer: deployer, + file: './bytecodes/MyNFT.wasm', +}); +``` + +--- + +## Read Methods + +| Method | Signature | Returns | +|--------|-----------|---------| +| `name()` | `async name(): Promise` | Collection name | +| `symbol()` | `async symbol(): Promise` | Collection symbol | +| `totalSupply()` | `async totalSupply(): Promise` | Total minted tokens | +| `maxSupply()` | `async maxSupply(): Promise` | Maximum supply | +| `balanceOf(owner)` | `async balanceOf(owner: Address): Promise` | Token count of owner | +| `ownerOf(tokenId)` | `async ownerOf(tokenId: bigint): Promise
` | Owner of token | +| `tokenURI(tokenId)` | `async tokenURI(tokenId: bigint): Promise` | Token metadata URI | +| `getApproved(tokenId)` | `async getApproved(tokenId: bigint): Promise
` | Approved spender | +| `isApprovedForAll(owner, operator)` | `async isApprovedForAll(owner: Address, operator: Address): Promise` | Operator approval | +| `tokenOfOwnerByIndex(owner, index)` | `async tokenOfOwnerByIndex(owner: Address, index: bigint): Promise` | Token ID at index | +| `getAllTokensOfOwner(owner)` | `async getAllTokensOfOwner(owner: Address): Promise` | All token IDs | +| `getTransferNonce(owner)` | `async getTransferNonce(owner: Address): Promise` | Transfer signature nonce | +| `getApproveNonce(owner)` | `async getApproveNonce(owner: Address): Promise` | Approval signature nonce | +| `domainSeparator()` | `async domainSeparator(): Promise` | Domain separator (32 bytes) | + +--- + +## Write Methods + +### `transferFrom(from, to, tokenId, sender?)` + +```typescript +async transferFrom( + from: Address, to: Address, tokenId: bigint, sender?: Address +): Promise +``` + +Transfers `tokenId` from `from` to `to`. The optional `sender` parameter overrides `msg.sender`. + +### `safeTransferFrom(from, to, tokenId, data?, sender?)` + +```typescript +async safeTransferFrom( + from: Address, to: Address, tokenId: bigint, + data?: Uint8Array, sender?: Address +): Promise +``` + +Safe transfer with optional callback data. + +### `approve(spender, tokenId, sender)` + +```typescript +async approve(spender: Address, tokenId: bigint, sender: Address): Promise +``` + +Approves `spender` to transfer `tokenId`. + +### `setApprovalForAll(operator, approved, sender)` + +```typescript +async setApprovalForAll( + operator: Address, approved: boolean, sender: Address +): Promise +``` + +Grants or revokes operator approval for all tokens. + +### `burn(tokenId, sender)` + +```typescript +async burn(tokenId: bigint, sender: Address): Promise +``` + +Burns `tokenId`. + +### `setBaseURI(baseURI)` + +```typescript +async setBaseURI(baseURI: string): Promise +``` + +Sets the base URI for token metadata. + +### `transferBySignature(...)` + +```typescript +async transferBySignature( + owner: Address, to: Address, tokenId: bigint, + deadline: bigint, signature: Uint8Array +): Promise +``` + +### `approveBySignature(...)` + +```typescript +async approveBySignature( + owner: Address, spender: Address, tokenId: bigint, + deadline: bigint, signature: Uint8Array +): Promise +``` + +--- + +## Static Event Decoders + +### `decodeTransferredEvent(data)` + +```typescript +static decodeTransferredEvent(data: Buffer | Uint8Array): TransferredEventNFT + +interface TransferredEventNFT { + readonly operator: Address; + readonly from: Address; + readonly to: Address; + readonly tokenId: bigint; +} +``` + +### `decodeApprovedEvent(data)` + +```typescript +static decodeApprovedEvent(data: Buffer | Uint8Array): ApprovedEventNFT + +interface ApprovedEventNFT { + readonly owner: Address; + readonly approved: Address; + readonly tokenId: bigint; +} +``` + +### `decodeApprovedForAllEvent(data)` + +```typescript +static decodeApprovedForAllEvent(data: Buffer | Uint8Array): ApprovedForAllEvent + +interface ApprovedForAllEvent { + readonly owner: Address; + readonly operator: Address; + readonly approved: boolean; +} +``` + +### `decodeURIEvent(data)` + +```typescript +static decodeURIEvent(data: Buffer | Uint8Array): URIEvent + +interface URIEvent { + readonly uri: string; + readonly tokenId: bigint; +} +``` + +--- + +[<- Previous: OP20](./op20.md) | [Next: OP721Extended ->](./op721-extended.md) diff --git a/docs/examples/block-replay.md b/docs/examples/block-replay.md new file mode 100644 index 0000000..18e1d4c --- /dev/null +++ b/docs/examples/block-replay.md @@ -0,0 +1,237 @@ +# Block Replay + +Replay real mainnet/testnet transactions in a test environment for debugging production issues. + +--- + +## Overview + +Block replay loads contract state and transactions from exported data (e.g., MongoDB dumps) and re-executes them locally against the OP_VM. + +```mermaid +flowchart LR + A[Export State JSON] --> B[Load into StateHandler] + B --> C[Register Contracts] + C --> D[Replay Transactions] + D --> E[Verify Results] +``` + +--- + +## Loading State + +Load contract state from JSON files: + +```typescript +import { StateHandler, Blockchain, ContractRuntime, BytecodeManager } from '@btc-vision/unit-test-framework'; +import { Address } from '@btc-vision/transaction'; +import * as fs from 'fs'; + +// Parse state file (pointer -> value mapping) +function loadStatesFromFile(filePath: string): FastMap { + const raw = fs.readFileSync(filePath, 'utf-8'); + const states = new FastMap(); + + // Parse your export format (e.g., JSON array of [pointer, value]) + const entries = JSON.parse(raw); + for (const [key, value] of entries) { + states.set(BigInt(key), BigInt(value)); + } + + return states; +} + +// Load state for a contract +const contractAddress = Address.fromString('0x...'); +const states = loadStatesFromFile('./state-dump/nativeswap-state.json'); + +StateHandler.overrideStates(contractAddress, states); +StateHandler.overrideDeployment(contractAddress); +``` + +--- + +## Contract Manager Pattern + +Create a `ContractManager` class that loads all contracts involved: + +```typescript +class ContractManager { + private contracts: Map = new Map(); + + async loadContract( + address: Address, + deployer: Address, + wasmPath: string, + statePath?: string, + ): Promise { + const contract = new GenericContractRuntime(deployer, address, wasmPath); + Blockchain.register(contract); + await contract.init(); + + if (statePath) { + const states = loadStatesFromFile(statePath); + StateHandler.overrideStates(address, states); + StateHandler.overrideDeployment(address); + } + + this.contracts.set(address.toHex(), contract); + return contract; + } + + dispose(): void { + for (const contract of this.contracts.values()) { + contract.dispose(); + } + } +} +``` + +--- + +## Replaying Transactions + +Load and re-execute transactions from exported data: + +```typescript +import { Transaction, TransactionInput, TransactionOutput, generateTransactionId } from '@btc-vision/unit-test-framework'; + +interface RawTransaction { + inputs: Array<{ txHash: string; outputIndex: number }>; + outputs: Array<{ to: string; value: string }>; + calldata: string; + sender: string; +} + +async function replayTransaction( + contract: ContractRuntime, + rawTx: RawTransaction, +): Promise { + // Build Bitcoin transaction context + const tx = new Transaction( + generateTransactionId(), + rawTx.inputs.map(i => new TransactionInput({ + txHash: Buffer.from(i.txHash, 'hex'), + outputIndex: i.outputIndex, + scriptSig: new Uint8Array(0), + flags: 0, + })), + [], + ); + + for (const out of rawTx.outputs) { + tx.addOutput(BigInt(out.value), out.to); + } + + // Set transaction context + Blockchain.transaction = tx; + Blockchain.msgSender = Address.fromString(rawTx.sender); + Blockchain.txOrigin = Address.fromString(rawTx.sender); + + // Execute + const calldata = Buffer.from(rawTx.calldata, 'hex'); + const response = await contract.execute({ calldata }); + + if (response.error) { + console.error('Transaction reverted:', response.error.message); + } else { + console.log('Gas used:', response.usedGas); + } +} +``` + +--- + +## Full Replay Test + +```typescript +import { opnet, OPNetUnit, Blockchain } from '@btc-vision/unit-test-framework'; + +await opnet('Block Replay', async (vm: OPNetUnit) => { + const manager = new ContractManager(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + // Load all contracts with their state + await manager.loadContract( + nativeSwapAddress, deployer, + './bytecodes/NativeSwap.wasm', + './state-dump/nativeswap.json', + ); + + await manager.loadContract( + tokenAddress, deployer, + './bytecodes/Token.wasm', + './state-dump/token.json', + ); + }); + + vm.afterEach(() => { + manager.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should replay block 12345', async () => { + // Load transactions for this block + const transactions = JSON.parse( + fs.readFileSync('./blocks/block-12345.json', 'utf-8'), + ); + + Blockchain.blockNumber = 12345n; + + for (const rawTx of transactions) { + await replayTransaction(contract, rawTx); + } + + // Verify final state matches expected + const reserve = await swap.getReserve(); + vm.info(`Final reserves: ${reserve.tokenReserve} / ${reserve.btcReserve}`); + }); +}); +``` + +--- + +## Streaming JSON Parser + +For large state files, use a streaming parser to avoid memory issues: + +```typescript +import { createReadStream } from 'fs'; +import { createInterface } from 'readline'; + +async function* streamStates(filePath: string): AsyncGenerator<[bigint, bigint]> { + const stream = createReadStream(filePath, { encoding: 'utf-8' }); + const rl = createInterface({ input: stream }); + + for await (const line of rl) { + const parsed = JSON.parse(line); + yield [BigInt(parsed.pointer), BigInt(parsed.value)]; + } +} + +async function loadLargeStates(filePath: string): Promise> { + const states = new FastMap(); + for await (const [key, value] of streamStates(filePath)) { + states.set(key, value); + } + return states; +} +``` + +--- + +## Tips + +- Export state at a specific block height for reproducible debugging +- Load all contracts that participate in cross-contract calls +- Set `Blockchain.blockNumber` to match the block being replayed +- Compare `response.usedGas` against the original transaction receipt +- Use `Blockchain.traceGas = true` for detailed gas breakdowns + +--- + +[<- Previous: NativeSwap Testing](./nativeswap-testing.md) | [Home: Documentation ->](../README.md) diff --git a/docs/examples/nativeswap-testing.md b/docs/examples/nativeswap-testing.md new file mode 100644 index 0000000..d755b14 --- /dev/null +++ b/docs/examples/nativeswap-testing.md @@ -0,0 +1,302 @@ +# NativeSwap Testing Example + +This example shows how to test a complex DeFi contract (NativeSwap) with multiple contract interactions, liquidity management, and event decoding. Based on the real tests in [opnet-unit-test](https://github.com/btc-vision/opnet-unit-test). + +--- + +## Architecture + +```mermaid +flowchart TB + subgraph Contracts + NS[NativeSwap] + Token[OP20 Token] + Staking[Staking Contract] + end + + subgraph Test Helpers + TH[TokenHelper] + PH[ProviderHelper] + OH[OperationsHelper] + end + + OH --> NS + OH --> Token + TH --> Token + PH --> NS +``` + +--- + +## Contract Wrapper + +Create a typed wrapper for NativeSwap extending `ContractRuntime`: + +```typescript +import { Address, BinaryReader, BinaryWriter } from '@btc-vision/transaction'; +import { BytecodeManager, CallResponse, ContractRuntime } from '@btc-vision/unit-test-framework'; + +export class NativeSwap extends ContractRuntime { + private readonly createPoolSelector = this.getSelector('createPool(...)'); + private readonly listLiquiditySelector = this.getSelector('listLiquidity(...)'); + private readonly reserveSelector = this.getSelector('reserve(...)'); + private readonly swapSelector = this.getSelector('swap(...)'); + private readonly getReserveSelector = this.getSelector('getReserve()'); + + constructor(deployer: Address, address: Address) { + super({ address, deployer, gasLimit: 350_000_000_000n }); + } + + public async createPool(tokenAddress: Address, floorPrice: bigint, ...): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.createPoolSelector); + calldata.writeAddress(tokenAddress); + calldata.writeU256(floorPrice); + // ... encode remaining parameters + + const response = await this.execute({ calldata: calldata.getBuffer() }); + if (response.error) throw this.handleError(response.error); + return response; + } + + public async getReserve(): Promise<{ tokenReserve: bigint; btcReserve: bigint }> { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.getReserveSelector); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + if (response.error) throw this.handleError(response.error); + + const reader = new BinaryReader(response.response); + return { + tokenReserve: reader.readU256(), + btcReserve: reader.readU256(), + }; + } + + // ... more methods + + protected handleError(error: Error): Error { + return new Error(`(NativeSwap) ${error.message}`); + } + + protected defineRequiredBytecodes(): void { + BytecodeManager.loadBytecode('./bytecodes/NativeSwap.wasm', this.address); + } + + private getSelector(sig: string): number { + return Number(`0x${this.abiCoder.encodeSelector(sig)}`); + } +} +``` + +--- + +## Test Structure + +```typescript +import { opnet, OPNetUnit, Assert, Blockchain, OP20 } from '@btc-vision/unit-test-framework'; +import { Address } from '@btc-vision/transaction'; +import { NativeSwap } from '../contracts/NativeSwap.js'; + +await opnet('NativeSwap Pool Tests', async (vm: OPNetUnit) => { + let swap: NativeSwap; + let token: OP20; + + const deployer = Blockchain.generateRandomAddress(); + const provider = Blockchain.generateRandomAddress(); + const buyer = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + // Create and register all contracts + token = new OP20({ + address: Blockchain.generateRandomAddress(), + deployer, file: './bytecodes/Token.wasm', decimals: 18, + }); + + swap = new NativeSwap(deployer, Blockchain.generateRandomAddress()); + + Blockchain.register(token); + Blockchain.register(swap); + + await token.init(); + await swap.init(); + + Blockchain.txOrigin = deployer; + Blockchain.msgSender = deployer; + }); + + vm.afterEach(() => { + token.dispose(); + swap.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should create a pool', async () => { + const response = await swap.createPool( + token.address, + Blockchain.expandTo18Decimals(1), // floor price + ); + + Assert.expect(response.usedGas).toBeGreaterThan(0n); + vm.success('Pool created'); + }); + + await vm.it('should list liquidity', async () => { + // Create pool first + await swap.createPool(token.address, Blockchain.expandTo18Decimals(1)); + + // Mint tokens to provider + await token.mintRaw(provider, Blockchain.expandTo18Decimals(10000)); + + // List liquidity + Blockchain.msgSender = provider; + const response = await swap.listLiquidity( + token.address, + Blockchain.expandTo18Decimals(1000), + ); + + // Check events + for (const event of response.events) { + vm.info(`Event: ${event.type}`); + } + }); + + await vm.it('should execute a swap', async () => { + // Setup: create pool, list liquidity, reserve + // ... + + const reserveBefore = await swap.getReserve(); + + Blockchain.msgSender = buyer; + const response = await swap.swap(/* ... */); + + const reserveAfter = await swap.getReserve(); + + // Verify reserves changed + Assert.expect(reserveAfter.tokenReserve).toNotEqual(reserveBefore.tokenReserve); + }); +}); +``` + +--- + +## Helper Pattern + +For complex DeFi testing, create helper classes that wrap common operations: + +### Token Balance Helper + +```typescript +class TokenHelper { + constructor(private token: OP20) {} + + async assertBalance(address: Address, expected: bigint, label: string): Promise { + const balance = await this.token.balanceOf(address); + Assert.expect(balance).toEqual(expected); + } + + async assertBalanceChanged( + address: Address, + balanceBefore: bigint, + expectedChange: bigint, + ): Promise { + const balanceAfter = await this.token.balanceOf(address); + Assert.expect(balanceAfter - balanceBefore).toEqual(expectedChange); + } +} +``` + +### Operations Helper + +```typescript +class OperationsHelper { + constructor( + private swap: NativeSwap, + private token: OP20, + ) {} + + async createPoolAndList( + deployer: Address, + provider: Address, + tokenAmount: bigint, + ): Promise { + // Create pool + Blockchain.msgSender = deployer; + await this.swap.createPool(this.token.address, Blockchain.expandTo18Decimals(1)); + + // Mint and list + await this.token.mintRaw(provider, tokenAmount); + Blockchain.msgSender = provider; + await this.swap.listLiquidity(this.token.address, tokenAmount); + } + + async reserveAndSwap(buyer: Address, amount: bigint): Promise { + Blockchain.msgSender = buyer; + await this.swap.reserve(amount); + + return await this.swap.swap(/* ... */); + } +} +``` + +--- + +## Event Decoding + +Decode contract events for validation: + +```typescript +await vm.it('should emit correct events on swap', async () => { + const response = await swap.swap(/* ... */); + + for (const event of response.events) { + switch (event.type) { + case 'SwapExecuted': { + const decoded = decodeSwapEvent(event.data); + vm.info(`Swapped: ${decoded.amountIn} -> ${decoded.amountOut}`); + break; + } + case 'Transfer': { + const decoded = OP20.decodeTransferredEvent(event.data); + vm.info(`Transfer: ${decoded.from.toHex()} -> ${decoded.to.toHex()}: ${decoded.value}`); + break; + } + } + } +}); +``` + +--- + +## Reentrancy Testing + +Test that contracts are protected against reentrancy: + +```typescript +await vm.it('should block reentrant calls', async () => { + // ReentrantToken is an OP20 that calls back into NativeSwap + const reentrantToken = new ReentrantToken({ + address: Blockchain.generateRandomAddress(), + deployer, file: './bytecodes/ReentrantToken.wasm', decimals: 18, + }); + + Blockchain.register(reentrantToken); + await reentrantToken.init(); + + // Set callback to re-enter swap during transfer + await reentrantToken.setCallback('swap'); + + // Should revert due to reentrancy guard + await Assert.expect(async () => { + await swap.swap(/* ... */); + }).toThrow(); +}); +``` + +--- + +[<- Previous: Consensus Rules](../advanced/consensus-rules.md) | [Next: Block Replay ->](./block-replay.md) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..71f6db9 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,109 @@ +# Installation + +This guide covers installing and configuring the OPNet Unit Test Framework. + +## Prerequisites + +- **Node.js** >= 22 +- **Rust toolchain** - Required for building `@btc-vision/op-vm` (the WebAssembly VM runtime) + +### Installing Rust + +If you don't have Rust installed: + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +## Install the Package + +```bash +npm install @btc-vision/unit-test-framework +``` + +### Peer Dependencies + +The framework automatically installs these transitive dependencies: + +- `@btc-vision/transaction` - Address, BinaryReader/Writer, ABICoder, signatures +- `@btc-vision/bitcoin` - Bitcoin network definitions +- `@btc-vision/op-vm` - The Rust-based OPNet virtual machine (native addon) + +## Project Setup + +### TypeScript Configuration + +Create a `tsconfig.json` for your project: + +```json +{ + "compilerOptions": { + "module": "ESNext", + "target": "ESNext", + "strict": true, + "skipLibCheck": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "lib": ["ESNext"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules"] +} +``` + +### Package Configuration + +Ensure your `package.json` has `"type": "module"` for ESM support: + +```json +{ + "type": "module", + "scripts": { + "test": "tsx test/my-contract.test.ts", + "test:all": "npm-run-all test:*" + } +} +``` + +### Install tsx + +Tests run directly via `tsx` (TypeScript executor), not through a build step: + +```bash +npm install -D tsx +``` + +## Verify Installation + +Create a minimal test file `test/sanity.test.ts`: + +```typescript +import { opnet, OPNetUnit, Assert, Blockchain } from '@btc-vision/unit-test-framework'; + +await opnet('Sanity Check', async (vm: OPNetUnit) => { + vm.beforeEach(async () => { + await Blockchain.init(); + }); + + vm.afterEach(() => { + Blockchain.dispose(); + }); + + await vm.it('should generate addresses', async () => { + const addr = Blockchain.generateRandomAddress(); + Assert.expect(addr).toBeDefined(); + }); +}); +``` + +Run it: + +```bash +npx tsx test/sanity.test.ts +``` + +If you see a green pass message, you're ready to write tests. + +--- + +[Next: Quick Start ->](./quick-start.md) diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md new file mode 100644 index 0000000..95e2ede --- /dev/null +++ b/docs/getting-started/quick-start.md @@ -0,0 +1,258 @@ +# Quick Start + +This guide walks you through writing your first OPNet contract test, from project setup to a passing test suite. + +## What We're Building + +By the end of this guide, you'll be able to: + +1. Create a contract runtime wrapper +2. Deploy and interact with a contract in tests +3. Assert on contract behavior + +```mermaid +flowchart LR + A[Write Runtime Wrapper] --> B[Register Contract] + B --> C[Execute Methods] + C --> D[Assert Results] +``` + +--- + +## Step 1: Create a Contract Runtime Wrapper + +Every contract needs a typed wrapper that extends `ContractRuntime`. This maps TypeScript methods to the contract's ABI selectors: + +```typescript +// test/runtime/MyTokenRuntime.ts +import { Address, BinaryReader, BinaryWriter } from '@btc-vision/transaction'; +import { BytecodeManager, CallResponse, ContractRuntime } from '@btc-vision/unit-test-framework'; + +export class MyTokenRuntime extends ContractRuntime { + private readonly balanceOfSelector: number = this.getSelector('balanceOf(address)'); + private readonly mintSelector: number = this.getSelector('mint(address,uint256)'); + + public constructor(deployer: Address, address: Address) { + super({ + address: address, + deployer: deployer, + gasLimit: 150_000_000_000n, + }); + } + + public async balanceOf(owner: Address): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.balanceOfSelector); + calldata.writeAddress(owner); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + + const reader = new BinaryReader(response.response); + return reader.readU256(); + } + + public async mint(to: Address, amount: bigint): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.mintSelector); + calldata.writeAddress(to); + calldata.writeU256(amount); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + return response; + } + + protected handleError(error: Error): Error { + return new Error(`(MyToken: ${this.address}) OP_NET: ${error.message}`); + } + + protected defineRequiredBytecodes(): void { + BytecodeManager.loadBytecode('./bytecodes/MyToken.wasm', this.address); + } + + private getSelector(signature: string): number { + return Number(`0x${this.abiCoder.encodeSelector(signature)}`); + } + + private handleResponse(response: CallResponse): void { + if (response.error) throw this.handleError(response.error); + if (!response.response) throw new Error('No response to decode'); + } +} +``` + +--- + +## Step 2: Write the Test + +```typescript +// test/my-token.test.ts +import { Address } from '@btc-vision/transaction'; +import { opnet, OPNetUnit, Assert, Blockchain } from '@btc-vision/unit-test-framework'; +import { MyTokenRuntime } from './runtime/MyTokenRuntime.js'; + +await opnet('MyToken Tests', async (vm: OPNetUnit) => { + let token: MyTokenRuntime; + + const deployer: Address = Blockchain.generateRandomAddress(); + const alice: Address = Blockchain.generateRandomAddress(); + const contractAddress: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + token = new MyTokenRuntime(deployer, contractAddress); + Blockchain.register(token); + await token.init(); + + Blockchain.txOrigin = deployer; + Blockchain.msgSender = deployer; + }); + + vm.afterEach(() => { + token.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should mint tokens', async () => { + await token.mint(alice, 1000n); + + const balance = await token.balanceOf(alice); + Assert.expect(balance).toEqual(1000n); + }); + + await vm.it('should start with zero balance', async () => { + const balance = await token.balanceOf(alice); + Assert.expect(balance).toEqual(0n); + }); + + await vm.it('should track gas usage', async () => { + const response = await token.mint(alice, 500n); + + vm.info(`Gas used for mint: ${response.usedGas}`); + Assert.expect(response.usedGas).toBeGreaterThan(0n); + }); + + await vm.it('should emit events', async () => { + const response = await token.mint(alice, 100n); + + Assert.expect(response.events.length).toBeGreaterThan(0); + vm.info(`Events emitted: ${response.events.length}`); + }); +}); +``` + +--- + +## Step 3: Run the Test + +```bash +npx tsx test/my-token.test.ts +``` + +You'll see output like: + +``` +[MyToken Tests] Starting... + [PASS] should mint tokens (12ms) + [PASS] should start with zero balance (3ms) + [PASS] should track gas usage (8ms) + [PASS] should emit events (7ms) +[MyToken Tests] All tests passed! +``` + +--- + +## Using Built-in Contract Helpers + +For standard OP20/OP721 contracts, you don't need to write a custom runtime. The framework provides ready-made helpers: + +```typescript +import { opnet, OPNetUnit, Assert, Blockchain, OP20 } from '@btc-vision/unit-test-framework'; +import { Address } from '@btc-vision/transaction'; + +await opnet('OP20 Quick Test', async (vm: OPNetUnit) => { + let token: OP20; + const deployer: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + token = new OP20({ + address: Blockchain.generateRandomAddress(), + deployer: deployer, + file: './bytecodes/MyToken.wasm', + decimals: 18, + }); + + Blockchain.register(token); + await token.init(); + + Blockchain.msgSender = deployer; + Blockchain.txOrigin = deployer; + }); + + vm.afterEach(() => { + token.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should read token metadata', async () => { + const { metadata } = await token.metadata(); + + vm.info(`Name: ${metadata.name}`); + vm.info(`Symbol: ${metadata.symbol}`); + vm.info(`Decimals: ${metadata.decimals}`); + }); + + await vm.it('should mint and check balance', async () => { + const receiver = Blockchain.generateRandomAddress(); + await token.mint(receiver, 1000); + + const balance = await token.balanceOf(receiver); + Assert.expect(balance).toEqual(Blockchain.expandToDecimal(1000, 18)); + }); +}); +``` + +--- + +## Test Lifecycle + +```mermaid +sequenceDiagram + participant Test as Test Runner + participant BEach as beforeEach + participant It as Test Case + participant AEach as afterEach + + Test->>BEach: Init blockchain & contracts + BEach->>It: Run test logic + It->>AEach: Cleanup + Note over Test: Repeat for each test +``` + +Every test follows this lifecycle: + +1. **`beforeEach`** - Initialize the blockchain, create and register contracts +2. **`vm.it`** - Execute test logic and assertions +3. **`afterEach`** - Dispose contracts and clean up state + +This ensures each test runs in isolation with a fresh blockchain state. + +--- + +## Next Steps + +- [Basic Tests](../writing-tests/basic-tests.md) - Deep dive into test structure +- [OP20 Token Tests](../writing-tests/op20-tokens.md) - Full OP20 testing guide +- [Custom Contracts](../writing-tests/custom-contracts.md) - Advanced runtime wrappers + +--- + +[<- Previous: Installation](./installation.md) | [Next: Basic Tests ->](../writing-tests/basic-tests.md) diff --git a/docs/writing-tests/basic-tests.md b/docs/writing-tests/basic-tests.md new file mode 100644 index 0000000..fec720e --- /dev/null +++ b/docs/writing-tests/basic-tests.md @@ -0,0 +1,280 @@ +# Basic Tests + +This guide covers the test runner, lifecycle hooks, and logging. + +## Table of Contents + +- [Test Runner](#test-runner) +- [Lifecycle Hooks](#lifecycle-hooks) +- [Logging](#logging) +- [Error Testing](#error-testing) +- [Standard Test Pattern](#standard-test-pattern) + +--- + +## Test Runner + +Tests are organized using the `opnet()` function, which creates an `OPNetUnit` test runner: + +```typescript +import { opnet, OPNetUnit } from '@btc-vision/unit-test-framework'; + +await opnet('Suite Name', async (vm: OPNetUnit) => { + // Register lifecycle hooks + // Define tests with vm.it() +}); +``` + +The `opnet()` function: + +1. Creates an `OPNetUnit` instance with the suite name +2. Invokes your callback with the instance +3. Calls cleanup (`afterAll`) in a `finally` block +4. Logs panics with stack traces if anything throws + +### Defining Tests + +Each test is defined with `vm.it()` and runs immediately (tests are sequential): + +```typescript +await vm.it('should do something', async () => { + // Test logic here +}); + +await vm.it('should do something else', async () => { + // Another test +}); +``` + +Tests always `await` because contract calls are async. + +--- + +## Lifecycle Hooks + +```mermaid +flowchart TD + BA[beforeAll] --> BE1[beforeEach] + BE1 --> T1[Test 1] + T1 --> AE1[afterEach] + AE1 --> BE2[beforeEach] + BE2 --> T2[Test 2] + T2 --> AE2[afterEach] + AE2 --> AA[afterAll] +``` + +### beforeAll + +Runs once before all tests. Called immediately (not deferred): + +```typescript +await vm.beforeAll(async () => { + // One-time expensive setup +}); +``` + +### beforeEach + +Runs before every test. This is where you initialize the blockchain and contracts: + +```typescript +vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + // Create and register contracts + contract = new MyContract(deployer, address); + Blockchain.register(contract); + await contract.init(); + + // Set transaction context + Blockchain.txOrigin = deployer; + Blockchain.msgSender = deployer; +}); +``` + +### afterEach + +Runs after every test. Clean up resources: + +```typescript +vm.afterEach(() => { + contract.dispose(); + Blockchain.dispose(); +}); +``` + +### afterAll + +Runs once after all tests. Final cleanup. Note: `Blockchain.cleanup()` is called automatically by the framework after `afterAll` runs, so you do not need to call it yourself. + +```typescript +vm.afterAll(() => { + contract.delete(); + Blockchain.dispose(); +}); +``` + +--- + +## Logging + +`OPNetUnit` extends `Logger` from `@btc-vision/logger`. Use these methods inside tests: + +```typescript +await vm.it('my test', async () => { + vm.log('General log message'); + vm.info('Info message'); + vm.success('Success message'); + vm.warn('Warning message'); + vm.error('Error message'); + vm.debug('Debug message'); + vm.panic('Fatal error message'); +}); +``` + +Each log level has a different color in the terminal output. Use `vm.info()` for diagnostic output and `vm.success()` for confirmation messages. + +--- + +## Error Testing + +### Testing that a call throws + +```typescript +await vm.it('should revert on invalid input', async () => { + await Assert.expect(async () => { + await contract.invalidMethod(); + }).toThrow(); +}); +``` + +### Testing for a specific error message + +```typescript +await vm.it('should revert with specific message', async () => { + await Assert.expect(async () => { + await contract.unauthorizedAction(); + }).toThrow('not authorized'); +}); +``` + +### Testing with regex + +```typescript +await vm.it('should match error pattern', async () => { + await Assert.expect(async () => { + await contract.badInput(); + }).toThrow(/out of (gas|memory)/); +}); +``` + +### Testing that a call does NOT throw + +```typescript +await vm.it('should succeed without error', async () => { + await Assert.expect(async () => { + await contract.validMethod(); + }).toNotThrow(); +}); +``` + +--- + +## Standard Test Pattern + +Here is the canonical test pattern used throughout the framework: + +```typescript +import { Address } from '@btc-vision/transaction'; +import { Assert, Blockchain, opnet, OPNetUnit } from '@btc-vision/unit-test-framework'; +import { MyContractRuntime } from '../contracts/runtime/MyContractRuntime.js'; + +await opnet('MyContract Tests', async (vm: OPNetUnit) => { + let contract: MyContractRuntime; + + const deployerAddress: Address = Blockchain.generateRandomAddress(); + const contractAddress: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + // 1. Reset blockchain state + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + // 2. Create and register the contract + contract = new MyContractRuntime(deployerAddress, contractAddress); + Blockchain.register(contract); + await contract.init(); + + // 3. Set the transaction context + Blockchain.txOrigin = deployerAddress; + Blockchain.msgSender = deployerAddress; + }); + + vm.afterEach(() => { + contract.dispose(); + Blockchain.dispose(); + }); + + // Test cases + await vm.it('should read a value', async () => { + const value = await contract.getValue(); + Assert.expect(value).toEqual(42n); + }); + + await vm.it('should write a value', async () => { + await contract.setValue(100n); + + const value = await contract.getValue(); + Assert.expect(value).toEqual(100n); + }); + + await vm.it('should reject unauthorized calls', async () => { + const unauthorized = Blockchain.generateRandomAddress(); + Blockchain.msgSender = unauthorized; + + await Assert.expect(async () => { + await contract.adminMethod(); + }).toThrow(); + }); +}); +``` + +--- + +## Multiple Test Suites + +You can have multiple `opnet()` calls in a single file: + +```typescript +await opnet('Read Operations', async (vm: OPNetUnit) => { + // ... setup ... + await vm.it('should read value', async () => { /* ... */ }); +}); + +await opnet('Write Operations', async (vm: OPNetUnit) => { + // ... setup ... + await vm.it('should write value', async () => { /* ... */ }); +}); +``` + +Or organize them across separate files and run individually: + +```bash +npx tsx test/reads.test.ts +npx tsx test/writes.test.ts +``` + +--- + +## Next Steps + +- [OP20 Token Tests](./op20-tokens.md) - Testing fungible tokens +- [OP721 NFT Tests](./op721-nfts.md) - Testing non-fungible tokens +- [Custom Contracts](./custom-contracts.md) - Building contract wrappers + +--- + +[<- Previous: Quick Start](../getting-started/quick-start.md) | [Next: OP20 Token Tests ->](./op20-tokens.md) diff --git a/docs/writing-tests/custom-contracts.md b/docs/writing-tests/custom-contracts.md new file mode 100644 index 0000000..e7cfeba --- /dev/null +++ b/docs/writing-tests/custom-contracts.md @@ -0,0 +1,489 @@ +# Testing Custom Contracts + +For contracts beyond OP20/OP721, extend `ContractRuntime` to create typed wrappers that map TypeScript methods to contract selectors. + +## Table of Contents + +- [Architecture](#architecture) +- [Creating a Runtime Wrapper](#creating-a-runtime-wrapper) +- [Selector Encoding](#selector-encoding) +- [Reading Data](#reading-data) +- [Writing Data](#writing-data) +- [Working with Storage](#working-with-storage) +- [Response Handling](#response-handling) +- [Complete Example](#complete-example) + +--- + +## Architecture + +```mermaid +flowchart TB + subgraph Your Code + RT[MyContractRuntime] + Test[Test File] + end + + subgraph Framework + CR[ContractRuntime] + BC[Blockchain] + VM[RustContract / OP_VM] + end + + subgraph Disk + WASM[Contract.wasm] + end + + RT -->|extends| CR + Test -->|uses| RT + Test -->|configures| BC + CR -->|executes| VM + VM -->|loads| WASM +``` + +Every contract wrapper: + +1. **Extends** `ContractRuntime` +2. **Defines selectors** for each contract method +3. **Encodes calldata** with `BinaryWriter` +4. **Decodes responses** with `BinaryReader` +5. **Loads bytecode** via `BytecodeManager` + +--- + +## Creating a Runtime Wrapper + +```typescript +import { Address, BinaryReader, BinaryWriter } from '@btc-vision/transaction'; +import { BytecodeManager, CallResponse, ContractRuntime } from '@btc-vision/unit-test-framework'; + +export class MyContractRuntime extends ContractRuntime { + // 1. Define selectors from ABI signatures + private readonly getValueSelector: number = this.getSelector('getValue()'); + private readonly setValueSelector: number = this.getSelector('setValue(uint256)'); + + // 2. Constructor with ContractDetails + public constructor(deployer: Address, address: Address, gasLimit: bigint = 150_000_000_000n) { + super({ + address: address, + deployer: deployer, + gasLimit, + }); + } + + // 3. Public methods that encode/decode contract calls + public async getValue(): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.getValueSelector); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + + const reader = new BinaryReader(response.response); + return reader.readU256(); + } + + public async setValue(value: bigint): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.setValueSelector); + calldata.writeU256(value); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + return response; + } + + // 4. Optional override: custom error wrapping (default formats with class name) + protected handleError(error: Error): Error { + return new Error(`(MyContract: ${this.address}) OP_NET: ${error.message}`); + } + + // 5. Override to load WASM from file (required unless `bytecode` is passed in ContractDetails) + protected defineRequiredBytecodes(): void { + BytecodeManager.loadBytecode('./bytecodes/MyContract.wasm', this.address); + } + + // 6. Helper: encode ABI selector + private getSelector(signature: string): number { + return Number(`0x${this.abiCoder.encodeSelector(signature)}`); + } + + // 7. Helper: validate response + private handleResponse(response: CallResponse): void { + if (response.error) throw this.handleError(response.error); + if (!response.response) throw new Error('No response to decode'); + } +} +``` + +--- + +## Selector Encoding + +Selectors are 4-byte identifiers derived from the function signature string. The `abiCoder` (available as `this.abiCoder` in `ContractRuntime`) encodes them: + +```typescript +// The signature must exactly match the contract's ABI +private readonly mintSelector = this.getSelector('mint(address,uint256)'); +private readonly balanceOfSelector = this.getSelector('balanceOf(address)'); +private readonly transferSelector = this.getSelector('transfer(address,uint256)'); +private readonly storeSelector = this.getSelector('store(bytes32,bytes32)'); +private readonly sha256Selector = this.getSelector('sha256(bytes)'); +``` + +### Common Parameter Types + +| ABI Type | BinaryWriter Method | BinaryReader Method | +|----------|-------------------|-------------------| +| `uint8` | `writeU8(n)` | `readU8()` | +| `uint16` | `writeU16(n)` | `readU16()` | +| `uint32` | `writeU32(n)` | `readU32()` | +| `uint64` | `writeU64(n)` | `readU64()` | +| `uint128` | `writeU128(n)` | `readU128()` | +| `uint256` | `writeU256(n)` | `readU256()` | +| `bool` | `writeBoolean(b)` | `readBoolean()` | +| `address` | `writeAddress(a)` | `readAddress()` | +| `bytes` | `writeBytesWithLength(b)` | `readBytesWithLength()` | +| `bytes32` | `writeBytes(b)` | `readBytes(32)` | +| `string` | `writeString(s)` | `readString()` | + +--- + +## Reading Data + +### Simple getter + +```typescript +public async getValue(): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.getValueSelector); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + + const reader = new BinaryReader(response.response); + return reader.readU256(); +} +``` + +### Parameterized query + +```typescript +public async balanceOf(owner: Address): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.balanceOfSelector); + calldata.writeAddress(owner); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + + const reader = new BinaryReader(response.response); + return reader.readU256(); +} +``` + +### Multiple return values + +```typescript +public async getInfo(): Promise<{ name: string; value: bigint }> { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.getInfoSelector); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + + const reader = new BinaryReader(response.response); + return { + name: reader.readString(), + value: reader.readU256(), + }; +} +``` + +--- + +## Writing Data + +### Simple setter + +```typescript +public async setValue(value: bigint): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.setValueSelector); + calldata.writeU256(value); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + return response; +} +``` + +### With sender override + +```typescript +public async transferOwnership(newOwner: Address, sender: Address): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.transferOwnershipSelector); + calldata.writeAddress(newOwner); + + const response = await this.execute({ + calldata: calldata.getBuffer(), + sender: sender, // Override msg.sender + txOrigin: sender, // Override tx.origin + }); + + this.handleResponse(response); + return response; +} +``` + +--- + +## Working with Storage + +For contracts with key-value storage: + +```typescript +public async store(key: Uint8Array, value: Uint8Array): Promise { + const calldata = new BinaryWriter(68); // Pre-allocate: 4 (selector) + 32 + 32 + calldata.writeSelector(this.storeSelector); + calldata.writeBytes(key); + calldata.writeBytes(value); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); +} + +public async load(key: Uint8Array): Promise { + const calldata = new BinaryWriter(36); // Pre-allocate: 4 + 32 + calldata.writeSelector(this.loadSelector); + calldata.writeBytes(key); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + + const reader = new BinaryReader(response.response); + return reader.readBytes(32); +} +``` + +--- + +## Response Handling + +### CallResponse Fields + +```typescript +const response = await contract.execute({ calldata }); + +response.status; // Exit status (0 = success, 1 = revert) +response.response; // Raw bytes (Uint8Array) to decode +response.error; // Error if status !== 0 +response.events; // Emitted events (NetEvent[]) +response.usedGas; // Gas consumed (bigint) +response.memoryPagesUsed; // WASM memory pages used +response.callStack; // Contract call stack +response.touchedAddresses; // Addresses accessed during execution +response.touchedBlocks; // Block numbers queried +``` + +### Returning structured data + +```typescript +public async verifySignature( + sig: Uint8Array, + sender: Address, +): Promise<{ result: boolean; gas: bigint }> { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.verifySelector); + calldata.writeBytesWithLength(sig); + + const response = await this.execute({ + calldata: calldata.getBuffer(), + sender: sender, + txOrigin: sender, + }); + this.handleResponse(response); + + const reader = new BinaryReader(response.response); + return { + result: reader.readBoolean(), + gas: response.usedGas, + }; +} +``` + +--- + +## Complete Example + +Here is a full runtime wrapper and its test file, based on the actual test contracts in the framework: + +### Runtime Wrapper + +```typescript +// test/contracts/runtime/TestContractRuntime.ts +import { Address, BinaryReader, BinaryWriter } from '@btc-vision/transaction'; +import { BytecodeManager, CallResponse, ContractRuntime } from '@btc-vision/unit-test-framework'; + +export class TestContractRuntime extends ContractRuntime { + private readonly sha256Selector = this.getSelector('sha256(bytes)'); + private readonly storeSelector = this.getSelector('store(bytes32,bytes32)'); + private readonly loadSelector = this.getSelector('load(bytes32)'); + private readonly accountTypeSelector = this.getSelector('accountType(address)'); + private readonly blockHashSelector = this.getSelector('blockHash(uint64)'); + private readonly recursiveCallSelector = this.getSelector('recursiveCall(uint32)'); + + public constructor(deployer: Address, address: Address, gasLimit: bigint = 150_000_000_000n) { + super({ address, deployer, gasLimit }); + } + + public async sha256(data: Uint8Array): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.sha256Selector); + calldata.writeBytesWithLength(data); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + return new BinaryReader(response.response).readBytes(32); + } + + public async store(key: Uint8Array, value: Uint8Array): Promise { + const calldata = new BinaryWriter(68); + calldata.writeSelector(this.storeSelector); + calldata.writeBytes(key); + calldata.writeBytes(value); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + } + + public async load(key: Uint8Array): Promise { + const calldata = new BinaryWriter(36); + calldata.writeSelector(this.loadSelector); + calldata.writeBytes(key); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + return new BinaryReader(response.response).readBytes(32); + } + + public async accountType(address: Address): Promise { + const calldata = new BinaryWriter(36); + calldata.writeSelector(this.accountTypeSelector); + calldata.writeAddress(address); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + return new BinaryReader(response.response).readU32(); + } + + public async blockHash(blockNumber: bigint): Promise { + const calldata = new BinaryWriter(12); + calldata.writeSelector(this.blockHashSelector); + calldata.writeU64(blockNumber); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + return new BinaryReader(response.response).readBytes(32); + } + + public async recursiveCall(depth: number): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.recursiveCallSelector); + calldata.writeU32(depth); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + } + + protected handleError(error: Error): Error { + return new Error(`(TestContract: ${this.address}) OP_NET: ${error.message}`); + } + + protected defineRequiredBytecodes(): void { + BytecodeManager.loadBytecode('./bytecodes/TestContract.wasm', this.address); + } + + private getSelector(sig: string): number { + return Number(`0x${this.abiCoder.encodeSelector(sig)}`); + } + + private handleResponse(response: CallResponse): void { + if (response.error) throw this.handleError(response.error); + if (!response.response) throw new Error('No response to decode'); + } +} +``` + +### Test File + +```typescript +// test/test-contract.test.ts +import { Address } from '@btc-vision/transaction'; +import { Assert, Blockchain, opnet, OPNetUnit } from '@btc-vision/unit-test-framework'; +import { TestContractRuntime } from './contracts/runtime/TestContractRuntime.js'; + +await opnet('TestContract', async (vm: OPNetUnit) => { + let contract: TestContractRuntime; + const deployer: Address = Blockchain.generateRandomAddress(); + const contractAddress: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + contract = new TestContractRuntime(deployer, contractAddress); + Blockchain.register(contract); + await contract.init(); + + Blockchain.txOrigin = deployer; + Blockchain.msgSender = deployer; + }); + + vm.afterEach(() => { + contract.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should hash data with SHA256', async () => { + const data = Uint8Array.from([0x3d]); + const hash = await contract.sha256(data); + Assert.expect(hash.length).toEqual(32); + }); + + await vm.it('should store and load data', async () => { + const key = new Uint8Array(32).fill(0x01); + const value = new Uint8Array(32).fill(0x42); + + await contract.store(key, value); + const loaded = await contract.load(key); + + Assert.expect(loaded).toDeepEqual(value); + }); + + await vm.it('should identify contract addresses', async () => { + const accountType = await contract.accountType(contract.address); + Assert.expect(accountType).toEqual(1); // 1 = contract + }); + + await vm.it('should fail on excessive recursion', async () => { + await Assert.expect(async () => { + await contract.recursiveCall(100); + }).toThrow(); + }); +}); +``` + +--- + +## Next Steps + +- [Contract Runtime API Reference](../api-reference/contract-runtime.md) - Full API details +- [Cross-Contract Calls](../advanced/cross-contract-calls.md) - Multi-contract testing +- [Upgradeable Contracts](../advanced/upgradeable-contracts.md) - Testing upgrades + +--- + +[<- Previous: OP721 NFT Tests](./op721-nfts.md) | [Next: Cross-Contract Calls ->](../advanced/cross-contract-calls.md) diff --git a/docs/writing-tests/op20-tokens.md b/docs/writing-tests/op20-tokens.md new file mode 100644 index 0000000..6a052b4 --- /dev/null +++ b/docs/writing-tests/op20-tokens.md @@ -0,0 +1,388 @@ +# Testing OP20 Tokens + +The framework provides a built-in `OP20` class that wraps all standard fungible token methods. No need to write a custom runtime for standard OP20 tokens. + +## Table of Contents + +- [Setup](#setup) +- [Reading Token Data](#reading-token-data) +- [Minting](#minting) +- [Transfers](#transfers) +- [Allowances](#allowances) +- [Burning](#burning) +- [Airdrops](#airdrops) +- [Event Decoding](#event-decoding) +- [Complete Example](#complete-example) + +--- + +## Setup + +```typescript +import { opnet, OPNetUnit, Assert, Blockchain, OP20 } from '@btc-vision/unit-test-framework'; +import { Address } from '@btc-vision/transaction'; + +await opnet('OP20 Token Tests', async (vm: OPNetUnit) => { + let token: OP20; + + const deployer: Address = Blockchain.generateRandomAddress(); + const alice: Address = Blockchain.generateRandomAddress(); + const bob: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + token = new OP20({ + address: Blockchain.generateRandomAddress(), + deployer: deployer, + file: './bytecodes/MyToken.wasm', + decimals: 18, + }); + + Blockchain.register(token); + await token.init(); + + Blockchain.msgSender = deployer; + Blockchain.txOrigin = deployer; + }); + + vm.afterEach(() => { + token.dispose(); + Blockchain.dispose(); + }); + + // Tests go here... +}); +``` + +### OP20 Constructor + +```typescript +new OP20({ + address: Address, // Contract address + deployer: Address, // Deployer address + file: string, // Path to compiled .wasm file + decimals: number, // Token decimals (e.g. 8, 18) + gasLimit?: bigint, // Optional gas limit (default: 100_000_000_000_000n) + deploymentCalldata?: Buffer, // Optional deployment calldata +}) +``` + +--- + +## Reading Token Data + +### Metadata + +```typescript +await vm.it('should read token metadata', async () => { + const { metadata } = await token.metadata(); + + vm.info(`Name: ${metadata.name}`); + vm.info(`Symbol: ${metadata.symbol}`); + vm.info(`Decimals: ${metadata.decimals}`); + vm.info(`Total Supply: ${metadata.totalSupply}`); + vm.info(`Max Supply: ${metadata.maximumSupply}`); +}); +``` + +### Balance + +```typescript +await vm.it('should check balance', async () => { + const balance = await token.balanceOf(alice); + Assert.expect(balance).toEqual(0n); + + // Human-readable balance (divides by 10^decimals) + const readable = await token.balanceOfNoDecimals(alice); + Assert.expect(readable).toEqual(0); +}); +``` + +### Total Supply + +```typescript +await vm.it('should read total supply', async () => { + const supply = await token.totalSupply(); + Assert.expect(supply).toBeGreaterThanOrEqual(0n); +}); +``` + +### Domain Separator + +```typescript +await vm.it('should read domain separator', async () => { + const separator = await token.domainSeparator(); + Assert.expect(separator.length).toEqual(32); +}); +``` + +--- + +## Minting + +### Mint in whole tokens + +```typescript +await vm.it('should mint tokens (whole units)', async () => { + // Mints 1000 tokens (automatically multiplied by 10^decimals) + await token.mint(alice, 1000); + + const balance = await token.balanceOf(alice); + Assert.expect(balance).toEqual(Blockchain.expandToDecimal(1000, 18)); +}); +``` + +### Mint raw amount + +```typescript +await vm.it('should mint raw token amount', async () => { + const rawAmount = 500_000_000_000_000_000_000n; // 500 * 10^18 + await token.mintRaw(alice, rawAmount); + + const balance = await token.balanceOf(alice); + Assert.expect(balance).toEqual(rawAmount); +}); +``` + +--- + +## Transfers + +### Safe Transfer + +```typescript +await vm.it('should transfer tokens', async () => { + await token.mint(alice, 1000); + + const amount = Blockchain.expandToDecimal(100, 18); + const response = await token.safeTransfer(alice, bob, amount); + + Assert.expect(response.usedGas).toBeGreaterThan(0n); + + const aliceBalance = await token.balanceOf(alice); + const bobBalance = await token.balanceOf(bob); + + Assert.expect(bobBalance).toEqual(amount); + Assert.expect(aliceBalance).toEqual(Blockchain.expandToDecimal(900, 18)); +}); +``` + +### Safe Transfer From (with allowance) + +```typescript +await vm.it('should transferFrom with allowance', async () => { + await token.mint(alice, 1000); + + const amount = Blockchain.expandToDecimal(200, 18); + + // Alice approves bob + await token.increaseAllowance(alice, bob, amount); + + // Bob transfers from Alice's account + Blockchain.msgSender = bob; + await token.safeTransferFrom(alice, bob, amount); + + const bobBalance = await token.balanceOf(bob); + Assert.expect(bobBalance).toEqual(amount); +}); +``` + +--- + +## Allowances + +### Increase/Decrease Allowance + +```typescript +await vm.it('should manage allowances', async () => { + const amount = Blockchain.expandToDecimal(500, 18); + + await token.increaseAllowance(alice, bob, amount); + let allowance = await token.allowance(alice, bob); + Assert.expect(allowance).toEqual(amount); + + const decrease = Blockchain.expandToDecimal(200, 18); + await token.decreaseAllowance(alice, bob, decrease); + allowance = await token.allowance(alice, bob); + Assert.expect(allowance).toEqual(amount - decrease); +}); +``` + +### Signature-based Allowance + +```typescript +await vm.it('should approve by signature', async () => { + const wallet = Blockchain.generateRandomWallet(); + const spender = Blockchain.generateRandomAddress(); + const amount = 1000n; + const deadline = BigInt(Date.now()) + 3600n; + const signature = new Uint8Array(64); // Your actual signature + + await token.increaseAllowanceBySignature( + wallet.address, spender, amount, deadline, signature + ); +}); +``` + +--- + +## Burning + +```typescript +await vm.it('should burn tokens', async () => { + await token.mint(alice, 1000); + + const burnAmount = Blockchain.expandToDecimal(300, 18); + const response = await token.burn(alice, burnAmount); + + Assert.expect(response.usedGas).toBeGreaterThan(0n); + + const balance = await token.balanceOf(alice); + Assert.expect(balance).toEqual(Blockchain.expandToDecimal(700, 18)); +}); +``` + +--- + +## Airdrops + +Batch mint to multiple addresses: + +```typescript +import { AddressMap } from '@btc-vision/transaction'; + +await vm.it('should airdrop to multiple addresses', async () => { + const recipients = new AddressMap(); + const amount = Blockchain.expandToDecimal(100, 18); + + recipients.set(alice, amount); + recipients.set(bob, amount); + + const response = await token.airdrop(recipients); + Assert.expect(response.usedGas).toBeGreaterThan(0n); + + const aliceBalance = await token.balanceOf(alice); + const bobBalance = await token.balanceOf(bob); + + Assert.expect(aliceBalance).toEqual(amount); + Assert.expect(bobBalance).toEqual(amount); +}); +``` + +--- + +## Event Decoding + +### Transfer Events + +```typescript +await vm.it('should decode transfer events', async () => { + await token.mint(alice, 1000); + const amount = Blockchain.expandToDecimal(50, 18); + const response = await token.safeTransfer(alice, bob, amount); + + for (const event of response.events) { + const decoded = OP20.decodeTransferredEvent(event.data); + vm.info(`From: ${decoded.from.toHex()}`); + vm.info(`To: ${decoded.to.toHex()}`); + vm.info(`Value: ${decoded.value}`); + } +}); +``` + +### All Event Decoders + +```typescript +// Transfer event: { operator, from, to, value } +OP20.decodeTransferredEvent(data); + +// Mint event: { to, value } +OP20.decodeMintedEvent(data); + +// Burn event: { from, value } +OP20.decodeBurnedEvent(data); + +// Approval event: { owner, spender, value } +OP20.decodeApprovedEvent(data); +``` + +--- + +## Complete Example + +```typescript +import { opnet, OPNetUnit, Assert, Blockchain, OP20 } from '@btc-vision/unit-test-framework'; +import { Address } from '@btc-vision/transaction'; + +await opnet('Complete OP20 Test Suite', async (vm: OPNetUnit) => { + let token: OP20; + const deployer: Address = Blockchain.generateRandomAddress(); + const alice: Address = Blockchain.generateRandomAddress(); + const bob: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + token = new OP20({ + address: Blockchain.generateRandomAddress(), + deployer: deployer, + file: './bytecodes/MyToken.wasm', + decimals: 8, + }); + + Blockchain.register(token); + await token.init(); + + Blockchain.msgSender = deployer; + Blockchain.txOrigin = deployer; + }); + + vm.afterEach(() => { + token.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should have correct metadata', async () => { + const { metadata } = await token.metadata(); + Assert.expect(metadata.decimals).toEqual(8); + vm.success(`Token: ${metadata.name} (${metadata.symbol})`); + }); + + await vm.it('should mint and transfer', async () => { + await token.mint(alice, 1000); + const amount = Blockchain.expandToDecimal(250, 8); + await token.safeTransfer(alice, bob, amount); + + Assert.expect(await token.balanceOf(bob)).toEqual(amount); + Assert.expect(await token.balanceOf(alice)).toEqual( + Blockchain.expandToDecimal(750, 8) + ); + }); + + await vm.it('should fail to transfer more than balance', async () => { + await token.mint(alice, 100); + const tooMuch = Blockchain.expandToDecimal(200, 8); + + await Assert.expect(async () => { + await token.safeTransfer(alice, bob, tooMuch); + }).toThrow(); + }); +}); +``` + +--- + +## Next Steps + +- [OP721 NFT Tests](./op721-nfts.md) - Testing non-fungible tokens +- [Custom Contracts](./custom-contracts.md) - Building contract wrappers +- [OP20 API Reference](../built-in-contracts/op20.md) - Full method reference + +--- + +[<- Previous: Basic Tests](./basic-tests.md) | [Next: OP721 NFT Tests ->](./op721-nfts.md) diff --git a/docs/writing-tests/op721-nfts.md b/docs/writing-tests/op721-nfts.md new file mode 100644 index 0000000..f1eb481 --- /dev/null +++ b/docs/writing-tests/op721-nfts.md @@ -0,0 +1,382 @@ +# Testing OP721 NFTs + +The framework provides built-in `OP721` and `OP721Extended` classes for testing non-fungible token contracts. + +## Table of Contents + +- [Setup](#setup) +- [Reading Collection Data](#reading-collection-data) +- [Transfers](#transfers) +- [Approvals](#approvals) +- [Burning](#burning) +- [Enumeration](#enumeration) +- [Event Decoding](#event-decoding) +- [OP721Extended: Reservation Minting](#op721extended-reservation-minting) +- [Complete Example](#complete-example) + +--- + +## Setup + +```typescript +import { opnet, OPNetUnit, Assert, Blockchain, OP721 } from '@btc-vision/unit-test-framework'; +import { Address } from '@btc-vision/transaction'; + +await opnet('OP721 NFT Tests', async (vm: OPNetUnit) => { + let nft: OP721; + + const deployer: Address = Blockchain.generateRandomAddress(); + const alice: Address = Blockchain.generateRandomAddress(); + const bob: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + nft = new OP721({ + address: Blockchain.generateRandomAddress(), + deployer: deployer, + file: './bytecodes/MyNFT.wasm', + }); + + Blockchain.register(nft); + await nft.init(); + + Blockchain.msgSender = deployer; + Blockchain.txOrigin = deployer; + }); + + vm.afterEach(() => { + nft.dispose(); + Blockchain.dispose(); + }); + + // Tests go here... +}); +``` + +--- + +## Reading Collection Data + +```typescript +await vm.it('should read collection info', async () => { + const name = await nft.name(); + const symbol = await nft.symbol(); + const totalSupply = await nft.totalSupply(); + const maxSupply = await nft.maxSupply(); + + vm.info(`Collection: ${name} (${symbol})`); + vm.info(`Supply: ${totalSupply} / ${maxSupply}`); +}); + +await vm.it('should read token URI', async () => { + const uri = await nft.tokenURI(1n); + vm.info(`Token 1 URI: ${uri}`); +}); + +await vm.it('should check ownership', async () => { + const owner = await nft.ownerOf(1n); + vm.info(`Token 1 owned by: ${owner.toHex()}`); +}); + +await vm.it('should check balance', async () => { + const balance = await nft.balanceOf(alice); + Assert.expect(balance).toBeGreaterThanOrEqual(0n); +}); +``` + +--- + +## Transfers + +### Direct Transfer + +```typescript +await vm.it('should transfer an NFT', async () => { + const tokenId = 1n; + + // Transfer from alice to bob (alice is the sender) + await nft.transferFrom(alice, bob, tokenId, alice); + + const newOwner = await nft.ownerOf(tokenId); + Assert.expect(newOwner).toEqualAddress(bob); +}); +``` + +### Safe Transfer (with data) + +```typescript +await vm.it('should safe transfer with data', async () => { + const tokenId = 1n; + const data = new Uint8Array([0x01, 0x02, 0x03]); + + const response = await nft.safeTransferFrom(alice, bob, tokenId, data, alice); + Assert.expect(response.usedGas).toBeGreaterThan(0n); +}); +``` + +### Signature-based Transfer + +```typescript +await vm.it('should transfer by signature', async () => { + const tokenId = 1n; + const deadline = BigInt(Date.now()) + 3600n; + const signature = new Uint8Array(64); // Actual signature + + await nft.transferBySignature(alice, bob, tokenId, deadline, signature); +}); +``` + +--- + +## Approvals + +### Single Token Approval + +```typescript +await vm.it('should approve a spender', async () => { + const tokenId = 1n; + await nft.approve(bob, tokenId, alice); + + const approved = await nft.getApproved(tokenId); + Assert.expect(approved).toEqualAddress(bob); +}); +``` + +### Operator Approval (All Tokens) + +```typescript +await vm.it('should set approval for all', async () => { + await nft.setApprovalForAll(bob, true, alice); + + const isApproved = await nft.isApprovedForAll(alice, bob); + Assert.expect(isApproved).toEqual(true); +}); + +await vm.it('should revoke approval for all', async () => { + await nft.setApprovalForAll(bob, false, alice); + + const isApproved = await nft.isApprovedForAll(alice, bob); + Assert.expect(isApproved).toEqual(false); +}); +``` + +--- + +## Burning + +```typescript +await vm.it('should burn an NFT', async () => { + const tokenId = 1n; + const response = await nft.burn(tokenId, alice); + + Assert.expect(response.usedGas).toBeGreaterThan(0n); + + // Querying burned token should fail + await Assert.expect(async () => { + await nft.ownerOf(tokenId); + }).toThrow(); +}); +``` + +--- + +## Enumeration + +```typescript +await vm.it('should enumerate tokens by owner', async () => { + // Get first token of owner + const firstToken = await nft.tokenOfOwnerByIndex(alice, 0n); + vm.info(`Alice's first token: ${firstToken}`); + + // Get all tokens of owner + const allTokens = await nft.getAllTokensOfOwner(alice); + vm.info(`Alice owns ${allTokens.length} tokens: ${allTokens.join(', ')}`); +}); +``` + +--- + +## Event Decoding + +```typescript +// Transfer: { operator, from, to, tokenId } +OP721.decodeTransferredEvent(data); + +// Approval: { owner, approved, tokenId } +OP721.decodeApprovedEvent(data); + +// ApprovalForAll: { owner, operator, approved } +OP721.decodeApprovedForAllEvent(data); + +// URI: { uri, tokenId } +OP721.decodeURIEvent(data); +``` + +### Example + +```typescript +await vm.it('should decode transfer events', async () => { + const response = await nft.transferFrom(alice, bob, 1n, alice); + + for (const event of response.events) { + const decoded = OP721.decodeTransferredEvent(event.data); + Assert.expect(decoded.to).toEqualAddress(bob); + Assert.expect(decoded.tokenId).toEqual(1n); + } +}); +``` + +--- + +## OP721Extended: Reservation Minting + +`OP721Extended` adds a reservation-based minting system on top of standard OP721: + +```typescript +import { OP721Extended } from '@btc-vision/unit-test-framework'; + +const nft = new OP721Extended({ + address: contractAddress, + deployer: deployer, + file: './bytecodes/MyNFTExtended.wasm', + mintPrice: 100000n, // Price per token in sats + reservationFeePercent: 15n, // 15% reservation fee + minReservationFee: 1000n, // Min fee in sats + reservationBlocks: 5n, // Blocks to claim + graceBlocks: 1n, // Grace period + maxReservationAmount: 20, // Max tokens per reservation +}); +``` + +### Reservation Flow + +```typescript +await vm.it('should complete reservation flow', async () => { + // 1. Enable minting + await nft.setMintEnabled(true); + Assert.expect(await nft.isMintEnabled()).toEqual(true); + + // 2. Reserve tokens + const reservation = await nft.reserve(5n, alice); + vm.info(`Remaining payment: ${reservation.remainingPayment}`); + vm.info(`Reserved at block: ${reservation.reservationBlock}`); + + // 3. Claim minted tokens + const claimed = await nft.claim(alice); + vm.info(`Claimed ${claimed.amountClaimed} tokens starting at #${claimed.startTokenId}`); + + Assert.expect(claimed.amountClaimed).toEqual(5n); +}); +``` + +### Purging Expired Reservations + +```typescript +await vm.it('should purge expired reservations', async () => { + await nft.reserve(3n, alice); + + // Advance blocks past expiry + for (let i = 0; i < 10; i++) { + Blockchain.mineBlock(); + } + + const purged = await nft.purgeExpired(); + vm.info(`Purged ${purged.totalPurged} reservations across ${purged.blocksProcessed} blocks`); +}); +``` + +### Status & Utility Methods + +```typescript +await vm.it('should read collection status', async () => { + const status = await nft.getStatus(); + + vm.info(`Minted: ${status.minted}`); + vm.info(`Reserved: ${status.reserved}`); + vm.info(`Available: ${status.available}`); + vm.info(`Max Supply: ${status.maxSupply}`); + + // Calculate fees + const fee = nft.calculateReservationFee(5n); + const remaining = nft.calculateRemainingPayment(5n); + vm.info(`Fee for 5 tokens: ${fee}`); + vm.info(`Remaining payment: ${remaining}`); + + // Check expiry + const expired = nft.isReservationExpired(100n, 110n); + const blocksLeft = nft.getBlocksUntilExpiry(100n, 103n); +}); +``` + +--- + +## Complete Example + +```typescript +import { opnet, OPNetUnit, Assert, Blockchain, OP721Extended } from '@btc-vision/unit-test-framework'; +import { Address } from '@btc-vision/transaction'; + +await opnet('NFT Reservation Tests', async (vm: OPNetUnit) => { + let nft: OP721Extended; + const deployer: Address = Blockchain.generateRandomAddress(); + const minter: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + nft = new OP721Extended({ + address: Blockchain.generateRandomAddress(), + deployer: deployer, + file: './bytecodes/MyNFTExtended.wasm', + }); + + Blockchain.register(nft); + await nft.init(); + + Blockchain.msgSender = deployer; + Blockchain.txOrigin = deployer; + + await nft.setMintEnabled(true); + }); + + vm.afterEach(() => { + nft.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should reserve and claim', async () => { + Blockchain.msgSender = minter; + const reservation = await nft.reserve(3n, minter); + + const claimed = await nft.claim(minter); + Assert.expect(claimed.amountClaimed).toEqual(3n); + }); + + await vm.it('should track supply changes', async () => { + const supplyBefore = await nft.totalSupply(); + Blockchain.msgSender = minter; + await nft.reserve(2n, minter); + await nft.claim(minter); + + const supplyAfter = await nft.totalSupply(); + Assert.expect(supplyAfter).toEqual(supplyBefore + 2n); + }); +}); +``` + +--- + +## Next Steps + +- [Custom Contracts](./custom-contracts.md) - Building contract wrappers +- [OP721 API Reference](../built-in-contracts/op721.md) - Full method reference +- [OP721Extended API Reference](../built-in-contracts/op721-extended.md) - Extended methods + +--- + +[<- Previous: OP20 Token Tests](./op20-tokens.md) | [Next: Custom Contracts ->](./custom-contracts.md) diff --git a/gulpfile.js b/gulpfile.js index 692fbcd..fd7c44f 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -10,7 +10,7 @@ process.on('uncaughtException', function (err) { console.log('Caught exception: ', err); }); -const tsProject = ts.createProject('tsconfig.json'); +const tsProject = ts.createProject('tsconfig.build.json'); function buildESM() { return tsProject diff --git a/package.json b/package.json index b6b7bb2..73f7ab9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@btc-vision/unit-test-framework", - "version": "0.4.10", + "version": "1.0.0-alpha.0", "description": "OP_NET Unit Test Framework. This package contains all the necessary tools to run unit tests for OP_NET smart contracts.", "main": "build/index.js", "scripts": { @@ -16,7 +16,9 @@ "test:memory": "tsx test/e2e/limits/Memory.test.ts", "test:sha256": "tsx test/e2e/imports/Hashes.test.ts", "test:signature": "tsx test/e2e/imports/MLDSA.test.ts", - "test:storage": "tsx test/e2e/imports/Storage.test.ts" + "test:storage": "tsx test/e2e/imports/Storage.test.ts", + "test:update-from-address": "tsx test/e2e/imports/UpdateFromAddress.test.ts", + "test:ecdsa": "tsx test/e2e/imports/ECDSA.test.ts" }, "repository": { "type": "git", @@ -38,30 +40,34 @@ "opnet": "1.7.31" }, "dependencies": { - "@btc-vision/bitcoin": "^6.4.11", - "@btc-vision/logger": "^1.0.7", - "@btc-vision/op-vm": "^0.6.1", - "@btc-vision/transaction": "1.7.28", + "@assemblyscript/loader": "^0.28.9", + "@btc-vision/assemblyscript": "^0.29.2", + "@btc-vision/bip32": "^7.1.2", + "@btc-vision/bitcoin": "^7.0.0-rc.4", + "@btc-vision/ecpair": "^4.0.5", + "@btc-vision/logger": "^1.0.8", + "@btc-vision/op-vm": "^1.0.0-rc.0", + "@btc-vision/transaction": "^1.8.0-rc.3", "gulp-logger-new": "^1.0.1", "npm-run-all": "^4.1.5", - "opnet": "1.7.31", + "opnet": "^1.8.1-rc.2", "typescript": "^5.9.3" }, "devDependencies": { - "@btc-vision/as-bignum": "^0.0.7", - "@btc-vision/btc-runtime": "1.10.12", - "@btc-vision/opnet-transform": "^0.2.1", - "@eslint/js": "^9.39.1", - "@types/node": "^25.0.1", - "@typescript-eslint/eslint-plugin": "^8.49.0", - "@typescript-eslint/parser": "^8.49.0", - "eslint": "^9.39.1", + "@btc-vision/as-bignum": "^0.1.2", + "@btc-vision/btc-runtime": "^1.11.0-rc.6", + "@btc-vision/opnet-transform": "^1.2.0", + "@eslint/js": "^10.0.1", + "@types/node": "^25.2.3", + "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^10.0.0", "gulp": "^5.0.1", "gulp-cached": "^1.1.1", "gulp-clean": "^0.4.0", - "gulp-eslint-new": "^2.5.0", + "gulp-eslint-new": "^2.6.0", "gulp-typescript": "^6.0.0-alpha.1", - "prettier": "^3.7.4", + "prettier": "^3.8.1", "tsx": "^4.21.0" } } diff --git a/scripts/buildTestContracts.js b/scripts/buildTestContracts.js index 2ee60e4..fd1e100 100644 --- a/scripts/buildTestContracts.js +++ b/scripts/buildTestContracts.js @@ -6,3 +6,15 @@ execSync('asc index.ts --target debug --measure --uncheckedBehavior never'); process.chdir(`${root}/test/e2e/contracts/gas-test-contract/contract`); execSync('asc GasTestContract.ts --target debug --measure --uncheckedBehavior never'); + +process.chdir(`${root}/test/e2e/contracts/upgradeable-contract/contract`); +execSync('asc index.ts --target debug --measure --uncheckedBehavior never'); + +process.chdir(`${root}/test/e2e/contracts/upgradeable-contract-v2/contract`); +execSync('asc index.ts --target debug --measure --uncheckedBehavior never'); + +process.chdir(`${root}/test/e2e/contracts/malicious-v2/contract`); +execSync('asc index.ts --target debug --measure --uncheckedBehavior never'); + +process.chdir(`${root}/test/e2e/contracts/ecdsa-contract/contract`); +execSync('asc index.ts --target debug --measure --uncheckedBehavior never'); diff --git a/src/bench.ts b/src/bench.ts index a6a9924..adba417 100644 --- a/src/bench.ts +++ b/src/bench.ts @@ -1,3 +1,4 @@ +import { toHex } from '@btc-vision/bitcoin'; import { Address, AddressMap } from '@btc-vision/transaction'; import { performance } from 'perf_hooks'; import crypto from 'crypto'; @@ -268,7 +269,7 @@ class HexKeyCache implements CacheImplementation { } private getKey(address: Address): string { - return address.toBuffer().toString('hex'); + return toHex(address.toBuffer()); } } diff --git a/src/blockchain/Blockchain.ts b/src/blockchain/Blockchain.ts index bf8a82e..60ab32d 100644 --- a/src/blockchain/Blockchain.ts +++ b/src/blockchain/Blockchain.ts @@ -132,6 +132,7 @@ class BlockchainBase extends Logger { this.storeJSFunction, this.callJSFunction, this.deployContractAtAddressJSFunction, + this.updateFromAddress, this.logJSFunction, this.emitJSFunction, this.inputsJSFunction, @@ -203,9 +204,14 @@ class BlockchainBase extends Logger { public clearContracts(): void { StateHandler.purgeAll(); ConsensusManager.default(); + BytecodeManager.clear(); this.addressMLDSACache.clear(); this.contracts.clear(); + + if (this._contractManager) { + this._contractManager.destroyCache(); + } } public generateAddress(deployer: Address, salt: Buffer, from: Address): Address { @@ -417,18 +423,33 @@ class BlockchainBase extends Logger { ): Promise => { if (this.enableDebug) console.log('DEPLOY', value.buffer); - const u = new Uint8Array(value.buffer); - const buf = Buffer.from(u.buffer, u.byteOffset, u.byteLength); - + const buf = Buffer.from(Array.from(value.buffer)); const c = this.bindings.get(BigInt(`${value.contractId}`)); // otherwise unsafe. - if (!c) { - throw new Error('Binding not found'); + throw new Error('Binding not found (deploy)'); } return c.deployContractAtAddress(buf); }; + private updateFromAddress: ( + _: never, + result: ThreadSafeJsImportResponse, + ) => Promise = ( + _: never, + value: ThreadSafeJsImportResponse, + ): Promise => { + if (this.enableDebug) console.log('DEPLOY', value.buffer); + + const buf = Buffer.from(Array.from(value.buffer)); + const c = this.bindings.get(BigInt(`${value.contractId}`)); // otherwise unsafe. + if (!c) { + throw new Error('Binding not found (deploy)'); + } + + return c.updateFromAddress(buf); + }; + private logJSFunction: (_: never, result: ThreadSafeJsImportResponse) => Promise = ( _: never, value: ThreadSafeJsImportResponse, diff --git a/src/consensus/ConsensusManager.ts b/src/consensus/ConsensusManager.ts index 7576b2b..293185a 100644 --- a/src/consensus/ConsensusManager.ts +++ b/src/consensus/ConsensusManager.ts @@ -16,7 +16,8 @@ class BaseConsensusManager extends Logger { public default(): void { this.consensusRules.reset(); - this.consensusRules.insertFlag(ConsensusRules.UNSAFE_QUANTUM_SIGNATURES_ALLOWED); + this.consensusRules.insertFlag(ConsensusRules.ALLOW_CLASSICAL_SIGNATURES); + this.consensusRules.insertFlag(ConsensusRules.UPDATE_CONTRACT_BY_ADDRESS); } } diff --git a/src/consensus/ConsensusRules.ts b/src/consensus/ConsensusRules.ts index de87a71..b7b8f11 100644 --- a/src/consensus/ConsensusRules.ts +++ b/src/consensus/ConsensusRules.ts @@ -4,9 +4,9 @@ export class ConsensusRules { // Flag constants public static readonly NONE: bigint = 0b00000000n; - public static readonly UNSAFE_QUANTUM_SIGNATURES_ALLOWED: bigint = 0b00000001n; + public static readonly ALLOW_CLASSICAL_SIGNATURES: bigint = 0b00000001n; - public static readonly RESERVED_FLAG_1: bigint = 0b00000010n; + public static readonly UPDATE_CONTRACT_BY_ADDRESS: bigint = 0b00000010n; public static readonly RESERVED_FLAG_2: bigint = 0b00000100n; private value: bigint; @@ -223,7 +223,7 @@ export class ConsensusRules { } public unsafeSignaturesAllowed(): boolean { - return this.containsFlag(ConsensusRules.UNSAFE_QUANTUM_SIGNATURES_ALLOWED); + return this.containsFlag(ConsensusRules.ALLOW_CLASSICAL_SIGNATURES); } } diff --git a/src/opnet/modules/ContractRuntime.ts b/src/opnet/modules/ContractRuntime.ts index 1c7916f..9bb970e 100644 --- a/src/opnet/modules/ContractRuntime.ts +++ b/src/opnet/modules/ContractRuntime.ts @@ -110,7 +110,7 @@ export class ContractRuntime extends Logger { try { this.deployer.tweakedPublicKeyToBuffer(); } catch (e) { - throw new Error('Deployer address does not have a valid tweaked public key'); + throw new Error('Deployer address does not have a valid tweaked public key', { cause: e }); } } @@ -125,6 +125,10 @@ export class ContractRuntime extends Logger { } protected _bytecode: Buffer | undefined; + private _pendingBytecode: Buffer | undefined; + private _pendingBytecodeBlock: bigint | undefined; + private _pendingUpgradeCalldata: Buffer | undefined; + private _hasUpgradedInCurrentExecution: boolean = false; protected get bytecode(): Buffer { if (!this._bytecode) throw new Error(`Bytecode not found for ${this.address}`); @@ -363,6 +367,9 @@ export class ContractRuntime extends Logger { StateHandler.pushAllTempStatesToGlobal(); } + } else { + // Transaction reverted: cancel any pending bytecode upgrade + this.cancelPendingBytecodeUpgrade(); } // Reset internal states @@ -391,9 +398,14 @@ export class ContractRuntime extends Logger { this.touchedAddresses = new AddressSet([this.address]); this.touchedBlocks = new Set([Blockchain.blockNumber]); + + this._hasUpgradedInCurrentExecution = false; } protected async executeCall(executionParameters: ExecutionParameters): Promise { + // Apply pending bytecode upgrade if block has advanced + const phase2Gas = await this.applyPendingBytecodeUpgrade(); + // Deploy if not deployed. const deployment = await this.deployContract(false); if (deployment) { @@ -409,7 +421,7 @@ export class ContractRuntime extends Logger { } } - this.gasUsed = executionParameters.gasUsed || 0n; + this.gasUsed = (executionParameters.gasUsed || 0n) + phase2Gas; this.memoryPagesUsed = executionParameters.memoryPagesUsed || 0n; // Backup states @@ -465,6 +477,103 @@ export class ContractRuntime extends Logger { }); } + private async applyPendingBytecodeUpgrade(): Promise { + if ( + !this._pendingBytecode || + this._pendingBytecodeBlock === undefined || + Blockchain.blockNumber <= this._pendingBytecodeBlock + ) { + return 0n; + } + + const previousBytecode = this._bytecode; + const previousContract = this._contract; + const calldata = this._pendingUpgradeCalldata || Buffer.alloc(0); + + // Swap to the new bytecode first + this._bytecode = this._pendingBytecode; + BytecodeManager.forceSetBytecode(this.address, this._pendingBytecode); + Blockchain.contractManager.destroyCache(); + + // Call onUpdate on the NEW bytecode, bypassing precomputation cache + let tempContract: RustContract | undefined; + let phase2GasUsed: bigint = 0n; + try { + // Block cross-contract calls and nested upgrades during Phase 2 onUpdate + this._hasUpgradedInCurrentExecution = true; + + tempContract = new RustContract(this.generateParams(true)); + this._contract = tempContract; + this.setEnvironment(Blockchain.msgSender, Blockchain.txOrigin); + + const response = await tempContract.onUpdate(calldata); + + // Capture gas BEFORE status check so failed Phase 2 is still charged + phase2GasUsed = response.gasUsed; + + if (response.status !== 0) { + throw new Error('OP_NET: onUpdate failed on new bytecode'); + } + } catch (e) { + // onUpdate failed — revert bytecode swap + if (!previousBytecode) { + throw new Error( + 'OP_NET: FATAL — upgrade revert with no previous bytecode (impossible state)', + { cause: e }, + ); + } + + this._bytecode = previousBytecode; + BytecodeManager.forceSetBytecode(this.address, previousBytecode); + Blockchain.contractManager.destroyCache(); + + // Clean up dirty state from failed Phase 2: + // 1. Temp storage writes from store() calls during failed onUpdate + StateHandler.clearTemporaryStates(this.address); + // 2. Events emitted during failed onUpdate + this.events = []; + this.totalEventLength = 0; + + // If we didn't capture gas from response (onUpdate threw entirely), + // try to read it from the temp contract's VM state + if (phase2GasUsed === 0n && tempContract) { + try { + phase2GasUsed = tempContract.getUsedGas(); + } catch { + // VM state unavailable — charge nothing + } + } + + if (this.logUnexpectedErrors) { + this.warn(`onUpdate failed, upgrade reverted: ${(e as Error).message}`); + } + } finally { + // Always restore original contract reference + this._contract = previousContract; + this._hasUpgradedInCurrentExecution = false; + + if (tempContract) { + try { + tempContract.dispose(); + } catch { + // Ignore dispose errors + } + } + + this._pendingBytecode = undefined; + this._pendingBytecodeBlock = undefined; + this._pendingUpgradeCalldata = undefined; + } + + return phase2GasUsed; + } + + private cancelPendingBytecodeUpgrade(): void { + this._pendingBytecode = undefined; + this._pendingBytecodeBlock = undefined; + this._pendingUpgradeCalldata = undefined; + } + protected calculateGasCostSave(response: CallResponse): bigint { if (response.usedGas !== this.gasUsed) { throw new Error('OP_NET: gas used mismatch'); @@ -556,7 +665,127 @@ export class ContractRuntime extends Logger { } } + private async updateFromAddress(data: Buffer): Promise { + if (this._hasUpgradedInCurrentExecution) { + throw new Error( + 'OP_NET: Cannot upgrade while another upgrade is in progress', + ); + } + + try { + const reader = new BinaryReader(data); + this.gasUsed = reader.readU64(); + const sourceAddress: Address = reader.readAddress(); + const calldata: Buffer = Buffer.from(reader.readBytes(reader.bytesLeft() | 0)); + + if (calldata.byteLength > CONSENSUS.COMPRESSION.MAX_DECOMPRESSED_SIZE) { + throw new Error( + `OP_NET: Upgrade calldata exceeds maximum decompressed size (${calldata.byteLength} > ${CONSENSUS.COMPRESSION.MAX_DECOMPRESSED_SIZE})`, + ); + } + + // Enforce one upgrade per block per contract + if ( + this._pendingBytecodeBlock !== undefined && + this._pendingBytecodeBlock === Blockchain.blockNumber + ) { + throw new Error('OP_NET: Only one upgrade per block per contract is allowed'); + } + + const gasBefore = this.gasUsed; + const newBytecode = BytecodeManager.getBytecode(sourceAddress) as Buffer; + + // Call onUpdate on a TEMPORARY WASM instance with the CURRENT bytecode. + // We cannot call onUpdate on the active WASM instance (it is mid-execution), + // so we spin up a fresh instance with the same bytecode and environment. + const originalContract = this._contract; + let tempContract: RustContract | undefined; + + // Set upgrade flag BEFORE onUpdate to prevent: + // 1. Nested updateFromAddress calls from within onUpdate + // 2. Cross-contract calls (Blockchain.call) from within onUpdate + this._hasUpgradedInCurrentExecution = true; + let upgradeCommitted = false; + + try { + tempContract = new RustContract(this.generateParams(true)); + this._contract = tempContract; + this.setEnvironment(Blockchain.msgSender, Blockchain.txOrigin); + + let error: Error | undefined; + const response = await tempContract + .onUpdate(calldata) + .catch((e: unknown) => { + error = e as Error; + }); + + if (error) { + throw error; + } + + if (!response || response.status !== 0) { + const used = (response?.gasUsed ?? this.gasUsed) - gasBefore; + this.gasUsed = response?.gasUsed ?? this.gasUsed; + + return this.buildUpdateFromAddressResponse( + 0, + used, + (response?.status as 0 | 1) ?? 1, + response?.data ?? this.getErrorAsBuffer(new Error('onUpdate failed')), + ); + } + + // Schedule bytecode replacement for the next block + this._pendingBytecode = newBytecode; + this._pendingBytecodeBlock = Blockchain.blockNumber; + this._pendingUpgradeCalldata = calldata; + upgradeCommitted = true; + + const used = response.gasUsed - gasBefore; + this.gasUsed = response.gasUsed; + + return this.buildUpdateFromAddressResponse( + newBytecode.byteLength, + used, + 0, + response.data, + ); + } finally { + this._contract = originalContract; + + if (!upgradeCommitted) { + this._hasUpgradedInCurrentExecution = false; + } + + if (tempContract) { + try { + tempContract.dispose(); + } catch { + // Ignore dispose errors + } + } + } + } catch (e) { + if (this.logUnexpectedErrors) { + this.warn(`(debug) updateFromAddress failed with error: ${(e as Error).message}`); + } + + return this.buildUpdateFromAddressResponse( + 0, + this.gasUsed, + 1, + this.getErrorAsBuffer(e as Error), + ); + } + } + private async deployContractAtAddress(data: Buffer): Promise { + if (this._hasUpgradedInCurrentExecution) { + throw new Error( + 'OP_NET: Cannot deploy contracts after upgrading bytecode in the same execution', + ); + } + try { const reader = new BinaryReader(data); this.gasUsed = reader.readU64(); @@ -680,6 +909,20 @@ export class ContractRuntime extends Logger { return writer.getBuffer(); } + private buildUpdateFromAddressResponse( + bytecodeLength: number, + usedGas: bigint, + status: 0 | 1, + response: Uint8Array, + ): Uint8Array { + const writer = new BinaryWriter(); + writer.writeU32(bytecodeLength); + writer.writeU64(usedGas); + writer.writeU32(status); + writer.writeBytes(response); + return writer.getBuffer(); + } + private loadMLDSA(data: Buffer): Buffer | Uint8Array { const reader = new BinaryReader(data); const level = reader.readU8(); @@ -791,6 +1034,12 @@ export class ContractRuntime extends Logger { throw new Error('Contract not initialized'); } + if (this._hasUpgradedInCurrentExecution) { + throw new Error( + 'OP_NET: Cannot call other contracts after upgrading bytecode in the same execution', + ); + } + let gasUsed: bigint = this.gasUsed; try { const reader = new BinaryReader(data); @@ -915,13 +1164,7 @@ export class ContractRuntime extends Logger { } private getErrorAsBuffer(error: Error | string | undefined): Uint8Array { - const errorWriter = new BinaryWriter(); - errorWriter.writeSelector(0x63739d5c); - errorWriter.writeStringWithLength( - typeof error === 'string' ? error : error?.message || 'Unknown error', - ); - - return errorWriter.getBuffer(); + return RustContract.getErrorAsBuffer(error); } private onLog(data: Buffer | Uint8Array): void { @@ -1072,7 +1315,7 @@ export class ContractRuntime extends Logger { } } - private generateParams(): ContractParameters { + private generateParams(bypassCache?: boolean): ContractParameters { return { address: this.p2opAddress, bytecode: this.bytecode, @@ -1081,7 +1324,9 @@ export class ContractRuntime extends Logger { memoryPagesUsed: this.memoryPagesUsed, network: this.getNetwork(), isDebugMode: this.isDebugMode, - returnProofs: this.proofFeatureEnabled, + bypassCache, + //returnProofs: this.proofFeatureEnabled, + updateFromAddress: this.updateFromAddress.bind(this), contractManager: Blockchain.contractManager, deployContractAtAddress: this.deployContractAtAddress.bind(this), load: (data: Buffer) => { diff --git a/src/opnet/modules/GetBytecode.ts b/src/opnet/modules/GetBytecode.ts index edea620..83b1915 100644 --- a/src/opnet/modules/GetBytecode.ts +++ b/src/opnet/modules/GetBytecode.ts @@ -37,6 +37,20 @@ class BytecodeManagerBase { this.bytecodeMap.set(address, bytecode); } + + public forceSetBytecode(address: Address, bytecode: Buffer): void { + this.bytecodeMap.set(address, bytecode); + } + + public removeBytecode(address: Address): void { + this.bytecodeMap.delete(address); + this.fileNameMap.delete(address); + } + + public clear(): void { + this.bytecodeMap.clear(); + this.fileNameMap.clear(); + } } export const BytecodeManager = new BytecodeManagerBase(); diff --git a/src/opnet/vm/RustContract.ts b/src/opnet/vm/RustContract.ts index 23208b4..c8d60ff 100644 --- a/src/opnet/vm/RustContract.ts +++ b/src/opnet/vm/RustContract.ts @@ -5,11 +5,13 @@ import { ExitDataResponse, } from '@btc-vision/op-vm'; -import { SELECTOR_BYTE_LENGTH, U32_BYTE_LENGTH } from '@btc-vision/transaction'; -import { Blockchain } from '../../blockchain/Blockchain.js'; -import { RustContractBinding } from './RustContractBinding.js'; +import { BinaryWriter, SELECTOR_BYTE_LENGTH, U32_BYTE_LENGTH } from '@btc-vision/transaction'; +import { RustContractBinding } from './RustContractBinding'; +import { Blockchain } from '../../blockchain/Blockchain'; -//init(); +process.on('uncaughtException', (error) => { + console.log('Uncaught Exception thrown:', error); +}); export interface ContractParameters extends Omit { readonly address: string; @@ -20,7 +22,7 @@ export interface ContractParameters extends Omit { readonly memoryPagesUsed: bigint; readonly network: BitcoinNetworkRequest; readonly isDebugMode: boolean; - readonly returnProofs: boolean; + readonly bypassCache?: boolean; readonly contractManager: ContractManager; } @@ -46,7 +48,7 @@ export class RustContract { } if (this._id == null) { - this._id = this.contractManager.reserveId(); + this._id = BigInt(this.contractManager.reserveId().toString()); Blockchain.registerBinding({ id: this._id, @@ -56,6 +58,7 @@ export class RustContract { tStore: this.params.tStore, call: this.params.call, deployContractAtAddress: this.params.deployContractAtAddress, + updateFromAddress: this.params.updateFromAddress, log: this.params.log, emit: this.params.emit, inputs: this.params.inputs, @@ -68,7 +71,7 @@ export class RustContract { this.instantiate(); } - return this._id; + return BigInt(this._id.toString()); } private _instantiated: boolean = false; @@ -93,11 +96,21 @@ export class RustContract { return this._params; } - public static decodeRevertData(revertDataBytes: Uint8Array): Error { + public static getErrorAsBuffer(error: Error | string | undefined): Uint8Array { + const errorWriter = new BinaryWriter(); + errorWriter.writeSelector(0x63739d5c); + errorWriter.writeStringWithLength( + typeof error === 'string' ? error : error?.message || 'Unknown error', + ); + + return errorWriter.getBuffer(); + } + + public static decodeRevertData(revertDataBytes: Uint8Array | Buffer): Error { if (RustContract.startsWithErrorSelector(revertDataBytes)) { const decoder = new TextDecoder(); const revertMessage = decoder.decode( - revertDataBytes.slice(SELECTOR_BYTE_LENGTH + U32_BYTE_LENGTH), + revertDataBytes.subarray(SELECTOR_BYTE_LENGTH + U32_BYTE_LENGTH), ); return new Error(revertMessage); @@ -137,15 +150,15 @@ export class RustContract { if (this._instantiated) return; this.contractManager.instantiate( - this._id, + BigInt(this._id.toString()), this.params.address, - this.params.bytecode, - this.params.gasUsed, - this.params.gasMax, - this.params.memoryPagesUsed, + Buffer.copyBytesFrom(this.params.bytecode), + BigInt(this.params.gasUsed.toString()), + BigInt(this.params.gasMax.toString()), + BigInt(this.params.memoryPagesUsed.toString()), this.params.network, this.params.isDebugMode, - //this.params.returnProofs, + this.params.bypassCache ?? false, ); this._instantiated = true; @@ -184,11 +197,16 @@ export class RustContract { } } - public async execute(calldata: Uint8Array | Buffer): Promise { + public async execute(calldata: Uint8Array | Buffer): Promise> { if (this.enableDebug) console.log('execute', calldata); try { - return await this.contractManager.execute(this.id, Buffer.from(calldata)); + const result = await this.contractManager.execute( + this.id, + Buffer.copyBytesFrom(calldata), + ); + + return this.toReadonlyObject(result); } catch (e) { if (this.enableDebug) console.log('Error in execute', e); @@ -210,11 +228,34 @@ export class RustContract { } } - public async onDeploy(calldata: Uint8Array | Buffer): Promise { + public async onUpdate(calldata: Uint8Array | Buffer): Promise> { + if (this.enableDebug) console.log('Setting onUpdate', calldata); + + try { + const result = await this.contractManager.onUpdate( + this.id, + Buffer.copyBytesFrom(calldata), + ); + + return this.toReadonlyObject(result); + } catch (e) { + if (this.enableDebug) console.log('Error in onUpdate', e); + + const error = e as Error; + throw this.getError(error); + } + } + + public async onDeploy(calldata: Uint8Array | Buffer): Promise> { if (this.enableDebug) console.log('Setting onDeployment', calldata); try { - return await this.contractManager.onDeploy(this.id, Buffer.from(calldata)); + const result = await this.contractManager.onDeploy( + this.id, + Buffer.copyBytesFrom(calldata), + ); + + return this.toReadonlyObject(result); } catch (e) { if (this.enableDebug) console.log('Error in onDeployment', e); @@ -224,7 +265,8 @@ export class RustContract { } public getRevertError(): Error { - const revertData = this.contractManager.getExitData(this.id).data; + const revertInfo = this.contractManager.getExitData(this.id); + const revertData = Buffer.copyBytesFrom(revertInfo.data); try { this.dispose(); @@ -233,8 +275,7 @@ export class RustContract { if (revertData.length === 0) { return new Error(`Execution reverted`); } else { - const revertDataBytes = Uint8Array.from(revertData); - return RustContract.decodeRevertData(revertDataBytes); + return RustContract.decodeRevertData(revertData); } } @@ -244,18 +285,36 @@ export class RustContract { return this.gasUsed; } - return this.contractManager.getUsedGas(this.id); + return BigInt(this.contractManager.getUsedGas(this.id).toString()); } catch (e) { const error = e as Error; throw this.getError(error); } } + private toReadonlyObject(result: ExitDataResponse): Readonly { + return Object.preventExtensions( + Object.freeze( + Object.seal({ + status: result.status, + data: Buffer.copyBytesFrom(result.data), + gasUsed: BigInt(result.gasUsed.toString()), + proofs: result.proofs?.map((proof) => { + return { + proof: Buffer.copyBytesFrom(proof.proof), + vk: Buffer.copyBytesFrom(proof.vk), + }; + }), + }), + ), + ); + } + private getError(err: Error): Error { if (this.enableDebug) console.log('Getting error', err); const msg = err.message; - if (msg && msg.includes('Execution reverted') && !msg.includes('Execution reverted:')) { + if (msg.includes('Execution reverted') && !msg.includes('Execution reverted:')) { return this.getRevertError(); } else { return err; diff --git a/src/opnet/vm/RustContractBinding.ts b/src/opnet/vm/RustContractBinding.ts index 6cd907a..a75a0be 100644 --- a/src/opnet/vm/RustContractBinding.ts +++ b/src/opnet/vm/RustContractBinding.ts @@ -9,6 +9,7 @@ export interface RustContractBinding { readonly tStore: (data: Buffer) => Promise; readonly call: (data: Buffer) => Promise; readonly deployContractAtAddress: (data: Buffer) => Promise; + readonly updateFromAddress: (data: Buffer) => Promise; readonly log: (data: Buffer) => void; readonly emit: (data: Buffer) => void; readonly inputs: () => Promise; diff --git a/test/e2e/contracts/ecdsa-contract/contract/ECDSAContract.ts b/test/e2e/contracts/ecdsa-contract/contract/ECDSAContract.ts new file mode 100644 index 0000000..5a0fe60 --- /dev/null +++ b/test/e2e/contracts/ecdsa-contract/contract/ECDSAContract.ts @@ -0,0 +1,59 @@ +import { + Blockchain, + BytesWriter, + Calldata, + OP_NET, +} from '@btc-vision/btc-runtime/runtime'; +import { sha256 } from '@btc-vision/btc-runtime/runtime/env/global'; + +@final +export class ECDSAContract extends OP_NET { + public constructor() { + super(); + } + + @method('bytes', 'bytes', 'bytes') + @returns({ + type: ABIDataTypes.BOOL, + name: 'valid', + }) + public verifyECDSAEthereum(calldata: Calldata): BytesWriter { + const publicKey = calldata.readBytesWithLength(); + const signature = calldata.readBytesWithLength(); + const hash = calldata.readBytesWithLength(); + + const isValid = Blockchain.verifyECDSASignature(publicKey, signature, hash); + + const writer = new BytesWriter(1); + writer.writeBoolean(isValid); + return writer; + } + + @method('bytes', 'bytes', 'bytes') + @returns({ + type: ABIDataTypes.BOOL, + name: 'valid', + }) + public verifyECDSABitcoin(calldata: Calldata): BytesWriter { + const publicKey = calldata.readBytesWithLength(); + const signature = calldata.readBytesWithLength(); + const hash = calldata.readBytesWithLength(); + + const isValid = Blockchain.verifyBitcoinECDSASignature(publicKey, signature, hash); + + const writer = new BytesWriter(1); + writer.writeBoolean(isValid); + return writer; + } + + @method('bytes') + @returns('bytes32') + public hashMessage(calldata: Calldata): BytesWriter { + const data = calldata.readBytesWithLength(); + const result = sha256(data); + + const writer = new BytesWriter(32); + writer.writeBytes(result); + return writer; + } +} diff --git a/test/e2e/contracts/ecdsa-contract/contract/asconfig.json b/test/e2e/contracts/ecdsa-contract/contract/asconfig.json new file mode 100644 index 0000000..5849ca3 --- /dev/null +++ b/test/e2e/contracts/ecdsa-contract/contract/asconfig.json @@ -0,0 +1,32 @@ +{ + "targets": { + "debug": { + "outFile": "build/ECDSAContract.wasm", + "textFile": "build/ECDSAContract.wat" + } + }, + "options": { + "transform": "@btc-vision/opnet-transform", + "sourceMap": false, + "optimizeLevel": 3, + "shrinkLevel": 1, + "converge": true, + "noAssert": false, + "enable": [ + "sign-extension", + "mutable-globals", + "nontrapping-f2i", + "bulk-memory", + "simd", + "reference-types", + "multi-value" + ], + "runtime": "stub", + "memoryBase": 0, + "initialMemory": 1, + "exportStart": "start", + "use": [ + "abort=index/abort" + ] + } +} diff --git a/test/e2e/contracts/ecdsa-contract/contract/index.ts b/test/e2e/contracts/ecdsa-contract/contract/index.ts new file mode 100644 index 0000000..2535cb4 --- /dev/null +++ b/test/e2e/contracts/ecdsa-contract/contract/index.ts @@ -0,0 +1,19 @@ +import { Blockchain } from '@btc-vision/btc-runtime/runtime'; +import { ECDSAContract } from './ECDSAContract'; +import { revertOnError } from '@btc-vision/btc-runtime/runtime/abort/abort'; + +// DO NOT TOUCH TO THIS. +Blockchain.contract = () => { + // ONLY CHANGE THE CONTRACT CLASS NAME. + // DO NOT ADD CUSTOM LOGIC HERE. + + return new ECDSAContract(); +}; + +// VERY IMPORTANT +export * from '@btc-vision/btc-runtime/runtime/exports'; + +// VERY IMPORTANT +export function abort(message: string, fileName: string, line: u32, column: u32): void { + revertOnError(message, fileName, line, column); +} diff --git a/test/e2e/contracts/ecdsa-contract/contract/tsconfig.json b/test/e2e/contracts/ecdsa-contract/contract/tsconfig.json new file mode 100644 index 0000000..bf55f3f --- /dev/null +++ b/test/e2e/contracts/ecdsa-contract/contract/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "@btc-vision/opnet-transform/std/assembly.json", + "compilerOptions": { + "module": "esnext", + "declaration": true, + "target": "esnext", + "noImplicitAny": true, + "removeComments": true, + "suppressImplicitAnyIndexErrors": false, + "preserveConstEnums": true, + "resolveJsonModule": true, + "skipLibCheck": false, + "sourceMap": false, + "moduleDetection": "force", + "experimentalDecorators": true, + "lib": [ + "es6" + ], + "strict": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "alwaysStrict": true, + "moduleResolution": "node", + "allowJs": false, + "incremental": true, + "allowSyntheticDefaultImports": true, + "outDir": "build" + }, + "include": [ + "./**/*.ts" + ] +} diff --git a/test/e2e/contracts/ecdsa-contract/runtime/ECDSAContractRuntime.ts b/test/e2e/contracts/ecdsa-contract/runtime/ECDSAContractRuntime.ts new file mode 100644 index 0000000..5ec2c00 --- /dev/null +++ b/test/e2e/contracts/ecdsa-contract/runtime/ECDSAContractRuntime.ts @@ -0,0 +1,96 @@ +import { Address, BinaryReader, BinaryWriter } from '@btc-vision/transaction'; +import { BytecodeManager, CallResponse, ContractRuntime } from '../../../../../src'; + +export class ECDSAContractRuntime extends ContractRuntime { + private readonly verifyECDSAEthereumSelector: number = this.getSelector( + 'verifyECDSAEthereum(bytes,bytes,bytes)', + ); + private readonly verifyECDSABitcoinSelector: number = this.getSelector( + 'verifyECDSABitcoin(bytes,bytes,bytes)', + ); + private readonly hashMessageSelector: number = this.getSelector('hashMessage(bytes)'); + + public constructor(deployer: Address, address: Address, gasLimit: bigint = 150_000_000_000n) { + super({ + address: address, + deployer: deployer, + gasLimit, + }); + } + + public async verifyECDSAEthereum( + publicKey: Uint8Array, + signature: Uint8Array, + hash: Uint8Array, + ): Promise<{ result: boolean; gas: bigint }> { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.verifyECDSAEthereumSelector); + calldata.writeBytesWithLength(publicKey); + calldata.writeBytesWithLength(signature); + calldata.writeBytesWithLength(hash); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + + const reader = new BinaryReader(response.response); + return { + result: reader.readBoolean(), + gas: response.usedGas, + }; + } + + public async verifyECDSABitcoin( + publicKey: Uint8Array, + signature: Uint8Array, + hash: Uint8Array, + ): Promise<{ result: boolean; gas: bigint }> { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.verifyECDSABitcoinSelector); + calldata.writeBytesWithLength(publicKey); + calldata.writeBytesWithLength(signature); + calldata.writeBytesWithLength(hash); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + + const reader = new BinaryReader(response.response); + return { + result: reader.readBoolean(), + gas: response.usedGas, + }; + } + + public async hashMessage(data: Uint8Array): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.hashMessageSelector); + calldata.writeBytesWithLength(data); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + + const reader = new BinaryReader(response.response); + return reader.readBytes(32); + } + + protected handleError(error: Error): Error { + return new Error(`(in ECDSA contract: ${this.address}) OP_NET: ${error.message}`); + } + + protected defineRequiredBytecodes(): void { + BytecodeManager.loadBytecode( + './test/e2e/contracts/ecdsa-contract/contract/build/ECDSAContract.wasm', + this.address, + ); + } + + private getSelector(signature: string): number { + return Number(`0x${this.abiCoder.encodeSelector(signature)}`); + } + + private handleResponse(response: CallResponse): void { + if (response.error) throw this.handleError(response.error); + if (!response.response) { + throw new Error('No response to decode'); + } + } +} diff --git a/test/e2e/contracts/malicious-v2/contract/MaliciousV2.ts b/test/e2e/contracts/malicious-v2/contract/MaliciousV2.ts new file mode 100644 index 0000000..872662c --- /dev/null +++ b/test/e2e/contracts/malicious-v2/contract/MaliciousV2.ts @@ -0,0 +1,46 @@ +import { + Blockchain, + BytesWriter, + Calldata, + OP_NET, +} from '@btc-vision/btc-runtime/runtime'; +import { Revert } from '@btc-vision/btc-runtime/runtime/types/Revert'; +import { NetEvent } from '@btc-vision/btc-runtime/runtime/events/NetEvent'; + +class PhantomEvent extends NetEvent { + constructor() { + const data = new BytesWriter(4); + data.writeU32(0xdeadbeef); + super('PhantomEvent', data); + } +} + +@final +export class MaliciousV2 extends OP_NET { + public constructor() { + super(); + } + + public override onUpdate(calldata: Calldata): void { + // 1. Write a malicious storage slot (should NOT persist if onUpdate fails) + const key = new Uint8Array(32); + key[0] = 0xff; + const value = new Uint8Array(32); + value[0] = 0x42; + Blockchain.setStorageAt(key, value); + + // 2. Emit a phantom event (should NOT persist if onUpdate fails) + this.emitEvent(new PhantomEvent()); + + // 3. Revert + throw new Revert('MaliciousV2: deliberate onUpdate revert'); + } + + @method() + @returns('uint32') + public getValue(_: Calldata): BytesWriter { + const result = new BytesWriter(4); + result.writeU32(99); + return result; + } +} diff --git a/test/e2e/contracts/malicious-v2/contract/asconfig.json b/test/e2e/contracts/malicious-v2/contract/asconfig.json new file mode 100644 index 0000000..f6942a8 --- /dev/null +++ b/test/e2e/contracts/malicious-v2/contract/asconfig.json @@ -0,0 +1,32 @@ +{ + "targets": { + "debug": { + "outFile": "build/MaliciousV2.wasm", + "textFile": "build/MaliciousV2.wat" + } + }, + "options": { + "transform": "@btc-vision/opnet-transform", + "sourceMap": false, + "optimizeLevel": 3, + "shrinkLevel": 1, + "converge": true, + "noAssert": false, + "enable": [ + "sign-extension", + "mutable-globals", + "nontrapping-f2i", + "bulk-memory", + "simd", + "reference-types", + "multi-value" + ], + "runtime": "stub", + "memoryBase": 0, + "initialMemory": 1, + "exportStart": "start", + "use": [ + "abort=index/abort" + ] + } +} diff --git a/test/e2e/contracts/malicious-v2/contract/index.ts b/test/e2e/contracts/malicious-v2/contract/index.ts new file mode 100644 index 0000000..5aa9add --- /dev/null +++ b/test/e2e/contracts/malicious-v2/contract/index.ts @@ -0,0 +1,19 @@ +import { Blockchain } from '@btc-vision/btc-runtime/runtime'; +import { MaliciousV2 } from './MaliciousV2'; +import { revertOnError } from '@btc-vision/btc-runtime/runtime/abort/abort'; + +// DO NOT TOUCH TO THIS. +Blockchain.contract = () => { + // ONLY CHANGE THE CONTRACT CLASS NAME. + // DO NOT ADD CUSTOM LOGIC HERE. + + return new MaliciousV2(); +}; + +// VERY IMPORTANT +export * from '@btc-vision/btc-runtime/runtime/exports'; + +// VERY IMPORTANT +export function abort(message: string, fileName: string, line: u32, column: u32): void { + revertOnError(message, fileName, line, column); +} diff --git a/test/e2e/contracts/malicious-v2/contract/tsconfig.json b/test/e2e/contracts/malicious-v2/contract/tsconfig.json new file mode 100644 index 0000000..bf55f3f --- /dev/null +++ b/test/e2e/contracts/malicious-v2/contract/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "@btc-vision/opnet-transform/std/assembly.json", + "compilerOptions": { + "module": "esnext", + "declaration": true, + "target": "esnext", + "noImplicitAny": true, + "removeComments": true, + "suppressImplicitAnyIndexErrors": false, + "preserveConstEnums": true, + "resolveJsonModule": true, + "skipLibCheck": false, + "sourceMap": false, + "moduleDetection": "force", + "experimentalDecorators": true, + "lib": [ + "es6" + ], + "strict": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "alwaysStrict": true, + "moduleResolution": "node", + "allowJs": false, + "incremental": true, + "allowSyntheticDefaultImports": true, + "outDir": "build" + }, + "include": [ + "./**/*.ts" + ] +} diff --git a/test/e2e/contracts/test-contract/contract/TestContract.ts b/test/e2e/contracts/test-contract/contract/TestContract.ts index 19c4678..183fbe8 100644 --- a/test/e2e/contracts/test-contract/contract/TestContract.ts +++ b/test/e2e/contracts/test-contract/contract/TestContract.ts @@ -6,13 +6,39 @@ import { Calldata, encodeSelector, OP_NET, + SignaturesMethods, } from '@btc-vision/btc-runtime/runtime'; import { callContract, getCallResult, ripemd160, sha256, -} from '@btc-vision/btc-runtime/runtime/env/global'; +} from '@btc-vision/btc-runtime/runtime/env/global'; /*const ONE_MB: usize = 1048576; // 1 MiB = 1 048 576 bytes + +// Two contiguous 1 MiB heaps +const src = new StaticArray(ONE_MB); +const dst = new StaticArray(ONE_MB); + +// Deterministic pattern → corruption becomes obvious if you inspect +for (let i: usize = 0; i < ONE_MB; ++i) { + unchecked((src[i] = i)); +} + +export function spamMemoryCopy(rounds: u32): void { + // Pound the bulk-memory instruction + const pSrc = changetype(src); + const pDst = changetype(dst); + + for (let n: u32 = 0; n < rounds; ++n) { + memory.copy(pDst, pSrc, ONE_MB); + } + + while (true) { + memory.fill(pDst, 0, ONE_MB); + } +} + +spamMemoryCopy(i32.MAX_VALUE);*/ /*const ONE_MB: usize = 1048576; // 1 MiB = 1 048 576 bytes @@ -70,7 +96,12 @@ export class TestContract extends OP_NET { message.writeString('Hello, world! This is a test message for MLDSA signing.'); const messageHashed = sha256(message.getBuffer()); - const result = Blockchain.verifySignature(Blockchain.tx.origin, data, messageHashed, true); + const result = Blockchain.verifySignature( + Blockchain.tx.origin, + data, + messageHashed, + SignaturesMethods.MLDSA, + ); const writer = new BytesWriter(1); writer.writeBoolean(result); @@ -88,7 +119,12 @@ export class TestContract extends OP_NET { message.writeString('Hello, world! This is a test message for Schnorr signing.'); const messageHashed = sha256(message.getBuffer()); - const result = Blockchain.verifySignature(Blockchain.tx.origin, data, messageHashed, false); + const result = Blockchain.verifySignature( + Blockchain.tx.origin, + data, + messageHashed, + SignaturesMethods.Schnorr, + ); const writer = new BytesWriter(1); writer.writeBoolean(result); diff --git a/test/e2e/contracts/upgradeable-contract-v2/contract/UpgradeableContractV2.ts b/test/e2e/contracts/upgradeable-contract-v2/contract/UpgradeableContractV2.ts new file mode 100644 index 0000000..33e4d8e --- /dev/null +++ b/test/e2e/contracts/upgradeable-contract-v2/contract/UpgradeableContractV2.ts @@ -0,0 +1,42 @@ +import { + Blockchain, + BytesWriter, + Calldata, + OP_NET, +} from '@btc-vision/btc-runtime/runtime'; + +@final +export class UpgradeableContractV2 extends OP_NET { + public constructor() { + super(); + } + + @method() + @returns('uint32') + public getValue(_: Calldata): BytesWriter { + const result = new BytesWriter(4); + result.writeU32(2); + return result; + } + + @method('bytes32', 'bytes32') + @returns('bytes32') + public store(calldata: Calldata): BytesWriter { + const key = calldata.readBytes(32); + const value = calldata.readBytes(32); + Blockchain.setStorageAt(key, value); + const result = new BytesWriter(32); + result.writeBytes(value); + return result; + } + + @method('bytes32') + @returns('bytes32') + public load(calldata: Calldata): BytesWriter { + const key = calldata.readBytes(32); + const value = Blockchain.getStorageAt(key); + const result = new BytesWriter(32); + result.writeBytes(value); + return result; + } +} diff --git a/test/e2e/contracts/upgradeable-contract-v2/contract/asconfig.json b/test/e2e/contracts/upgradeable-contract-v2/contract/asconfig.json new file mode 100644 index 0000000..17632d1 --- /dev/null +++ b/test/e2e/contracts/upgradeable-contract-v2/contract/asconfig.json @@ -0,0 +1,32 @@ +{ + "targets": { + "debug": { + "outFile": "build/UpgradeableContractV2.wasm", + "textFile": "build/UpgradeableContractV2.wat" + } + }, + "options": { + "transform": "@btc-vision/opnet-transform", + "sourceMap": false, + "optimizeLevel": 3, + "shrinkLevel": 1, + "converge": true, + "noAssert": false, + "enable": [ + "sign-extension", + "mutable-globals", + "nontrapping-f2i", + "bulk-memory", + "simd", + "reference-types", + "multi-value" + ], + "runtime": "stub", + "memoryBase": 0, + "initialMemory": 1, + "exportStart": "start", + "use": [ + "abort=index/abort" + ] + } +} diff --git a/test/e2e/contracts/upgradeable-contract-v2/contract/index.ts b/test/e2e/contracts/upgradeable-contract-v2/contract/index.ts new file mode 100644 index 0000000..d17e068 --- /dev/null +++ b/test/e2e/contracts/upgradeable-contract-v2/contract/index.ts @@ -0,0 +1,19 @@ +import { Blockchain } from '@btc-vision/btc-runtime/runtime'; +import { UpgradeableContractV2 } from './UpgradeableContractV2'; +import { revertOnError } from '@btc-vision/btc-runtime/runtime/abort/abort'; + +// DO NOT TOUCH TO THIS. +Blockchain.contract = () => { + // ONLY CHANGE THE CONTRACT CLASS NAME. + // DO NOT ADD CUSTOM LOGIC HERE. + + return new UpgradeableContractV2(); +}; + +// VERY IMPORTANT +export * from '@btc-vision/btc-runtime/runtime/exports'; + +// VERY IMPORTANT +export function abort(message: string, fileName: string, line: u32, column: u32): void { + revertOnError(message, fileName, line, column); +} diff --git a/test/e2e/contracts/upgradeable-contract-v2/contract/tsconfig.json b/test/e2e/contracts/upgradeable-contract-v2/contract/tsconfig.json new file mode 100644 index 0000000..4d6d11e --- /dev/null +++ b/test/e2e/contracts/upgradeable-contract-v2/contract/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "@btc-vision/opnet-transform/std/assembly.json", + "compilerOptions": { + "module": "esnext", + "declaration": true, + "target": "esnext", + "noImplicitAny": true, + "removeComments": true, + "suppressImplicitAnyIndexErrors": false, + "preserveConstEnums": true, + "resolveJsonModule": true, + "skipLibCheck": false, + "sourceMap": false, + "moduleDetection": "force", + "experimentalDecorators": true, + "lib": [ + "es6" + ], + "strict": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "alwaysStrict": true, + "moduleResolution": "node", + "allowJs": false, + "incremental": true, + "allowSyntheticDefaultImports": true, + "outDir": "build" + }, + "include": [ + "./**/*.ts" + ], +} diff --git a/test/e2e/contracts/upgradeable-contract/contract/UpgradeableContract.ts b/test/e2e/contracts/upgradeable-contract/contract/UpgradeableContract.ts new file mode 100644 index 0000000..3c7804b --- /dev/null +++ b/test/e2e/contracts/upgradeable-contract/contract/UpgradeableContract.ts @@ -0,0 +1,50 @@ +import { + Address, + Blockchain, + BytesWriter, + Calldata, + OP_NET, +} from '@btc-vision/btc-runtime/runtime'; + +@final +export class UpgradeableContract extends OP_NET { + public constructor() { + super(); + } + + @method() + @returns('uint32') + public getValue(_: Calldata): BytesWriter { + const result = new BytesWriter(4); + result.writeU32(1); + return result; + } + + @method('address') + public upgrade(calldata: Calldata): BytesWriter { + const sourceAddress = calldata.readAddress(); + Blockchain.updateContractFromExisting(sourceAddress); + return new BytesWriter(0); + } + + @method('bytes32', 'bytes32') + @returns('bytes32') + public store(calldata: Calldata): BytesWriter { + const key = calldata.readBytes(32); + const value = calldata.readBytes(32); + Blockchain.setStorageAt(key, value); + const result = new BytesWriter(32); + result.writeBytes(value); + return result; + } + + @method('bytes32') + @returns('bytes32') + public load(calldata: Calldata): BytesWriter { + const key = calldata.readBytes(32); + const value = Blockchain.getStorageAt(key); + const result = new BytesWriter(32); + result.writeBytes(value); + return result; + } +} diff --git a/test/e2e/contracts/upgradeable-contract/contract/asconfig.json b/test/e2e/contracts/upgradeable-contract/contract/asconfig.json new file mode 100644 index 0000000..9ebc239 --- /dev/null +++ b/test/e2e/contracts/upgradeable-contract/contract/asconfig.json @@ -0,0 +1,32 @@ +{ + "targets": { + "debug": { + "outFile": "build/UpgradeableContract.wasm", + "textFile": "build/UpgradeableContract.wat" + } + }, + "options": { + "transform": "@btc-vision/opnet-transform", + "sourceMap": false, + "optimizeLevel": 3, + "shrinkLevel": 1, + "converge": true, + "noAssert": false, + "enable": [ + "sign-extension", + "mutable-globals", + "nontrapping-f2i", + "bulk-memory", + "simd", + "reference-types", + "multi-value" + ], + "runtime": "stub", + "memoryBase": 0, + "initialMemory": 1, + "exportStart": "start", + "use": [ + "abort=index/abort" + ] + } +} diff --git a/test/e2e/contracts/upgradeable-contract/contract/index.ts b/test/e2e/contracts/upgradeable-contract/contract/index.ts new file mode 100644 index 0000000..0bb1053 --- /dev/null +++ b/test/e2e/contracts/upgradeable-contract/contract/index.ts @@ -0,0 +1,19 @@ +import { Blockchain } from '@btc-vision/btc-runtime/runtime'; +import { UpgradeableContract } from './UpgradeableContract'; +import { revertOnError } from '@btc-vision/btc-runtime/runtime/abort/abort'; + +// DO NOT TOUCH TO THIS. +Blockchain.contract = () => { + // ONLY CHANGE THE CONTRACT CLASS NAME. + // DO NOT ADD CUSTOM LOGIC HERE. + + return new UpgradeableContract(); +}; + +// VERY IMPORTANT +export * from '@btc-vision/btc-runtime/runtime/exports'; + +// VERY IMPORTANT +export function abort(message: string, fileName: string, line: u32, column: u32): void { + revertOnError(message, fileName, line, column); +} diff --git a/test/e2e/contracts/upgradeable-contract/contract/tsconfig.json b/test/e2e/contracts/upgradeable-contract/contract/tsconfig.json new file mode 100644 index 0000000..4d6d11e --- /dev/null +++ b/test/e2e/contracts/upgradeable-contract/contract/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "@btc-vision/opnet-transform/std/assembly.json", + "compilerOptions": { + "module": "esnext", + "declaration": true, + "target": "esnext", + "noImplicitAny": true, + "removeComments": true, + "suppressImplicitAnyIndexErrors": false, + "preserveConstEnums": true, + "resolveJsonModule": true, + "skipLibCheck": false, + "sourceMap": false, + "moduleDetection": "force", + "experimentalDecorators": true, + "lib": [ + "es6" + ], + "strict": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "alwaysStrict": true, + "moduleResolution": "node", + "allowJs": false, + "incremental": true, + "allowSyntheticDefaultImports": true, + "outDir": "build" + }, + "include": [ + "./**/*.ts" + ], +} diff --git a/test/e2e/contracts/upgradeable-contract/runtime/UpgradeableContractRuntime.ts b/test/e2e/contracts/upgradeable-contract/runtime/UpgradeableContractRuntime.ts new file mode 100644 index 0000000..f257b9e --- /dev/null +++ b/test/e2e/contracts/upgradeable-contract/runtime/UpgradeableContractRuntime.ts @@ -0,0 +1,80 @@ +import { Address, BinaryReader, BinaryWriter } from '@btc-vision/transaction'; +import { BytecodeManager, CallResponse, ContractRuntime } from '../../../../../src'; + +export class UpgradeableContractRuntime extends ContractRuntime { + private readonly getValueSelector: number = this.getSelector('getValue()'); + private readonly upgradeSelector: number = this.getSelector('upgrade(address)'); + private readonly storeSelector: number = this.getSelector('store(bytes32,bytes32)'); + private readonly loadSelector: number = this.getSelector('load(bytes32)'); + + public constructor(deployer: Address, address: Address, gasLimit: bigint = 150_000_000_000n) { + super({ + address: address, + deployer: deployer, + gasLimit, + }); + } + + public async getValue(): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.getValueSelector); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + + const reader = new BinaryReader(response.response); + return reader.readU32(); + } + + public async upgrade(sourceAddress: Address): Promise { + const calldata = new BinaryWriter(); + calldata.writeSelector(this.upgradeSelector); + calldata.writeAddress(sourceAddress); + + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + return response; + } + + public async storeValue(key: Uint8Array, value: Uint8Array): Promise { + const calldata = new BinaryWriter(68); + calldata.writeSelector(this.storeSelector); + calldata.writeBytes(key); + calldata.writeBytes(value); + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + return value; + } + + public async loadValue(key: Uint8Array): Promise { + const calldata = new BinaryWriter(36); + calldata.writeSelector(this.loadSelector); + calldata.writeBytes(key); + const response = await this.execute({ calldata: calldata.getBuffer() }); + this.handleResponse(response); + const reader = new BinaryReader(response.response); + return reader.readBytes(32); + } + + protected handleError(error: Error): Error { + return new Error(`(in upgradeable contract: ${this.address}) OP_NET: ${error.message}`); + } + + protected defineRequiredBytecodes(): void { + BytecodeManager.loadBytecode( + './test/e2e/contracts/upgradeable-contract/contract/build/UpgradeableContract.wasm', + this.address, + ); + } + + private getSelector(signature: string): number { + return Number(`0x${this.abiCoder.encodeSelector(signature)}`); + } + + private handleResponse(response: CallResponse): void { + if (response.error) throw this.handleError(response.error); + if (!response.response) { + throw new Error('No response to decode'); + } + } +} diff --git a/test/e2e/imports/Dos.test.ts b/test/e2e/imports/Dos.test.ts index 6d3e7cf..b361b88 100644 --- a/test/e2e/imports/Dos.test.ts +++ b/test/e2e/imports/Dos.test.ts @@ -13,7 +13,7 @@ await opnet('Dos tests', async (vm: OPNetUnit) => { Blockchain.clearContracts(); await Blockchain.init(); - contract = new TestContractRuntime(deployerAddress, contractAddress, 150_000_000_000n); //15_000_000_000_001n + contract = new TestContractRuntime(deployerAddress, contractAddress, 150_000_000_000n); Blockchain.register(contract); await contract.init(); @@ -26,15 +26,17 @@ await opnet('Dos tests', async (vm: OPNetUnit) => { Blockchain.dispose(); }); - await vm.it('memory', async () => { + await vm.it('should not crash when querying own account type', async () => { const targetAddress = contract.address; const t = Date.now(); - await Assert.throwsAsync(async () => { - await contract.accountTypeCall(targetAddress); - }); - + const accountType = await contract.accountTypeCall(targetAddress); const elapsed = Date.now() - t; - console.log(`Execution time: ${elapsed} ms`); + + // Contract address should return account type 1 (contract) + Assert.expect(accountType).toEqual(1); + + // Should complete in reasonable time (no DoS) + Assert.expect(elapsed < 5000).toEqual(true); }); }); diff --git a/test/e2e/imports/ECDSA.test.ts b/test/e2e/imports/ECDSA.test.ts new file mode 100644 index 0000000..4cf0cf2 --- /dev/null +++ b/test/e2e/imports/ECDSA.test.ts @@ -0,0 +1,302 @@ +import { createHash } from 'crypto'; +import { Address } from '@btc-vision/transaction'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { Assert, Blockchain, opnet, OPNetUnit } from '../../../src'; +import { ECDSAContractRuntime } from '../contracts/ecdsa-contract/runtime/ECDSAContractRuntime'; + +function sha256(data: Uint8Array): Uint8Array { + return new Uint8Array(createHash('sha256').update(data).digest()); +} + +function deterministicKey(seed: string): Uint8Array { + return new Uint8Array(createHash('sha256').update(seed).digest()); +} + +interface ECDSAKeyPair { + privateKey: Uint8Array; + compressed: Uint8Array; // 33 bytes + uncompressed: Uint8Array; // 65 bytes + raw: Uint8Array; // 64 bytes (x || y, no prefix) +} + +function generateKeyPair(seed: string): ECDSAKeyPair { + const privateKey = deterministicKey(seed); + const compressed = secp256k1.getPublicKey(privateKey, true); + const uncompressed = secp256k1.getPublicKey(privateKey, false); + const raw = uncompressed.slice(1); // drop 0x04 prefix + return { privateKey, compressed, uncompressed, raw }; +} + +/** + * Sign a raw message and return Ethereum-style 65-byte sig: r(32) || s(32) || v(1). + * + * IMPORTANT: noble/curves secp256k1.sign() hashes the message internally with SHA-256. + * The Rust VM's verify_prehash expects the SHA-256 hash of the raw message. + * So we sign the RAW message (noble hashes it), and pass SHA-256(rawMessage) to the contract. + */ +function signEthereum(rawMessage: Uint8Array, privateKey: Uint8Array): Uint8Array { + // noble/curves hashes rawMessage internally → signature is over SHA-256(rawMessage) + const sigBytes = secp256k1.sign(rawMessage, privateKey); + const pub33 = secp256k1.getPublicKey(privateKey, true); + + // Find recovery id using secp256k1.recoverPublicKey (also hashes internally) + for (let v = 0; v <= 1; v++) { + const recSig = new Uint8Array(65); + recSig[0] = v; + recSig.set(sigBytes, 1); + + try { + const recovered = secp256k1.recoverPublicKey(recSig, rawMessage); + if (Buffer.from(recovered).equals(Buffer.from(pub33))) { + // Build Ethereum-style sig: r(32) || s(32) || v(1) + const ethSig = new Uint8Array(65); + ethSig.set(sigBytes.slice(0, 32), 0); // r + ethSig.set(sigBytes.slice(32, 64), 32); // s + ethSig[64] = v; + return ethSig; + } + } catch { + // not this v + } + } + + throw new Error('Could not find recovery id'); +} + +/** + * Sign a raw message and return Bitcoin-style 64-byte compact sig: r(32) || s(32). + * + * IMPORTANT: noble/curves secp256k1.sign() hashes the message internally with SHA-256. + * The Rust VM's verify_prehash expects the SHA-256 hash of the raw message. + */ +function signBitcoin(rawMessage: Uint8Array, privateKey: Uint8Array): Uint8Array { + return new Uint8Array(secp256k1.sign(rawMessage, privateKey)); +} + +await opnet('ECDSA Signature Verification', async (vm: OPNetUnit) => { + let contract: ECDSAContractRuntime; + + const deployerAddress: Address = Blockchain.generateRandomAddress(); + const contractAddress: Address = Blockchain.generateRandomAddress(); + + // Raw message bytes (NOT pre-hashed) — noble/curves hashes internally + const testMessage = new TextEncoder().encode('Hello, ECDSA test message!'); + // SHA-256 hash to pass to the contract (the Rust VM verifies against this prehash) + const testHash = sha256(testMessage); + + const key1 = generateKeyPair('ecdsa-test-key-1'); + const key2 = generateKeyPair('ecdsa-test-key-2'); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + contract = new ECDSAContractRuntime(deployerAddress, contractAddress); + Blockchain.register(contract); + await contract.init(); + + Blockchain.txOrigin = deployerAddress; + Blockchain.msgSender = deployerAddress; + }); + + vm.afterEach(() => { + contract.dispose(); + Blockchain.dispose(); + }); + + // ─── Ethereum ECDSA (ecrecover) ─── + + await vm.it( + 'should verify a valid Ethereum ECDSA signature with compressed public key', + async () => { + const sig = signEthereum(testMessage, key1.privateKey); + const { result } = await contract.verifyECDSAEthereum(key1.compressed, sig, testHash); + Assert.expect(result).toEqual(true); + }, + ); + + await vm.it( + 'should verify a valid Ethereum ECDSA signature with uncompressed public key', + async () => { + const sig = signEthereum(testMessage, key1.privateKey); + const { result } = await contract.verifyECDSAEthereum( + key1.uncompressed, + sig, + testHash, + ); + Assert.expect(result).toEqual(true); + }, + ); + + await vm.it( + 'should verify a valid Ethereum ECDSA signature with raw 64-byte public key', + async () => { + const sig = signEthereum(testMessage, key1.privateKey); + const { result } = await contract.verifyECDSAEthereum(key1.raw, sig, testHash); + Assert.expect(result).toEqual(true); + }, + ); + + await vm.it('should reject Ethereum ECDSA signature with wrong public key', async () => { + const sig = signEthereum(testMessage, key1.privateKey); + const { result } = await contract.verifyECDSAEthereum(key2.compressed, sig, testHash); + Assert.expect(result).toEqual(false); + }); + + await vm.it('should reject Ethereum ECDSA signature with wrong message', async () => { + const sig = signEthereum(testMessage, key1.privateKey); + const wrongHash = sha256(new TextEncoder().encode('wrong message')); + const { result } = await contract.verifyECDSAEthereum(key1.compressed, sig, wrongHash); + Assert.expect(result).toEqual(false); + }); + + await vm.it( + 'should verify Ethereum ECDSA with v=27/28 (EIP-155 style recovery id)', + async () => { + const sig = signEthereum(testMessage, key1.privateKey); + // Convert v from 0/1 to 27/28 + sig[64] = sig[64] + 27; + const { result } = await contract.verifyECDSAEthereum(key1.compressed, sig, testHash); + Assert.expect(result).toEqual(true); + }, + ); + + // ─── Bitcoin ECDSA (direct verify) ─── + + await vm.it( + 'should verify a valid Bitcoin ECDSA signature with compressed public key', + async () => { + const sig = signBitcoin(testMessage, key1.privateKey); + const { result } = await contract.verifyECDSABitcoin(key1.compressed, sig, testHash); + Assert.expect(result).toEqual(true); + }, + ); + + await vm.it( + 'should verify a valid Bitcoin ECDSA signature with uncompressed public key', + async () => { + const sig = signBitcoin(testMessage, key1.privateKey); + const { result } = await contract.verifyECDSABitcoin( + key1.uncompressed, + sig, + testHash, + ); + Assert.expect(result).toEqual(true); + }, + ); + + await vm.it( + 'should verify a valid Bitcoin ECDSA signature with raw 64-byte public key', + async () => { + const sig = signBitcoin(testMessage, key1.privateKey); + const { result } = await contract.verifyECDSABitcoin(key1.raw, sig, testHash); + Assert.expect(result).toEqual(true); + }, + ); + + await vm.it('should reject Bitcoin ECDSA signature with wrong public key', async () => { + const sig = signBitcoin(testMessage, key1.privateKey); + const { result } = await contract.verifyECDSABitcoin(key2.compressed, sig, testHash); + Assert.expect(result).toEqual(false); + }); + + await vm.it('should reject Bitcoin ECDSA signature with wrong message', async () => { + const sig = signBitcoin(testMessage, key1.privateKey); + const wrongHash = sha256(new TextEncoder().encode('wrong message')); + const { result } = await contract.verifyECDSABitcoin(key1.compressed, sig, wrongHash); + Assert.expect(result).toEqual(false); + }); + + // ─── Multiple keys ─── + + await vm.it( + 'should verify Ethereum ECDSA with multiple different keys', + async () => { + for (let i = 0; i < 5; i++) { + const key = generateKeyPair(`multi-key-eth-${i}`); + const sig = signEthereum(testMessage, key.privateKey); + const { result } = await contract.verifyECDSAEthereum( + key.compressed, + sig, + testHash, + ); + Assert.expect(result).toEqual(true); + } + }, + ); + + await vm.it( + 'should verify Bitcoin ECDSA with multiple different keys', + async () => { + for (let i = 0; i < 5; i++) { + const key = generateKeyPair(`multi-key-btc-${i}`); + const sig = signBitcoin(testMessage, key.privateKey); + const { result } = await contract.verifyECDSABitcoin( + key.compressed, + sig, + testHash, + ); + Assert.expect(result).toEqual(true); + } + }, + ); + + // ─── Different message hashes ─── + + await vm.it('should verify Ethereum ECDSA with various message hashes', async () => { + const messages = ['msg1', 'msg2', 'a longer test message', '']; + for (const msg of messages) { + const rawMsg = new TextEncoder().encode(msg); + const hash = sha256(rawMsg); + const sig = signEthereum(rawMsg, key1.privateKey); + const { result } = await contract.verifyECDSAEthereum(key1.compressed, sig, hash); + Assert.expect(result).toEqual(true); + } + }); + + await vm.it('should verify Bitcoin ECDSA with various message hashes', async () => { + const messages = ['msg1', 'msg2', 'a longer test message', '']; + for (const msg of messages) { + const rawMsg = new TextEncoder().encode(msg); + const hash = sha256(rawMsg); + const sig = signBitcoin(rawMsg, key1.privateKey); + const { result } = await contract.verifyECDSABitcoin(key1.compressed, sig, hash); + Assert.expect(result).toEqual(true); + } + }); + + // ─── Gas consumption ─── + + await vm.it('should consume gas for Ethereum ECDSA verification', async () => { + const sig = signEthereum(testMessage, key1.privateKey); + const { gas } = await contract.verifyECDSAEthereum(key1.compressed, sig, testHash); + Assert.expect(gas > 0n).toEqual(true); + }); + + await vm.it('should consume gas for Bitcoin ECDSA verification', async () => { + const sig = signBitcoin(testMessage, key1.privateKey); + const { gas } = await contract.verifyECDSABitcoin(key1.compressed, sig, testHash); + Assert.expect(gas > 0n).toEqual(true); + }); + + // ─── Cross-key rejection (sign with one key, verify with another) ─── + + await vm.it( + 'should reject Ethereum signature signed by key1 but verified against key2', + async () => { + const sig = signEthereum(testMessage, key1.privateKey); + const { result } = await contract.verifyECDSAEthereum(key2.compressed, sig, testHash); + Assert.expect(result).toEqual(false); + }, + ); + + await vm.it( + 'should reject Bitcoin signature signed by key1 but verified against key2', + async () => { + const sig = signBitcoin(testMessage, key1.privateKey); + const { result } = await contract.verifyECDSABitcoin(key2.compressed, sig, testHash); + Assert.expect(result).toEqual(false); + }, + ); +}); diff --git a/test/e2e/imports/UpdateFromAddress.test.ts b/test/e2e/imports/UpdateFromAddress.test.ts new file mode 100644 index 0000000..e030e38 --- /dev/null +++ b/test/e2e/imports/UpdateFromAddress.test.ts @@ -0,0 +1,843 @@ +import { ABICoder, Address, BinaryWriter } from '@btc-vision/transaction'; +import { + Assert, + Blockchain, + BytecodeManager, + ContractRuntime, + opnet, + OPNetUnit, + StateHandler, +} from '../../../src'; +import { UpgradeableContractRuntime } from '../contracts/upgradeable-contract/runtime/UpgradeableContractRuntime'; + +const V2_WASM_PATH = + './test/e2e/contracts/upgradeable-contract-v2/contract/build/UpgradeableContractV2.wasm'; + +class V2SourceContractRuntime extends ContractRuntime { + public constructor(deployer: Address, address: Address) { + super({ + address: address, + deployer: deployer, + gasLimit: 150_000_000_000n, + }); + } + + protected handleError(error: Error): Error { + return new Error(`(v2 source) OP_NET: ${error.message}`); + } + + protected defineRequiredBytecodes(): void { + BytecodeManager.loadBytecode(V2_WASM_PATH, this.address); + } +} + +await opnet('UpdateFromAddress tests', async (vm: OPNetUnit) => { + let contract: UpgradeableContractRuntime; + let v2Source: V2SourceContractRuntime; + + const deployerAddress: Address = Blockchain.generateRandomAddress(); + const contractAddress: Address = Blockchain.generateRandomAddress(); + const v2SourceAddress: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + contract = new UpgradeableContractRuntime(deployerAddress, contractAddress); + Blockchain.register(contract); + await contract.init(); + + // Register v2 source as a contract so isContract() returns true + v2Source = new V2SourceContractRuntime(deployerAddress, v2SourceAddress); + Blockchain.register(v2Source); + await v2Source.init(); + + Blockchain.txOrigin = deployerAddress; + Blockchain.msgSender = deployerAddress; + }); + + vm.afterEach(() => { + contract.dispose(); + v2Source.dispose(); + Blockchain.dispose(); + }); + + // --- Basic upgrade flow --- + + await vm.it('should return getValue=1 before upgrade', async () => { + const value = await contract.getValue(); + Assert.expect(value).toEqual(1); + }); + + await vm.it('should not apply upgrade on the same block', async () => { + // Before upgrade, getValue returns 1 + const valueBefore = await contract.getValue(); + Assert.expect(valueBefore).toEqual(1); + + // Perform upgrade + await contract.upgrade(v2SourceAddress); + + // Same block: getValue should still return 1 (old bytecode) + const valueSameBlock = await contract.getValue(); + Assert.expect(valueSameBlock).toEqual(1); + }); + + await vm.it('should apply upgrade on the next block', async () => { + // Before upgrade + const valueBefore = await contract.getValue(); + Assert.expect(valueBefore).toEqual(1); + + // Perform upgrade + await contract.upgrade(v2SourceAddress); + + // Advance to next block + Blockchain.mineBlock(); + + // After next block, getValue should return 2 (new bytecode) + const valueAfter = await contract.getValue(); + Assert.expect(valueAfter).toEqual(2); + }); + + // --- Storage persistence --- + + await vm.it('should preserve storage across upgrade', async () => { + const storageKey = new Uint8Array(32); + storageKey[31] = 42; + + const storageValue = new Uint8Array(32); + storageValue[31] = 99; + + // Store a value pre-upgrade + await contract.storeValue(storageKey, storageValue); + + // Verify it can be loaded before upgrade + const loadedBefore = await contract.loadValue(storageKey); + Assert.expect(areBytesEqual(loadedBefore, storageValue)).toEqual(true); + + // Upgrade and advance block + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + + // Verify storage persists after upgrade with new bytecode + const loadedAfter = await contract.loadValue(storageKey); + Assert.expect(areBytesEqual(loadedAfter, storageValue)).toEqual(true); + }); + + await vm.it('should preserve multiple storage entries across upgrade', async () => { + const key1 = new Uint8Array(32); + key1[31] = 1; + const value1 = new Uint8Array(32); + value1[31] = 10; + + const key2 = new Uint8Array(32); + key2[31] = 2; + const value2 = new Uint8Array(32); + value2[31] = 20; + + const key3 = new Uint8Array(32); + key3[31] = 3; + const value3 = new Uint8Array(32); + value3[31] = 30; + + // Store multiple values + await contract.storeValue(key1, value1); + await contract.storeValue(key2, value2); + await contract.storeValue(key3, value3); + + // Upgrade and advance block + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + + // All values should be preserved + const loaded1 = await contract.loadValue(key1); + const loaded2 = await contract.loadValue(key2); + const loaded3 = await contract.loadValue(key3); + + Assert.expect(areBytesEqual(loaded1, value1)).toEqual(true); + Assert.expect(areBytesEqual(loaded2, value2)).toEqual(true); + Assert.expect(areBytesEqual(loaded3, value3)).toEqual(true); + }); + + await vm.it('should allow writing storage after upgrade', async () => { + // Upgrade and advance block + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + + // Write new storage with v2 bytecode + const key = new Uint8Array(32); + key[31] = 77; + const value = new Uint8Array(32); + value[31] = 88; + + await contract.storeValue(key, value); + + const loaded = await contract.loadValue(key); + Assert.expect(areBytesEqual(loaded, value)).toEqual(true); + }); + + await vm.it('should allow reading storage on same block as upgrade (still v1)', async () => { + const storageKey = new Uint8Array(32); + storageKey[31] = 55; + const storageValue = new Uint8Array(32); + storageValue[31] = 66; + + // Store value + await contract.storeValue(storageKey, storageValue); + + // Upgrade (same block) + await contract.upgrade(v2SourceAddress); + + // Read on same block — still using v1 bytecode, but storage should be accessible + const loaded = await contract.loadValue(storageKey); + Assert.expect(areBytesEqual(loaded, storageValue)).toEqual(true); + }); + + // --- Error handling --- + + await vm.it('should revert when source address has no bytecode', async () => { + const nonExistentAddress = Blockchain.generateRandomAddress(); + + await Assert.expect(async () => { + await contract.upgrade(nonExistentAddress); + }).toThrow(); + }); + + await vm.it('should remain functional after failed upgrade attempt', async () => { + const nonExistentAddress = Blockchain.generateRandomAddress(); + + // Attempt upgrade with invalid address (should fail) + try { + await contract.upgrade(nonExistentAddress); + } catch { + // Expected to fail + } + + // Contract should still work with v1 bytecode + const value = await contract.getValue(); + Assert.expect(value).toEqual(1); + }); + + await vm.it('should preserve storage after failed upgrade attempt', async () => { + const storageKey = new Uint8Array(32); + storageKey[31] = 11; + const storageValue = new Uint8Array(32); + storageValue[31] = 22; + + await contract.storeValue(storageKey, storageValue); + + const nonExistentAddress = Blockchain.generateRandomAddress(); + try { + await contract.upgrade(nonExistentAddress); + } catch { + // Expected to fail + } + + // Storage should still be intact + const loaded = await contract.loadValue(storageKey); + Assert.expect(areBytesEqual(loaded, storageValue)).toEqual(true); + }); + + // --- Address preservation --- + + await vm.it('should preserve contract address after upgrade', async () => { + const addressBefore = contract.address; + + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + + Assert.expect(contract.address.equals(addressBefore)).toEqual(true); + }); + + // --- Block boundary behavior --- + + await vm.it('should not apply upgrade until block advances', async () => { + await contract.upgrade(v2SourceAddress); + + // Same block: multiple calls should all return v1 value + const value1 = await contract.getValue(); + const value2 = await contract.getValue(); + Assert.expect(value1).toEqual(1); + Assert.expect(value2).toEqual(1); + + // Now advance block + Blockchain.mineBlock(); + + // Next block: should return v2 value + const value3 = await contract.getValue(); + Assert.expect(value3).toEqual(2); + }); + + await vm.it('should apply upgrade exactly at next block boundary', async () => { + await contract.upgrade(v2SourceAddress); + + // Same block + Assert.expect(await contract.getValue()).toEqual(1); + + // Mine one block + Blockchain.mineBlock(); + + // Exactly one block later: upgrade should be applied + Assert.expect(await contract.getValue()).toEqual(2); + + // Mine another block: should still be v2 + Blockchain.mineBlock(); + + Assert.expect(await contract.getValue()).toEqual(2); + }); + + // --- Gas tracking --- + + await vm.it('should consume gas during upgrade', async () => { + const response = await contract.upgrade(v2SourceAddress); + + // Upgrade execution must consume gas + Assert.expect(response.usedGas > 0n).toEqual(true); + }); + + await vm.it('should consume more gas than a simple getValue call', async () => { + // Measure gas for a simple call + const abiCoder = new ABICoder(); + const getValueCalldata = new BinaryWriter(); + getValueCalldata.writeSelector( + Number(`0x${abiCoder.encodeSelector('getValue()')}`), + ); + const simpleResponse = await contract.execute({ + calldata: getValueCalldata.getBuffer(), + }); + const simpleGas = simpleResponse.usedGas; + + // Measure gas for upgrade (includes onUpdate + bytecode overhead) + const upgradeResponse = await contract.upgrade(v2SourceAddress); + const upgradeGas = upgradeResponse.usedGas; + + // Upgrade should cost more than a trivial getter + Assert.expect(upgradeGas > simpleGas).toEqual(true); + }); + + await vm.it('should not charge upgrade gas on failed upgrade', async () => { + const nonExistentAddress = Blockchain.generateRandomAddress(); + + // Failed upgrade should still return a response (with error) + // but the overall tx reverts so gas is handled by the VM + await Assert.expect(async () => { + await contract.upgrade(nonExistentAddress); + }).toThrow(); + + // Contract should still be functional (no gas state corruption) + const value = await contract.getValue(); + Assert.expect(value).toEqual(1); + }); + + // --- One upgrade per block enforcement --- + + await vm.it('should reject second upgrade in the same block', async () => { + // First upgrade should succeed + await contract.upgrade(v2SourceAddress); + + // Second upgrade in the same block should revert + await Assert.expect(async () => { + await contract.upgrade(v2SourceAddress); + }).toThrow(); + + // Contract should still work + const value = await contract.getValue(); + Assert.expect(value).toEqual(1); + }); + + await vm.it('should clear pending state after block advances', async () => { + // Upgrade and advance + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + + // Upgrade applied — getValue returns 2 + Assert.expect(await contract.getValue()).toEqual(2); + + // Mine another block — no pending upgrade, should still work + Blockchain.mineBlock(); + Assert.expect(await contract.getValue()).toEqual(2); + + // Storage operations should still work after pending state cleared + const key = new Uint8Array(32); + key[31] = 42; + const value = new Uint8Array(32); + value[31] = 99; + + await contract.storeValue(key, value); + const loaded = await contract.loadValue(key); + Assert.expect(areBytesEqual(loaded, value)).toEqual(true); + }); + + // --- Upgrade + subsequent operations --- + + await vm.it('should allow operations between upgrade and block advance', async () => { + const storageKey = new Uint8Array(32); + storageKey[31] = 100; + const storageValue = new Uint8Array(32); + storageValue[31] = 200; + + // Upgrade + await contract.upgrade(v2SourceAddress); + + // Store data between upgrade and block advance (still v1) + await contract.storeValue(storageKey, storageValue); + + // Mine block to apply upgrade + Blockchain.mineBlock(); + + // Data written before block advance should persist with v2 + const loaded = await contract.loadValue(storageKey); + Assert.expect(areBytesEqual(loaded, storageValue)).toEqual(true); + + // And getValue now returns 2 + Assert.expect(await contract.getValue()).toEqual(2); + }); +}); + +await opnet('BytecodeManager targeted removal', async (vm: OPNetUnit) => { + await vm.it('should remove bytecode for a specific address', async () => { + const address = Blockchain.generateRandomAddress(); + const fakeBytecode = Buffer.from([0x00, 0x61, 0x73, 0x6d]); + + BytecodeManager.forceSetBytecode(address, fakeBytecode); + + const loaded = BytecodeManager.getBytecode(address); + Assert.expect(loaded).toBeDefined(); + + BytecodeManager.removeBytecode(address); + + await Assert.expect(async () => { + BytecodeManager.getBytecode(address); + }).toThrow('not found'); + }); + + await vm.it('should not affect other addresses when removing one', async () => { + const addr1 = Blockchain.generateRandomAddress(); + const addr2 = Blockchain.generateRandomAddress(); + + BytecodeManager.forceSetBytecode(addr1, Buffer.from([0x01])); + BytecodeManager.forceSetBytecode(addr2, Buffer.from([0x02])); + + BytecodeManager.removeBytecode(addr1); + + // addr2 unaffected + Assert.expect(BytecodeManager.getBytecode(addr2)).toBeDefined(); + + // addr1 gone + await Assert.expect(async () => { + BytecodeManager.getBytecode(addr1); + }).toThrow('not found'); + + BytecodeManager.removeBytecode(addr2); + }); +}); + +await opnet('Upgrade lifecycle edge cases', async (vm: OPNetUnit) => { + let contract: UpgradeableContractRuntime; + let v2Source: V2SourceContractRuntime; + + const deployerAddress: Address = Blockchain.generateRandomAddress(); + const contractAddress: Address = Blockchain.generateRandomAddress(); + const v2SourceAddress: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + contract = new UpgradeableContractRuntime(deployerAddress, contractAddress); + Blockchain.register(contract); + await contract.init(); + + v2Source = new V2SourceContractRuntime(deployerAddress, v2SourceAddress); + Blockchain.register(v2Source); + await v2Source.init(); + + Blockchain.txOrigin = deployerAddress; + Blockchain.msgSender = deployerAddress; + }); + + vm.afterEach(() => { + contract.dispose(); + v2Source.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should allow multiple calls after successful upgrade + mine', async () => { + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + + // Multiple sequential calls on new bytecode + Assert.expect(await contract.getValue()).toEqual(2); + Assert.expect(await contract.getValue()).toEqual(2); + Assert.expect(await contract.getValue()).toEqual(2); + }); + + await vm.it('should allow storage operations on new bytecode after upgrade', async () => { + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + + // Write with new bytecode + const key = new Uint8Array(32); + key[31] = 0xaa; + const value = new Uint8Array(32); + value[31] = 0xbb; + + await contract.storeValue(key, value); + const loaded = await contract.loadValue(key); + Assert.expect(areBytesEqual(loaded, value)).toEqual(true); + }); + + await vm.it('should handle upgrade followed by multiple block advances', async () => { + await contract.upgrade(v2SourceAddress); + + // Mine 3 blocks + Blockchain.mineBlock(); + Blockchain.mineBlock(); + Blockchain.mineBlock(); + + // V2 behavior persists across all blocks + Assert.expect(await contract.getValue()).toEqual(2); + }); + + await vm.it('should not corrupt state after failed upgrade + successful retry', async () => { + const fakeAddr = Blockchain.generateRandomAddress(); + + // Store value before any upgrades + const key = new Uint8Array(32); + key[31] = 0x01; + const value = new Uint8Array(32); + value[31] = 0xff; + await contract.storeValue(key, value); + + // Failed upgrade + try { + await contract.upgrade(fakeAddr); + } catch { + // expected + } + + // Contract still V1 and functional + Assert.expect(await contract.getValue()).toEqual(1); + + // Storage intact + const loaded = await contract.loadValue(key); + Assert.expect(areBytesEqual(loaded, value)).toEqual(true); + + // Now do a real upgrade + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + + // V2 active and storage preserved + Assert.expect(await contract.getValue()).toEqual(2); + const loadedAfter = await contract.loadValue(key); + Assert.expect(areBytesEqual(loadedAfter, value)).toEqual(true); + }); +}); + +await opnet('Phase 2 gas accounting', async (vm: OPNetUnit) => { + let contract: UpgradeableContractRuntime; + let v2Source: V2SourceContractRuntime; + + const deployerAddress: Address = Blockchain.generateRandomAddress(); + const contractAddress: Address = Blockchain.generateRandomAddress(); + const v2SourceAddress: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + contract = new UpgradeableContractRuntime(deployerAddress, contractAddress); + Blockchain.register(contract); + await contract.init(); + + v2Source = new V2SourceContractRuntime(deployerAddress, v2SourceAddress); + Blockchain.register(v2Source); + await v2Source.init(); + + Blockchain.txOrigin = deployerAddress; + Blockchain.msgSender = deployerAddress; + }); + + vm.afterEach(() => { + contract.dispose(); + v2Source.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should charge Phase 2 onUpdate gas to the first caller after mine', async () => { + // Baseline: gas for getValue without pending upgrade + const abiCoder = new ABICoder(); + const getValueCalldata = new BinaryWriter(); + getValueCalldata.writeSelector( + Number(`0x${abiCoder.encodeSelector('getValue()')}`), + ); + + const baselineResponse = await contract.execute({ + calldata: getValueCalldata.getBuffer(), + }); + const baselineGas = baselineResponse.usedGas; + + // Queue upgrade, mine block + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + + // First call after mine triggers Phase 2 (applyPendingBytecodeUpgrade) + const getValueCalldata2 = new BinaryWriter(); + getValueCalldata2.writeSelector( + Number(`0x${abiCoder.encodeSelector('getValue()')}`), + ); + + const postUpgradeResponse = await contract.execute({ + calldata: getValueCalldata2.getBuffer(), + }); + const postUpgradeGas = postUpgradeResponse.usedGas; + + // Phase 2 onUpdate gas MUST be included — first call costs more than baseline + Assert.expect(postUpgradeGas > baselineGas).toEqual(true); + }); + + await vm.it('should NOT charge Phase 2 gas on second call (already applied)', async () => { + const abiCoder = new ABICoder(); + + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + + // First call: pays Phase 2 gas + const calldata1 = new BinaryWriter(); + calldata1.writeSelector(Number(`0x${abiCoder.encodeSelector('getValue()')}`)); + const firstCallResponse = await contract.execute({ calldata: calldata1.getBuffer() }); + const firstCallGas = firstCallResponse.usedGas; + + // Second call: no pending upgrade, normal gas + const calldata2 = new BinaryWriter(); + calldata2.writeSelector(Number(`0x${abiCoder.encodeSelector('getValue()')}`)); + const secondCallResponse = await contract.execute({ calldata: calldata2.getBuffer() }); + const secondCallGas = secondCallResponse.usedGas; + + // Second call should cost less (no Phase 2 overhead) + Assert.expect(firstCallGas > secondCallGas).toEqual(true); + }); +}); + +await opnet('Phase 2 upgrade guard enforcement', async (vm: OPNetUnit) => { + let contract: UpgradeableContractRuntime; + let v2Source: V2SourceContractRuntime; + + const deployerAddress: Address = Blockchain.generateRandomAddress(); + const contractAddress: Address = Blockchain.generateRandomAddress(); + const v2SourceAddress: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + contract = new UpgradeableContractRuntime(deployerAddress, contractAddress); + Blockchain.register(contract); + await contract.init(); + + v2Source = new V2SourceContractRuntime(deployerAddress, v2SourceAddress); + Blockchain.register(v2Source); + await v2Source.init(); + + Blockchain.txOrigin = deployerAddress; + Blockchain.msgSender = deployerAddress; + }); + + vm.afterEach(() => { + contract.dispose(); + v2Source.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should clear upgrade guard after Phase 2 completes', async () => { + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + + // First call triggers Phase 2. Guard is set during onUpdate, cleared after. + // If guard leaked, this getValue call would fail. + Assert.expect(await contract.getValue()).toEqual(2); + + // Second call — guard must be clear, contract fully functional + Assert.expect(await contract.getValue()).toEqual(2); + + // Storage ops work (proves no lingering blocked state) + const key = new Uint8Array(32); + key[31] = 0x42; + const value = new Uint8Array(32); + value[31] = 0x99; + await contract.storeValue(key, value); + const loaded = await contract.loadValue(key); + Assert.expect(areBytesEqual(loaded, value)).toEqual(true); + }); + + await vm.it('should allow upgrade on a later block after Phase 2 completed', async () => { + // First upgrade cycle + await contract.upgrade(v2SourceAddress); + Blockchain.mineBlock(); + Assert.expect(await contract.getValue()).toEqual(2); + + // Re-register V1 source to allow upgrading back (simulate V3) + // Use V2 source again — just proving the mechanism allows it + Blockchain.mineBlock(); + + // The guard from Phase 2 must be fully cleared + // A new upgrade request should succeed on a new block + const key = new Uint8Array(32); + key[31] = 0x01; + const value = new Uint8Array(32); + value[31] = 0xff; + await contract.storeValue(key, value); + + const loaded = await contract.loadValue(key); + Assert.expect(areBytesEqual(loaded, value)).toEqual(true); + }); +}); + +function areBytesEqual(arr1: Uint8Array, arr2: Uint8Array): boolean { + if (arr1.length !== arr2.length) { + return false; + } + + return arr1.every((value, index) => value === arr2[index]); +} + +// --- Malicious V2 contract that writes storage + emits events + reverts in onUpdate --- + +const MALICIOUS_V2_WASM_PATH = + './test/e2e/contracts/malicious-v2/contract/build/MaliciousV2.wasm'; + +class MaliciousV2SourceRuntime extends ContractRuntime { + public constructor(deployer: Address, address: Address) { + super({ + address: address, + deployer: deployer, + gasLimit: 150_000_000_000n, + }); + } + + protected handleError(error: Error): Error { + return new Error(`(malicious-v2 source) OP_NET: ${error.message}`); + } + + protected defineRequiredBytecodes(): void { + BytecodeManager.loadBytecode(MALICIOUS_V2_WASM_PATH, this.address); + } +} + +// The malicious V2 contract's onUpdate writes storage slot 0xff...00 = 0x42...00, +// emits a "PhantomEvent", then reverts. These side effects MUST NOT leak. + +await opnet('Failed Phase 2 state isolation', async (vm: OPNetUnit) => { + let contract: UpgradeableContractRuntime; + let maliciousV2Source: MaliciousV2SourceRuntime; + + const deployerAddress: Address = Blockchain.generateRandomAddress(); + const contractAddress: Address = Blockchain.generateRandomAddress(); + const maliciousV2Address: Address = Blockchain.generateRandomAddress(); + + vm.beforeEach(async () => { + Blockchain.dispose(); + Blockchain.clearContracts(); + await Blockchain.init(); + + contract = new UpgradeableContractRuntime(deployerAddress, contractAddress); + Blockchain.register(contract); + await contract.init(); + + maliciousV2Source = new MaliciousV2SourceRuntime(deployerAddress, maliciousV2Address); + Blockchain.register(maliciousV2Source); + await maliciousV2Source.init(); + + Blockchain.txOrigin = deployerAddress; + Blockchain.msgSender = deployerAddress; + }); + + vm.afterEach(() => { + contract.dispose(); + maliciousV2Source.dispose(); + Blockchain.dispose(); + }); + + await vm.it('should NOT leak storage writes from failed Phase 2 onUpdate', async () => { + // Queue upgrade to malicious V2 (Phase 1 succeeds on current bytecode) + await contract.upgrade(maliciousV2Address); + + // Advance block to trigger Phase 2 + Blockchain.mineBlock(); + + // Phase 2 runs malicious V2's onUpdate which: + // 1. Writes storage slot 0xff...00 = 0x42...00 + // 2. Emits PhantomEvent + // 3. Reverts + // Phase 2 should fail and revert bytecode. + + // Execute a getValue call — this triggers Phase 2 internally + const abiCoder = new ABICoder(); + const calldata = new BinaryWriter(); + calldata.writeSelector(Number(`0x${abiCoder.encodeSelector('getValue()')}`)); + const response = await contract.execute({ calldata: calldata.getBuffer() }); + + // Contract should still be on v1 bytecode (Phase 2 failed, reverted) + Assert.expect(response.status).toEqual(0); + + // The malicious storage write (slot 0xff...00) must NOT exist in global state + const poisonKey = 0xffn << 248n; // 0xff followed by 31 zero bytes + const globalValue = StateHandler.globalLoad(contractAddress, poisonKey); + Assert.expect(globalValue).toEqual(undefined); + + // Temp states for this contract must be clean (pushed to global already, so check global) + // If the fix works, the poisoned write was cleared before it could be committed + Assert.expect(StateHandler.globalHas(contractAddress, poisonKey)).toEqual(false); + }); + + await vm.it('should NOT leak events from failed Phase 2 onUpdate', async () => { + await contract.upgrade(maliciousV2Address); + Blockchain.mineBlock(); + + const abiCoder = new ABICoder(); + const calldata = new BinaryWriter(); + calldata.writeSelector(Number(`0x${abiCoder.encodeSelector('getValue()')}`)); + const response = await contract.execute({ calldata: calldata.getBuffer() }); + + // No "PhantomEvent" should appear in the transaction events + const phantomEvents = response.events.filter( + (e) => e.eventType === 'PhantomEvent', + ); + Assert.expect(phantomEvents.length).toEqual(0); + }); + + await vm.it('should charge gas for failed Phase 2 onUpdate', async () => { + await contract.upgrade(maliciousV2Address); + Blockchain.mineBlock(); + + // Baseline: normal getValue gas without any pending upgrade + const abiCoder = new ABICoder(); + + // First call: triggers failed Phase 2 + getValue + const calldata1 = new BinaryWriter(); + calldata1.writeSelector(Number(`0x${abiCoder.encodeSelector('getValue()')}`)); + const firstResponse = await contract.execute({ calldata: calldata1.getBuffer() }); + const gasWithFailedPhase2 = firstResponse.usedGas; + + // Second call: no pending upgrade, clean getValue + const calldata2 = new BinaryWriter(); + calldata2.writeSelector(Number(`0x${abiCoder.encodeSelector('getValue()')}`)); + const secondResponse = await contract.execute({ calldata: calldata2.getBuffer() }); + const baselineGas = secondResponse.usedGas; + + // Failed Phase 2 must still cost gas — first call should be more expensive + Assert.expect(gasWithFailedPhase2 > baselineGas).toEqual(true); + }); + + await vm.it('should revert bytecode after failed Phase 2 (still v1)', async () => { + await contract.upgrade(maliciousV2Address); + Blockchain.mineBlock(); + + // getValue should return 1 (v1) — Phase 2 failed, bytecode reverted + const value = await contract.getValue(); + Assert.expect(value).toEqual(1); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..33e5d62 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/tsconfig.json b/tsconfig.json index b9c645d..6895cb7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "module": "esnext", - "target": "esnext", + "module": "ESNext", + "target": "ESNext", "declaration": true, "noImplicitAny": true, "removeComments": false, @@ -13,14 +13,7 @@ "moduleDetection": "force", "allowImportingTsExtensions": false, "experimentalDecorators": true, - "lib": [ - "es6", - "es2020", - "es2021", - "es2022", - "esnext", - "dom" - ], + "lib": ["ESNext"], "strict": true, "strictNullChecks": true, "strictFunctionTypes": true, @@ -32,23 +25,14 @@ "incremental": true, "allowSyntheticDefaultImports": true, "outDir": "build", - "typeRoots": [ - "node_modules/@types" - ] + "typeRoots": ["node_modules/@types"] }, "include": [ "src/**/*.ts", - "src/*", - "src/**/*.js", - "src/*.ts", - "src/*.js", - "src/*.cjs", - "test/**/*.ts", - "test/*.ts" - ], + "test/**/*.ts" + ], "exclude": [ - "test/**/*.ts", - "test/**/*.js", - "node_modules" + "node_modules", + "test/e2e/contracts/*/contract/**" ] }