SDK Reference

Complete reference for the Panda SDK -- all decorators, classes, types, and testing utilities available for writing and testing Python smart contracts.

Installation

pip install panda-sdk

Quick Start

<!-- Source: contracts/examples/counter.py -->
from panda import contract, constructor, call, query, event

@contract
class Counter:
    class State:
        count: int = 0
        owner: str = ""
        last_caller: str = ""

    @constructor
    def deploy(self, ctx, initial_count: int = 0):
        self.state.owner = ctx.sender
        self.state.count = initial_count

    @call
    def increment(self, ctx):
        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,
        ))

    @query
    def get_count(self) -> int:
        return self.state.count

Test locally:

from panda.testing import ContractTestRunner

runner = ContractTestRunner()
addr = runner.deploy("examples/counter.py", sender="alice")
runner.call(addr, "increment", sender="alice")
runner.call(addr, "increment", sender="alice")
result = runner.query(addr, "get_count")
assert result.return_value == 2

Deploy via the Contract Playground or programmatically via the SDK client.


Decorators

@contract

Marks a class as a Panda smart contract. The class must define an inner State class with typed fields and default values.

from panda import contract

@contract
class MyContract:
    class State:
        count: int = 0
        name: str = ""
        scores: list = []
        metadata: dict = {}
        active: bool = True

Requirements:

  • Must have an inner State class
  • All state fields must have type annotations and default values
  • Only one @contract class per file
  • Supported state types: int, float, str, bool, bytes, list, dict

@constructor

Marks a method as the deploy-time constructor. Runs once when the contract is deployed. Cannot be called again after deployment.

from panda import constructor

@constructor
def deploy(self, ctx, name: str, symbol: str, initial_supply: int = 0):
    self.state.name = name
    self.state.symbol = symbol
    self.state.owner = ctx.sender
    if initial_supply > 0:
        self.state.total_supply = initial_supply
        self.state.balances[ctx.sender] = initial_supply

Notes:

  • Receives ctx as the first parameter (after self)
  • Can accept deployment arguments
  • Implicitly a @call method (modifies state)

@call

Marks a method as state-mutating. Requires a transaction and costs gas.

from panda import call

@call
def transfer(self, ctx, to: str, amount: int):
    sender_balance = self.state.balances.get(ctx.sender, 0)
    if sender_balance < amount:
        raise ValueError("Insufficient balance")
    self.state.balances[ctx.sender] -= amount
    self.state.balances[to] = self.state.balances.get(to, 0) + amount

Notes:

  • First parameter after self is always ctx (execution context)
  • Can modify self.state
  • Can emit events with self.emit()
  • Can be async def for await-based patterns

@query

Marks a method as read-only. Free to call, no transaction needed.

from panda import query

@query
def balance_of(self, address: str) -> int:
    return self.state.balances.get(address, 0)

Notes:

  • Does NOT receive a ctx parameter
  • Cannot modify state (mutations are discarded)
  • Return value is sent back to the caller

@event

Events are not a method decorator. Instead, use event.Name(**fields) with self.emit():

from panda import event

# Inside a @call method:
self.emit(event.Transfer(sender=ctx.sender, recipient=to, amount=100))
self.emit(event.ModelTrained(samples=len(data), accuracy=0.95))

Event names are created dynamically -- no pre-definition needed. Events appear in transaction receipts and are indexed by the block explorer.

@private

Marks a contract or method for encrypted state and privacy-preserving execution.

from panda import private

# Apply to entire contract:
@private
@contract
class SecretModel:
    class State:
        weights: list = []

# Or apply to individual methods:
@call
@private
def update_secret(self, ctx, data: str):
    self.state.secret_data = data

@proof

Requires ZK proof generation for a method.

from panda import proof

@call
@proof(type="validity", backend="auto")
def train(self, ctx, features: list, labels: list):
    # Execution is proven correct via ZK proof
    pass

@call
@proof(type="fraud")
def optimistic_op(self, ctx, data: list):
    # Executes optimistically; can be challenged with a fraud proof
    pass

Parameters:

ParameterValuesDefaultDescription
type"validity", "fraud""validity"Proof type
backend"auto", "risc_zero", "halo2", "sp1""auto"ZK backend

See the Proofs guide for details.

@receiver

Marks a method as a cross-chain message handler. Can only be called with cross-chain context.

from panda import receiver

@receiver(chains=["ethereum", "solana"])
def on_deposit(self, ctx, amount: int, depositor: str):
    ctx.source_chain   # "ethereum" or "solana"
    ctx.message_id     # unique message ID
    ctx.is_cross_chain # always True
    self.state.total += amount

@receiver()  # accepts from any chain
def on_any_message(self, ctx, data: str):
    pass

Parameters:

ParameterTypeDefaultDescription
chainslist[str] or NoneNoneWhitelist of allowed source chains. None = accept all.

See the Cross-Chain guide for details.

@callback

Marks a method as a timer callback. Called by the timer system when a scheduled timer fires.

from panda import callback

@callback
def execute_withdrawal(self, ctx, to: str = "", amount: int = 0):
    """Called by the timer system -- cannot be called by users."""
    self.state.balances[to] = self.state.balances.get(to, 0) + amount

Notes:

  • Implicitly a @call method (can modify state)
  • Cannot be called directly by users -- only the timer system can invoke it

@view

Marks a method as a visualization endpoint. Returns renderable content (charts, SVG, HTML).

from panda import view

@query
@view
def chart(self) -> str:
    """Auto-detect content type."""
    import json
    return json.dumps({"$schema": "https://vega.github.io/schema/vega-lite/v5.json", ...})

@query
@view(content_type="svg")
def diagram(self) -> str:
    """Explicit SVG content type."""
    return '<svg xmlns="http://www.w3.org/2000/svg">...</svg>'

Supported content types: "svg", "vega", "html", "json", "png"

See the Visualizations guide for details.

@agent

Marks a contract as a PRC-Agent.

from panda import agent

@agent(capabilities=["inference", "cross_contract"])
@contract
class MyAgent:
    class State:
        active: bool = True

Valid capabilities: "inference", "training", "data_access", "cross_contract"

See the AI Agents guide for details.


Context Object

The ctx parameter passed to @call, @constructor, @callback, and @receiver methods:

@call
def my_method(self, ctx):
    ctx.sender            # Address of the transaction sender (str)
    ctx.block_height      # Current block number (int)
    ctx.block_time        # Block timestamp in seconds since epoch (int)
    ctx.contract_address  # This contract's address (str)
    ctx.gas_remaining     # Remaining gas budget in PCU (int)
    ctx.chain_id          # Chain identifier, e.g. "panda-eth-mainnet" (str)
    ctx.gas_deposit       # Gas deposit for async operations (int)
    ctx.gas_deposit_remaining  # Remaining gas deposit (int)

    # Cross-chain fields (set on @receiver methods):
    ctx.source_chain      # Origin chain: "ethereum", "solana", etc. (str)
    ctx.message_id        # Unique cross-chain message ID (str)
    ctx.is_cross_chain    # True if this is a cross-chain call (bool)

Important: Use ctx.block_time instead of time.time() or datetime.now(). Standard library time functions are blocked for determinism.


State Management

Defining State

@contract
class MyContract:
    class State:
        count: int = 0
        name: str = ""
        scores: list = []
        metadata: dict = {}
        active: bool = True
        model_bytes: bytes = b""

Reading and Writing

@call
def update(self, ctx, new_name: str):
    current = self.state.name          # read
    self.state.name = new_name         # write
    self.state.metadata["updated"] = ctx.block_time  # nested write
    self.state.scores.append(42.0)     # list append

Emitting Events

@call
def transfer(self, ctx, to: str, amount: int):
    # ... logic ...
    self.emit(event.Transfer(sender=ctx.sender, recipient=to, amount=amount))

Cross-Chain Messaging

Chain Class

from panda import Chain

eth = Chain.ethereum()      # or Chain("ethereum")
sol = Chain.solana()        # or Chain("solana")
l2  = Chain.l2()            # or Chain("panda_l2")
hub = Chain.hub()           # or Chain("panda_hub")

Methods:

MethodReturnsDescription
chain.call(addr, method, **kwargs)_CrossChainCallResultCall a remote contract method
chain.transfer(addr, amount, **kwargs)_CrossChainCallResultTransfer value
chain.send(addr, **payload)str (msg_id)Send raw message
chain.try_send(addr, **payload)(bool, str)Error-safe send
chain.contract(addr)RemoteContractGet untyped remote handle
chain.prc20(addr)RemotePRC20Get typed PRC-20 handle

Fire-and-Forget vs Await

# Fire-and-forget: msg_id is a string, execution continues
msg_id = eth.call("0xAddr", "mint", amount=100)

# Await: suspends until response arrives (method must be async def)
result = await eth.call("0xAddr", "mint", amount=100)

send_message / try_send_message

from panda import send_message, try_send_message

# Raises on error
msg_id = send_message("ethereum", "0xRecipient", action="mint", amount=100)

# Returns (success, msg_id_or_error) -- never raises
success, result = try_send_message("ethereum", "0xRecipient", amount=100)

RemoteContract

Untyped proxy for calling any method on a remote contract:

remote = eth.contract("0x1234...")
msg_id = remote.deposit(amount=100)        # fire-and-forget
result = await remote.deposit(amount=100)   # await

RemotePRC20

Typed proxy with PRC-20 methods:

usdc = eth.prc20("0xUSDC")
usdc.transfer(to="0xBob", value=1000)
usdc.approve(spender="0xDex", value=5000)
usdc.transfer_from(owner="0xAlice", to="0xBob", value=100)
usdc.mint(to="0xBob", amount=500)
usdc.balance_of(owner="0xBob")
usdc.total_supply()

See the Cross-Chain guide for full details and examples.


Cross-Contract Calls (Same Chain)

call_contract / query_contract

from panda import call_contract, query_contract

@call
def delegate(self, ctx, target: str, value: int):
    result = call_contract(target, "process", value=value)
    self.state.last_result = result

@query
def check_balance(self, token: str, owner: str) -> int:
    return query_contract(token, "balance_of", owner=owner)

Contract Handle

Pythonic proxy for same-chain contract calls:

from panda import Contract

token = Contract("0x1234...")
token.transfer(to="0x5678", value=100)      # calls call_contract
balance = token.balance_of(owner="0x5678")  # calls call_contract

PRC20 Handle

Typed same-chain PRC-20 proxy with proper call/query routing:

from panda import PRC20

token = PRC20("0x1234...")
token.transfer(to="0x5678", value=100)        # call_contract (state-mutating)
token.approve(spender="0xDex", value=5000)    # call_contract
balance = token.balance_of(owner="0x5678")    # query_contract (read-only)
supply = token.total_supply()                  # query_contract
name = token.name()                            # query_contract
symbol = token.symbol()                        # query_contract

Token Standard (FungibleToken)

The FungibleToken class provides an ERC-20-like implementation for building PRC-20 tokens. Here is the real PRC20Token reference implementation:

<!-- Source: 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 = {}

    def _t(self) -> FungibleToken:
        return FungibleToken(self.state.token)

    @constructor
    def deploy(self, ctx, name: str, symbol: str, decimals: int = 18,
               max_supply: int = 0, initial_supply: int = 0):
        t = self._t()
        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()

    @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))

    @query
    def balance_of(self, owner: str) -> int:
        return self._t().balance_of(owner)

FungibleToken methods:

MethodDescription
configure(name, symbol, decimals=18, max_supply=0)Set token metadata
mint(to, amount)Create tokens
burn(addr, amount)Destroy tokens
transfer(sender, to, amount)Transfer tokens
approve(owner, spender, amount)Set spending allowance
transfer_from(spender, owner, to, amount)Transfer using allowance
balance_of(addr)Get balance
allowance(owner, spender)Get allowance
total_supply()Get total supply
add_minter(addr) / remove_minter(addr)Manage minting permissions
to_dict()Serialize for state storage

Storage Data Structures

The panda.storage module provides deterministic data structures with guaranteed iteration order across all validators.

PandaMap

Ordered key-value store with prefix scanning:

from panda.storage import PandaMap

m = PandaMap()
m.set("alice", 100)
m.set("bob", 200)
m.get("alice")           # 100
m.get("carol", 0)        # 0 (default)
m.contains("bob")        # True
m.delete("bob")          # True
list(m.keys())           # ["alice"] (sorted)
m.prefix("al")           # [("alice", 100)]
m.range("a", "c")        # [("alice", 100)]
m.size()                 # 1
m.to_dict()              # serialize for state storage

PandaSet

Ordered set with membership operations:

from panda.storage import PandaSet

s = PandaSet()
s.add("alice")
s.add("bob")
"alice" in s             # True
s.members()              # ["alice", "bob"] (sorted)
s.remove("bob")          # True
s.membership_hash()      # SHA-256 of sorted members
s.union(other_set)       # new PandaSet
s.intersection(other)    # new PandaSet
s.difference(other)      # new PandaSet
s.to_dict()              # serialize for state storage

PandaCounter

Named counters with overflow protection:

from panda.storage import PandaCounter

c = PandaCounter()
c.increment("views")         # 1
c.increment("views", 10)     # 11
c.decrement("views", 5)      # 6
c.get("views")                # 6
c.reset("views")              # sets to 0
c.all()                       # {"views": 0} (sorted)
c.to_dict()                   # serialize

PandaSortedMap

Map sorted by numeric value with rank queries (leaderboards, priority queues):

from panda.storage import PandaSortedMap

lb = PandaSortedMap()
lb.set("alice", 100)
lb.set("bob", 200)
lb.set("carol", 150)
lb.top(2)                     # [("bob", 200), ("carol", 150)]
lb.bottom(2)                  # [("alice", 100), ("carol", 150)]
lb.rank("alice")              # 2 (0-indexed from top)
lb.range_by_value(100, 160)   # [("carol", 150), ("alice", 100)]
lb.to_dict()                  # serialize

PandaQueue

Bounded FIFO queue:

from panda.storage import PandaQueue

q = PandaQueue(max_size=100)
q.push({"event": "transfer", "amount": 50})
q.push({"event": "mint", "amount": 100})
q.peek()                      # {"event": "transfer", "amount": 50}
item = q.pop()                # {"event": "transfer", "amount": 50}
q.size()                      # 1
q.is_empty()                  # False
q.is_full()                   # False
q.to_dict()                   # serialize

PandaTimeSeries

Time-indexed data with windowed aggregation:

from panda.storage import PandaTimeSeries

ts = PandaTimeSeries(max_points=10000)
ts.append(100, 42.0)         # block_height=100, value=42.0
ts.append(101, 43.5)
ts.append(102, 41.0)
ts.window(100, 103)          # [[100, 42.0], [101, 43.5], [102, 41.0]]
ts.window_mean(100, 103)     # 42.166...
ts.window_sum(100, 103)      # 126.5
ts.window_min(100, 103)      # 41.0
ts.window_max(100, 103)      # 43.5
ts.latest(2)                 # [[101, 43.5], [102, 41.0]]
ts.to_dict()                 # serialize

Async Contracts

sleep

Suspend execution for a number of blocks:

from panda import sleep

@call
async def delayed_withdrawal(self, ctx, amount: int):
    self.state.pending[ctx.sender] = amount
    await sleep(blocks=100)
    # This runs 100 blocks later
    self.state.pending.pop(ctx.sender)
    self.state.balances[ctx.sender] += amount

@call
async def scheduled_at(self, ctx, target_block: int):
    await sleep(until_block=target_block)
    # Runs at the specified block height

delay (convenience)

Every contract automatically gets a delay method:

@call
async def step_one(self, ctx):
    self.state.phase = 1
    await self.delay(ctx, blocks=10)
    self.state.phase = 2

Testing

ContractTestRunner

The ContractTestRunner simulates a Panda blockchain environment locally.

from panda.testing import ContractTestRunner

runner = ContractTestRunner(
    chain_id="panda-test",
    initial_block_height=1,
    initial_block_time=1700000000,
)

Deploying Contracts

# From a file path
addr = runner.deploy("contracts/my_contract.py", sender="alice")

# From a @contract class
addr = runner.deploy(MyContract, sender="alice")

# With constructor arguments
addr = runner.deploy(MyToken, sender="alice", name="Test", symbol="TST", initial_supply=1000)

# With explicit address
addr = runner.deploy(MyContract, sender="alice", address="0xCustomAddr")

Calling Methods

# @call method
result = runner.call(addr, "transfer", sender="alice", to="bob", amount=100)
result.return_value     # method return value
result.state            # full state dict after execution
result.state_diff       # changed fields: {field: {"old": v, "new": v}}
result.events           # list of emitted events
result.logs             # list of print() output
result.gas_used         # gas consumed

# @query method
result = runner.query(addr, "balance_of", address="alice")
result.return_value     # the returned value

State Inspection

state = runner.get_state(addr)       # dict of state fields
info = runner.get_info(addr)         # ContractInfo with address, owner, code_hash, state

Block Control

runner.set_block(height=100, time=1700001000)
runner.set_block_time(1700002000)
# block auto-advances by 1 after each call()

Cross-Contract Calls

The test runner automatically handles call_contract() and query_contract() between contracts deployed in the same runner:

token_addr = runner.deploy(TokenContract, sender="alice")
swap_addr = runner.deploy(SwapContract, sender="alice")

# SwapContract can call_contract(token_addr, "transfer", ...)
# and it will route through the test runner

Cross-Chain Testing

# Call a @receiver with cross-chain context
result = runner.call_cross_chain(
    addr, "on_deposit",
    sender="relayer",
    source_chain="ethereum",
    message_id="0xabc",
    amount=1000,
    depositor="0xAlice",
)

# Deliver response to a suspended await chain.call()
pending = runner.get_pending_cross_chain_calls()
msg_id = pending[0]["msg_id"]
result = runner.deliver_cross_chain_response(msg_id, response={"success": True})

# Inspect emitted cross-chain messages
messages = runner.get_cross_chain_messages()
runner.clear_cross_chain_messages()

Timer / Async Testing

# Fire all pending timers (from await sleep())
results = runner.fire_pending_timers(addr, sender="timer_system")

# Inspect hibernation snapshots (suspended coroutine state)
snapshots = runner.get_hibernation_snapshot(addr)

CallResult

Returned by runner.call() and runner.query():

@dataclass
class CallResult:
    return_value: Any = None           # Method return value
    state: dict = {}                   # Full state after execution
    state_diff: dict = {}              # Changed fields
    gas_used: int = 0                  # Gas consumed
    events: list = []                  # Emitted events
    logs: list = []                    # print() output
    stdout: str = ""                   # Captured stdout
    gas_deposit_remaining: int = 0     # Remaining gas deposit
    gas_deposit_refund: int = 0        # Gas deposit refund

Event Format

result.events[0]["event"]            # "Transfer"
result.events[0]["data"]["amount"]   # 100
result.events[0]["data"]["sender"]   # "alice"

Types

Context

from panda import Context

@dataclass
class Context:
    sender: str = ""
    block_height: int = 0
    block_time: int = 0
    contract_address: str = ""
    gas_remaining: int = 1_000_000
    chain_id: str = "panda-local"
    gas_deposit: int = 0
    gas_deposit_remaining: int = 0
    source_chain: str = ""           # cross-chain only
    message_id: str = ""             # cross-chain only
    is_cross_chain: bool = False     # cross-chain only

ContractInfo

from panda import ContractInfo

@dataclass
class ContractInfo:
    address: str = ""
    owner: str = ""
    code_hash: str = ""
    state: dict = {}

Exceptions

ExceptionDescription
PandaErrorBase exception for all SDK errors
ContractErrorError during contract execution
ValidationErrorContract source validation failure
StateErrorInvalid state operation
GasExhaustedErrorGas limit exceeded

Constants

from panda import ETHEREUM, SOLANA, PANDA_L2, PANDA_HUB

Complete Import Reference

# Core decorators
from panda import contract, constructor, call, query, callback, private, proof, view, agent, event, receiver

# Cross-contract calls (same chain)
from panda import call_contract, query_contract, Contract

# Typed token handle (same chain)
from panda import PRC20

# Cross-chain messaging
from panda import send_message, try_send_message, Chain, RemoteContract, RemotePRC20
from panda import ETHEREUM, SOLANA, PANDA_L2, PANDA_HUB

# Async
from panda import sleep

# Types
from panda import Context, CallResult, EventData, ContractInfo
from panda import PandaError, ContractError, ValidationError, StateError, GasExhaustedError

# State proxy
from panda import StateProxy

# Token implementation
from panda.token import FungibleToken

# Storage data structures
from panda.storage import PandaMap, PandaSet, PandaCounter, PandaSortedMap, PandaQueue, PandaTimeSeries

# Agent helpers
from panda import Agent, AgentError

# Testing
from panda.testing import ContractTestRunner

Example Contract Library

Panda ships with a comprehensive set of example contracts:

Getting Started

ContractDescription
hello_world.pySimplest contract: greeting message with events
counter.pyCounter with increment, decrement, add, reset, owner controls
price_predictor.pyML price prediction with LinearRegression
fraud_detector.pyML fraud detection with LogisticRegression
prediction_market.pyBinary prediction market with betting and payouts

Tokens and DeFi

ContractDescription
prc20_token.pyPRC-20 fungible token (reference implementation)
prc721_collection.pyPRC-721 NFT collection
token_swap.pyAMM-style token swap using cross-contract PRC-20 calls
nft_marketplace.pyNFT marketplace with listing, buying, and royalties

Cross-Chain

ContractDescription
cross_chain_bridge_v2.pyFull bridge with @receiver, await, fire-and-forget
cross_chain_defi.pyDeFi swap with typed remote handles and multi-chain awaits
cross_chain_messenger.pySimple messaging between chains
token_bridge.pyToken bridge with lock/mint/release flow

Machine Learning

ContractDescription
regression_trainer.pyLinear regression on-chain
logistic_classifier.pyLogistic regression classifier
clustering_contract.pyK-Means clustering
decision_tree_contract.pyDecision tree classifier
neural_network_contract.pyMLP neural network
ensemble_classifier.pyRandom forest ensemble
online_learner.pySGD online learning
image_classifier.pyImage classification pipeline
model_marketplace.pyBuy/sell ML models on-chain
torch_sentiment.pyPyTorch sentiment analysis
tf_regression.pyTensorFlow regression
numpy_data_analysis.pyNumPy-based data analysis
numpy_matrix_ops.pyNumPy matrix operations
numpy_signal_processor.pySignal processing with NumPy
pandas_data_pipeline.pyPandas data pipeline
pandas_portfolio_tracker.pyPortfolio tracking with Pandas
pandas_time_series.pyTime series analysis
async_sgd_trainer.pyAsync SGD with await sleep()

Cryptographic Patterns

ContractDescription
hash_registry.pyCommit-reveal scheme using SHA-256
merkle_airdrop.pyGas-efficient airdrop with Merkle proofs
multisig_wallet.pyM-of-N multisig with HMAC verification
secret_auction.pySealed-bid auction with Pedersen commitments
timelock_vault.pyTime-locked vault with derived-key withdrawal

Design Patterns

ContractDescription
ownable.pyOwnership pattern with two-step transfer
pausable.pyPausable contract (inherits Ownable)
access_control.pyRole-based access control (RBAC)
registry.pyOn-chain contract discovery and metadata
escrow.pyEscrow with cross-contract PRC-20 transfers
dao_voting.pyDAO governance with token-weighted voting

Privacy and ZK

ContractDescription
zkp_membership.pyAnonymous authorization via Merkle proofs and nullifiers
private_model.pyPrivate ML model inference
private_statistics.pyPrivate statistical computations
differential_privacy.pyDifferential privacy mechanisms
homomorphic_tally.pyHomomorphic encryption for private tallying
secure_aggregation.pySecure multi-party aggregation
federated_trainer.pyFederated learning coordinator

Visualizations

ContractDescription
bar_chart.pyBar chart via Vega-Lite @view
dashboard.pyMulti-panel dashboard
data_table.pyHTML data table
vega_scatter.pyScatter plot via Vega-Lite

Next Steps