actproof 0.2.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.
- actproof/__init__.py +234 -0
- actproof/anchor.py +586 -0
- actproof/canonical.py +369 -0
- actproof/catalogue.py +1031 -0
- actproof/cli.py +593 -0
- actproof/manifest.py +728 -0
- actproof/receipt.py +678 -0
- actproof/signers/__init__.py +89 -0
- actproof/signers/google_kms.py +392 -0
- actproof/signers/interface.py +298 -0
- actproof/signers/mnemonic.py +153 -0
- actproof/timestamp.py +527 -0
- actproof/verify.py +683 -0
- actproof-0.2.0.dist-info/METADATA +295 -0
- actproof-0.2.0.dist-info/RECORD +18 -0
- actproof-0.2.0.dist-info/WHEEL +4 -0
- actproof-0.2.0.dist-info/entry_points.txt +2 -0
- actproof-0.2.0.dist-info/licenses/LICENSE +21 -0
actproof/anchor.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Deyan Paroushev
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
Anchor a manifest hash to the Algorand ledger via an ARC-2 disclosed-mode note.
|
|
5
|
+
|
|
6
|
+
This is the module that touches money. Each call to ``anchor_manifest`` (in
|
|
7
|
+
production mode) submits a real, signed, 0-Algo self-payment transaction to
|
|
8
|
+
mainnet. The transaction carries a small JSON note containing the
|
|
9
|
+
``manifest_hash``; that note plus its txid and confirmation round are the
|
|
10
|
+
on-chain commitment.
|
|
11
|
+
|
|
12
|
+
Three modes per the architectural review
|
|
13
|
+
----------------------------------------
|
|
14
|
+
|
|
15
|
+
* ``AnchorMode.DRAFT`` - build the note bytes and the Algorand transaction
|
|
16
|
+
object, do NOT sign, do NOT submit. Returns an ``AnchorRecord`` with
|
|
17
|
+
``txid=""`` and ``block_round=None``. Useful for previewing what will go
|
|
18
|
+
on-chain, for offline test workflows, and for CI smoke tests that should
|
|
19
|
+
never burn real Algos.
|
|
20
|
+
* ``AnchorMode.DEMO`` - sign and submit to **testnet**. Real transaction,
|
|
21
|
+
real confirmation, but on the test network where transaction fees are
|
|
22
|
+
paid in test Algos. Used for end-to-end staging tests, integration suites,
|
|
23
|
+
partner demos.
|
|
24
|
+
* ``AnchorMode.PRODUCTION`` - sign and submit to **mainnet**. Real
|
|
25
|
+
transaction with non-zero economic value (the 1000-microAlgo fee, plus
|
|
26
|
+
the durability cost of permanent ledger storage).
|
|
27
|
+
|
|
28
|
+
The mode is an explicit argument with no default. Callers must say which
|
|
29
|
+
network they want. There is no "if I don't say anything, please submit to
|
|
30
|
+
mainnet."
|
|
31
|
+
|
|
32
|
+
ARC-2 disclosed-mode note format
|
|
33
|
+
--------------------------------
|
|
34
|
+
|
|
35
|
+
The on-chain note is::
|
|
36
|
+
|
|
37
|
+
actproof:j{"h":"<hex>","t":"<batching_profile>","v":1}
|
|
38
|
+
|
|
39
|
+
After the ``actproof:j`` ARC-2 prefix, the payload is RFC 8785 canonical
|
|
40
|
+
JSON with three short fields:
|
|
41
|
+
|
|
42
|
+
* ``"h"`` - the manifest_hash as lowercase hex (no ``"sha256:"`` prefix to
|
|
43
|
+
keep size minimal; the algorithm is implied by length and by ARC-2 dapp
|
|
44
|
+
name actproof always using SHA-256 in v1).
|
|
45
|
+
* ``"t"`` - the batching profile (``"single_attestation_anchor_v1"`` in v1).
|
|
46
|
+
* ``"v"`` - the format version (``1``).
|
|
47
|
+
|
|
48
|
+
Total bytes well under the 1000-byte Algorand note limit (about 125 bytes
|
|
49
|
+
for SHA-256, leaving plenty of headroom for future formats).
|
|
50
|
+
|
|
51
|
+
Signer abstraction
|
|
52
|
+
------------------
|
|
53
|
+
|
|
54
|
+
This module defines a ``Signer`` Protocol with two operations: ``address``
|
|
55
|
+
(the Algorand address being anchored from) and ``sign_transaction(txn)``
|
|
56
|
+
(sign a built ``Transaction``, return a ``SignedTransaction``). Concrete
|
|
57
|
+
implementations land in v0.0.8 (``actproof.signers``): ``KMSSigner`` for
|
|
58
|
+
AWS KMS production use, ``MnemonicSigner`` for testing only. The Protocol
|
|
59
|
+
deliberately exposes ONLY transaction signing; concrete classes enforce
|
|
60
|
+
the discipline that the underlying key never signs arbitrary bytes.
|
|
61
|
+
|
|
62
|
+
API
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
* ``anchor_manifest(manifest_hash, *, signer, mode, ...) -> AnchorRecord``
|
|
66
|
+
* ``build_note_payload(manifest_hash, batching_profile) -> bytes``
|
|
67
|
+
* ``build_note_bytes(manifest_hash, batching_profile) -> bytes``
|
|
68
|
+
* ``build_transaction(manifest_hash, *, signer_address, suggested_params,
|
|
69
|
+
batching_profile) -> PaymentTxn``
|
|
70
|
+
* ``AnchorMode`` - the three-mode enum.
|
|
71
|
+
* ``Signer`` - the signer Protocol.
|
|
72
|
+
* ``AnchorError`` - raised on submission or confirmation problems.
|
|
73
|
+
* Constants: ``DEFAULT_ALGOD_URL_MAINNET``, ``DEFAULT_ALGOD_URL_TESTNET``,
|
|
74
|
+
``DEFAULT_CONFIRMATION_TIMEOUT_SECONDS``, ``ALGORAND_NOTE_MAX_BYTES``,
|
|
75
|
+
``NOTE_VERSION``.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
from __future__ import annotations
|
|
79
|
+
|
|
80
|
+
import base64
|
|
81
|
+
import logging
|
|
82
|
+
import time
|
|
83
|
+
from dataclasses import dataclass
|
|
84
|
+
from datetime import datetime, timezone
|
|
85
|
+
from enum import Enum
|
|
86
|
+
from typing import Any, Optional, Protocol, runtime_checkable
|
|
87
|
+
|
|
88
|
+
from actproof.canonical import canonicalize
|
|
89
|
+
from actproof.manifest import BATCHING_PROFILE_SINGLE
|
|
90
|
+
from actproof.receipt import (
|
|
91
|
+
ALGORAND_MAINNET,
|
|
92
|
+
ALGORAND_TESTNET,
|
|
93
|
+
ARC2_DAPP_NAME,
|
|
94
|
+
ARC2_FORMAT_VERSION_JSON,
|
|
95
|
+
ARC2_NOTE_FORMAT,
|
|
96
|
+
AnchorRecord,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
logger = logging.getLogger(__name__)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ─────────────────────────────────────────────────────────────────
|
|
104
|
+
# OPTIONAL IMPORT: py-algorand-sdk
|
|
105
|
+
# ─────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
from algosdk import transaction as _algo_txn
|
|
109
|
+
from algosdk.v2client.algod import AlgodClient
|
|
110
|
+
_ALGOSDK_AVAILABLE: bool = True
|
|
111
|
+
_ALGOSDK_ERROR: Optional[str] = None
|
|
112
|
+
PaymentTxn = _algo_txn.PaymentTxn
|
|
113
|
+
SignedTransaction = _algo_txn.SignedTransaction
|
|
114
|
+
SuggestedParams = _algo_txn.SuggestedParams
|
|
115
|
+
Transaction = _algo_txn.Transaction
|
|
116
|
+
except Exception as exc: # noqa: BLE001
|
|
117
|
+
# py-algorand-sdk is a hard dependency; mirror the timestamp.py pattern
|
|
118
|
+
# of staying importable even if a transitive conflict (e.g. msgpack,
|
|
119
|
+
# cryptography, websockets) breaks the loader. Failures surface at
|
|
120
|
+
# call time.
|
|
121
|
+
_ALGOSDK_AVAILABLE = False
|
|
122
|
+
_ALGOSDK_ERROR = str(exc)
|
|
123
|
+
PaymentTxn = None # type: ignore[assignment,misc]
|
|
124
|
+
SignedTransaction = None # type: ignore[assignment,misc]
|
|
125
|
+
SuggestedParams = None # type: ignore[assignment,misc]
|
|
126
|
+
Transaction = None # type: ignore[assignment,misc]
|
|
127
|
+
AlgodClient = None # type: ignore[assignment,misc]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = [
|
|
131
|
+
"AnchorMode",
|
|
132
|
+
"AnchorError",
|
|
133
|
+
"Signer",
|
|
134
|
+
"anchor_manifest",
|
|
135
|
+
"build_note_payload",
|
|
136
|
+
"build_note_bytes",
|
|
137
|
+
"build_transaction",
|
|
138
|
+
"DEFAULT_ALGOD_URL_MAINNET",
|
|
139
|
+
"DEFAULT_ALGOD_URL_TESTNET",
|
|
140
|
+
"DEFAULT_CONFIRMATION_TIMEOUT_SECONDS",
|
|
141
|
+
"ALGORAND_NOTE_MAX_BYTES",
|
|
142
|
+
"NOTE_VERSION",
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ─────────────────────────────────────────────────────────────────
|
|
147
|
+
# CONSTANTS
|
|
148
|
+
# ─────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
DEFAULT_ALGOD_URL_MAINNET: str = "https://mainnet-api.algonode.cloud"
|
|
151
|
+
"""Default public algod endpoint for mainnet (Algonode, free)."""
|
|
152
|
+
|
|
153
|
+
DEFAULT_ALGOD_URL_TESTNET: str = "https://testnet-api.algonode.cloud"
|
|
154
|
+
"""Default public algod endpoint for testnet (Algonode, free)."""
|
|
155
|
+
|
|
156
|
+
DEFAULT_CONFIRMATION_TIMEOUT_SECONDS: float = 60.0
|
|
157
|
+
"""Default time to wait for a transaction to confirm on-chain (Algorand
|
|
158
|
+
blocks are ~3.3 seconds; 60s covers roughly 18 rounds)."""
|
|
159
|
+
|
|
160
|
+
ALGORAND_NOTE_MAX_BYTES: int = 1000
|
|
161
|
+
"""Algorand transaction note max size in bytes (protocol limit)."""
|
|
162
|
+
|
|
163
|
+
NOTE_VERSION: int = 1
|
|
164
|
+
"""Note format version. Increment when the ``h``/``t``/``v`` payload
|
|
165
|
+
structure changes (would require coordinated rollout of verifiers)."""
|
|
166
|
+
|
|
167
|
+
_ARC2_PREFIX: bytes = b"actproof:j"
|
|
168
|
+
"""ARC-2 note prefix: dapp_name + ':' + format_version."""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ─────────────────────────────────────────────────────────────────
|
|
172
|
+
# EXCEPTIONS
|
|
173
|
+
# ─────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
class AnchorError(RuntimeError):
|
|
176
|
+
"""Raised on submission, signing, or confirmation problems.
|
|
177
|
+
|
|
178
|
+
Subclass of ``RuntimeError`` rather than ``ValueError`` because the
|
|
179
|
+
typical failure mode here is operational (network down, signer
|
|
180
|
+
unavailable, confirmation timed out) rather than input-validation.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ─────────────────────────────────────────────────────────────────
|
|
185
|
+
# ENUMS
|
|
186
|
+
# ─────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
class AnchorMode(str, Enum):
|
|
189
|
+
"""Three operational modes for ``anchor_manifest``.
|
|
190
|
+
|
|
191
|
+
* ``DRAFT`` - build the transaction without signing or submitting.
|
|
192
|
+
Returns an ``AnchorRecord`` with ``txid=""`` and ``block_round=None``.
|
|
193
|
+
Network defaults to testnet for the recorded ``network`` field.
|
|
194
|
+
* ``DEMO`` - sign and submit to **testnet**.
|
|
195
|
+
* ``PRODUCTION`` - sign and submit to **mainnet**.
|
|
196
|
+
"""
|
|
197
|
+
DRAFT = "draft"
|
|
198
|
+
DEMO = "demo"
|
|
199
|
+
PRODUCTION = "production"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ─────────────────────────────────────────────────────────────────
|
|
203
|
+
# SIGNER PROTOCOL
|
|
204
|
+
# ─────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
@runtime_checkable
|
|
207
|
+
class Signer(Protocol):
|
|
208
|
+
"""Sign Algorand transactions. Implementations land in v0.0.8.
|
|
209
|
+
|
|
210
|
+
Concrete implementations in ``actproof.signers``:
|
|
211
|
+
|
|
212
|
+
* ``KMSSigner`` - AWS KMS-backed Ed25519 signing for production.
|
|
213
|
+
* ``MnemonicSigner`` - mnemonic-based local signing for testing only.
|
|
214
|
+
|
|
215
|
+
Defense-in-depth note: implementations should sign ONLY Algorand
|
|
216
|
+
transactions, never arbitrary bytes. The Protocol enforces this
|
|
217
|
+
structurally (it only exposes ``sign_transaction``); the v0.0.8
|
|
218
|
+
abstract base class additionally enforces it at class-definition
|
|
219
|
+
time via ``__init_subclass__``.
|
|
220
|
+
|
|
221
|
+
Attributes:
|
|
222
|
+
address: The signer's 58-character base32 Algorand address.
|
|
223
|
+
|
|
224
|
+
Methods:
|
|
225
|
+
sign_transaction(txn): Sign a built ``Transaction``, return a
|
|
226
|
+
``SignedTransaction``.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def address(self) -> str:
|
|
231
|
+
"""The signer's 58-character base32 Algorand address."""
|
|
232
|
+
...
|
|
233
|
+
|
|
234
|
+
def sign_transaction(self, txn: Any) -> Any:
|
|
235
|
+
"""Sign an Algorand transaction. Returns a ``SignedTransaction``.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
txn: An ``algosdk.transaction.Transaction`` (typically a
|
|
239
|
+
``PaymentTxn``).
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
An ``algosdk.transaction.SignedTransaction``.
|
|
243
|
+
"""
|
|
244
|
+
...
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ─────────────────────────────────────────────────────────────────
|
|
248
|
+
# NOTE CONSTRUCTION
|
|
249
|
+
# ─────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
def build_note_payload(
|
|
252
|
+
manifest_hash: bytes,
|
|
253
|
+
batching_profile: str = BATCHING_PROFILE_SINGLE,
|
|
254
|
+
) -> bytes:
|
|
255
|
+
"""Build the ARC-2 disclosed-mode note payload bytes (without prefix).
|
|
256
|
+
|
|
257
|
+
The payload is RFC 8785 canonical JSON with three short fields:
|
|
258
|
+
``h`` (manifest hash hex), ``t`` (batching profile), ``v`` (version).
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
manifest_hash: Raw bytes of the manifest hash. Typically 32 bytes
|
|
262
|
+
for SHA-256.
|
|
263
|
+
batching_profile: The batching profile identifier. Defaults to
|
|
264
|
+
``"single_attestation_anchor_v1"`` (v1).
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Canonical JSON payload as UTF-8 bytes. To get the full note bytes,
|
|
268
|
+
prepend ``actproof:j``.
|
|
269
|
+
"""
|
|
270
|
+
payload_dict = {
|
|
271
|
+
"h": manifest_hash.hex(),
|
|
272
|
+
"t": batching_profile,
|
|
273
|
+
"v": NOTE_VERSION,
|
|
274
|
+
}
|
|
275
|
+
# RFC 8785 sorts keys alphabetically; payload becomes h, t, v.
|
|
276
|
+
return canonicalize(payload_dict)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def build_note_bytes(
|
|
280
|
+
manifest_hash: bytes,
|
|
281
|
+
batching_profile: str = BATCHING_PROFILE_SINGLE,
|
|
282
|
+
) -> bytes:
|
|
283
|
+
"""Build the full on-chain note bytes, including the ARC-2 prefix.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
manifest_hash: Raw bytes of the manifest hash.
|
|
287
|
+
batching_profile: The batching profile identifier.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Note bytes: ``b"actproof:j" + canonical_payload_bytes``. Goes
|
|
291
|
+
directly into the ``note`` field of an Algorand transaction.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
AnchorError: If the resulting note exceeds the Algorand 1000-byte
|
|
295
|
+
note limit (this should not happen for sensible inputs).
|
|
296
|
+
"""
|
|
297
|
+
payload = build_note_payload(manifest_hash, batching_profile)
|
|
298
|
+
note_bytes = _ARC2_PREFIX + payload
|
|
299
|
+
if len(note_bytes) > ALGORAND_NOTE_MAX_BYTES:
|
|
300
|
+
raise AnchorError(
|
|
301
|
+
f"Note size {len(note_bytes)} bytes exceeds Algorand limit "
|
|
302
|
+
f"{ALGORAND_NOTE_MAX_BYTES} bytes. This should not happen for "
|
|
303
|
+
f"a SHA-256 single-attestation anchor; check the batching "
|
|
304
|
+
f"profile string length."
|
|
305
|
+
)
|
|
306
|
+
return note_bytes
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ─────────────────────────────────────────────────────────────────
|
|
310
|
+
# TRANSACTION CONSTRUCTION
|
|
311
|
+
# ─────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
def build_transaction(
|
|
314
|
+
manifest_hash: bytes,
|
|
315
|
+
*,
|
|
316
|
+
signer_address: str,
|
|
317
|
+
suggested_params: Any,
|
|
318
|
+
batching_profile: str = BATCHING_PROFILE_SINGLE,
|
|
319
|
+
) -> Any:
|
|
320
|
+
"""Build the unsigned Algorand transaction carrying the anchor note.
|
|
321
|
+
|
|
322
|
+
The transaction is a 0-Algo self-payment: ``sender = receiver =
|
|
323
|
+
signer_address``, ``amount = 0``, note = the ARC-2 disclosed-mode
|
|
324
|
+
note bytes. The 0-Algo self-payment pattern is the standard way to
|
|
325
|
+
publish a note on Algorand without moving funds.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
manifest_hash: Raw bytes of the manifest hash to anchor.
|
|
329
|
+
signer_address: Algorand address (58 chars base32) that will
|
|
330
|
+
both send and receive the 0-Algo payment.
|
|
331
|
+
suggested_params: ``algosdk.transaction.SuggestedParams`` from
|
|
332
|
+
``algod_client.suggested_params()``. Caller must fetch this
|
|
333
|
+
before calling; the function does not reach out for it.
|
|
334
|
+
batching_profile: The batching profile identifier. Defaults to v1.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
An unsigned ``algosdk.transaction.PaymentTxn``.
|
|
338
|
+
|
|
339
|
+
Raises:
|
|
340
|
+
AnchorError: If py-algorand-sdk is unavailable.
|
|
341
|
+
"""
|
|
342
|
+
_ensure_algosdk()
|
|
343
|
+
note = build_note_bytes(manifest_hash, batching_profile)
|
|
344
|
+
return PaymentTxn(
|
|
345
|
+
sender=signer_address,
|
|
346
|
+
sp=suggested_params,
|
|
347
|
+
receiver=signer_address,
|
|
348
|
+
amt=0,
|
|
349
|
+
note=note,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ─────────────────────────────────────────────────────────────────
|
|
354
|
+
# INTERNAL HELPERS
|
|
355
|
+
# ─────────────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
def _ensure_algosdk() -> None:
|
|
358
|
+
"""Raise AnchorError if py-algorand-sdk is not importable."""
|
|
359
|
+
if not _ALGOSDK_AVAILABLE:
|
|
360
|
+
raise AnchorError(
|
|
361
|
+
f"py-algorand-sdk is required but cannot be imported: "
|
|
362
|
+
f"{_ALGOSDK_ERROR}. Install or repair with: "
|
|
363
|
+
f"pip install 'py-algorand-sdk>=2.6.1'"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _network_for_mode(mode: AnchorMode) -> str:
|
|
368
|
+
"""Map an AnchorMode to its associated network identifier."""
|
|
369
|
+
if mode is AnchorMode.PRODUCTION:
|
|
370
|
+
return ALGORAND_MAINNET
|
|
371
|
+
return ALGORAND_TESTNET # DRAFT and DEMO both use testnet semantics
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _default_algod_url_for_mode(mode: AnchorMode) -> str:
|
|
375
|
+
if mode is AnchorMode.PRODUCTION:
|
|
376
|
+
return DEFAULT_ALGOD_URL_MAINNET
|
|
377
|
+
return DEFAULT_ALGOD_URL_TESTNET
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _make_algod_client(
|
|
381
|
+
algod_client: Optional[Any],
|
|
382
|
+
algod_url: Optional[str],
|
|
383
|
+
algod_token: Optional[str],
|
|
384
|
+
mode: AnchorMode,
|
|
385
|
+
) -> Any:
|
|
386
|
+
"""Return an AlgodClient, either passed-in or constructed from args."""
|
|
387
|
+
_ensure_algosdk()
|
|
388
|
+
if algod_client is not None:
|
|
389
|
+
return algod_client
|
|
390
|
+
url = algod_url or _default_algod_url_for_mode(mode)
|
|
391
|
+
token = algod_token or ""
|
|
392
|
+
return AlgodClient(token, url)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _wait_for_confirmation(
|
|
396
|
+
algod_client: Any,
|
|
397
|
+
txid: str,
|
|
398
|
+
timeout_seconds: float,
|
|
399
|
+
) -> tuple[int, str]:
|
|
400
|
+
"""Poll algod until ``txid`` is confirmed. Returns (block_round, iso_ts).
|
|
401
|
+
|
|
402
|
+
Raises:
|
|
403
|
+
AnchorError: On timeout or if algod reports an error.
|
|
404
|
+
"""
|
|
405
|
+
deadline = time.monotonic() + timeout_seconds
|
|
406
|
+
last_round_pending: Optional[int] = None
|
|
407
|
+
while True:
|
|
408
|
+
try:
|
|
409
|
+
tx_info = algod_client.pending_transaction_info(txid)
|
|
410
|
+
except Exception as exc: # noqa: BLE001
|
|
411
|
+
raise AnchorError(
|
|
412
|
+
f"algod.pending_transaction_info failed for {txid}: {exc}"
|
|
413
|
+
) from exc
|
|
414
|
+
|
|
415
|
+
confirmed_round = tx_info.get("confirmed-round") or 0
|
|
416
|
+
if confirmed_round > 0:
|
|
417
|
+
confirmed_at = datetime.now(timezone.utc).isoformat().replace(
|
|
418
|
+
"+00:00", "Z"
|
|
419
|
+
)
|
|
420
|
+
return int(confirmed_round), confirmed_at
|
|
421
|
+
|
|
422
|
+
pool_error = tx_info.get("pool-error")
|
|
423
|
+
if pool_error:
|
|
424
|
+
raise AnchorError(
|
|
425
|
+
f"Transaction {txid} rejected by algod pool: {pool_error}"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if time.monotonic() >= deadline:
|
|
429
|
+
raise AnchorError(
|
|
430
|
+
f"Timed out after {timeout_seconds}s waiting for {txid} to "
|
|
431
|
+
f"confirm (last pending round seen: {last_round_pending})"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
last_round_pending = tx_info.get("last-round")
|
|
435
|
+
# Algorand block time is ~3.3s; poll every 1s for responsiveness.
|
|
436
|
+
time.sleep(1.0)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ─────────────────────────────────────────────────────────────────
|
|
440
|
+
# PUBLIC ORCHESTRATOR
|
|
441
|
+
# ─────────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
def anchor_manifest(
|
|
444
|
+
manifest_hash: bytes,
|
|
445
|
+
*,
|
|
446
|
+
signer: Signer,
|
|
447
|
+
mode: AnchorMode,
|
|
448
|
+
algod_client: Optional[Any] = None,
|
|
449
|
+
algod_url: Optional[str] = None,
|
|
450
|
+
algod_token: Optional[str] = None,
|
|
451
|
+
batching_profile: str = BATCHING_PROFILE_SINGLE,
|
|
452
|
+
wait_for_confirmation: bool = True,
|
|
453
|
+
confirmation_timeout_seconds: float = DEFAULT_CONFIRMATION_TIMEOUT_SECONDS,
|
|
454
|
+
) -> AnchorRecord:
|
|
455
|
+
"""Anchor a manifest hash to Algorand.
|
|
456
|
+
|
|
457
|
+
The end-to-end flow:
|
|
458
|
+
|
|
459
|
+
1. Build the ARC-2 disclosed-mode note bytes from ``manifest_hash``.
|
|
460
|
+
2. If ``mode`` is ``DRAFT``: return an unanchored AnchorRecord.
|
|
461
|
+
3. Otherwise: fetch ``suggested_params`` from algod.
|
|
462
|
+
4. Build the unsigned ``PaymentTxn``.
|
|
463
|
+
5. Sign via ``signer.sign_transaction``.
|
|
464
|
+
6. Submit to algod.
|
|
465
|
+
7. If ``wait_for_confirmation``: poll until confirmed (or timeout).
|
|
466
|
+
8. Return an ``AnchorRecord`` populated with the result.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
manifest_hash: Raw bytes of the manifest hash to anchor.
|
|
470
|
+
Typically 32 bytes (SHA-256).
|
|
471
|
+
signer: A ``Signer`` implementation. Provides the Algorand address
|
|
472
|
+
and signs the built transaction.
|
|
473
|
+
mode: One of ``AnchorMode.DRAFT``, ``DEMO``, or ``PRODUCTION``.
|
|
474
|
+
No default; must be explicit.
|
|
475
|
+
algod_client: Optional pre-built ``AlgodClient``. If not provided,
|
|
476
|
+
one is constructed from ``algod_url`` and ``algod_token``, or
|
|
477
|
+
from Algonode defaults based on ``mode``.
|
|
478
|
+
algod_url: Optional algod HTTPS URL. Defaults per mode.
|
|
479
|
+
algod_token: Optional algod auth token. Default empty (public nodes
|
|
480
|
+
accept empty tokens).
|
|
481
|
+
batching_profile: Batching profile identifier. v1 default.
|
|
482
|
+
wait_for_confirmation: If ``True`` (default), poll until the
|
|
483
|
+
submitted transaction confirms or the timeout expires. If
|
|
484
|
+
``False``, return as soon as the transaction is submitted
|
|
485
|
+
with ``block_round=None`` and ``confirmed_at=None``.
|
|
486
|
+
confirmation_timeout_seconds: How long to wait for confirmation.
|
|
487
|
+
Default 60.0 seconds.
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
An ``AnchorRecord`` ready to slot into a ``Receipt`` via
|
|
491
|
+
``build_receipt``.
|
|
492
|
+
|
|
493
|
+
Raises:
|
|
494
|
+
AnchorError: If py-algorand-sdk is unavailable, the signer rejects
|
|
495
|
+
the transaction, algod refuses to submit, or confirmation times
|
|
496
|
+
out.
|
|
497
|
+
"""
|
|
498
|
+
_ensure_algosdk()
|
|
499
|
+
|
|
500
|
+
# Build the note bytes. Same in all three modes; the note is the
|
|
501
|
+
# commitment, the transaction is the carrier.
|
|
502
|
+
note_bytes = build_note_bytes(manifest_hash, batching_profile)
|
|
503
|
+
note_payload_b64 = base64.b64encode(note_bytes[len(_ARC2_PREFIX):]).decode("ascii")
|
|
504
|
+
|
|
505
|
+
network = _network_for_mode(mode)
|
|
506
|
+
|
|
507
|
+
# DRAFT mode: don't sign, don't submit, don't fetch suggested_params.
|
|
508
|
+
if mode is AnchorMode.DRAFT:
|
|
509
|
+
logger.info(
|
|
510
|
+
"DRAFT anchor: built note (%d bytes) for hash %s; not submitting",
|
|
511
|
+
len(note_bytes), manifest_hash.hex()[:16],
|
|
512
|
+
)
|
|
513
|
+
return AnchorRecord(
|
|
514
|
+
network=network,
|
|
515
|
+
txid="",
|
|
516
|
+
block_round=None,
|
|
517
|
+
confirmed_at=None,
|
|
518
|
+
note_format=ARC2_NOTE_FORMAT,
|
|
519
|
+
note_dapp_name=ARC2_DAPP_NAME,
|
|
520
|
+
note_format_version=ARC2_FORMAT_VERSION_JSON,
|
|
521
|
+
note_payload_b64=note_payload_b64,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
# DEMO and PRODUCTION: real submission.
|
|
525
|
+
client = _make_algod_client(algod_client, algod_url, algod_token, mode)
|
|
526
|
+
|
|
527
|
+
# Fetch suggested params (algod call).
|
|
528
|
+
try:
|
|
529
|
+
suggested_params = client.suggested_params()
|
|
530
|
+
except Exception as exc: # noqa: BLE001
|
|
531
|
+
raise AnchorError(
|
|
532
|
+
f"algod.suggested_params() failed for mode {mode.value}: {exc}"
|
|
533
|
+
) from exc
|
|
534
|
+
|
|
535
|
+
# Build the unsigned transaction.
|
|
536
|
+
unsigned_txn = build_transaction(
|
|
537
|
+
manifest_hash,
|
|
538
|
+
signer_address=signer.address,
|
|
539
|
+
suggested_params=suggested_params,
|
|
540
|
+
batching_profile=batching_profile,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Sign via the signer abstraction. The signer enforces "transactions
|
|
544
|
+
# only, never raw bytes" at its own boundary.
|
|
545
|
+
try:
|
|
546
|
+
signed_txn = signer.sign_transaction(unsigned_txn)
|
|
547
|
+
except Exception as exc: # noqa: BLE001
|
|
548
|
+
raise AnchorError(
|
|
549
|
+
f"Signer rejected transaction: {exc}"
|
|
550
|
+
) from exc
|
|
551
|
+
|
|
552
|
+
# Submit to algod.
|
|
553
|
+
try:
|
|
554
|
+
txid = client.send_transaction(signed_txn)
|
|
555
|
+
except Exception as exc: # noqa: BLE001
|
|
556
|
+
raise AnchorError(
|
|
557
|
+
f"algod.send_transaction failed for mode {mode.value}: {exc}"
|
|
558
|
+
) from exc
|
|
559
|
+
|
|
560
|
+
logger.info(
|
|
561
|
+
"Submitted txn %s in mode=%s, hash=%s",
|
|
562
|
+
txid, mode.value, manifest_hash.hex()[:16],
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# Optionally wait for confirmation.
|
|
566
|
+
block_round: Optional[int] = None
|
|
567
|
+
confirmed_at: Optional[str] = None
|
|
568
|
+
if wait_for_confirmation:
|
|
569
|
+
block_round, confirmed_at = _wait_for_confirmation(
|
|
570
|
+
client, txid, confirmation_timeout_seconds,
|
|
571
|
+
)
|
|
572
|
+
logger.info(
|
|
573
|
+
"Confirmed txn %s in round %d at %s",
|
|
574
|
+
txid, block_round, confirmed_at,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
return AnchorRecord(
|
|
578
|
+
network=network,
|
|
579
|
+
txid=txid,
|
|
580
|
+
block_round=block_round,
|
|
581
|
+
confirmed_at=confirmed_at,
|
|
582
|
+
note_format=ARC2_NOTE_FORMAT,
|
|
583
|
+
note_dapp_name=ARC2_DAPP_NAME,
|
|
584
|
+
note_format_version=ARC2_FORMAT_VERSION_JSON,
|
|
585
|
+
note_payload_b64=note_payload_b64,
|
|
586
|
+
)
|