Metadata-Version: 2.5
Name: cowboy-protocol-py
Version: 0.6.0
Summary: Pure-Python wrapper over the cowboy-protocol-wasm-ffi codec wasm (the canonical Cowboy transaction codec, via wasmtime). The Python analog of @cowboyinc/protocol-wasm.
Project-URL: Repository, https://github.com/cowboyinc/core
Project-URL: Homepage, https://github.com/cowboyinc/core/tree/devnet/protocol/bindings/python
Author: Cowboy Foundation
License-Expression: MIT OR Apache-2.0
License-File: LICENSE-APACHE
License-File: LICENSE-MIT
Keywords: blockchain,codec,cowboy,transaction,wasm
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Rust
Requires-Python: >=3.10
Requires-Dist: wasmtime>=20
Provides-Extra: test
Requires-Dist: pytest>=7; extra == 'test'
Description-Content-Type: text/markdown

# cowboy-protocol-py

The canonical Cowboy transaction codec for Python — a thin, **pure-Python**
wrapper (import name `cowboy_protocol`) that loads the
[`cowboy-protocol-wasm-ffi`](../../crates/cowboy-protocol-wasm-ffi) `.wasm` via
the [`wasmtime`](https://pypi.org/project/wasmtime/) pip package and exposes a
Pythonic API.

It is the Python analog of `@cowboyinc/protocol-wasm`: every byte and every
signature comes from the **same** `cowboy-protocol-codec` compiled to wasm, so
`python == native == wasm == ts` — byte-identical, proven by the golden gate
(every vector in `crates/cowboy-protocol-codec/fixtures/commonware-codec/`
plus the quoted CBQS `CreateStream` wire vector).

This package lives **in** [`cowboyinc/core`](https://github.com/cowboyinc/core)
under `protocol/bindings/python`, co-located and versioned with the wasm and its
flat ABI, so the ABI and its only Python consumer can never drift across repos.
(Paths below are relative to `protocol/`.) The bundled `.wasm` is built from the
wasm-ffi crate via `crates/cowboy-protocol-wasm-ffi/build-ffi.sh` and committed.
A codec change makes the committed artifact stale; CI reports that as a warning
and the release owner refreshes it before the next release, which refuses to
publish a stale one. The package tests load that committed artifact and check
golden bytes and retired-instruction rejection.

Version 0.6.0 ships codec/FFI 0.4.0 and rejects the retired
`EntitlementCreateRole`, `EntitlementAssignRole` and `EntitlementRevokeRole`
variants. This is a breaking, coordinated fresh-devnet release (COW-4964).

## Why pure-Python

`wasmtime` ships its own platform wheels, so installing this package needs **no
Rust/C compiler** — `pip install` just pulls the `wasmtime` wheel and the
committed `.wasm`. The wasm has **no JS / wasm-bindgen imports** (it instantiates
with an empty import set), so a plain wasmtime host runs it directly.

## Install

```bash
pip install cowboy-protocol-py
```

To install an unreleased revision, pin it by commit:

```bash
pip install "cowboy-protocol-py @ git+https://github.com/cowboyinc/core.git@<rev>#subdirectory=protocol/bindings/python"
```

Replace `<rev>` with a full commit SHA (or tag) to pin. For local development
against a checkout:

```bash
pip install -e bindings/python
```

## API

```python
import cowboy_protocol as cp

cp.signing_preimage(tx_json: str) -> bytes
cp.signing_hash(tx_json: str) -> bytes            # 32 bytes
cp.signing_hash_hex(tx_json: str) -> str          # lowercase hex, no 0x
cp.encode_transaction(tx_json: str) -> bytes      # full canonical tx encoding
cp.encode_submission(tx_json: str) -> bytes       # the POST /submit body
cp.sign(hash32: bytes, priv_key32: bytes) -> bytes            # 65-byte r||s||v
cp.sign_transaction(tx_json: str, priv_key32: bytes) -> str   # signed tx as canyon-JSON
cp.address_from_private_key(priv_key32: bytes) -> str         # EIP-55 checksum
```

`tx_json` is the **canyon-JSON transaction shape** — byte-for-byte the same
serde form node serializes, identical to the golden vectors' `input.json` (see
`crates/cowboy-protocol-codec/fixtures/commonware-codec/<name>/input.json`).

On any wasm error (malformed JSON, bad private key, etc.) the wrapper reads the
wasm's `cpw_last_error` and raises `cowboy_protocol.CowboyProtocolError` — it
never returns garbage.

### RAS + CBFS control-plane signing bytes

The canonical **signing-byte** contract (`-codec`'s `ras` + `cbfs_signing`
modules) is surfaced so a non-Rust signer — the python-sdk — derives the bytes
it signs from here instead of re-implementing them (a byte-level fork is exactly
the split that caused the 2026-07-11 outage, COW-2608):

```python
# RAS consensus-auth (COW-2636)
cp.owner_authorization_signing_bytes(authorization_json: str) -> bytes  # RasOwnerAuthorization
cp.owner_delegation_signing_bytes(delegation_json: str) -> bytes        # RasOwnerDelegation
cp.relay_authorization_signing_bytes(authorization_json: str) -> bytes  # RasRelayAuthorization

# CBFS control-plane (COW-2649)
cp.delegation_cert_signing_bytes(cert_json: str) -> bytes            # DelegationCert
cp.delegation_cert_hash(cert_json: str) -> bytes                     # 32-byte keccak256(signing bytes)
cp.owner_cap_token_signing_bytes(token_json: str) -> bytes          # OwnerCapTokenV1
cp.delegated_request_signing_bytes(request_input_json: str) -> bytes  # DelegatedRequestInput
cp.relay_usage_signing_bytes(usage_input_json: str) -> bytes        # RelayUsageSigningInput
```

Each takes **one** canyon-JSON document — the serde shape of the named `-codec`
struct, byte-for-byte the fixtures' `input` (fixed-width byte fields are JSON
arrays of `u8`; `Address` is the `0x…` string; `Option<T>` is the value or
`null`). `RasOwnerDelegation` and `DelegationCert` require a typed
`service_class`: the string `MOUNT_CAPABLE_V1` or `NON_MOUNT_SERVICE_V1`, or
one of the bound classes `{"VOLUME_BOUND_SERVICE_V1": {"volume_id": [32 u8],
"workload_id": [32 u8]}}`, `{"WORKLOAD_MOUNT_V1": {"volume_id": [32 u8],
"workload_id": [32 u8]}}` and `{"SERVICE_RELEASE_V1": {"volume_id": [32 u8]}}`;
omission and unknown classes fail closed. Every byte comes from the **same** wasm the
native golden gate pins:
`tests/test_golden.py` drives these against
`crates/cowboy-protocol-codec/fixtures/ras/vectors.json`, the same manifest the
Rust `wasmtime_golden` gate pins, so `python == native == wasm`. See the
per-function docstrings and the crate-level ABI doc in
`crates/cowboy-protocol-wasm-ffi/src/lib.rs` for the exact per-struct JSON shape.

### Example

```python
import cowboy_protocol as cp

tx = """{
  "chain_id": 42, "nonce": 0,
  "instruction": {"System": {"CreateAccount": {"account": "0x0000000000000000000000000000000000001111"}}},
  "cycles_limit": 50000, "cells_limit": 50000, "access_limit": 512,
  "max_fee_per_cycle": 1, "max_fee_per_cell": 1, "max_fee_per_access": "1",
  "max_priority_fee_per_cycle": 0, "max_priority_fee_per_cell": 0,
  "max_priority_fee_per_access": "0",
  "from": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1",
  "access_list": null, "signature": {"r": [0], "s": [0], "v": 0},
  "additional_signers": [], "origin_tx_hash": null,
  "origin_remaining_cycles": null, "origin_remaining_cells": null,
  "origin_remaining_access": null, "metadata": []
}"""

priv = bytes([1]) * 32
print(cp.address_from_private_key(priv))   # EIP-55 checksummed
h = cp.signing_hash(tx)                     # 32 bytes
sig = cp.sign(h, priv)                      # 65 bytes r||s||v
signed_tx_json = cp.sign_transaction(tx, priv)
body = cp.encode_submission(signed_tx_json) # bytes to POST /submit
```

## Security — in-wasm signing threat model

`sign`, `sign_transaction` and `address_from_private_key` pass the raw 32-byte
private key **into wasm**, where it lives in the host's linear memory for the
duration of the call. This is the intentionally **weaker** threat model accepted
for COW-2363; isolated-signer hardening is deferred. Do not feed high-value keys
to these in-process signers in an untrusted host.

## The golden gate (anti-drift)

`tests/test_golden.py` loads this package for every transaction fixture vector and the
quoted CBQS `CreateStream` vector, then asserts `cp.*` reproduces
`preimage.bin` / `signing_hash.hex` / `submission.bin`
**byte-identically** and reproduces the golden RFC-6979 signatures. It also
replays the RAS/CBFS signing manifest, the CIP-10 workload transactions and the
JobSpec vectors.

The vectors live in `tests/fixtures`: copies of the source fixtures under
`crates/`, shipped in the sdist so the gate runs against an installed package
with no checkout. `tests/test_fixture_sync.py` compares each copy with its
source; refresh them with `python bindings/python/tests/test_fixture_sync.py`
(it needs only Python).

CI (`cowboyinc/core`'s `.github/workflows/core.yml`, the `protocol-python` job)
runs this suite on the lowest and a current supported Python, twice:

- **Blocking: against the commit's own codec.** In a temporary copy of the
  package, with the `.wasm` the `wasm` job just built and fixtures refreshed
  from source. A codec change that breaks the Python binding fails here.
- **Advisory: against what is committed.** The committed `.wasm` and the
  committed fixture copies. If either is stale the job shows a warning and
  still passes. A codec change never has to touch this package.

The release owner refreshes both the `.wasm` and the fixture copies before
tagging, and the release workflow refuses to publish until they are current.

A bit-exact committed-vs-fresh byte-match is intentionally **not** used: wasm
builds aren't reproducible across toolchains (macOS dev vs CI ubuntu), so a
byte-match would false-fail on benign build differences rather than real drift.
Instead the `wasm` job in `core.yml` rebuilds the `.wasm` from source via
`build-ffi.sh` and runs the wasmtime golden gate twice. The fresh build must
equal the native codec: that blocks. The committed package `.wasm` is checked
the same way, and against its build record, but only reports: a stale one shows
a warning and does not fail the job. The release workflow runs those same two
checks and blocks on them.

## Refreshing the bundled wasm

**This is the release owner's step before tagging a release. It is optional on
an ordinary pull request:** a codec change makes the committed wasm, and
possibly the fixture copies, stale; CI says so in a warning, and nothing is
blocked. `publish-protocol-py.yml` refuses to release until the wasm, its
record and the fixture copies are current.

The shipped `.wasm` is the one CI builds on Linux with the pinned toolchain
(`rust-toolchain.toml`, `wasm32-unknown-unknown`, `--release --locked`, no
`wasm-opt`). It is never built on a developer machine. `_provenance.json`,
shipped beside it and returned by `cowboy_protocol.provenance()`, records its
sha256 and size, the JobSpec version, the toolchain, and a digest of what the
wasm is compiled from, and only that:

- `src/`, `Cargo.toml` and `build.rs` of the codec crate,
  `cowboy-protocol-types` and the wasm-ffi crate. Tests and fixtures are not
  inputs.
- The `Cargo.lock` entries the wasm-ffi crate resolves for
  `wasm32-unknown-unknown`, from `cargo tree`. The rest of the monorepo's
  lockfile is not an input.
- `rust-toolchain.toml` and the workspace's `[profile.release]` section.
- `crates/cowboy-protocol-wasm-ffi/build-ffi.sh`, the build recipe. It refuses
  to run with `RUSTFLAGS` (or its `CARGO_*` equivalents) or a
  `CARGO_PROFILE_RELEASE_*` override set, so the script holds every flag.
- The repository's `.cargo/config.toml`, which can set `rustflags` or
  `[profile.release]` overrides for the same build.

Releases are cut by `.github/workflows/publish-protocol-py.yml`; see
`docs/ci/protocol-py-releases.md` in the repository root.

`scripts/protocol-py-provenance.py check` (it needs cargo and Python 3.11+)
fails when the committed wasm or any of those inputs no longer matches the
record, and refuses a record written with `record --allow-host`, for a
non-Linux build host, or for a wasm whose `producers` section names another
compiler than the record's `rustc` at the pinned toolchain. It is advisory in the `wasm` job of `core.yml` and blocking in the
release workflow. To refresh, from the repository root:

```bash
# 1. Start from the commit to release: a branch at the tip of devnet.
# 2. Download the build core.yml's `wasm` job uploaded for that commit. Every
#    devnet push and pull request run uploads one, kept for 30 days:
gh run list --workflow core.yml --branch devnet --event push --limit 5
gh run download <run-id> --name protocol-py-wasm --dir /tmp/protocol-py-wasm
# 3. Install it and write the record. This refuses a wasm that does not match
#    its sha256 file, was not built by the pinned toolchain on Linux, or was
#    built from inputs other than the ones in this checkout. If it refuses,
#    the run built different inputs: use the run for the commit you are on.
python3 scripts/protocol-py-provenance.py record \
  --wasm /tmp/protocol-py-wasm/cowboy_protocol_wasm_ffi.wasm \
  --sha256 /tmp/protocol-py-wasm/cowboy_protocol_wasm_ffi.wasm.sha256 \
  --rustc-vv /tmp/protocol-py-wasm/rustc-vv.txt \
  --inputs /tmp/protocol-py-wasm/inputs.json
python3 scripts/protocol-py-provenance.py check
# 4. Commit the wasm and _provenance.json together, merge that pull request,
#    and tag the merged commit.
```

Refresh the fixture copies in the same change: run
`python protocol/bindings/python/tests/test_fixture_sync.py` and commit whatever it
changes under `tests/fixtures`.

`source_revision` in the record is the release commit. A commit cannot name
itself, so it is `null` in the repository and written by the publish workflow
before the archives are built.

Run locally:

```bash
pip install -e bindings/python
pip install pytest
python -m pytest bindings/python/tests -v
```

## License

MIT OR Apache-2.0.
