Skip to content

taker.podle

taker.podle

Proof of Discrete Log Equivalence (PoDLE) generation for takers.

This module re-exports PoDLE generation functions from jmcore and provides taker-specific utilities for UTXO selection and commitment generation.

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.

Protocol flow: 1. Taker generates commitment \(C = H(P_2)\) where \(P_2 = k \cdot J\) (\(k\) = private key, \(J\) = NUMS point) 2. Taker sends commitment \(C\) to maker 3. Maker accepts and sends pubkey 4. Taker reveals \(P\), \(P_2\), \(\text{sig}\), \(e\) as the "revelation" 5. Maker verifies: \(P = k \cdot G\) and \(P_2 = k \cdot J\) (same \(k\))

Reference: https://gist.github.com/AdamISZ/9cbba5e9408d23813ca8

Attributes

__all__ = ['ExtendedPoDLECommitment', 'PoDLECommitment', 'PoDLEError', 'generate_podle', 'get_eligible_podle_utxos', 'select_podle_utxo', 'serialize_revelation'] module-attribute

Classes

ExtendedPoDLECommitment

PoDLE commitment with extended UTXO metadata for neutrino_compat feature.

This extends the base PoDLECommitment with scriptpubkey and blockheight for Neutrino-compatible UTXO verification.

Source code in taker/src/taker/podle.py
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 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
110
111
112
113
@dataclass
class ExtendedPoDLECommitment:
    """
    PoDLE commitment with extended UTXO metadata for neutrino_compat feature.

    This extends the base PoDLECommitment with scriptpubkey and blockheight
    for Neutrino-compatible UTXO verification.
    """

    commitment: PoDLECommitment
    scriptpubkey: str | None = None  # Hex-encoded scriptPubKey
    blockheight: int | None = None  # Block height where UTXO was confirmed

    # Expose underlying commitment properties for compatibility
    @property
    def p(self) -> bytes:
        """Public key P = k*G"""
        return self.commitment.p

    @property
    def p2(self) -> bytes:
        """Commitment point P2 = k*J"""
        return self.commitment.p2

    @property
    def sig(self) -> bytes:
        """Schnorr signature s"""
        return self.commitment.sig

    @property
    def e(self) -> bytes:
        """Challenge e"""
        return self.commitment.e

    @property
    def utxo(self) -> str:
        """UTXO reference txid:vout"""
        return self.commitment.utxo

    @property
    def index(self) -> int:
        """NUMS point index used"""
        return self.commitment.index

    def to_revelation(self, extended: bool = False) -> dict[str, str]:
        """
        Convert to revelation format for sending to maker.

        Args:
            extended: If True, include scriptpubkey:blockheight in utxo string
        """
        rev = self.commitment.to_revelation()
        if extended and self.scriptpubkey and self.blockheight is not None:
            # Replace utxo with extended format: txid:vout:scriptpubkey:blockheight
            txid, vout = self.commitment.utxo.split(":")
            rev["utxo"] = f"{txid}:{vout}:{self.scriptpubkey}:{self.blockheight}"
        return rev

    def to_commitment_str(self) -> str:
        """Get commitment as hex string."""
        return self.commitment.to_commitment_str()

    def has_neutrino_metadata(self) -> bool:
        """Check if we have metadata for Neutrino-compatible verification."""
        return self.scriptpubkey is not None and self.blockheight is not None
Attributes
blockheight: int | None = None class-attribute instance-attribute
commitment: PoDLECommitment instance-attribute
e: bytes property

Challenge e

index: int property

NUMS point index used

p: bytes property

Public key P = k*G

p2: bytes property

Commitment point P2 = k*J

scriptpubkey: str | None = None class-attribute instance-attribute
sig: bytes property

Schnorr signature s

utxo: str property

UTXO reference txid:vout

Methods:
has_neutrino_metadata() -> bool

Check if we have metadata for Neutrino-compatible verification.

Source code in taker/src/taker/podle.py
111
112
113
def has_neutrino_metadata(self) -> bool:
    """Check if we have metadata for Neutrino-compatible verification."""
    return self.scriptpubkey is not None and self.blockheight is not None
to_commitment_str() -> str

Get commitment as hex string.

Source code in taker/src/taker/podle.py
107
108
109
def to_commitment_str(self) -> str:
    """Get commitment as hex string."""
    return self.commitment.to_commitment_str()
to_revelation(extended: bool = False) -> dict[str, str]

Convert to revelation format for sending to maker.

Args: extended: If True, include scriptpubkey:blockheight in utxo string

Source code in taker/src/taker/podle.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def to_revelation(self, extended: bool = False) -> dict[str, str]:
    """
    Convert to revelation format for sending to maker.

    Args:
        extended: If True, include scriptpubkey:blockheight in utxo string
    """
    rev = self.commitment.to_revelation()
    if extended and self.scriptpubkey and self.blockheight is not None:
        # Replace utxo with extended format: txid:vout:scriptpubkey:blockheight
        txid, vout = self.commitment.utxo.split(":")
        rev["utxo"] = f"{txid}:{vout}:{self.scriptpubkey}:{self.blockheight}"
    return rev

PoDLECommitment

PoDLE commitment data generated by taker.

Source code in jmcore/src/jmcore/podle.py
111
112
113
114
115
116
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
@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
Methods:
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
133
134
135
136
137
138
139
140
141
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
123
124
125
126
127
128
129
130
131
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
73
74
75
76
class PoDLEError(Exception):
    """PoDLE generation or verification error."""

    pass

Functions:

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
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
394
395
396
397
398
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)")

    try:
        private_key = CKey(private_key_bytes)
    except ValueError as exc:
        raise PoDLEError("Invalid private key value") from exc

    # Calculate P = k*G (standard public key)
    p_point = private_key.pub
    p_bytes = point_to_bytes(p_point)

    # Get NUMS point J
    j_point = get_nums_point(index)

    # Calculate P2 = k*J
    p2_point = j_point.multiply(private_key_bytes)
    p2_bytes = point_to_bytes(p2_point)

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

    # Derive the nonce from the secret key and proof transcript. This removes
    # runtime RNG failures from a Schnorr-style operation where nonce reuse
    # across two proofs would reveal the UTXO private key.
    s_bytes: bytes | None = None
    for k_proof in _podle_nonce_candidates(
        private_key_bytes,
        utxo_str,
        index,
        p_bytes,
        p2_bytes,
    ):
        kg_bytes = point_to_bytes(scalar_mult_g(k_proof))
        kj_bytes = point_to_bytes(point_mult(k_proof, j_point))
        e_bytes = hashlib.sha256(kg_bytes + kj_bytes + p_bytes + p2_bytes).digest()
        s_bytes = _compute_podle_response(private_key, k_proof, e_bytes)
        if s_bytes is not None:
            break
    if s_bytes is None:
        raise PoDLEError("PoDLE nonce candidates exhausted")

    logger.bind(sensitive=True).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_eligible_podle_utxos(utxos: list[UTXOInfo], cj_amount: int, min_confirmations: int = 5, min_percent: int = 20) -> list[UTXOInfo]

Get all eligible UTXOs for PoDLE commitment, sorted by preference.

Criteria: - Must have at least min_confirmations - Must be at least min_percent of cj_amount

Returns: List of eligible UTXOs sorted by (confirmations, value) descending

Source code in taker/src/taker/podle.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def get_eligible_podle_utxos(
    utxos: list[UTXOInfo],
    cj_amount: int,
    min_confirmations: int = 5,
    min_percent: int = 20,
) -> list[UTXOInfo]:
    """
    Get all eligible UTXOs for PoDLE commitment, sorted by preference.

    Criteria:
    - Must have at least min_confirmations
    - Must be at least min_percent of cj_amount

    Returns:
        List of eligible UTXOs sorted by (confirmations, value) descending
    """
    min_value = int(cj_amount * min_percent / 100)

    eligible = [u for u in utxos if u.confirmations >= min_confirmations and u.value >= min_value]

    # Prefer older UTXOs with more value
    eligible.sort(key=lambda u: (u.confirmations, u.value), reverse=True)
    return eligible

select_podle_utxo(utxos: list[UTXOInfo], cj_amount: int, min_confirmations: int = 5, min_percent: int = 20) -> UTXOInfo | None

Select the best UTXO for PoDLE commitment.

Args: utxos: Available UTXOs cj_amount: CoinJoin amount min_confirmations: Minimum confirmations required min_percent: Minimum value as percentage of cj_amount

Returns: Best UTXO for PoDLE or None if no suitable UTXO

Source code in taker/src/taker/podle.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def select_podle_utxo(
    utxos: list[UTXOInfo],
    cj_amount: int,
    min_confirmations: int = 5,
    min_percent: int = 20,
) -> UTXOInfo | None:
    """
    Select the best UTXO for PoDLE commitment.

    Args:
        utxos: Available UTXOs
        cj_amount: CoinJoin amount
        min_confirmations: Minimum confirmations required
        min_percent: Minimum value as percentage of cj_amount

    Returns:
        Best UTXO for PoDLE or None if no suitable UTXO
    """
    eligible = get_eligible_podle_utxos(utxos, cj_amount, min_confirmations, min_percent)

    if not eligible:
        min_value = int(cj_amount * min_percent / 100)
        logger.warning(
            f"No suitable UTXOs for PoDLE: need {min_confirmations}+ confirmations "
            f"and value >= {min_value} sats ({min_percent}% of {cj_amount})"
        )
        return None

    selected = eligible[0]
    logger.info(
        f"Selected UTXO for PoDLE: {selected.txid}:{selected.vout} "
        f"(value={selected.value}, confs={selected.confirmations})"
    )

    return selected

serialize_revelation(commitment: PoDLECommitment) -> str

Serialize PoDLE revelation to wire format.

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

Source code in jmcore/src/jmcore/podle.py
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
def serialize_revelation(commitment: PoDLECommitment) -> str:
    """
    Serialize PoDLE revelation to wire format.

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