Docs

Plugin System

Add any capability to your Sertone control center — rate limiting, analytics, SLA bonds, custom fees, compliance filters — without touching the core. Write in any language. Deploy as a sidecar. Zero lock-in.

How it works

A plugin is any HTTP service that implements three endpoints. You register its URL in your control center panel. The control center calls your plugin on each request lifecycle event.

Consumer call arrives ↓ [pre-request hook] ← your plugin decides: allow / block / charge extra fee ↓ Request forwarded to provider ↓ [post-response hook] ← your plugin receives latency + status code ↓ Settlement executes on-chain ↓ Response returned to consumer
Plugin errors never block requests. Every hook call has a 2-second timeout. If your plugin is down, slow, or returns an error, the request proceeds as if no plugin were installed. Design for failure — your control center will not wait for you.

Plugins can be installed by the provider (to control who calls their services), by the consumer (to control which services they call), or both. A plugin can charge an additional fee on each request — that fee flows directly between the control centers, independent of the main 5% IP royalty settlement.

Hook API Reference

GET /sertone/info

Called when the plugin is first registered and periodically to verify it is alive. Returns plugin metadata.

FieldTypeDescription
namestringHuman-readable plugin name
versionstringSemantic version, e.g. "1.0.0"
descriptionstringOne-sentence description
authorstringPlugin author (optional, can be anonymous)
fee_model"provider" | "consumer" | "none"Who pays the plugin fee
{
  "name": "My Rate Limiter",
  "version": "1.0.0",
  "description": "Limits each consumer to 100 requests per hour",
  "author": "anonymous",
  "fee_model": "none"
}

POST /sertone/pre-request

Called before every request is forwarded. Your plugin decides whether to allow it and optionally adds a fee.

Request body:

FieldTypeDescription
request_idstringUnique ID for this request (use for deduplication)
api_idstringPublic ID of the API being called
consumer_wallet_hashstringSHA-256 of the consumer's wallet address (privacy-preserving identifier — same consumer always has same hash)
endpoint_pathstringThe specific endpoint being called, e.g. /v1/data
timestampnumberUnix timestamp in milliseconds

Response:

FieldTypeRequiredDescription
allowbooleanYesIf false, the request is rejected with HTTP 402
reasonstringNoReason shown to consumer when allow is false
additional_fee_usdcstringNoExtra USDC fee as a decimal string, e.g. "0.00010"
fee_destinationstringIf fee > 0EVM wallet address to receive the fee
// Allow with no fee
{ "allow": true }

// Block with reason
{ "allow": false, "reason": "Rate limit exceeded. Resets in 42 seconds." }

// Allow and charge 0.0001 USDC
{
  "allow": true,
  "additional_fee_usdc": "0.00010",
  "fee_destination": "0xYourPluginWalletAddress"
}

POST /sertone/post-response

Called after every response is received. Use this to collect quality signals, update analytics, or trigger alerts. Your response is advisory — it does not block or modify the response.

Request body:

FieldTypeDescription
request_idstringSame ID from pre-request
api_idstringPublic API ID
latency_msnumberRound-trip latency in milliseconds
status_codenumberHTTP status code returned by the provider
settled_usdcstringAmount settled to provider, e.g. "0.00095"
timestampnumberUnix timestamp in milliseconds

Response:

FieldTypeDescription
quality_signal-1 | 0 | 1Your quality assessment: bad / neutral / good. Fed into the catalog quality score for this API.
{ "quality_signal": 1 }   // good
{ "quality_signal": 0 }   // neutral
{ "quality_signal": -1 }  // bad (e.g. latency > 2000ms or status 5xx)

Quickstart

The fastest path to a working plugin is three steps:

1. Write the plugin

Copy any of the examples below. The minimum viable plugin is about 40 lines of code in any language.

2. Run it as a sidecar

Run your plugin on the same host as your control center. The control center calls it on localhost:PORT — it never needs to be publicly accessible.

# docker-compose.yml — add your plugin as a sidecar
services:
  sertone:
    image: xbtest33/demo22:latest
    # ... your existing sertone config ...

  my-plugin:
    image: my-plugin:latest
    ports:
      - "127.0.0.1:4001:4001"  # only localhost, not public
    restart: unless-stopped

3. Register in your control center

Go to Settings → Plugins in your admin panel. Enter the plugin URL: http://localhost:4001. The control center pings /sertone/info to verify the connection, then activates all hooks.

No restart required. Plugin changes take effect immediately. You can enable, disable, or swap plugins without restarting your control center.

Node.js Examples

Hello World (Node.js)

The minimal plugin. Allows all requests, reports all responses as good quality. No dependencies beyond Node.js built-ins.

// sertone-plugin-hello — minimal Node.js plugin
// No dependencies. Allows all requests, good quality on all responses.
// Run: node server.js

const http = require('http');

const PORT = process.env.PORT || 4001;

const routes = {
  'GET /sertone/info': (req, res) => {
    res.json({
      name: 'Hello World Plugin',
      version: '1.0.0',
      description: 'Allows all requests and reports all responses as good quality.',
      author: 'anonymous',
      fee_model: 'none',
    });
  },

  'POST /sertone/pre-request': (req, res) => {
    // Read and discard body (required to keep the connection clean)
    req.resume();
    req.on('end', () => res.json({ allow: true }));
  },

  'POST /sertone/post-response': (req, res) => {
    req.resume();
    req.on('end', () => res.json({ quality_signal: 1 }));
  },
};

// Minimal HTTP server with JSON helpers
const server = http.createServer((req, res) => {
  const key = `${req.method} ${req.url}`;
  res.json = (data) => {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(data));
  };
  if (routes[key]) {
    routes[key](req, res);
  } else {
    res.writeHead(404);
    res.end('Not found');
  }
});

server.listen(PORT, '0.0.0.0', () => {
  console.log(`Hello World plugin running on port ${PORT}`);
});
FROM node:22-alpine
WORKDIR /app
COPY server.js .
EXPOSE 4001
CMD ["node", "server.js"]
services:
  sertone-plugin-hello:
    build: .
    ports:
      - "127.0.0.1:4001:4001"
    restart: unless-stopped
    environment:
      - PORT=4001

Rate Limiter (Node.js)

Limits each consumer to a configurable number of requests per time window. Tracks consumers by their wallet hash. No database required — in-memory sliding window.

// sertone-plugin-ratelimiter — sliding window rate limiter
// No dependencies. Limits each consumer by wallet hash.
// Config via environment variables.

const http = require('http');

const PORT          = parseInt(process.env.PORT || '4001');
const MAX_REQUESTS  = parseInt(process.env.MAX_REQUESTS || '100');   // per window
const WINDOW_MS     = parseInt(process.env.WINDOW_MS || '3600000'); // 1 hour default

// Map: consumerHash -> array of request timestamps
const windows = new Map();

function getWindowCount(hash) {
  const now = Date.now();
  const cutoff = now - WINDOW_MS;
  const times = (windows.get(hash) || []).filter(t => t > cutoff);
  times.push(now);
  windows.set(hash, times);
  return times.length; // includes the current request
}

function timeUntilReset(hash) {
  const times = windows.get(hash) || [];
  if (times.length === 0) return 0;
  const oldest = Math.min(...times);
  return Math.ceil((oldest + WINDOW_MS - Date.now()) / 1000);
}

// Cleanup old entries every 10 minutes
setInterval(() => {
  const cutoff = Date.now() - WINDOW_MS;
  for (const [hash, times] of windows) {
    const filtered = times.filter(t => t > cutoff);
    if (filtered.length === 0) windows.delete(hash);
    else windows.set(hash, filtered);
  }
}, 10 * 60 * 1000);

function readBody(req) {
  return new Promise((resolve) => {
    let data = '';
    req.on('data', chunk => data += chunk);
    req.on('end', () => {
      try { resolve(JSON.parse(data)); } catch { resolve({}); }
    });
  });
}

const server = http.createServer(async (req, res) => {
  res.json = (data, status = 200) => {
    res.writeHead(status, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(data));
  };

  if (req.method === 'GET' && req.url === '/sertone/info') {
    return res.json({
      name: 'Rate Limiter',
      version: '1.0.0',
      description: `Limits each consumer to ${MAX_REQUESTS} requests per ${WINDOW_MS / 60000} minutes.`,
      author: 'anonymous',
      fee_model: 'none',
    });
  }

  if (req.method === 'POST' && req.url === '/sertone/pre-request') {
    const body = await readBody(req);
    const hash = body.consumer_wallet_hash || 'unknown';
    const count = getWindowCount(hash);

    if (count > MAX_REQUESTS) {
      const resetIn = timeUntilReset(hash);
      return res.json({
        allow: false,
        reason: `Rate limit exceeded (${MAX_REQUESTS} requests per ${WINDOW_MS / 60000} min). Resets in ${resetIn}s.`,
      });
    }

    return res.json({ allow: true });
  }

  if (req.method === 'POST' && req.url === '/sertone/post-response') {
    const body = await readBody(req);
    const signal = body.status_code >= 500 ? -1 : (body.latency_ms > 3000 ? 0 : 1);
    return res.json({ quality_signal: signal });
  }

  res.writeHead(404);
  res.end('Not found');
});

server.listen(PORT, '0.0.0.0', () => {
  console.log(`Rate Limiter plugin: max=${MAX_REQUESTS} per ${WINDOW_MS / 60000}min on port ${PORT}`);
});
FROM node:22-alpine
WORKDIR /app
COPY server.js .
EXPOSE 4001
CMD ["node", "server.js"]
services:
  sertone-plugin-ratelimiter:
    build: .
    ports:
      - "127.0.0.1:4001:4001"
    restart: unless-stopped
    environment:
      - PORT=4001
      - MAX_REQUESTS=100    # requests per window
      - WINDOW_MS=3600000   # window size in ms (3600000 = 1 hour)

Python Examples

Hello World (Python)

Same minimal plugin in Python. Uses only the standard library — no pip install needed.

# sertone-plugin-hello — minimal Python plugin
# No dependencies. Standard library only.
# Run: python3 plugin.py

import os
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

PORT = int(os.environ.get('PORT', 4001))

class PluginHandler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        pass  # Silence default access log

    def _read_body(self):
        length = int(self.headers.get('Content-Length', 0))
        raw = self.rfile.read(length) if length > 0 else b'{}'
        try:
            return json.loads(raw)
        except Exception:
            return {}

    def _respond(self, data, status=200):
        body = json.dumps(data).encode()
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        if self.path == '/sertone/info':
            self._respond({
                'name': 'Hello World Plugin (Python)',
                'version': '1.0.0',
                'description': 'Allows all requests and reports good quality.',
                'author': 'anonymous',
                'fee_model': 'none',
            })
        else:
            self.send_error(404)

    def do_POST(self):
        self._read_body()  # consume body

        if self.path == '/sertone/pre-request':
            self._respond({'allow': True})
        elif self.path == '/sertone/post-response':
            self._respond({'quality_signal': 1})
        else:
            self.send_error(404)

if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', PORT), PluginHandler)
    print(f'Hello World plugin (Python) running on port {PORT}')
    server.serve_forever()
FROM python:3.12-alpine
WORKDIR /app
COPY plugin.py .
EXPOSE 4001
CMD ["python3", "plugin.py"]
services:
  sertone-plugin-hello-py:
    build: .
    ports:
      - "127.0.0.1:4001:4001"
    restart: unless-stopped
    environment:
      - PORT=4001

Request Logger (Python)

Logs every request and response to JSONL files on disk. Useful for debugging, auditing, and building your own analytics. Rotates log files daily.

# sertone-plugin-logger — JSONL request logger with daily rotation
# No dependencies. Standard library only.
# Logs to /app/logs/YYYY-MM-DD.jsonl (mount as a volume to persist)

import os
import json
import time
import threading
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path

PORT     = int(os.environ.get('PORT', 4001))
LOG_DIR  = Path(os.environ.get('LOG_DIR', '/app/logs'))
LOG_DIR.mkdir(parents=True, exist_ok=True)

_lock = threading.Lock()

def log_event(event_type, data):
    today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
    entry = {'_type': event_type, '_ts': int(time.time() * 1000), **data}
    with _lock:
        with open(LOG_DIR / f'{today}.jsonl', 'a') as f:
            f.write(json.dumps(entry) + '\n')

class PluginHandler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        pass

    def _read_body(self):
        length = int(self.headers.get('Content-Length', 0))
        raw = self.rfile.read(length) if length > 0 else b'{}'
        try:
            return json.loads(raw)
        except Exception:
            return {}

    def _respond(self, data, status=200):
        body = json.dumps(data).encode()
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        if self.path == '/sertone/info':
            logs = sorted(LOG_DIR.glob('*.jsonl'))
            self._respond({
                'name': 'Request Logger',
                'version': '1.0.0',
                'description': 'Logs all requests and responses to JSONL files.',
                'author': 'anonymous',
                'fee_model': 'none',
                'log_files': [f.name for f in logs[-7:]],
            })
        elif self.path.startswith('/logs/'):
            filename = self.path[6:]
            filepath = LOG_DIR / filename
            if filepath.exists() and filepath.parent == LOG_DIR:
                content = filepath.read_bytes()
                self.send_response(200)
                self.send_header('Content-Type', 'application/x-ndjson')
                self.send_header('Content-Length', str(len(content)))
                self.end_headers()
                self.wfile.write(content)
            else:
                self.send_error(404)
        else:
            self.send_error(404)

    def do_POST(self):
        body = self._read_body()

        if self.path == '/sertone/pre-request':
            log_event('request', body)
            self._respond({'allow': True})

        elif self.path == '/sertone/post-response':
            log_event('response', body)
            status = body.get('status_code', 200)
            latency = body.get('latency_ms', 0)
            signal = -1 if status >= 500 else (0 if latency > 3000 else 1)
            self._respond({'quality_signal': signal})

        else:
            self.send_error(404)

if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', PORT), PluginHandler)
    print(f'Logger plugin running on port {PORT}, logs in {LOG_DIR}')
    server.serve_forever()
FROM python:3.12-alpine
WORKDIR /app
COPY plugin.py .
RUN mkdir -p /app/logs
EXPOSE 4001
CMD ["python3", "plugin.py"]
services:
  sertone-plugin-logger:
    build: .
    ports:
      - "127.0.0.1:4001:4001"
    restart: unless-stopped
    volumes:
      - ./logs:/app/logs   # persists logs on the host
    environment:
      - PORT=4001
      - LOG_DIR=/app/logs

Go Examples

Hello World (Go)

Zero dependencies. Compiles to a single static binary under 6 MB. Ideal for resource-constrained environments like Raspberry Pi.

// sertone-plugin-hello — minimal Go plugin
// Zero dependencies. Single binary. ~5 MB.
// Build: go build -o plugin . && ./plugin

package main

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
)

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "4001"
	}

	http.HandleFunc("/sertone/info", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}
		respond(w, map[string]any{
			"name":        "Hello World Plugin (Go)",
			"version":     "1.0.0",
			"description": "Allows all requests and reports good quality.",
			"author":      "anonymous",
			"fee_model":   "none",
		})
	})

	http.HandleFunc("/sertone/pre-request", func(w http.ResponseWriter, r *http.Request) {
		io.Copy(io.Discard, r.Body) // consume body
		respond(w, map[string]any{"allow": true})
	})

	http.HandleFunc("/sertone/post-response", func(w http.ResponseWriter, r *http.Request) {
		io.Copy(io.Discard, r.Body)
		respond(w, map[string]any{"quality_signal": 1})
	})

	log.Printf("Hello World plugin (Go) running on port %s", port)
	log.Fatal(http.ListenAndServe("0.0.0.0:"+port, nil))
}

func respond(w http.ResponseWriter, data any) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(data)
}
module sertone-plugin-hello

go 1.22
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -ldflags="-s -w" -o plugin .

FROM scratch
COPY --from=builder /app/plugin /plugin
EXPOSE 4001
ENTRYPOINT ["/plugin"]
services:
  sertone-plugin-hello-go:
    build: .
    ports:
      - "127.0.0.1:4001:4001"
    restart: unless-stopped
    environment:
      - PORT=4001

Reference Plugins

These plugins are open-source, ready to run, and solve real problems. Each is published with full source code.

SLA Bond provider-side open source

Providers lock a USDC deposit as a public quality commitment. The bond is slashed and paid to consumers if the provider's quality score drops below a configurable threshold. Fully on-chain, no third-party custodian. Source published — run your own instance or use a community-operated one.

Provider installs plugin, deposits N USDC into the SLA contract ↓ Every response is scored: latency + status code ↓ If 7-day rolling quality score < threshold: consumer receives payout from bond ↓ Provider replenishes bond to continue offering services
// SLA Bond Plugin — provider-side quality commitment
// Tracks quality score per API over a rolling window.
// On threshold breach, signals consumers to claim from the bond contract.
// Deploy the SLABond.sol contract separately and set CONTRACT_ADDRESS.

const http = require('http');
const { ethers } = require('ethers');

const PORT             = parseInt(process.env.PORT || '4001');
const QUALITY_THRESHOLD = parseFloat(process.env.QUALITY_THRESHOLD || '80'); // 0-100
const WINDOW_HOURS     = parseInt(process.env.WINDOW_HOURS || '168');       // 7 days
const CONTRACT_ADDRESS = process.env.CONTRACT_ADDRESS || '';
const RPC_URL          = process.env.RPC_URL || 'https://mainnet.base.org';

// Rolling window quality store: api_id -> [{ts, signal}]
const signals = new Map();

function addSignal(apiId, signal) {
  const cutoff = Date.now() - WINDOW_HOURS * 3600 * 1000;
  const list = (signals.get(apiId) || []).filter(s => s.ts > cutoff);
  list.push({ ts: Date.now(), signal });
  signals.set(apiId, list);
  return list;
}

function qualityScore(apiId) {
  const list = signals.get(apiId) || [];
  if (list.length === 0) return 100;
  const sum = list.reduce((acc, s) => acc + (s.signal === 1 ? 100 : s.signal === 0 ? 50 : 0), 0);
  return sum / list.length;
}

function readBody(req) {
  return new Promise(resolve => {
    let data = '';
    req.on('data', c => data += c);
    req.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({}); }});
  });
}

const server = http.createServer(async (req, res) => {
  res.json = (data, status = 200) => {
    res.writeHead(status, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(data));
  };

  if (req.method === 'GET' && req.url === '/sertone/info') {
    return res.json({
      name: 'SLA Bond',
      version: '1.0.0',
      description: `Quality commitment bond. Threshold: ${QUALITY_THRESHOLD}. Window: ${WINDOW_HOURS}h.`,
      author: 'community',
      fee_model: 'provider',
      contract: CONTRACT_ADDRESS || 'not configured',
    });
  }

  if (req.method === 'GET' && req.url.startsWith('/quality/')) {
    const apiId = req.url.slice(9);
    return res.json({ api_id: apiId, quality: qualityScore(apiId), threshold: QUALITY_THRESHOLD });
  }

  if (req.method === 'POST' && req.url === '/sertone/pre-request') {
    const body = await readBody(req);
    // Check current quality — if critically low, pause the API
    const score = qualityScore(body.api_id);
    if (score < QUALITY_THRESHOLD * 0.5) {
      return res.json({
        allow: false,
        reason: `Provider quality score ${score.toFixed(0)}/100 is critically low. Service temporarily paused for consumer protection.`,
      });
    }
    return res.json({ allow: true });
  }

  if (req.method === 'POST' && req.url === '/sertone/post-response') {
    const body = await readBody(req);
    const status = body.status_code || 200;
    const latency = body.latency_ms || 0;
    let signal = 1;
    if (status >= 500) signal = -1;
    else if (status >= 400 || latency > 5000) signal = 0;
    else if (latency > 2000) signal = 0;

    const list = addSignal(body.api_id, signal);
    const score = qualityScore(body.api_id);

    // If quality drops below threshold, log for operator review
    if (score < QUALITY_THRESHOLD && list.length >= 10) {
      console.warn(`Quality alert: api=${body.api_id} score=${score.toFixed(1)} threshold=${QUALITY_THRESHOLD}`);
    }

    return res.json({ quality_signal: signal });
  }

  res.writeHead(404);
  res.end('Not found');
});

server.listen(PORT, '0.0.0.0', () => {
  console.log(`SLA Bond plugin running on port ${PORT}, threshold=${QUALITY_THRESHOLD}`);
});
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/// @title SLABond — on-chain quality commitment deposit
/// @notice Providers deposit USDC. If quality drops, consumers claim compensation.
///         No admin key. No upgradeable proxy. Fully autonomous.
contract SLABond {
    IERC20 public immutable usdc;

    struct Bond {
        uint256 amount;       // USDC deposited (6 decimals)
        uint256 depositedAt;
        bool active;
    }

    // provider wallet => api_id => bond
    mapping(address => mapping(bytes32 => Bond)) public bonds;

    // quality oracle: set by the plugin server calling this contract
    address public immutable oracle;

    event BondDeposited(address indexed provider, bytes32 indexed apiId, uint256 amount);
    event BondClaimed(address indexed consumer, bytes32 indexed apiId, uint256 amount);
    event BondWithdrawn(address indexed provider, bytes32 indexed apiId, uint256 amount);

    constructor(address _usdc, address _oracle) {
        usdc = IERC20(_usdc);
        oracle = _oracle;
    }

    /// @notice Provider deposits bond for a specific API
    function deposit(bytes32 apiId, uint256 amount) external {
        require(amount > 0, "Amount must be positive");
        usdc.transferFrom(msg.sender, address(this), amount);
        bonds[msg.sender][apiId].amount += amount;
        bonds[msg.sender][apiId].depositedAt = block.timestamp;
        bonds[msg.sender][apiId].active = true;
        emit BondDeposited(msg.sender, apiId, amount);
    }

    /// @notice Oracle (plugin server) triggers consumer compensation on SLA breach
    /// @dev Only the oracle address can call this. Oracle is the plugin server.
    function claimBreach(
        address provider,
        bytes32 apiId,
        address consumer,
        uint256 compensation
    ) external {
        require(msg.sender == oracle, "Only oracle");
        Bond storage b = bonds[provider][apiId];
        require(b.active, "No active bond");
        require(b.amount >= compensation, "Insufficient bond");
        b.amount -= compensation;
        if (b.amount == 0) b.active = false;
        usdc.transfer(consumer, compensation);
        emit BondClaimed(consumer, apiId, compensation);
    }

    /// @notice Provider withdraws remaining bond (voluntary exit)
    function withdraw(bytes32 apiId) external {
        Bond storage b = bonds[msg.sender][apiId];
        require(b.amount > 0, "Nothing to withdraw");
        uint256 amount = b.amount;
        b.amount = 0;
        b.active = false;
        usdc.transfer(msg.sender, amount);
        emit BondWithdrawn(msg.sender, apiId, amount);
    }

    /// @notice View bond for a provider + api
    function getBond(address provider, bytes32 apiId)
        external view returns (uint256 amount, bool active) {
        Bond storage b = bonds[provider][apiId];
        return (b.amount, b.active);
    }
}
services:
  sertone-plugin-sla-bond:
    build: .
    ports:
      - "127.0.0.1:4001:4001"
    restart: unless-stopped
    environment:
      - PORT=4001
      - QUALITY_THRESHOLD=80   # minimum acceptable quality score (0-100)
      - WINDOW_HOURS=168       # rolling window (168 = 7 days)
      - CONTRACT_ADDRESS=0x... # deployed SLABond.sol contract address
      - RPC_URL=https://mainnet.base.org
Geographic Firewall provider-side open source

Blocks or allows consumers from specific regions based on the mesh node origin. Useful for providers who need to comply with export controls or licensing restrictions. Because Sertone is anonymous by design, this operates on mesh topology signals, not IP addresses.

# Geographic Firewall Plugin — allowlist/denylist by wallet hash prefix
# In a fully anonymous network, "geography" is approximated by
# the wallet addresses registered in specific regional catalogs.
# Configure ALLOWED_PREFIXES (hex prefix of consumer wallet hash)
# or BLOCKED_PREFIXES to restrict access.
# This is a soft control — it filters, not guarantees.

import os
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

PORT             = int(os.environ.get('PORT', 4001))
ALLOWED_PREFIXES = [p for p in os.environ.get('ALLOWED_PREFIXES', '').split(',') if p]
BLOCKED_PREFIXES = [p for p in os.environ.get('BLOCKED_PREFIXES', '').split(',') if p]

def is_allowed(wallet_hash: str) -> tuple[bool, str]:
    h = wallet_hash.lower()
    if BLOCKED_PREFIXES:
        for prefix in BLOCKED_PREFIXES:
            if h.startswith(prefix.lower()):
                return False, f'Consumer blocked by geographic policy.'
    if ALLOWED_PREFIXES:
        for prefix in ALLOWED_PREFIXES:
            if h.startswith(prefix.lower()):
                return True, ''
        return False, 'Consumer not in allowed region.'
    return True, ''

class PluginHandler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args): pass

    def _read_body(self):
        length = int(self.headers.get('Content-Length', 0))
        raw = self.rfile.read(length) if length > 0 else b'{}'
        try: return json.loads(raw)
        except: return {}

    def _respond(self, data, status=200):
        body = json.dumps(data).encode()
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        if self.path == '/sertone/info':
            self._respond({
                'name': 'Geographic Firewall',
                'version': '1.0.0',
                'description': 'Allows or blocks consumers by wallet hash prefix.',
                'author': 'anonymous',
                'fee_model': 'none',
                'allowed_prefixes': ALLOWED_PREFIXES or ['(all)'],
                'blocked_prefixes': BLOCKED_PREFIXES or ['(none)'],
            })
        else:
            self.send_error(404)

    def do_POST(self):
        body = self._read_body()
        if self.path == '/sertone/pre-request':
            h = body.get('consumer_wallet_hash', '')
            allowed, reason = is_allowed(h)
            resp = {'allow': allowed}
            if not allowed: resp['reason'] = reason
            self._respond(resp)
        elif self.path == '/sertone/post-response':
            self._respond({'quality_signal': 1})
        else:
            self.send_error(404)

if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', PORT), PluginHandler)
    print(f'Geographic Firewall on port {PORT}')
    if ALLOWED_PREFIXES: print(f'  Allowed prefixes: {ALLOWED_PREFIXES}')
    if BLOCKED_PREFIXES: print(f'  Blocked prefixes: {BLOCKED_PREFIXES}')
    server.serve_forever()

Security notes