Paid Inference
Introduction
The PaidModel contract turns a trained machine learning model into a monetizable, on-chain inference endpoint. The creator deploys a model, sets a price per inference call, and every caller pays that price to run predict(). Payments accrue to the creator's on-chain balance and usage statistics are tracked per user.
This is the simplest way to monetize ML on PandaChain: any panda.ml model becomes a pay-per-call API.
How It Works
- Deploy a
PaidModelcontract with a model name and a per-call price. - Train the model on-chain (or upload a pre-trained
panda.mlmodel). - Callers pay at least
price_per_callto submit features and receive predictions. - Revenue accrues to the creator's balance and per-user stats are recorded.
- Withdraw bookkeeping deducts from the recorded balance.
Prices are integer base units, not floats. A "price of 100" means 100 base units per call.
The PaidModel Contract
The canonical contract lives at contracts/marketplace/paid_model.py. It trains a LogisticRegression classifier and charges per prediction.
"""
PaidModel -- Paid ML model inference as a smart contract.
- A creator deploys a model on-chain and sets a price per inference call.
- Users pay to call predict(); payment is credited to the creator's balance.
- Usage stats are tracked (total calls, total revenue, per-user usage).
"""
from panda import contract, constructor, call, query, event
from panda.ml import load_model, save_model
@contract
class PaidModel:
"""On-chain paid inference: deploy a model, charge per prediction."""
class State:
model_dict: dict = {}
model_name: str = ""
model_description: str = ""
is_trained: bool = False
creator: str = ""
price_per_call: int = 0
creator_balance: int = 0
total_revenue: int = 0
total_calls: int = 0
user_calls: dict = {} # address -> call count
user_spent: dict = {} # address -> total spent
@constructor
def deploy(self, ctx, model_name: str, price_per_call: int, description: str = ""):
"""Deploy the paid model. price_per_call is in integer base units."""
if not model_name or not model_name.strip():
raise ValueError("model_name is required")
if price_per_call < 0:
raise ValueError("price_per_call must be non-negative")
self.state.creator = ctx.sender
self.state.model_name = model_name.strip()
self.state.model_description = description.strip()
self.state.price_per_call = price_per_call
self.emit(event.PaidModelDeployed(
creator=ctx.sender,
model_name=model_name.strip(),
price_per_call=price_per_call,
))
@call
def train(self, ctx, x: list, y: list):
"""Fit a LogisticRegression on (x, y). Creator only."""
if ctx.sender != self.state.creator:
raise ValueError("only the creator can train the model")
if not x or not y:
raise ValueError("training data cannot be empty")
if len(x) != len(y):
raise ValueError("x and y must have the same length")
from panda.ml import LogisticRegression
model = LogisticRegression()
model.fit(x, y)
self.state.model_dict = save_model(model)
self.state.is_trained = True
self.emit(event.ModelTrained(
trainer=ctx.sender,
samples=len(x),
model_type=self.state.model_dict.get("__panda_ml_model__", "unknown"),
))
@call
def predict(self, ctx, x: list, payment: int = 0) -> list:
"""Pay and get a prediction. Requires payment >= price_per_call."""
if not self.state.is_trained:
raise ValueError("model is not trained yet")
if not x:
raise ValueError("input data cannot be empty")
price = self.state.price_per_call
if payment < price:
raise ValueError(f"insufficient payment: sent {payment}, required {price}")
model = load_model(self.state.model_dict)
predictions = model.predict(x)
self.state.creator_balance = self.state.creator_balance + payment
self.state.total_revenue = self.state.total_revenue + payment
self.state.total_calls = self.state.total_calls + 1
user_calls = dict(self.state.user_calls)
user_calls[ctx.sender] = user_calls.get(ctx.sender, 0) + 1
self.state.user_calls = user_calls
user_spent = dict(self.state.user_spent)
user_spent[ctx.sender] = user_spent.get(ctx.sender, 0) + payment
self.state.user_spent = user_spent
self.emit(event.InferencePaid(
caller=ctx.sender,
payment=payment,
input_count=len(x),
))
return predictions
@call
def withdraw(self, ctx, amount: int = 0):
"""Deduct earnings from the recorded creator balance. Creator only.
amount=0 withdraws the full balance."""
if ctx.sender != self.state.creator:
raise ValueError("only the creator can withdraw")
balance = self.state.creator_balance
if amount == 0:
amount = balance
if amount <= 0:
raise ValueError("nothing to withdraw")
if amount > balance:
raise ValueError(f"insufficient balance: requested {amount}, available {balance}")
self.state.creator_balance = balance - amount
self.emit(event.Withdrawal(
creator=ctx.sender,
amount=amount,
remaining=balance - amount,
))
@call
def set_price(self, ctx, new_price: int):
"""Update the per-call price. Creator only."""
if ctx.sender != self.state.creator:
raise ValueError("only the creator can set the price")
if new_price < 0:
raise ValueError("price must be non-negative")
old_price = self.state.price_per_call
self.state.price_per_call = new_price
self.emit(event.PriceChanged(
creator=ctx.sender,
old_price=old_price,
new_price=new_price,
))
@query
def get_price(self) -> int:
return self.state.price_per_call
@query
def get_stats(self) -> dict:
return {
"model_name": self.state.model_name,
"creator": self.state.creator,
"is_trained": self.state.is_trained,
"price_per_call": self.state.price_per_call,
"total_calls": self.state.total_calls,
"total_revenue": self.state.total_revenue,
"creator_balance": self.state.creator_balance,
"unique_users": len(self.state.user_calls),
}
The full source also includes upload_model, get_user_stats, get_model_info, and the SDK metadata surface described below.
Methods
State-changing calls (@call)
| Method | Signature | Access | Notes |
|---|---|---|---|
train | train(ctx, x, y) | Creator only | Fits a LogisticRegression on (x, y). |
upload_model | upload_model(ctx, model_dict) | Creator only | Loads a pre-trained model from panda.ml.save_model(); must contain the __panda_ml_model__ key. |
predict | predict(ctx, x, payment=0) -> list | Anyone | Requires payment >= price_per_call. payment is an explicit argument. Returns predictions. |
withdraw | withdraw(ctx, amount=0) | Creator only | Deducts from creator_balance (bookkeeping only). amount=0 withdraws the full balance. |
set_price | set_price(ctx, new_price) | Creator only | Updates the per-call price. |
set_accuracy | set_accuracy(ctx, accuracy_bps) | Creator only | Records measured accuracy in basis points (0–10000). |
pay_rent | pay_rent(ctx, amount) | Anyone | Buys amount blocks of rent, extending rent_paid_through_block. |
fund | fund(ctx, amount) | Anyone | Adds prepaid inference credits. |
Read-only queries (@query)
| Method | Returns | Notes |
|---|---|---|
get_price() | int | Current per-call price. |
get_stats() | dict | Keys: model_name, creator, is_trained, price_per_call, total_calls, total_revenue, creator_balance, unique_users (all snake_case). |
get_user_stats(user) | dict | Keys: call_count, total_spent for the given address. |
get_model_info() | dict | Keys: model_name, description, model_type, creator, is_trained, price_per_call. |
metadata() | dict | Canonical metadata read by the panda.model SDK (kind="paid", accuracy_bps, version, ready, plus rent fields). |
rent_status() | dict | Keys: paid_through_block, current_block, blocks_remaining, status. |
get_balance() | dict | {"balance": <prepaid credits>}. |
How Pricing Works
- The creator sets
price_per_callat deploy time (or later viaset_price). - Every
predict()call must includepayment >= price_per_call. Payment is passed as an explicit method argument, not as a transaction value. - The full
payment(including any overpayment) is credited tocreator_balanceandtotal_revenue, and counted in per-user stats. - A model can be free by deploying with
price_per_call=0;predict(..., payment=0)then succeeds.
Prices and balances are integer base units throughout. There are no floating-point amounts in the contract.
Withdrawing Earnings
withdraw(ctx, amount=0) is creator-only and updates accounting state: it deducts amount (or the entire creator_balance when amount=0) from creator_balance and emits a Withdrawal event with the remaining balance.
Caveat:
withdrawis bookkeeping only. It records the deduction increator_balancebut does not transfer native funds. Settling the recorded balance to an external account is handled outside this contract.
Events
The contract emits the following events:
| Event | Fields |
|---|---|
PaidModelDeployed | creator, model_name, price_per_call |
ModelTrained | trainer, samples, model_type |
ModelUploaded | uploader, model_type |
InferencePaid | caller, payment, input_count |
Withdrawal | creator, amount, remaining |
PriceChanged | creator, old_price, new_price |
Using from the SDK
The Python client ships as panda-sdk-client and exposes PandaProvider.
pip install panda-sdk-client
Deploy a PaidModel
from panda_client import PandaProvider
provider = PandaProvider("http://localhost:8545")
provider.discover_sender() # use the first unlocked account on the node
dep = provider.deploy_file(
"contracts/marketplace/paid_model.py",
constructor_args={
"model_name": "FraudDetector",
"price_per_call": 100,
"description": "Detects fraudulent transactions",
},
)
print(f"Deployed at: {dep.contract_address}")
deploy returns a DeployResult with contract_address, tx_hash, and gas_used.
Train the Model
provider.call(
dep.contract_address,
"train",
args={
"x": [[1, 1], [2, 2], [10, 10], [11, 11]],
"y": [0, 0, 1, 1],
},
)
Run a Prediction
result = provider.call(
dep.contract_address,
"predict",
args={"x": [[10, 10]], "payment": 100}, # payment goes in args, not value=
sender="0xCaller",
)
args is a dict, and payment is passed inside it. call returns a CallResult with tx_hash, gas_used, status, and logs.
Check Stats
stats = provider.query(dep.contract_address, "get_stats")
print(f"Total calls: {stats['total_calls']}")
print(f"Revenue: {stats['total_revenue']}")
print(f"Balance: {stats['creator_balance']}")
print(f"Unique users: {stats['unique_users']}")
query returns the method's return value directly (here, the get_stats dict).
Related Contracts
PaidModel is the building block; the marketplace pairs it with these contracts:
model_registry.py— A discovery registry. Creators register their deployed models (register_model) so others cansearch_models,list_models, and rate them.streaming_inference_coordinator.py+shard_replica_set.py— Sharded inference. The coordinator splits a large model across shards and dispatches layer-by-layer inference jobs, while eachShardReplicaSetmanages a pool of replicas for one shard and selects healthy replicas to serve requests.