Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ roundtrip = decode_offer(wire) # exact equality
- **Agent marketplaces.** Two MCP servers settle on `AgentEscrow` with timeout protection. Provider only paid when work confirms; payer can claim back if provider goes silent.
- **High-volume A2A.** Agents on the same Lux/Base subnet exchange `PaymentOffer`/`PaymentProof` over ZAP wire — zero parse-time allocation, ~10× smaller than JSON, schema-locked across Python ↔ Go.
- **Autonomous burn caps.** Long-running agents enforce per-hour / per-day spend limits before the on-chain submit, killing the runaway-loop class of bugs.
- **Create Protocol registry integration.** The stable registry-facing surface is pinned in [`docs/create-protocol-stable.md`](docs/create-protocol-stable.md).

---

Expand Down
40 changes: 40 additions & 0 deletions docs/create-protocol-stable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Create Protocol stable surface

This page pins the package-level Switchboard surface consumed by the Create
Protocol registry. The source dependency is
[`create-protocol/cr8/specs/switchboard-integration.md`](https://github.com/create-protocol/cr8/blob/8886a0939f3b763732e9b6797b2ebdd8e9d09a53/specs/switchboard-integration.md#4-switchboard-primitives-the-registry-depends-on)
at commit `8886a0939f3b763732e9b6797b2ebdd8e9d09a53`.

The functions below are exported from the top-level `switchboard` package.
Changing their name, required arguments, or return shape requires a new
Switchboard major version.

| Primitive | Stable surface | Return contract | Smoke coverage |
|---|---|---|---|
| MPC wallet provisioning | `switchboard.provision_wallet(quorum, recovery_set) -> Address` | EVM address string used as `wallet_id` by the other wallet primitives | `tests/test_create_protocol_surface.py` |
| Threshold signing | `switchboard.sign(wallet_id, payload) -> Signature` | Hex `0x` signature string over a canonical payload | `tests/test_create_protocol_surface.py` |
| Key rotation | `switchboard.rotate(wallet_id, new_quorum) -> Address` | Same address for quorum-only rotation | `tests/test_create_protocol_surface.py` |
| x402 metering | `switchboard.meter(session_id, rate) -> MeterReceipt` | Receipt with `session_id`, `rate`, `receipt_id`, and `issued_at` | `tests/test_create_protocol_surface.py` |
| A2A counterparty handshake | `switchboard.a2a_handshake(peer) -> A2AChannel` | Open channel with `peer`, `channel_id`, `status`, and `opened_at` | `tests/test_create_protocol_surface.py` |
| Recovery quorum lookup | `switchboard.recovery_quorum(wallet_id) -> set[Address]` | Copy of the configured recovery quorum | `tests/test_create_protocol_surface.py` |

## Version contract

- `wallet_id` is the EVM address returned by `provision_wallet`.
- `quorum` accepts `"threshold/parties"`, `(threshold, parties)`, or a mapping
with `threshold` and `parties` keys.
- `rotate` preserves the wallet address for ordinary quorum rotation. Catastrophic
key replacement to a new address remains an application-level migration and is
not this stable primitive.
- `meter` and `a2a_handshake` return dataclasses with `to_dict()` for JSON/RPC
clients.
- The current facade is in-process and delegates to existing Switchboard Python
primitives. Durable MPC, metering, or A2A backends can replace the internals
without changing these top-level calls.

## Gap handling

The Create Protocol registry depends on the function contracts above, not on a
specific backend implementation. If a future backend cannot satisfy one of the
rows, open a tracking issue before release and keep the current stable function
raising `CreateProtocolSurfaceError` instead of silently changing behavior.
29 changes: 28 additions & 1 deletion switchboard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,37 @@

__version__ = "0.1.0"

__all__ = ["__version__", "load_registry", "GasManager", "GasLimits", "BudgetStatus", "BudgetExhausted"]
__all__ = [
"__version__",
"load_registry",
"GasManager",
"GasLimits",
"BudgetStatus",
"BudgetExhausted",
"A2AChannel",
"CreateProtocolSurfaceError",
"MeterReceipt",
"a2a_handshake",
"meter",
"provision_wallet",
"recovery_quorum",
"rotate",
"sign",
]


from switchboard.gas_manager import BudgetExhausted, GasLimits, GasManager, BudgetStatus
from switchboard.create_protocol import (
A2AChannel,
CreateProtocolSurfaceError,
MeterReceipt,
a2a_handshake,
meter,
provision_wallet,
recovery_quorum,
rotate,
sign,
)


def load_registry() -> dict[str, Any]:
Expand Down
194 changes: 194 additions & 0 deletions switchboard/create_protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""Stable Create Protocol facade for switchboard.

This module pins the small package-level surface that the Create Protocol
registry consumes. The implementation delegates to the current in-process
Switchboard primitives, so production deployments can swap in durable MPC,
metering, and A2A backends without changing the import contract.
"""

from __future__ import annotations

import hashlib
import json
import time
import uuid
from dataclasses import asdict, dataclass, field
from typing import Any, Iterable, Mapping

from switchboard.mpc_wallet import MPCWallet


Address = str
Signature = str


class CreateProtocolSurfaceError(ValueError):
"""Raised when the stable Create Protocol facade cannot service a request."""


@dataclass
class MeterReceipt:
"""Off-chain x402 metering receipt returned by ``meter``."""

session_id: str
rate: str
receipt_id: str
issued_at: float = field(default_factory=time.time)

def to_dict(self) -> dict[str, Any]:
return asdict(self)


@dataclass
class A2AChannel:
"""Counterparty channel returned by ``a2a_handshake``."""

peer: str
channel_id: str
status: str = "open"
opened_at: float = field(default_factory=time.time)

def to_dict(self) -> dict[str, Any]:
return asdict(self)


@dataclass
class _ProvisionedWallet:
wallet: MPCWallet
recovery_set: set[Address]


_WALLETS: dict[Address, _ProvisionedWallet] = {}
_METER_RECEIPTS: list[MeterReceipt] = []
_CHANNELS: dict[str, A2AChannel] = {}


def provision_wallet(quorum: Any, recovery_set: Iterable[Address] = ()) -> Address:
"""Provision an MPC wallet and return its stable EVM address.

``quorum`` accepts ``"2/3"``, ``(2, 3)``, or
``{"threshold": 2, "parties": 3}``. The returned address is the wallet id
accepted by ``sign``, ``rotate``, and ``recovery_quorum``.
"""

threshold, parties = _parse_quorum(quorum)
recovery = {str(address) for address in recovery_set}
wallet = MPCWallet(
parties=parties,
threshold=threshold,
wallet_label=_wallet_label(recovery),
)
address = wallet.get_evm_address()
_WALLETS[address] = _ProvisionedWallet(wallet=wallet, recovery_set=recovery)
return address


def sign(wallet_id: Address, payload: bytes | str | Mapping[str, Any]) -> Signature:
"""Sign an arbitrary payload with the provisioned MPC wallet."""

provisioned = _require_wallet(wallet_id)
canonical_payload = _canonical_payload(payload)
return provisioned.wallet.sign_and_send({"payload": canonical_payload.hex()})


def rotate(wallet_id: Address, new_quorum: Any) -> Address:
"""Rotate the wallet quorum while preserving the on-chain address."""

provisioned = _require_wallet(wallet_id)
threshold, parties = _parse_quorum(new_quorum)
replacement = MPCWallet(
parties=parties,
threshold=threshold,
chain_id=provisioned.wallet.config.chain_id,
wallet_label=provisioned.wallet.config.wallet_label,
)
if replacement.get_evm_address() != wallet_id:
raise CreateProtocolSurfaceError("quorum rotation changed the wallet address")
_WALLETS[wallet_id] = _ProvisionedWallet(
wallet=replacement,
recovery_set=set(provisioned.recovery_set),
)
return wallet_id


def meter(session_id: str, rate: str | int | float) -> MeterReceipt:
"""Record an x402 metering checkpoint and return a portable receipt."""

if not session_id:
raise CreateProtocolSurfaceError("session_id is required")
rate_value = str(rate)
receipt_seed = f"{session_id}:{rate_value}:{len(_METER_RECEIPTS)}"
receipt_id = hashlib.sha256(receipt_seed.encode()).hexdigest()
receipt = MeterReceipt(session_id=session_id, rate=rate_value, receipt_id="0x" + receipt_id)
_METER_RECEIPTS.append(receipt)
return receipt


def a2a_handshake(peer: str) -> A2AChannel:
"""Open an A2A counterparty channel for payment negotiation."""

if not peer:
raise CreateProtocolSurfaceError("peer is required")
channel = A2AChannel(peer=peer, channel_id=str(uuid.uuid4()))
_CHANNELS[channel.channel_id] = channel
return channel


def recovery_quorum(wallet_id: Address) -> set[Address]:
"""Return the configured recovery quorum for a provisioned wallet."""

return set(_require_wallet(wallet_id).recovery_set)


def _parse_quorum(quorum: Any) -> tuple[int, int]:
if isinstance(quorum, str):
try:
threshold_raw, parties_raw = quorum.split("/", 1)
threshold = int(threshold_raw)
parties = int(parties_raw)
except (TypeError, ValueError) as exc:
raise CreateProtocolSurfaceError("quorum string must look like '2/3'") from exc
elif isinstance(quorum, Mapping):
try:
threshold = int(quorum["threshold"])
parties = int(quorum["parties"])
except (KeyError, TypeError, ValueError) as exc:
raise CreateProtocolSurfaceError(
"quorum mapping requires threshold and parties"
) from exc
else:
try:
threshold, parties = quorum
threshold = int(threshold)
parties = int(parties)
except (TypeError, ValueError) as exc:
raise CreateProtocolSurfaceError("quorum must be a string, mapping, or pair") from exc

if threshold < 1 or parties < 1:
raise CreateProtocolSurfaceError("quorum values must be positive")
if threshold > parties:
raise CreateProtocolSurfaceError("threshold cannot exceed parties")
return threshold, parties


def _canonical_payload(payload: bytes | str | Mapping[str, Any]) -> bytes:
if isinstance(payload, bytes):
return payload
if isinstance(payload, str):
return payload.encode()
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()


def _require_wallet(wallet_id: Address) -> _ProvisionedWallet:
try:
return _WALLETS[wallet_id]
except KeyError as exc:
raise CreateProtocolSurfaceError(f"unknown wallet_id: {wallet_id}") from exc


def _wallet_label(recovery_set: set[Address]) -> str:
seed = json.dumps(
{"recovery_set": sorted(recovery_set), "nonce": uuid.uuid4().hex},
separators=(",", ":"),
)
return "create-protocol-" + hashlib.sha256(seed.encode()).hexdigest()[:16]
62 changes: 62 additions & 0 deletions tests/test_create_protocol_surface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Smoke tests for the Create Protocol stable facade."""

import pytest

import switchboard
from switchboard.create_protocol import CreateProtocolSurfaceError


RECOVERY_SET = {
"0x1111111111111111111111111111111111111111",
"0x2222222222222222222222222222222222222222",
}


def test_create_protocol_primitives_are_exported():
for name in (
"provision_wallet",
"sign",
"rotate",
"meter",
"a2a_handshake",
"recovery_quorum",
):
assert callable(getattr(switchboard, name))


def test_wallet_provision_sign_rotate_and_recovery_quorum():
wallet_id = switchboard.provision_wallet("2/3", RECOVERY_SET)

assert wallet_id.startswith("0x")
assert switchboard.recovery_quorum(wallet_id) == RECOVERY_SET

signature = switchboard.sign(wallet_id, {"agentId": 7, "nonce": 1})
assert signature.startswith("0x")
assert len(signature) == 66

rotated = switchboard.rotate(wallet_id, {"threshold": 3, "parties": 5})
assert rotated == wallet_id
assert switchboard.recovery_quorum(wallet_id) == RECOVERY_SET


def test_meter_returns_portable_receipt():
receipt = switchboard.meter("session-123", "0.001-USDC/request")

assert receipt.session_id == "session-123"
assert receipt.rate == "0.001-USDC/request"
assert receipt.receipt_id.startswith("0x")
assert receipt.to_dict()["session_id"] == "session-123"


def test_a2a_handshake_returns_channel():
channel = switchboard.a2a_handshake("agent:peer-1")

assert channel.peer == "agent:peer-1"
assert channel.status == "open"
assert channel.channel_id
assert channel.to_dict()["status"] == "open"


def test_unknown_wallet_rejected():
with pytest.raises(CreateProtocolSurfaceError):
switchboard.sign("0x0000000000000000000000000000000000000000", b"payload")