Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ jobs:
test:
name: Test
runs-on: ${{ matrix.os }}
permissions:
contents: read
checks: write
pull-requests: write
strategy:
matrix:
include:
Expand Down Expand Up @@ -64,5 +68,7 @@ jobs:
- name: Publish results
uses: EnricoMi/publish-unit-test-result-action/composite@v2
if: always()
# Publishing results must never fail the job (e.g. GitHub API 429 throttling).
continue-on-error: true
with:
junit_files: "*.xml"
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog], and this project adheres to [Semantic Versioning].

## [3.19.0](https://github.com/baking-bad/pytezos/compare/3.18.0...3.19.0) - 2026-06-28

### Added

- Ushuaia protocol support (025).
- tz5 / ML-DSA-44 account support: base58 prefixes (`tz5`, `mdpk`, `mdsk`, `mdesk`, `mdsig`), `is_pkh` recognition, and address forging/parsing (binary tag `\x04`). Note: signing with ML-DSA-44 is not yet implemented; only forging/parsing of tz5 addresses is supported.
- Modern Tenderbake consensus operations: `attestation`, `preattestation`, `attestation_with_dal`, and the `attestations_aggregate` / `preattestations_aggregate` BLS aggregates, with DAL bitset forging.

### Fixed

- Repaired the Binder image build and stale documentation references.

### Changed

- Octez binaries updated to v25.0.

## [3.18.0](https://github.com/baking-bad/pytezos/compare/3.17.0...3.18.0) - 2026-05-21

### Changed
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ kernel-docs: ## Build docs for Michelson IPython kernel
sandbox-params:
docker pull tezos/tezos:master
docker create --name temp tezos/tezos:master
docker cp temp:/usr/local/share/tezos/024-PtTALLiN-parameters src/pytezos/sandbox/
docker cp temp:/usr/local/share/tezos/025-PsUshuai-parameters src/pytezos/sandbox/
docker rm temp

rpc-docs: ## Build docs for Tezos node RPC
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@

## Installation

Make sure you have Python 3.9 to 3.13 installed and set as default in the system.
Make sure you have Python 3.10 to 3.13 installed and set as default in the system.

You also need to install cryptographic packages before installing the library/building the project:

Expand Down Expand Up @@ -129,7 +129,7 @@ $ docker-compose up -d notebook

Requirements:

* Python 3.9 to 3.13
* Python 3.10 to 3.13
* libsodium, coincurve, gmp
* make

Expand Down
2 changes: 1 addition & 1 deletion binder/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.8-slim-buster
FROM python:3.12-slim-bookworm

ARG NB_USER=mickey
ARG NB_UID=1000
Expand Down
8 changes: 4 additions & 4 deletions docs/source/quick_start.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ In this quick start guide, we'll go through the main concepts and inspect one of
Requirements
------------

Make sure you have Python 3.8+ installed and set as default in the system.
Make sure you have Python 3.10+ installed and set as default in the system.

You also need to install cryptographic packages before installing the library/building the project:

Expand Down Expand Up @@ -509,7 +509,7 @@ as it has BigMap entries, named entrypoints, and a non-trivial data scheme.

Properties
.key # tz1Ne4yzDRQPd5HFz6sTaCYCNHwFubT2MWsB
.shell # https://mainnet-tezos.giganode.io/ (mainnet)
.shell # https://rpc.tzkt.io/mainnet (mainnet)
.address # KT1REEb5VxWRjcHm5GzDMwErMmNFftsE5Gpf
.block_id # head
.storage # access storage data at block `block_id`
Expand Down Expand Up @@ -584,7 +584,7 @@ In order to see type definition, just remove the trailing brackets:

Properties
.key # tz1Ne4yzDRQPd5HFz6sTaCYCNHwFubT2MWsB
.shell # https://mainnet-tezos.giganode.io/ (mainnet)
.shell # https://rpc.tzkt.io/mainnet (mainnet)
.address # KT1REEb5VxWRjcHm5GzDMwErMmNFftsE5Gpf
.block_id # head
.path # /ledger
Expand Down Expand Up @@ -638,7 +638,7 @@ We can do the same using special entrypoint ``balance_of``. Let's give a look at

Properties
.key # tz1Ne4yzDRQPd5HFz6sTaCYCNHwFubT2MWsB
.shell # https://mainnet-tezos.giganode.io/ (mainnet)
.shell # https://rpc.tzkt.io/mainnet (mainnet)
.address # KT1REEb5VxWRjcHm5GzDMwErMmNFftsE5Gpf
.block_id # head
.entrypoint # balance_of
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pytezos"
version = "3.18.0"
version = "3.19.0"
description = "Python toolkit for Tezos"
license = { text = "MIT" }
readme = "README.md"
Expand Down
9 changes: 7 additions & 2 deletions src/pytezos/crypto/encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
(b'tz2', 36, tb([6, 161, 161]), 20, 'secp256k1 public key hash'),
(b'tz3', 36, tb([6, 161, 164]), 20, 'p256 public key hash'),
(b'tz4', 36, tb([6, 161, 166]), 20, 'BLS12-381 (MinPk) public key hash'),
(b'tz5', 36, tb([6, 161, 169]), 20, 'ML-DSA-44 public key hash'),
(b'KT1', 36, tb([2, 90, 121]), 20, 'originated smart contract address'),
(b'txr1', 37, tb([1, 128, 120, 31]), 20, 'tx_rollup_l2_address'),
(b'sr1', 36, tb([6, 124, 117]), 20, 'originated smart rollup address'),
Expand Down Expand Up @@ -50,6 +51,10 @@
(b'BLpk', 76, tb([6, 149, 135, 204]), 48, 'bls12_381_min_pk public_key'),
(b'BLsk', 54, tb([3, 150, 192, 40]), 32, 'bls12_381 secret_key'),
(b'BLesk', 88, tb([2, 5, 30, 53, 25]), 56, 'bls12_381 encrypted_secret_key'),
(b'mdpk', 1802, tb([13, 7, 237, 67]), 1312, 'ML-DSA-44 public key'),
(b'mdsk', 5298, tb([9, 57, 116, 57]), 3872, 'ML-DSA-44 secret key'),
(b'mdesk', 5332, tb([5, 49, 133, 39, 172]), 3896, 'ML-DSA-44 encrypted secret key'),
(b'mdsig', 3316, tb([1, 156, 45, 210, 3]), 2420, 'ML-DSA-44 signature'),
]

operation_tags = {
Expand Down Expand Up @@ -120,12 +125,12 @@ def _validate(v: Union[str, bytes], prefixes: list):


def validate_pkh(v: Union[str, bytes]):
"""Ensure parameter is a public key hash (starts with b'tz1', b'tz2', b'tz3', b'tz4')
"""Ensure parameter is a public key hash (starts with b'tz1', b'tz2', b'tz3', b'tz4', b'tz5')

:param v: string or bytes
:raises ValueError: if parameter is not a public key hash
"""
return _validate(v, prefixes=[b'tz1', b'tz2', b'tz3', b'tz4'])
return _validate(v, prefixes=[b'tz1', b'tz2', b'tz3', b'tz4', b'tz5'])


def validate_l2_pkh(v: Union[str, bytes]):
Expand Down
6 changes: 6 additions & 0 deletions src/pytezos/michelson/forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ def forge_address(value: str, tz_only=False) -> bytes:
res = b'\x00\x02' + address
elif prefix == 'tz4':
res = b'\x00\x03' + address
elif prefix == 'tz5':
res = b'\x00\x04' + address
elif prefix == 'KT1':
res = b'\x01' + address + b'\x00'
elif prefix == 'txr1':
Expand All @@ -188,6 +190,7 @@ def unforge_address(data: bytes) -> str:
b'\x00\x01': b'tz2',
b'\x00\x02': b'tz3',
b'\x00\x03': b'tz4',
b'\x00\x04': b'tz5',
}

for bin_prefix, tz_prefix in tz_prefixes.items():
Expand Down Expand Up @@ -245,6 +248,8 @@ def forge_public_key(value: str) -> bytes:
return b'\x02' + res
elif prefix == 'BLpk':
return b'\x03' + res
elif prefix == 'mdpk':
return b'\x04' + res

raise ValueError(f'Unrecognized key type: #{prefix}')

Expand All @@ -260,6 +265,7 @@ def unforge_public_key(data: bytes) -> str:
b'\x01': b'sppk',
b'\x02': b'p2pk',
b'\x03': b'BLpk',
b'\x04': b'mdpk',
}
return base58_encode(data[1:], key_prefix[data[:1]]).decode()

Expand Down
89 changes: 89 additions & 0 deletions src/pytezos/operation/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,95 @@ def endorsement_with_slot(self, endorsement: dict, slot: int):
}
)

@inline_doc
def attestation(
self,
slot: int,
level: int,
round: int, # noqa: A002
block_payload_hash: str,
dal_attestation: Optional[int] = None,
) -> Dict[str, Any]:
"""Attest a block (Tenderbake consensus, Oxford+ rename of `endorsement`).

:param slot: First (smallest) slot of the attester for this level
:param level: Attested level
:param round: Attested round
:param block_payload_hash: Hash of the attested block payload (vh...)
:param dal_attestation: Optional DAL attestation bitset (produces an `attestation_with_dal`)
:returns: dict or OperationGroup
"""
content = {
'kind': 'attestation',
'slot': slot,
'level': level,
'round': round,
'block_payload_hash': block_payload_hash,
}
if dal_attestation is not None:
content['dal_attestation'] = dal_attestation
return self.operation(content)

@inline_doc
def preattestation(
self,
slot: int,
level: int,
round: int, # noqa: A002
block_payload_hash: str,
) -> Dict[str, Any]:
"""Preattest a block (Tenderbake consensus, Oxford+ rename of `preendorsement`).

:param slot: First (smallest) slot of the attester for this level
:param level: Preattested level
:param round: Preattested round
:param block_payload_hash: Hash of the preattested block payload (vh...)
:returns: dict or OperationGroup
"""
return self.operation(
{
'kind': 'preattestation',
'slot': slot,
'level': level,
'round': round,
'block_payload_hash': block_payload_hash,
}
)

@inline_doc
def double_consensus_operation_evidence(self, slot: int, op1: Dict[str, Any], op2: Dict[str, Any]):
"""Provide evidence of a double (pre)attestation (Oxford+, replaces `double_endorsement_evidence`).

Inlined consensus operation format:

.. code-block:: python

{
"branch": $block_hash,
"operations": {
"kind": "attestation", # or "preattestation"
"slot": integer,
"level": integer,
"round": integer,
"block_payload_hash": $block_payload_hash
},
"signature"?: $Signature
}

:param slot: Slot of the denounced attester
:param op1: Inlined consensus operation
:param op2: Inlined consensus operation
:returns: dict or OperationGroup
"""
return self.operation(
{
'kind': 'double_consensus_operation_evidence',
'slot': slot,
'op1': op1,
'op2': op2,
}
)

@inline_doc
def seed_nonce_revelation(self, level: int, nonce: str):
"""Reveal the nonce committed operation in the previous cycle.
Expand Down
88 changes: 88 additions & 0 deletions src/pytezos/operation/forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pytezos.michelson.forge import forge_array
from pytezos.michelson.forge import forge_base58
from pytezos.michelson.forge import forge_bool
from pytezos.michelson.forge import forge_int
from pytezos.michelson.forge import forge_int16
from pytezos.michelson.forge import forge_int32
from pytezos.michelson.forge import forge_micheline
Expand Down Expand Up @@ -58,6 +59,12 @@ def forge_operation(content: Dict[str, Any]) -> bytes:
'delegation': forge_delegation,
'endorsement': forge_endorsement,
'endorsement_with_slot': forge_endorsement_with_slot,
'preattestation': forge_consensus_operation,
'attestation': forge_consensus_operation,
'attestation_with_dal': forge_consensus_operation,
'preattestations_aggregate': forge_preattestations_aggregate,
'attestations_aggregate': forge_attestations_aggregate,
'double_consensus_operation_evidence': forge_double_consensus_operation_evidence,
'register_global_constant': forge_register_global_constant,
'transfer_ticket': forge_transfer_ticket,
'smart_rollup_add_messages': forge_smart_rollup_add_messages,
Expand Down Expand Up @@ -183,6 +190,87 @@ def forge_endorsement_with_slot(content: Dict[str, Any]) -> bytes:
return res


def forge_consensus_content(content: Dict[str, Any]) -> bytes:
"""Forge the body shared by (pre)attestation ops: slot, level, round, block_payload_hash.

Appends the DAL bitset (signed Zarith, Data_encoding.z) when `dal_attestation` is present.
"""
res = forge_int16(int(content['slot']))
res += forge_int32(int(content['level']))
res += forge_int32(int(content['round']))
res += forge_base58(content['block_payload_hash'])
if content.get('dal_attestation') is not None:
res += forge_int(int(content['dal_attestation']))
return res


def _consensus_tag(content: Dict[str, Any]) -> int:
"""Resolve the consensus operation tag, switching `attestation` to the DAL tag when needed."""
if content['kind'] == 'attestation' and content.get('dal_attestation') is not None:
return operation_tags['attestation_with_dal']
return operation_tags[content['kind']]


def forge_consensus_operation(content: Dict[str, Any]) -> bytes:
"""Forge a modern consensus operation: `attestation`, `preattestation` or `attestation_with_dal`."""
res = forge_tag(_consensus_tag(content))
res += forge_consensus_content(content)
return res


def forge_inlined_consensus_operation(content: Dict[str, Any]) -> bytes:
"""Forge an inlined consensus operation (used inside denunciations).

Layout: branch ‖ op-tag ‖ consensus_content ‖ [signature].
"""
op = content['operations']
res = forge_base58(content['branch'])
res += forge_tag(_consensus_tag(op))
res += forge_consensus_content(op)
if content.get('signature'):
res += forge_base58(content['signature'])
return res


def forge_double_consensus_operation_evidence(content: Dict[str, Any]) -> bytes:
res = forge_tag(operation_tags[content['kind']])
res += forge_int16(int(content['slot']))
res += forge_array(forge_inlined_consensus_operation(content['op1']))
res += forge_array(forge_inlined_consensus_operation(content['op2']))
return res


def forge_consensus_aggregate_content(content: Dict[str, Any]) -> bytes:
"""Forge the aggregate consensus content: level, round, block_payload_hash (no slot)."""
res = forge_int32(int(content['level']))
res += forge_int32(int(content['round']))
res += forge_base58(content['block_payload_hash'])
return res


def forge_preattestations_aggregate(content: Dict[str, Any]) -> bytes:
res = forge_tag(operation_tags[content['kind']])
res += forge_consensus_aggregate_content(content['consensus_content'])
res += forge_array(b''.join(forge_int16(int(slot)) for slot in content['committee']))
return res


def forge_attestations_aggregate(content: Dict[str, Any]) -> bytes:
res = forge_tag(operation_tags[content['kind']])
res += forge_consensus_aggregate_content(content['consensus_content'])

committee = b''
for member in content['committee']:
committee += forge_int16(int(member['slot']))
if member.get('dal_attestation') is not None:
committee += forge_bool(True)
committee += forge_int(int(member['dal_attestation']))
else:
committee += forge_bool(False)
res += forge_array(committee)
return res


def forge_failing_noop(content: Dict[str, Any]) -> bytes:
res = forge_tag(operation_tags[content['kind']])
res += forge_array(content['arbitrary'].encode())
Expand Down
Loading
Loading