This tutorial guides you through creating a complete OP20 token contract from scratch. By the end, you'll understand the core concepts of OP_NET smart contract development.
A simple fungible token (like an ERC20 on Ethereum) with:
- Fixed maximum supply
- Minting capability (deployer only)
- Transfer functionality
- Balance queries
Create src/token/MyToken.ts:
import { u256 } from '@btc-vision/as-bignum/assembly';
import {
Blockchain,
BytesWriter,
Calldata,
OP20,
OP20InitParameters,
} from '@btc-vision/btc-runtime/runtime';
@final
export class MyToken extends OP20 {
public constructor() {
super();
}
public override onDeployment(_calldata: Calldata): void {
const maxSupply: u256 = u256.fromString('1000000000000000000000000');
const decimals: u8 = 18;
const name: string = 'MyToken';
const symbol: string = 'MTK';
this.instantiate(new OP20InitParameters(maxSupply, decimals, name, symbol));
this._mint(Blockchain.tx.origin, maxSupply);
}
@method(
{ name: 'address', type: ABIDataTypes.ADDRESS },
{ name: 'amount', type: ABIDataTypes.UINT256 }
)
@emit('Minted')
public mint(calldata: Calldata): BytesWriter {
this.onlyDeployer(Blockchain.tx.sender);
this._mint(calldata.readAddress(), calldata.readU256());
return new BytesWriter(0);
}
}Let's break this down piece by piece.
This diagram illustrates the complete lifecycle of an OP_NET smart contract from deployment to execution:
---
config:
theme: dark
---
flowchart LR
A["Deployment"] --> B["onDeployment()"]
B --> C["Initialize Parameters"]
C --> D["Contract Active"]
D --> E{"Transaction"}
E --> F["execute()"]
F -->|"transfer()"| G["Transfer Tokens"]
F -->|"mint()"| H["Mint Tokens"]
F -->|"balanceOf()"| I["Query Balance"]
F -->|"approve()"| J["Approve Spender"]
G --> K["Complete"]
H --> K
I --> K
J --> K
K --> E
This diagram shows how your MyToken contract inherits functionality from the OP20 base class:
---
config:
theme: dark
---
classDiagram
class OP_NET {
+Address address
+Address contractDeployer
+onDeployment(calldata)
+execute(selector, calldata)
+onlyDeployer(caller)
+emitEvent(event)
}
class ReentrancyGuard {
-u8 reentrancyStatus
+nonReentrant()
+protected()
}
class OP20 {
+StoredU256 _totalSupply
+StoredString _name
+StoredString _symbol
+u8 _decimals
+u256 _maxSupply
+transfer(to, amount)
+approve(spender, amount)
+balanceOf(address)
+_mint(to, amount)
+_burn(from, amount)
}
class MyToken {
+onDeployment(calldata)
+mint(calldata)
}
OP_NET <|-- ReentrancyGuard
ReentrancyGuard <|-- OP20
OP20 <|-- MyToken
note for MyToken "Custom implementation:\n- Deployment logic\n- Additional mint function"
note for OP20 "Built-in methods:\n- transfer\n- approve\n- balanceOf\n- totalSupply"
note for OP_NET "Base contract:\n- Access control\n- Event system\n- Execution router"
@final
export class MyToken extends OP20 {| Component | Meaning |
|---|---|
@final |
AssemblyScript decorator - prevents inheritance |
export |
Makes the class accessible outside the file |
extends OP20 |
Inherits from the fungible token standard |
Solidity equivalent:
contract MyToken is ERC20 {public constructor() {
super();
}IMPORTANT: In OP_NET, the constructor runs on every contract interaction, not just deployment. This is different from Solidity!
// OP_NET // Solidity
public constructor() { // constructor() {
super(); // // Runs ONCE at deployment
// Runs EVERY time! // }
}Never put initialization logic in the constructor. Use onDeployment instead.
public override onDeployment(_calldata: Calldata): void {
const maxSupply: u256 = u256.fromString('1000000000000000000000000');
const decimals: u8 = 18;
const name: string = 'MyToken';
const symbol: string = 'MTK';
this.instantiate(new OP20InitParameters(maxSupply, decimals, name, symbol));
this._mint(Blockchain.tx.origin, maxSupply);
}This method runs once when the contract is first deployed. It's the equivalent of Solidity's constructor().
| Parameter | Value | Meaning |
|---|---|---|
maxSupply |
1,000,000 (with 18 decimals) | Maximum tokens that can ever exist |
decimals |
18 | Decimal places (like ETH/wei) |
name |
"MyToken" | Human-readable name |
symbol |
"MTK" | Ticker symbol |
Solidity equivalent:
constructor() ERC20("MyToken", "MTK") {
_mint(msg.sender, 1000000 * 10**18);
}@method(
{ name: 'address', type: ABIDataTypes.ADDRESS },
{ name: 'amount', type: ABIDataTypes.UINT256 }
)
@emit('Minted')
public mint(calldata: Calldata): BytesWriter {
this.onlyDeployer(Blockchain.tx.sender);
this._mint(calldata.readAddress(), calldata.readU256());
return new BytesWriter(0);
}This sequence diagram shows what happens when the mint function is called:
---
config:
theme: dark
---
sequenceDiagram
participant User as 👤 User
participant Blockchain as Blockchain
participant MyToken as MyToken
participant OP20 as OP20
participant Storage as Storage
User->>Blockchain: Call mint(to, amount)
Blockchain->>MyToken: constructor()
Note over MyToken: Runs on EVERY call
Blockchain->>MyToken: execute(selector, calldata)
MyToken->>MyToken: mint(calldata)
MyToken->>Blockchain: Check tx.sender
Blockchain-->>MyToken: sender address
MyToken->>MyToken: onlyDeployer(sender)
alt sender != deployer
MyToken-->>User: Revert("Only deployer")
end
MyToken->>MyToken: readAddress() from calldata
MyToken->>MyToken: readU256() from calldata
MyToken->>OP20: _mint(to, amount)
OP20->>Storage: Read current balance[to]
Storage-->>OP20: currentBalance
OP20->>OP20: newBalance = currentBalance + amount
OP20->>Storage: Write balance[to] = newBalance
OP20->>Storage: Read totalSupply
Storage-->>OP20: currentSupply
OP20->>OP20: newSupply = currentSupply + amount
OP20->>Storage: Write totalSupply = newSupply
OP20->>Blockchain: emit(MintEvent)
OP20-->>MyToken: Success
MyToken->>MyToken: new BytesWriter(0)
MyToken-->>Blockchain: Empty response
Blockchain-->>User: Transaction success
Note over Storage: All changes persisted<br/>to blockchain state
Breaking this down:
| Line | Purpose |
|---|---|
@method(...) |
Declares method parameters for ABI generation |
@emit('Minted') |
Declares event emission for ABI documentation |
onlyDeployer(...) |
Access control - only the deployer can call |
calldata.readAddress() |
Parse the recipient address from input |
calldata.readU256() |
Parse the amount from input |
_mint(to, amount) |
Internal mint function from OP20 |
return new BytesWriter(0) |
Return empty response |
Solidity equivalent:
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}OP_NET uses u256 for large numbers (like balances):
import { u256 } from '@btc-vision/as-bignum/assembly';
// Creating u256 values
const a = u256.fromU64(100); // From small number
const b = u256.fromU64(1_000_000); // From u64
const c = u256.fromString('99999999999999'); // From string (large numbers)
// NEVER use floating point!
// const bad = u256.fromU64(1.5); // WRONG! - No floating point!Why not native numbers?
| JavaScript/TypeScript | AssemblyScript/OP_NET |
|---|---|
number (64-bit float) |
Non-deterministic! |
BigInt |
Not supported in WASM |
| N/A | u256 (deterministic) |
Addresses are 32 bytes in OP_NET:
import { Address, Blockchain } from '@btc-vision/btc-runtime/runtime';
// Get the current sender
const sender: Address = Blockchain.tx.sender;
// Zero address (like address(0) in Solidity)
const zero = Address.zero();
// Compare addresses
if (sender.equals(zero)) {
throw new Revert('Cannot be zero address');
}Input parsing uses Calldata:
public myMethod(calldata: Calldata): BytesWriter {
// Read parameters in order
const address = calldata.readAddress(); // 32 bytes
const amount = calldata.readU256(); // 32 bytes
const flag = calldata.readBoolean(); // 1 byte
const data = calldata.readBytes(); // Variable length
// ...
}By extending OP20, your token automatically gets these methods:
| Method | Description | Selector |
|---|---|---|
transfer(to, amount) |
Transfer tokens | Built-in |
transferFrom(from, to, amount) |
Transfer with approval | Built-in |
approve(spender, amount) |
Approve spender | Built-in |
balanceOf(address) |
Get balance | Built-in |
allowance(owner, spender) |
Get allowance | Built-in |
totalSupply() |
Total supply | Built-in |
name() |
Token name | Built-in |
symbol() |
Token symbol | Built-in |
decimals() |
Decimal places | Built-in |
Add to your package.json:
{
"scripts": {
"build:token": "asc src/token/index.ts --target token --measure --uncheckedBehavior never"
}
}Create src/token/index.ts:
import { Blockchain } from '@btc-vision/btc-runtime/runtime';
import { revertOnError } from '@btc-vision/btc-runtime/runtime/abort/abort';
import { MyToken } from './MyToken';
// DO NOT TOUCH TO THIS.
Blockchain.contract = () => {
// ONLY CHANGE THE CONTRACT CLASS NAME.
// DO NOT ADD CUSTOM LOGIC HERE.
return new MyToken();
};
// 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);
}Build:
npm run build:tokenHere's a side-by-side comparison of the complete contract:
| OP_NET (AssemblyScript) | Solidity |
|---|---|
import { u256 } from '@btc-vision/as-bignum/assembly';
import {
Blockchain,
BytesWriter,
Calldata,
OP20,
OP20InitParameters,
} from '@btc-vision/btc-runtime/runtime';
@final
export class MyToken extends OP20 {
public constructor() {
super();
}
public override onDeployment(_: Calldata): void {
const maxSupply = u256.fromString('1000000000000000000000000');
this.instantiate(new OP20InitParameters(
maxSupply,
18,
'MyToken',
'MTK'
));
this._mint(Blockchain.tx.origin, maxSupply);
}
@method(
{ name: 'address', type: ABIDataTypes.ADDRESS },
{ name: 'amount', type: ABIDataTypes.UINT256 }
)
@emit('Minted')
public mint(calldata: Calldata): BytesWriter {
this.onlyDeployer(Blockchain.tx.sender);
this._mint(
calldata.readAddress(),
calldata.readU256()
);
return new BytesWriter(0);
}
} |
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyToken is ERC20, Ownable {
uint256 public constant MAX_SUPPLY =
1000000 * 10**18;
constructor()
ERC20("MyToken", "MTK")
Ownable(msg.sender)
{
_mint(msg.sender, MAX_SUPPLY);
}
function mint(
address to,
uint256 amount
) external onlyOwner {
_mint(to, amount);
}
} |
// Only deployer can call
@method({ name: 'param', type: ABIDataTypes.UINT256 })
@returns({ name: 'result', type: ABIDataTypes.UINT256 })
public adminFunction(calldata: Calldata): BytesWriter {
this.onlyDeployer(Blockchain.tx.sender);
const param = calldata.readU256();
// ... perform admin logic
const result = new BytesWriter(32);
result.writeU256(param);
return result;
}import { Revert, Address, BytesWriter, Calldata } from '@btc-vision/btc-runtime/runtime';
@method(
{ name: 'to', type: ABIDataTypes.ADDRESS },
{ name: 'amount', type: ABIDataTypes.UINT256 }
)
@emit('Transferred')
public transfer(calldata: Calldata): BytesWriter {
const to = calldata.readAddress();
const amount = calldata.readU256();
if (to.equals(Address.zero())) {
throw new Revert('Cannot transfer to zero address');
}
// ...
return new BytesWriter(0);
}import { SafeMath } from '@btc-vision/btc-runtime/runtime';
// In OP20, balances are managed automatically
const balance: u256 = this.balanceOf(address);
// When performing u256 operations, always use SafeMath
const newBalance = SafeMath.add(balance, amount);
const result = SafeMath.sub(balance, amount);Now that you've created your first contract:
- Understand the project structure
- Learn about the blockchain environment
- Explore storage in depth
- See more examples
Navigation:
- Previous: Installation
- Next: Project Structure