diff --git a/README.md b/README.md index 38222fd..f336f2f 100644 --- a/README.md +++ b/README.md @@ -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). --- diff --git a/docs/create-protocol-stable.md b/docs/create-protocol-stable.md new file mode 100644 index 0000000..1b31167 --- /dev/null +++ b/docs/create-protocol-stable.md @@ -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. diff --git a/switchboard/__init__.py b/switchboard/__init__.py index fc3609e..53a545e 100644 --- a/switchboard/__init__.py +++ b/switchboard/__init__.py @@ -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]: diff --git a/switchboard/create_protocol.py b/switchboard/create_protocol.py new file mode 100644 index 0000000..05fc811 --- /dev/null +++ b/switchboard/create_protocol.py @@ -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] diff --git a/tests/test_create_protocol_surface.py b/tests/test_create_protocol_surface.py new file mode 100644 index 0000000..e75d6fe --- /dev/null +++ b/tests/test_create_protocol_surface.py @@ -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")