cowboy-protocol-py 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ """``cowboy_protocol`` — the canonical Cowboy transaction codec for Python.
2
+
3
+ A thin, pure-Python wrapper (no compiler at install) that loads the WS-A
4
+ ``cowboy-protocol-wasm-ffi`` ``.wasm`` via the ``wasmtime`` pip package and
5
+ exposes a Pythonic API. It is the Python analog of ``@cowboyinc/protocol-wasm``:
6
+ every byte and every signature comes from the SAME ``cowboy-protocol-codec``
7
+ compiled to wasm, so ``python == native == wasm == ts`` (byte-identical, proven
8
+ by the golden gate).
9
+
10
+ This package lives **in** ``cowboyinc/core`` (``protocol/bindings/python``),
11
+ co-located and versioned with the wasm + its flat ABI, so the ABI and its only
12
+ Python consumer can never drift across repos.
13
+
14
+ Example
15
+ -------
16
+ >>> import cowboy_protocol as cp
17
+ >>> tx = '{"chain_id": 42, "nonce": 0, ...}' # canyon-JSON tx shape
18
+ >>> cp.signing_hash_hex(tx)
19
+ '…64 hex chars…'
20
+ >>> priv = bytes.fromhex("01" * 32)
21
+ >>> cp.address_from_private_key(priv)
22
+ '0x…EIP-55 checksum…'
23
+ >>> signed_tx_json = cp.sign_transaction(tx, priv)
24
+
25
+ The ``tx_json`` argument is the canyon-JSON transaction shape — byte-for-byte
26
+ the same serde form node serializes, identical to the golden vectors'
27
+ ``input.json`` (see ``crates/cowboy-protocol-codec/fixtures/commonware-codec/``).
28
+
29
+ On any wasm error (malformed JSON, bad key, etc.) the wrapper reads the wasm's
30
+ ``cpw_last_error`` and raises :class:`CowboyProtocolError` — it never returns
31
+ garbage.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import importlib.resources
37
+ import json
38
+
39
+ from . import error_codes # noqa: F401
40
+ from ._host import CowboyProtocolError, host
41
+
42
+ __all__ = [
43
+ # COW-2792 canonical error-code registry submodule.
44
+ "error_codes",
45
+ "CowboyProtocolError",
46
+ "provenance",
47
+ "signing_preimage",
48
+ "signing_hash",
49
+ "signing_hash_hex",
50
+ "encode_transaction",
51
+ "encode_submission",
52
+ "sign",
53
+ "sign_transaction",
54
+ "address_from_private_key",
55
+ "normalize_job_request",
56
+ "encode_job_spec_json",
57
+ "decode_job_spec_to_json",
58
+ "validate_job_spec_bytes",
59
+ "validate_job_spec_json",
60
+ # RAS consensus-auth signing bytes (COW-2636).
61
+ "owner_authorization_signing_bytes",
62
+ "owner_delegation_signing_bytes",
63
+ "relay_authorization_signing_bytes",
64
+ # CBFS control-plane signing bytes + the delegation-cert hash (COW-2649).
65
+ "delegation_cert_signing_bytes",
66
+ "delegation_cert_hash",
67
+ "owner_cap_token_signing_bytes",
68
+ "delegated_request_signing_bytes",
69
+ "relay_usage_signing_bytes",
70
+ ]
71
+
72
+ __version__ = "0.6.0"
73
+
74
+
75
+ def provenance() -> dict:
76
+ """The build record shipped with this package (``_provenance.json``).
77
+
78
+ Says which wasm this distribution carries (``wasm.sha256`` / ``wasm.size``),
79
+ the JobSpec version it emits, the toolchain that built it and a digest of
80
+ the codec sources it was built from. ``source_revision`` is the release
81
+ commit, written when a release is built; it is ``None`` in a checkout. A
82
+ consumer can compare this record with the bundled wasm's actual hash.
83
+ """
84
+ return json.loads(
85
+ importlib.resources.files(__package__)
86
+ .joinpath("_provenance.json")
87
+ .read_text(encoding="utf-8")
88
+ )
89
+
90
+
91
+ def _tx_bytes(tx_json: str) -> bytes:
92
+ """Encode a canyon-JSON tx string as UTF-8 for the ABI boundary."""
93
+ if not isinstance(tx_json, str):
94
+ raise TypeError(f"tx_json must be str, got {type(tx_json).__name__}")
95
+ return tx_json.encode("utf-8")
96
+
97
+
98
+ def signing_preimage(tx_json: str) -> bytes:
99
+ """Return the raw bytes of ``tx.signing_preimage()``.
100
+
101
+ The canonical encoding with all signatures zeroed and a leading
102
+ ``CURRENT_TX_VERSION`` byte. ``tx_json`` is the canyon-JSON tx shape.
103
+ """
104
+ return host().call("cpw_signing_preimage", _tx_bytes(tx_json))
105
+
106
+
107
+ def signing_hash(tx_json: str) -> bytes:
108
+ """Return the 32-byte ``tx.signing_hash()`` (``keccak256(signing_preimage())``)."""
109
+ return host().call("cpw_signing_hash", _tx_bytes(tx_json))
110
+
111
+
112
+ def signing_hash_hex(tx_json: str) -> str:
113
+ """Return ``tx.signing_hash()`` as lowercase hex (no ``0x`` prefix)."""
114
+ return host().call("cpw_signing_hash_hex", _tx_bytes(tx_json)).decode("ascii")
115
+
116
+
117
+ def encode_transaction(tx_json: str) -> bytes:
118
+ """Return the full canonical tx encoding (signatures included, leading version)."""
119
+ return host().call("cpw_encode_transaction", _tx_bytes(tx_json))
120
+
121
+
122
+ def encode_submission(tx_json: str) -> bytes:
123
+ """Return the ``POST /submit`` body: ``Submission::Transactions(vec![tx]).encode()``.
124
+
125
+ The single-tx on-chain submission envelope (native commonware-codec).
126
+ """
127
+ return host().call("cpw_encode_submission", _tx_bytes(tx_json))
128
+
129
+
130
+ def _require_key(priv_key32: bytes) -> bytes:
131
+ """Validate the caller-supplied 32-byte private key buffer."""
132
+ if not isinstance(priv_key32, (bytes, bytearray)):
133
+ raise TypeError(
134
+ f"priv_key32 must be bytes, got {type(priv_key32).__name__}"
135
+ )
136
+ if len(priv_key32) != 32:
137
+ raise ValueError(
138
+ f"priv_key32 must be exactly 32 bytes, got {len(priv_key32)}"
139
+ )
140
+ return bytes(priv_key32)
141
+
142
+
143
+ def sign(hash32: bytes, priv_key32: bytes) -> bytes:
144
+ """Sign a 32-byte hash, returning a 65-byte ``r || s || v`` signature.
145
+
146
+ RFC-6979 deterministic, low-S normalized, Ethereum recovery id. ``hash32``
147
+ is typically the output of :func:`signing_hash`.
148
+
149
+ SECURITY: the private key is passed into wasm and lives in the host's linear
150
+ memory for the call (the weaker threat model accepted for COW-2363). Do not
151
+ feed high-value keys to this in-process signer in an untrusted host.
152
+ """
153
+ if not isinstance(hash32, (bytes, bytearray)):
154
+ raise TypeError(f"hash32 must be bytes, got {type(hash32).__name__}")
155
+ if len(hash32) != 32:
156
+ raise ValueError(f"hash32 must be exactly 32 bytes, got {len(hash32)}")
157
+ key = _require_key(priv_key32)
158
+ # cpw_sign input = hash(32) || key(32)
159
+ return host().call("cpw_sign", bytes(hash32) + key)
160
+
161
+
162
+ def sign_transaction(tx_json: str, priv_key32: bytes) -> str:
163
+ """Sign a transaction and return the signed tx as canyon-JSON.
164
+
165
+ Sets ``from`` from the derived address and the primary ``signature``
166
+ (single-signer path; mirrors ``-codec``'s ``Transaction::sign``). For
167
+ multi-signer txs, assemble client-side with :func:`sign` +
168
+ :func:`encode_submission`.
169
+
170
+ SECURITY: see :func:`sign` — the private key lives in wasm linear memory.
171
+ """
172
+ key = _require_key(priv_key32)
173
+ # cpw_sign_transaction input = key(32) || json
174
+ out = host().call("cpw_sign_transaction", key + _tx_bytes(tx_json))
175
+ return out.decode("utf-8")
176
+
177
+
178
+ def address_from_private_key(priv_key32: bytes) -> str:
179
+ """Return the EIP-55 checksummed address (``0x…``) for a 32-byte private key.
180
+
181
+ SECURITY: see :func:`sign` — the private key lives in wasm linear memory.
182
+ """
183
+ key = _require_key(priv_key32)
184
+ return host().call("cpw_address_from_private_key", key).decode("utf-8")
185
+
186
+
187
+ # ─── JobSpec bridges (COW-2440 slice-4 Part A) ──────────────────────────────
188
+ #
189
+ # These marshal the canyon-JSON `JobRequest`/`JobSpec` boundary <-> the
190
+ # canonical commonware-codec `JobSpec` bytes. ALL normalize/validate/encode
191
+ # LOGIC lives in `-codec`'s `job_spec` module; this wrapper (like the wasm
192
+ # bridge it calls) only (de)serializes and calls it — no protocol logic here
193
+ # either. See the crate-level ABI doc in
194
+ # ``crates/cowboy-protocol-wasm-ffi/src/lib.rs`` for the verbatim contract.
195
+
196
+
197
+ def normalize_job_request(request_json: str) -> bytes:
198
+ """Normalize a friendly ``JobRequest`` JSON document into canonical bytes.
199
+
200
+ ``request_json`` is a ``JobRequest`` JSON document (UTF-8 on the wire).
201
+ Returns ``normalize(request).encode()`` — the canonical ``JobSpec`` bytes.
202
+
203
+ Raises :class:`CowboyProtocolError` on malformed JSON, or the (currently
204
+ infallible-in-practice) ``ValidationError`` ``normalize`` itself can
205
+ return.
206
+ """
207
+ if not isinstance(request_json, str):
208
+ raise TypeError(
209
+ f"request_json must be str, got {type(request_json).__name__}"
210
+ )
211
+ return host().call("cpw_normalize_job_request", request_json.encode("utf-8"))
212
+
213
+
214
+ def encode_job_spec_json(spec_json: str) -> bytes:
215
+ """Encode a ``JobSpec`` JSON document into canonical bytes.
216
+
217
+ ``spec_json`` is a ``JobSpec`` JSON document (UTF-8 on the wire). Returns
218
+ ``spec.encode()`` — the canonical ``JobSpec`` bytes.
219
+
220
+ Raises :class:`CowboyProtocolError` on malformed/incomplete JSON.
221
+ """
222
+ if not isinstance(spec_json, str):
223
+ raise TypeError(f"spec_json must be str, got {type(spec_json).__name__}")
224
+ return host().call("cpw_encode_job_spec_json", spec_json.encode("utf-8"))
225
+
226
+
227
+ def decode_job_spec_to_json(spec_bytes: bytes) -> str:
228
+ """Decode canonical ``JobSpec`` bytes and re-serialize them as JSON.
229
+
230
+ ``spec_bytes`` must be ``JobSpec::decode_canonical``-valid canonical bytes
231
+ (e.g. the output of :func:`normalize_job_request` or
232
+ :func:`encode_job_spec_json`).
233
+
234
+ Raises :class:`CowboyProtocolError` if the bytes fail ``decode_canonical``
235
+ (bad version byte, trailing bytes, a truncated field, an over-cap length
236
+ prefix, ...).
237
+ """
238
+ if not isinstance(spec_bytes, (bytes, bytearray)):
239
+ raise TypeError(
240
+ f"spec_bytes must be bytes, got {type(spec_bytes).__name__}"
241
+ )
242
+ return host().call("cpw_decode_job_spec_to_json", bytes(spec_bytes)).decode(
243
+ "utf-8"
244
+ )
245
+
246
+
247
+ def validate_job_spec_bytes(spec_bytes: bytes) -> bytes:
248
+ """Run ``-codec``'s ``validate()`` over canonical ``JobSpec`` bytes.
249
+
250
+ ``spec_bytes`` must be ``JobSpec::decode_canonical``-valid canonical
251
+ bytes.
252
+
253
+ **Verdict convention**: the return value is itself the verdict, NOT a
254
+ boolean. A validation VERDICT (accept or reject) is always a
255
+ success-path result:
256
+
257
+ * **empty bytes (``b""``) = ACCEPT** — the spec passed ``validate()``.
258
+ * **non-empty bytes = REJECT** — a UTF-8 JSON object
259
+ ``{"code", "field", "message"}`` (the ``ValidationError``'s own
260
+ fields) describing why ``validate()`` rejected the spec.
261
+
262
+ A :class:`CowboyProtocolError` is raised ONLY when the input does not
263
+ even decode into a well-formed ``JobSpec`` (a decode failure is a
264
+ distinct, FFI-level error from a well-formed spec that ``validate()``
265
+ rejects — see the crate doc's ``cpw_validate_job_spec_bytes`` entry).
266
+ """
267
+ if not isinstance(spec_bytes, (bytes, bytearray)):
268
+ raise TypeError(
269
+ f"spec_bytes must be bytes, got {type(spec_bytes).__name__}"
270
+ )
271
+ return host().call("cpw_validate_job_spec_bytes", bytes(spec_bytes))
272
+
273
+
274
+ def validate_job_spec_json(spec_json: str) -> bytes:
275
+ """Run ``-codec``'s ``validate()`` over a ``JobSpec`` JSON document.
276
+
277
+ Same **verdict convention** as :func:`validate_job_spec_bytes`: empty
278
+ bytes = accept, non-empty bytes = a ``{"code", "field", "message"}``
279
+ reject verdict. A :class:`CowboyProtocolError` is raised only when
280
+ ``spec_json`` fails to even deserialize into a ``JobSpec``.
281
+ """
282
+ if not isinstance(spec_json, str):
283
+ raise TypeError(f"spec_json must be str, got {type(spec_json).__name__}")
284
+ return host().call("cpw_validate_job_spec_json", spec_json.encode("utf-8"))
285
+
286
+
287
+ # ─── RAS + CBFS control-plane signing bytes (COW-2636 / COW-2649 completion) ──
288
+ #
289
+ # These surface the canonical SIGNING-BYTE contract `-codec`'s `ras` +
290
+ # `cbfs_signing` modules own — the same big-endian, EIP-55-address,
291
+ # `Encoder`-domain bytes the chain (node) and the cbfs control-plane verify. A
292
+ # non-Rust signer (the python-sdk) MUST derive the bytes it signs from here, not
293
+ # re-implement them: a byte-level fork is exactly the "two implementations that
294
+ # must agree" split that caused the 2026-07-11 outage (COW-2608). Every byte
295
+ # comes from the SAME wasm the native golden gate pins (byte-identity proven by
296
+ # `tests/test_golden.py` against `crates/cowboy-protocol-codec/fixtures/ras/`).
297
+ #
298
+ # Each takes ONE canyon-JSON document — the serde shape of the `-codec` struct
299
+ # named in the docstring, byte-for-byte the fixtures' `input` (fixed-width byte
300
+ # fields are JSON arrays of `u8`; `Address` is the `0x…` string; `Option<T>` is
301
+ # the value or `null`). See the crate-level ABI doc in
302
+ # ``crates/cowboy-protocol-wasm-ffi/src/lib.rs`` for the verbatim contract.
303
+
304
+
305
+ def _signing_json(value: str, param: str) -> bytes:
306
+ """Validate a signing-input JSON string and encode it UTF-8 for the ABI."""
307
+ if not isinstance(value, str):
308
+ raise TypeError(f"{param} must be str, got {type(value).__name__}")
309
+ return value.encode("utf-8")
310
+
311
+
312
+ def owner_authorization_signing_bytes(authorization_json: str) -> bytes:
313
+ """Return ``ras_owner_authorization_signing_bytes()`` for a ``RasOwnerAuthorization``.
314
+
315
+ The CBFS-key authorization over one RAS owner operation (CIP-9 §12.2.1).
316
+ ``authorization_json`` is the ``RasOwnerAuthorization`` JSON shape:
317
+ ``{version: u8, owner_address: "0x…", delegated_pubkey: [32 u8],
318
+ volume_id: [32 u8] | null, operation: str, valid_from_epoch: u64,
319
+ valid_until_epoch: u64, nonce: [16 u8], payload_hash: [32 u8],
320
+ signature: [64 u8]}``.
321
+
322
+ ``operation`` must be a known RAS owner op — the codec fails closed
323
+ (raising :class:`CowboyProtocolError` ``"unknown RAS operation"``) on an
324
+ unrecognized operation, so the signing bytes can never be computed for an
325
+ off-registry op.
326
+ """
327
+ return host().call(
328
+ "cpw_ras_owner_authorization_signing_bytes",
329
+ _signing_json(authorization_json, "authorization_json"),
330
+ )
331
+
332
+
333
+ def owner_delegation_signing_bytes(delegation_json: str) -> bytes:
334
+ """Return ``ras_owner_delegation_signing_bytes()`` for a ``RasOwnerDelegation``.
335
+
336
+ The epoch-scoped owner delegation for relayer-submitted RAS instructions.
337
+ ``delegation_json`` is the ``RasOwnerDelegation`` JSON shape:
338
+ ``{wallet_address: "0x…", delegated_pubkey: [32 u8], scopes: [str],
339
+ service_class: ServiceClass,
340
+ chain_id: u64, network: str, aud: str, valid_from_epoch: u64,
341
+ valid_until_epoch: u64, signature: [65 u8]}``.
342
+
343
+ ``ServiceClass`` is ``"MOUNT_CAPABLE_V1"``, ``"NON_MOUNT_SERVICE_V1"``,
344
+ ``{"VOLUME_BOUND_SERVICE_V1": {volume_id: [32 u8], workload_id: [32 u8]}}``,
345
+ ``{"WORKLOAD_MOUNT_V1": {volume_id: [32 u8], workload_id: [32 u8]}}`` or
346
+ ``{"SERVICE_RELEASE_V1": {volume_id: [32 u8]}}``.
347
+ """
348
+ return host().call(
349
+ "cpw_ras_owner_delegation_signing_bytes",
350
+ _signing_json(delegation_json, "delegation_json"),
351
+ )
352
+
353
+
354
+ def relay_authorization_signing_bytes(authorization_json: str) -> bytes:
355
+ """Return ``ras_relay_authorization_signing_bytes()`` for a ``RasRelayAuthorization``.
356
+
357
+ The relay-identity (Ed25519) proof over relay registry metadata.
358
+ ``authorization_json`` is the ``RasRelayAuthorization`` JSON shape:
359
+ ``{version: u8, owner_address: "0x…", node_id: [32 u8], operation: str,
360
+ valid_from_epoch: u64, valid_until_epoch: u64, nonce: [16 u8],
361
+ payload_hash: [32 u8], signature: [64 u8]}``.
362
+
363
+ ``operation`` must be a known RAS relay op (same fail-closed registry gate
364
+ as :func:`owner_authorization_signing_bytes`).
365
+ """
366
+ return host().call(
367
+ "cpw_ras_relay_authorization_signing_bytes",
368
+ _signing_json(authorization_json, "authorization_json"),
369
+ )
370
+
371
+
372
+ def delegation_cert_signing_bytes(cert_json: str) -> bytes:
373
+ """Return ``delegation_cert_signing_bytes()`` for a ``DelegationCert`` (CIP-9 §7).
374
+
375
+ The canonical bytes the wallet's secp256k1 key signs to establish a
376
+ delegation certificate (every field EXCEPT ``signature``).
377
+ ``cert_json`` is the ``DelegationCert`` JSON shape:
378
+ ``{wallet_address: "0x…", cbfs_public_key: [32 u8], scope: str,
379
+ service_class: ServiceClass,
380
+ chain_id: u64, network: str, aud: str, expires_at_ms: u64,
381
+ signature: [u8]}``.
382
+
383
+ ``ServiceClass`` is ``"MOUNT_CAPABLE_V1"``, ``"NON_MOUNT_SERVICE_V1"``,
384
+ ``{"VOLUME_BOUND_SERVICE_V1": {volume_id: [32 u8], workload_id: [32 u8]}}``,
385
+ ``{"WORKLOAD_MOUNT_V1": {volume_id: [32 u8], workload_id: [32 u8]}}`` or
386
+ ``{"SERVICE_RELEASE_V1": {volume_id: [32 u8]}}``.
387
+ """
388
+ return host().call(
389
+ "cpw_cbfs_delegation_cert_signing_bytes",
390
+ _signing_json(cert_json, "cert_json"),
391
+ )
392
+
393
+
394
+ def delegation_cert_hash(cert_json: str) -> bytes:
395
+ """Return ``delegation_cert_hash()`` — the 32-byte keccak256 of
396
+ :func:`delegation_cert_signing_bytes`.
397
+
398
+ The digest the wallet's secp256k1 key actually signs (and which owner
399
+ cap-token verification recovers the wallet address from). ``cert_json`` is
400
+ the same ``DelegationCert`` JSON shape as
401
+ :func:`delegation_cert_signing_bytes`; returns 32 raw bytes.
402
+ """
403
+ return host().call(
404
+ "cpw_cbfs_delegation_cert_hash",
405
+ _signing_json(cert_json, "cert_json"),
406
+ )
407
+
408
+
409
+ def owner_cap_token_signing_bytes(token_json: str) -> bytes:
410
+ """Return ``owner_cap_token_signing_bytes()`` for an ``OwnerCapTokenV1``.
411
+
412
+ The canonical bytes the cbfs Ed25519 key signs for an owner-issued
413
+ capability token (covers every field except ``owner_signature``; the
414
+ embedded delegation cert is included in its WIRE form, signature and all).
415
+ ``token_json`` is the ``OwnerCapTokenV1`` JSON shape:
416
+ ``{version: u8, volume_id: [32 u8], volume_name: str, owner_address: "0x…",
417
+ access_mode: "WriteOnly" | "ReadOnly" | "ReadWrite", path_prefix: str,
418
+ valid_from_ms: u64, valid_until_ms: u64, mount: bool, sync_interval: u32,
419
+ delegation_cert: {DelegationCert}, owner_signature: [u8]}`` — where
420
+ ``delegation_cert`` is the nested :func:`delegation_cert_signing_bytes`
421
+ shape.
422
+ """
423
+ return host().call(
424
+ "cpw_cbfs_owner_cap_token_signing_bytes",
425
+ _signing_json(token_json, "token_json"),
426
+ )
427
+
428
+
429
+ def delegated_request_signing_bytes(request_input_json: str) -> bytes:
430
+ """Return ``delegated_request_signing_bytes()`` for a per-request signature.
431
+
432
+ The challenge-bound bytes the cbfs key signs so a captured token cannot be
433
+ replayed (method ‖ path ‖ ``keccak256(body)`` ‖ timestamp ‖ challenge).
434
+ ``request_input_json`` is the ``DelegatedRequestInput`` JSON shape:
435
+ ``{method: str, path_and_query: str, body: [u8], timestamp_ms: u64,
436
+ nonce_or_challenge: [16 u8]}`` (``body`` is the raw request body as a JSON
437
+ array of ``u8``; it is hashed, not embedded verbatim).
438
+ """
439
+ return host().call(
440
+ "cpw_cbfs_delegated_request_signing_bytes",
441
+ _signing_json(request_input_json, "request_input_json"),
442
+ )
443
+
444
+
445
+ def relay_usage_signing_bytes(usage_input_json: str) -> bytes:
446
+ """Return ``relay_usage_signing_bytes()`` for a relay usage report.
447
+
448
+ The domain-separated bytes a relay's Ed25519 identity key signs for a
449
+ periodic registry usage report (covers every ``report`` field except its
450
+ own ``signature``). ``usage_input_json`` is the ``RelayUsageSigningInput``
451
+ JSON shape: ``{chain_id: u64, network: str, report: {ReportUsageRequest}}``
452
+ where ``report`` carries the full usage counters plus any
453
+ ``drain_absorption_receipts`` (each folded in whole, signature included).
454
+ """
455
+ return host().call(
456
+ "cpw_cbfs_relay_usage_signing_bytes",
457
+ _signing_json(usage_input_json, "usage_input_json"),
458
+ )
@@ -0,0 +1,204 @@
1
+ """Internal wasmtime host for the ``cowboy-protocol-wasm-ffi`` module.
2
+
3
+ This module loads the bundled non-JS, flat-C-ABI ``.wasm`` (built from
4
+ ``crates/cowboy-protocol-wasm-ffi`` via ``build-ffi.sh``) with the
5
+ ``wasmtime`` pip package and drives its flat ABI:
6
+
7
+ * ``cpw_alloc(len) -> ptr`` (host input buffers)
8
+ * ``cpw_free(ptr, len)`` (free input + output)
9
+ * ``cpw_<fn>(in_ptr, in_len) -> u64`` (packed ``(ptr<<32)|len``)
10
+ * ``cpw_last_error() -> u64`` (packed error message)
11
+
12
+ Every codec call returns a packed ``u64``: ``out_ptr = ret >> 32`` and
13
+ ``out_len = ret & 0xFFFF_FFFF``. ``out_ptr == 0`` is the error sentinel; on
14
+ error we read ``cpw_last_error`` and raise :class:`CowboyProtocolError`.
15
+
16
+ The module declares NO imports (no ``__wbindgen_*``, no ``getrandom/js``), so it
17
+ instantiates with an empty import set. See the crate-level docs in
18
+ ``crates/cowboy-protocol-wasm-ffi/src/lib.rs`` for the verbatim ABI contract.
19
+
20
+ This host is single-threaded by design (mirrors the wasm's single-instance,
21
+ ``thread_local`` last-error slot). A module-global lock serializes all calls so
22
+ the alloc/call/read/free dance and the last-error read stay coherent even if a
23
+ caller touches the API from multiple threads.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import importlib.resources
29
+ import threading
30
+
31
+ from wasmtime import Engine, Instance, Module, Store
32
+
33
+ #: Filename of the bundled wasm artifact (committed package data; kept in sync
34
+ #: with a fresh ``build-ffi.sh`` build by the CI anti-drift gate).
35
+ WASM_FILENAME = "cowboy_protocol_wasm_ffi.wasm"
36
+
37
+ #: The flat-ABI codec exports, each ``(in_ptr: u32, in_len: u32) -> u64``: the
38
+ #: eight transaction-codec exports, the five ``JobSpec`` bridges (COW-2440
39
+ #: slice-4 Part A), and the eight RAS + CBFS control-plane signing/hash bridges
40
+ #: (COW-2636/2649 completion WS-P1).
41
+ _CODEC_FNS = (
42
+ "cpw_signing_preimage",
43
+ "cpw_signing_hash",
44
+ "cpw_signing_hash_hex",
45
+ "cpw_encode_transaction",
46
+ "cpw_encode_submission",
47
+ "cpw_sign",
48
+ "cpw_sign_transaction",
49
+ "cpw_address_from_private_key",
50
+ "cpw_normalize_job_request",
51
+ "cpw_encode_job_spec_json",
52
+ "cpw_decode_job_spec_to_json",
53
+ "cpw_validate_job_spec_bytes",
54
+ "cpw_validate_job_spec_json",
55
+ # RAS consensus-auth signing bytes (COW-2636).
56
+ "cpw_ras_owner_authorization_signing_bytes",
57
+ "cpw_ras_owner_delegation_signing_bytes",
58
+ "cpw_ras_relay_authorization_signing_bytes",
59
+ # CBFS control-plane signing bytes + the delegation-cert hash (COW-2649).
60
+ "cpw_cbfs_delegation_cert_signing_bytes",
61
+ "cpw_cbfs_delegation_cert_hash",
62
+ "cpw_cbfs_owner_cap_token_signing_bytes",
63
+ "cpw_cbfs_delegated_request_signing_bytes",
64
+ "cpw_cbfs_relay_usage_signing_bytes",
65
+ )
66
+
67
+
68
+ class CowboyProtocolError(Exception):
69
+ """Raised when a wasm codec call returns the error sentinel.
70
+
71
+ The message is the UTF-8 string retrieved from ``cpw_last_error`` (e.g.
72
+ ``"invalid transaction JSON: ..."`` or ``"invalid secp256k1 private
73
+ key: ..."``).
74
+ """
75
+
76
+
77
+ def _wasm_bytes() -> bytes:
78
+ """Read the bundled ``.wasm`` from package data."""
79
+ # Chain single-arg joinpath calls: multi-arg Traversable.joinpath(*parts) is
80
+ # only guaranteed on 3.11+, but this package supports 3.10+ (a zip/namespace
81
+ # import on 3.10 would TypeError on the multi-arg form).
82
+ return (
83
+ importlib.resources.files(__package__)
84
+ .joinpath("_wasm")
85
+ .joinpath(WASM_FILENAME)
86
+ .read_bytes()
87
+ )
88
+
89
+
90
+ class _Host:
91
+ """A loaded wasm instance plus typed handles to its flat ABI.
92
+
93
+ All public methods take/return raw ``bytes`` (or ``str`` for the error
94
+ path); the higher-level Pythonic API lives in :mod:`cowboy_protocol`.
95
+ """
96
+
97
+ def __init__(self) -> None:
98
+ engine = Engine()
99
+ module = Module(engine, _wasm_bytes())
100
+
101
+ # No-JS-imports invariant: a non-JS wasm host provides NO imports. The
102
+ # module must declare none (any __wbindgen_* / getrandom-js import would
103
+ # appear here and would make the empty-import instantiation below fail).
104
+ imports = list(module.imports)
105
+ if imports:
106
+ names = [f"{i.module}::{i.name}" for i in imports]
107
+ raise CowboyProtocolError(
108
+ "ffi wasm must have NO imports (no JS/wasm-bindgen host "
109
+ f"coupling); found: {names}"
110
+ )
111
+
112
+ self._store = Store(engine)
113
+ instance = Instance(self._store, module, [])
114
+ exports = instance.exports(self._store)
115
+
116
+ self._memory = exports["memory"]
117
+ self._alloc = exports["cpw_alloc"]
118
+ self._free = exports["cpw_free"]
119
+ self._last_error = exports["cpw_last_error"]
120
+ self._codec = {name: exports[name] for name in _CODEC_FNS}
121
+ self._lock = threading.Lock()
122
+
123
+ # ── linear-memory helpers ────────────────────────────────────────────────
124
+
125
+ def _read_mem(self, ptr: int, length: int) -> bytes:
126
+ """Copy ``[ptr, ptr+length)`` out of wasm linear memory."""
127
+ if length == 0:
128
+ return b""
129
+ data = self._memory.read(self._store, ptr, ptr + length)
130
+ return bytes(data)
131
+
132
+ def _write_input(self, data: bytes) -> int:
133
+ """Allocate a wasm buffer and copy ``data`` into it; return its ptr.
134
+
135
+ Returns 0 for empty input (matching ``cpw_alloc(0) == 0``); the codec
136
+ fns treat ``(0, 0)`` as an empty slice.
137
+ """
138
+ if not data:
139
+ return 0
140
+ ptr = self._alloc(self._store, len(data))
141
+ if ptr == 0:
142
+ raise CowboyProtocolError(
143
+ f"cpw_alloc returned null for a {len(data)}-byte input (OOM?)"
144
+ )
145
+ self._memory.write(self._store, data, ptr)
146
+ return ptr
147
+
148
+ def _take_last_error(self) -> str:
149
+ """Read + free the pending ``cpw_last_error`` message (or a fallback)."""
150
+ packed = self._last_error(self._store)
151
+ ptr = (packed >> 32) & 0xFFFF_FFFF
152
+ length = packed & 0xFFFF_FFFF
153
+ if ptr == 0:
154
+ return "<no error message>"
155
+ msg = self._read_mem(ptr, length)
156
+ self._free(self._store, ptr, length)
157
+ return msg.decode("utf-8", errors="replace")
158
+
159
+ # ── the alloc / write / call / read / free dance ─────────────────────────
160
+
161
+ def call(self, func: str, data: bytes) -> bytes:
162
+ """Invoke a codec export and return its raw output bytes.
163
+
164
+ Handles the full ABI dance: alloc + write the input, call, read the
165
+ packed return, copy the output out, and free BOTH buffers (in a
166
+ ``finally`` so they are reclaimed even if a step raises). On the error
167
+ sentinel (``out_ptr == 0``) reads ``cpw_last_error`` and raises
168
+ :class:`CowboyProtocolError`.
169
+ """
170
+ fn = self._codec[func]
171
+ with self._lock:
172
+ in_ptr = self._write_input(data)
173
+ in_len = len(data)
174
+ try:
175
+ packed = fn(self._store, in_ptr, in_len)
176
+ finally:
177
+ if in_ptr != 0:
178
+ self._free(self._store, in_ptr, in_len)
179
+
180
+ out_ptr = (packed >> 32) & 0xFFFF_FFFF
181
+ out_len = packed & 0xFFFF_FFFF
182
+ if out_ptr == 0:
183
+ raise CowboyProtocolError(self._take_last_error())
184
+ try:
185
+ return self._read_mem(out_ptr, out_len)
186
+ finally:
187
+ self._free(self._store, out_ptr, out_len)
188
+
189
+
190
+ # A single process-wide host instance, created lazily on first use. The wasm is
191
+ # single-threaded and the host serializes calls under a lock, so one instance is
192
+ # correct and avoids re-reading/compiling the module per call.
193
+ _HOST: _Host | None = None
194
+ _HOST_LOCK = threading.Lock()
195
+
196
+
197
+ def host() -> _Host:
198
+ """Return the process-wide :class:`_Host`, instantiating it on first call."""
199
+ global _HOST
200
+ if _HOST is None:
201
+ with _HOST_LOCK:
202
+ if _HOST is None:
203
+ _HOST = _Host()
204
+ return _HOST