Skip to content

jmcore.podle

jmcore.podle

Proof of Discrete Log Equivalence (PoDLE) for JoinMarket.

PoDLE is used to prevent sybil attacks in JoinMarket by requiring takers to prove ownership of a UTXO without revealing which UTXO until after the maker commits to participate.

This module provides both generation (for takers) and verification (for makers) of PoDLE proofs.

Protocol flow: 1. Taker generates commitment C = H(P2) where P2 = kJ (k = private key, J = NUMS point) 2. Taker sends commitment C to maker 3. Maker accepts and sends pubkey 4. Taker reveals P, P2, sig, e as the "revelation" 5. Maker verifies: P = kG and P2 = k*J (same k)

For detailed cryptographic documentation including NUMS point generation algorithm and secp256k1 curve parameters, see DOCS.md section "Cryptographic Foundations".

Reference: https://gist.github.com/AdamISZ/9cbba5e9408d23813ca8 Reference: joinmarket-clientserver/src/jmclient/podle.py

Attributes

G_COMPRESSED = bytes.fromhex('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798') module-attribute

G_UNCOMPRESSED = bytes.fromhex('0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8') module-attribute

NUMS_TEST_VECTORS = {0: '0296f47ec8e6d6a9c3379c2ce983a6752bcfa88d46f2a6ffe0dd12c9ae76d01a1f', 1: '023f9976b86d3f1426638da600348d96dc1f1eb0bd5614cc50db9e9a067c0464a2', 5: '02bbc5c4393395a38446e2bd4d638b7bfd864afb5ffaf4bed4caf797df0e657434', 9: '021b739f21b981c2dcbaf9af4d89223a282939a92aee079e94a46c273759e5b42e', 100: '02aacc3145d04972d0527c4458629d328219feda92bef6ef6025878e3a252e105a', 255: '02a0a8694820c794852110e5939a2c03f8482f81ed57396042c6b34557f6eb430a'} module-attribute

Classes

PoDLECommitment

PoDLE commitment data generated by taker.

Source code in jmcore/src/jmcore/podle.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
@dataclass
class PoDLECommitment:
    """PoDLE commitment data generated by taker."""

    commitment: bytes  # H(P2) - 32 bytes
    p: bytes  # Public key P = k*G - 33 bytes compressed
    p2: bytes  # Commitment point P2 = k*J - 33 bytes compressed
    sig: bytes  # Schnorr signature s - 32 bytes
    e: bytes  # Challenge e - 32 bytes
    utxo: str  # UTXO reference "txid:vout"
    index: int  # NUMS point index used

    def to_revelation(self) -> dict[str, str]:
        """Convert to revelation format for sending to maker."""
        return {
            "P": self.p.hex(),
            "P2": self.p2.hex(),
            "sig": self.sig.hex(),
            "e": self.e.hex(),
            "utxo": self.utxo,
        }

    def to_commitment_str(self) -> str:
        """
        Get commitment as string with type prefix.

        JoinMarket requires a commitment type prefix to allow future
        commitment schemes. "P" indicates a standard PoDLE commitment.
        Format: "P" + hex(commitment)
        """
        return "P" + self.commitment.hex()
Attributes
commitment: bytes instance-attribute
e: bytes instance-attribute
index: int instance-attribute
p: bytes instance-attribute
p2: bytes instance-attribute
sig: bytes instance-attribute
utxo: str instance-attribute
Functions
to_commitment_str() -> str

Get commitment as string with type prefix.

JoinMarket requires a commitment type prefix to allow future commitment schemes. "P" indicates a standard PoDLE commitment. Format: "P" + hex(commitment)

Source code in jmcore/src/jmcore/podle.py
101
102
103
104
105
106
107
108
109
def to_commitment_str(self) -> str:
    """
    Get commitment as string with type prefix.

    JoinMarket requires a commitment type prefix to allow future
    commitment schemes. "P" indicates a standard PoDLE commitment.
    Format: "P" + hex(commitment)
    """
    return "P" + self.commitment.hex()
to_revelation() -> dict[str, str]

Convert to revelation format for sending to maker.

Source code in jmcore/src/jmcore/podle.py
91
92
93
94
95
96
97
98
99
def to_revelation(self) -> dict[str, str]:
    """Convert to revelation format for sending to maker."""
    return {
        "P": self.p.hex(),
        "P2": self.p2.hex(),
        "sig": self.sig.hex(),
        "e": self.e.hex(),
        "utxo": self.utxo,
    }

PoDLEError

Bases: Exception

PoDLE generation or verification error.

Source code in jmcore/src/jmcore/podle.py
69
70
71
72
class PoDLEError(Exception):
    """PoDLE generation or verification error."""

    pass

Functions

deserialize_revelation(revelation_str: str) -> dict[str, Any] | None

Deserialize PoDLE revelation from wire format.

Format: P|P2|sig|e|utxo (pipe-separated hex strings)

Source code in jmcore/src/jmcore/podle.py
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def deserialize_revelation(revelation_str: str) -> dict[str, Any] | None:
    """
    Deserialize PoDLE revelation from wire format.

    Format: P|P2|sig|e|utxo (pipe-separated hex strings)
    """
    try:
        parts = revelation_str.split("|")
        if len(parts) != 5:
            logger.warning(f"Invalid revelation format: expected 5 parts, got {len(parts)}")
            return None

        return {
            "P": parts[0],
            "P2": parts[1],
            "sig": parts[2],
            "e": parts[3],
            "utxo": parts[4],
        }

    except Exception as e:
        logger.error(f"Failed to deserialize PoDLE revelation: {e}")
        return None

generate_nums_point(index: int) -> PublicKey

Generate a Nothing-Up-My-Sleeve (NUMS) point deterministically.

The algorithm takes secp256k1's generator G (both compressed and uncompressed), appends the index byte and a counter byte, then SHA256 hashes the result. It tries to create a valid curve point by prepending 0x02 to the hash. The first valid point found is returned as the NUMS point for that index.

This ensures NUMS points are generated transparently with no hidden discrete log.

Reference: https://gist.github.com/AdamISZ/9cbba5e9408d23813ca8 Reference: joinmarket-clientserver/src/jmclient/podle.py getNUMS()

Args: index: Index of the NUMS point to generate (0-255)

Returns: The NUMS point as a PublicKey

Raises: PoDLEError: If index is out of range or no valid point found (should never happen)

Source code in jmcore/src/jmcore/podle.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def generate_nums_point(index: int) -> PublicKey:
    """
    Generate a Nothing-Up-My-Sleeve (NUMS) point deterministically.

    The algorithm takes secp256k1's generator G (both compressed and uncompressed),
    appends the index byte and a counter byte, then SHA256 hashes the result.
    It tries to create a valid curve point by prepending 0x02 to the hash.
    The first valid point found is returned as the NUMS point for that index.

    This ensures NUMS points are generated transparently with no hidden discrete log.

    Reference: https://gist.github.com/AdamISZ/9cbba5e9408d23813ca8
    Reference: joinmarket-clientserver/src/jmclient/podle.py getNUMS()

    Args:
        index: Index of the NUMS point to generate (0-255)

    Returns:
        The NUMS point as a PublicKey

    Raises:
        PoDLEError: If index is out of range or no valid point found (should never happen)
    """
    if not 0 <= index <= 255:
        raise PoDLEError(f"NUMS point index {index} must be in range 0-255")

    # Try both compressed and uncompressed G as seeds
    for g_bytes in [G_COMPRESSED, G_UNCOMPRESSED]:
        seed = g_bytes + bytes([index])
        for counter in range(256):
            seed_with_counter = seed + bytes([counter])
            hashed_seed = hashlib.sha256(seed_with_counter).digest()
            # Try to create a valid point with 02 prefix (even y-coordinate)
            claimed_point = b"\x02" + hashed_seed
            try:
                nums_point = PublicKey(claimed_point)
                # coincurve validates the point on construction
                return nums_point
            except Exception:
                continue

    # This should never happen given the search space
    raise PoDLEError(f"Failed to generate NUMS point for index {index}")  # pragma: no cover

generate_podle(private_key_bytes: bytes, utxo_str: str, index: int = 0) -> PoDLECommitment

Generate a PoDLE commitment for a UTXO.

The PoDLE proves that the taker owns the UTXO without revealing the private key. It creates a zero-knowledge proof that: P = kG and P2 = kJ have the same discrete log k.

Args: private_key_bytes: 32-byte private key utxo_str: UTXO reference as "txid:vout" index: NUMS point index (0-255)

Returns: PoDLECommitment with all proof data

Source code in jmcore/src/jmcore/podle.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
def generate_podle(
    private_key_bytes: bytes,
    utxo_str: str,
    index: int = 0,
) -> PoDLECommitment:
    """
    Generate a PoDLE commitment for a UTXO.

    The PoDLE proves that the taker owns the UTXO without revealing
    the private key. It creates a zero-knowledge proof that:
    P = k*G and P2 = k*J have the same discrete log k.

    Args:
        private_key_bytes: 32-byte private key
        utxo_str: UTXO reference as "txid:vout"
        index: NUMS point index (0-255)

    Returns:
        PoDLECommitment with all proof data
    """
    if len(private_key_bytes) != 32:
        raise PoDLEError(f"Invalid private key length: {len(private_key_bytes)}")

    if not 0 <= index <= 255:
        raise PoDLEError(f"Invalid NUMS index: {index} (must be 0-255)")

    # Get private key as integer
    k = int.from_bytes(private_key_bytes, "big")
    if k == 0 or k >= SECP256K1_N:
        raise PoDLEError("Invalid private key value")

    # Calculate P = k*G (standard public key)
    p_point = scalar_mult_g(k)
    p_bytes = point_to_bytes(p_point)

    # Get NUMS point J
    j_point = get_nums_point(index)

    # Calculate P2 = k*J
    p2_point = point_mult(k, j_point)
    p2_bytes = point_to_bytes(p2_point)

    # Generate commitment C = H(P2)
    commitment = hashlib.sha256(p2_bytes).digest()

    # Generate Schnorr-like proof
    # Choose random nonce k_proof
    k_proof = int.from_bytes(secrets.token_bytes(32), "big") % SECP256K1_N
    if k_proof == 0:
        k_proof = 1

    # Kg = k_proof * G
    kg_point = scalar_mult_g(k_proof)
    kg_bytes = point_to_bytes(kg_point)

    # Kj = k_proof * J
    kj_point = point_mult(k_proof, j_point)
    kj_bytes = point_to_bytes(kj_point)

    # Challenge e = H(Kg || Kj || P || P2)
    e_bytes = hashlib.sha256(kg_bytes + kj_bytes + p_bytes + p2_bytes).digest()
    e = int.from_bytes(e_bytes, "big") % SECP256K1_N

    # Response s = k_proof + e * k (mod n) - JAM compatible
    s = (k_proof + e * k) % SECP256K1_N
    s_bytes = s.to_bytes(32, "big")

    logger.debug(
        f"Generated PoDLE for {utxo_str} using NUMS index {index}, "
        f"commitment={commitment.hex()[:16]}..."
    )

    return PoDLECommitment(
        commitment=commitment,
        p=p_bytes,
        p2=p2_bytes,
        sig=s_bytes,
        e=e_bytes,
        utxo=utxo_str,
        index=index,
    )

get_nums_point(index: int) -> PublicKey

Get Nothing-Up-My-Sleeve (NUMS) generator point J for given index.

NUMS points are generated deterministically and cached for efficiency. Supports indices 0-255.

Args: index: Index of the NUMS point (0-255)

Returns: The NUMS point as a PublicKey

Source code in jmcore/src/jmcore/podle.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def get_nums_point(index: int) -> PublicKey:
    """
    Get Nothing-Up-My-Sleeve (NUMS) generator point J for given index.

    NUMS points are generated deterministically and cached for efficiency.
    Supports indices 0-255.

    Args:
        index: Index of the NUMS point (0-255)

    Returns:
        The NUMS point as a PublicKey
    """
    if index in _nums_cache:
        return _nums_cache[index]

    nums_point = generate_nums_point(index)
    _nums_cache[index] = nums_point
    return nums_point

parse_podle_revelation(revelation: dict[str, Any]) -> dict[str, Any] | None

Parse and validate PoDLE revelation structure.

Expected format from taker: { 'P': , 'P2': , 'sig': , 'e': , 'utxo': }

Returns parsed structure with bytes, or None if invalid. Extended format includes scriptpubkey and blockheight for neutrino_compat feature.

Source code in jmcore/src/jmcore/podle.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
def parse_podle_revelation(revelation: dict[str, Any]) -> dict[str, Any] | None:
    """
    Parse and validate PoDLE revelation structure.

    Expected format from taker:
    {
        'P': <hex string>,
        'P2': <hex string>,
        'sig': <hex string>,
        'e': <hex string>,
        'utxo': <txid:vout or txid:vout:scriptpubkey:blockheight string>
    }

    Returns parsed structure with bytes, or None if invalid.
    Extended format includes scriptpubkey and blockheight for neutrino_compat feature.
    """
    try:
        required_fields = ["P", "P2", "sig", "e", "utxo"]
        for field in required_fields:
            if field not in revelation:
                logger.warning(f"Missing required field in PoDLE revelation: {field}")
                return None

        p_bytes = bytes.fromhex(revelation["P"])
        p2_bytes = bytes.fromhex(revelation["P2"])
        sig_bytes = bytes.fromhex(revelation["sig"])
        e_bytes = bytes.fromhex(revelation["e"])

        utxo_parts = revelation["utxo"].split(":")

        # Legacy format: txid:vout (2 parts)
        # Extended format: txid:vout:scriptpubkey:blockheight (4 parts)
        if len(utxo_parts) == 2:
            txid = utxo_parts[0]
            vout = int(utxo_parts[1])
            scriptpubkey = None
            blockheight = None
        elif len(utxo_parts) == 4:
            txid = utxo_parts[0]
            vout = int(utxo_parts[1])
            scriptpubkey = utxo_parts[2]
            blockheight = int(utxo_parts[3])
            logger.debug(f"Parsed extended UTXO format: {txid}:{vout} with metadata")
        else:
            logger.warning(f"Invalid UTXO format: {revelation['utxo']}")
            return None

        result: dict[str, Any] = {
            "P": p_bytes,
            "P2": p2_bytes,
            "sig": sig_bytes,
            "e": e_bytes,
            "txid": txid,
            "vout": vout,
        }

        # Add extended metadata if present
        if scriptpubkey is not None:
            result["scriptpubkey"] = scriptpubkey
        if blockheight is not None:
            result["blockheight"] = blockheight

        return result

    except Exception as e:
        logger.error(f"Failed to parse PoDLE revelation: {e}")
        return None

point_add(p1: PublicKey, p2: PublicKey) -> PublicKey

Add two EC points using coincurve.

Source code in jmcore/src/jmcore/podle.py
201
202
203
def point_add(p1: PublicKey, p2: PublicKey) -> PublicKey:
    """Add two EC points using coincurve."""
    return p1.combine([p2])

point_mult(scalar: int, point: PublicKey) -> PublicKey

Multiply EC point by scalar using coincurve.

Source code in jmcore/src/jmcore/podle.py
192
193
194
195
196
197
198
def point_mult(scalar: int, point: PublicKey) -> PublicKey:
    """Multiply EC point by scalar using coincurve."""
    scalar = scalar % SECP256K1_N
    if scalar == 0:
        raise PoDLEError("Scalar cannot be zero")
    scalar_bytes = scalar.to_bytes(32, "big")
    return point.multiply(scalar_bytes)

point_to_bytes(point: PublicKey) -> bytes

Convert EC point to compressed bytes.

Source code in jmcore/src/jmcore/podle.py
206
207
208
def point_to_bytes(point: PublicKey) -> bytes:
    """Convert EC point to compressed bytes."""
    return point.format(compressed=True)

scalar_mult_g(scalar: int) -> PublicKey

Multiply generator G by scalar (creates public key from private key).

Source code in jmcore/src/jmcore/podle.py
183
184
185
186
187
188
189
def scalar_mult_g(scalar: int) -> PublicKey:
    """Multiply generator G by scalar (creates public key from private key)."""
    scalar = scalar % SECP256K1_N
    if scalar == 0:
        raise PoDLEError("Scalar cannot be zero")
    scalar_bytes = scalar.to_bytes(32, "big")
    return PublicKey.from_secret(scalar_bytes)

serialize_revelation(commitment: PoDLECommitment) -> str

Serialize PoDLE revelation to wire format.

Format: P|P2|sig|e|utxo (pipe-separated hex strings)

Source code in jmcore/src/jmcore/podle.py
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
def serialize_revelation(commitment: PoDLECommitment) -> str:
    """
    Serialize PoDLE revelation to wire format.

    Format: P|P2|sig|e|utxo (pipe-separated hex strings)
    """
    return "|".join(
        [
            commitment.p.hex(),
            commitment.p2.hex(),
            commitment.sig.hex(),
            commitment.e.hex(),
            commitment.utxo,
        ]
    )

verify_podle(p: bytes, p2: bytes, sig: bytes, e: bytes, commitment: bytes, index_range: range = range(10)) -> tuple[bool, str]

Verify PoDLE proof.

Verifies that P and P2 have the same discrete log (private key) without revealing the private key itself.

Args: p: Public key bytes (33 bytes compressed) p2: Commitment public key bytes (33 bytes compressed) sig: Signature s value (32 bytes) e: Challenge e value (32 bytes) commitment: sha256(P2) commitment (32 bytes) index_range: Allowed NUMS indices to try

Returns: (is_valid, error_message)

Source code in jmcore/src/jmcore/podle.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
def verify_podle(
    p: bytes,
    p2: bytes,
    sig: bytes,
    e: bytes,
    commitment: bytes,
    index_range: range = range(10),
) -> tuple[bool, str]:
    """
    Verify PoDLE proof.

    Verifies that P and P2 have the same discrete log (private key)
    without revealing the private key itself.

    Args:
        p: Public key bytes (33 bytes compressed)
        p2: Commitment public key bytes (33 bytes compressed)
        sig: Signature s value (32 bytes)
        e: Challenge e value (32 bytes)
        commitment: sha256(P2) commitment (32 bytes)
        index_range: Allowed NUMS indices to try

    Returns:
        (is_valid, error_message)
    """
    try:
        if len(p) != 33:
            return False, f"Invalid P length: {len(p)}, expected 33"
        if len(p2) != 33:
            return False, f"Invalid P2 length: {len(p2)}, expected 33"
        if len(sig) != 32:
            return False, f"Invalid sig length: {len(sig)}, expected 32"
        if len(e) != 32:
            return False, f"Invalid e length: {len(e)}, expected 32"
        if len(commitment) != 32:
            return False, f"Invalid commitment length: {len(commitment)}, expected 32"

        expected_commitment = hashlib.sha256(p2).digest()
        if commitment != expected_commitment:
            return False, "Commitment does not match H(P2)"

        p_point = PublicKey(p)
        p2_point = PublicKey(p2)

        s_int = int.from_bytes(sig, "big")
        e_int = int.from_bytes(e, "big")

        if s_int >= SECP256K1_N or e_int >= SECP256K1_N:
            return False, "Signature values out of range"

        # sg = s * G
        sg = scalar_mult_g(s_int) if s_int > 0 else None

        # Compute -e mod N for subtraction (JAM compatible: s = k + e*x, verify Kg = s*G - e*P)
        minus_e_int = (-e_int) % SECP256K1_N

        for index in index_range:
            try:
                j = get_nums_point(index)

                # Kg = s*G - e*P = s*G + (-e)*P (JAM compatible verification)
                minus_e_p = point_mult(minus_e_int, p_point)
                kg = point_add(sg, minus_e_p) if sg is not None else minus_e_p

                # Kj = s*J - e*P2 = s*J + (-e)*P2
                minus_e_p2 = point_mult(minus_e_int, p2_point)
                if s_int > 0:
                    sj = point_mult(s_int, j)
                    kj = point_add(sj, minus_e_p2)
                else:
                    kj = minus_e_p2

                kg_bytes = point_to_bytes(kg)
                kj_bytes = point_to_bytes(kj)

                e_check = hashlib.sha256(kg_bytes + kj_bytes + p + p2).digest()

                if e_check == e:
                    logger.debug(f"PoDLE verification successful at index {index}")
                    return True, ""

            except Exception as ex:
                logger.debug(f"PoDLE verification failed at index {index}: {ex}")
                continue

        return False, f"PoDLE verification failed for all indices in {index_range}"

    except Exception as ex:
        logger.error(f"PoDLE verification error: {ex}")
        return False, f"Verification error: {ex}"