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

  1. Deploy a PaidModel contract with a model name and a per-call price.
  2. Train the model on-chain (or upload a pre-trained panda.ml model).
  3. Callers pay at least price_per_call to submit features and receive predictions.
  4. Revenue accrues to the creator's balance and per-user stats are recorded.
  5. 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)

MethodSignatureAccessNotes
traintrain(ctx, x, y)Creator onlyFits a LogisticRegression on (x, y).
upload_modelupload_model(ctx, model_dict)Creator onlyLoads a pre-trained model from panda.ml.save_model(); must contain the __panda_ml_model__ key.
predictpredict(ctx, x, payment=0) -> listAnyoneRequires payment >= price_per_call. payment is an explicit argument. Returns predictions.
withdrawwithdraw(ctx, amount=0)Creator onlyDeducts from creator_balance (bookkeeping only). amount=0 withdraws the full balance.
set_priceset_price(ctx, new_price)Creator onlyUpdates the per-call price.
set_accuracyset_accuracy(ctx, accuracy_bps)Creator onlyRecords measured accuracy in basis points (0–10000).
pay_rentpay_rent(ctx, amount)AnyoneBuys amount blocks of rent, extending rent_paid_through_block.
fundfund(ctx, amount)AnyoneAdds prepaid inference credits.

Read-only queries (@query)

MethodReturnsNotes
get_price()intCurrent per-call price.
get_stats()dictKeys: model_name, creator, is_trained, price_per_call, total_calls, total_revenue, creator_balance, unique_users (all snake_case).
get_user_stats(user)dictKeys: call_count, total_spent for the given address.
get_model_info()dictKeys: model_name, description, model_type, creator, is_trained, price_per_call.
metadata()dictCanonical metadata read by the panda.model SDK (kind="paid", accuracy_bps, version, ready, plus rent fields).
rent_status()dictKeys: paid_through_block, current_block, blocks_remaining, status.
get_balance()dict{"balance": <prepaid credits>}.

How Pricing Works

  1. The creator sets price_per_call at deploy time (or later via set_price).
  2. Every predict() call must include payment >= price_per_call. Payment is passed as an explicit method argument, not as a transaction value.
  3. The full payment (including any overpayment) is credited to creator_balance and total_revenue, and counted in per-user stats.
  4. 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: withdraw is bookkeeping only. It records the deduction in creator_balance but 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:

EventFields
PaidModelDeployedcreator, model_name, price_per_call
ModelTrainedtrainer, samples, model_type
ModelUploadeduploader, model_type
InferencePaidcaller, payment, input_count
Withdrawalcreator, amount, remaining
PriceChangedcreator, 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 can search_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 each ShardReplicaSet manages a pool of replicas for one shard and selects healthy replicas to serve requests.