Skip to content

Finish the porting of WabiSabi credential system with CLSAG ring signatures and add Python bindings#1

Open
m0wer wants to merge 19 commits into
WalletWasabi:masterfrom
m0wer:master
Open

Finish the porting of WabiSabi credential system with CLSAG ring signatures and add Python bindings#1
m0wer wants to merge 19 commits into
WalletWasabi:masterfrom
m0wer:master

Conversation

@m0wer

@m0wer m0wer commented May 26, 2026

Copy link
Copy Markdown

Overview

Full implementation of the WabiSabi anonymous credential protocol (ported from WalletWasabi C#) with CLSAG-style linkable ring signatures, a complete PyO3 Python extension module, and a registration show proof. Brings the crate from a stub to a functionally complete, test-verified library at version 0.3.0.


Changes

Cryptographic correctness

  • Replace hand-rolled scalar/group arithmetic with k256::Scalar / k256::ProjectivePoint; fixes a buggy schoolbook multiplier and InvalidGroupElement on zero-scalar multiply
  • Fix CredentialIssuerSecretKey::compute_parameters — issuer parameter I was ya*Ga instead of Gv - x0*Gx0 - x1*Gx1 - ya*Ga, breaking all credential presentation verification
  • Fix Credential::value_as_scalar LE/BE mismatch (now uses Scalar::from_u64)
  • Rewrite Generators::from_text to match WalletWasabi's sqrt(x³+7) mod p hash-to-curve construction
  • Fix GroupElement serde: deserializer was reading Vec<u8> (length-prefixed) instead of a 33-tuple, breaking all bincode/postcard round-trips

Zero-knowledge proof system

  • Port all canonical WabiSabi ProofSystem statement constructors: issuer_parameters, show_credential, balance_proof, range_proof (full bit-decomposition, up to 51-bit), zero_proof, pedersen_commitment
  • Tighten Knowledge::new to validate witness width against every equation row
  • Unify WabiSabiClient and CredentialIssuer onto a single UnifiedRegistration/{N}/{isNull} transcript; fix range_proof_knowledge to return the original Ma (not Ma - sum(2^i * B_i))
  • Add RegistrationShow proof: reveals credential amount and exposes compressed serial point, transcript-bound to a caller-supplied label

Protocol features

  • CredentialIssuer::with_max_amount / with_range_proof_width builders + matching client-side pair so both sides agree on bit width
  • Credential::serial() accessor for coordinator-level dedup
  • Generators::from_text associated function (mirrors C# Generators.FromText)
  • Derive PartialEq/Eq on CredentialPresentation, ZeroCredentialsRequest, RealCredentialsRequest
  • Drop two dead placeholder methods (create_presentation_witness, create_knowledge_statement) that encoded incorrect witness layouts

CLSAG ring signatures (src/crypto/clsag.rs)

  • Single-key LSAG over secp256k1 with per-run rotating key image; within-run Sybil linkability, cross-run unlinkability
  • Hash-to-point via secp256k1_XMD:SHA-256_SSWU_RO_ with DST prefix jmng/clsag/v1/
  • Wire format: 33 + 32 + 32*N bytes (compressed key image, c0, one s_i per ring member)

Python bindings (PyO3, --features python)

  • generate_issuer_secret_key(), derive_issuer_parameters(sk)
  • CredentialIssuer: configure(max_amount, width), handle_request, reset
  • WabiSabiClient: configure, create_request_for_zero_amount, create_request, handle_response
  • Credential: to_bytes / from_bytes, serial()
  • WabiSabiClient.present_for_registration(cred, label), CredentialIssuer.verify_registration(blob, amount, label)
  • clsag_sign, clsag_verify (returns (bool, key_image_bytes)), clsag_key_image
  • abi3-py311 wheel; extension-module cdylib; bincode at all FFI boundaries; opaque ValidationHandle to hold non-serialisable Strobe transcript state

Test coverage

  • Port of WalletWasabi unit tests: MAC, IssuerKey, ProofSystem, TranscriptTests (synthetic nonce secret-dependence with zero-entropy RNG), CredentialTests (lifecycle, invalid-request, double-spend, range-proof width), CredentialRequesting equality tests
  • 8 CLSAG unit tests; 5 PyO3-level FFI tests; 183 Rust tests total

Version bumps

Version Reason
0.1.x → 0.2.0 New public CLSAG API + Python bindings surface
0.2.0 → 0.3.0 Registration show proof + Credential::serial()

FYI: the last three commits are new, not a strict port. But needed them for some project.


PS: Thanks for creating the library, and for starting the Rust port!

m0wer added 19 commits May 6, 2026 17:51
The hand-rolled modular arithmetic in crypto/scalar.rs reimplemented add/sub/mul
mod n on top of secp256k1::Scalar (which is just a 32-byte tweak with no
arithmetic), with a buggy schoolbook multiplier and no documented operator
contract. Replace it with a thin newtype around k256::Scalar that delegates to
the audited RustCrypto implementation. Also rebase GroupElement on
k256::ProjectivePoint to fix InvalidGroupElement returns when multiplying by
zero (the zero-amount credential path).

While here, fix Credential::value_as_scalar which copied the value as
little-endian into a buffer that Scalar::from_bytes parsed as big-endian
(silently corrupting credential amounts). Use Scalar::from_u64 directly.

Also rewrite Generators::from_text to match the WalletWasabi C# hash-to-curve
construction (compute y = sqrt(x^3 + 7) mod p with natural parity) instead of
the previous try-both-parities-of-the-hash-as-x heuristic which produced
different points than the reference implementation.

Test result before: 60 passing, 9-11 failing.
Test result after:  67 passing, 5 failing.
…ation

CredentialIssuerSecretKey::compute_parameters returned I = ya*Ga, but the
issuer-side check Z = CV - (w*Gw + x0*Cx0 + x1*Cx1 + ya*Ca) only reduces to
z*I when I = Gv - x0*Gx0 - x1*Gx1 - ya*Ga. With the old I, every credential
presentation verification would have failed for a non-trivial credential.

Also fix two test fixtures in zero_knowledge::credential_presentation that
built value_scalar by copying 8 LE bytes into a 32-byte buffer parsed as BE,
making the credential's stored value disagree with the Ma it was issued
against. Use Scalar::from_u64 directly.

Tests: 69 passing (was 68), 3 failing (was 4).
verify_request_proofs aggregated the prover's per-credential
knowledge-of-randomness proofs against verifier-built balance + range
statements that did not match in count or shape, so every issuance
request failed before reaching the MAC step. Until the full real-amount
proof system is wired up, detect a zero-amount request (no presented
credentials, delta == 0, no bit commitments) and verify each requested
Ma_i against the matching Statement Ma_i = r_i * Gh, which is exactly
what create_request_for_zero_amount produces. Real-amount requests are
allowed through unchecked for now with a TODO comment.

Also bulk-fix lingering Scalar::from_bytes-of-LE-bytes bugs in
tests/credential_tests.rs and the csharp_ported tests to use
Scalar::from_u64 directly.

Tests: lib 72 pass, integration 67 pass (139 total).
Implement the canonical statement and knowledge constructors from
WalletWasabi.Crypto.ZeroKnowledge.ProofSystem so prover and verifier
build byte-identical Sigma protocol matrices:

- issuer_parameters_{statement,knowledge}: 3-equation proof of (w, wp,
  x0, x1, ya) underlying both Cw and the issued MAC.
- show_credential_{statement,knowledge}: 4-equation proof binding the
  randomized presentation (Ca, Cx0, Cx1, S) to (z, value, randomness, t)
  with witness order [z, -t*z, t, value, randomness].
- balance_proof_{statement,knowledge}: single-equation proof that the
  balance commitment commits to zero with witness [zSum, rDeltaSum].
- range_proof_{statement,knowledge}: full bit-decomposition statement
  (2*width + 1 equations) including per-bit Pedersen commitments and
  the b in {0,1} sub-proofs. Witness layout [r, b_i, r_i, rb_i].
- zero_proof_{statement,knowledge}: range proof of width 0, used for
  bootstrap credential requests.
- pedersen_commitment helper.

Also tighten Knowledge::new so the witness width is validated against
every equation, not just the first; multi-row matrices produced by
from_matrix already pad with infinity for unused columns.

8 round-trip tests cover prover -> verifier interop for each statement
including a full 51-bit range proof (the typical Bitcoin sat range bound).

Changelog: Add canonical WabiSabi ProofSystem statement and knowledge
constructors required for verifiable real-amount credential issuance
Rewrite WabiSabiClient and CredentialIssuer to share a single
`UnifiedRegistration/{N}/{isNull}` transcript across presentations,
range/zero proofs, balance proof, and MAC issuance proofs (matches
WalletWasabi C# canonical algorithm).

Fix root cause of failing real-amount round-trip: range_proof_knowledge
now returns the original Ma alongside the bit commitments. Previously
the client extracted Ma from `statement.public_points()[0]`, which for
width > 0 holds `Ma - sum(2^i * B_i)` rather than Ma itself. The
malformed Ma was then sent in the IssuanceRequest, causing the issuer
to rebuild a different range-proof statement and a different balance
commitment, so the Fiat-Shamir challenges diverged and verification
failed. Width-0 (zero issuance) was unaffected because the subtracted
sum is empty.

Add IssuanceValidationData and CredentialsResponseValidation to carry
the post-issuance state needed to finalize MACs into Credentials.

Add WabiSabiError::InvalidAmount.

Add tests/round_trip_tests.rs::zero_then_real_round_trip exercising
both the null and real-amount issuance paths back-to-back.

Changelog: WabiSabi credential issuance now supports real (non-zero)
amounts with full proof verification round-trip.
Adds Rust ports of the splitting and invalid-request scenarios from
WalletWasabi.Tests/UnitTests/WabiSabi/CredentialTests.cs:

- credential_issuance_full_lifecycle: zero -> real -> reissuance/split
- invalid_request_wrong_number_of_requested
- invalid_request_wrong_number_of_presented
- invalid_request_bad_bit_commitments
- invalid_request_swapped_zero_proofs
- double_spend_serial_number_rejected
- range_proof_width_matches_max_amount

Required wiring on the issuer:
- explicit InvalidNumberOfRequestedCredentials/PresentedCredentials errors
- per-request bit-commitment width validation
- with_max_amount builder + range_proof_width/max_amount accessors

And the client gained a matching with_max_amount/range_proof_width pair so
both sides agree on the bit width derived from the configured maximum
amount, mirroring CredentialIssuer.cs.
Adds Rust ports of WalletWasabi.Tests/UnitTests/Crypto/{MacTests,
CredentialIssuerKeyTests}.cs:

- mac_rejects_zero_t / mac_rejects_infinity_attribute
- mac_can_produce_and_verify, mac_can_detect_invalid
- mac_equality_truth_table (full 8-case combinatorial sweep)
- issuer_secret_key_rejects_zero_in_each_field
- issuer_secret_key_accepts_all_nonzero
- issuer_parameters_rejects_infinity

Required wiring on the crypto layer:
- WabiSabiError::ZeroScalar { name } and ::PointAtInfinity { name } that
  carry the offending parameter name, matching the C# ArgumentException
  messages.
- CredentialIssuerSecretKey::try_from_scalars: validating constructor that
  rejects any zero scalar component. CredentialIssuerSecretKey::new now
  loops on the (negligible) chance of a zero from the random source so its
  output is always valid.
- CredentialIssuerParameters::try_new: validating constructor that rejects
  the point at infinity for either group element.

SchnorrBlinding tests are intentionally not ported: the legacy ECDSA
blind-signature module is not part of the WabiSabi credential system and
Downstream consumers use plain BIP340 Schnorr instead.
Direct port of WalletWasabi.Tests/UnitTests/Crypto/ProofSystemTests.cs
covering the high-level statement constructors:

- can_prove_and_verify_mac: issuer-parameters knowledge round-trip plus
  tamper detection via reversed responses or nonces.
- can_prove_and_verify_mac_show: show-credential proof against a
  randomized presentation, asserting Z = z*I.
- can_prove_and_verify_presented_balance: balance proof for a presented
  credential B = Ca - a*Gg, with shifted/raw rejection cases.
- can_prove_and_verify_requested_balance: balance proof for a requested
  attribute B = a*Gg - Ma using the (0, -r) witness.
- can_prove_and_verify_balance_*: 12 theory cases mirroring the C#
  parameterised test, covering zero, equal, asymmetric and 32-bit
  boundary deltas.
- can_prove_and_verify_zero_proofs: paired zero-attribute proofs
  through the multi-knowledge transcript.
- zero_proof_rejects_nonzero_attribute: assert_soundness rejects an
  attribute that does not commit to zero.

All 179 tests pass.
Add the deterministic-RNG variant of the synthetic-nonces divergence
test (WalletWasabi.Tests/UnitTests/Crypto/TranscriptTests.cs).

The existing test_synthetic_nonces_uniqueness covers the SecureRandom
case where every provider receives independent entropy, but cannot
distinguish 'transcript state caused divergence' from 'fresh randomness
caused divergence'. This new test feeds every provider a StepRng(0, 0)
zero-byte stream so the only entropy source is the transcript-bound
state. We assert:

  - different commitment OR different witness still produce distinct
    nonces (transcript state is the sole differentiator), and
  - identical commitment + identical witness + zero RNG produce
    identical nonces (sanity check that the only divergence vector
    is the transcript state itself).

This guards the safety claim that synthetic nonces stay unique even
when the host RNG is broken.

The accompanying StrobeTestVectors.json from the WalletWasabi suite is
not vendored in the upstream tree we cloned; Strobe-128 conformance is
already validated by strobe-rs's own KAT suite, so we do not duplicate
StrobeTests.cs.

9 transcript tests pass; 180 total.
Two small library additions to support test ports of the
WabiSabi credential-requesting wire surface:

* Derive PartialEq + Eq on CredentialPresentation,
  ZeroCredentialsRequest, and RealCredentialsRequest. Their fields
  are already structurally comparable (GroupElement, IssuanceRequest,
  Proof) so the derive is free, and downstream tests/binding code
  can now check protocol-level wire equality without manual
  field-by-field comparison.

* Re-expose generators::from_text as Generators::from_text. The
  free function was already public but had no associated-function
  surface, while WalletWasabi tests and downstream code routinely
  call it as Generators.FromText(label). Forwarding it from the
  Generators impl matches the C# spelling without changing
  behaviour.
Direct port of the WalletWasabi credential-request equality tests:

* WalletWasabi.Tests/UnitTests/WabiSabi/Crypto/CredentialRequesting/
    ZeroCredentialsRequestTests.cs::EqualityTest
* WalletWasabi.Tests/UnitTests/WabiSabi/Crypto/CredentialRequesting/
    RealCredentialsRequestTests.cs::EqualityTest

Both upstream tests guard the wire-DTO equality contract: two requests
built from identical inputs must compare equal, and a one-byte change
in the Ma attribute commitment must compare not-equal. We mirror the
exact factory shape (Generators::from_text labels, Proof responses,
IssuanceRequest with two bit commitments, RealCredentialsRequest with
delta=123_456 and a single CredentialPresentation) so the test reads
1:1 against the C# source.

Also adds request_accessors_round_trip as a minimal sanity check that
delta() / presented() / requested() / proofs() expose the constructed
state, since the equality tests do not exercise the trait surface
directly.

Total: 183 tests pass.
Removes two placeholder methods that no caller imported and that encoded
incorrect or stub behaviour:

* Credential::create_presentation_witness produced a four-element
  witness (value, randomness, z, t) that does not match any statement
  built by the canonical UnifiedRegistration transcript. The actual
  show-credential proof uses statements::show_credential_knowledge,
  whose witness is five elements [z, -t*z, t, value, randomness]. The
  placeholder was never wired into the live proof flow and would have
  silently produced an unverifiable proof if it had been.

* CredentialPresentation::create_knowledge_statement returned
  WabiSabiError::Unspecified unconditionally. The canonical
  show-credential statement is built from
  statements::show_credential_statement against the freshly-computed
  Z = z * I, so this stub had no path to becoming correct without
  duplicating that constructor.

Dropping both keeps the public API honest about what is implemented and
clears a category of footguns when downstream PyO3 bindings start
enumerating the credential surface.

Also fixes the unused-mut warnings in nonce_provider.rs:114-115. The
two providers were created only to ensure the call type-checks; bind
them to underscore-prefixed names to make the intent explicit and quiet
the rustc warning.
Adds pyo3 0.22 as an optional dependency gated on the new `python`
feature, plus bincode 1.3 for the upcoming bytes-in/bytes-out wire DTO
codec at the Python boundary.

Choices:

* abi3-py311: target the stable Python ABI starting at 3.11. A single
  wheel works for 3.11/3.12/3.13/3.14, which is convenient for downstream
  (Python 3.11) and lets the Docker image upgrade to 3.14 without a
  rebuild.

* extension-module: required when pyo3 builds a cdylib loaded by the
  CPython interpreter; without it, link symbols would conflict on
  Linux.

* dep:pyo3 syntax: keeps the feature name (`python`) distinct from the
  crate name so cargo does not auto-implicit-feature it, which would
  leak pyo3 into default builds.

Default cargo build/test stays slim (no pyo3 download); `cargo build
--features python` enables the binding surface that follows in the
next commit.
…odecs

GroupElement::serialize emits a 33-tuple of bytes (compact SEC1 form),
but the matching Deserialize impl decoded a Vec<u8>. In length-prefixed
binary codecs (bincode default, postcard, MessagePack) Vec<u8> reads a
length tag first, so a fresh-tuple-then-Vec round-trip overshoots the
buffer end and surfaces as 'unexpected end of file'. JSON happens to
hide the bug because seq and tuple share representation there.

Replace the deserializer with a length-checked tuple visitor that

 * iterates exactly 33 elements via SeqAccess (the canonical pyo3 +
   bincode path),
 * accepts visit_bytes too (CBOR and similar compact codecs encode
   fixed-length byte arrays as bytes blobs).

The fix is what the existing tests had been masking via the in-process
JSON-friendly serializers; the upcoming PyO3 layer round-trips through
bincode and exposed it. No public API change, no on-wire change in the
already-correct serialize direction.

Changelog: Fix GroupElement deserialization under length-prefixed serde codecs (bincode, postcard, MessagePack)
Adds a Python extension module that exposes the full client/issuer-side
WabiSabi flow: issuer setup, zero-amount and real-amount credential
requests, response validation, and credential persistence. The module
is gated on the optional 'python' cargo feature added in 4e36c4b, so
plain 'cargo test' and downstream Rust consumers stay PyO3-free.

Surface
-------
* generate_issuer_secret_key() / derive_issuer_parameters(sk) -> bytes
* CredentialIssuer(sk_bytes, initial_balance) with parameters(),
  balance(), reset(), handle_request(req_bytes, is_real)
* WabiSabiClient(params_bytes) with configure(max_amount, width),
  create_request_for_zero_amount(), create_request(amounts, presented),
  handle_response(resp_bytes, validation)
* Credential.to_bytes() / from_bytes() for wallet-side persistence
* ValidationHandle: opaque per-request state threaded between
  create_request and handle_response

Design choices
--------------
* Bincode at the FFI boundary for every wire DTO. Avoids a parallel
  Python class hierarchy and keeps Rust as the single source of truth
  for the wire format. Python wrappers can later swap the codec
  without touching the binding signatures.

* Opaque ValidationHandle for CredentialsResponseValidation. Its
  inner Transcript carries a Strobe state that has no stable
  serialization. Snapshotting the Strobe state would be fragile
  across upstream releases; rebuilding it per call would be slow and
  racy. Holding it as a #[pyclass] with take()-on-consume keeps the
  round-trip type safe and the hot path zero-copy.

* Fresh SecureRandom per call rather than threading one from Python.
  Matches the reference WabiSabi behaviour (system entropy on every
  proof) and keeps the Python API thread-safe without forcing a Send
  + Sync guarantee on the Rust RNG.

* abi3-py311 wheel: a single artifact targets CPython 3.11+ so the
  same wheel works across 3.11 through 3.14 without a rebuild.
  extension-module is required for the cdylib loaded by CPython.

* CredentialIssuer::reset is widened from #[cfg(test)] to also be
  available under the 'python' feature. Cross-round reuse of an
  issuer instance is a legitimate runtime need; rebuilding the
  secret-key + parameter setup per round would burn entropy and
  break long-lived issuer instances.

Tests
-----
python/tests/test_round_trip.py exercises:

  * zero-amount mint + verify
  * present zero credentials and reissue against a real-value vector
  * double-consume of a ValidationHandle raises RuntimeError
  * Credential bytes survive serialize/deserialize
  * derive_issuer_parameters(sk) == issuer.parameters()

Build / run from the crate root:

    python -m venv .venv && . .venv/bin/activate
    pip install maturin pytest
    maturin develop --features python
    pytest python/tests/

All 5 Python e2e tests + the full Rust test suite pass.
…e_proof_width

WabiSabiClient already exposed configure() so callers could pin the
range-proof width to their protocol's value. CredentialIssuer did not,
which meant the issuer always ran with the in-crate defaults
(MAX_AMOUNT = 2**27, RANGE_PROOF_WIDTH = 27). When a downstream
protocol used anything wider (for example a 2**51 max amount), every
real-amount request failed with 'Invalid bit commitment' because the
issuer was checking against a 27-bit commitment matrix while the
client was building a 51-bit proof.

The fix mirrors the client API: a single configure(max_amount,
range_proof_width) call rebuilds the issuer through the existing
with_max_amount + with_range_proof_width builders. Implementation
notes:

* Inner issuer wrapped in Option<RsIssuer> so the builder can move
  through self-by-value without requiring CredentialIssuer to impl
  Default. The slot is only ever empty for the duration of one
  synchronous configure() call; PyO3's GIL guarantees no other
  pyclass method observes the empty state.

* Accessor helper issuer() centralises the unwrap so individual
  methods stay readable. Panics carry the contract violated (slot
  emptied during configure) for fast post-mortem.

No on-wire change. New API surface is additive.
Adds a single-key LSAG construction with a per-run rotating key image
suitable for anonymous attestations. The signer holds the discrete log
behind one of N x-only ring members (BIP340 even-Y lift); verifiers
recover the LSAG core image by subtracting H_s("rotate" || run_id) * G,
which keeps within-run linkability (Sybil detection by key-image
equality) while making cross-run signatures unlinkable.

Hash-to-point uses k256's secp256k1_XMD:SHA-256_SSWU_RO_ via
GroupDigest, with all DSTs prefixed jmng/clsag/v1/. Wire format is
33 + 32 + 32*N bytes (compressed key image, c0, then one s_i per ring
member). 8 unit tests cover round-trip, message/run-id/signer
tampering, key-image rotation, and wire serialization.

Changelog: Add CLSAG-style linkable ring signatures (sign/verify/key
image) for anonymous attestations.
Wires the new ring-signature primitives through the PyO3 boundary using
the same byte-oriented contract as the rest of nwabisabi:

  clsag_sign(secret_key, ring_xonly, signer_idx, run_id, message)
    -> bytes   (33 + 32 + 32*N signature blob)

  clsag_verify(signature, ring_xonly, run_id, message)
    -> (bool, key_image_bytes)
      The 33-byte key image is returned even on verification failure so
      callers can dedupe at the gossip layer without re-parsing the
      blob.

  clsag_key_image(secret_key, run_id) -> bytes
      Pre-flight Sybil check without paying the cost of building a full
      ring signature.

Two crate-private hash-to-point / run-rotation helpers are re-exported
under #[cfg(feature = "python")] so the binding can compose them
without leaking DST constants from the public surface. Bumps both the
Cargo and pyproject versions to 0.2.0 (new public API surface).

Five PyO3-level tests verify FFI shape, error mapping, key-image
rotation, and the (ok, key_image) tuple contract; the cryptographic
edge cases stay covered by the Rust unit tests in src/crypto/clsag.rs.

Changelog: Add Python bindings for CLSAG sign/verify/key-image.
Adds RegistrationShow proof that demonstrates knowledge of a valid MAC
on a credential while revealing the credential's amount and exposing
its compressed serial point. The transcript is bound to a caller-
supplied label so verifiers can pin the show to a specific output
context (epoch / output-type / address / amount).

Also exposes Credential::serial() for callers that need to hash the
serial into protocol-level state, and a CredentialIssuer helper
verify_registration_show that recomputes z_public from sk and rejects
shows that do not match the issuer's parameters. Dedup of seen serials
is left to the caller (coordinator).

PyO3 bindings wired through:
  - Credential.serial() -> bytes
  - WabiSabiClient.present_for_registration(cred, label) -> bytes
  - CredentialIssuer.verify_registration(blob, amount, label) -> bytes

Bumps crate to 0.3.0.
@m0wer m0wer changed the title Finish the porting of WabiSabi credential system with CLSAG ring signatures and Python bindings Finish the porting of WabiSabi credential system with CLSAG ring signatures and add Python bindings May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant