Contract Development Guide
This guide covers everything you need to know about writing, testing, and deploying Python smart contracts on Panda.
Token standards (PRC)
Panda defines chain-level interfaces aligned with common Ethereum EIPs:
| Standard | Summary | Reference |
|---|---|---|
| PRC-20 | Fungible tokens (ERC-20 analogue) | docs/PRC20.md, contracts/tokens/prc20_token.py |
| PRC-721 | Non-fungible tokens (ERC-721 analogue) | docs/PRC721.md, contracts/tokens/prc721_collection.py |
Example Contract Library
Panda ships with a comprehensive library of example contracts organized by category:
contracts/tokens/ — Token Standards & DeFi
| Contract | Description |
|---|---|
prc20_token.py | PRC-20 fungible token (reference implementation) |
prc721_collection.py | PRC-721 NFT collection (reference implementation) |
iprc20.py | Abstract PRC-20 interface for type safety |
iprc721.py | Abstract PRC-721 interface for type safety |
prc20_interface.py | PRC-20 implemented via the IPRC20 interface pattern |
prc721_interface.py | PRC-721 implemented via the IPRC721 interface pattern |
token_swap.py | AMM-style token swap (cross-contract calls to PRC-20s) |
nft_marketplace.py | NFT marketplace (cross-contract calls to PRC-721 + PRC-20) |
contracts/crypto/ — Cryptographic Primitives
| Contract | Description |
|---|---|
hash_registry.py | Commit-reveal scheme using SHA-256 |
merkle_airdrop.py | Gas-efficient airdrop with Merkle proof verification |
multisig_wallet.py | M-of-N multisig with HMAC-based signer verification |
secret_auction.py | Sealed-bid auction using Pedersen commitments |
timelock_vault.py | Time-locked vault with derived-key withdrawal |
contracts/patterns/ — Design Patterns & Inheritance
| Contract | Description |
|---|---|
ownable.py | Ownership pattern (two-step transfer) |
pausable.py | Pausable extension (inherits Ownable pattern) |
access_control.py | Role-based access control (RBAC) with admin hierarchy |
registry.py | On-chain contract discovery and metadata registry |
escrow.py | Escrow service using cross-contract PRC-20 transfers |
dao_voting.py | DAO governance with cross-contract token balance queries |
contracts/ml/ — Machine Learning
12 ML contracts covering regression, classification, clustering, neural networks, ensemble methods, online learning, and model marketplaces.
contracts/privacy/ — Privacy & Federated Learning
Private model inference and federated training contracts.
Contract Types
Panda supports two types of programs:
Stateless Scripts
The simplest programs are plain Python scripts. They take optional input via stdin, execute, and produce output via stdout. No decorators, no SDK import, no state persistence.
# fibonacci.py
def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
print(fib(100))
# analysis.py
import numpy as np
import pandas as pd
data = pd.DataFrame({
"x": np.random.default_rng(42).normal(0, 1, 1000),
"y": np.random.default_rng(42).normal(5, 2, 1000),
})
print(data.describe().to_json())
Run stateless scripts with:
panda run fibonacci.py # Execute locally
panda run fibonacci.py --on-chain # Execute as on-chain transaction
Stateful Contracts
Stateful contracts use the @contract decorator and maintain persistent on-chain state. They are deployed to an address and called repeatedly via transactions.
Here is the Counter contract (contracts/examples/counter.py), the simplest complete example:
from panda import contract, constructor, call, query, event
@contract
class Counter:
"""A simple counter that tracks a value and who last modified it."""
class State:
count: int = 0
owner: str = ""
last_caller: str = ""
@constructor
def deploy(self, ctx, initial_count: int = 0):
"""Set the initial count and owner on deployment."""
self.state.owner = ctx.sender
self.state.count = initial_count
@call
def increment(self, ctx):
"""Add 1 to the counter."""
self.state.count = self.state.count + 1
self.state.last_caller = ctx.sender
self.emit(event.CountChanged(
new_count=self.state.count,
changed_by=ctx.sender,
))
@call
def decrement(self, ctx):
"""Subtract 1 from the counter. Rejects if count would go below 0."""
if self.state.count <= 0:
raise ValueError("Counter cannot go below zero")
self.state.count = self.state.count - 1
self.state.last_caller = ctx.sender
self.emit(event.CountChanged(
new_count=self.state.count,
changed_by=ctx.sender,
))
@call
def reset(self, ctx):
"""Reset the counter to 0. Only the owner can reset."""
if ctx.sender != self.state.owner:
raise ValueError("Only the owner can reset")
self.state.count = 0
self.state.last_caller = ctx.sender
self.emit(event.CountReset(reset_by=ctx.sender))
@query
def get_count(self) -> int:
"""Return the current count."""
return self.state.count
@query
def info(self) -> dict:
"""Return contract metadata."""
return {
"count": self.state.count,
"owner": self.state.owner,
"last_caller": self.state.last_caller,
}
HelloWorld
The simplest possible Panda contract. It stores a greeting message, lets anyone update it, and emits an event on each update.
<!-- Source: contracts/examples/hello_world.py -->"""
HelloWorld -- A simple greeting contract.
Example Panda smart contract demonstrating @contract, @call, @query, @event decorators.
"""
from panda import contract, constructor, call, query, event
@contract
class HelloWorld:
"""A simple greeting contract that stores and updates a message."""
class State:
message: str = "Hello, World!"
owner: str = ""
greeting_count: int = 0
@constructor
def deploy(self, ctx):
"""Called once on deployment."""
self.state.owner = ctx.sender
print(f"HelloWorld deployed by {ctx.sender}")
@call
def set_message(self, ctx, message: str):
"""Update the greeting message. Increments greeting_count and emits event."""
self.state.message = message
self.state.greeting_count = self.state.greeting_count + 1
self.emit(
event.MessageUpdated(
new_message=message,
greeting_count=self.state.greeting_count,
updated_by=ctx.sender,
)
)
print(
f"Message updated to '{message}' (greeting #{self.state.greeting_count}) by {ctx.sender}"
)
@query
def get_message(self) -> str:
"""Return the current greeting message. Read-only operation."""
return self.state.message
@query
def info(self) -> dict:
"""Return contract metadata."""
return {
"message": self.state.message,
"owner": self.state.owner,
"greeting_count": self.state.greeting_count,
}
This contract demonstrates every core concept in under 50 lines:
@contractmarks the class as a smart contractclass Statedefines persistent on-chain storage with typed, defaulted fields@constructorruns once at deploy time to set the owner@callmethods mutate state and cost gas (set_messageupdates the message and emits an event)@querymethods are read-only and free (get_messagereturns the current greeting)self.emit(event.Name(...))records an indexed event in the transaction receipt
Decorators Reference
@contract
Marks a class as a Panda smart contract. Every contract must have an inner State class. Here is the state layout from the PRC20Token contract (contracts/tokens/prc20_token.py):
from panda import contract, constructor, call, query, event
from panda.token import FungibleToken, TokenError
@contract
class PRC20Token:
class State:
token: dict = {}
mint_authority: str = ""
freeze_authority: str = ""
frozen: dict = {}
Requirements:
- The class must contain a
Stateinner class - State fields must have type annotations and default values
- Only one
@contractclass per file
@call
Marks a method as state-mutating. Calling a @call method produces a transaction, costs gas, and can modify the contract's state.
Here is the transfer method from PRC20Token (contracts/tokens/prc20_token.py):
@call
def transfer(self, ctx, to: str, value: int):
self._not_frozen(ctx.sender)
self._not_frozen(to)
t = self._t()
if value == 0:
self.state.token = t.to_dict()
self.emit(event.Transfer(_from=ctx.sender, _to=to, _value=0))
return
t.transfer(ctx.sender, to, value)
self.state.token = t.to_dict()
self.emit(event.Transfer(_from=ctx.sender, _to=to, _value=value))
The first parameter after self is always ctx (the execution context). Remaining parameters are the method arguments provided by the caller.
@query
Marks a method as read-only. Calling a @query method does not produce a transaction and costs no gas. The method cannot modify state.
Here is the balance_of query from PRC20Token (contracts/tokens/prc20_token.py):
@query
def balance_of(self, owner: str) -> int:
return self._t().balance_of(owner)
Query methods do not receive a ctx parameter. They can only read from self.state, not write to it.
@event
Events are not used as a direct decorator on methods. Instead, events are emitted inside @call methods using self.emit().
Here is an example from the Counter contract (contracts/examples/counter.py):
@call
def increment(self, ctx):
"""Add 1 to the counter."""
self.state.count = self.state.count + 1
self.state.last_caller = ctx.sender
self.emit(event.CountChanged(
new_count=self.state.count,
changed_by=ctx.sender,
))
Events appear in transaction receipts and are indexed by the block explorer. The event object is imported from the panda module:
from panda import event
Event names are created dynamically -- you do not need to pre-define them. event.AnyName(key=value) creates an event with that name and the provided fields.
@private
Marks a contract or method as privacy-enabled. Private contracts can have their code and/or state encrypted while remaining verifiable via ZK proofs.
from panda import contract, call, query, private
@contract
@private
class SecretModel:
class State:
weights: list[float] = []
@call
def update_weights(self, ctx, new_weights: list[float]):
self.state.weights = new_weights
@query
def predict(self, features: list[float]) -> float:
import numpy as np
return float(np.dot(self.state.weights, features))
@proof
Specifies the ZK proof type for a method. Two modes are available:
from panda import proof
@call
@proof(type="validity")
def critical_operation(self, ctx, data: list[float]):
# A ZK validity proof is generated for this execution
# Proves the computation was correct without re-execution
pass
@call
@proof(type="fraud")
def optimistic_operation(self, ctx, data: list[float]):
# Executes optimistically; anyone can submit a fraud proof
# during the challenge window if the result is incorrect
pass
Execution Context
The ctx object passed to @call methods provides information about the current execution environment:
@call
def my_method(self, ctx):
ctx.sender # Address of the caller (str)
ctx.block_height # Current block number (int)
ctx.block_time # Block timestamp in seconds (int)
ctx.contract_address # This contract's address (str)
ctx.gas_remaining # Remaining gas budget (int)
ctx.chain_id # "panda-eth-mainnet" or "panda-sol-mainnet" (str)
Use ctx.block_time instead of time.time() or datetime.now() for timestamps. These standard library functions are blocked because they would break determinism.
State Management
Defining State
The State inner class defines the contract's persistent storage. Every field must have a type annotation and a default value:
class State:
count: int = 0
name: str = ""
scores: list[float] = []
metadata: dict = {}
is_active: bool = True
weights: bytes = b""
Supported State Types
| Type | Description | Example |
|---|---|---|
int | Integer (arbitrary precision) | count: int = 0 |
float | IEEE 754 double | score: float = 0.0 |
str | UTF-8 string | name: str = "" |
bool | Boolean | active: bool = True |
bytes | Binary data | data: bytes = b"" |
list | Ordered list | items: list = [] |
dict | Key-value map | balances: dict = {} |
list[float] | Typed list | weights: list[float] = [] |
Reading and Writing State
Inside contract methods, access state through self.state. Here is how the PredictionMarket contract (contracts/examples/prediction_market.py) reads and writes state in its place_bet method:
@call
def place_bet(self, ctx, side: str, amount: int):
if self.state.resolved:
raise ValueError("Market already resolved")
if ctx.block_time >= self.state.deadline:
raise ValueError("Betting period has ended")
if side not in ("yes", "no"):
raise ValueError("Side must be 'yes' or 'no'")
if amount <= 0:
raise ValueError("Amount must be positive")
addr = ctx.sender
bets = dict(self.state.bets)
if addr in bets:
raise ValueError("Already placed a bet")
bets[addr] = {"side": side, "amount": amount}
self.state.bets = bets
if side == "yes":
self.state.yes_pool = self.state.yes_pool + amount
else:
self.state.no_pool = self.state.no_pool + amount
self.emit(event.BetPlaced(
bettor=addr,
side=side,
amount=amount,
yes_pool=self.state.yes_pool,
no_pool=self.state.no_pool,
))
State reads and writes are tracked by PandaVM. Only changed fields are included in the state diff that gets applied to the chain.
State Serialization
State is serialized using MessagePack with deterministic key ordering. This is handled automatically -- you do not need to manage serialization.
Large State (ML Models)
For ML contracts, use panda.ml's save_model() and load_model() to store trained models as JSON dicts in contract state. Here is how the PricePredictor contract (contracts/examples/price_predictor.py) handles model persistence:
from panda.ml import LinearRegression, save_model, load_model, r2_score
# In the train method:
model = LinearRegression()
model.fit(features, prices)
preds = model.predict(features)
r2 = r2_score(prices, preds)
self.state.model = save_model(model)
self.state.is_trained = True
# In the predict query:
model = load_model(self.state.model)
predictions = model.predict(features)
return [round(p, 2) for p in predictions]
Supported Libraries
Tier 1 (First-class, determinism verified)
import numpy as np # Numerical computation
import pandas as pd # Data manipulation
import sklearn # Classical ML (LinearRegression, RandomForest, etc.)
import tensorflow as tf # Deep learning (CPU only)
import torch # PyTorch (CPU only)
import keras # High-level neural networks
Tier 2 (Supported, determinism verified)
import scipy # Scientific computing
import xgboost # Gradient boosting
import lightgbm # Gradient boosting
import statsmodels # Statistical models
Tier 3 (Available, user-verified)
import polars # Fast dataframes
import networkx # Graph algorithms
import sympy # Symbolic math
from PIL import Image # Image processing
Standard Library (Included)
The following standard library modules are available: json, math, decimal, fractions, statistics, collections, itertools, functools, operator, copy, re, struct, hashlib, hmac, base64, binascii, pickle, csv, dataclasses, enum, abc, typing, io, textwrap, string, pprint, bisect, heapq, array, numbers, warnings, contextlib, inspect.
Forbidden Patterns
The contract linter rejects the following at deployment time:
Forbidden Imports
import os # No system access
import subprocess # No process spawning
import socket # No network access
import threading # No parallelism
import multiprocessing # No parallelism
import asyncio # No async (non-deterministic scheduling)
import ctypes # No native code loading
import secrets # Non-deterministic
import uuid # Non-deterministic
import requests # No network
import urllib # No network
import http.client # No network
import signal # No signal handling
Forbidden Patterns
time.time() # Use ctx.block_time
datetime.now() # Use ctx.block_time
datetime.utcnow() # Use ctx.block_time
uuid.uuid4() # Non-deterministic
random.random() # Must seed first with random.seed()
open("/path", "w") # No filesystem write
eval("dynamic code") # No dynamic code execution
exec("dynamic code") # No dynamic code execution
Cross-Contract Calls
Contracts can call other deployed contracts:
from panda import call_contract
@call
def delegate(self, ctx, other_contract: str, value: int):
result = call_contract(
address=other_contract,
method="process",
value=value,
)
self.state.last_result = result
Cross-contract calls cost a base fee of 1000 PCU plus the gas consumed by the called contract.
Testing
Local Testing
Use panda test to run contracts through PandaVM locally:
panda test my_contract.py
Writing Test Scripts
Create a test script that exercises your contract:
# test_counter.py
import json
import subprocess
def test_counter():
# Deploy
result = subprocess.run(
["panda", "test", "counter.py", "--method", "initialize"],
capture_output=True, text=True
)
assert result.returncode == 0
# Increment
result = subprocess.run(
["panda", "test", "counter.py",
"--method", "increment",
"--args", '{"amount": 5}',
"--state", '{"count": 0, "owner": "test"}'],
capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["state"]["count"] == 5
Determinism Verification
PandaVM automatically verifies determinism by running contracts twice and comparing results:
panda test my_contract.py --verify-determinism
This runs the contract twice with identical inputs and asserts byte-for-byte equality of:
- Output/return value
- State after execution
- Emitted events
Example Contracts
Token Contract
The PRC20Token (contracts/tokens/prc20_token.py) is the reference fungible token implementation. It uses panda.token.FungibleToken for core token logic and adds freeze/thaw authority:
from panda import contract, constructor, call, query, event
from panda.token import FungibleToken, TokenError
@contract
class PRC20Token:
class State:
token: dict = {}
mint_authority: str = ""
freeze_authority: str = ""
frozen: dict = {}
def _t(self) -> FungibleToken:
return FungibleToken(self.state.token)
def _not_frozen(self, addr: str):
if self.state.frozen.get(addr, False):
raise TokenError(f"frozen: {addr}")
@constructor
def deploy(self, ctx, name: str, symbol: str, decimals: int = 18,
max_supply: int = 0, initial_supply: int = 0):
"""Runs at deploy; pass matching JSON to panda deploy --args."""
t = self._t()
if t.name() or t.total_supply() > 0:
raise TokenError("already initialized")
t.configure(name=name, symbol=symbol, decimals=decimals, max_supply=max_supply)
self.state.mint_authority = ctx.sender
self.state.freeze_authority = ctx.sender
if initial_supply > 0:
t.mint(ctx.sender, initial_supply)
self.state.token = t.to_dict()
self.emit(
event.PRC20Initialized(
deployer=ctx.sender, name=name, symbol=symbol,
decimals=decimals, initial_supply=initial_supply,
)
)
@call
def transfer(self, ctx, to: str, value: int):
self._not_frozen(ctx.sender)
self._not_frozen(to)
t = self._t()
t.transfer(ctx.sender, to, value)
self.state.token = t.to_dict()
self.emit(event.Transfer(_from=ctx.sender, _to=to, _value=value))
@call
def approve(self, ctx, spender: str, value: int):
self._not_frozen(ctx.sender)
t = self._t()
t.approve(ctx.sender, spender, value)
self.state.token = t.to_dict()
self.emit(event.Approval(_owner=ctx.sender, _spender=spender, _value=value))
@call
def mint(self, ctx, to: str, amount: int):
if ctx.sender != self.state.mint_authority:
raise TokenError("not mint authority")
t = self._t()
t.mint(to, amount)
self.state.token = t.to_dict()
self.emit(event.Transfer(
_from="0x0000000000000000000000000000000000000000",
_to=to, _value=amount,
))
@query
def balance_of(self, owner: str) -> int:
return self._t().balance_of(owner)
@query
def total_supply(self) -> int:
return self._t().total_supply()
@query
def allowance(self, owner: str, spender: str) -> int:
return self._t().allowance(owner, spender)
Prediction Market
The PredictionMarket (contracts/examples/prediction_market.py) is a binary prediction market where users bet on YES or NO outcomes, with proportional payouts after resolution:
from panda import contract, constructor, call, query, event
@contract
class PredictionMarket:
"""Binary prediction market where users bet on YES or NO outcomes."""
class State:
question: str = ""
owner: str = ""
deadline: int = 0
resolved: bool = False
outcome: str = ""
yes_pool: int = 0
no_pool: int = 0
bets: dict = {}
claimed: dict = {}
@constructor
def deploy(self, ctx, question: str, deadline: int):
if not question:
raise ValueError("Question cannot be empty")
if deadline <= ctx.block_time:
raise ValueError("Deadline must be in the future")
self.state.question = question
self.state.owner = ctx.sender
self.state.deadline = deadline
@call
def place_bet(self, ctx, side: str, amount: int):
if self.state.resolved:
raise ValueError("Market already resolved")
if ctx.block_time >= self.state.deadline:
raise ValueError("Betting period has ended")
if side not in ("yes", "no"):
raise ValueError("Side must be 'yes' or 'no'")
if amount <= 0:
raise ValueError("Amount must be positive")
addr = ctx.sender
bets = dict(self.state.bets)
if addr in bets:
raise ValueError("Already placed a bet")
bets[addr] = {"side": side, "amount": amount}
self.state.bets = bets
if side == "yes":
self.state.yes_pool = self.state.yes_pool + amount
else:
self.state.no_pool = self.state.no_pool + amount
self.emit(event.BetPlaced(
bettor=addr, side=side, amount=amount,
yes_pool=self.state.yes_pool, no_pool=self.state.no_pool,
))
@call
def resolve(self, ctx, outcome: str):
if ctx.sender != self.state.owner:
raise ValueError("Only the owner can resolve")
if self.state.resolved:
raise ValueError("Already resolved")
if ctx.block_time < self.state.deadline:
raise ValueError("Cannot resolve before deadline")
if outcome not in ("yes", "no"):
raise ValueError("Outcome must be 'yes' or 'no'")
self.state.resolved = True
self.state.outcome = outcome
self.emit(event.MarketResolved(
outcome=outcome,
yes_pool=self.state.yes_pool,
no_pool=self.state.no_pool,
))
@call
def claim_payout(self, ctx):
if not self.state.resolved:
raise ValueError("Market not resolved yet")
addr = ctx.sender
bets = dict(self.state.bets)
claimed = dict(self.state.claimed)
if addr not in bets:
raise ValueError("No bet placed")
if addr in claimed:
raise ValueError("Already claimed")
bet = bets[addr]
if bet["side"] != self.state.outcome:
raise ValueError("You lost this bet")
total_pool = self.state.yes_pool + self.state.no_pool
winning_pool = self.state.yes_pool if self.state.outcome == "yes" else self.state.no_pool
payout = (bet["amount"] * total_pool) // winning_pool
claimed[addr] = True
self.state.claimed = claimed
self.emit(event.PayoutClaimed(
bettor=addr, payout=payout, bet_amount=bet["amount"],
))
return payout
@query
def get_market(self) -> dict:
return {
"question": self.state.question,
"deadline": self.state.deadline,
"resolved": self.state.resolved,
"outcome": self.state.outcome,
"yes_pool": self.state.yes_pool,
"no_pool": self.state.no_pool,
"total_bets": len(self.state.bets),
}
@query
def get_odds(self) -> dict:
total = self.state.yes_pool + self.state.no_pool
if total == 0:
return {"yes_pct": 50, "no_pct": 50}
return {
"yes_pct": (self.state.yes_pool * 100) // total,
"no_pct": (self.state.no_pool * 100) // total,
}
On-Chain ML Pipeline
The FraudDetector (contracts/examples/fraud_detector.py) trains a logistic regression classifier on-chain and uses it to flag suspicious transactions in real time:
from panda import contract, constructor, call, query, event
from panda.ml import LogisticRegression, save_model, load_model
@contract
class FraudDetector:
"""Logistic regression classifier for on-chain fraud detection."""
class State:
owner: str = ""
model: dict = {}
is_trained: bool = False
model_version: int = 0
total_predictions: int = 0
flagged_count: int = 0
threshold: float = 0.5
@constructor
def deploy(self, ctx, threshold: float = 0.5):
if threshold <= 0.0 or threshold >= 1.0:
raise ValueError("Threshold must be between 0 and 1")
self.state.owner = ctx.sender
self.state.threshold = threshold
@call
def train(self, ctx, features: list, labels: list):
if ctx.sender != self.state.owner:
raise ValueError("Only owner can train")
if len(features) != len(labels):
raise ValueError("features and labels must have same length")
for label in labels:
if label not in (0, 1):
raise ValueError("Labels must be 0 or 1")
model = LogisticRegression()
model.fit(features, labels)
self.state.model = save_model(model)
self.state.is_trained = True
self.state.model_version = self.state.model_version + 1
self.emit(event.ModelTrained(
version=self.state.model_version,
sample_count=len(features),
fraud_count=sum(labels),
trainer=ctx.sender,
))
@call
def check_transaction(self, ctx, features: list):
if not self.state.is_trained:
raise ValueError("Model not trained yet")
model = load_model(self.state.model)
probas = model.predict_proba([features])
proba = probas[0]
fraud_score = round(proba[1], 6) if len(proba) > 1 else round(proba[0], 6)
flagged = fraud_score >= self.state.threshold
self.state.total_predictions = self.state.total_predictions + 1
if flagged:
self.state.flagged_count = self.state.flagged_count + 1
self.emit(event.TransactionChecked(
checker=ctx.sender, flagged=flagged, score=fraud_score,
))
return {"flagged": flagged, "score": fraud_score}
@query
def predict(self, features: list) -> list:
if not self.state.is_trained:
raise ValueError("Model not trained yet")
model = load_model(self.state.model)
probas = model.predict_proba(features)
results = []
for proba in probas:
fraud_score = round(proba[1], 6) if len(proba) > 1 else round(proba[0], 6)
results.append({
"flagged": fraud_score >= self.state.threshold,
"score": fraud_score,
})
return results
@query
def stats(self) -> dict:
return {
"is_trained": self.state.is_trained,
"model_version": self.state.model_version,
"threshold": self.state.threshold,
"total_predictions": self.state.total_predictions,
"flagged_count": self.state.flagged_count,
}
Deployment Checklist
Before deploying a contract to testnet or mainnet:
- Run
panda lint contract.py-- ensure no warnings or errors - Run
panda test contract.py-- verify all methods work correctly - Run
panda test contract.py --verify-determinism-- confirm deterministic execution - Review gas costs with
panda estimate contract.py --method <name> --args <json> - Test on local Kind cluster first (
make k8s-local) - Deploy to testnet before mainnet
- Verify the contract in the block explorer