514 lines
19 KiB
Python
514 lines
19 KiB
Python
"""Solana contract boundary for the Distributed Inference Network.
|
|
|
|
The prototype uses deterministic local state with Solana-shaped wrapper classes.
|
|
ADR-0007 keeps the public Python boundary stable so real Solana programs can
|
|
replace this adapter later without changing gateway or tracker call sites.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
import json
|
|
import urllib.request
|
|
|
|
__version__ = "0.1.0"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RegistryWallet:
|
|
"""Stake, strike, and ban state for a node operator wallet."""
|
|
|
|
stake_balance: int = 0
|
|
strike_count: int = 0
|
|
banned: bool = False
|
|
completed_job_count: int = 0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ApiKeyBalance:
|
|
"""Client API key payment account balance."""
|
|
|
|
lamports: int = 0
|
|
usdc_micro: int = 0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ComputeAttribution:
|
|
"""On-chain work attribution recorded by the gateway after inference."""
|
|
|
|
session_id: str
|
|
api_key: str
|
|
node_wallet: str
|
|
layer_start: int
|
|
layer_end: int
|
|
tokens: int
|
|
speed_score: float = 1.0
|
|
settled_epoch: int | None = None
|
|
|
|
@property
|
|
def layer_count(self) -> int:
|
|
return self.layer_end - self.layer_start + 1
|
|
|
|
@property
|
|
def work_units(self) -> int:
|
|
return self.layer_count * self.tokens
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ValidationEvent:
|
|
"""Completed inference data consumed by fraud validators."""
|
|
|
|
index: int
|
|
session_id: str
|
|
model: str
|
|
messages: list[dict]
|
|
observed_output: str
|
|
route_nodes: list[dict]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SlashReceipt:
|
|
"""Local slash transaction receipt."""
|
|
|
|
signature: str
|
|
cluster: str
|
|
wallet_address: str
|
|
slash_amount: int
|
|
stake_before: int
|
|
stake_after: int
|
|
strike_count: int
|
|
banned: bool
|
|
reason: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SettlementResult:
|
|
"""Summary returned by an epoch settlement transaction."""
|
|
|
|
epoch: int
|
|
client_debits: dict[str, int]
|
|
node_rewards: dict[str, int]
|
|
validator_rewards: dict[str, int]
|
|
|
|
|
|
@dataclass
|
|
class _LocalContractState:
|
|
registry: dict[str, RegistryWallet] = field(default_factory=dict)
|
|
api_keys: dict[str, ApiKeyBalance] = field(default_factory=dict)
|
|
attributions: list[ComputeAttribution] = field(default_factory=list)
|
|
validation_events: list[ValidationEvent] = field(default_factory=list)
|
|
slash_receipts: list[SlashReceipt] = field(default_factory=list)
|
|
token_balances: dict[str, int] = field(default_factory=dict)
|
|
probationary_job_count: int = 50
|
|
|
|
|
|
class RegistryContract:
|
|
"""Registry wrapper for node stake, strikes, and bans."""
|
|
|
|
def __init__(self, state: _LocalContractState, cluster: str) -> None:
|
|
self._state = state
|
|
self._cluster = cluster
|
|
|
|
def submit_stake(self, wallet_address: str, amount: int) -> dict:
|
|
if amount <= 0:
|
|
raise ValueError("stake amount must be positive")
|
|
current = self.get_wallet(wallet_address)
|
|
self._state.registry[wallet_address] = RegistryWallet(
|
|
stake_balance=current.stake_balance + amount,
|
|
strike_count=current.strike_count,
|
|
banned=current.banned,
|
|
completed_job_count=current.completed_job_count,
|
|
)
|
|
return {"signature": f"local-stake-{wallet_address}", "cluster": self._cluster}
|
|
|
|
def get_wallet(self, wallet_address: str) -> RegistryWallet:
|
|
return self._state.registry.get(wallet_address, RegistryWallet())
|
|
|
|
def record_strike(self, wallet_address: str, ban_threshold: int = 3) -> dict:
|
|
current = self.get_wallet(wallet_address)
|
|
strike_count = current.strike_count + 1
|
|
self._state.registry[wallet_address] = RegistryWallet(
|
|
stake_balance=current.stake_balance,
|
|
strike_count=strike_count,
|
|
banned=current.banned or strike_count >= ban_threshold,
|
|
completed_job_count=current.completed_job_count,
|
|
)
|
|
return {"signature": f"local-strike-{wallet_address}", "cluster": self._cluster}
|
|
|
|
def submit_slash_proof(
|
|
self,
|
|
*,
|
|
wallet_address: str,
|
|
slash_amount: int,
|
|
strike_threshold: int = 3,
|
|
reason: str = "fraudulent inference output",
|
|
webhook_url: str | None = None,
|
|
) -> SlashReceipt:
|
|
if not wallet_address:
|
|
raise ValueError("wallet_address is required")
|
|
if slash_amount <= 0:
|
|
raise ValueError("slash_amount must be positive")
|
|
if strike_threshold <= 0:
|
|
raise ValueError("strike_threshold must be positive")
|
|
|
|
current = self.get_wallet(wallet_address)
|
|
stake_after = max(0, current.stake_balance - slash_amount)
|
|
strike_count = current.strike_count + 1
|
|
banned = current.banned or strike_count >= strike_threshold
|
|
self._state.registry[wallet_address] = RegistryWallet(
|
|
stake_balance=stake_after,
|
|
strike_count=strike_count,
|
|
banned=banned,
|
|
completed_job_count=current.completed_job_count,
|
|
)
|
|
receipt = SlashReceipt(
|
|
signature=f"local-slash-{wallet_address}-{len(self._state.slash_receipts) + 1}",
|
|
cluster=self._cluster,
|
|
wallet_address=wallet_address,
|
|
slash_amount=slash_amount,
|
|
stake_before=current.stake_balance,
|
|
stake_after=stake_after,
|
|
strike_count=strike_count,
|
|
banned=banned,
|
|
reason=reason,
|
|
)
|
|
self._state.slash_receipts.append(receipt)
|
|
_notify_slash(receipt, webhook_url)
|
|
return receipt
|
|
|
|
def ban_wallet(self, wallet_address: str) -> dict:
|
|
current = self.get_wallet(wallet_address)
|
|
self._state.registry[wallet_address] = RegistryWallet(
|
|
stake_balance=current.stake_balance,
|
|
strike_count=current.strike_count,
|
|
banned=True,
|
|
completed_job_count=current.completed_job_count,
|
|
)
|
|
return {"signature": f"local-ban-{wallet_address}", "cluster": self._cluster}
|
|
|
|
def has_minimum_stake(self, wallet_address: str | None, minimum_stake: int) -> bool:
|
|
if not wallet_address:
|
|
return False
|
|
wallet = self.get_wallet(wallet_address)
|
|
return wallet.stake_balance >= minimum_stake
|
|
|
|
def list_wallets(self) -> dict[str, RegistryWallet]:
|
|
"""Snapshot of all known wallets (dashboard / monitoring)."""
|
|
return dict(self._state.registry)
|
|
|
|
def record_completed_job(self, wallet_address: str) -> RegistryWallet:
|
|
current = self.get_wallet(wallet_address)
|
|
updated = RegistryWallet(
|
|
stake_balance=current.stake_balance,
|
|
strike_count=current.strike_count,
|
|
banned=current.banned,
|
|
completed_job_count=current.completed_job_count + 1,
|
|
)
|
|
self._state.registry[wallet_address] = updated
|
|
return updated
|
|
|
|
def probationary_jobs_remaining(self, wallet_address: str) -> int:
|
|
wallet = self.get_wallet(wallet_address)
|
|
return max(0, self._state.probationary_job_count - wallet.completed_job_count)
|
|
|
|
|
|
class ValidationContract:
|
|
"""Validation event log consumed by the optimistic fraud detector."""
|
|
|
|
def __init__(self, state: _LocalContractState) -> None:
|
|
self._state = state
|
|
|
|
def record_completed_inference(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
model: str,
|
|
messages: list[dict],
|
|
observed_output: str,
|
|
route_nodes: list[dict],
|
|
) -> ValidationEvent:
|
|
if not session_id:
|
|
raise ValueError("session_id is required")
|
|
if not model:
|
|
raise ValueError("model is required")
|
|
if not isinstance(messages, list):
|
|
raise ValueError("messages must be a list")
|
|
if not isinstance(observed_output, str):
|
|
raise ValueError("observed_output must be a string")
|
|
if not isinstance(route_nodes, list):
|
|
raise ValueError("route_nodes must be a list")
|
|
event = ValidationEvent(
|
|
index=len(self._state.validation_events),
|
|
session_id=session_id,
|
|
model=model,
|
|
messages=[dict(message) for message in messages],
|
|
observed_output=observed_output,
|
|
route_nodes=[dict(node) for node in route_nodes],
|
|
)
|
|
self._state.validation_events.append(event)
|
|
return event
|
|
|
|
def list_completed_inferences(self, *, after_index: int = -1) -> list[ValidationEvent]:
|
|
return [
|
|
event for event in self._state.validation_events
|
|
if event.index > after_index
|
|
]
|
|
|
|
|
|
class PaymentContract:
|
|
"""Payment wrapper for funded API keys and compute attribution."""
|
|
|
|
def __init__(self, state: _LocalContractState, cluster: str, starting_credit_lamports: int) -> None:
|
|
self._state = state
|
|
self._cluster = cluster
|
|
self._starting_credit_lamports = starting_credit_lamports
|
|
|
|
def fund_api_key(
|
|
self,
|
|
api_key: str,
|
|
*,
|
|
lamports: int = 0,
|
|
usdc_micro: int = 0,
|
|
) -> dict:
|
|
if lamports < 0 or usdc_micro < 0:
|
|
raise ValueError("funding amounts must be non-negative")
|
|
if lamports == 0 and usdc_micro == 0:
|
|
raise ValueError("funding amount must be positive")
|
|
current = self.get_balance(api_key)
|
|
self._state.api_keys[api_key] = ApiKeyBalance(
|
|
lamports=current.lamports + lamports,
|
|
usdc_micro=current.usdc_micro + usdc_micro,
|
|
)
|
|
return {"signature": f"local-fund-{api_key}", "cluster": self._cluster}
|
|
|
|
def get_balance(self, api_key: str) -> ApiKeyBalance:
|
|
return self._state.api_keys.get(
|
|
api_key,
|
|
ApiKeyBalance(lamports=self._starting_credit_lamports),
|
|
)
|
|
|
|
def record_attribution(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
api_key: str,
|
|
node_wallet: str,
|
|
layer_start: int,
|
|
layer_end: int,
|
|
tokens: int,
|
|
speed_score: float = 1.0,
|
|
) -> dict:
|
|
if not api_key:
|
|
raise ValueError("api_key is required")
|
|
if not node_wallet:
|
|
raise ValueError("node_wallet is required")
|
|
if layer_start < 0 or layer_end < layer_start:
|
|
raise ValueError("layer range must be non-negative and ordered")
|
|
if tokens <= 0:
|
|
raise ValueError("tokens must be positive")
|
|
if speed_score <= 0:
|
|
raise ValueError("speed_score must be positive")
|
|
self._state.attributions.append(ComputeAttribution(
|
|
session_id=session_id,
|
|
api_key=api_key,
|
|
node_wallet=node_wallet,
|
|
layer_start=layer_start,
|
|
layer_end=layer_end,
|
|
tokens=tokens,
|
|
speed_score=speed_score,
|
|
))
|
|
return {"signature": f"local-attribution-{session_id}", "cluster": self._cluster}
|
|
|
|
def list_attributions(self, *, include_settled: bool = True) -> list[ComputeAttribution]:
|
|
if include_settled:
|
|
return list(self._state.attributions)
|
|
return [a for a in self._state.attributions if a.settled_epoch is None]
|
|
|
|
|
|
class SettlementContract:
|
|
"""Settlement wrapper that debits clients and credits token rewards."""
|
|
|
|
def __init__(
|
|
self,
|
|
state: _LocalContractState,
|
|
cluster: str,
|
|
cost_per_layer_token_lamport: int,
|
|
) -> None:
|
|
self._state = state
|
|
self._cluster = cluster
|
|
self._cost_per_layer_token_lamport = cost_per_layer_token_lamport
|
|
|
|
def settle_epoch(
|
|
self,
|
|
*,
|
|
epoch: int,
|
|
validator_wallet: str | None = None,
|
|
validator_reward_share_bps: int = 0,
|
|
) -> SettlementResult:
|
|
if epoch < 0:
|
|
raise ValueError("epoch must be non-negative")
|
|
if not 0 <= validator_reward_share_bps <= 10_000:
|
|
raise ValueError("validator_reward_share_bps must be between 0 and 10000")
|
|
unsettled = [a for a in self._state.attributions if a.settled_epoch is None]
|
|
|
|
client_debits: dict[str, int] = {}
|
|
eligible_work_by_node: dict[str, int] = {}
|
|
node_rewards: dict[str, int] = {}
|
|
eligible_raw_work = 0
|
|
completed_jobs_to_record: dict[str, int] = {}
|
|
for attribution in unsettled:
|
|
debit = attribution.work_units * self._cost_per_layer_token_lamport
|
|
client_debits[attribution.api_key] = client_debits.get(attribution.api_key, 0) + debit
|
|
wallet = self._state.registry.get(attribution.node_wallet, RegistryWallet())
|
|
completed_before_job = wallet.completed_job_count + completed_jobs_to_record.get(attribution.node_wallet, 0)
|
|
if completed_before_job >= self._state.probationary_job_count:
|
|
eligible_raw_work += attribution.work_units
|
|
weighted_work = int(attribution.work_units * attribution.speed_score)
|
|
eligible_work_by_node[attribution.node_wallet] = (
|
|
eligible_work_by_node.get(attribution.node_wallet, 0) + weighted_work
|
|
)
|
|
node_rewards.setdefault(attribution.node_wallet, 0)
|
|
completed_jobs_to_record[attribution.node_wallet] = completed_jobs_to_record.get(attribution.node_wallet, 0) + 1
|
|
|
|
for api_key, debit in client_debits.items():
|
|
current = self._state.api_keys.get(api_key, ApiKeyBalance())
|
|
if current.lamports < debit:
|
|
raise ValueError(f"insufficient SOL balance for API key: {api_key}")
|
|
self._state.api_keys[api_key] = ApiKeyBalance(
|
|
lamports=current.lamports - debit,
|
|
usdc_micro=current.usdc_micro,
|
|
)
|
|
|
|
for wallet_address, count in completed_jobs_to_record.items():
|
|
self._record_completed_jobs(wallet_address, count)
|
|
|
|
total_weighted_work = sum(eligible_work_by_node.values())
|
|
raw_total_work = sum(a.work_units for a in unsettled)
|
|
validator_rewards: dict[str, int] = {}
|
|
validator_reward_total = (raw_total_work * validator_reward_share_bps) // 10_000
|
|
if validator_wallet and validator_reward_total:
|
|
validator_rewards[validator_wallet] = validator_reward_total
|
|
|
|
node_reward_pool = max(0, eligible_raw_work - validator_reward_total)
|
|
for node_wallet, weighted_work_units in eligible_work_by_node.items():
|
|
reward = 0 if total_weighted_work == 0 else (node_reward_pool * weighted_work_units) // total_weighted_work
|
|
node_rewards[node_wallet] = reward
|
|
self._state.token_balances[node_wallet] = (
|
|
self._state.token_balances.get(node_wallet, 0) + reward
|
|
)
|
|
|
|
for wallet, reward in validator_rewards.items():
|
|
self._state.token_balances[wallet] = self._state.token_balances.get(wallet, 0) + reward
|
|
|
|
self._state.attributions = [
|
|
ComputeAttribution(
|
|
session_id=a.session_id,
|
|
api_key=a.api_key,
|
|
node_wallet=a.node_wallet,
|
|
layer_start=a.layer_start,
|
|
layer_end=a.layer_end,
|
|
tokens=a.tokens,
|
|
speed_score=a.speed_score,
|
|
settled_epoch=epoch,
|
|
)
|
|
if a.settled_epoch is None else a
|
|
for a in self._state.attributions
|
|
]
|
|
return SettlementResult(
|
|
epoch=epoch,
|
|
client_debits=client_debits,
|
|
node_rewards=node_rewards,
|
|
validator_rewards=validator_rewards,
|
|
)
|
|
|
|
def get_token_balance(self, wallet_address: str) -> int:
|
|
return self._state.token_balances.get(wallet_address, 0)
|
|
|
|
def _record_completed_jobs(self, wallet_address: str, count: int) -> None:
|
|
current = self._state.registry.get(wallet_address, RegistryWallet())
|
|
self._state.registry[wallet_address] = RegistryWallet(
|
|
stake_balance=current.stake_balance,
|
|
strike_count=current.strike_count,
|
|
banned=current.banned,
|
|
completed_job_count=current.completed_job_count + count,
|
|
)
|
|
|
|
def deployment_plan(self) -> dict:
|
|
"""Return the configured manual testnet deployment targets."""
|
|
return {
|
|
"cluster": self._cluster,
|
|
"programs": ["registry", "payment", "settlement"],
|
|
}
|
|
|
|
|
|
class LocalSolanaContracts:
|
|
"""Facade that exposes all three contract wrappers over local validator state."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
cluster: str = "local-test-validator",
|
|
cost_per_layer_token_lamport: int = 1,
|
|
starting_credit_lamports: int = 1_000,
|
|
probationary_job_count: int = 50,
|
|
) -> None:
|
|
if cost_per_layer_token_lamport <= 0:
|
|
raise ValueError("cost_per_layer_token_lamport must be positive")
|
|
if starting_credit_lamports < 0:
|
|
raise ValueError("starting_credit_lamports must be non-negative")
|
|
if probationary_job_count < 0:
|
|
raise ValueError("probationary_job_count must be non-negative")
|
|
self.cluster = cluster
|
|
self._state = _LocalContractState()
|
|
self._state.probationary_job_count = probationary_job_count
|
|
self.registry = RegistryContract(self._state, cluster)
|
|
self.validation = ValidationContract(self._state)
|
|
self.payment = PaymentContract(self._state, cluster, starting_credit_lamports)
|
|
self.settlement = SettlementContract(
|
|
self._state,
|
|
cluster,
|
|
cost_per_layer_token_lamport,
|
|
)
|
|
|
|
|
|
def _notify_slash(receipt: SlashReceipt, webhook_url: str | None) -> None:
|
|
message = (
|
|
f"WARNING: node {receipt.wallet_address} slashed "
|
|
f"by {receipt.slash_amount} lamports; strikes={receipt.strike_count}; "
|
|
f"banned={receipt.banned}; reason={receipt.reason}"
|
|
)
|
|
print(message, flush=True)
|
|
if not webhook_url:
|
|
return
|
|
payload = json.dumps({
|
|
"event": "node_slashed",
|
|
"wallet_address": receipt.wallet_address,
|
|
"slash_amount": receipt.slash_amount,
|
|
"strike_count": receipt.strike_count,
|
|
"banned": receipt.banned,
|
|
"reason": receipt.reason,
|
|
}).encode()
|
|
req = urllib.request.Request(
|
|
webhook_url,
|
|
data=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
urllib.request.urlopen(req, timeout=2).read()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
__all__ = [
|
|
"ApiKeyBalance",
|
|
"ComputeAttribution",
|
|
"LocalSolanaContracts",
|
|
"PaymentContract",
|
|
"RegistryContract",
|
|
"RegistryWallet",
|
|
"SettlementContract",
|
|
"SettlementResult",
|
|
"SlashReceipt",
|
|
"ValidationContract",
|
|
"ValidationEvent",
|
|
]
|