chore: remove configuration files and enhance blockchain explorer with advanced search, analytics, and export features

- Delete .aitbc.yaml.example CLI configuration template
- Delete .lycheeignore link checker exclusion rules
- Delete .nvmrc Node.js version specification
- Add advanced search panel with filters for address, amount range, transaction type, time range, and validator
- Add analytics dashboard with transaction volume, active addresses, and block time metrics
- Add Chart.js integration
This commit is contained in:
oib
2026-03-02 15:38:25 +01:00
parent af185cdd8b
commit ccedbace53
271 changed files with 35942 additions and 2359 deletions

View File

@@ -250,6 +250,129 @@ files = [
[package.dependencies]
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
[[package]]
name = "charset-normalizer"
version = "3.4.4"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"},
{file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"},
{file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"},
{file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"},
{file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"},
{file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"},
{file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"},
{file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"},
{file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"},
{file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"},
{file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"},
]
[[package]]
name = "click"
version = "8.3.0"
@@ -1244,6 +1367,28 @@ files = [
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
[[package]]
name = "requests"
version = "2.32.5"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
]
[package.dependencies]
certifi = ">=2017.4.17"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "rich"
version = "13.9.4"
@@ -1485,6 +1630,24 @@ files = [
[package.dependencies]
typing-extensions = ">=4.12.0"
[[package]]
name = "urllib3"
version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
]
[package.extras]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "uvicorn"
version = "0.30.6"
@@ -1783,4 +1946,4 @@ uvloop = ["uvloop"]
[metadata]
lock-version = "2.1"
python-versions = "^3.13"
content-hash = "9ff81ad9b7b98a0ae6a73e23d6c58336d2a89d0f5f5e035e9e0e56c509826720"
content-hash = "6c9b058d64062b2dc6d0dcde3bd59eab081c1f73a927ea22e1e1346b1309025f"

View File

@@ -25,6 +25,7 @@ uvloop = ">=0.22.0"
rich = "^13.7.1"
cryptography = "^42.0.5"
asyncpg = ">=0.29.0"
requests = "^2.32.5"
[tool.poetry.extras]
uvloop = ["uvloop"]

View File

@@ -95,7 +95,7 @@ async def lifespan(app: FastAPI):
broadcast_url=settings.gossip_broadcast_url,
)
await gossip_broker.set_backend(backend)
_app_logger.info("Blockchain node started", extra={"chain_id": settings.chain_id})
_app_logger.info("Blockchain node started", extra={"supported_chains": settings.supported_chains})
try:
yield
finally:
@@ -134,7 +134,7 @@ def create_app() -> FastAPI:
async def health() -> dict:
return {
"status": "ok",
"chain_id": settings.chain_id,
"supported_chains": [c.strip() for c in settings.supported_chains.split(",") if c.strip()],
"proposer_id": settings.proposer_id,
}

View File

@@ -6,10 +6,20 @@ from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import BaseModel
class ProposerConfig(BaseModel):
chain_id: str
proposer_id: str
interval_seconds: int
max_block_size_bytes: int
max_txs_per_block: int
class ChainSettings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False)
chain_id: str = "ait-devnet"
supported_chains: str = "ait-devnet" # Comma-separated list of supported chain IDs
db_path: Path = Path("./data/chain.db")
rpc_bind_host: str = "127.0.0.1"

View File

@@ -4,7 +4,6 @@ import re
from datetime import datetime
from typing import Callable, ContextManager, Optional
import httpx
from sqlmodel import Session, select
from ..logger import get_logger
@@ -21,6 +20,42 @@ def _sanitize_metric_suffix(value: str) -> str:
return sanitized or "unknown"
import time
class CircuitBreaker:
def __init__(self, threshold: int, timeout: int):
self._threshold = threshold
self._timeout = timeout
self._failures = 0
self._last_failure_time = 0.0
self._state = "closed"
@property
def state(self) -> str:
if self._state == "open":
if time.time() - self._last_failure_time > self._timeout:
self._state = "half-open"
return self._state
def allow_request(self) -> bool:
state = self.state
if state == "closed":
return True
if state == "half-open":
return True
return False
def record_failure(self) -> None:
self._failures += 1
self._last_failure_time = time.time()
if self._failures >= self._threshold:
self._state = "open"
def record_success(self) -> None:
self._failures = 0
self._state = "closed"
class PoAProposer:
"""Proof-of-Authority block proposer.
@@ -83,26 +118,13 @@ class PoAProposer:
return
def _propose_block(self) -> None:
# Check RPC mempool for transactions
try:
response = httpx.get("http://localhost:8082/metrics")
if response.status_code == 200:
has_transactions = False
for line in response.text.split("\n"):
if line.startswith("mempool_size"):
size = float(line.split(" ")[1])
if size > 0:
has_transactions = True
break
if not has_transactions:
return
except Exception as exc:
self._logger.error(f"Error checking RPC mempool: {exc}")
# Check internal mempool
from ..mempool import get_mempool
if get_mempool().size(self._config.chain_id) == 0:
return
with self._session_factory() as session:
head = session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first()
head = session.exec(select(Block).where(Block.chain_id == self._config.chain_id).order_by(Block.height.desc()).limit(1)).first()
next_height = 0
parent_hash = "0x00"
interval_seconds: Optional[float] = None
@@ -115,6 +137,7 @@ class PoAProposer:
block_hash = self._compute_block_hash(next_height, parent_hash, timestamp)
block = Block(
chain_id=self._config.chain_id,
height=next_height,
hash=block_hash,
parent_hash=parent_hash,
@@ -163,13 +186,15 @@ class PoAProposer:
def _ensure_genesis_block(self) -> None:
with self._session_factory() as session:
head = session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first()
head = session.exec(select(Block).where(Block.chain_id == self._config.chain_id).order_by(Block.height.desc()).limit(1)).first()
if head is not None:
return
timestamp = datetime.utcnow()
# Use a deterministic genesis timestamp so all nodes agree on the genesis block hash
timestamp = datetime(2025, 1, 1, 0, 0, 0)
block_hash = self._compute_block_hash(0, "0x00", timestamp)
genesis = Block(
chain_id=self._config.chain_id,
height=0,
hash=block_hash,
parent_hash="0x00",

View File

@@ -0,0 +1,43 @@
import logging
import sys
from logging.handlers import RotatingFileHandler
import json
from datetime import datetime
class JsonFormatter(logging.Formatter):
def format(self, record):
log_record = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"level": record.levelname,
"logger": record.name,
"message": record.getMessage()
}
# Add any extra arguments passed to the logger
if hasattr(record, "chain_id"):
log_record["chain_id"] = record.chain_id
if hasattr(record, "supported_chains"):
log_record["supported_chains"] = record.supported_chains
if hasattr(record, "height"):
log_record["height"] = record.height
if hasattr(record, "hash"):
log_record["hash"] = record.hash
if hasattr(record, "proposer"):
log_record["proposer"] = record.proposer
if hasattr(record, "error"):
log_record["error"] = record.error
return json.dumps(log_record)
def get_logger(name: str) -> logging.Logger:
logger = logging.getLogger(name)
if not logger.handlers:
logger.setLevel(logging.INFO)
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(JsonFormatter())
logger.addHandler(console_handler)
return logger

View File

@@ -16,10 +16,10 @@ logger = get_logger(__name__)
class BlockchainNode:
def __init__(self) -> None:
self._stop_event = asyncio.Event()
self._proposer: Optional[PoAProposer] = None
self._proposers: dict[str, PoAProposer] = {}
async def start(self) -> None:
logger.info("Starting blockchain node", extra={"chain_id": settings.chain_id})
logger.info("Starting blockchain node", extra={"supported_chains": getattr(settings, 'supported_chains', settings.chain_id)})
init_db()
init_mempool(
backend=settings.mempool_backend,
@@ -27,7 +27,7 @@ class BlockchainNode:
max_size=settings.mempool_max_size,
min_fee=settings.min_fee,
)
self._start_proposer()
self._start_proposers()
try:
await self._stop_event.wait()
finally:
@@ -38,29 +38,29 @@ class BlockchainNode:
self._stop_event.set()
await self._shutdown()
def _start_proposer(self) -> None:
if self._proposer is not None:
return
def _start_proposers(self) -> None:
chains_str = getattr(settings, 'supported_chains', settings.chain_id)
chains = [c.strip() for c in chains_str.split(",") if c.strip()]
for chain_id in chains:
if chain_id in self._proposers:
continue
proposer_config = ProposerConfig(
chain_id=settings.chain_id,
proposer_id=settings.proposer_id,
interval_seconds=settings.block_time_seconds,
max_block_size_bytes=settings.max_block_size_bytes,
max_txs_per_block=settings.max_txs_per_block,
)
cb = CircuitBreaker(
threshold=settings.circuit_breaker_threshold,
timeout=settings.circuit_breaker_timeout,
)
self._proposer = PoAProposer(config=proposer_config, session_factory=session_scope, circuit_breaker=cb)
asyncio.create_task(self._proposer.start())
proposer_config = ProposerConfig(
chain_id=chain_id,
proposer_id=settings.proposer_id,
interval_seconds=settings.block_time_seconds,
max_block_size_bytes=settings.max_block_size_bytes,
max_txs_per_block=settings.max_txs_per_block,
)
proposer = PoAProposer(config=proposer_config, session_factory=session_scope)
self._proposers[chain_id] = proposer
asyncio.create_task(proposer.start())
async def _shutdown(self) -> None:
if self._proposer is None:
return
await self._proposer.stop()
self._proposer = None
for chain_id, proposer in list(self._proposers.items()):
await proposer.stop()
self._proposers.clear()
@asynccontextmanager

View File

@@ -38,7 +38,7 @@ class InMemoryMempool:
self._max_size = max_size
self._min_fee = min_fee
def add(self, tx: Dict[str, Any]) -> str:
def add(self, tx: Dict[str, Any], chain_id: str = "ait-devnet") -> str:
fee = tx.get("fee", 0)
if fee < self._min_fee:
raise ValueError(f"Fee {fee} below minimum {self._min_fee}")
@@ -56,14 +56,14 @@ class InMemoryMempool:
self._evict_lowest_fee()
self._transactions[tx_hash] = entry
metrics_registry.set_gauge("mempool_size", float(len(self._transactions)))
metrics_registry.increment("mempool_tx_added_total")
metrics_registry.increment(f"mempool_tx_added_total_{chain_id}")
return tx_hash
def list_transactions(self) -> List[PendingTransaction]:
def list_transactions(self, chain_id: str = "ait-devnet") -> List[PendingTransaction]:
with self._lock:
return list(self._transactions.values())
def drain(self, max_count: int, max_bytes: int) -> List[PendingTransaction]:
def drain(self, max_count: int, max_bytes: int, chain_id: str = "ait-devnet") -> List[PendingTransaction]:
"""Drain transactions for block inclusion, prioritized by fee (highest first)."""
with self._lock:
sorted_txs = sorted(
@@ -84,17 +84,17 @@ class InMemoryMempool:
del self._transactions[tx.tx_hash]
metrics_registry.set_gauge("mempool_size", float(len(self._transactions)))
metrics_registry.increment("mempool_tx_drained_total", float(len(result)))
metrics_registry.increment(f"mempool_tx_drained_total_{chain_id}", float(len(result)))
return result
def remove(self, tx_hash: str) -> bool:
def remove(self, tx_hash: str, chain_id: str = "ait-devnet") -> bool:
with self._lock:
removed = self._transactions.pop(tx_hash, None) is not None
if removed:
metrics_registry.set_gauge("mempool_size", float(len(self._transactions)))
return removed
def size(self) -> int:
def size(self, chain_id: str = "ait-devnet") -> int:
with self._lock:
return len(self._transactions)
@@ -104,7 +104,7 @@ class InMemoryMempool:
return
lowest = min(self._transactions.values(), key=lambda t: (t.fee, -t.received_at))
del self._transactions[lowest.tx_hash]
metrics_registry.increment("mempool_evictions_total")
metrics_registry.increment(f"mempool_evictions_total_{chain_id}")
class DatabaseMempool:
@@ -123,17 +123,19 @@ class DatabaseMempool:
with self._lock:
self._conn.execute("""
CREATE TABLE IF NOT EXISTS mempool (
tx_hash TEXT PRIMARY KEY,
chain_id TEXT NOT NULL,
tx_hash TEXT NOT NULL,
content TEXT NOT NULL,
fee INTEGER DEFAULT 0,
size_bytes INTEGER DEFAULT 0,
received_at REAL NOT NULL
received_at REAL NOT NULL,
PRIMARY KEY (chain_id, tx_hash)
)
""")
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_mempool_fee ON mempool(fee DESC)")
self._conn.commit()
def add(self, tx: Dict[str, Any]) -> str:
def add(self, tx: Dict[str, Any], chain_id: str = "ait-devnet") -> str:
fee = tx.get("fee", 0)
if fee < self._min_fee:
raise ValueError(f"Fee {fee} below minimum {self._min_fee}")
@@ -144,33 +146,34 @@ class DatabaseMempool:
with self._lock:
# Check duplicate
row = self._conn.execute("SELECT 1 FROM mempool WHERE tx_hash = ?", (tx_hash,)).fetchone()
row = self._conn.execute("SELECT 1 FROM mempool WHERE chain_id = ? AND tx_hash = ?", (chain_id, tx_hash)).fetchone()
if row:
return tx_hash
# Evict if full
count = self._conn.execute("SELECT COUNT(*) FROM mempool").fetchone()[0]
count = self._conn.execute("SELECT COUNT(*) FROM mempool WHERE chain_id = ?", (chain_id,)).fetchone()[0]
if count >= self._max_size:
self._conn.execute("""
DELETE FROM mempool WHERE tx_hash = (
SELECT tx_hash FROM mempool ORDER BY fee ASC, received_at DESC LIMIT 1
DELETE FROM mempool WHERE chain_id = ? AND tx_hash = (
SELECT tx_hash FROM mempool WHERE chain_id = ? ORDER BY fee ASC, received_at DESC LIMIT 1
)
""")
metrics_registry.increment("mempool_evictions_total")
""", (chain_id, chain_id))
metrics_registry.increment(f"mempool_evictions_total_{chain_id}")
self._conn.execute(
"INSERT INTO mempool (tx_hash, content, fee, size_bytes, received_at) VALUES (?, ?, ?, ?, ?)",
(tx_hash, content, fee, size_bytes, time.time())
"INSERT INTO mempool (chain_id, tx_hash, content, fee, size_bytes, received_at) VALUES (?, ?, ?, ?, ?, ?)",
(chain_id, tx_hash, content, fee, size_bytes, time.time())
)
self._conn.commit()
metrics_registry.increment("mempool_tx_added_total")
self._update_gauge()
metrics_registry.increment(f"mempool_tx_added_total_{chain_id}")
self._update_gauge(chain_id)
return tx_hash
def list_transactions(self) -> List[PendingTransaction]:
def list_transactions(self, chain_id: str = "ait-devnet") -> List[PendingTransaction]:
with self._lock:
rows = self._conn.execute(
"SELECT tx_hash, content, fee, size_bytes, received_at FROM mempool ORDER BY fee DESC, received_at ASC"
"SELECT tx_hash, content, fee, size_bytes, received_at FROM mempool WHERE chain_id = ? ORDER BY fee DESC, received_at ASC",
(chain_id,)
).fetchall()
return [
PendingTransaction(
@@ -179,10 +182,11 @@ class DatabaseMempool:
) for r in rows
]
def drain(self, max_count: int, max_bytes: int) -> List[PendingTransaction]:
def drain(self, max_count: int, max_bytes: int, chain_id: str = "ait-devnet") -> List[PendingTransaction]:
with self._lock:
rows = self._conn.execute(
"SELECT tx_hash, content, fee, size_bytes, received_at FROM mempool ORDER BY fee DESC, received_at ASC"
"SELECT tx_hash, content, fee, size_bytes, received_at FROM mempool WHERE chain_id = ? ORDER BY fee DESC, received_at ASC",
(chain_id,)
).fetchall()
result: List[PendingTransaction] = []
@@ -203,29 +207,29 @@ class DatabaseMempool:
if hashes_to_remove:
placeholders = ",".join("?" * len(hashes_to_remove))
self._conn.execute(f"DELETE FROM mempool WHERE tx_hash IN ({placeholders})", hashes_to_remove)
self._conn.execute(f"DELETE FROM mempool WHERE chain_id = ? AND tx_hash IN ({placeholders})", [chain_id] + hashes_to_remove)
self._conn.commit()
metrics_registry.increment("mempool_tx_drained_total", float(len(result)))
self._update_gauge()
metrics_registry.increment(f"mempool_tx_drained_total_{chain_id}", float(len(result)))
self._update_gauge(chain_id)
return result
def remove(self, tx_hash: str) -> bool:
def remove(self, tx_hash: str, chain_id: str = "ait-devnet") -> bool:
with self._lock:
cursor = self._conn.execute("DELETE FROM mempool WHERE tx_hash = ?", (tx_hash,))
cursor = self._conn.execute("DELETE FROM mempool WHERE chain_id = ? AND tx_hash = ?", (chain_id, tx_hash))
self._conn.commit()
removed = cursor.rowcount > 0
if removed:
self._update_gauge()
self._update_gauge(chain_id)
return removed
def size(self) -> int:
def size(self, chain_id: str = "ait-devnet") -> int:
with self._lock:
return self._conn.execute("SELECT COUNT(*) FROM mempool").fetchone()[0]
return self._conn.execute("SELECT COUNT(*) FROM mempool WHERE chain_id = ?", (chain_id,)).fetchone()[0]
def _update_gauge(self) -> None:
count = self._conn.execute("SELECT COUNT(*) FROM mempool").fetchone()[0]
metrics_registry.set_gauge("mempool_size", float(count))
def _update_gauge(self, chain_id: str = "ait-devnet") -> None:
count = self._conn.execute("SELECT COUNT(*) FROM mempool WHERE chain_id = ?", (chain_id,)).fetchone()[0]
metrics_registry.set_gauge(f"mempool_size_{chain_id}", float(count))
# Singleton

View File

@@ -6,6 +6,7 @@ from pydantic import field_validator
from sqlalchemy import Column
from sqlalchemy.types import JSON
from sqlmodel import Field, Relationship, SQLModel
from sqlalchemy import UniqueConstraint
_HEX_PATTERN = re.compile(r"^(0x)?[0-9a-fA-F]+$")
@@ -24,9 +25,11 @@ def _validate_optional_hex(value: Optional[str], field_name: str) -> Optional[st
class Block(SQLModel, table=True):
__tablename__ = "block"
__table_args__ = (UniqueConstraint("chain_id", "height", name="uix_block_chain_height"),)
id: Optional[int] = Field(default=None, primary_key=True)
height: int = Field(index=True, unique=True)
chain_id: str = Field(index=True)
height: int = Field(index=True)
hash: str = Field(index=True, unique=True)
parent_hash: str
proposer: str
@@ -37,11 +40,19 @@ class Block(SQLModel, table=True):
# Relationships - use sa_relationship_kwargs for lazy loading
transactions: List["Transaction"] = Relationship(
back_populates="block",
sa_relationship_kwargs={"lazy": "selectin"}
sa_relationship_kwargs={
"lazy": "selectin",
"primaryjoin": "and_(Transaction.block_height==Block.height, Transaction.chain_id==Block.chain_id)",
"foreign_keys": "[Transaction.block_height, Transaction.chain_id]"
}
)
receipts: List["Receipt"] = Relationship(
back_populates="block",
sa_relationship_kwargs={"lazy": "selectin"}
sa_relationship_kwargs={
"lazy": "selectin",
"primaryjoin": "and_(Receipt.block_height==Block.height, Receipt.chain_id==Block.chain_id)",
"foreign_keys": "[Receipt.block_height, Receipt.chain_id]"
}
)
@field_validator("hash", mode="before")
@@ -62,13 +73,14 @@ class Block(SQLModel, table=True):
class Transaction(SQLModel, table=True):
__tablename__ = "transaction"
__table_args__ = (UniqueConstraint("chain_id", "tx_hash", name="uix_tx_chain_hash"),)
id: Optional[int] = Field(default=None, primary_key=True)
tx_hash: str = Field(index=True, unique=True)
chain_id: str = Field(index=True)
tx_hash: str = Field(index=True)
block_height: Optional[int] = Field(
default=None,
index=True,
foreign_key="block.height",
)
sender: str
recipient: str
@@ -79,7 +91,13 @@ class Transaction(SQLModel, table=True):
created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
# Relationship
block: Optional["Block"] = Relationship(back_populates="transactions")
block: Optional["Block"] = Relationship(
back_populates="transactions",
sa_relationship_kwargs={
"primaryjoin": "and_(Transaction.block_height==Block.height, Transaction.chain_id==Block.chain_id)",
"foreign_keys": "[Transaction.block_height, Transaction.chain_id]"
}
)
@field_validator("tx_hash", mode="before")
@classmethod
@@ -89,14 +107,15 @@ class Transaction(SQLModel, table=True):
class Receipt(SQLModel, table=True):
__tablename__ = "receipt"
__table_args__ = (UniqueConstraint("chain_id", "receipt_id", name="uix_receipt_chain_id"),)
id: Optional[int] = Field(default=None, primary_key=True)
chain_id: str = Field(index=True)
job_id: str = Field(index=True)
receipt_id: str = Field(index=True, unique=True)
receipt_id: str = Field(index=True)
block_height: Optional[int] = Field(
default=None,
index=True,
foreign_key="block.height",
)
payload: dict = Field(
default_factory=dict,
@@ -114,7 +133,13 @@ class Receipt(SQLModel, table=True):
recorded_at: datetime = Field(default_factory=datetime.utcnow, index=True)
# Relationship
block: Optional["Block"] = Relationship(back_populates="receipts")
block: Optional["Block"] = Relationship(
back_populates="receipts",
sa_relationship_kwargs={
"primaryjoin": "and_(Receipt.block_height==Block.height, Receipt.chain_id==Block.chain_id)",
"foreign_keys": "[Receipt.block_height, Receipt.chain_id]"
}
)
@field_validator("receipt_id", mode="before")
@classmethod
@@ -125,6 +150,7 @@ class Receipt(SQLModel, table=True):
class Account(SQLModel, table=True):
__tablename__ = "account"
chain_id: str = Field(primary_key=True)
address: str = Field(primary_key=True)
balance: int = 0
nonce: int = 0

View File

@@ -67,11 +67,11 @@ class MintFaucetRequest(BaseModel):
@router.get("/head", summary="Get current chain head")
async def get_head() -> Dict[str, Any]:
async def get_head(chain_id: str = "ait-devnet") -> Dict[str, Any]:
metrics_registry.increment("rpc_get_head_total")
start = time.perf_counter()
with session_scope() as session:
result = session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first()
result = session.exec(select(Block).where(Block.chain_id == chain_id).order_by(Block.height.desc()).limit(1)).first()
if result is None:
metrics_registry.increment("rpc_get_head_not_found_total")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no blocks yet")
@@ -161,11 +161,11 @@ async def get_blocks_range(start: int, end: int) -> Dict[str, Any]:
@router.get("/tx/{tx_hash}", summary="Get transaction by hash")
async def get_transaction(tx_hash: str) -> Dict[str, Any]:
async def get_transaction(tx_hash: str, chain_id: str = "ait-devnet") -> Dict[str, Any]:
metrics_registry.increment("rpc_get_transaction_total")
start = time.perf_counter()
with session_scope() as session:
tx = session.exec(select(Transaction).where(Transaction.tx_hash == tx_hash)).first()
tx = session.exec(select(Transaction).where(Transaction.chain_id == chain_id).where(Transaction.tx_hash == tx_hash)).first()
if tx is None:
metrics_registry.increment("rpc_get_transaction_not_found_total")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="transaction not found")
@@ -304,7 +304,7 @@ async def get_balance(address: str) -> Dict[str, Any]:
metrics_registry.increment("rpc_get_balance_total")
start = time.perf_counter()
with session_scope() as session:
account = session.get(Account, address)
account = session.get(Account, (chain_id, address))
if account is None:
metrics_registry.increment("rpc_get_balance_empty_total")
metrics_registry.observe("rpc_get_balance_duration_seconds", time.perf_counter() - start)
@@ -332,7 +332,7 @@ async def get_address_details(address: str, limit: int = 20, offset: int = 0) ->
with session_scope() as session:
# Get account info
account = session.get(Account, address)
account = session.get(Account, (chain_id, address))
# Get transactions where this address is sender or recipient
sent_txs = session.exec(
@@ -399,6 +399,7 @@ async def get_addresses(limit: int = 20, offset: int = 0, min_balance: int = 0)
# Get addresses with balance >= min_balance
addresses = session.exec(
select(Account)
.where(Account.chain_id == chain_id)
.where(Account.balance >= min_balance)
.order_by(Account.balance.desc())
.offset(offset)
@@ -406,7 +407,7 @@ async def get_addresses(limit: int = 20, offset: int = 0, min_balance: int = 0)
).all()
# Get total count
total_count = len(session.exec(select(Account).where(Account.balance >= min_balance)).all())
total_count = len(session.exec(select(Account).where(Account.chain_id == chain_id).where(Account.balance >= min_balance)).all())
if not addresses:
metrics_registry.increment("rpc_get_addresses_empty_total")
@@ -421,8 +422,8 @@ async def get_addresses(limit: int = 20, offset: int = 0, min_balance: int = 0)
address_list = []
for addr in addresses:
# Get transaction counts
sent_count = session.exec(select(func.count()).select_from(Transaction).where(Transaction.sender == addr.address)).one()
received_count = session.exec(select(func.count()).select_from(Transaction).where(Transaction.recipient == addr.address)).one()
sent_count = session.exec(select(func.count()).select_from(Transaction).where(Transaction.chain_id == chain_id).where(Transaction.sender == addr.address)).one()
received_count = session.exec(select(func.count()).select_from(Transaction).where(Transaction.chain_id == chain_id).where(Transaction.recipient == addr.address)).one()
address_list.append({
"address": addr.address,
@@ -445,13 +446,13 @@ async def get_addresses(limit: int = 20, offset: int = 0, min_balance: int = 0)
@router.post("/sendTx", summary="Submit a new transaction")
async def send_transaction(request: TransactionRequest) -> Dict[str, Any]:
async def send_transaction(request: TransactionRequest, chain_id: str = "ait-devnet") -> Dict[str, Any]:
metrics_registry.increment("rpc_send_tx_total")
start = time.perf_counter()
mempool = get_mempool()
tx_dict = request.model_dump()
try:
tx_hash = mempool.add(tx_dict)
tx_hash = mempool.add(tx_dict, chain_id=chain_id)
except ValueError as e:
metrics_registry.increment("rpc_send_tx_rejected_total")
raise HTTPException(status_code=400, detail=str(e))
@@ -484,7 +485,7 @@ async def send_transaction(request: TransactionRequest) -> Dict[str, Any]:
@router.post("/submitReceipt", summary="Submit receipt claim transaction")
async def submit_receipt(request: ReceiptSubmissionRequest) -> Dict[str, Any]:
async def submit_receipt(request: ReceiptSubmissionRequest, chain_id: str = "ait-devnet") -> Dict[str, Any]:
metrics_registry.increment("rpc_submit_receipt_total")
start = time.perf_counter()
tx_payload = {
@@ -497,7 +498,7 @@ async def submit_receipt(request: ReceiptSubmissionRequest) -> Dict[str, Any]:
}
tx_request = TransactionRequest.model_validate(tx_payload)
try:
response = await send_transaction(tx_request)
response = await send_transaction(tx_request, chain_id)
metrics_registry.increment("rpc_submit_receipt_success_total")
return response
except HTTPException:
@@ -530,13 +531,13 @@ async def estimate_fee(request: EstimateFeeRequest) -> Dict[str, Any]:
@router.post("/admin/mintFaucet", summary="Mint devnet funds to an address")
async def mint_faucet(request: MintFaucetRequest) -> Dict[str, Any]:
async def mint_faucet(request: MintFaucetRequest, chain_id: str = "ait-devnet") -> Dict[str, Any]:
metrics_registry.increment("rpc_mint_faucet_total")
start = time.perf_counter()
with session_scope() as session:
account = session.get(Account, request.address)
account = session.get(Account, (chain_id, request.address))
if account is None:
account = Account(address=request.address, balance=request.amount)
account = Account(chain_id=chain_id, address=request.address, balance=request.amount)
session.add(account)
else:
account.balance += request.amount
@@ -559,7 +560,7 @@ class ImportBlockRequest(BaseModel):
@router.post("/importBlock", summary="Import a block from a remote peer")
async def import_block(request: ImportBlockRequest) -> Dict[str, Any]:
async def import_block(request: ImportBlockRequest, chain_id: str = "ait-devnet") -> Dict[str, Any]:
from ..sync import ChainSync, ProposerSignatureValidator
from ..config import settings as cfg
@@ -570,7 +571,7 @@ async def import_block(request: ImportBlockRequest) -> Dict[str, Any]:
validator = ProposerSignatureValidator(trusted_proposers=trusted if trusted else None)
sync = ChainSync(
session_factory=session_scope,
chain_id=cfg.chain_id,
chain_id=chain_id,
max_reorg_depth=cfg.max_reorg_depth,
validator=validator,
validate_signatures=cfg.sync_validate_signatures,
@@ -598,10 +599,10 @@ async def import_block(request: ImportBlockRequest) -> Dict[str, Any]:
@router.get("/syncStatus", summary="Get chain sync status")
async def sync_status() -> Dict[str, Any]:
async def sync_status(chain_id: str = "ait-devnet") -> Dict[str, Any]:
from ..sync import ChainSync
from ..config import settings as cfg
metrics_registry.increment("rpc_sync_status_total")
sync = ChainSync(session_factory=session_scope, chain_id=cfg.chain_id)
sync = ChainSync(session_factory=session_scope, chain_id=chain_id)
return sync.get_sync_status()

View File

@@ -140,14 +140,14 @@ class ChainSync:
# Get our chain head
our_head = session.exec(
select(Block).order_by(Block.height.desc()).limit(1)
select(Block).where(Block.chain_id == self._chain_id).order_by(Block.height.desc()).limit(1)
).first()
our_height = our_head.height if our_head else -1
# Case 1: Block extends our chain directly
if height == our_height + 1:
parent_exists = session.exec(
select(Block).where(Block.hash == parent_hash)
select(Block).where(Block.chain_id == self._chain_id).where(Block.hash == parent_hash)
).first()
if parent_exists or (height == 0 and parent_hash == "0x00"):
result = self._append_block(session, block_data, transactions)
@@ -159,7 +159,7 @@ class ChainSync:
if height <= our_height:
# Check if it's a fork at a previous height
existing_at_height = session.exec(
select(Block).where(Block.height == height)
select(Block).where(Block.chain_id == self._chain_id).where(Block.height == height)
).first()
if existing_at_height and existing_at_height.hash != block_hash:
# Fork detected — resolve by longest chain rule
@@ -191,6 +191,7 @@ class ChainSync:
tx_count = len(transactions)
block = Block(
chain_id=self._chain_id,
height=block_data["height"],
hash=block_data["hash"],
parent_hash=block_data["parent_hash"],
@@ -205,6 +206,7 @@ class ChainSync:
if transactions:
for tx_data in transactions:
tx = Transaction(
chain_id=self._chain_id,
tx_hash=tx_data.get("tx_hash", ""),
block_height=block_data["height"],
sender=tx_data.get("sender", ""),
@@ -271,14 +273,14 @@ class ChainSync:
# Perform reorg: remove blocks from fork_height onwards, then append
blocks_to_remove = session.exec(
select(Block).where(Block.height >= fork_height).order_by(Block.height.desc())
select(Block).where(Block.chain_id == self._chain_id).where(Block.height >= fork_height).order_by(Block.height.desc())
).all()
removed_count = 0
for old_block in blocks_to_remove:
# Remove transactions in the block
old_txs = session.exec(
select(Transaction).where(Transaction.block_height == old_block.height)
select(Transaction).where(Transaction.chain_id == self._chain_id).where(Transaction.block_height == old_block.height)
).all()
for tx in old_txs:
session.delete(tx)
@@ -304,11 +306,11 @@ class ChainSync:
"""Get current sync status and metrics."""
with self._session_factory() as session:
head = session.exec(
select(Block).order_by(Block.height.desc()).limit(1)
select(Block).where(Block.chain_id == self._chain_id).order_by(Block.height.desc()).limit(1)
).first()
total_blocks = session.exec(select(Block)).all()
total_txs = session.exec(select(Transaction)).all()
total_blocks = session.exec(select(Block).where(Block.chain_id == self._chain_id)).all()
total_txs = session.exec(select(Transaction).where(Transaction.chain_id == self._chain_id)).all()
return {
"chain_id": self._chain_id,