Metadata-Version: 2.5
Name: cowboy-protocol-py
Version: 0.7.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
(the codec-owned `crates/cowboy-protocol-codec/fixtures/tx-v3/` corpus,
including CBQS transaction instructions).

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.

The `EntitlementCreateRole`, `EntitlementAssignRole` and
`EntitlementRevokeRole` variants remain retired (COW-4964). Auth v3 uses
ordinary transaction version 3, trusted full chain identities, and typed
signature slots; v2 envelopes are rejected.

## 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_hash(tx_json: str, chain_identity_json: str) -> bytes  # native ID, 32 bytes
cp.eip712_signing_hash(tx_json: str, chain_identity_json: str) -> bytes
cp.eip712_typed_data(tx_json: str, chain_identity_json: str) -> str  # canonical JCS
cp.encode_transaction(tx_json: str) -> bytes   # ordinary v3 canonical encoding
cp.decode_transaction(raw: bytes) -> str       # strict v3 JSON, no trailing bytes
cp.attach_signature(unsigned_bytes: bytes, slot_index: int, signature_hex: str) -> bytes
cp.encode_submission(tx_json: str) -> bytes    # the POST /submit body
cp.encode_submission_batch(data: bytes, lengths: list[int]) -> bytes
cp.sign_transaction(tx_json: str, priv_key32: bytes, mode: int,
                    chain_identity_json: str) -> str  # signed primary slot
cp.sign(hash32: bytes, priv_key32: bytes) -> bytes  # low-level 65-byte r||s||v primitive
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/tx-v3/<name>/input.json`).

`chain_identity_json` is a closed object with a nonzero canonical decimal-string
`chain_id` and a nonzero lowercase `0x`-prefixed 32-byte `chain_instance_id`.
Discover it from the intended RPC's closed `/chain-info`, independently of any
prepared signing payload, and retain the same trusted identity for the operation.
Mode `0` signs the native digest; mode `1` signs EIP-712. Transaction signatures
are full `{algorithm:1, mode:0|1, public_key:null, signature:"0x…"}` slots.

The public hash, typed-data, and signing exports enforce the codec's checked
producer boundary. They refuse deferred envelopes and invalid signer assemblies.
Native Rust total identity hashes still exist for consensus/verification vectors;
a total vector hash does not authorize a producer to sign it. The retired
`signing_preimage` and `signing_hash_hex` exports are absent; use the native ID
bytes' `.hex()` only when a hex display is needed.

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.

### Native RAS, CBFS and workload object signing (CN-7)

These C object helpers use the codec's closed unsigned DTOs and require trusted
`chain_identity_json: str` as their **last argument**. They share names and
argument order with the WASM and flat-FFI exports. Python returns `bytes` for
preimages and 32-byte hashes, and `dict` for successful verifier results.

```python
# Native object preimages, hashes and core verifiers.
cp.ras_owner_delegation_signing_preimage(object_json: str, chain_identity_json: str) -> bytes
cp.ras_owner_delegation_signing_hash(object_json: str, chain_identity_json: str) -> bytes
cp.ras_owner_delegation_verify(signed_object_json: str, chain_identity_json: str) -> dict
cp.cbfs_delegation_cert_signing_preimage(object_json: str, chain_identity_json: str) -> bytes
cp.cbfs_delegation_cert_signing_hash(object_json: str, chain_identity_json: str) -> bytes
cp.cbfs_delegation_cert_verify(signed_object_json: str, chain_identity_json: str) -> dict
cp.ras_owner_authorization_signing_preimage(object_json: str, chain_identity_json: str) -> bytes
cp.ras_owner_authorization_verify(signed_object_json: str, chain_identity_json: str) -> dict
cp.ras_relay_authorization_signing_preimage(object_json: str, chain_identity_json: str) -> bytes
cp.ras_relay_authorization_verify(signed_object_json: str, expected_signing_public_key_json: str, chain_identity_json: str) -> dict
cp.workload_mount_policy_signing_preimage(object_json: str, chain_identity_json: str) -> bytes
cp.workload_mount_policy_signing_hash(object_json: str, chain_identity_json: str) -> bytes
cp.workload_mount_policy_verify(signed_object_json: str, chain_identity_json: str) -> dict

# Paid-read digests: the same unsigned DTO and full identity contract.
cp.cbfs_read_authorization_digest(object_json: str, chain_identity_json: str) -> bytes
cp.cbfs_read_completion_digest(object_json: str, chain_identity_json: str) -> bytes
cp.cbfs_read_channel_voucher_digest(object_json: str, chain_identity_json: str) -> bytes
```

`ras_owner_authorization_verify` checks signature self-consistency against the
`delegated_pubkey` carried by the object. Success does not establish registration
or authority. Compare the returned `signing_public_key` with an independently
trusted registered delegation, then enforce its scope, expiry and revocation
before accepting the authorization.

The identity JSON has exactly `chain_id` (a nonzero canonical decimal u64
string) and `chain_instance_id` (`0x` plus 64 lowercase hex digits, nonzero):

```python
import json

chain_identity_json = json.dumps({
    "chain_id": "1337",
    "chain_instance_id": "0x" + "71" * 32,
})
```

Missing/malformed identity, extra fields and numeric chain IDs are errors. The
signer obtains and checks both members against its trusted chain discovery
result. A matching numeric chain ID alone does not identify a genesis instance.
For objects carrying a `chain_id`, that field must equal the trusted ID.

Object DTOs are JSON objects with canonical decimal-string integers, lowercase
`0x` fixed bytes and tagged `SigningPublicKey` strings. For example, an Ed25519
key is the commonware-encoded string
`"0x0220d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"`.
Raw key arrays, uppercase key hex, missing key prefixes, unknown/duplicate
fields and positional arrays are rejected. Delegation addresses accept lowercase
or valid EIP-55 spelling. The native DTOs have no `network` member.

Hash/preimage inputs contain no `signature` member. A verifier takes that same
DTO plus one required `signature` slot with all four members:
`{"algorithm":1,"mode":0,"public_key":null,"signature":"0x<130 lowercase hex>"}`
for wallet signatures. `public_key` must be present even when null. Wallet
slots use secp256k1 native mode, low-S and recovery ID 0 or 1; owner/relay
authorization slots use native Ed25519. The relay verifier's separate
`expected_signing_public_key_json` is a JSON string encoding the independently
trusted registered Ed25519 key, because its slot forbids an inline key and its
body carries none. Successful verifiers return `{"address":"0x…"}` or
`{"signing_public_key":"0x…"}`; invalid signatures raise `CowboyProtocolError`.

`service_class` is exactly `"MOUNT_CAPABLE_V1"` or `"NON_MOUNT_SERVICE_V1"`.
Object-wrapped unit classes and all three retired workload service classes
(`WORKLOAD_MOUNT_V1`, `VOLUME_BOUND_SERVICE_V1`, `SERVICE_RELEASE_V1`) reject.
Standing workload mount policies use the separate CN-19 policy API below.
Owner authorization DTOs require `volume_id` and `workload_effect_binding`,
using explicit null when absent. The complete schemas and delegation examples
are in the [WASM object API](../../crates/cowboy-protocol-wasm/README.md#native-storage-object-signing-cn-7)
and [shared DTO source](../../crates/cowboy-protocol-codec/src/storage_signing_json.rs).

Native preimages bind the domain, full identity and canonical object body.
Ed25519 signs those returned bytes; secp256k1 signs the returned Keccak-256
hash. Python hash helpers return the 32 raw bytes, so use
`"0x" + digest.hex()` when comparing with a WASM/CLI hash string. No helper adds
a wallet personal-message prefix.

Existing Python names keep the same native DTO contract and final identity
argument:

```python
cp.owner_authorization_signing_bytes(object_json: str, chain_identity_json: str) -> bytes
cp.owner_delegation_signing_bytes(object_json: str, chain_identity_json: str) -> bytes
cp.relay_authorization_signing_bytes(object_json: str, chain_identity_json: str) -> bytes
cp.delegation_cert_signing_bytes(object_json: str, chain_identity_json: str) -> bytes
cp.delegation_cert_hash(object_json: str, chain_identity_json: str) -> bytes
```

`cbfs auth delegation-prepare` emits unsigned DTO objects `cert` and `ras`,
`chain_identity`, and bare hex `cert_hash` / `ras_hash`. Serialize each DTO and
the checked identity as JSON arguments, reconstruct the hashes here, then
compare before signing. Completion's `--cert-sig` and `--ras-sig` accept the
full native slot JSON. Transaction-building commands such as
`delegation-register-tx` instead return the CN-2 handoff members
`unsigned_tx_hex`, `slot_index`, `mode`, `signing_hash`, and `chain_identity`.

The CBFS data-plane helpers below retain their one-document native-serde input
contracts, described by their function docstrings:

```python
cp.owner_cap_token_signing_bytes(token_json: str) -> bytes
cp.delegated_request_signing_bytes(request_input_json: str) -> bytes
cp.relay_usage_signing_bytes(usage_input_json: str) -> bytes
```

`tests/test_golden.py` and the Rust `wasmtime_golden` gate pin these exports to
the same codec vectors. The flat ABI is documented in
`crates/cowboy-protocol-wasm-ffi/src/lib.rs`.

### Example

```python
import json
import cowboy_protocol as cp

# Example identity only. In a live operation discover it from the intended RPC.
identity = json.dumps({
    "chain_id": "42", "chain_instance_id": "0x" + "11" * 32,
})
tx = json.dumps({
    "chain_id": 42, "nonce": 0, "nonce_lane": 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": None, "metadata": [], "additional_signers": [],
    "origin_tx_hash": None, "origin_remaining_cycles": None,
    "origin_remaining_cells": None, "origin_remaining_access": None,
    "run_root": None, "delivery": 0, "sponsor": None,
    "signature": {"algorithm": 1, "mode": 0, "public_key": None,
                  "signature": "0x" + "00" * 65},
})

priv = bytes([1]) * 32
native_id = cp.signing_hash(tx, identity)
signed_tx_json = cp.sign_transaction(tx, priv, 0, identity)
body = cp.encode_submission(signed_tx_json)  # bytes to POST /submit
assert cp.signing_hash(signed_tx_json, identity) == native_id
```

## 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 the v3 transaction corpus and asserts canonical
transaction/submission bytes, checked native or EIP-712 digests, and golden
RFC-6979 signatures. Deferred vectors retain native total-identity artifacts
for the Rust gate, while the Python producer hash export must refuse them.
`test_v3_corpus.py` also checks the native-generated `tx-v3-bindings.json`
outcomes for invalid signer assemblies. The suite additionally replays the
RAS/CBFS signing manifest, CIP-10 workload transactions and 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.

## Signed object helpers

The Python package mirrors the commerce, Watchtower, room and anchor exports in
[`protocol-wasm`](../../crates/cowboy-protocol-wasm/README.md#identity-bound-commerce-watchtower-and-room-objects),
including the same strict unsigned DTOs and JSON argument order. Hashes and
preimages return `bytes`; verifiers and slot decoders return Python dictionaries.
Each JSON argument is a separate flat-ABI pointer/length pair.

For example, `payment_intent_signing_hash(unsigned_json, chain_identity_json)`
returns the exact 32 bytes to sign. Replace the unsigned `policy_revision` with
the payer's account signature under `signature` (`policy_revision` plus
`[key_index, slot]` entries, each slot with `public_key: None`), then call
`payment_intent_verify(signed_json, chain_identity_json, payer_auth_json)` to
invoke the core verifier, passing the payer's `GET /accounts/{address}/auth`
body. Credit and purchase callers also compare the returned signer with their
independently trusted configured authority.

Watchtower verification takes three arguments:
`watchtower_message_verify(signed_json, expected_signing_public_key_json, chain_identity_json)`
(and the equivalent usage receipt function). The expected key is a JSON string
containing canonical tagged Ed25519 key bytes from a trusted publisher/usage
source. It is never obtained from the signed DTO.

`room_authority_commitment(authority_json, chain_identity_json)` requires a valid
identity but returns a content-only SHA256 commitment, which is not instance-bound.

CN-19 planners return the codec's exact JSON **text** (`str`):

```python
cp.workload_mount_policy_plan(record_json_bytes, volume_id, owner,
                              valid_until_epoch, revision, chain_identity_json) -> str
cp.workload_mount_policy_instruction(record_json_bytes, volume_id, owner,
                                     valid_until_epoch, revision,
                                     signature_slot_json, chain_identity_json) -> str
```

Epoch and revision arguments are canonical decimal strings. The object functions
above take the closed ten-member unsigned policy DTO; verify adds the required
secp256k1 native signature slot with `public_key: null`. Hash/preimage return
`bytes`, and verify returns a `dict`. The full identity is required last and
must match the policy's embedded pair. The requested volume must be declared;
its mount principal may differ from the asserted owner under a mount grant.
Publication independently proves current volume ownership.
