DeFi Patterns
This guide covers decentralized finance primitives on Panda: escrow, automated market makers, NFT marketplaces, prediction markets, and cross-contract composition.
Escrow
The Escrow contract holds PRC-20 tokens until both parties agree on fulfillment. It uses cross-contract PRC20 handles to transfer tokens between buyer, seller, and the escrow contract itself.
# Source: contracts/patterns/escrow.py
"""
Escrow — Cross-contract escrow service using PRC-20 tokens.
Demonstrates complex cross-contract call patterns:
- Buyer deposits payment tokens into escrow
- Seller delivers goods/services
- Buyer confirms delivery, releasing funds to seller
- Or buyer requests refund within the grace period
"""
from panda import contract, constructor, call, query, event, PRC20
@contract
class Escrow:
"""Escrow service using PRC-20 tokens via cross-contract calls."""
class State:
payment_token: str = ""
escrows: dict = {}
next_escrow_id: int = 1
owner: str = ""
fee_bps: int = 100 # 1% fee
total_volume: int = 0
@constructor
def deploy(self, ctx, payment_token: str, fee_bps: int = 100):
self.state.payment_token = payment_token
self.state.owner = ctx.sender
self.state.fee_bps = fee_bps
print(f"Escrow deployed by {ctx.sender}, token={payment_token}, fee={fee_bps}bps")
@call
def create_escrow(self, ctx, seller: str, amount: int, grace_period: int):
"""Create an escrow. Buyer deposits tokens via cross-contract call.
grace_period: seconds after creation during which buyer can refund.
"""
if amount <= 0:
raise ValueError("amount must be positive")
if seller == ctx.sender:
raise ValueError("buyer and seller must be different")
# Transfer tokens from buyer to escrow contract
PRC20(self.state.payment_token).transfer_from(
owner=ctx.sender, to=ctx.contract_address, value=amount
)
eid = self.state.next_escrow_id
escrows = dict(self.state.escrows)
escrows[str(eid)] = {
"buyer": ctx.sender,
"seller": seller,
"amount": amount,
"status": "active",
"created_at": ctx.block_time,
"grace_deadline": ctx.block_time + grace_period,
}
self.state.escrows = escrows
self.state.next_escrow_id = eid + 1
self.state.total_volume = self.state.total_volume + amount
self.emit(
event.EscrowCreated(
escrow_id=eid,
buyer=ctx.sender,
seller=seller,
amount=amount,
)
)
print(f"Escrow #{eid} created: buyer={ctx.sender}, seller={seller}, amount={amount}")
@call
def release(self, ctx, escrow_id: int):
"""Release funds to seller. Only buyer can release."""
key = str(escrow_id)
escrows = dict(self.state.escrows)
escrow = escrows.get(key)
if not escrow:
raise ValueError("escrow not found")
if escrow["status"] != "active":
raise ValueError(f"escrow is {escrow['status']}, not active")
if ctx.sender != escrow["buyer"]:
raise ValueError("only buyer can release")
# Calculate fee
fee = (escrow["amount"] * self.state.fee_bps) // 10000
seller_amount = escrow["amount"] - fee
# Transfer to seller
token = PRC20(self.state.payment_token)
token.transfer(to=escrow["seller"], value=seller_amount)
# Transfer fee to owner
if fee > 0:
token.transfer(to=self.state.owner, value=fee)
escrow["status"] = "released"
escrows[key] = escrow
self.state.escrows = escrows
self.emit(
event.EscrowReleased(
escrow_id=escrow_id,
seller=escrow["seller"],
amount=seller_amount,
fee=fee,
)
)
print(f"Escrow #{escrow_id} released: {seller_amount} to {escrow['seller']}, fee={fee}")
@call
def refund(self, ctx, escrow_id: int):
"""Refund buyer. Only buyer can refund, and only within grace period."""
key = str(escrow_id)
escrows = dict(self.state.escrows)
escrow = escrows.get(key)
if not escrow:
raise ValueError("escrow not found")
if escrow["status"] != "active":
raise ValueError(f"escrow is {escrow['status']}, not active")
if ctx.sender != escrow["buyer"]:
raise ValueError("only buyer can refund")
if ctx.block_time > escrow["grace_deadline"]:
raise ValueError("grace period has expired")
# Refund full amount to buyer
PRC20(self.state.payment_token).transfer(to=escrow["buyer"], value=escrow["amount"])
escrow["status"] = "refunded"
escrows[key] = escrow
self.state.escrows = escrows
self.emit(
event.EscrowRefunded(
escrow_id=escrow_id,
buyer=escrow["buyer"],
amount=escrow["amount"],
)
)
print(f"Escrow #{escrow_id} refunded: {escrow['amount']} to {escrow['buyer']}")
@call
def dispute(self, ctx, escrow_id: int, reason: str):
"""Mark an escrow as disputed. Either buyer or seller can dispute."""
key = str(escrow_id)
escrows = dict(self.state.escrows)
escrow = escrows.get(key)
if not escrow:
raise ValueError("escrow not found")
if escrow["status"] != "active":
raise ValueError("can only dispute active escrows")
if ctx.sender != escrow["buyer"] and ctx.sender != escrow["seller"]:
raise ValueError("only buyer or seller can dispute")
escrow["status"] = "disputed"
escrow["dispute_reason"] = reason
escrow["disputed_by"] = ctx.sender
escrows[key] = escrow
self.state.escrows = escrows
self.emit(
event.EscrowDisputed(
escrow_id=escrow_id,
disputed_by=ctx.sender,
reason=reason,
)
)
print(f"Escrow #{escrow_id} disputed by {ctx.sender}: {reason}")
@query
def get_escrow(self, escrow_id: int) -> dict:
return self.state.escrows.get(str(escrow_id), {})
@query
def get_stats(self) -> dict:
active = sum(1 for e in self.state.escrows.values() if e["status"] == "active")
return {
"total_volume": self.state.total_volume,
"total_escrows": len(self.state.escrows),
"active_escrows": active,
}
Escrow lifecycle:
active-- funds deposited, waiting for fulfillmentreleased-- buyer confirmed delivery, funds sent to seller (minus fee)refunded-- buyer cancelled within grace period, funds returneddisputed-- either party raised a dispute (resolution handled off-chain or by a governance contract)
Automated Market Maker (AMM)
The TokenSwap contract implements a constant-product AMM using the formula x * y = k to price trades between two PRC-20 tokens.
# Source: contracts/tokens/token_swap.py
from panda import contract, constructor, call, query, event, PRC20
@contract
class TokenSwap:
"""Simple AMM swap pool for two PRC-20 tokens via cross-contract calls."""
class State:
token_a: str = ""
token_b: str = ""
reserve_a: int = 0
reserve_b: int = 0
total_lp: int = 0
lp_balances: dict = {}
fee_bps: int = 30 # 0.3% fee in basis points
owner: str = ""
@constructor
def deploy(self, ctx, token_a: str, token_b: str, fee_bps: int = 30):
if token_a == token_b:
raise ValueError("tokens must be different")
self.state.token_a = token_a
self.state.token_b = token_b
self.state.fee_bps = fee_bps
self.state.owner = ctx.sender
@call
def add_liquidity(self, ctx, amount_a: int, amount_b: int):
"""Add liquidity to the pool. Transfers tokens from sender via cross-call."""
if amount_a <= 0 or amount_b <= 0:
raise ValueError("amounts must be positive")
# Transfer tokens into the pool
PRC20(self.state.token_a).transfer_from(
owner=ctx.sender, to=ctx.contract_address, value=amount_a
)
PRC20(self.state.token_b).transfer_from(
owner=ctx.sender, to=ctx.contract_address, value=amount_b
)
# Calculate LP tokens to mint
if self.state.total_lp == 0:
# First deposit: LP = sqrt(a * b) approximated as (a + b) / 2
lp_amount = (amount_a + amount_b) // 2
else:
lp_a = amount_a * self.state.total_lp // self.state.reserve_a
lp_b = amount_b * self.state.total_lp // self.state.reserve_b
lp_amount = min(lp_a, lp_b)
if lp_amount <= 0:
raise ValueError("insufficient liquidity minted")
self.state.reserve_a = self.state.reserve_a + amount_a
self.state.reserve_b = self.state.reserve_b + amount_b
self.state.total_lp = self.state.total_lp + lp_amount
lp = dict(self.state.lp_balances)
lp[ctx.sender] = lp.get(ctx.sender, 0) + lp_amount
self.state.lp_balances = lp
self.emit(
event.LiquidityAdded(
provider=ctx.sender,
amount_a=amount_a,
amount_b=amount_b,
lp_minted=lp_amount,
)
)
@call
def swap_a_for_b(self, ctx, amount_in: int):
"""Swap token_a for token_b using constant-product formula."""
if amount_in <= 0:
raise ValueError("amount must be positive")
if self.state.reserve_a == 0 or self.state.reserve_b == 0:
raise ValueError("no liquidity")
# Apply fee
amount_in_with_fee = amount_in * (10000 - self.state.fee_bps)
amount_out = (amount_in_with_fee * self.state.reserve_b) // (
self.state.reserve_a * 10000 + amount_in_with_fee
)
if amount_out <= 0:
raise ValueError("insufficient output amount")
# Transfer token_a from sender into pool
PRC20(self.state.token_a).transfer_from(
owner=ctx.sender, to=ctx.contract_address, value=amount_in
)
# Transfer token_b from pool to sender
PRC20(self.state.token_b).transfer(to=ctx.sender, value=amount_out)
self.state.reserve_a = self.state.reserve_a + amount_in
self.state.reserve_b = self.state.reserve_b - amount_out
self.emit(
event.Swap(
sender=ctx.sender,
token_in=self.state.token_a,
amount_in=amount_in,
amount_out=amount_out,
)
)
@call
def swap_b_for_a(self, ctx, amount_in: int):
"""Swap token_b for token_a using constant-product formula."""
if amount_in <= 0:
raise ValueError("amount must be positive")
if self.state.reserve_a == 0 or self.state.reserve_b == 0:
raise ValueError("no liquidity")
amount_in_with_fee = amount_in * (10000 - self.state.fee_bps)
amount_out = (amount_in_with_fee * self.state.reserve_a) // (
self.state.reserve_b * 10000 + amount_in_with_fee
)
if amount_out <= 0:
raise ValueError("insufficient output amount")
PRC20(self.state.token_b).transfer_from(
owner=ctx.sender, to=ctx.contract_address, value=amount_in
)
PRC20(self.state.token_a).transfer(to=ctx.sender, value=amount_out)
self.state.reserve_b = self.state.reserve_b + amount_in
self.state.reserve_a = self.state.reserve_a - amount_out
self.emit(
event.Swap(
sender=ctx.sender,
token_in=self.state.token_b,
amount_in=amount_in,
amount_out=amount_out,
)
)
@query
def get_reserves(self) -> dict:
return {
"reserve_a": self.state.reserve_a,
"reserve_b": self.state.reserve_b,
"total_lp": self.state.total_lp,
}
@query
def quote_swap_a_for_b(self, amount_in: int) -> int:
"""Preview how much token_b you'd get for amount_in of token_a."""
if self.state.reserve_a == 0 or self.state.reserve_b == 0:
return 0
amount_in_with_fee = amount_in * (10000 - self.state.fee_bps)
return (amount_in_with_fee * self.state.reserve_b) // (
self.state.reserve_a * 10000 + amount_in_with_fee
)
How the constant-product formula works:
The formula ensures reserve_a * reserve_b = k (constant) after every swap. Larger trades get progressively worse prices (slippage), which protects liquidity providers. Fees (default 0.3%) are deducted before the swap calculation, making k grow over time and rewarding LPs.
Liquidity provision uses (a + b) / 2 for the first deposit and proportional minting via min(lp_a, lp_b) for subsequent deposits, ensuring fair LP token distribution.
NFT Marketplace
The NFT Marketplace combines PRC-721 and PRC-20 cross-contract calls to enable atomic buy/sell of NFTs with token payments.
# Source: contracts/tokens/nft_marketplace.py (excerpt: buy_nft method)
from panda import contract, constructor, call, query, event, Contract, PRC20
@call
def buy_nft(self, ctx, nft_contract: str, token_id: int):
"""Buy a listed NFT. Buyer must have approved payment token spending."""
listing_id = f"{nft_contract}:{token_id}"
listings = dict(self.state.listings)
listing = listings.get(listing_id)
if not listing or not listing["active"]:
raise ValueError("listing not found or inactive")
price = listing["price"]
seller = listing["seller"]
# Calculate fees
fee = (price * self.state.fee_bps) // 10000
seller_proceeds = price - fee
# Transfer payment from buyer to seller
payment = PRC20(self.state.payment_token)
payment.transfer_from(owner=ctx.sender, to=seller, value=seller_proceeds)
# Transfer fee to fee recipient
if fee > 0:
payment.transfer_from(owner=ctx.sender, to=self.state.fee_recipient, value=fee)
# Transfer NFT from seller to buyer
Contract(nft_contract).transfer_from(
from_addr=seller, to_addr=ctx.sender, token_id=token_id
)
# Mark listing as inactive
listing["active"] = False
listings[listing_id] = listing
self.state.listings = listings
self.state.total_volume = self.state.total_volume + price
self.state.total_sales = self.state.total_sales + 1
self.emit(
event.NFTSold(
buyer=ctx.sender,
seller=seller,
nft_contract=nft_contract,
token_id=token_id,
price=price,
fee=fee,
)
)
The buy_nft method performs three cross-contract calls atomically:
PRC20.transfer_from-- move payment tokens from buyer to sellerPRC20.transfer_from-- move marketplace fee to fee recipientContract.transfer_from-- transfer the NFT from seller to buyer
If any call fails, the entire transaction reverts -- no partial state changes.
Prediction Market
A binary prediction market where users bet on YES or NO outcomes. Uses time-based logic for betting deadlines and proportional payout calculations.
# Source: contracts/examples/prediction_market.py
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" or "no" after resolution
yes_pool: int = 0
no_pool: int = 0
bets: dict = {} # {addr: {"side": "yes"|"no", "amount": int}}
claimed: dict = {} # {addr: True} after payout claimed
@constructor
def deploy(self, ctx, question: str, deadline: int):
"""Create a new prediction market.
Args:
question: The yes/no question being predicted.
deadline: Block timestamp after which no new bets are accepted.
"""
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
print(f"PredictionMarket deployed: '{question}' by {ctx.sender}")
@call
def place_bet(self, ctx, side: str, amount: int):
"""Place a bet on YES or NO.
Args:
side: "yes" or "no"
amount: Amount to wager (must be positive)
"""
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):
"""Resolve the market. Only the owner can resolve, and only after deadline.
Args:
outcome: "yes" or "no"
"""
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):
"""Claim winnings after market resolution."""
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")
# Calculate proportional payout from total pool
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
if winning_pool == 0:
raise ValueError("No winners")
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 market status."""
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:
"""Return current implied odds (percentage of pool)."""
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,
}
Prediction market mechanics:
- Betting: users place bets on "yes" or "no" before the deadline. Each address can only bet once.
- Resolution: only the owner (oracle) can resolve the market after the deadline passes.
- Payout: winners receive a proportional share of the total pool. If you bet 100 on "yes" and the yes pool is 500 out of a total 800, your payout is
(100 * 800) / 500 = 160. - Implied odds: the
get_oddsquery shows the current market consensus as percentages.
Cross-Contract Composition
Panda's cross-contract call system enables composability -- contracts can build on each other:
from panda import Contract, PRC20
# PRC20 handle -- typed interface for token contracts
token = PRC20("0xABC...")
balance = token.balance_of(owner="0x123...")
token.transfer(to="0x456...", value=100)
token.transfer_from(owner="0x123...", to="0x456...", value=100)
# Generic Contract handle -- call any method on any contract
nft = Contract("0xDEF...")
owner = nft.owner_of(token_id=42)
nft.transfer_from(from_addr="0x123...", to_addr="0x456...", token_id=42)
This enables composability patterns seen throughout the DeFi contracts:
- Escrow uses
PRC20.transfer_fromto deposit andPRC20.transferto release - TokenSwap uses
PRC20handles for both token_a and token_b reserves - NFT Marketplace uses
Contractfor NFT queries/transfers andPRC20for payments - DAO Voting (in the Governance guide) uses
PRC20.balance_ofandPRC20.total_supplyfor voting power
Try It
- Open the Playground and select the Token Swap (AMM), Escrow, or Prediction Market template
- Follow the DeFi: Token Swap tutorial for a guided AMM walkthrough
- See the Tokens & DeFi guide for PRC-20 and PRC-721 details