From 1afd587c5962255d1c6c2d70c3be1906a41077a0 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Mon, 10 Nov 2025 15:21:30 +0100 Subject: [PATCH 1/6] chore: syncing with ts specs and regen --- api/specs/algod.oas3.json | 204 ++++++++++-------- api/specs/compatibility.md | 79 +++++++ api/specs/indexer.oas3.json | 5 +- api/specs/kmd.oas3.json | 2 +- src/algokit_algod_client/client.py | 6 +- src/algokit_algod_client/models/__init__.py | 6 +- src/algokit_algod_client/models/_asset.py | 4 +- .../models/_dryrun_source.py | 4 +- .../models/_eval_delta_key_value.py | 9 +- .../models/_genesis_file_in_json.py | 7 +- ...py => _get_block_tx_ids_response_model.py} | 6 +- .../models/_source_map.py | 26 +++ .../models/_teal_compile_response_model.py | 8 +- .../models/_teal_key_value.py | 9 +- src/algokit_indexer_client/models/_asset.py | 4 +- .../models/_transaction.py | 8 +- 16 files changed, 265 insertions(+), 122 deletions(-) create mode 100644 api/specs/compatibility.md rename src/algokit_algod_client/models/{_get_block_txids_response_model.py => _get_block_tx_ids_response_model.py} (56%) create mode 100644 src/algokit_algod_client/models/_source_map.py diff --git a/api/specs/algod.oas3.json b/api/specs/algod.oas3.json index 39789f6d..149e5f9d 100644 --- a/api/specs/algod.oas3.json +++ b/api/specs/algod.oas3.json @@ -434,9 +434,9 @@ "description": "Maximum number of results to return.", "schema": { "type": "integer", - "x-algokit-bigint": true + "x-go-type": "uint64" }, - "x-algokit-bigint": true + "x-go-type": "uint64" }, { "name": "next", @@ -680,9 +680,9 @@ "description": "Truncated number of transactions to display. If max=0, returns all pending txns.", "schema": { "type": "integer", - "x-algokit-bigint": true + "x-go-type": "uint64" }, - "x-algokit-bigint": true + "x-go-type": "uint64" }, { "name": "format", @@ -886,7 +886,7 @@ "get": { "tags": ["public", "nonparticipating"], "summary": "Get the top level transaction IDs for the block on the given round.", - "operationId": "GetBlockTxids", + "operationId": "GetBlockTxIds", "parameters": [ { "name": "round", @@ -917,7 +917,8 @@ "description": "Block transaction IDs.", "items": { "type": "string" - } + }, + "x-algokit-field-rename": "block_tx_ids" } } } @@ -1272,11 +1273,13 @@ "online-money": { "type": "integer", "description": "OnlineMoney", + "x-go-type": "uint64", "x-algokit-bigint": true }, "total-money": { "type": "integer", "description": "TotalMoney", + "x-go-type": "uint64", "x-algokit-bigint": true } }, @@ -1902,42 +1905,42 @@ "catchpoint-total-accounts": { "type": "integer", "description": "The total number of accounts included in the current catchpoint", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-processed-accounts": { "type": "integer", "description": "The number of accounts from the current catchpoint that have been processed so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-verified-accounts": { "type": "integer", "description": "The number of accounts from the current catchpoint that have been verified so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-total-kvs": { "type": "integer", "description": "The total number of key-values (KVs) included in the current catchpoint", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-processed-kvs": { "type": "integer", "description": "The number of key-values (KVs) from the current catchpoint that have been processed so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-verified-kvs": { "type": "integer", "description": "The number of key-values (KVs) from the current catchpoint that have been verified so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-total-blocks": { "type": "integer", "description": "The total number of blocks that are required to complete the current catchpoint catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-acquired-blocks": { "type": "integer", "description": "The number of blocks that have already been obtained by the node as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "upgrade-delay": { "type": "integer", @@ -2099,42 +2102,42 @@ "catchpoint-total-accounts": { "type": "integer", "description": "The total number of accounts included in the current catchpoint", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-processed-accounts": { "type": "integer", "description": "The number of accounts from the current catchpoint that have been processed so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-verified-accounts": { "type": "integer", "description": "The number of accounts from the current catchpoint that have been verified so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-total-kvs": { "type": "integer", "description": "The total number of key-values (KVs) included in the current catchpoint", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-processed-kvs": { "type": "integer", "description": "The number of key-values (KVs) from the current catchpoint that have been processed so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-verified-kvs": { "type": "integer", "description": "The number of key-values (KVs) from the current catchpoint that have been verified so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-total-blocks": { "type": "integer", "description": "The total number of blocks that are required to complete the current catchpoint catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-acquired-blocks": { "type": "integer", "description": "The number of blocks that have already been obtained by the node as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "upgrade-delay": { "type": "integer", @@ -2423,7 +2426,7 @@ "version": { "type": "integer", "description": "The version of this response object.", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "last-round": { "type": "integer", @@ -2528,6 +2531,7 @@ "fee": { "type": "integer", "description": "Fee is the suggested transaction fee\nFee is in units of micro-Algos per byte.\nFee may fall to zero but transactions must still have a fee of\nat least MinTxnFee for the current network protocol.", + "x-go-type": "uint64", "x-algokit-bigint": true }, "genesis-hash": { @@ -2549,6 +2553,7 @@ "min-fee": { "type": "integer", "description": "The minimum transaction fee (not per byte) required for the\ntxn to validate for the current network protocol.", + "x-go-type": "uint64", "x-algokit-bigint": true } }, @@ -2607,9 +2612,9 @@ "description": "Truncated number of transactions to display. If max=0, returns all pending txns.", "schema": { "type": "integer", - "x-algokit-bigint": true + "x-go-type": "uint64" }, - "x-algokit-bigint": true + "x-go-type": "uint64" }, { "name": "format", @@ -3353,9 +3358,9 @@ "description": "Max number of box names to return. If max is not set, or max == 0, returns all box-names.", "schema": { "type": "integer", - "x-algokit-bigint": true + "x-go-type": "uint64" }, - "x-algokit-bigint": true + "x-go-type": "uint64" } ], "responses": { @@ -3833,9 +3838,7 @@ "description": "base64 encoded program bytes" }, "sourcemap": { - "type": "object", - "properties": {}, - "description": "JSON of the source map" + "$ref": "#/components/schemas/SourceMap" } } } @@ -4284,7 +4287,7 @@ "offset": { "type": "integer", "description": "Timestamp offset in seconds.", - "x-algokit-bigint": true + "x-go-type": "uint64" } } } @@ -4323,9 +4326,9 @@ "schema": { "minimum": 0, "type": "integer", - "x-algokit-bigint": true + "x-go-type": "uint64" }, - "x-algokit-bigint": true + "x-go-type": "uint64" } ], "responses": { @@ -4426,15 +4429,7 @@ }, "Genesis": { "title": "Genesis File in JSON", - "required": [ - "alloc", - "fees", - "id", - "network", - "proto", - "rwd", - "timestamp" - ], + "required": ["alloc", "fees", "id", "network", "proto", "rwd"], "type": "object", "properties": { "alloc": { @@ -4539,18 +4534,14 @@ }, "total-apps-opted-in": { "type": "integer", - "description": "The count of all applications that have been opted in, equivalent to the count of application local data (AppLocalState objects) stored in this account.", - "format": "uint64", - "x-algokit-bigint": true + "description": "The count of all applications that have been opted in, equivalent to the count of application local data (AppLocalState objects) stored in this account." }, "apps-total-schema": { "$ref": "#/components/schemas/ApplicationStateSchema" }, "apps-total-extra-pages": { "type": "integer", - "description": "\\[teap\\] the sum of all extra application program pages for this account.", - "format": "uint64", - "x-algokit-bigint": true + "description": "\\[teap\\] the sum of all extra application program pages for this account." }, "assets": { "type": "array", @@ -4561,9 +4552,7 @@ }, "total-assets-opted-in": { "type": "integer", - "description": "The count of all assets that have been opted in, equivalent to the count of AssetHolding objects held by this account.", - "format": "uint64", - "x-algokit-bigint": true + "description": "The count of all assets that have been opted in, equivalent to the count of AssetHolding objects held by this account." }, "created-apps": { "type": "array", @@ -4574,9 +4563,7 @@ }, "total-created-apps": { "type": "integer", - "description": "The count of all apps (AppParams objects) created by this account.", - "format": "uint64", - "x-algokit-bigint": true + "description": "The count of all apps (AppParams objects) created by this account." }, "created-assets": { "type": "array", @@ -4587,21 +4574,15 @@ }, "total-created-assets": { "type": "integer", - "description": "The count of all assets (AssetParams objects) created by this account.", - "format": "uint64", - "x-algokit-bigint": true + "description": "The count of all assets (AssetParams objects) created by this account." }, "total-boxes": { "type": "integer", - "description": "\\[tbx\\] The number of existing boxes created by this account's app.", - "format": "uint64", - "x-algokit-bigint": true + "description": "\\[tbx\\] The number of existing boxes created by this account's app." }, "total-box-bytes": { "type": "integer", - "description": "\\[tbxb\\] The total number of bytes used by this account's app's box keys and values.", - "format": "uint64", - "x-algokit-bigint": true + "description": "\\[tbxb\\] The total number of bytes used by this account's app's box keys and values." }, "participation": { "$ref": "#/components/schemas/AccountParticipation" @@ -4701,6 +4682,7 @@ "vote-key-dilution": { "type": "integer", "description": "\\[voteKD\\] Number of subkeys in each batch of participation keys.", + "x-go-type": "uint64", "x-algokit-bigint": true }, "vote-last-valid": { @@ -4732,6 +4714,7 @@ "type": "integer", "description": "unique asset identifier", "x-go-type": "basics.AssetIndex", + "x-algokit-field-rename": "id", "x-algokit-bigint": true }, "params": { @@ -4780,9 +4763,7 @@ "maximum": 19, "minimum": 0, "type": "integer", - "description": "\\[dc\\] The number of digits to use after the decimal point when displaying this asset. If 0, the asset is not divisible. If 1, the base unit of the asset is in tenths. If 2, the base unit of the asset is in hundredths, and so on. This value must be between 0 and 19 (inclusive).", - "format": "uint64", - "x-algokit-bigint": true + "description": "\\[dc\\] The number of digits to use after the decimal point when displaying this asset. If 0, the asset is not divisible. If 1, the base unit of the asset is in tenths. If 2, the base unit of the asset is in hundredths, and so on. This value must be between 0 and 19 (inclusive)." }, "default-frozen": { "type": "boolean", @@ -4980,7 +4961,9 @@ "type": "object", "properties": { "key": { - "type": "string" + "type": "string", + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", + "format": "byte" }, "value": { "$ref": "#/components/schemas/TealValue" @@ -5072,7 +5055,9 @@ "type": "object", "properties": { "key": { - "type": "string" + "type": "string", + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", + "format": "byte" }, "value": { "$ref": "#/components/schemas/EvalDelta" @@ -5158,7 +5143,7 @@ "version": { "type": "integer", "description": "\\[v\\] the number of updates to the application programs", - "x-algokit-bigint": true + "x-go-type": "uint64" } }, "description": "Stores the global information associated with an application." @@ -5348,12 +5333,12 @@ "type": "string" }, "txn-index": { - "type": "integer", - "x-algokit-bigint": true + "type": "integer" }, "app-index": { "type": "integer", "x-go-type": "basics.AppIndex", + "x-algokit-field-rename": "app_id", "x-algokit-bigint": true } }, @@ -5602,11 +5587,13 @@ "closing-amount": { "type": "integer", "description": "Closing amount for the transaction.", + "x-go-type": "uint64", "x-algokit-bigint": true }, "asset-closing-amount": { "type": "integer", "description": "The number of the asset's unit that were transferred to the close-to address.", + "x-go-type": "uint64", "x-algokit-bigint": true }, "confirmed-round": { @@ -5622,11 +5609,13 @@ "receiver-rewards": { "type": "integer", "description": "Rewards in microalgos applied to the receiver account.", + "x-go-type": "uint64", "x-algokit-bigint": true }, "sender-rewards": { "type": "integer", "description": "Rewards in microalgos applied to the sender account.", + "x-go-type": "uint64", "x-algokit-bigint": true }, "local-state-delta": { @@ -5752,7 +5741,7 @@ "index": { "type": "integer", "description": "The index of the light block header in the vector commitment tree", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "treedepth": { "type": "integer", @@ -6147,12 +6136,12 @@ "treedepth": { "type": "integer", "description": "Represents the depth of the tree that is being proven, i.e. the number of edges from a leaf to the root.", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "idx": { "type": "integer", "description": "Index of the transaction in the block's payset.", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "hashtype": { "type": "string", @@ -6161,6 +6150,34 @@ } }, "description": "Proof of transaction in a block." + }, + "SourceMap": { + "type": "object", + "required": ["version", "sources", "names", "mappings"], + "properties": { + "version": { + "type": "integer" + }, + "sources": { + "description": "A list of original sources used by the \"mappings\" entry.", + "type": "array", + "items": { + "type": "string" + } + }, + "names": { + "description": "A list of symbol names used by the \"mappings\" entry.", + "type": "array", + "items": { + "type": "string" + } + }, + "mappings": { + "description": "A string with the encoded mapping data.", + "type": "string" + } + }, + "description": "Source map for the program" } }, "responses": { @@ -6175,7 +6192,7 @@ "offset": { "type": "integer", "description": "Timestamp offset in seconds.", - "x-algokit-bigint": true + "x-go-type": "uint64" } } } @@ -6387,7 +6404,8 @@ "description": "Block transaction IDs.", "items": { "type": "string" - } + }, + "x-algokit-field-rename": "block_tx_ids" } } } @@ -6524,42 +6542,42 @@ "catchpoint-total-accounts": { "type": "integer", "description": "The total number of accounts included in the current catchpoint", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-processed-accounts": { "type": "integer", "description": "The number of accounts from the current catchpoint that have been processed so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-verified-accounts": { "type": "integer", "description": "The number of accounts from the current catchpoint that have been verified so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-total-kvs": { "type": "integer", "description": "The total number of key-values (KVs) included in the current catchpoint", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-processed-kvs": { "type": "integer", "description": "The number of key-values (KVs) from the current catchpoint that have been processed so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-verified-kvs": { "type": "integer", "description": "The number of key-values (KVs) from the current catchpoint that have been verified so far as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-total-blocks": { "type": "integer", "description": "The total number of blocks that are required to complete the current catchpoint catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "catchpoint-acquired-blocks": { "type": "integer", "description": "The number of blocks that have already been obtained by the node as part of the catchup", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "upgrade-delay": { "type": "integer", @@ -6699,7 +6717,7 @@ "version": { "type": "integer", "description": "The version of this response object.", - "x-algokit-bigint": true + "x-go-type": "uint64" }, "last-round": { "type": "integer", @@ -6764,11 +6782,13 @@ "online-money": { "type": "integer", "description": "OnlineMoney", + "x-go-type": "uint64", "x-algokit-bigint": true }, "total-money": { "type": "integer", "description": "TotalMoney", + "x-go-type": "uint64", "x-algokit-bigint": true } }, @@ -6799,6 +6819,7 @@ "fee": { "type": "integer", "description": "Fee is the suggested transaction fee\nFee is in units of micro-Algos per byte.\nFee may fall to zero but transactions must still have a fee of\nat least MinTxnFee for the current network protocol.", + "x-go-type": "uint64", "x-algokit-bigint": true }, "genesis-hash": { @@ -6820,6 +6841,7 @@ "min-fee": { "type": "integer", "description": "The minimum transaction fee (not per byte) required for the\ntxn to validate for the current network protocol.", + "x-go-type": "uint64", "x-algokit-bigint": true } }, @@ -6894,9 +6916,7 @@ "description": "base64 encoded program bytes" }, "sourcemap": { - "type": "object", - "properties": {}, - "description": "JSON of the source map" + "$ref": "#/components/schemas/SourceMap" } } } @@ -6996,9 +7016,9 @@ "description": "Maximum number of results to return.", "schema": { "type": "integer", - "x-algokit-bigint": true + "x-go-type": "uint64" }, - "x-algokit-bigint": true + "x-go-type": "uint64" }, "max": { "name": "max", @@ -7006,9 +7026,9 @@ "description": "Truncated number of transactions to display. If max=0, returns all pending txns.", "schema": { "type": "integer", - "x-algokit-bigint": true + "x-go-type": "uint64" }, - "x-algokit-bigint": true + "x-go-type": "uint64" }, "next": { "name": "next", diff --git a/api/specs/compatibility.md b/api/specs/compatibility.md new file mode 100644 index 00000000..618ad633 --- /dev/null +++ b/api/specs/compatibility.md @@ -0,0 +1,79 @@ +| Endpoint | Response-msgpack | Input-msgpack | Response-json | Input-json | +| -------------------------------------------------------- | ---------------- | ------------- | ------------- | ---------- | +| GET /health | ❌ | N/A | ✅ | N/A | +| GET /ready | ❌ | N/A | ✅ | N/A | +| GET /metrics | ❌ | N/A | ✅ | N/A | +| GET /genesis | ❌ | N/A | ✅ | N/A | +| GET /swagger.json | ❌ | N/A | ✅ | N/A | +| GET /versions | ❌ | N/A | ✅ | N/A | +| GET /debug/settings/pprof | ❌ | N/A | ✅ | N/A | +| PUT /debug/settings/pprof | ❌ | N/A | ✅ | N/A | +| GET /debug/settings/config | ❌ | N/A | ✅ | N/A | +| GET /v2/accounts/{address} | ✅ | N/A | ✅ | N/A | +| GET /v2/accounts/{address}/assets/{asset-id} | ✅ | N/A | ✅ | N/A | +| GET /v2/accounts/{address}/assets | ❌ | N/A | ✅ | N/A | +| GET /v2/accounts/{address}/applications/{application-id} | ✅ | N/A | ✅ | N/A | +| GET /v2/accounts/{address}/transactions/pending | ✅ | N/A | ✅ | N/A | +| GET /v2/blocks/{round} | ✅ | N/A | ✅ | N/A | +| GET /v2/blocks/{round}/txids | ❌ | N/A | ✅ | N/A | +| GET /v2/blocks/{round}/hash | ❌ | N/A | ✅ | N/A | +| GET /v2/blocks/{round}/transactions/{txid}/proof | ❌ | N/A | ✅ | N/A | +| GET /v2/blocks/{round}/logs | ❌ | N/A | ✅ | N/A | +| GET /v2/ledger/supply | ❌ | N/A | ✅ | N/A | +| GET /v2/participation | ❌ | N/A | ✅ | N/A | +| POST /v2/participation | ❌ | ✅ | ✅ | ❌ | +| POST /v2/participation/generate/{address} | ❌ | N/A | ✅ | N/A | +| GET /v2/participation/{participation-id} | ❌ | N/A | ✅ | N/A | +| POST /v2/participation/{participation-id} | ❌ | ✅ | ✅ | ❌ | +| DELETE /v2/participation/{participation-id} | ❌ | N/A | ✅ | N/A | +| POST /v2/shutdown | ❌ | N/A | ✅ | N/A | +| GET /v2/status | ❌ | N/A | ✅ | N/A | +| GET /v2/status/wait-for-block-after/{round} | ❌ | N/A | ✅ | N/A | +| POST /v2/transactions | ❌ | ❌ | ✅ | ❌ | +| POST /v2/transactions/async | ❌ | ❌ | ✅ | ❌ | +| POST /v2/transactions/simulate | ✅ | ✅ | ✅ | ✅ | +| GET /v2/transactions/params | ❌ | N/A | ✅ | N/A | +| GET /v2/transactions/pending | ✅ | N/A | ✅ | N/A | +| GET /v2/transactions/pending/{txid} | ✅ | N/A | ✅ | N/A | +| GET /v2/deltas/{round} | ✅ | N/A | ✅ | N/A | +| GET /v2/deltas/{round}/txn/group | ✅ | N/A | ✅ | N/A | +| GET /v2/deltas/txn/group/{id} | ✅ | N/A | ✅ | N/A | +| GET /v2/stateproofs/{round} | ❌ | N/A | ✅ | N/A | +| GET /v2/blocks/{round}/lightheader/proof | ❌ | N/A | ✅ | N/A | +| GET /v2/applications/{application-id} | ❌ | N/A | ✅ | N/A | +| GET /v2/applications/{application-id}/boxes | ❌ | N/A | ✅ | N/A | +| GET /v2/applications/{application-id}/box | ❌ | N/A | ✅ | N/A | +| GET /v2/assets/{asset-id} | ❌ | N/A | ✅ | N/A | +| GET /v2/ledger/sync | ❌ | N/A | ✅ | N/A | +| DELETE /v2/ledger/sync | ❌ | N/A | ✅ | N/A | +| POST /v2/ledger/sync/{round} | ❌ | N/A | ✅ | N/A | +| POST /v2/teal/compile | ❌ | N/A | ✅ | ❌ | +| POST /v2/teal/disassemble | ❌ | N/A | ✅ | ❌ | +| POST /v2/catchup/{catchpoint} | ❌ | N/A | ✅ | N/A | +| DELETE /v2/catchup/{catchpoint} | ❌ | N/A | ✅ | N/A | +| POST /v2/teal/dryrun | ❌ | ✅ | ✅ | ✅ | +| GET /v2/experimental | ❌ | N/A | ✅ | N/A | +| GET /v2/devmode/blocks/offset | ❌ | N/A | ✅ | N/A | +| POST /v2/devmode/blocks/offset/{offset} | ❌ | N/A | ✅ | N/A | + +Similar to above but focused on abstractions: + +| Abstraction | Related Endpoints | Supports msgpack Encoding | Supports msgpack Decoding | +| ----------------------------------- | ----------------------------------------------------------------------------- | ------------------------- | ------------------------- | +| Account | GET /v2/accounts/{address} | No | Yes | +| AccountAssetResponse | GET /v2/accounts/{address}/assets/{asset-id} | No | Yes | +| AccountApplicationResponse | GET /v2/accounts/{address}/applications/{application-id} | No | Yes | +| AssetHolding | GET /v2/accounts/{address}/assets/{asset-id} | No | Yes | +| ApplicationLocalState | GET /v2/accounts/{address}/applications/{application-id} | No | Yes | +| BlockResponse | GET /v2/blocks/{round} | No | Yes | +| PendingTransactions | GET /v2/transactions/pending, GET /v2/accounts/{address}/transactions/pending | No | Yes | +| PendingTransactionResponse | GET /v2/transactions/pending/{txid} | No | Yes | +| LedgerStateDelta | GET /v2/deltas/{round}, GET /v2/deltas/txn/group/{id} | No | Yes | +| LedgerStateDeltaForTransactionGroup | GET /v2/deltas/{round}/txn/group | No | Yes | +| SimulateRequest | POST /v2/transactions/simulate | Yes | No | +| SimulateResponse | POST /v2/transactions/simulate (response) | No | Yes | +| DryrunRequest | POST /v2/teal/dryrun | Yes | No | +| DryrunResponse | POST /v2/teal/dryrun (response) | No | Yes | +| ErrorResponse | Various error responses across all endpoints | No | Yes | + +This table shows that while many abstractions in the Algorand API support msgpack decoding (receiving msgpack from the API), only two abstractions - SimulateRequest and DryrunRequest - support msgpack encoding (sending msgpack to the API). diff --git a/api/specs/indexer.oas3.json b/api/specs/indexer.oas3.json index e4b0a754..da9b30cb 100644 --- a/api/specs/indexer.oas3.json +++ b/api/specs/indexer.oas3.json @@ -3587,6 +3587,7 @@ "index": { "type": "integer", "description": "unique asset identifier", + "x-algokit-field-rename": "id", "x-algokit-bigint": true }, "deleted": { @@ -4315,11 +4316,13 @@ "created-application-index": { "type": "integer", "description": "Specifies an application index (ID) if an application was created with this transaction.", + "x-algokit-field-rename": "created_app_id", "x-algokit-bigint": true }, "created-asset-index": { "type": "integer", "description": "Specifies an asset index (ID) if an asset was created with this transaction.", + "x-algokit-field-rename": "created_asset_id", "x-algokit-bigint": true }, "fee": { @@ -5873,4 +5876,4 @@ } }, "x-original-swagger-version": "2.0" -} +} \ No newline at end of file diff --git a/api/specs/kmd.oas3.json b/api/specs/kmd.oas3.json index f9c47840..029b87aa 100644 --- a/api/specs/kmd.oas3.json +++ b/api/specs/kmd.oas3.json @@ -1950,4 +1950,4 @@ } }, "x-original-swagger-version": "2.0" -} +} \ No newline at end of file diff --git a/src/algokit_algod_client/client.py b/src/algokit_algod_client/client.py index 7dc9c050..4bfc4e71 100644 --- a/src/algokit_algod_client/client.py +++ b/src/algokit_algod_client/client.py @@ -854,10 +854,10 @@ def get_block_time_stamp_offset( raise UnexpectedStatusError(response.status_code, response.text) - def get_block_txids( + def get_block_tx_ids( self, round_: int, - ) -> models.GetBlockTxidsResponseModel: + ) -> models.GetBlockTxIdsResponseModel: """ Get the top level transaction IDs for the block on the given round. """ @@ -880,7 +880,7 @@ def get_block_txids( response = self._client.request(**request_kwargs) if response.is_success: - return self._decode_response(response, model=models.GetBlockTxidsResponseModel) + return self._decode_response(response, model=models.GetBlockTxIdsResponseModel) raise UnexpectedStatusError(response.status_code, response.text) diff --git a/src/algokit_algod_client/models/__init__.py b/src/algokit_algod_client/models/__init__.py index c3e80268..aecbe313 100644 --- a/src/algokit_algod_client/models/__init__.py +++ b/src/algokit_algod_client/models/__init__.py @@ -48,7 +48,7 @@ from ._get_block_hash_response_model import GetBlockHashResponseModel from ._get_block_logs_response_model import GetBlockLogsResponseModel from ._get_block_time_stamp_offset_response_model import GetBlockTimeStampOffsetResponseModel -from ._get_block_txids_response_model import GetBlockTxidsResponseModel +from ._get_block_tx_ids_response_model import GetBlockTxIdsResponseModel from ._get_pending_transactions_by_address_response_model import GetPendingTransactionsByAddressResponseModel from ._get_pending_transactions_response_model import GetPendingTransactionsResponseModel from ._get_status_response_model import GetStatusResponseModel @@ -75,6 +75,7 @@ from ._simulation_eval_overrides import SimulationEvalOverrides from ._simulation_opcode_trace_unit import SimulationOpcodeTraceUnit from ._simulation_transaction_exec_trace import SimulationTransactionExecTrace +from ._source_map import SourceMap from ._start_catchup_response_model import StartCatchupResponseModel from ._state_delta import StateDelta from ._state_proof import StateProof @@ -153,7 +154,7 @@ "GetBlockHashResponseModel", "GetBlockLogsResponseModel", "GetBlockTimeStampOffsetResponseModel", - "GetBlockTxidsResponseModel", + "GetBlockTxIdsResponseModel", "GetPendingTransactionsByAddressResponseModel", "GetPendingTransactionsResponseModel", "GetStatusResponseModel", @@ -180,6 +181,7 @@ "SimulationEvalOverrides", "SimulationOpcodeTraceUnit", "SimulationTransactionExecTrace", + "SourceMap", "StartCatchupResponseModel", "StateDelta", "StateProof", diff --git a/src/algokit_algod_client/models/_asset.py b/src/algokit_algod_client/models/_asset.py index 4d12884c..beca8ed6 100644 --- a/src/algokit_algod_client/models/_asset.py +++ b/src/algokit_algod_client/models/_asset.py @@ -14,8 +14,8 @@ class Asset: Specifies both the unique identifier and the parameters for an asset """ - index: int = field( - metadata=wire("index"), + id_: int = field( + metadata=wire("id"), ) params: AssetParams = field( metadata=nested("params", lambda: AssetParams), diff --git a/src/algokit_algod_client/models/_dryrun_source.py b/src/algokit_algod_client/models/_dryrun_source.py index 7a2c5765..5b8651f6 100644 --- a/src/algokit_algod_client/models/_dryrun_source.py +++ b/src/algokit_algod_client/models/_dryrun_source.py @@ -13,8 +13,8 @@ class DryrunSource: transactions or application state. """ - app_index: int = field( - metadata=wire("app-index"), + app_id: int = field( + metadata=wire("app_id"), ) field_name: str = field( metadata=wire("field-name"), diff --git a/src/algokit_algod_client/models/_eval_delta_key_value.py b/src/algokit_algod_client/models/_eval_delta_key_value.py index 174a3f63..b1f6ea5b 100644 --- a/src/algokit_algod_client/models/_eval_delta_key_value.py +++ b/src/algokit_algod_client/models/_eval_delta_key_value.py @@ -6,6 +6,7 @@ from algokit_common.serde import nested, wire from ._eval_delta import EvalDelta +from ._serde_helpers import decode_bytes_base64, encode_bytes_base64 @dataclass(slots=True) @@ -14,8 +15,12 @@ class EvalDeltaKeyValue: Key-value pairs for StateDelta. """ - key: str = field( - metadata=wire("key"), + key: bytes = field( + metadata=wire( + "key", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), ) value: EvalDelta = field( metadata=nested("value", lambda: EvalDelta), diff --git a/src/algokit_algod_client/models/_genesis_file_in_json.py b/src/algokit_algod_client/models/_genesis_file_in_json.py index 954699aa..4ab364d4 100644 --- a/src/algokit_algod_client/models/_genesis_file_in_json.py +++ b/src/algokit_algod_client/models/_genesis_file_in_json.py @@ -33,9 +33,6 @@ class GenesisFileInJson: rwd: str = field( metadata=wire("rwd"), ) - timestamp: int = field( - metadata=wire("timestamp"), - ) comment: str | None = field( default=None, metadata=wire("comment"), @@ -44,3 +41,7 @@ class GenesisFileInJson: default=None, metadata=wire("devmode"), ) + timestamp: int | None = field( + default=None, + metadata=wire("timestamp"), + ) diff --git a/src/algokit_algod_client/models/_get_block_txids_response_model.py b/src/algokit_algod_client/models/_get_block_tx_ids_response_model.py similarity index 56% rename from src/algokit_algod_client/models/_get_block_txids_response_model.py rename to src/algokit_algod_client/models/_get_block_tx_ids_response_model.py index 379b4c9c..c8906579 100644 --- a/src/algokit_algod_client/models/_get_block_txids_response_model.py +++ b/src/algokit_algod_client/models/_get_block_tx_ids_response_model.py @@ -7,7 +7,7 @@ @dataclass(slots=True) -class GetBlockTxidsResponseModel: - block_txids: list[str] = field( - metadata=wire("blockTxids"), +class GetBlockTxIdsResponseModel: + block_tx_ids: list[str] = field( + metadata=wire("block_tx_ids"), ) diff --git a/src/algokit_algod_client/models/_source_map.py b/src/algokit_algod_client/models/_source_map.py new file mode 100644 index 00000000..13cb7621 --- /dev/null +++ b/src/algokit_algod_client/models/_source_map.py @@ -0,0 +1,26 @@ +# AUTO-GENERATED: oas_generator + + +from dataclasses import dataclass, field + +from algokit_common.serde import wire + + +@dataclass(slots=True) +class SourceMap: + """ + Source map for the program + """ + + mappings: str = field( + metadata=wire("mappings"), + ) + names: list[str] = field( + metadata=wire("names"), + ) + sources: list[str] = field( + metadata=wire("sources"), + ) + version: int = field( + metadata=wire("version"), + ) diff --git a/src/algokit_algod_client/models/_teal_compile_response_model.py b/src/algokit_algod_client/models/_teal_compile_response_model.py index ba73d214..a1256d31 100644 --- a/src/algokit_algod_client/models/_teal_compile_response_model.py +++ b/src/algokit_algod_client/models/_teal_compile_response_model.py @@ -3,7 +3,9 @@ from dataclasses import dataclass, field -from algokit_common.serde import wire +from algokit_common.serde import nested, wire + +from ._source_map import SourceMap @dataclass(slots=True) @@ -14,7 +16,7 @@ class TealCompileResponseModel: result: str = field( metadata=wire("result"), ) - sourcemap: dict[str, object] | None = field( + sourcemap: SourceMap | None = field( default=None, - metadata=wire("sourcemap"), + metadata=nested("sourcemap", lambda: SourceMap), ) diff --git a/src/algokit_algod_client/models/_teal_key_value.py b/src/algokit_algod_client/models/_teal_key_value.py index 5675279f..6cd7d52c 100644 --- a/src/algokit_algod_client/models/_teal_key_value.py +++ b/src/algokit_algod_client/models/_teal_key_value.py @@ -5,6 +5,7 @@ from algokit_common.serde import nested, wire +from ._serde_helpers import decode_bytes_base64, encode_bytes_base64 from ._teal_value import TealValue @@ -14,8 +15,12 @@ class TealKeyValue: Represents a key-value pair in an application store. """ - key: str = field( - metadata=wire("key"), + key: bytes = field( + metadata=wire( + "key", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), ) value: TealValue = field( metadata=nested("value", lambda: TealValue), diff --git a/src/algokit_indexer_client/models/_asset.py b/src/algokit_indexer_client/models/_asset.py index 2dad1a22..0a191f60 100644 --- a/src/algokit_indexer_client/models/_asset.py +++ b/src/algokit_indexer_client/models/_asset.py @@ -14,8 +14,8 @@ class Asset: Specifies both the unique identifier and the parameters for an asset """ - index: int = field( - metadata=wire("index"), + id_: int = field( + metadata=wire("id"), ) params: AssetParams = field( metadata=nested("params", lambda: AssetParams), diff --git a/src/algokit_indexer_client/models/_transaction.py b/src/algokit_indexer_client/models/_transaction.py index c8893645..df5b37ca 100644 --- a/src/algokit_indexer_client/models/_transaction.py +++ b/src/algokit_indexer_client/models/_transaction.py @@ -84,13 +84,13 @@ class Transaction: default=None, metadata=wire("confirmed-round"), ) - created_application_index: int | None = field( + created_app_id: int | None = field( default=None, - metadata=wire("created-application-index"), + metadata=wire("created_app_id"), ) - created_asset_index: int | None = field( + created_asset_id: int | None = field( default=None, - metadata=wire("created-asset-index"), + metadata=wire("created_asset_id"), ) genesis_hash: bytes | None = field( default=None, From a5313677cfcdb4385262eba7698f5193a889e5e3 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Mon, 10 Nov 2025 18:19:05 +0100 Subject: [PATCH 2/6] refactor: preserve map keys and fix state-proof serialization --- .../src/oas_generator/builder.py | 5 +- .../renderer/templates/client.py.j2 | 13 +-- .../templates/models/_serde_helpers.py.j2 | 52 +++++++++--- .../renderer/templates/models/block.py.j2 | 67 +++++++++++---- src/algokit_algod_client/client.py | 13 +-- .../models/_app_call_logs.py | 2 +- src/algokit_algod_client/models/_asset.py | 2 +- .../models/_dryrun_source.py | 2 +- .../_get_block_tx_ids_response_model.py | 2 +- .../models/_pending_transaction_response.py | 4 +- .../models/_serde_helpers.py | 54 ++++++++---- src/algokit_algod_client/models/block.py | 67 +++++++++++---- src/algokit_indexer_client/client.py | 13 +-- src/algokit_indexer_client/models/_asset.py | 2 +- .../models/_serde_helpers.py | 54 ++++++++---- .../models/_transaction.py | 4 +- src/algokit_kmd_client/client.py | 13 +-- .../models/_serde_helpers.py | 54 ++++++++---- src/algokit_transact/models/state_proof.py | 82 ++++++++++++------- tests/modules/transact/common.py | 25 +++--- 20 files changed, 344 insertions(+), 186 deletions(-) diff --git a/api/oas-generator/src/oas_generator/builder.py b/api/oas-generator/src/oas_generator/builder.py index 553ab89e..ca3b80f9 100644 --- a/api/oas-generator/src/oas_generator/builder.py +++ b/api/oas-generator/src/oas_generator/builder.py @@ -240,7 +240,8 @@ def _build_model(self, entry: SchemaEntry) -> ctx.ModelDescriptor: # noqa: C901 for prop_name in sorted(properties): prop_schema = properties[prop_name] or {} - wire_name = prop_schema.get("x-algokit-field-rename") or prop_name + wire_name = prop_name + python_name_hint = prop_schema.get("x-algokit-field-rename") or prop_name type_info = self.resolver.resolve(prop_schema, hint=entry.python_name + self.sanitizer.pascal(prop_name)) if type_info.is_signed_transaction: self.uses_signed_transaction = True @@ -250,7 +251,7 @@ def _build_model(self, entry: SchemaEntry) -> ctx.ModelDescriptor: # noqa: C901 annotation = f"{annotation} | None" annotation = self._apply_forward_reference_annotation(annotation, entry, type_info) field = ctx.ModelField( - name=self.sanitizer.snake(wire_name), + name=self.sanitizer.snake(python_name_hint), wire_name=wire_name, type_hint=annotation, required=prop_name in required, diff --git a/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 index ffae8d85..95594033 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 @@ -264,18 +264,7 @@ class {{ client.class_name }}: def _normalize_msgpack(self, value: object) -> object: if isinstance(value, dict): - normalized: dict[object, object] = {} - for key, item in value.items(): - normalized[self._ensure_str_key(key)] = self._normalize_msgpack(item) - return normalized + return {key: self._normalize_msgpack(item) for key, item in value.items()} if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value - - def _ensure_str_key(self, key: object) -> object: - if isinstance(key, bytes): - try: - return key.decode("utf-8") - except UnicodeDecodeError: - return key - return key diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/_serde_helpers.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/_serde_helpers.py.j2 index 36b407af..d32411d5 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/_serde_helpers.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/_serde_helpers.py.j2 @@ -10,6 +10,7 @@ from algokit_common.serde import from_wire, to_wire T = TypeVar("T") E = TypeVar("E", bound=Enum) +KT = TypeVar("KT") BytesLike: TypeAlias = bytes | bytearray | memoryview @@ -32,7 +33,10 @@ def decode_bytes_base64(raw: object) -> bytes: try: return base64.b64decode(raw.encode("ascii"), validate=True) except (BinasciiError, UnicodeEncodeError) as exc: - raise ValueError("Invalid base64 payload") from exc + try: + return raw.encode("utf-8") + except UnicodeEncodeError as fallback_exc: + raise ValueError("Invalid base64 payload") from fallback_exc raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") @@ -109,7 +113,10 @@ def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> li def encode_model_mapping( - factory: Callable[[], type[T]], mapping: Mapping[str, object] | None + factory: Callable[[], type[T]], + mapping: Mapping[object, object] | None, + *, + key_encoder: Callable[[object], str] | None = None, ) -> dict[str, object] | None: if mapping is None: return None @@ -118,35 +125,54 @@ def encode_model_mapping( for key, value in mapping.items(): if value is None: continue + encoded_key: str + if key_encoder is not None: + encoded_key = key_encoder(key) + elif isinstance(key, str): + encoded_key = key + else: + encoded_key = str(key) if isinstance(value, cls) or is_dataclass(value): - encoded[str(key)] = to_wire(value) + encoded[encoded_key] = to_wire(value) else: - encoded[str(key)] = value + encoded[encoded_key] = value return encoded or None -def decode_model_mapping(factory: Callable[[], type[T]], raw: object) -> dict[str, T] | None: +def decode_model_mapping( + factory: Callable[[], type[T]], + raw: object, + *, + key_decoder: Callable[[object], KT] | None = None, +) -> dict[KT, T] | None: if not isinstance(raw, Mapping): return None cls = factory() - decoded: dict[str, T] = {} + decoded: dict[KT, T] = {} for key, value in raw.items(): if isinstance(value, Mapping): - decoded[str(key)] = from_wire(cls, value) + decoded_key = key_decoder(key) if key_decoder is not None else key + decoded[decoded_key] = from_wire(cls, value) return decoded or None def mapping_encoder( factory: Callable[[], type[T]], -) -> Callable[[Mapping[str, object] | None], dict[str, object] | None]: - def _encode(mapping: Mapping[str, object] | None) -> dict[str, object] | None: - return encode_model_mapping(factory, mapping) + *, + key_encoder: Callable[[object], str] | None = None, +) -> Callable[[Mapping[object, object] | None], dict[str, object] | None]: + def _encode(mapping: Mapping[object, object] | None) -> dict[str, object] | None: + return encode_model_mapping(factory, mapping, key_encoder=key_encoder) return _encode -def mapping_decoder(factory: Callable[[], type[T]]) -> Callable[[object], dict[str, T] | None]: - def _decode(raw: object) -> dict[str, T] | None: - return decode_model_mapping(factory, raw) +def mapping_decoder( + factory: Callable[[], type[T]], + *, + key_decoder: Callable[[object], KT] | None = None, +) -> Callable[[object], dict[KT, T] | None]: + def _decode(raw: object) -> dict[KT, T] | None: + return decode_model_mapping(factory, raw, key_decoder=key_decoder) return _decode diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 index ee02fc9c..e3ab754b 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 @@ -2,14 +2,16 @@ from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Any +from typing import Any, cast from algokit_common.serde import flatten, nested, wire from algokit_transact.models.signed_transaction import SignedTransaction from ._serde_helpers import ( + decode_bytes_base64, decode_model_mapping, decode_model_sequence, + encode_bytes_base64, encode_model_mapping, encode_model_sequence, mapping_decoder, @@ -29,43 +31,73 @@ __all__ = [ ] -BlockStateDelta = dict[str, "BlockEvalDelta"] -BlockStateProofTracking = dict[str, "BlockStateProofTrackingData"] +BlockStateDelta = dict[bytes, "BlockEvalDelta"] +BlockStateProofTracking = dict[int, "BlockStateProofTrackingData"] def _encode_block_state_delta(value: BlockStateDelta | None) -> dict[str, object] | None: if value is None: return None - return encode_model_mapping(lambda: BlockEvalDelta, value) + return encode_model_mapping( + lambda: BlockEvalDelta, + cast(Mapping[object, object], value), + key_encoder=_encode_state_delta_key, + ) + + +def _encode_state_delta_key(key: object) -> str: + if isinstance(key, bytes): + return encode_bytes_base64(key) + if isinstance(key, memoryview): + return encode_bytes_base64(bytes(key)) + if isinstance(key, bytearray): + return encode_bytes_base64(bytes(key)) + raise TypeError("State delta keys must be bytes-like") + + +def _decode_state_proof_tracking_key(key: object) -> int: + if isinstance(key, int): + return key + if isinstance(key, str): + return int(key) + raise TypeError("State proof tracking keys must be numeric") def _decode_block_state_delta(raw: object) -> BlockStateDelta | None: - decoded = decode_model_mapping(lambda: BlockEvalDelta, raw) + decoded = decode_model_mapping(lambda: BlockEvalDelta, raw, key_decoder=decode_bytes_base64) return decoded or None -def _encode_local_deltas(mapping: Mapping[str, BlockStateDelta] | None) -> dict[str, object] | None: +def _encode_local_deltas(mapping: Mapping[int, BlockStateDelta] | None) -> dict[str, object] | None: if mapping is None: return None out: dict[str, object] = {} for key, value in mapping.items(): - encoded = encode_model_mapping(lambda: BlockEvalDelta, value) + encoded = _encode_block_state_delta(value) if encoded: - out[str(key)] = encoded + out[str(int(key))] = encoded return out or None -def _decode_local_deltas(raw: object) -> dict[str, BlockStateDelta] | None: +def _decode_local_deltas(raw: object) -> dict[int, BlockStateDelta] | None: if not isinstance(raw, Mapping): return None - out: dict[str, BlockStateDelta] = {} + out: dict[int, BlockStateDelta] = {} for key, value in raw.items(): - decoded = decode_model_mapping(lambda: BlockEvalDelta, value) + decoded = _decode_block_state_delta(value) if decoded is not None: - out[str(key)] = decoded + out[_decode_local_delta_index_key(key)] = decoded return out or None +def _decode_local_delta_index_key(key: object) -> int: + if isinstance(key, int): + return key + if isinstance(key, str): + return int(key) + raise TypeError("Local delta keys must be numeric") + + @dataclass(slots=True) class BlockEvalDelta: """Represents a TEAL value delta within block state changes.""" @@ -83,8 +115,8 @@ class BlockAccountStateDelta: delta: BlockStateDelta = field( metadata=wire( "delta", - encode=lambda value: encode_model_mapping(lambda: BlockEvalDelta, value), - decode=lambda raw: decode_model_mapping(lambda: BlockEvalDelta, raw), + encode=_encode_block_state_delta, + decode=_decode_block_state_delta, ) ) @@ -140,7 +172,7 @@ class BlockAppEvalDelta: decode=_decode_block_state_delta, ), ) - local_deltas: dict[str, BlockStateDelta] | None = field( + local_deltas: dict[int, BlockStateDelta] | None = field( default=None, metadata=wire( "ld", @@ -198,7 +230,10 @@ class Block: metadata=wire( "spt", encode=mapping_encoder(lambda: BlockStateProofTrackingData), - decode=mapping_decoder(lambda: BlockStateProofTrackingData), + decode=mapping_decoder( + lambda: BlockStateProofTrackingData, + key_decoder=_decode_state_proof_tracking_key, + ), ), ) expired_participation_accounts: list[bytes] | None = field( diff --git a/src/algokit_algod_client/client.py b/src/algokit_algod_client/client.py index 4bfc4e71..a063c72e 100644 --- a/src/algokit_algod_client/client.py +++ b/src/algokit_algod_client/client.py @@ -1953,18 +1953,7 @@ def _decode_response( def _normalize_msgpack(self, value: object) -> object: if isinstance(value, dict): - normalized: dict[object, object] = {} - for key, item in value.items(): - normalized[self._ensure_str_key(key)] = self._normalize_msgpack(item) - return normalized + return {key: self._normalize_msgpack(item) for key, item in value.items()} if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value - - def _ensure_str_key(self, key: object) -> object: - if isinstance(key, bytes): - try: - return key.decode("utf-8") - except UnicodeDecodeError: - return key - return key diff --git a/src/algokit_algod_client/models/_app_call_logs.py b/src/algokit_algod_client/models/_app_call_logs.py index 7dcc2b29..a49c8b60 100644 --- a/src/algokit_algod_client/models/_app_call_logs.py +++ b/src/algokit_algod_client/models/_app_call_logs.py @@ -16,7 +16,7 @@ class AppCallLogs: """ app_id: int = field( - metadata=wire("app_id"), + metadata=wire("application-index"), ) logs: list[bytes] = field( metadata=wire( diff --git a/src/algokit_algod_client/models/_asset.py b/src/algokit_algod_client/models/_asset.py index beca8ed6..ee7772fe 100644 --- a/src/algokit_algod_client/models/_asset.py +++ b/src/algokit_algod_client/models/_asset.py @@ -15,7 +15,7 @@ class Asset: """ id_: int = field( - metadata=wire("id"), + metadata=wire("index"), ) params: AssetParams = field( metadata=nested("params", lambda: AssetParams), diff --git a/src/algokit_algod_client/models/_dryrun_source.py b/src/algokit_algod_client/models/_dryrun_source.py index 5b8651f6..dc4246f0 100644 --- a/src/algokit_algod_client/models/_dryrun_source.py +++ b/src/algokit_algod_client/models/_dryrun_source.py @@ -14,7 +14,7 @@ class DryrunSource: """ app_id: int = field( - metadata=wire("app_id"), + metadata=wire("app-index"), ) field_name: str = field( metadata=wire("field-name"), diff --git a/src/algokit_algod_client/models/_get_block_tx_ids_response_model.py b/src/algokit_algod_client/models/_get_block_tx_ids_response_model.py index c8906579..503ea47c 100644 --- a/src/algokit_algod_client/models/_get_block_tx_ids_response_model.py +++ b/src/algokit_algod_client/models/_get_block_tx_ids_response_model.py @@ -9,5 +9,5 @@ @dataclass(slots=True) class GetBlockTxIdsResponseModel: block_tx_ids: list[str] = field( - metadata=wire("block_tx_ids"), + metadata=wire("blockTxids"), ) diff --git a/src/algokit_algod_client/models/_pending_transaction_response.py b/src/algokit_algod_client/models/_pending_transaction_response.py index 16fa134f..fc47e253 100644 --- a/src/algokit_algod_client/models/_pending_transaction_response.py +++ b/src/algokit_algod_client/models/_pending_transaction_response.py @@ -26,7 +26,7 @@ class PendingTransactionResponse: ) app_id: int | None = field( default=None, - metadata=wire("app_id"), + metadata=wire("application-index"), ) asset_closing_amount: int | None = field( default=None, @@ -34,7 +34,7 @@ class PendingTransactionResponse: ) asset_id: int | None = field( default=None, - metadata=wire("asset_id"), + metadata=wire("asset-index"), ) close_rewards: int | None = field( default=None, diff --git a/src/algokit_algod_client/models/_serde_helpers.py b/src/algokit_algod_client/models/_serde_helpers.py index bef79ee0..eaf4df1f 100644 --- a/src/algokit_algod_client/models/_serde_helpers.py +++ b/src/algokit_algod_client/models/_serde_helpers.py @@ -10,6 +10,7 @@ T = TypeVar("T") E = TypeVar("E", bound=Enum) +KT = TypeVar("KT") BytesLike: TypeAlias = bytes | bytearray | memoryview @@ -31,8 +32,11 @@ def decode_bytes_base64(raw: object) -> bytes: if isinstance(raw, str): try: return base64.b64decode(raw.encode("ascii"), validate=True) - except (BinasciiError, UnicodeEncodeError) as exc: - raise ValueError("Invalid base64 payload") from exc + except (BinasciiError, UnicodeEncodeError): + try: + return raw.encode("utf-8") + except UnicodeEncodeError as fallback_exc: + raise ValueError("Invalid base64 payload") from fallback_exc raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") @@ -109,7 +113,10 @@ def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> li def encode_model_mapping( - factory: Callable[[], type[T]], mapping: Mapping[str, object] | None + factory: Callable[[], type[T]], + mapping: Mapping[object, object] | None, + *, + key_encoder: Callable[[object], str] | None = None, ) -> dict[str, object] | None: if mapping is None: return None @@ -118,35 +125,54 @@ def encode_model_mapping( for key, value in mapping.items(): if value is None: continue + encoded_key: str + if key_encoder is not None: + encoded_key = key_encoder(key) + elif isinstance(key, str): + encoded_key = key + else: + encoded_key = str(key) if isinstance(value, cls) or is_dataclass(value): - encoded[str(key)] = to_wire(value) + encoded[encoded_key] = to_wire(value) else: - encoded[str(key)] = value + encoded[encoded_key] = value return encoded or None -def decode_model_mapping(factory: Callable[[], type[T]], raw: object) -> dict[str, T] | None: +def decode_model_mapping( + factory: Callable[[], type[T]], + raw: object, + *, + key_decoder: Callable[[object], KT] | None = None, +) -> dict[KT, T] | None: if not isinstance(raw, Mapping): return None cls = factory() - decoded: dict[str, T] = {} + decoded: dict[KT, T] = {} for key, value in raw.items(): if isinstance(value, Mapping): - decoded[str(key)] = from_wire(cls, value) + decoded_key = key_decoder(key) if key_decoder is not None else key + decoded[decoded_key] = from_wire(cls, value) return decoded or None def mapping_encoder( factory: Callable[[], type[T]], -) -> Callable[[Mapping[str, object] | None], dict[str, object] | None]: - def _encode(mapping: Mapping[str, object] | None) -> dict[str, object] | None: - return encode_model_mapping(factory, mapping) + *, + key_encoder: Callable[[object], str] | None = None, +) -> Callable[[Mapping[object, object] | None], dict[str, object] | None]: + def _encode(mapping: Mapping[object, object] | None) -> dict[str, object] | None: + return encode_model_mapping(factory, mapping, key_encoder=key_encoder) return _encode -def mapping_decoder(factory: Callable[[], type[T]]) -> Callable[[object], dict[str, T] | None]: - def _decode(raw: object) -> dict[str, T] | None: - return decode_model_mapping(factory, raw) +def mapping_decoder( + factory: Callable[[], type[T]], + *, + key_decoder: Callable[[object], KT] | None = None, +) -> Callable[[object], dict[KT, T] | None]: + def _decode(raw: object) -> dict[KT, T] | None: + return decode_model_mapping(factory, raw, key_decoder=key_decoder) return _decode diff --git a/src/algokit_algod_client/models/block.py b/src/algokit_algod_client/models/block.py index 586f1d9b..ca8ad5c4 100644 --- a/src/algokit_algod_client/models/block.py +++ b/src/algokit_algod_client/models/block.py @@ -3,14 +3,16 @@ from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Any +from typing import Any, cast from algokit_common.serde import flatten, nested, wire from algokit_transact.models.signed_transaction import SignedTransaction from ._serde_helpers import ( + decode_bytes_base64, decode_model_mapping, decode_model_sequence, + encode_bytes_base64, encode_model_mapping, encode_model_sequence, mapping_decoder, @@ -30,43 +32,73 @@ ] -BlockStateDelta = dict[str, "BlockEvalDelta"] -BlockStateProofTracking = dict[str, "BlockStateProofTrackingData"] +BlockStateDelta = dict[bytes, "BlockEvalDelta"] +BlockStateProofTracking = dict[int, "BlockStateProofTrackingData"] def _encode_block_state_delta(value: BlockStateDelta | None) -> dict[str, object] | None: if value is None: return None - return encode_model_mapping(lambda: BlockEvalDelta, value) + return encode_model_mapping( + lambda: BlockEvalDelta, + cast(Mapping[object, object], value), + key_encoder=_encode_state_delta_key, + ) + + +def _encode_state_delta_key(key: object) -> str: + if isinstance(key, bytes): + return encode_bytes_base64(key) + if isinstance(key, memoryview): + return encode_bytes_base64(bytes(key)) + if isinstance(key, bytearray): + return encode_bytes_base64(bytes(key)) + raise TypeError("State delta keys must be bytes-like") + + +def _decode_state_proof_tracking_key(key: object) -> int: + if isinstance(key, int): + return key + if isinstance(key, str): + return int(key) + raise TypeError("State proof tracking keys must be numeric") def _decode_block_state_delta(raw: object) -> BlockStateDelta | None: - decoded = decode_model_mapping(lambda: BlockEvalDelta, raw) + decoded = decode_model_mapping(lambda: BlockEvalDelta, raw, key_decoder=decode_bytes_base64) return decoded or None -def _encode_local_deltas(mapping: Mapping[str, BlockStateDelta] | None) -> dict[str, object] | None: +def _encode_local_deltas(mapping: Mapping[int, BlockStateDelta] | None) -> dict[str, object] | None: if mapping is None: return None out: dict[str, object] = {} for key, value in mapping.items(): - encoded = encode_model_mapping(lambda: BlockEvalDelta, value) + encoded = _encode_block_state_delta(value) if encoded: - out[str(key)] = encoded + out[str(int(key))] = encoded return out or None -def _decode_local_deltas(raw: object) -> dict[str, BlockStateDelta] | None: +def _decode_local_deltas(raw: object) -> dict[int, BlockStateDelta] | None: if not isinstance(raw, Mapping): return None - out: dict[str, BlockStateDelta] = {} + out: dict[int, BlockStateDelta] = {} for key, value in raw.items(): - decoded = decode_model_mapping(lambda: BlockEvalDelta, value) + decoded = _decode_block_state_delta(value) if decoded is not None: - out[str(key)] = decoded + out[_decode_local_delta_index_key(key)] = decoded return out or None +def _decode_local_delta_index_key(key: object) -> int: + if isinstance(key, int): + return key + if isinstance(key, str): + return int(key) + raise TypeError("Local delta keys must be numeric") + + @dataclass(slots=True) class BlockEvalDelta: """Represents a TEAL value delta within block state changes.""" @@ -84,8 +116,8 @@ class BlockAccountStateDelta: delta: BlockStateDelta = field( metadata=wire( "delta", - encode=lambda value: encode_model_mapping(lambda: BlockEvalDelta, value), - decode=lambda raw: decode_model_mapping(lambda: BlockEvalDelta, raw), + encode=_encode_block_state_delta, + decode=_decode_block_state_delta, ) ) @@ -141,7 +173,7 @@ class BlockAppEvalDelta: decode=_decode_block_state_delta, ), ) - local_deltas: dict[str, BlockStateDelta] | None = field( + local_deltas: dict[int, BlockStateDelta] | None = field( default=None, metadata=wire( "ld", @@ -199,7 +231,10 @@ class Block: metadata=wire( "spt", encode=mapping_encoder(lambda: BlockStateProofTrackingData), - decode=mapping_decoder(lambda: BlockStateProofTrackingData), + decode=mapping_decoder( + lambda: BlockStateProofTrackingData, + key_decoder=_decode_state_proof_tracking_key, + ), ), ) expired_participation_accounts: list[bytes] | None = field( diff --git a/src/algokit_indexer_client/client.py b/src/algokit_indexer_client/client.py index 338d3ac5..f3103c46 100644 --- a/src/algokit_indexer_client/client.py +++ b/src/algokit_indexer_client/client.py @@ -1239,18 +1239,7 @@ def _decode_response( def _normalize_msgpack(self, value: object) -> object: if isinstance(value, dict): - normalized: dict[object, object] = {} - for key, item in value.items(): - normalized[self._ensure_str_key(key)] = self._normalize_msgpack(item) - return normalized + return {key: self._normalize_msgpack(item) for key, item in value.items()} if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value - - def _ensure_str_key(self, key: object) -> object: - if isinstance(key, bytes): - try: - return key.decode("utf-8") - except UnicodeDecodeError: - return key - return key diff --git a/src/algokit_indexer_client/models/_asset.py b/src/algokit_indexer_client/models/_asset.py index 0a191f60..2c678e3c 100644 --- a/src/algokit_indexer_client/models/_asset.py +++ b/src/algokit_indexer_client/models/_asset.py @@ -15,7 +15,7 @@ class Asset: """ id_: int = field( - metadata=wire("id"), + metadata=wire("index"), ) params: AssetParams = field( metadata=nested("params", lambda: AssetParams), diff --git a/src/algokit_indexer_client/models/_serde_helpers.py b/src/algokit_indexer_client/models/_serde_helpers.py index bef79ee0..eaf4df1f 100644 --- a/src/algokit_indexer_client/models/_serde_helpers.py +++ b/src/algokit_indexer_client/models/_serde_helpers.py @@ -10,6 +10,7 @@ T = TypeVar("T") E = TypeVar("E", bound=Enum) +KT = TypeVar("KT") BytesLike: TypeAlias = bytes | bytearray | memoryview @@ -31,8 +32,11 @@ def decode_bytes_base64(raw: object) -> bytes: if isinstance(raw, str): try: return base64.b64decode(raw.encode("ascii"), validate=True) - except (BinasciiError, UnicodeEncodeError) as exc: - raise ValueError("Invalid base64 payload") from exc + except (BinasciiError, UnicodeEncodeError): + try: + return raw.encode("utf-8") + except UnicodeEncodeError as fallback_exc: + raise ValueError("Invalid base64 payload") from fallback_exc raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") @@ -109,7 +113,10 @@ def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> li def encode_model_mapping( - factory: Callable[[], type[T]], mapping: Mapping[str, object] | None + factory: Callable[[], type[T]], + mapping: Mapping[object, object] | None, + *, + key_encoder: Callable[[object], str] | None = None, ) -> dict[str, object] | None: if mapping is None: return None @@ -118,35 +125,54 @@ def encode_model_mapping( for key, value in mapping.items(): if value is None: continue + encoded_key: str + if key_encoder is not None: + encoded_key = key_encoder(key) + elif isinstance(key, str): + encoded_key = key + else: + encoded_key = str(key) if isinstance(value, cls) or is_dataclass(value): - encoded[str(key)] = to_wire(value) + encoded[encoded_key] = to_wire(value) else: - encoded[str(key)] = value + encoded[encoded_key] = value return encoded or None -def decode_model_mapping(factory: Callable[[], type[T]], raw: object) -> dict[str, T] | None: +def decode_model_mapping( + factory: Callable[[], type[T]], + raw: object, + *, + key_decoder: Callable[[object], KT] | None = None, +) -> dict[KT, T] | None: if not isinstance(raw, Mapping): return None cls = factory() - decoded: dict[str, T] = {} + decoded: dict[KT, T] = {} for key, value in raw.items(): if isinstance(value, Mapping): - decoded[str(key)] = from_wire(cls, value) + decoded_key = key_decoder(key) if key_decoder is not None else key + decoded[decoded_key] = from_wire(cls, value) return decoded or None def mapping_encoder( factory: Callable[[], type[T]], -) -> Callable[[Mapping[str, object] | None], dict[str, object] | None]: - def _encode(mapping: Mapping[str, object] | None) -> dict[str, object] | None: - return encode_model_mapping(factory, mapping) + *, + key_encoder: Callable[[object], str] | None = None, +) -> Callable[[Mapping[object, object] | None], dict[str, object] | None]: + def _encode(mapping: Mapping[object, object] | None) -> dict[str, object] | None: + return encode_model_mapping(factory, mapping, key_encoder=key_encoder) return _encode -def mapping_decoder(factory: Callable[[], type[T]]) -> Callable[[object], dict[str, T] | None]: - def _decode(raw: object) -> dict[str, T] | None: - return decode_model_mapping(factory, raw) +def mapping_decoder( + factory: Callable[[], type[T]], + *, + key_decoder: Callable[[object], KT] | None = None, +) -> Callable[[object], dict[KT, T] | None]: + def _decode(raw: object) -> dict[KT, T] | None: + return decode_model_mapping(factory, raw, key_decoder=key_decoder) return _decode diff --git a/src/algokit_indexer_client/models/_transaction.py b/src/algokit_indexer_client/models/_transaction.py index df5b37ca..fd896df8 100644 --- a/src/algokit_indexer_client/models/_transaction.py +++ b/src/algokit_indexer_client/models/_transaction.py @@ -86,11 +86,11 @@ class Transaction: ) created_app_id: int | None = field( default=None, - metadata=wire("created_app_id"), + metadata=wire("created-application-index"), ) created_asset_id: int | None = field( default=None, - metadata=wire("created_asset_id"), + metadata=wire("created-asset-index"), ) genesis_hash: bytes | None = field( default=None, diff --git a/src/algokit_kmd_client/client.py b/src/algokit_kmd_client/client.py index 7b595fb5..60eb5c60 100644 --- a/src/algokit_kmd_client/client.py +++ b/src/algokit_kmd_client/client.py @@ -1072,18 +1072,7 @@ def _decode_response( def _normalize_msgpack(self, value: object) -> object: if isinstance(value, dict): - normalized: dict[object, object] = {} - for key, item in value.items(): - normalized[self._ensure_str_key(key)] = self._normalize_msgpack(item) - return normalized + return {key: self._normalize_msgpack(item) for key, item in value.items()} if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value - - def _ensure_str_key(self, key: object) -> object: - if isinstance(key, bytes): - try: - return key.decode("utf-8") - except UnicodeDecodeError: - return key - return key diff --git a/src/algokit_kmd_client/models/_serde_helpers.py b/src/algokit_kmd_client/models/_serde_helpers.py index bef79ee0..eaf4df1f 100644 --- a/src/algokit_kmd_client/models/_serde_helpers.py +++ b/src/algokit_kmd_client/models/_serde_helpers.py @@ -10,6 +10,7 @@ T = TypeVar("T") E = TypeVar("E", bound=Enum) +KT = TypeVar("KT") BytesLike: TypeAlias = bytes | bytearray | memoryview @@ -31,8 +32,11 @@ def decode_bytes_base64(raw: object) -> bytes: if isinstance(raw, str): try: return base64.b64decode(raw.encode("ascii"), validate=True) - except (BinasciiError, UnicodeEncodeError) as exc: - raise ValueError("Invalid base64 payload") from exc + except (BinasciiError, UnicodeEncodeError): + try: + return raw.encode("utf-8") + except UnicodeEncodeError as fallback_exc: + raise ValueError("Invalid base64 payload") from fallback_exc raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") @@ -109,7 +113,10 @@ def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> li def encode_model_mapping( - factory: Callable[[], type[T]], mapping: Mapping[str, object] | None + factory: Callable[[], type[T]], + mapping: Mapping[object, object] | None, + *, + key_encoder: Callable[[object], str] | None = None, ) -> dict[str, object] | None: if mapping is None: return None @@ -118,35 +125,54 @@ def encode_model_mapping( for key, value in mapping.items(): if value is None: continue + encoded_key: str + if key_encoder is not None: + encoded_key = key_encoder(key) + elif isinstance(key, str): + encoded_key = key + else: + encoded_key = str(key) if isinstance(value, cls) or is_dataclass(value): - encoded[str(key)] = to_wire(value) + encoded[encoded_key] = to_wire(value) else: - encoded[str(key)] = value + encoded[encoded_key] = value return encoded or None -def decode_model_mapping(factory: Callable[[], type[T]], raw: object) -> dict[str, T] | None: +def decode_model_mapping( + factory: Callable[[], type[T]], + raw: object, + *, + key_decoder: Callable[[object], KT] | None = None, +) -> dict[KT, T] | None: if not isinstance(raw, Mapping): return None cls = factory() - decoded: dict[str, T] = {} + decoded: dict[KT, T] = {} for key, value in raw.items(): if isinstance(value, Mapping): - decoded[str(key)] = from_wire(cls, value) + decoded_key = key_decoder(key) if key_decoder is not None else key + decoded[decoded_key] = from_wire(cls, value) return decoded or None def mapping_encoder( factory: Callable[[], type[T]], -) -> Callable[[Mapping[str, object] | None], dict[str, object] | None]: - def _encode(mapping: Mapping[str, object] | None) -> dict[str, object] | None: - return encode_model_mapping(factory, mapping) + *, + key_encoder: Callable[[object], str] | None = None, +) -> Callable[[Mapping[object, object] | None], dict[str, object] | None]: + def _encode(mapping: Mapping[object, object] | None) -> dict[str, object] | None: + return encode_model_mapping(factory, mapping, key_encoder=key_encoder) return _encode -def mapping_decoder(factory: Callable[[], type[T]]) -> Callable[[object], dict[str, T] | None]: - def _decode(raw: object) -> dict[str, T] | None: - return decode_model_mapping(factory, raw) +def mapping_decoder( + factory: Callable[[], type[T]], + *, + key_decoder: Callable[[object], KT] | None = None, +) -> Callable[[object], dict[KT, T] | None]: + def _decode(raw: object) -> dict[KT, T] | None: + return decode_model_mapping(factory, raw, key_decoder=key_decoder) return _decode diff --git a/src/algokit_transact/models/state_proof.py b/src/algokit_transact/models/state_proof.py index 318e3159..ac896bf9 100644 --- a/src/algokit_transact/models/state_proof.py +++ b/src/algokit_transact/models/state_proof.py @@ -1,4 +1,4 @@ -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from dataclasses import dataclass, field from typing import cast @@ -62,11 +62,11 @@ class StateProof: sig_proofs: MerkleArrayProof | None = field(default=None, metadata=nested("S", MerkleArrayProof)) part_proofs: MerkleArrayProof | None = field(default=None, metadata=nested("P", MerkleArrayProof)) merkle_signature_salt_version: int | None = field(default=None, metadata=wire("v")) - reveals: tuple[Reveal, ...] | None = field( + reveals: dict[int, Reveal] | None = field( default=None, metadata=wire( "r", - encode=lambda obj: _encode_reveals(cast(tuple[Reveal, ...] | None, obj)), + encode=lambda obj: _encode_reveals(cast(dict[int, Reveal] | None, obj)), decode=lambda obj: _decode_reveals(obj), ), ) @@ -90,38 +90,64 @@ class StateProofTransactionFields: message: StateProofMessage | None = field(default=None, metadata=nested("spmsg", StateProofMessage)) -def _encode_reveals(seq: tuple[Reveal, ...] | None) -> dict[int, dict[str, object]] | None: - match seq: - case None | (): - return None - case _: - return { - pos if isinstance(pos := to_wire(item).get("pos"), int) else idx: { - k: v for k in ("p", "s") if (v := to_wire(item).get(k)) is not None - } - for idx, item in enumerate(seq) - } +def _encode_reveals(mapping: Mapping[int, Reveal] | Iterable[Reveal] | None) -> dict[int, dict[str, object]] | None: + if mapping is None: + return None + entries: Iterable[tuple[int, Reveal]] + if isinstance(mapping, Mapping): + entries = ( + (_coerce_reveal_position(key, idx), reveal) + for idx, (key, reveal) in enumerate(mapping.items()) + ) + else: + entries = ( + (_coerce_reveal_position(getattr(reveal, "position", None), idx), reveal) + for idx, reveal in enumerate(mapping) + ) + encoded: dict[int, dict[str, object]] = {} + for position, reveal in entries: + data = to_wire(reveal) + payload = {key: value for key in ("p", "s") if (value := data.get(key)) is not None} + if payload: + encoded[int(position)] = payload + return encoded or None -def _decode_reveals(obj: object | None) -> tuple[Reveal, ...] | None: +def _decode_reveals(obj: object | None) -> dict[int, Reveal] | None: if obj is None: return None if isinstance(obj, Mapping): - # Also support legacy map form: { pos: {p:..., s:...} } - return tuple( - from_wire( - Reveal, {**(v if isinstance(v, Mapping) else {}), "pos": int(k) if isinstance(k, int | str) else 0} - ) - for k, v in obj.items() - ) + decoded: dict[int, Reveal] = {} + for key, value in obj.items(): + if not isinstance(value, Mapping): + continue + position = _coerce_reveal_position(key, len(decoded)) + payload = dict(value) + payload.setdefault("pos", position) + decoded[position] = from_wire(Reveal, payload) + return decoded or None if isinstance(obj, list): - return tuple( - from_wire( - Reveal, {**(v if isinstance(v, Mapping) else {}), "pos": v.get("pos") if isinstance(v, Mapping) else i} - ) - for i, v in enumerate(obj) - ) + decoded_list: dict[int, Reveal] = {} + for idx, value in enumerate(obj): + if not isinstance(value, Mapping): + continue + position = _coerce_reveal_position(value.get("pos"), idx) + payload = dict(value) + payload["pos"] = position + decoded_list[position] = from_wire(Reveal, payload) + return decoded_list or None return None + + +def _coerce_reveal_position(raw: object, fallback: int) -> int: + if isinstance(raw, int): + return raw + if isinstance(raw, str): + try: + return int(raw) + except ValueError: + return fallback + return fallback diff --git a/tests/modules/transact/common.py b/tests/modules/transact/common.py index 4ddbc553..cf86980b 100644 --- a/tests/modules/transact/common.py +++ b/tests/modules/transact/common.py @@ -305,23 +305,24 @@ def _parse_sigslot(payload: Mapping[str, Any] | None) -> SigslotCommit | None: ) -def _parse_reveals(payload: object) -> tuple[Reveal, ...] | None: +def _parse_reveals(payload: object) -> dict[int, Reveal] | None: if not isinstance(payload, list): return None - reveals: list[Reveal] = [] - for item in payload: + reveals: dict[int, Reveal] = {} + for idx, item in enumerate(payload): if not isinstance(item, Mapping): continue - reveals.append( - Reveal( - participant=_parse_participant( - item.get("participant") if isinstance(item.get("participant"), Mapping) else None - ), - sigslot=_parse_sigslot(item.get("sigslot") if isinstance(item.get("sigslot"), Mapping) else None), - position=_maybe_int(item.get("position", 0)), - ) + position = _maybe_int(item.get("position")) + if position is None: + position = idx + reveals[position] = Reveal( + participant=_parse_participant( + item.get("participant") if isinstance(item.get("participant"), Mapping) else None + ), + sigslot=_parse_sigslot(item.get("sigslot") if isinstance(item.get("sigslot"), Mapping) else None), + position=position, ) - return tuple(reveals) + return reveals or None def _parse_state_proof(payload: Mapping[str, Any] | None) -> StateProof | None: From a1fa8c773f6df6b47c6738a867ab033b8fc5459d Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 11 Nov 2025 12:35:19 +0100 Subject: [PATCH 3/6] refactor: refines msgpack decoding in api generator and boolean fields in block model Improves msgpack decoding in Algod, Indexer and KMD clients by handling byte keys and values. This prevents decoding errors when encountering non-UTF-8 byte sequences. Additionally, adds decoding for boolean fields in block models to correctly interpret raw values as booleans. This addresses issues with inconsistent data representation. --- .../src/oas_generator/builder.py | 1 + api/oas-generator/src/oas_generator/models.py | 1 + .../src/oas_generator/renderer/engine.py | 38 +- .../renderer/templates/client.py.j2 | 15 +- .../renderer/templates/models/__init__.py.j2 | 10 +- .../templates/models/_serde_helpers.py.j2 | 55 ++- .../renderer/templates/models/block.py.j2 | 29 +- .../templates/models/ledger_state_delta.py.j2 | 461 ++++++++++++++++++ api/specs/compatibility.md | 79 --- src/algokit_algod_client/client.py | 15 +- src/algokit_algod_client/models/__init__.py | 70 ++- .../models/{block.py => _block.py} | 29 +- .../models/_ledger_state_delta.py | 458 ++++++++++++++++- .../models/_serde_helpers.py | 57 ++- src/algokit_common/serde/_core.py | 39 +- src/algokit_indexer_client/client.py | 15 +- .../models/_serde_helpers.py | 57 ++- src/algokit_kmd_client/client.py | 15 +- .../models/_serde_helpers.py | 57 ++- src/algokit_transact/models/state_proof.py | 3 +- tests/modules/algod_client/test_block.py | 18 +- .../algod_client/test_ledger_state_delta.py | 45 ++ 22 files changed, 1347 insertions(+), 220 deletions(-) create mode 100644 api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 delete mode 100644 api/specs/compatibility.md rename src/algokit_algod_client/models/{block.py => _block.py} (92%) create mode 100644 tests/modules/algod_client/test_ledger_state_delta.py diff --git a/api/oas-generator/src/oas_generator/builder.py b/api/oas-generator/src/oas_generator/builder.py index ca3b80f9..7cf4b4e8 100644 --- a/api/oas-generator/src/oas_generator/builder.py +++ b/api/oas-generator/src/oas_generator/builder.py @@ -694,4 +694,5 @@ def build_client_descriptor( uses_signed_transaction=uses_signed_txn, uses_msgpack=operation_builder.uses_msgpack, include_block_models=operation_builder.uses_block_models, + include_ledger_state_delta_models="LedgerStateDelta" in registry.entries, ) diff --git a/api/oas-generator/src/oas_generator/models.py b/api/oas-generator/src/oas_generator/models.py index 69a43f93..a4e05ff4 100644 --- a/api/oas-generator/src/oas_generator/models.py +++ b/api/oas-generator/src/oas_generator/models.py @@ -146,3 +146,4 @@ class ClientDescriptor: uses_signed_transaction: bool = False uses_msgpack: bool = False include_block_models: bool = False + include_ledger_state_delta_models: bool = False diff --git a/api/oas-generator/src/oas_generator/renderer/engine.py b/api/oas-generator/src/oas_generator/renderer/engine.py index d1fc8621..64ef1978 100644 --- a/api/oas-generator/src/oas_generator/renderer/engine.py +++ b/api/oas-generator/src/oas_generator/renderer/engine.py @@ -26,6 +26,31 @@ class TemplateRenderer: "Block", "GetBlock", ] + LEDGER_STATE_DELTA_EXPORTS: ClassVar[list[str]] = [ + "LedgerTealValue", + "LedgerStateSchema", + "LedgerAppParams", + "LedgerAppLocalState", + "LedgerAppLocalStateDelta", + "LedgerAppParamsDelta", + "LedgerAppResourceRecord", + "LedgerAssetHolding", + "LedgerAssetHoldingDelta", + "LedgerAssetParams", + "LedgerAssetParamsDelta", + "LedgerAssetResourceRecord", + "LedgerVotingData", + "LedgerAccountBaseData", + "LedgerAccountData", + "LedgerBalanceRecord", + "LedgerAccountDeltas", + "LedgerKvValueDelta", + "LedgerIncludedTransactions", + "LedgerModifiedCreatable", + "LedgerAlgoCount", + "LedgerAccountTotals", + "LedgerStateDelta", + ] def __init__(self, template_dir: Path | None = None) -> None: if template_dir: @@ -56,6 +81,8 @@ def render(self, client: ctx.ClientDescriptor, config: GeneratorConfig) -> dict[ files[models_dir / "__init__.py"] = self._render_template("models/__init__.py.j2", context) files[models_dir / "_serde_helpers.py"] = self._render_template("models/_serde_helpers.py.j2", context) for model in context["client"].models: + if context["client"].include_ledger_state_delta_models and model.name == "LedgerStateDelta": + continue model_context = {**context, "model": model} files[models_dir / f"{model.module_name}.py"] = self._render_template("models/model.py.j2", model_context) for enum in context["client"].enums: @@ -67,7 +94,11 @@ def render(self, client: ctx.ClientDescriptor, config: GeneratorConfig) -> dict[ "models/type_alias.py.j2", alias_context ) if client.include_block_models: - files[models_dir / "block.py"] = self._render_template("models/block.py.j2", context) + files[models_dir / "_block.py"] = self._render_template("models/block.py.j2", context) + if client.include_ledger_state_delta_models: + files[models_dir / "_ledger_state_delta.py"] = self._render_template( + "models/ledger_state_delta.py.j2", context + ) files[target / "py.typed"] = "" return files @@ -85,6 +116,10 @@ def _build_context(self, client: ctx.ClientDescriptor, config: GeneratorConfig) for name in self.BLOCK_MODEL_EXPORTS: if name not in model_exports: model_exports.append(name) + if client.include_ledger_state_delta_models: + for name in self.LEDGER_STATE_DELTA_EXPORTS: + if name not in model_exports: + model_exports.append(name) metadata_usage = self._collect_metadata_usage(client) model_modules = [{"module": model.module_name, "name": model.name} for model in client.models] enum_modules = [{"module": enum.module_name, "name": enum.name} for enum in client.enums] @@ -105,6 +140,7 @@ def _build_context(self, client: ctx.ClientDescriptor, config: GeneratorConfig) "needs_datetime": any(model.requires_datetime for model in client.models), "client_needs_datetime": self._client_requires_datetime(client), "block_exports": self.BLOCK_MODEL_EXPORTS, + "ledger_state_delta_exports": self.LEDGER_STATE_DELTA_EXPORTS, "needs_literal": needs_literal, } diff --git a/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 index 95594033..ffb15ef4 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 @@ -248,7 +248,7 @@ class {{ client.class_name }}: return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: - data = msgpack.unpackb(response.content, raw=False, strict_map_key=False) + data = msgpack.unpackb(response.content, raw=True, strict_map_key=False) data = self._normalize_msgpack(data) elif content_type.startswith("application/json"): data = response.json() @@ -264,7 +264,18 @@ class {{ client.class_name }}: def _normalize_msgpack(self, value: object) -> object: if isinstance(value, dict): - return {key: self._normalize_msgpack(item) for key, item in value.items()} + normalized: dict[object, object] = {} + for key, item in value.items(): + normalized[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) + return normalized if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value + + def _coerce_msgpack_key(self, key: object) -> object: + if isinstance(key, bytes): + try: + return key.decode("utf-8") + except UnicodeDecodeError: + return key + return key diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/__init__.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/__init__.py.j2 index 7d1a7289..a79e75b7 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/__init__.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/__init__.py.j2 @@ -1,16 +1,18 @@ {% if client.uses_signed_transaction %}from algokit_transact.models.signed_transaction import SignedTransaction -{% endif %}{% for item in model_modules %}from .{{ item.module }} import {{ item.name }} -{% endfor %}{% for item in enum_modules %}from .{{ item.module }} import {{ item.name }} +{% endif %}{% for item in model_modules %}{% if not (client.include_ledger_state_delta_models and item.name == "LedgerStateDelta") %}from .{{ item.module }} import {{ item.name }} +{% endif %}{% endfor %}{% for item in enum_modules %}from .{{ item.module }} import {{ item.name }} {% endfor %}{% for item in alias_modules %}from .{{ item.module }} import {{ item.name }} -{% endfor %}{% if client.include_block_models %}from .block import ( +{% endfor %}{% if client.include_block_models %}from ._block import ( {{ block_exports | join(',\n ') }} ) +{% endif %}{% if client.include_ledger_state_delta_models %}from ._ledger_state_delta import ( + {{ ledger_state_delta_exports | join(',\n ') }} +) {% endif %} __all__ = [ {% for name in model_exports %}"{{ name }}", {% endfor %} ] - diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/_serde_helpers.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/_serde_helpers.py.j2 index d32411d5..7d862133 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/_serde_helpers.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/_serde_helpers.py.j2 @@ -8,9 +8,9 @@ from typing import Callable, TypeAlias, TypeVar from algokit_common.serde import from_wire, to_wire -T = TypeVar("T") -E = TypeVar("E", bound=Enum) -KT = TypeVar("KT") +DecodedT = TypeVar("DecodedT") +EnumValueT = TypeVar("EnumValueT", bound=Enum) +MapKeyT = TypeVar("MapKeyT") BytesLike: TypeAlias = bytes | bytearray | memoryview @@ -33,11 +33,22 @@ def decode_bytes_base64(raw: object) -> bytes: try: return base64.b64decode(raw.encode("ascii"), validate=True) except (BinasciiError, UnicodeEncodeError) as exc: + raise ValueError("Invalid base64 payload") from exc + raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") + + +def decode_bytes_map_key(raw: object) -> bytes: + if isinstance(raw, bytes | bytearray | memoryview): + return bytes(raw) + if isinstance(raw, str): + try: + return decode_bytes_base64(raw) + except ValueError: try: return raw.encode("utf-8") except UnicodeEncodeError as fallback_exc: - raise ValueError("Invalid base64 payload") from fallback_exc - raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") + raise ValueError("Invalid bytes map key") from fallback_exc + raise TypeError(f"Unsupported map key for bytes field: {type(raw)!r}") def encode_bytes_sequence(values: Iterable[BytesLike | None] | None) -> list[str | None] | None: @@ -77,11 +88,11 @@ def encode_model_sequence(values: Iterable[object] | None) -> list[dict[str, obj return encoded or None -def decode_model_sequence(cls_factory: Callable[[], type[T]], raw: object) -> list[T] | None: +def decode_model_sequence(cls_factory: Callable[[], type[DecodedT]], raw: object) -> list[DecodedT] | None: if not isinstance(raw, list): return None cls = cls_factory() - decoded: list[T] = [] + decoded: list[DecodedT] = [] for item in raw: if isinstance(item, Mapping): decoded.append(from_wire(cls, item)) @@ -99,11 +110,11 @@ def encode_enum_sequence(values: Iterable[object] | None) -> list[object] | None return encoded or None -def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> list[E] | None: +def decode_enum_sequence(enum_factory: Callable[[], type[EnumValueT]], raw: object) -> list[EnumValueT] | None: if not isinstance(raw, list): return None enum_cls = enum_factory() - decoded: list[E] = [] + decoded: list[EnumValueT] = [] for item in raw: try: decoded.append(enum_cls(item)) @@ -113,7 +124,7 @@ def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> li def encode_model_mapping( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], mapping: Mapping[object, object] | None, *, key_encoder: Callable[[object], str] | None = None, @@ -140,15 +151,15 @@ def encode_model_mapping( def decode_model_mapping( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], raw: object, *, - key_decoder: Callable[[object], KT] | None = None, -) -> dict[KT, T] | None: + key_decoder: Callable[[object], MapKeyT] | None = None, +) -> dict[MapKeyT, DecodedT] | None: if not isinstance(raw, Mapping): return None cls = factory() - decoded: dict[KT, T] = {} + decoded: dict[MapKeyT, DecodedT] = {} for key, value in raw.items(): if isinstance(value, Mapping): decoded_key = key_decoder(key) if key_decoder is not None else key @@ -156,8 +167,14 @@ def decode_model_mapping( return decoded or None +def decode_optional_bool(raw: object) -> bool | None: + if raw is None: + return None + return bool(raw) + + def mapping_encoder( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], *, key_encoder: Callable[[object], str] | None = None, ) -> Callable[[Mapping[object, object] | None], dict[str, object] | None]: @@ -168,11 +185,11 @@ def mapping_encoder( def mapping_decoder( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], *, - key_decoder: Callable[[object], KT] | None = None, -) -> Callable[[object], dict[KT, T] | None]: - def _decode(raw: object) -> dict[KT, T] | None: + key_decoder: Callable[[object], MapKeyT] | None = None, +) -> Callable[[object], dict[MapKeyT, DecodedT] | None]: + def _decode(raw: object) -> dict[MapKeyT, DecodedT] | None: return decode_model_mapping(factory, raw, key_decoder=key_decoder) return _decode diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 index e3ab754b..0209bdd2 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 @@ -4,13 +4,14 @@ from collections.abc import Mapping from dataclasses import dataclass, field from typing import Any, cast -from algokit_common.serde import flatten, nested, wire +from algokit_common.serde import flatten, nested, wire, addr from algokit_transact.models.signed_transaction import SignedTransaction from ._serde_helpers import ( - decode_bytes_base64, + decode_bytes_map_key, decode_model_mapping, decode_model_sequence, + decode_optional_bool, encode_bytes_base64, encode_model_mapping, encode_model_sequence, @@ -64,10 +65,20 @@ def _decode_state_proof_tracking_key(key: object) -> int: def _decode_block_state_delta(raw: object) -> BlockStateDelta | None: - decoded = decode_model_mapping(lambda: BlockEvalDelta, raw, key_decoder=decode_bytes_base64) + decoded = decode_model_mapping(lambda: BlockEvalDelta, raw, key_decoder=decode_bytes_map_key) return decoded or None +def _encode_local_delta_index_key(key: object) -> str: + if isinstance(key, bool): + return str(int(key)) + if isinstance(key, int): + return str(key) + if isinstance(key, str): + return str(int(key)) + raise TypeError("Local delta keys must be numeric") + + def _encode_local_deltas(mapping: Mapping[int, BlockStateDelta] | None) -> dict[str, object] | None: if mapping is None: return None @@ -75,7 +86,7 @@ def _encode_local_deltas(mapping: Mapping[int, BlockStateDelta] | None) -> dict[ for key, value in mapping.items(): encoded = _encode_block_state_delta(value) if encoded: - out[str(int(key))] = encoded + out[_encode_local_delta_index_key(key)] = encoded return out or None @@ -156,8 +167,8 @@ class SignedTxnInBlock: ) config_asset: int | None = field(default=None, metadata=wire("caid")) application_id: int | None = field(default=None, metadata=wire("apid")) - has_genesis_id: bool | None = field(default=None, metadata=wire("hgi")) - has_genesis_hash: bool | None = field(default=None, metadata=wire("hgh")) + has_genesis_id: bool | None = field(default=None, metadata=wire("hgi", decode=decode_optional_bool)) + has_genesis_hash: bool | None = field(default=None, metadata=wire("hgh", decode=decode_optional_bool)) @dataclass(slots=True) @@ -206,12 +217,12 @@ class Block: timestamp: int | None = field(default=None, metadata=wire("ts")) genesis_id: str | None = field(default=None, metadata=wire("gen")) genesis_hash: bytes | None = field(default=None, metadata=wire("gh")) - proposer: bytes | None = field(default=None, metadata=wire("prp")) + proposer: bytes | None = field(default=None, metadata=addr("prp")) fees_collected: int | None = field(default=None, metadata=wire("fc")) bonus: int | None = field(default=None, metadata=wire("bi")) proposer_payout: int | None = field(default=None, metadata=wire("pp")) - fee_sink: bytes | None = field(default=None, metadata=wire("fees")) - rewards_pool: bytes | None = field(default=None, metadata=wire("rwd")) + fee_sink: bytes | None = field(default=None, metadata=addr("fees")) + rewards_pool: bytes | None = field(default=None, metadata=addr("rwd")) rewards_level: int | None = field(default=None, metadata=wire("earn")) rewards_rate: int | None = field(default=None, metadata=wire("rate")) rewards_residue: int | None = field(default=None, metadata=wire("frac")) diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 new file mode 100644 index 00000000..e2ed4584 --- /dev/null +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 @@ -0,0 +1,461 @@ +# AUTO-GENERATED: oas_generator + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Callable, TypeVar, cast + +from algokit_common.serde import DecodeError, addr, flatten, from_wire, nested, to_wire, wire + +from ._serde_helpers import ( + decode_bytes_base64, + decode_model_mapping, + decode_model_sequence, + encode_bytes_base64, + encode_model_mapping, + encode_model_sequence, +) +from ._block import Block + +DecodedT = TypeVar("DecodedT") + +__all__ = [ + "LedgerTealValue", + "LedgerStateSchema", + "LedgerAppParams", + "LedgerAppLocalState", + "LedgerAppLocalStateDelta", + "LedgerAppParamsDelta", + "LedgerAppResourceRecord", + "LedgerAssetHolding", + "LedgerAssetHoldingDelta", + "LedgerAssetParams", + "LedgerAssetParamsDelta", + "LedgerAssetResourceRecord", + "LedgerVotingData", + "LedgerAccountBaseData", + "LedgerAccountData", + "LedgerBalanceRecord", + "LedgerAccountDeltas", + "LedgerKvValueDelta", + "LedgerIncludedTransactions", + "LedgerModifiedCreatable", + "LedgerAlgoCount", + "LedgerAccountTotals", + "LedgerStateDelta", +] + + +def _encode_bytes_key(key: object) -> str: + if isinstance(key, bytes | bytearray | memoryview): + return encode_bytes_base64(key) + if isinstance(key, str): + return key + raise TypeError("Ledger delta map keys must be bytes-like or str") + + +def _decode_bytes_key(key: object) -> bytes: + if isinstance(key, bytes): + return key + if isinstance(key, str): + try: + return decode_bytes_base64(key) + except (TypeError, ValueError): + pass + return key.encode("utf-8") + raise TypeError("Ledger delta map keys must be bytes-like or str") + + +def _encode_teal_value_map(mapping: Mapping[bytes, "LedgerTealValue"] | None) -> dict[str, object] | None: + if not mapping: + return None + return encode_model_mapping( + lambda: LedgerTealValue, + cast(Mapping[object, object], mapping), + key_encoder=_encode_bytes_key, + ) + + +def _decode_teal_value_map(raw: object) -> dict[bytes, "LedgerTealValue"] | None: + decoded = decode_model_mapping(lambda: LedgerTealValue, raw, key_decoder=_decode_bytes_key) + return decoded or None + + +def _encode_kv_delta_map(mapping: Mapping[bytes, "LedgerKvValueDelta"] | None) -> dict[str, object] | None: + if not mapping: + return None + return encode_model_mapping( + lambda: LedgerKvValueDelta, + cast(Mapping[object, object], mapping), + key_encoder=_encode_bytes_key, + ) + + +def _decode_kv_delta_map(raw: object) -> dict[bytes, "LedgerKvValueDelta"] | None: + decoded = decode_model_mapping(lambda: LedgerKvValueDelta, raw, key_decoder=_decode_bytes_key) + return decoded or None + + +def _encode_txid_map(mapping: Mapping[bytes, "LedgerIncludedTransactions"] | None) -> dict[str, object] | None: + if not mapping: + return None + return encode_model_mapping( + lambda: LedgerIncludedTransactions, + cast(Mapping[object, object], mapping), + key_encoder=_encode_bytes_key, + ) + + +def _decode_txid_map(raw: object) -> dict[bytes, "LedgerIncludedTransactions"] | None: + decoded = decode_model_mapping(lambda: LedgerIncludedTransactions, raw, key_decoder=_decode_bytes_key) + return decoded or None + + +def _encode_creatables(mapping: Mapping[int, "LedgerModifiedCreatable | None"] | None) -> dict[int, object] | None: + if not mapping: + return None + encoded: dict[int, object] = {} + for key, value in mapping.items(): + if value is None: + continue + encoded[int(key)] = to_wire(value) + return encoded or None + + +def _decode_creatables(raw: object) -> dict[int, "LedgerModifiedCreatable"] | None: + if not isinstance(raw, Mapping): + return None + decoded: dict[int, LedgerModifiedCreatable] = {} + for key, value in raw.items(): + if not isinstance(value, Mapping): + continue + try: + decoded[int(key)] = from_wire(LedgerModifiedCreatable, value) + except (DecodeError, TypeError, ValueError): + continue + return decoded or None + + +def _encode_optional_sequence(values: list[object] | None) -> list[dict[str, object]] | None: + if values is None: + return None + encoded = encode_model_sequence(values) + return encoded or None + + +def _decode_sequence(factory: Callable[[], type[DecodedT]], raw: object) -> list[DecodedT]: + decoded = decode_model_sequence(factory, raw) + return decoded or [] + + +@dataclass(slots=True) +class LedgerTealValue: + type: int = field(metadata=wire("tt")) + bytes_: bytes | None = field( + default=None, + metadata=wire( + "tb", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + uint: int | None = field(default=None, metadata=wire("ui")) + + +@dataclass(slots=True) +class LedgerStateSchema: + num_uints: int | None = field(default=None, metadata=wire("nui")) + num_byte_slices: int | None = field(default=None, metadata=wire("nbs")) + + +@dataclass(slots=True) +class LedgerAppParams: + approval_program: bytes = field( + metadata=wire( + "approv", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + clear_state_program: bytes = field( + metadata=wire( + "clearp", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + local_state_schema: LedgerStateSchema = field(metadata=nested("lsch", lambda: LedgerStateSchema)) + global_state_schema: LedgerStateSchema = field(metadata=nested("gsch", lambda: LedgerStateSchema)) + extra_program_pages: int = field(metadata=wire("epp")) + global_state: dict[bytes, LedgerTealValue] | None = field( + default=None, + metadata=wire( + "gs", + encode=_encode_teal_value_map, + decode=_decode_teal_value_map, + ), + ) + + +@dataclass(slots=True) +class LedgerAppLocalState: + schema: LedgerStateSchema = field(metadata=nested("hsch", lambda: LedgerStateSchema)) + key_value: dict[bytes, LedgerTealValue] | None = field( + default=None, + metadata=wire( + "tkv", + encode=_encode_teal_value_map, + decode=_decode_teal_value_map, + ), + ) + + +@dataclass(slots=True) +class LedgerAppLocalStateDelta: + deleted: bool = field(metadata=wire("Deleted")) + local_state: LedgerAppLocalState | None = field( + default=None, + metadata=nested("LocalState", lambda: LedgerAppLocalState), + ) + + +@dataclass(slots=True) +class LedgerAppParamsDelta: + deleted: bool = field(metadata=wire("Deleted")) + params: LedgerAppParams | None = field( + default=None, + metadata=nested("Params", lambda: LedgerAppParams), + ) + + +@dataclass(slots=True) +class LedgerAppResourceRecord: + app_id: int = field(metadata=wire("Aidx")) + address: str = field(metadata=addr("Addr")) + params: LedgerAppParamsDelta = field(metadata=nested("Params", lambda: LedgerAppParamsDelta)) + state: LedgerAppLocalStateDelta = field( + metadata=nested("State", lambda: LedgerAppLocalStateDelta), + ) + + +@dataclass(slots=True) +class LedgerAssetHolding: + amount: int = field(metadata=wire("a")) + frozen: bool = field(metadata=wire("f")) + + +@dataclass(slots=True) +class LedgerAssetHoldingDelta: + deleted: bool = field(metadata=wire("Deleted")) + holding: LedgerAssetHolding | None = field( + default=None, + metadata=nested("Holding", lambda: LedgerAssetHolding), + ) + + +@dataclass(slots=True) +class LedgerAssetParams: + total: int = field(metadata=wire("t")) + decimals: int = field(metadata=wire("dc")) + default_frozen: bool = field(metadata=wire("df")) + unit_name: str | None = field(default=None, metadata=wire("un")) + asset_name: str | None = field(default=None, metadata=wire("an")) + url: str | None = field(default=None, metadata=wire("au")) + metadata_hash: bytes | None = field( + default=None, + metadata=wire( + "am", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + manager: str | None = field(default=None, metadata=addr("m")) + reserve: str | None = field(default=None, metadata=addr("r")) + freeze: str | None = field(default=None, metadata=addr("f")) + clawback: str | None = field(default=None, metadata=addr("c")) + + +@dataclass(slots=True) +class LedgerAssetParamsDelta: + deleted: bool = field(metadata=wire("Deleted")) + params: LedgerAssetParams | None = field( + default=None, + metadata=nested("Params", lambda: LedgerAssetParams), + ) + + +@dataclass(slots=True) +class LedgerAssetResourceRecord: + asset_id: int = field(metadata=wire("Aidx")) + address: str = field(metadata=addr("Addr")) + params: LedgerAssetParamsDelta = field(metadata=nested("Params", lambda: LedgerAssetParamsDelta)) + holding: LedgerAssetHoldingDelta = field(metadata=nested("Holding", lambda: LedgerAssetHoldingDelta)) + + +@dataclass(slots=True) +class LedgerVotingData: + vote_id: bytes = field( + metadata=wire( + "VoteID", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + selection_id: bytes = field( + metadata=wire( + "SelectionID", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + state_proof_id: bytes = field( + metadata=wire( + "StateProofID", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + vote_first_valid: int = field(metadata=wire("VoteFirstValid")) + vote_last_valid: int = field(metadata=wire("VoteLastValid")) + vote_key_dilution: int = field(metadata=wire("VoteKeyDilution")) + + +@dataclass(slots=True) +class LedgerAccountBaseData: + status: int = field(metadata=wire("Status")) + micro_algos: int = field(metadata=wire("MicroAlgos")) + rewards_base: int = field(metadata=wire("RewardsBase")) + rewarded_micro_algos: int = field(metadata=wire("RewardedMicroAlgos")) + auth_address: str = field(metadata=addr("AuthAddr")) + incentive_eligible: bool = field(metadata=wire("IncentiveEligible")) + total_app_schema: LedgerStateSchema = field(metadata=nested("TotalAppSchema", lambda: LedgerStateSchema)) + total_extra_app_pages: int = field(metadata=wire("TotalExtraAppPages")) + total_app_params: int = field(metadata=wire("TotalLedgerAppParams")) + total_app_local_states: int = field(metadata=wire("TotalLedgerAppLocalStates")) + total_asset_params: int = field(metadata=wire("TotalLedgerAssetParams")) + total_assets: int = field(metadata=wire("TotalAssets")) + total_boxes: int = field(metadata=wire("TotalBoxes")) + total_box_bytes: int = field(metadata=wire("TotalBoxBytes")) + last_proposed: int = field(metadata=wire("LastProposed")) + last_heartbeat: int = field(metadata=wire("LastHeartbeat")) + + +@dataclass(slots=True) +class LedgerAccountData: + account_base_data: LedgerAccountBaseData = field(metadata=flatten(lambda: LedgerAccountBaseData)) + voting_data: LedgerVotingData = field(metadata=flatten(lambda: LedgerVotingData)) + + +@dataclass(slots=True) +class LedgerBalanceRecord: + address: str = field(metadata=addr("Addr")) + account_data: LedgerAccountData = field(metadata=flatten(lambda: LedgerAccountData)) + + +@dataclass(slots=True) +class LedgerAccountDeltas: + accounts: list[LedgerBalanceRecord] = field( + default_factory=list, + metadata=wire( + "Accts", + encode=encode_model_sequence, + decode=lambda raw: _decode_sequence(lambda: LedgerBalanceRecord, raw), + ), + ) + app_resources: list[LedgerAppResourceRecord] = field( + default_factory=list, + metadata=wire( + "AppResources", + encode=_encode_optional_sequence, + decode=lambda raw: _decode_sequence(lambda: LedgerAppResourceRecord, raw), + ), + ) + asset_resources: list[LedgerAssetResourceRecord] = field( + default_factory=list, + metadata=wire( + "AssetResources", + encode=_encode_optional_sequence, + decode=lambda raw: _decode_sequence(lambda: LedgerAssetResourceRecord, raw), + ), + ) + + +@dataclass(slots=True) +class LedgerKvValueDelta: + data: bytes | None = field( + default=None, + metadata=wire( + "Data", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + old_data: bytes | None = field( + default=None, + metadata=wire( + "OldData", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + + +@dataclass(slots=True) +class LedgerIncludedTransactions: + last_valid: int = field(metadata=wire("LastValid")) + intra: int = field(metadata=wire("Intra")) + + +@dataclass(slots=True) +class LedgerModifiedCreatable: + creatable_type: int = field(metadata=wire("Ctype")) + created: bool = field(metadata=wire("Created")) + creator: str = field(metadata=addr("Creator")) + ndeltas: int = field(metadata=wire("Ndeltas")) + + +@dataclass(slots=True) +class LedgerAlgoCount: + money: int = field(metadata=wire("mon")) + reward_units: int = field(metadata=wire("rwd")) + + +@dataclass(slots=True) +class LedgerAccountTotals: + online: LedgerAlgoCount = field(metadata=nested("online", lambda: LedgerAlgoCount)) + offline: LedgerAlgoCount = field(metadata=nested("offline", lambda: LedgerAlgoCount)) + not_participating: LedgerAlgoCount = field(metadata=nested("notpart", lambda: LedgerAlgoCount)) + rewards_level: int = field(metadata=wire("rwdlvl")) + + +@dataclass(slots=True) +class LedgerStateDelta: + accounts: LedgerAccountDeltas = field(metadata=nested("Accts", lambda: LedgerAccountDeltas)) + block: Block = field(metadata=nested("Hdr", lambda: Block)) + state_proof_next: int = field(metadata=wire("StateProofNext")) + prev_timestamp: int = field(metadata=wire("PrevTimestamp")) + totals: LedgerAccountTotals = field(metadata=nested("Totals", lambda: LedgerAccountTotals)) + kv_mods: dict[bytes, LedgerKvValueDelta] | None = field( + default=None, + metadata=wire( + "KvMods", + encode=_encode_kv_delta_map, + decode=_decode_kv_delta_map, + ), + ) + txids: dict[bytes, LedgerIncludedTransactions] | None = field( + default=None, + metadata=wire( + "Txids", + encode=_encode_txid_map, + decode=_decode_txid_map, + ), + ) + txleases: object | None = field(default=None, metadata=wire("Txleases")) + creatables: dict[int, LedgerModifiedCreatable] | None = field( + default=None, + metadata=wire( + "Creatables", + encode=_encode_creatables, + decode=_decode_creatables, + ), + ) diff --git a/api/specs/compatibility.md b/api/specs/compatibility.md deleted file mode 100644 index 618ad633..00000000 --- a/api/specs/compatibility.md +++ /dev/null @@ -1,79 +0,0 @@ -| Endpoint | Response-msgpack | Input-msgpack | Response-json | Input-json | -| -------------------------------------------------------- | ---------------- | ------------- | ------------- | ---------- | -| GET /health | ❌ | N/A | ✅ | N/A | -| GET /ready | ❌ | N/A | ✅ | N/A | -| GET /metrics | ❌ | N/A | ✅ | N/A | -| GET /genesis | ❌ | N/A | ✅ | N/A | -| GET /swagger.json | ❌ | N/A | ✅ | N/A | -| GET /versions | ❌ | N/A | ✅ | N/A | -| GET /debug/settings/pprof | ❌ | N/A | ✅ | N/A | -| PUT /debug/settings/pprof | ❌ | N/A | ✅ | N/A | -| GET /debug/settings/config | ❌ | N/A | ✅ | N/A | -| GET /v2/accounts/{address} | ✅ | N/A | ✅ | N/A | -| GET /v2/accounts/{address}/assets/{asset-id} | ✅ | N/A | ✅ | N/A | -| GET /v2/accounts/{address}/assets | ❌ | N/A | ✅ | N/A | -| GET /v2/accounts/{address}/applications/{application-id} | ✅ | N/A | ✅ | N/A | -| GET /v2/accounts/{address}/transactions/pending | ✅ | N/A | ✅ | N/A | -| GET /v2/blocks/{round} | ✅ | N/A | ✅ | N/A | -| GET /v2/blocks/{round}/txids | ❌ | N/A | ✅ | N/A | -| GET /v2/blocks/{round}/hash | ❌ | N/A | ✅ | N/A | -| GET /v2/blocks/{round}/transactions/{txid}/proof | ❌ | N/A | ✅ | N/A | -| GET /v2/blocks/{round}/logs | ❌ | N/A | ✅ | N/A | -| GET /v2/ledger/supply | ❌ | N/A | ✅ | N/A | -| GET /v2/participation | ❌ | N/A | ✅ | N/A | -| POST /v2/participation | ❌ | ✅ | ✅ | ❌ | -| POST /v2/participation/generate/{address} | ❌ | N/A | ✅ | N/A | -| GET /v2/participation/{participation-id} | ❌ | N/A | ✅ | N/A | -| POST /v2/participation/{participation-id} | ❌ | ✅ | ✅ | ❌ | -| DELETE /v2/participation/{participation-id} | ❌ | N/A | ✅ | N/A | -| POST /v2/shutdown | ❌ | N/A | ✅ | N/A | -| GET /v2/status | ❌ | N/A | ✅ | N/A | -| GET /v2/status/wait-for-block-after/{round} | ❌ | N/A | ✅ | N/A | -| POST /v2/transactions | ❌ | ❌ | ✅ | ❌ | -| POST /v2/transactions/async | ❌ | ❌ | ✅ | ❌ | -| POST /v2/transactions/simulate | ✅ | ✅ | ✅ | ✅ | -| GET /v2/transactions/params | ❌ | N/A | ✅ | N/A | -| GET /v2/transactions/pending | ✅ | N/A | ✅ | N/A | -| GET /v2/transactions/pending/{txid} | ✅ | N/A | ✅ | N/A | -| GET /v2/deltas/{round} | ✅ | N/A | ✅ | N/A | -| GET /v2/deltas/{round}/txn/group | ✅ | N/A | ✅ | N/A | -| GET /v2/deltas/txn/group/{id} | ✅ | N/A | ✅ | N/A | -| GET /v2/stateproofs/{round} | ❌ | N/A | ✅ | N/A | -| GET /v2/blocks/{round}/lightheader/proof | ❌ | N/A | ✅ | N/A | -| GET /v2/applications/{application-id} | ❌ | N/A | ✅ | N/A | -| GET /v2/applications/{application-id}/boxes | ❌ | N/A | ✅ | N/A | -| GET /v2/applications/{application-id}/box | ❌ | N/A | ✅ | N/A | -| GET /v2/assets/{asset-id} | ❌ | N/A | ✅ | N/A | -| GET /v2/ledger/sync | ❌ | N/A | ✅ | N/A | -| DELETE /v2/ledger/sync | ❌ | N/A | ✅ | N/A | -| POST /v2/ledger/sync/{round} | ❌ | N/A | ✅ | N/A | -| POST /v2/teal/compile | ❌ | N/A | ✅ | ❌ | -| POST /v2/teal/disassemble | ❌ | N/A | ✅ | ❌ | -| POST /v2/catchup/{catchpoint} | ❌ | N/A | ✅ | N/A | -| DELETE /v2/catchup/{catchpoint} | ❌ | N/A | ✅ | N/A | -| POST /v2/teal/dryrun | ❌ | ✅ | ✅ | ✅ | -| GET /v2/experimental | ❌ | N/A | ✅ | N/A | -| GET /v2/devmode/blocks/offset | ❌ | N/A | ✅ | N/A | -| POST /v2/devmode/blocks/offset/{offset} | ❌ | N/A | ✅ | N/A | - -Similar to above but focused on abstractions: - -| Abstraction | Related Endpoints | Supports msgpack Encoding | Supports msgpack Decoding | -| ----------------------------------- | ----------------------------------------------------------------------------- | ------------------------- | ------------------------- | -| Account | GET /v2/accounts/{address} | No | Yes | -| AccountAssetResponse | GET /v2/accounts/{address}/assets/{asset-id} | No | Yes | -| AccountApplicationResponse | GET /v2/accounts/{address}/applications/{application-id} | No | Yes | -| AssetHolding | GET /v2/accounts/{address}/assets/{asset-id} | No | Yes | -| ApplicationLocalState | GET /v2/accounts/{address}/applications/{application-id} | No | Yes | -| BlockResponse | GET /v2/blocks/{round} | No | Yes | -| PendingTransactions | GET /v2/transactions/pending, GET /v2/accounts/{address}/transactions/pending | No | Yes | -| PendingTransactionResponse | GET /v2/transactions/pending/{txid} | No | Yes | -| LedgerStateDelta | GET /v2/deltas/{round}, GET /v2/deltas/txn/group/{id} | No | Yes | -| LedgerStateDeltaForTransactionGroup | GET /v2/deltas/{round}/txn/group | No | Yes | -| SimulateRequest | POST /v2/transactions/simulate | Yes | No | -| SimulateResponse | POST /v2/transactions/simulate (response) | No | Yes | -| DryrunRequest | POST /v2/teal/dryrun | Yes | No | -| DryrunResponse | POST /v2/teal/dryrun (response) | No | Yes | -| ErrorResponse | Various error responses across all endpoints | No | Yes | - -This table shows that while many abstractions in the Algorand API support msgpack decoding (receiving msgpack from the API), only two abstractions - SimulateRequest and DryrunRequest - support msgpack encoding (sending msgpack to the API). diff --git a/src/algokit_algod_client/client.py b/src/algokit_algod_client/client.py index a063c72e..ce029649 100644 --- a/src/algokit_algod_client/client.py +++ b/src/algokit_algod_client/client.py @@ -1937,7 +1937,7 @@ def _decode_response( return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: - data = msgpack.unpackb(response.content, raw=False, strict_map_key=False) + data = msgpack.unpackb(response.content, raw=True, strict_map_key=False) data = self._normalize_msgpack(data) elif content_type.startswith("application/json"): data = response.json() @@ -1953,7 +1953,18 @@ def _decode_response( def _normalize_msgpack(self, value: object) -> object: if isinstance(value, dict): - return {key: self._normalize_msgpack(item) for key, item in value.items()} + normalized: dict[object, object] = {} + for key, item in value.items(): + normalized[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) + return normalized if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value + + def _coerce_msgpack_key(self, key: object) -> object: + if isinstance(key, bytes): + try: + return key.decode("utf-8") + except UnicodeDecodeError: + return key + return key diff --git a/src/algokit_algod_client/models/__init__.py b/src/algokit_algod_client/models/__init__.py index aecbe313..9be8dd09 100644 --- a/src/algokit_algod_client/models/__init__.py +++ b/src/algokit_algod_client/models/__init__.py @@ -30,6 +30,17 @@ from ._asset_params import AssetParams from ._avm_key_value import AvmKeyValue from ._avm_value import AvmValue +from ._block import ( + Block, + BlockAccountStateDelta, + BlockAppEvalDelta, + BlockEvalDelta, + BlockStateDelta, + BlockStateProofTracking, + BlockStateProofTrackingData, + GetBlock, + SignedTxnInBlock, +) from ._box import Box from ._box_descriptor import BoxDescriptor from ._box_reference import BoxReference @@ -57,7 +68,31 @@ from ._get_transaction_group_ledger_state_deltas_for_round_response_model import ( GetTransactionGroupLedgerStateDeltasForRoundResponseModel, ) -from ._ledger_state_delta import LedgerStateDelta +from ._ledger_state_delta import ( + LedgerAccountBaseData, + LedgerAccountData, + LedgerAccountDeltas, + LedgerAccountTotals, + LedgerAlgoCount, + LedgerAppLocalState, + LedgerAppLocalStateDelta, + LedgerAppParams, + LedgerAppParamsDelta, + LedgerAppResourceRecord, + LedgerAssetHolding, + LedgerAssetHoldingDelta, + LedgerAssetParams, + LedgerAssetParamsDelta, + LedgerAssetResourceRecord, + LedgerBalanceRecord, + LedgerIncludedTransactions, + LedgerKvValueDelta, + LedgerModifiedCreatable, + LedgerStateDelta, + LedgerStateSchema, + LedgerTealValue, + LedgerVotingData, +) from ._ledger_state_delta_for_transaction_group import LedgerStateDeltaForTransactionGroup from ._light_block_header_proof import LightBlockHeaderProof from ._participation_key import ParticipationKey @@ -90,17 +125,6 @@ from ._transaction_proof import TransactionProof from ._version_contains_the_current_algod_version import VersionContainsTheCurrentAlgodVersion from ._wait_for_block_response_model import WaitForBlockResponseModel -from .block import ( - Block, - BlockAccountStateDelta, - BlockAppEvalDelta, - BlockEvalDelta, - BlockStateDelta, - BlockStateProofTracking, - BlockStateProofTrackingData, - GetBlock, - SignedTxnInBlock, -) __all__ = [ "AbortCatchupResponseModel", @@ -161,8 +185,30 @@ "GetSupplyResponseModel", "GetSyncRoundResponseModel", "GetTransactionGroupLedgerStateDeltasForRoundResponseModel", + "LedgerAccountBaseData", + "LedgerAccountData", + "LedgerAccountDeltas", + "LedgerAccountTotals", + "LedgerAlgoCount", + "LedgerAppLocalState", + "LedgerAppLocalStateDelta", + "LedgerAppParams", + "LedgerAppParamsDelta", + "LedgerAppResourceRecord", + "LedgerAssetHolding", + "LedgerAssetHoldingDelta", + "LedgerAssetParams", + "LedgerAssetParamsDelta", + "LedgerAssetResourceRecord", + "LedgerBalanceRecord", + "LedgerIncludedTransactions", + "LedgerKvValueDelta", + "LedgerModifiedCreatable", "LedgerStateDelta", "LedgerStateDeltaForTransactionGroup", + "LedgerStateSchema", + "LedgerTealValue", + "LedgerVotingData", "LightBlockHeaderProof", "ParticipationKey", "PendingTransactionResponse", diff --git a/src/algokit_algod_client/models/block.py b/src/algokit_algod_client/models/_block.py similarity index 92% rename from src/algokit_algod_client/models/block.py rename to src/algokit_algod_client/models/_block.py index ca8ad5c4..532903fd 100644 --- a/src/algokit_algod_client/models/block.py +++ b/src/algokit_algod_client/models/_block.py @@ -5,13 +5,14 @@ from dataclasses import dataclass, field from typing import Any, cast -from algokit_common.serde import flatten, nested, wire +from algokit_common.serde import addr, flatten, nested, wire from algokit_transact.models.signed_transaction import SignedTransaction from ._serde_helpers import ( - decode_bytes_base64, + decode_bytes_map_key, decode_model_mapping, decode_model_sequence, + decode_optional_bool, encode_bytes_base64, encode_model_mapping, encode_model_sequence, @@ -65,10 +66,20 @@ def _decode_state_proof_tracking_key(key: object) -> int: def _decode_block_state_delta(raw: object) -> BlockStateDelta | None: - decoded = decode_model_mapping(lambda: BlockEvalDelta, raw, key_decoder=decode_bytes_base64) + decoded = decode_model_mapping(lambda: BlockEvalDelta, raw, key_decoder=decode_bytes_map_key) return decoded or None +def _encode_local_delta_index_key(key: object) -> str: + if isinstance(key, bool): + return str(int(key)) + if isinstance(key, int): + return str(key) + if isinstance(key, str): + return str(int(key)) + raise TypeError("Local delta keys must be numeric") + + def _encode_local_deltas(mapping: Mapping[int, BlockStateDelta] | None) -> dict[str, object] | None: if mapping is None: return None @@ -76,7 +87,7 @@ def _encode_local_deltas(mapping: Mapping[int, BlockStateDelta] | None) -> dict[ for key, value in mapping.items(): encoded = _encode_block_state_delta(value) if encoded: - out[str(int(key))] = encoded + out[_encode_local_delta_index_key(key)] = encoded return out or None @@ -157,8 +168,8 @@ class SignedTxnInBlock: ) config_asset: int | None = field(default=None, metadata=wire("caid")) application_id: int | None = field(default=None, metadata=wire("apid")) - has_genesis_id: bool | None = field(default=None, metadata=wire("hgi")) - has_genesis_hash: bool | None = field(default=None, metadata=wire("hgh")) + has_genesis_id: bool | None = field(default=None, metadata=wire("hgi", decode=decode_optional_bool)) + has_genesis_hash: bool | None = field(default=None, metadata=wire("hgh", decode=decode_optional_bool)) @dataclass(slots=True) @@ -207,12 +218,12 @@ class Block: timestamp: int | None = field(default=None, metadata=wire("ts")) genesis_id: str | None = field(default=None, metadata=wire("gen")) genesis_hash: bytes | None = field(default=None, metadata=wire("gh")) - proposer: bytes | None = field(default=None, metadata=wire("prp")) + proposer: bytes | None = field(default=None, metadata=addr("prp")) fees_collected: int | None = field(default=None, metadata=wire("fc")) bonus: int | None = field(default=None, metadata=wire("bi")) proposer_payout: int | None = field(default=None, metadata=wire("pp")) - fee_sink: bytes | None = field(default=None, metadata=wire("fees")) - rewards_pool: bytes | None = field(default=None, metadata=wire("rwd")) + fee_sink: bytes | None = field(default=None, metadata=addr("fees")) + rewards_pool: bytes | None = field(default=None, metadata=addr("rwd")) rewards_level: int | None = field(default=None, metadata=wire("earn")) rewards_rate: int | None = field(default=None, metadata=wire("rate")) rewards_residue: int | None = field(default=None, metadata=wire("frac")) diff --git a/src/algokit_algod_client/models/_ledger_state_delta.py b/src/algokit_algod_client/models/_ledger_state_delta.py index 0c49b65f..d06918e7 100644 --- a/src/algokit_algod_client/models/_ledger_state_delta.py +++ b/src/algokit_algod_client/models/_ledger_state_delta.py @@ -1,11 +1,461 @@ # AUTO-GENERATED: oas_generator +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import TypeVar, cast -from dataclasses import dataclass +from algokit_common.serde import DecodeError, addr, flatten, from_wire, nested, to_wire, wire + +from ._block import Block +from ._serde_helpers import ( + decode_bytes_base64, + decode_model_mapping, + decode_model_sequence, + encode_bytes_base64, + encode_model_mapping, + encode_model_sequence, +) + +DecodedT = TypeVar("DecodedT") + +__all__ = [ + "LedgerAccountBaseData", + "LedgerAccountData", + "LedgerAccountDeltas", + "LedgerAccountTotals", + "LedgerAlgoCount", + "LedgerAppLocalState", + "LedgerAppLocalStateDelta", + "LedgerAppParams", + "LedgerAppParamsDelta", + "LedgerAppResourceRecord", + "LedgerAssetHolding", + "LedgerAssetHoldingDelta", + "LedgerAssetParams", + "LedgerAssetParamsDelta", + "LedgerAssetResourceRecord", + "LedgerBalanceRecord", + "LedgerIncludedTransactions", + "LedgerKvValueDelta", + "LedgerModifiedCreatable", + "LedgerStateDelta", + "LedgerStateSchema", + "LedgerTealValue", + "LedgerVotingData", +] + + +def _encode_bytes_key(key: object) -> str: + if isinstance(key, bytes | bytearray | memoryview): + return encode_bytes_base64(key) + if isinstance(key, str): + return key + raise TypeError("Ledger delta map keys must be bytes-like or str") + + +def _decode_bytes_key(key: object) -> bytes: + if isinstance(key, bytes): + return key + if isinstance(key, str): + try: + return decode_bytes_base64(key) + except (TypeError, ValueError): + pass + return key.encode("utf-8") + raise TypeError("Ledger delta map keys must be bytes-like or str") + + +def _encode_teal_value_map(mapping: Mapping[bytes, "LedgerTealValue"] | None) -> dict[str, object] | None: + if not mapping: + return None + return encode_model_mapping( + lambda: LedgerTealValue, + cast(Mapping[object, object], mapping), + key_encoder=_encode_bytes_key, + ) + + +def _decode_teal_value_map(raw: object) -> dict[bytes, "LedgerTealValue"] | None: + decoded = decode_model_mapping(lambda: LedgerTealValue, raw, key_decoder=_decode_bytes_key) + return decoded or None + + +def _encode_kv_delta_map(mapping: Mapping[bytes, "LedgerKvValueDelta"] | None) -> dict[str, object] | None: + if not mapping: + return None + return encode_model_mapping( + lambda: LedgerKvValueDelta, + cast(Mapping[object, object], mapping), + key_encoder=_encode_bytes_key, + ) + + +def _decode_kv_delta_map(raw: object) -> dict[bytes, "LedgerKvValueDelta"] | None: + decoded = decode_model_mapping(lambda: LedgerKvValueDelta, raw, key_decoder=_decode_bytes_key) + return decoded or None + + +def _encode_txid_map(mapping: Mapping[bytes, "LedgerIncludedTransactions"] | None) -> dict[str, object] | None: + if not mapping: + return None + return encode_model_mapping( + lambda: LedgerIncludedTransactions, + cast(Mapping[object, object], mapping), + key_encoder=_encode_bytes_key, + ) + + +def _decode_txid_map(raw: object) -> dict[bytes, "LedgerIncludedTransactions"] | None: + decoded = decode_model_mapping(lambda: LedgerIncludedTransactions, raw, key_decoder=_decode_bytes_key) + return decoded or None + + +def _encode_creatables(mapping: Mapping[int, "LedgerModifiedCreatable | None"] | None) -> dict[int, object] | None: + if not mapping: + return None + encoded: dict[int, object] = {} + for key, value in mapping.items(): + if value is None: + continue + encoded[int(key)] = to_wire(value) + return encoded or None + + +def _decode_creatables(raw: object) -> dict[int, "LedgerModifiedCreatable"] | None: + if not isinstance(raw, Mapping): + return None + decoded: dict[int, LedgerModifiedCreatable] = {} + for key, value in raw.items(): + if not isinstance(value, Mapping): + continue + try: + decoded[int(key)] = from_wire(LedgerModifiedCreatable, value) + except (DecodeError, TypeError, ValueError): + continue + return decoded or None + + +def _encode_optional_sequence(values: list[object] | None) -> list[dict[str, object]] | None: + if values is None: + return None + encoded = encode_model_sequence(values) + return encoded or None + + +def _decode_sequence(factory: Callable[[], type[DecodedT]], raw: object) -> list[DecodedT]: + decoded = decode_model_sequence(factory, raw) + return decoded or [] + + +@dataclass(slots=True) +class LedgerTealValue: + type: int = field(metadata=wire("tt")) + bytes_: bytes | None = field( + default=None, + metadata=wire( + "tb", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + uint: int | None = field(default=None, metadata=wire("ui")) + + +@dataclass(slots=True) +class LedgerStateSchema: + num_uints: int | None = field(default=None, metadata=wire("nui")) + num_byte_slices: int | None = field(default=None, metadata=wire("nbs")) + + +@dataclass(slots=True) +class LedgerAppParams: + approval_program: bytes = field( + metadata=wire( + "approv", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + clear_state_program: bytes = field( + metadata=wire( + "clearp", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + local_state_schema: LedgerStateSchema = field(metadata=nested("lsch", lambda: LedgerStateSchema)) + global_state_schema: LedgerStateSchema = field(metadata=nested("gsch", lambda: LedgerStateSchema)) + extra_program_pages: int = field(metadata=wire("epp")) + global_state: dict[bytes, LedgerTealValue] | None = field( + default=None, + metadata=wire( + "gs", + encode=_encode_teal_value_map, + decode=_decode_teal_value_map, + ), + ) + + +@dataclass(slots=True) +class LedgerAppLocalState: + schema: LedgerStateSchema = field(metadata=nested("hsch", lambda: LedgerStateSchema)) + key_value: dict[bytes, LedgerTealValue] | None = field( + default=None, + metadata=wire( + "tkv", + encode=_encode_teal_value_map, + decode=_decode_teal_value_map, + ), + ) + + +@dataclass(slots=True) +class LedgerAppLocalStateDelta: + deleted: bool = field(metadata=wire("Deleted")) + local_state: LedgerAppLocalState | None = field( + default=None, + metadata=nested("LocalState", lambda: LedgerAppLocalState), + ) + + +@dataclass(slots=True) +class LedgerAppParamsDelta: + deleted: bool = field(metadata=wire("Deleted")) + params: LedgerAppParams | None = field( + default=None, + metadata=nested("Params", lambda: LedgerAppParams), + ) + + +@dataclass(slots=True) +class LedgerAppResourceRecord: + app_id: int = field(metadata=wire("Aidx")) + address: str = field(metadata=addr("Addr")) + params: LedgerAppParamsDelta = field(metadata=nested("Params", lambda: LedgerAppParamsDelta)) + state: LedgerAppLocalStateDelta = field( + metadata=nested("State", lambda: LedgerAppLocalStateDelta), + ) + + +@dataclass(slots=True) +class LedgerAssetHolding: + amount: int = field(metadata=wire("a")) + frozen: bool = field(metadata=wire("f")) + + +@dataclass(slots=True) +class LedgerAssetHoldingDelta: + deleted: bool = field(metadata=wire("Deleted")) + holding: LedgerAssetHolding | None = field( + default=None, + metadata=nested("Holding", lambda: LedgerAssetHolding), + ) + + +@dataclass(slots=True) +class LedgerAssetParams: + total: int = field(metadata=wire("t")) + decimals: int = field(metadata=wire("dc")) + default_frozen: bool = field(metadata=wire("df")) + unit_name: str | None = field(default=None, metadata=wire("un")) + asset_name: str | None = field(default=None, metadata=wire("an")) + url: str | None = field(default=None, metadata=wire("au")) + metadata_hash: bytes | None = field( + default=None, + metadata=wire( + "am", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + manager: str | None = field(default=None, metadata=addr("m")) + reserve: str | None = field(default=None, metadata=addr("r")) + freeze: str | None = field(default=None, metadata=addr("f")) + clawback: str | None = field(default=None, metadata=addr("c")) + + +@dataclass(slots=True) +class LedgerAssetParamsDelta: + deleted: bool = field(metadata=wire("Deleted")) + params: LedgerAssetParams | None = field( + default=None, + metadata=nested("Params", lambda: LedgerAssetParams), + ) + + +@dataclass(slots=True) +class LedgerAssetResourceRecord: + asset_id: int = field(metadata=wire("Aidx")) + address: str = field(metadata=addr("Addr")) + params: LedgerAssetParamsDelta = field(metadata=nested("Params", lambda: LedgerAssetParamsDelta)) + holding: LedgerAssetHoldingDelta = field(metadata=nested("Holding", lambda: LedgerAssetHoldingDelta)) + + +@dataclass(slots=True) +class LedgerVotingData: + vote_id: bytes = field( + metadata=wire( + "VoteID", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + selection_id: bytes = field( + metadata=wire( + "SelectionID", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + state_proof_id: bytes = field( + metadata=wire( + "StateProofID", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + vote_first_valid: int = field(metadata=wire("VoteFirstValid")) + vote_last_valid: int = field(metadata=wire("VoteLastValid")) + vote_key_dilution: int = field(metadata=wire("VoteKeyDilution")) + + +@dataclass(slots=True) +class LedgerAccountBaseData: + status: int = field(metadata=wire("Status")) + micro_algos: int = field(metadata=wire("MicroAlgos")) + rewards_base: int = field(metadata=wire("RewardsBase")) + rewarded_micro_algos: int = field(metadata=wire("RewardedMicroAlgos")) + auth_address: str = field(metadata=addr("AuthAddr")) + incentive_eligible: bool = field(metadata=wire("IncentiveEligible")) + total_app_schema: LedgerStateSchema = field(metadata=nested("TotalAppSchema", lambda: LedgerStateSchema)) + total_extra_app_pages: int = field(metadata=wire("TotalExtraAppPages")) + total_app_params: int = field(metadata=wire("TotalLedgerAppParams")) + total_app_local_states: int = field(metadata=wire("TotalLedgerAppLocalStates")) + total_asset_params: int = field(metadata=wire("TotalLedgerAssetParams")) + total_assets: int = field(metadata=wire("TotalAssets")) + total_boxes: int = field(metadata=wire("TotalBoxes")) + total_box_bytes: int = field(metadata=wire("TotalBoxBytes")) + last_proposed: int = field(metadata=wire("LastProposed")) + last_heartbeat: int = field(metadata=wire("LastHeartbeat")) + + +@dataclass(slots=True) +class LedgerAccountData: + account_base_data: LedgerAccountBaseData = field(metadata=flatten(lambda: LedgerAccountBaseData)) + voting_data: LedgerVotingData = field(metadata=flatten(lambda: LedgerVotingData)) + + +@dataclass(slots=True) +class LedgerBalanceRecord: + address: str = field(metadata=addr("Addr")) + account_data: LedgerAccountData = field(metadata=flatten(lambda: LedgerAccountData)) + + +@dataclass(slots=True) +class LedgerAccountDeltas: + accounts: list[LedgerBalanceRecord] = field( + default_factory=list, + metadata=wire( + "Accts", + encode=encode_model_sequence, + decode=lambda raw: _decode_sequence(lambda: LedgerBalanceRecord, raw), + ), + ) + app_resources: list[LedgerAppResourceRecord] = field( + default_factory=list, + metadata=wire( + "AppResources", + encode=_encode_optional_sequence, + decode=lambda raw: _decode_sequence(lambda: LedgerAppResourceRecord, raw), + ), + ) + asset_resources: list[LedgerAssetResourceRecord] = field( + default_factory=list, + metadata=wire( + "AssetResources", + encode=_encode_optional_sequence, + decode=lambda raw: _decode_sequence(lambda: LedgerAssetResourceRecord, raw), + ), + ) + + +@dataclass(slots=True) +class LedgerKvValueDelta: + data: bytes | None = field( + default=None, + metadata=wire( + "Data", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + old_data: bytes | None = field( + default=None, + metadata=wire( + "OldData", + encode=encode_bytes_base64, + decode=decode_bytes_base64, + ), + ) + + +@dataclass(slots=True) +class LedgerIncludedTransactions: + last_valid: int = field(metadata=wire("LastValid")) + intra: int = field(metadata=wire("Intra")) + + +@dataclass(slots=True) +class LedgerModifiedCreatable: + creatable_type: int = field(metadata=wire("Ctype")) + created: bool = field(metadata=wire("Created")) + creator: str = field(metadata=addr("Creator")) + ndeltas: int = field(metadata=wire("Ndeltas")) + + +@dataclass(slots=True) +class LedgerAlgoCount: + money: int = field(metadata=wire("mon")) + reward_units: int = field(metadata=wire("rwd")) + + +@dataclass(slots=True) +class LedgerAccountTotals: + online: LedgerAlgoCount = field(metadata=nested("online", lambda: LedgerAlgoCount)) + offline: LedgerAlgoCount = field(metadata=nested("offline", lambda: LedgerAlgoCount)) + not_participating: LedgerAlgoCount = field(metadata=nested("notpart", lambda: LedgerAlgoCount)) + rewards_level: int = field(metadata=wire("rwdlvl")) @dataclass(slots=True) class LedgerStateDelta: - """ - Ledger StateDelta object - """ + accounts: LedgerAccountDeltas = field(metadata=nested("Accts", lambda: LedgerAccountDeltas)) + block: Block = field(metadata=nested("Hdr", lambda: Block)) + state_proof_next: int = field(metadata=wire("StateProofNext")) + prev_timestamp: int = field(metadata=wire("PrevTimestamp")) + totals: LedgerAccountTotals = field(metadata=nested("Totals", lambda: LedgerAccountTotals)) + kv_mods: dict[bytes, LedgerKvValueDelta] | None = field( + default=None, + metadata=wire( + "KvMods", + encode=_encode_kv_delta_map, + decode=_decode_kv_delta_map, + ), + ) + txids: dict[bytes, LedgerIncludedTransactions] | None = field( + default=None, + metadata=wire( + "Txids", + encode=_encode_txid_map, + decode=_decode_txid_map, + ), + ) + txleases: object | None = field(default=None, metadata=wire("Txleases")) + creatables: dict[int, LedgerModifiedCreatable] | None = field( + default=None, + metadata=wire( + "Creatables", + encode=_encode_creatables, + decode=_decode_creatables, + ), + ) diff --git a/src/algokit_algod_client/models/_serde_helpers.py b/src/algokit_algod_client/models/_serde_helpers.py index eaf4df1f..2d829300 100644 --- a/src/algokit_algod_client/models/_serde_helpers.py +++ b/src/algokit_algod_client/models/_serde_helpers.py @@ -8,9 +8,9 @@ from algokit_common.serde import from_wire, to_wire -T = TypeVar("T") -E = TypeVar("E", bound=Enum) -KT = TypeVar("KT") +DecodedT = TypeVar("DecodedT") +EnumValueT = TypeVar("EnumValueT", bound=Enum) +MapKeyT = TypeVar("MapKeyT") BytesLike: TypeAlias = bytes | bytearray | memoryview @@ -32,12 +32,23 @@ def decode_bytes_base64(raw: object) -> bytes: if isinstance(raw, str): try: return base64.b64decode(raw.encode("ascii"), validate=True) - except (BinasciiError, UnicodeEncodeError): + except (BinasciiError, UnicodeEncodeError) as exc: + raise ValueError("Invalid base64 payload") from exc + raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") + + +def decode_bytes_map_key(raw: object) -> bytes: + if isinstance(raw, bytes | bytearray | memoryview): + return bytes(raw) + if isinstance(raw, str): + try: + return decode_bytes_base64(raw) + except ValueError: try: return raw.encode("utf-8") except UnicodeEncodeError as fallback_exc: - raise ValueError("Invalid base64 payload") from fallback_exc - raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") + raise ValueError("Invalid bytes map key") from fallback_exc + raise TypeError(f"Unsupported map key for bytes field: {type(raw)!r}") def encode_bytes_sequence(values: Iterable[BytesLike | None] | None) -> list[str | None] | None: @@ -77,11 +88,11 @@ def encode_model_sequence(values: Iterable[object] | None) -> list[dict[str, obj return encoded or None -def decode_model_sequence(cls_factory: Callable[[], type[T]], raw: object) -> list[T] | None: +def decode_model_sequence(cls_factory: Callable[[], type[DecodedT]], raw: object) -> list[DecodedT] | None: if not isinstance(raw, list): return None cls = cls_factory() - decoded: list[T] = [] + decoded: list[DecodedT] = [] for item in raw: if isinstance(item, Mapping): decoded.append(from_wire(cls, item)) @@ -99,11 +110,11 @@ def encode_enum_sequence(values: Iterable[object] | None) -> list[object] | None return encoded or None -def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> list[E] | None: +def decode_enum_sequence(enum_factory: Callable[[], type[EnumValueT]], raw: object) -> list[EnumValueT] | None: if not isinstance(raw, list): return None enum_cls = enum_factory() - decoded: list[E] = [] + decoded: list[EnumValueT] = [] for item in raw: try: decoded.append(enum_cls(item)) @@ -113,7 +124,7 @@ def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> li def encode_model_mapping( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], mapping: Mapping[object, object] | None, *, key_encoder: Callable[[object], str] | None = None, @@ -140,15 +151,15 @@ def encode_model_mapping( def decode_model_mapping( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], raw: object, *, - key_decoder: Callable[[object], KT] | None = None, -) -> dict[KT, T] | None: + key_decoder: Callable[[object], MapKeyT] | None = None, +) -> dict[MapKeyT, DecodedT] | None: if not isinstance(raw, Mapping): return None cls = factory() - decoded: dict[KT, T] = {} + decoded: dict[MapKeyT, DecodedT] = {} for key, value in raw.items(): if isinstance(value, Mapping): decoded_key = key_decoder(key) if key_decoder is not None else key @@ -156,8 +167,14 @@ def decode_model_mapping( return decoded or None +def decode_optional_bool(raw: object) -> bool | None: + if raw is None: + return None + return bool(raw) + + def mapping_encoder( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], *, key_encoder: Callable[[object], str] | None = None, ) -> Callable[[Mapping[object, object] | None], dict[str, object] | None]: @@ -168,11 +185,11 @@ def _encode(mapping: Mapping[object, object] | None) -> dict[str, object] | None def mapping_decoder( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], *, - key_decoder: Callable[[object], KT] | None = None, -) -> Callable[[object], dict[KT, T] | None]: - def _decode(raw: object) -> dict[KT, T] | None: + key_decoder: Callable[[object], MapKeyT] | None = None, +) -> Callable[[object], dict[MapKeyT, DecodedT] | None]: + def _decode(raw: object) -> dict[MapKeyT, DecodedT] | None: return decode_model_mapping(factory, raw, key_decoder=key_decoder) return _decode diff --git a/src/algokit_common/serde/_core.py b/src/algokit_common/serde/_core.py index 5c1a419d..ddda308f 100644 --- a/src/algokit_common/serde/_core.py +++ b/src/algokit_common/serde/_core.py @@ -1,7 +1,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass, fields, is_dataclass from enum import Enum -from typing import TypeVar, cast +from typing import TypeVar, cast, get_args, get_origin from algokit_common import address_from_public_key, public_key_from_address from algokit_common.serde._primitives import ( @@ -30,7 +30,7 @@ ] -T = TypeVar("T") +DecodedValueT = TypeVar("DecodedValueT") class EncodeError(ValueError): @@ -71,6 +71,22 @@ def wire( ChildType = type[object] | Callable[[], type[object]] | None +def _expects_text_value(type_hint: object) -> bool: + if isinstance(type_hint, type) and issubclass(type_hint, Enum): + return True + if type_hint is str: + return True + origin = get_origin(type_hint) + if origin is None: + return False + if origin is str: + return True + if origin in (list, tuple, set, frozenset, dict): + return False + args = [arg for arg in get_args(type_hint) if arg is not type(None)] + return any(_expects_text_value(arg) for arg in args) + + def flatten( child_cls: ChildType, *, @@ -104,6 +120,7 @@ class _FieldHandler: nested_alias: str | None present_if: Callable[[Mapping[str, object]], bool] | None = None pass_obj: bool = False + expects_text: bool = False class _SerdePlan: @@ -143,6 +160,7 @@ def _compile_plan(cls: type[object]) -> _SerdePlan: child_cls=None, nested_alias=None, pass_obj=bool(meta.get("pass_obj", False)), + expects_text=_expects_text_value(f.type), ) ) elif kind == "nested": @@ -306,7 +324,18 @@ def _decode_wire_field(kwargs: dict[str, object], h: _FieldHandler, payload: Map raise DecodeError(f"Missing required field {h.name!r} (alias {alias!r})") kwargs[h.name] = None return - kwargs[h.name] = _decode_with_hint(raw, h.decode_fn) + value = raw + needs_text = bool( + h.expects_text and (h.decode_fn is None or (isinstance(h.decode_fn, type) and issubclass(h.decode_fn, Enum))) + ) + if needs_text and isinstance(value, bytes | bytearray | memoryview): + raw_bytes = bytes(cast(bytes | bytearray | memoryview, value)) + try: + value = raw_bytes.decode("utf-8") + except UnicodeDecodeError: + # Some Algorand fields legitimately carry printable data inside binary slots. + value = raw_bytes + kwargs[h.name] = _decode_with_hint(value, h.decode_fn) def _decode_nested_field(kwargs: dict[str, object], h: _FieldHandler, payload: Mapping[str, object]) -> None: @@ -374,7 +403,7 @@ def _has_path(source: Mapping[str, object], path: str) -> bool: return True -def from_wire(cls: type[T], payload: Mapping[str, object]) -> T: +def from_wire(cls: type[DecodedValueT], payload: Mapping[str, object]) -> DecodedValueT: """Decode a wire dict into a dataclass instance using field metadata.""" plan = _plan_for(cls) kwargs: dict[str, object] = {} @@ -386,7 +415,7 @@ def from_wire(cls: type[T], payload: Mapping[str, object]) -> T: elif h.kind == "flatten": _decode_flatten_field(kwargs, h, payload) try: - return cast(T, plan.cls(**kwargs)) + return cast(DecodedValueT, plan.cls(**kwargs)) except TypeError as exc: raise DecodeError(f"Failed to construct {plan.cls.__name__}: {exc}") from exc diff --git a/src/algokit_indexer_client/client.py b/src/algokit_indexer_client/client.py index f3103c46..df9613b1 100644 --- a/src/algokit_indexer_client/client.py +++ b/src/algokit_indexer_client/client.py @@ -1223,7 +1223,7 @@ def _decode_response( return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: - data = msgpack.unpackb(response.content, raw=False, strict_map_key=False) + data = msgpack.unpackb(response.content, raw=True, strict_map_key=False) data = self._normalize_msgpack(data) elif content_type.startswith("application/json"): data = response.json() @@ -1239,7 +1239,18 @@ def _decode_response( def _normalize_msgpack(self, value: object) -> object: if isinstance(value, dict): - return {key: self._normalize_msgpack(item) for key, item in value.items()} + normalized: dict[object, object] = {} + for key, item in value.items(): + normalized[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) + return normalized if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value + + def _coerce_msgpack_key(self, key: object) -> object: + if isinstance(key, bytes): + try: + return key.decode("utf-8") + except UnicodeDecodeError: + return key + return key diff --git a/src/algokit_indexer_client/models/_serde_helpers.py b/src/algokit_indexer_client/models/_serde_helpers.py index eaf4df1f..2d829300 100644 --- a/src/algokit_indexer_client/models/_serde_helpers.py +++ b/src/algokit_indexer_client/models/_serde_helpers.py @@ -8,9 +8,9 @@ from algokit_common.serde import from_wire, to_wire -T = TypeVar("T") -E = TypeVar("E", bound=Enum) -KT = TypeVar("KT") +DecodedT = TypeVar("DecodedT") +EnumValueT = TypeVar("EnumValueT", bound=Enum) +MapKeyT = TypeVar("MapKeyT") BytesLike: TypeAlias = bytes | bytearray | memoryview @@ -32,12 +32,23 @@ def decode_bytes_base64(raw: object) -> bytes: if isinstance(raw, str): try: return base64.b64decode(raw.encode("ascii"), validate=True) - except (BinasciiError, UnicodeEncodeError): + except (BinasciiError, UnicodeEncodeError) as exc: + raise ValueError("Invalid base64 payload") from exc + raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") + + +def decode_bytes_map_key(raw: object) -> bytes: + if isinstance(raw, bytes | bytearray | memoryview): + return bytes(raw) + if isinstance(raw, str): + try: + return decode_bytes_base64(raw) + except ValueError: try: return raw.encode("utf-8") except UnicodeEncodeError as fallback_exc: - raise ValueError("Invalid base64 payload") from fallback_exc - raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") + raise ValueError("Invalid bytes map key") from fallback_exc + raise TypeError(f"Unsupported map key for bytes field: {type(raw)!r}") def encode_bytes_sequence(values: Iterable[BytesLike | None] | None) -> list[str | None] | None: @@ -77,11 +88,11 @@ def encode_model_sequence(values: Iterable[object] | None) -> list[dict[str, obj return encoded or None -def decode_model_sequence(cls_factory: Callable[[], type[T]], raw: object) -> list[T] | None: +def decode_model_sequence(cls_factory: Callable[[], type[DecodedT]], raw: object) -> list[DecodedT] | None: if not isinstance(raw, list): return None cls = cls_factory() - decoded: list[T] = [] + decoded: list[DecodedT] = [] for item in raw: if isinstance(item, Mapping): decoded.append(from_wire(cls, item)) @@ -99,11 +110,11 @@ def encode_enum_sequence(values: Iterable[object] | None) -> list[object] | None return encoded or None -def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> list[E] | None: +def decode_enum_sequence(enum_factory: Callable[[], type[EnumValueT]], raw: object) -> list[EnumValueT] | None: if not isinstance(raw, list): return None enum_cls = enum_factory() - decoded: list[E] = [] + decoded: list[EnumValueT] = [] for item in raw: try: decoded.append(enum_cls(item)) @@ -113,7 +124,7 @@ def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> li def encode_model_mapping( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], mapping: Mapping[object, object] | None, *, key_encoder: Callable[[object], str] | None = None, @@ -140,15 +151,15 @@ def encode_model_mapping( def decode_model_mapping( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], raw: object, *, - key_decoder: Callable[[object], KT] | None = None, -) -> dict[KT, T] | None: + key_decoder: Callable[[object], MapKeyT] | None = None, +) -> dict[MapKeyT, DecodedT] | None: if not isinstance(raw, Mapping): return None cls = factory() - decoded: dict[KT, T] = {} + decoded: dict[MapKeyT, DecodedT] = {} for key, value in raw.items(): if isinstance(value, Mapping): decoded_key = key_decoder(key) if key_decoder is not None else key @@ -156,8 +167,14 @@ def decode_model_mapping( return decoded or None +def decode_optional_bool(raw: object) -> bool | None: + if raw is None: + return None + return bool(raw) + + def mapping_encoder( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], *, key_encoder: Callable[[object], str] | None = None, ) -> Callable[[Mapping[object, object] | None], dict[str, object] | None]: @@ -168,11 +185,11 @@ def _encode(mapping: Mapping[object, object] | None) -> dict[str, object] | None def mapping_decoder( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], *, - key_decoder: Callable[[object], KT] | None = None, -) -> Callable[[object], dict[KT, T] | None]: - def _decode(raw: object) -> dict[KT, T] | None: + key_decoder: Callable[[object], MapKeyT] | None = None, +) -> Callable[[object], dict[MapKeyT, DecodedT] | None]: + def _decode(raw: object) -> dict[MapKeyT, DecodedT] | None: return decode_model_mapping(factory, raw, key_decoder=key_decoder) return _decode diff --git a/src/algokit_kmd_client/client.py b/src/algokit_kmd_client/client.py index 60eb5c60..25edcf5e 100644 --- a/src/algokit_kmd_client/client.py +++ b/src/algokit_kmd_client/client.py @@ -1056,7 +1056,7 @@ def _decode_response( return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: - data = msgpack.unpackb(response.content, raw=False, strict_map_key=False) + data = msgpack.unpackb(response.content, raw=True, strict_map_key=False) data = self._normalize_msgpack(data) elif content_type.startswith("application/json"): data = response.json() @@ -1072,7 +1072,18 @@ def _decode_response( def _normalize_msgpack(self, value: object) -> object: if isinstance(value, dict): - return {key: self._normalize_msgpack(item) for key, item in value.items()} + normalized: dict[object, object] = {} + for key, item in value.items(): + normalized[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) + return normalized if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value + + def _coerce_msgpack_key(self, key: object) -> object: + if isinstance(key, bytes): + try: + return key.decode("utf-8") + except UnicodeDecodeError: + return key + return key diff --git a/src/algokit_kmd_client/models/_serde_helpers.py b/src/algokit_kmd_client/models/_serde_helpers.py index eaf4df1f..2d829300 100644 --- a/src/algokit_kmd_client/models/_serde_helpers.py +++ b/src/algokit_kmd_client/models/_serde_helpers.py @@ -8,9 +8,9 @@ from algokit_common.serde import from_wire, to_wire -T = TypeVar("T") -E = TypeVar("E", bound=Enum) -KT = TypeVar("KT") +DecodedT = TypeVar("DecodedT") +EnumValueT = TypeVar("EnumValueT", bound=Enum) +MapKeyT = TypeVar("MapKeyT") BytesLike: TypeAlias = bytes | bytearray | memoryview @@ -32,12 +32,23 @@ def decode_bytes_base64(raw: object) -> bytes: if isinstance(raw, str): try: return base64.b64decode(raw.encode("ascii"), validate=True) - except (BinasciiError, UnicodeEncodeError): + except (BinasciiError, UnicodeEncodeError) as exc: + raise ValueError("Invalid base64 payload") from exc + raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") + + +def decode_bytes_map_key(raw: object) -> bytes: + if isinstance(raw, bytes | bytearray | memoryview): + return bytes(raw) + if isinstance(raw, str): + try: + return decode_bytes_base64(raw) + except ValueError: try: return raw.encode("utf-8") except UnicodeEncodeError as fallback_exc: - raise ValueError("Invalid base64 payload") from fallback_exc - raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}") + raise ValueError("Invalid bytes map key") from fallback_exc + raise TypeError(f"Unsupported map key for bytes field: {type(raw)!r}") def encode_bytes_sequence(values: Iterable[BytesLike | None] | None) -> list[str | None] | None: @@ -77,11 +88,11 @@ def encode_model_sequence(values: Iterable[object] | None) -> list[dict[str, obj return encoded or None -def decode_model_sequence(cls_factory: Callable[[], type[T]], raw: object) -> list[T] | None: +def decode_model_sequence(cls_factory: Callable[[], type[DecodedT]], raw: object) -> list[DecodedT] | None: if not isinstance(raw, list): return None cls = cls_factory() - decoded: list[T] = [] + decoded: list[DecodedT] = [] for item in raw: if isinstance(item, Mapping): decoded.append(from_wire(cls, item)) @@ -99,11 +110,11 @@ def encode_enum_sequence(values: Iterable[object] | None) -> list[object] | None return encoded or None -def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> list[E] | None: +def decode_enum_sequence(enum_factory: Callable[[], type[EnumValueT]], raw: object) -> list[EnumValueT] | None: if not isinstance(raw, list): return None enum_cls = enum_factory() - decoded: list[E] = [] + decoded: list[EnumValueT] = [] for item in raw: try: decoded.append(enum_cls(item)) @@ -113,7 +124,7 @@ def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> li def encode_model_mapping( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], mapping: Mapping[object, object] | None, *, key_encoder: Callable[[object], str] | None = None, @@ -140,15 +151,15 @@ def encode_model_mapping( def decode_model_mapping( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], raw: object, *, - key_decoder: Callable[[object], KT] | None = None, -) -> dict[KT, T] | None: + key_decoder: Callable[[object], MapKeyT] | None = None, +) -> dict[MapKeyT, DecodedT] | None: if not isinstance(raw, Mapping): return None cls = factory() - decoded: dict[KT, T] = {} + decoded: dict[MapKeyT, DecodedT] = {} for key, value in raw.items(): if isinstance(value, Mapping): decoded_key = key_decoder(key) if key_decoder is not None else key @@ -156,8 +167,14 @@ def decode_model_mapping( return decoded or None +def decode_optional_bool(raw: object) -> bool | None: + if raw is None: + return None + return bool(raw) + + def mapping_encoder( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], *, key_encoder: Callable[[object], str] | None = None, ) -> Callable[[Mapping[object, object] | None], dict[str, object] | None]: @@ -168,11 +185,11 @@ def _encode(mapping: Mapping[object, object] | None) -> dict[str, object] | None def mapping_decoder( - factory: Callable[[], type[T]], + factory: Callable[[], type[DecodedT]], *, - key_decoder: Callable[[object], KT] | None = None, -) -> Callable[[object], dict[KT, T] | None]: - def _decode(raw: object) -> dict[KT, T] | None: + key_decoder: Callable[[object], MapKeyT] | None = None, +) -> Callable[[object], dict[MapKeyT, DecodedT] | None]: + def _decode(raw: object) -> dict[MapKeyT, DecodedT] | None: return decode_model_mapping(factory, raw, key_decoder=key_decoder) return _decode diff --git a/src/algokit_transact/models/state_proof.py b/src/algokit_transact/models/state_proof.py index ac896bf9..123bed52 100644 --- a/src/algokit_transact/models/state_proof.py +++ b/src/algokit_transact/models/state_proof.py @@ -96,7 +96,7 @@ def _encode_reveals(mapping: Mapping[int, Reveal] | Iterable[Reveal] | None) -> entries: Iterable[tuple[int, Reveal]] if isinstance(mapping, Mapping): entries = ( - (_coerce_reveal_position(key, idx), reveal) + (key if isinstance(key, int) else _coerce_reveal_position(key, idx), reveal) for idx, (key, reveal) in enumerate(mapping.items()) ) else: @@ -150,4 +150,5 @@ def _coerce_reveal_position(raw: object, fallback: int) -> int: return int(raw) except ValueError: return fallback + # Fallback silently to preserve legacy behavior when nodes omit this field. return fallback diff --git a/tests/modules/algod_client/test_block.py b/tests/modules/algod_client/test_block.py index 8d9e3b14..a6b94454 100644 --- a/tests/modules/algod_client/test_block.py +++ b/tests/modules/algod_client/test_block.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from algokit_algod_client import AlgodClient, ClientConfig @@ -10,11 +8,13 @@ def test_block_endpoint() -> None: ) algod_client = AlgodClient(config) - # Large block with state proof type txns on testnet (skip if not accessible) - block_round = 24098947 - resp = algod_client.get_block(round_=block_round, header_only=False) + # 1. Large block with state proof type txns on testnet (skip if not accessible) + # 2. Block with global and local state deltas where keys can not be decoded as + block_rounds = [24099447, 24099347] + + for block_round in block_rounds: + resp = algod_client.get_block(round_=block_round, header_only=False) - assert resp.cert is not None - assert resp.block.state_proof_tracking is not None - assert resp.block.transactions is not None - assert len(resp.block.transactions) > 0 + assert resp.block.state_proof_tracking is not None + assert resp.block.transactions is not None + assert len(resp.block.transactions) > 0 diff --git a/tests/modules/algod_client/test_ledger_state_delta.py b/tests/modules/algod_client/test_ledger_state_delta.py new file mode 100644 index 00000000..6d010fe0 --- /dev/null +++ b/tests/modules/algod_client/test_ledger_state_delta.py @@ -0,0 +1,45 @@ +from algokit_algod_client import AlgodClient, ClientConfig + + +def test_ledger_state_delta_endpoint() -> None: + config = ClientConfig( + base_url="https://testnet-api.4160.nodely.dev", + token=None, + ) + algod_client = AlgodClient(config) + + # 1. Large block with state proof type txns on testnet (skip if not accessible) + # 2. Block with global and local state deltas where keys can not be decoded as + block_rounds = [24099447, 24099347] + + for block_round in block_rounds: + resp = algod_client.get_ledger_state_delta(round_=block_round) + + assert resp.block.round == block_round + assert resp.block.genesis_id == "testnet-v1.0" + assert resp.accounts.accounts + for account in resp.accounts.accounts: + assert len(account.address) == 58 + assert resp.totals.online.money > 0 + assert resp.totals.offline.money >= 0 + assert resp.totals.not_participating.money >= 0 + + # App resources (if any) should keep both IDs and decoded addresses. + for resource in resp.accounts.app_resources or []: + assert resource.app_id > 0 + assert len(resource.address) == 58 + if resource.state.local_state: + # Local state deltas should track schema counts. + assert resource.state.local_state.schema.num_byte_slices is not None + + # Creatables (if any) should expose creators and types. + for creatable in (resp.creatables or {}).values(): + assert len(creatable.creator) == 58 + assert creatable.creatable_type in (0, 1) + + # TxIDs are keyed by raw digest bytes (32-byte values) with last-valid metadata. + if resp.txids: + for txid_bytes, tx_info in resp.txids.items(): + assert isinstance(txid_bytes, bytes) + assert len(txid_bytes) == 32 + assert tx_info.last_valid >= block_round From 8248b8f2a117fdab5d52902ae262e96916c375a9 Mon Sep 17 00:00:00 2001 From: Daniel McGregor Date: Wed, 12 Nov 2025 11:36:47 +0800 Subject: [PATCH 4/6] test: eliminate test warnings by adding markers --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 688f48e2..de66aed6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -235,6 +235,11 @@ sequence = [ [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] +markers = [ + "group_transaction_tests", + "group_transaction_group_tests", + "group_generic_transaction_tests", +] addopts = "-n auto" norecursedirs = [ "src", From 4a58b4858ac983bc3c8249ab419a208e04deafdd Mon Sep 17 00:00:00 2001 From: Daniel McGregor Date: Wed, 12 Nov 2025 14:45:40 +0800 Subject: [PATCH 5/6] refactor: remove unnecessary cast --- src/algokit_common/serde/_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/algokit_common/serde/_core.py b/src/algokit_common/serde/_core.py index ddda308f..73e90363 100644 --- a/src/algokit_common/serde/_core.py +++ b/src/algokit_common/serde/_core.py @@ -46,7 +46,7 @@ def wire( alias: str, *, encode: Callable[..., object] | None = None, - decode: Callable[[object], object] | type | None = None, + decode: Callable[..., object] | type | None = None, omit_if_none: bool = True, keep_zero: bool = False, keep_false: bool = False, @@ -329,7 +329,7 @@ def _decode_wire_field(kwargs: dict[str, object], h: _FieldHandler, payload: Map h.expects_text and (h.decode_fn is None or (isinstance(h.decode_fn, type) and issubclass(h.decode_fn, Enum))) ) if needs_text and isinstance(value, bytes | bytearray | memoryview): - raw_bytes = bytes(cast(bytes | bytearray | memoryview, value)) + raw_bytes = bytes(value) try: value = raw_bytes.decode("utf-8") except UnicodeDecodeError: From 12bfc91796b027920e54b1ab746e6701424a0eaa Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Wed, 12 Nov 2025 10:47:13 +0100 Subject: [PATCH 6/6] fix: minor tweaks in state delta model after verifying with go algorand --- .../templates/models/ledger_state_delta.py.j2 | 11 ++++++++--- .../models/_ledger_state_delta.py | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 index e2ed4584..7a10c160 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 @@ -186,6 +186,11 @@ class LedgerAppParams: local_state_schema: LedgerStateSchema = field(metadata=nested("lsch", lambda: LedgerStateSchema)) global_state_schema: LedgerStateSchema = field(metadata=nested("gsch", lambda: LedgerStateSchema)) extra_program_pages: int = field(metadata=wire("epp")) + version: int | None = field(default=None, metadata=wire("v")) + size_sponsor: str | None = field( + default=None, + metadata=addr("ss"), + ) global_state: dict[bytes, LedgerTealValue] | None = field( default=None, metadata=wire( @@ -329,9 +334,9 @@ class LedgerAccountBaseData: incentive_eligible: bool = field(metadata=wire("IncentiveEligible")) total_app_schema: LedgerStateSchema = field(metadata=nested("TotalAppSchema", lambda: LedgerStateSchema)) total_extra_app_pages: int = field(metadata=wire("TotalExtraAppPages")) - total_app_params: int = field(metadata=wire("TotalLedgerAppParams")) - total_app_local_states: int = field(metadata=wire("TotalLedgerAppLocalStates")) - total_asset_params: int = field(metadata=wire("TotalLedgerAssetParams")) + total_app_params: int = field(metadata=wire("TotalAppParams")) + total_app_local_states: int = field(metadata=wire("TotalAppLocalStates")) + total_asset_params: int = field(metadata=wire("TotalAssetParams")) total_assets: int = field(metadata=wire("TotalAssets")) total_boxes: int = field(metadata=wire("TotalBoxes")) total_box_bytes: int = field(metadata=wire("TotalBoxBytes")) diff --git a/src/algokit_algod_client/models/_ledger_state_delta.py b/src/algokit_algod_client/models/_ledger_state_delta.py index d06918e7..55d5ac94 100644 --- a/src/algokit_algod_client/models/_ledger_state_delta.py +++ b/src/algokit_algod_client/models/_ledger_state_delta.py @@ -186,6 +186,11 @@ class LedgerAppParams: local_state_schema: LedgerStateSchema = field(metadata=nested("lsch", lambda: LedgerStateSchema)) global_state_schema: LedgerStateSchema = field(metadata=nested("gsch", lambda: LedgerStateSchema)) extra_program_pages: int = field(metadata=wire("epp")) + version: int | None = field(default=None, metadata=wire("v")) + size_sponsor: str | None = field( + default=None, + metadata=addr("ss"), + ) global_state: dict[bytes, LedgerTealValue] | None = field( default=None, metadata=wire( @@ -329,9 +334,9 @@ class LedgerAccountBaseData: incentive_eligible: bool = field(metadata=wire("IncentiveEligible")) total_app_schema: LedgerStateSchema = field(metadata=nested("TotalAppSchema", lambda: LedgerStateSchema)) total_extra_app_pages: int = field(metadata=wire("TotalExtraAppPages")) - total_app_params: int = field(metadata=wire("TotalLedgerAppParams")) - total_app_local_states: int = field(metadata=wire("TotalLedgerAppLocalStates")) - total_asset_params: int = field(metadata=wire("TotalLedgerAssetParams")) + total_app_params: int = field(metadata=wire("TotalAppParams")) + total_app_local_states: int = field(metadata=wire("TotalAppLocalStates")) + total_asset_params: int = field(metadata=wire("TotalAssetParams")) total_assets: int = field(metadata=wire("TotalAssets")) total_boxes: int = field(metadata=wire("TotalBoxes")) total_box_bytes: int = field(metadata=wire("TotalBoxBytes"))