diff --git a/.env.example b/.env.example index 925008d0..e96b9e71 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,3 @@ NEXT_PUBLIC_BLOCK_EXPLORER_URL=https://base.blockscout.com TIPS_UI_RPC_URL=http://localhost:8545 -TIPS_UI_AWS_REGION=us-east-1 -TIPS_UI_S3_BUCKET_NAME=tips -TIPS_UI_S3_CONFIG_TYPE=manual -TIPS_UI_S3_ENDPOINT=http://localhost:7000 -TIPS_UI_S3_ACCESS_KEY_ID=minioadmin -TIPS_UI_S3_SECRET_ACCESS_KEY=minioadmin +TIPS_UI_AUDIT_RPC_URL=http://localhost:8080 diff --git a/bun.lock b/bun.lock index 929dbf55..833adbd7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "ui", "dependencies": { - "@aws-sdk/client-s3": "3.940.0", + "@aws-sdk/client-s3": "^3.1071.0", "next": "16.0.7", "react": "19.2.1", "react-dom": "19.2.1", @@ -41,69 +41,43 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.940.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/middleware-bucket-endpoint": "3.936.0", "@aws-sdk/middleware-expect-continue": "3.936.0", "@aws-sdk/middleware-flexible-checksums": "3.940.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-location-constraint": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-sdk-s3": "3.940.0", "@aws-sdk/middleware-ssec": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/signature-v4-multi-region": "3.940.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-blob-browser": "^4.2.6", "@smithy/hash-node": "^4.2.5", "@smithy/hash-stream-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/md5-js": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-Wi4qnBT6shRRMXuuTgjMFTU5mu2KFWisgcigEMPptjPGUtJvBVi4PTGgS64qsLoUk/obqDAyOBOfEtRZ2ddC2w=="], + "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.22", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-qh0fG/RtrFztst4+vn1HZehAvAhr5Jlq/WMP7e5KvvfF16oNVBc9CDNVdxdm19vzOY2x0qiDMFCRjhxQAusGWQ=="], - "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-SdqJGWVhmIURvCSgkDditHRO+ozubwZk9aCX9MK8qxyOndhobCndW1ozl3hX9psvMAo9Q4bppjuqy/GHWpjB+A=="], + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1071.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.22", "@aws-sdk/credential-provider-node": "^3.972.57", "@aws-sdk/middleware-flexible-checksums": "^3.974.32", "@aws-sdk/middleware-sdk-s3": "^3.972.53", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-BqsqkaU/FztbQnq5Aw0BK6/weQgwnC3n2w19M7CjEjRHscr5dZU8+ihi7PIY6UMW9RkJrzUUEmaoHQrIVScFYQ=="], - "@aws-sdk/core": ["@aws-sdk/core@3.940.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws-sdk/xml-builder": "3.930.0", "@smithy/core": "^3.18.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-KsGD2FLaX5ngJao1mHxodIVU9VYd1E8810fcYiGwO1PFHDzf5BEkp6D9IdMeQwT8Q6JLYtiiT1Y/o3UCScnGoA=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.22", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@aws-sdk/xml-builder": "^3.972.30", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-YofH63shc6YRdXjz80BJkpJW+Bkn0Cuu2dn4Rv7s9G2Idt58tgtzQEWxrR2xVljlVfIBeUjPuULnSVYLke3sUQ=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.940.0", "", { "dependencies": { "@aws-sdk/core": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-/G3l5/wbZYP2XEQiOoIkRJmlv15f1P3MSd1a0gz27lHEMrOJOGq66rF1Ca4OJLzapWt3Fy9BPrZAepoAX11kMw=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.48", "", { "dependencies": { "@aws-sdk/core": "^3.974.22", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-h6FEC95fbexUd6zxm4PdgS82bTcI2PRtUb2ZwMipb/Xr8bPwtf0G8rBo2jp7NA24Mbx2JA8/WingiYpA9RCCyw=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.940.0", "", { "dependencies": { "@aws-sdk/core": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-dOrc03DHElNBD6N9Okt4U0zhrG4Wix5QUBSZPr5VN8SvmjD9dkrrxOkkJaMCl/bzrW7kbQEp7LuBdbxArMmOZQ=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.22", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-lJO3OLpjvz5m/RSBQmsG/CEUGsvCy5ruxKwPQaOCqxqCMuyYT2BZwQUTDZVVwqQ9LrZKuK24JSa6r31hL/tvkg=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.940.0", "", { "dependencies": { "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-env": "3.940.0", "@aws-sdk/credential-provider-http": "3.940.0", "@aws-sdk/credential-provider-login": "3.940.0", "@aws-sdk/credential-provider-process": "3.940.0", "@aws-sdk/credential-provider-sso": "3.940.0", "@aws-sdk/credential-provider-web-identity": "3.940.0", "@aws-sdk/nested-clients": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-gn7PJQEzb/cnInNFTOaDoCN/hOKqMejNmLof1W5VW95Qk0TPO52lH8R4RmJPnRrwFMswOWswTOpR1roKNLIrcw=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.22", "@aws-sdk/credential-provider-env": "^3.972.48", "@aws-sdk/credential-provider-http": "^3.972.50", "@aws-sdk/credential-provider-login": "^3.972.54", "@aws-sdk/credential-provider-process": "^3.972.48", "@aws-sdk/credential-provider-sso": "^3.972.54", "@aws-sdk/credential-provider-web-identity": "^3.972.54", "@aws-sdk/nested-clients": "^3.997.22", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-TBoF4buBGYhXjdZAryayY2TrkQj2B2KfE/msG4V53XCt+w0EhEwM2JRjx8p2grJ2C6gtH5++SAwEvGMRdi0yyw=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.940.0", "", { "dependencies": { "@aws-sdk/core": "3.940.0", "@aws-sdk/nested-clients": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-fOKC3VZkwa9T2l2VFKWRtfHQPQuISqqNl35ZhcXjWKVwRwl/o7THPMkqI4XwgT2noGa7LLYVbWMwnsgSsBqglg=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.22", "@aws-sdk/nested-clients": "^3.997.22", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-hBWI3wZTdTGiuMfmPts6AWbAjFfRniOQnqx68tc2cQvRKWawFbN9wkLOVPWM1FAOyowZU73mC6Fi+rHSHNyLFw=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.940.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.940.0", "@aws-sdk/credential-provider-http": "3.940.0", "@aws-sdk/credential-provider-ini": "3.940.0", "@aws-sdk/credential-provider-process": "3.940.0", "@aws-sdk/credential-provider-sso": "3.940.0", "@aws-sdk/credential-provider-web-identity": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-M8NFAvgvO6xZjiti5kztFiAYmSmSlG3eUfr4ZHSfXYZUA/KUdZU/D6xJyaLnU8cYRWBludb6K9XPKKVwKfqm4g=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.57", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.48", "@aws-sdk/credential-provider-http": "^3.972.50", "@aws-sdk/credential-provider-ini": "^3.972.55", "@aws-sdk/credential-provider-process": "^3.972.48", "@aws-sdk/credential-provider-sso": "^3.972.54", "@aws-sdk/credential-provider-web-identity": "^3.972.54", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-u6dClpzNdWf1HGWz4wwhdXi1wiOofCLniM9S4BQQGlLAN9TW7VB+ld5V533GdKrYMaFeBGFqKnj0JCYvynLqwQ=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.940.0", "", { "dependencies": { "@aws-sdk/core": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-pILBzt5/TYCqRsJb7vZlxmRIe0/T+FZPeml417EK75060ajDGnVJjHcuVdLVIeKoTKm9gmJc9l45gon6PbHyUQ=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.48", "", { "dependencies": { "@aws-sdk/core": "^3.974.22", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-w6VZwojPt12WnEkAUy6Nu4K6sWCbBmR7QX390b0nE6vRvkXbrYr9Lq9VySGkfjiMjpUA87op+J4EgvRmtWIDoQ=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.940.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.940.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/token-providers": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-q6JMHIkBlDCOMnA3RAzf8cGfup+8ukhhb50fNpghMs1SNBGhanmaMbZSgLigBRsPQW7fOk2l8jnzdVLS+BB9Uw=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.22", "@aws-sdk/nested-clients": "^3.997.22", "@aws-sdk/token-providers": "3.1071.0", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-23uZpIpF2SIFDCa1fcWa202tK4gGeyvX6GIIAjiB8WBsvsVRBMnJ/7dCxHzxf7eZT7GToJg837LDIBnZsl/VUg=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.940.0", "", { "dependencies": { "@aws-sdk/core": "3.940.0", "@aws-sdk/nested-clients": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-9QLTIkDJHHaYL0nyymO41H8g3ui1yz6Y3GmAN1gYQa6plXisuFBnGAbmKVj7zNvjWaOKdF0dV3dd3AFKEDoJ/w=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.22", "@aws-sdk/nested-clients": "^3.997.22", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-0Iv5QttS6wcATlodYKgvQj6B9Db51rx7NU9fqu0PoLeS4BIgdYMc/QK4smwLwpm5RFrs02V/eLyEFp3FklvlNQ=="], - "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-XLSVVfAorUxZh6dzF+HTOp4R1B5EQcdpGcPliWr0KUj2jukgjZEcqbBmjyMF/p9bmyQsONX80iURF1HLAlW0qg=="], + "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.32", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.7", "tslib": "^2.6.2" } }, "sha512-KhuzFMzUbb3oEj43CdPDbEJ/RG/RkErkmXk3J/LE8OPFNvkCn8PYPMpjOLgzAzvxBacsSyytdWf+R50q0alJ4w=="], - "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Eb4ELAC23bEQLJmUMYnPWcjD3FZIsmz2svDiXEcxRkQU9r7NRID7pM7C5NPH94wOfiCk0b2Y8rVyFXW0lGQwbA=="], + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.53", "", { "dependencies": { "@aws-sdk/core": "^3.974.22", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-keWp6Z5cEIJzPwoCf/WRm0ceAeephPDDivhRsK/xXs2ZYXyypJ2/DL9G1IR0bz/s+iZC0EgzmFV4r7rlvLlxQQ=="], - "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.940.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/is-array-buffer": "^4.2.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-WdsxDAVj5qaa5ApAP+JbpCOMHFGSmzjs2Y2OBSbWPeR9Ew7t/Okj+kUub94QJPsgzhvU1/cqNejhsw5VxeFKSQ=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.22", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.22", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-4IwtcYSxEIVw5hcp8ogq0CMbFNZFw7jJUetpfFUhFFeqsa1K8j2Ihg2hnxLyOp3stMZnXda6VzOmPi1AFZQXcg=="], - "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.35", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg=="], - "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-SCMPenDtQMd9o5da9JzkHz838w3327iqXk3cbNnXWqnNRx6unyW8FL0DZ84gIY12kAyVHz5WEqlWuekc15ehfw=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1071.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.22", "@aws-sdk/nested-clients": "^3.997.22", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-4LDW2Qob6LoLFuqYSYZq2AyTE9koSE9+i+n5UZcm10GpmQOK0zRD9L4uYlzItiTKksIWgC/qMFChAi3RvKYtMg=="], - "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw=="], + "@aws-sdk/types": ["@aws-sdk/types@3.973.13", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg=="], - "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws/lambda-invoke-store": "^0.2.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA=="], + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.8", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g=="], - "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.940.0", "", { "dependencies": { "@aws-sdk/core": "3.940.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/core": "^3.18.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-JYkLjgS1wLoKHJ40G63+afM1ehmsPsjcmrHirKh8+kSCx4ip7+nL1e/twV4Zicxr8RJi9Y0Ahq5mDvneilDDKQ=="], - - "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-/GLC9lZdVp05ozRik5KsuODR/N7j+W+2TbfdFL3iS+7un+gnP6hC8RDOZd6WhpZp7drXQ9guKiTAxkZQwzS8DA=="], - - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.940.0", "", { "dependencies": { "@aws-sdk/core": "3.940.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@smithy/core": "^3.18.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-nJbLrUj6fY+l2W2rIB9P4Qvpiy0tnTdg/dmixRxrU1z3e8wBdspJlyE+AZN4fuVbeL6rrRrO/zxQC1bB3cw5IA=="], - - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-x0mdv6DkjXqXEcQj3URbCltEzW6hoy/1uIL+i8gExP6YKrnhiZ7SzuB4gPls2UOpK5UqLiqXjhRLfBb1C9i4Dw=="], - - "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw=="], - - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.940.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ugHZEoktD/bG6mdgmhzLDjMP2VrYRAUPRPF1DpCyiZexkH7DCU7XrSJyXMvkcf0DHV+URk0q2sLf/oqn1D2uYw=="], - - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.940.0", "", { "dependencies": { "@aws-sdk/core": "3.940.0", "@aws-sdk/nested-clients": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-k5qbRe/ZFjW9oWEdzLIa2twRVIEx7p/9rutofyrRysrtEnYh3HAWCngAnwbgKMoiwa806UzcTRx0TjyEpnKcCg=="], - - "@aws-sdk/types": ["@aws-sdk/types@3.936.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg=="], - - "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA=="], - - "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-endpoints": "^3.2.5", "tslib": "^2.6.2" } }, "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w=="], - - "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], - - "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw=="], - - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.940.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/types": "3.936.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-dlD/F+L/jN26I8Zg5x0oDGJiA+/WEQmnSE27fi5ydvYnpfQLwThtQo9SsNS47XSR/SOULaaoC9qx929rZuo74A=="], - - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.930.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.30", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], @@ -211,113 +185,31 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodable/entities": ["@nodable/entities@2.2.0", "", {}, "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg=="], + "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], - "@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="], - - "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="], - - "@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="], - - "@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="], - - "@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="], - - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="], - - "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="], - - "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="], - - "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="], - - "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="], - - "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="], - - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="], - - "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="], - - "@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="], - - "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="], - - "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="], - - "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], - - "@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="], - - "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="], - - "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="], - - "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="], + "@smithy/core": ["@smithy/core@3.25.1", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-zpDbpXBCBsxfLtG2GEUyfgvHvSFrw5CwDZSNzL0v52gx/c3oPlPbm+7W7num8xs6vyiUBn+bvYPHcQDOXZynCQ=="], - "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.1", "", { "dependencies": { "@smithy/core": "^3.25.1", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ=="], - "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.5.1", "", { "dependencies": { "@smithy/core": "^3.25.1", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw=="], - "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="], + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.8.1", "", { "dependencies": { "@smithy/core": "^3.25.1", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw=="], - "@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="], + "@smithy/signature-v4": ["@smithy/signature-v4@5.5.1", "", { "dependencies": { "@smithy/core": "^3.25.1", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ=="], - "@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="], + "@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="], - "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="], + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="], - - "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="], - - "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="], - - "@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="], - - "@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="], - - "@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="], - - "@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="], - - "@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="], - - "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="], - - "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="], - - "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], - - "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="], - - "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="], - - "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="], - - "@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="], - - "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], - - "@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="], - - "@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="], - - "@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="], - - "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], - - "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], - - "@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="], - - "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -359,6 +251,8 @@ "abitype": ["abitype@1.1.0", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A=="], + "anynum": ["anynum@1.0.0", "", {}, "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], "caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="], @@ -373,7 +267,9 @@ "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], + + "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -413,6 +309,8 @@ "ox": ["ox@0.9.6", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.9", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-8SuCbHPvv2eZLYXrNmC0EC12rdzXQLdhnOMlHDW2wiCPLxBrOOJwX5L5E61by+UjTPOryqQiRSnjIKCI+GykKg=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], @@ -429,7 +327,7 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "strnum": ["strnum@2.2.2", "", {}, "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA=="], + "strnum": ["strnum@2.4.0", "", { "dependencies": { "anynum": "^1.0.0" } }, "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -447,11 +345,7 @@ "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - - "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - - "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], @@ -466,17 +360,5 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - - "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - - "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], } } diff --git a/next.config.ts b/next.config.ts index 68a6c64d..ee6198d2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", + outputFileTracingRoot: __dirname, + turbopack: { + root: __dirname, + }, }; export default nextConfig; diff --git a/package.json b/package.json index 54d105ce..34850080 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "format": "biome format --write" }, "dependencies": { - "@aws-sdk/client-s3": "3.940.0", + "@aws-sdk/client-s3": "^3.1071.0", "next": "16.0.7", "react": "19.2.1", "react-dom": "19.2.1", diff --git a/src/app/api/block/[hash]/route.ts b/src/app/api/block/[hash]/route.ts index 37d01449..4c04becf 100644 --- a/src/app/api/block/[hash]/route.ts +++ b/src/app/api/block/[hash]/route.ts @@ -2,13 +2,32 @@ import { type NextRequest, NextResponse } from "next/server"; import { type Block, createPublicClient, type Hash, http } from "viem"; import { mainnet } from "viem/chains"; import { - type BlockData, - type BlockTransaction, - cacheBlockData, + bundleHistoryFromAuditEvents, + getAuditEventsByBlockNumber, + getAuditEventsByTransactionHash, + isAuditConfigured, + meterBundleResponseFromAuditEvent, + transactionMetadataFromAuditEvents, +} from "@/lib/audit-events"; +import { calculateTransactionFee } from "@/lib/explorer-format"; +import { getTransactionReceiptSummaries } from "@/lib/receipts"; +import { getBlockFromCache, getBundleHistory, getTransactionMetadataByHash, } from "@/lib/s3"; +import type { + BlockData, + BlockTransaction, + BundleEvent, +} from "@/lib/transaction-data"; + +const BLOCK_EVENT_TYPES = new Set([ + "BUILDER_FLASHBLOCK_STARTED", + "BUILDER_FLASHBLOCK_PUBLISHED", + "BUILDER_FLASHBLOCK_BUILD_STOPPED", + "BUILDER_PAYLOAD_FINALIZED", +]); function serializeBlockData(block: BlockData) { return { @@ -17,6 +36,7 @@ function serializeBlockData(block: BlockData) { timestamp: block.timestamp.toString(), gasUsed: block.gasUsed.toString(), gasLimit: block.gasLimit.toString(), + baseFeePerGas: block.baseFeePerGas?.toString() ?? null, transactions: block.transactions.map((tx) => { let metering: { transaction: unknown; bundle: unknown } | null = null; if (tx.meterBundleResponse) { @@ -26,14 +46,23 @@ function serializeBlockData(block: BlockData) { } return { hash: tx.hash, + blockHash: tx.blockHash, + blockNumber: tx.blockNumber.toString(), + blockTimestamp: tx.blockTimestamp.toString(), from: tx.from, to: tx.to, + input: tx.input, + value: tx.value.toString(), gasLimit: tx.gasLimit.toString(), + gasUsed: tx.gasUsed?.toString() ?? null, + effectiveGasPrice: tx.effectiveGasPrice?.toString() ?? null, + transactionFee: tx.transactionFee?.toString() ?? null, bundleId: tx.bundleId, index: tx.index, metering, }; }), + eventHistory: block.eventHistory ?? [], }; } @@ -78,7 +107,7 @@ async function fetchBlockFromRpcByNumber( } } -async function enrichTransactionWithBundleData(txHash: string): Promise<{ +async function enrichTransactionFromS3(txHash: string): Promise<{ bundleId: string | null; meterBundleResponse: Record | null; }> { @@ -94,7 +123,7 @@ async function enrichTransactionWithBundleData(txHash: string): Promise<{ } const receivedEvent = bundleHistory.history.find( - (e) => e.event === "Received", + (event) => event.event === "Received", ); if (!receivedEvent?.data?.bundle?.meter_bundle_response) { return { bundleId, meterBundleResponse: null }; @@ -107,19 +136,91 @@ async function enrichTransactionWithBundleData(txHash: string): Promise<{ }; } +async function enrichTransactionWithBundleData(txHash: string): Promise<{ + bundleId: string | null; + meterBundleResponse: Record | null; +}> { + if (!isAuditConfigured()) { + return enrichTransactionFromS3(txHash); + } + + try { + const events = await getAuditEventsByTransactionHash(txHash); + const metadata = transactionMetadataFromAuditEvents(events); + const bundleId = metadata?.bundle_ids[0] ?? null; + if (bundleId !== null) { + const accepted = events.find( + (event) => event.event_type === "SIMULATION_SUCCEEDED", + ); + return { + bundleId, + meterBundleResponse: accepted + ? (meterBundleResponseFromAuditEvent(accepted) as unknown as Record< + string, + unknown + >) + : null, + }; + } + } catch { + // Audit is an optional read path; retain the S3-backed behavior on errors. + } + + return enrichTransactionFromS3(txHash); +} + +async function getBlockEventHistory( + hash: Hash, + number: bigint, +): Promise { + if (!isAuditConfigured()) { + return []; + } + + try { + return ( + bundleHistoryFromAuditEvents( + hash, + (await getAuditEventsByBlockNumber(Number(number))).filter((event) => + BLOCK_EVENT_TYPES.has(event.event_type), + ), + )?.history ?? [] + ); + } catch (error) { + console.error("Failed to fetch block event history from audit RPC:", error); + return []; + } +} + async function buildAndCacheBlockData( rpcBlock: Block, hash: Hash, number: bigint, ): Promise { + const receiptByHash = await getTransactionReceiptSummaries( + rpcBlock.transactions.map((tx) => tx.hash), + ); const transactions: BlockTransaction[] = await Promise.all( rpcBlock.transactions.map(async (tx, index) => { const enriched = await enrichTransactionWithBundleData(tx.hash); + const receipt = receiptByHash.get(tx.hash); + const effectiveGasPrice = receipt?.effectiveGasPrice ?? null; return { hash: tx.hash, + blockHash: hash, + blockNumber: number, + blockTimestamp: rpcBlock.timestamp, from: tx.from, to: tx.to, + input: tx.input, + value: tx.value, gasLimit: tx.gas, + gasUsed: receipt?.gasUsed ?? null, + effectiveGasPrice, + transactionFee: calculateTransactionFee( + receipt?.gasUsed ?? null, + effectiveGasPrice, + ), bundleId: enriched.bundleId, index, meterBundleResponse: enriched.meterBundleResponse, @@ -132,65 +233,16 @@ async function buildAndCacheBlockData( number, timestamp: rpcBlock.timestamp, transactions, + eventHistory: await getBlockEventHistory(hash, number), gasUsed: rpcBlock.gasUsed, gasLimit: rpcBlock.gasLimit, + baseFeePerGas: rpcBlock.baseFeePerGas ?? null, cachedAt: Date.now(), }; - await cacheBlockData(blockData); - return blockData; } -// On OP Stack, the first transaction (index 0) is the L1 attributes deposit transaction. -// This is not a perfect check (ideally we'd check tx.type === 'deposit' or type 0x7e), -// but sufficient for filtering out system transactions that don't need simulation data. -function isSystemTransaction(tx: BlockTransaction): boolean { - return tx.index === 0; -} - -async function refetchMissingTransactionSimulations( - block: BlockData, -): Promise<{ updatedBlock: BlockData; hasUpdates: boolean }> { - const transactionsToRefetch = block.transactions.filter( - (tx) => tx.bundleId === null && !isSystemTransaction(tx), - ); - - if (transactionsToRefetch.length === 0) { - return { updatedBlock: block, hasUpdates: false }; - } - - const refetchResults = await Promise.all( - transactionsToRefetch.map(async (tx) => { - const enriched = await enrichTransactionWithBundleData(tx.hash); - return { hash: tx.hash, ...enriched }; - }), - ); - - let hasUpdates = false; - const updatedTransactions = block.transactions.map((tx) => { - const refetchResult = refetchResults.find((r) => r.hash === tx.hash); - if (refetchResult && refetchResult.bundleId !== null) { - hasUpdates = true; - return { - ...tx, - bundleId: refetchResult.bundleId, - meterBundleResponse: refetchResult.meterBundleResponse, - }; - } - return tx; - }); - - return { - updatedBlock: { - ...block, - transactions: updatedTransactions, - cachedAt: hasUpdates ? Date.now() : block.cachedAt, - }, - hasUpdates, - }; -} - export async function GET( _request: NextRequest, { params }: { params: Promise<{ hash: string }> }, @@ -205,17 +257,6 @@ export async function GET( return NextResponse.json({ error: "Block not found" }, { status: 404 }); } - // Check cache by resolved hash - const cachedBlock = await getBlockFromCache(rpcBlock.hash); - if (cachedBlock) { - const { updatedBlock, hasUpdates } = - await refetchMissingTransactionSimulations(cachedBlock); - if (hasUpdates) { - await cacheBlockData(updatedBlock); - } - return NextResponse.json(serializeBlockData(updatedBlock)); - } - const blockData = await buildAndCacheBlockData( rpcBlock, rpcBlock.hash, @@ -224,20 +265,12 @@ export async function GET( return NextResponse.json(serializeBlockData(blockData)); } - const cachedBlock = await getBlockFromCache(identifier); - if (cachedBlock) { - const { updatedBlock, hasUpdates } = - await refetchMissingTransactionSimulations(cachedBlock); - - if (hasUpdates) { - await cacheBlockData(updatedBlock); - } - - return NextResponse.json(serializeBlockData(updatedBlock)); - } - const rpcBlock = await fetchBlockFromRpc(identifier); if (!rpcBlock || !rpcBlock.hash || !rpcBlock.number) { + const cachedBlock = await getBlockFromCache(identifier); + if (cachedBlock) { + return NextResponse.json(serializeBlockData(cachedBlock)); + } return NextResponse.json({ error: "Block not found" }, { status: 404 }); } diff --git a/src/app/api/blocks/route.ts b/src/app/api/blocks/route.ts index 919256a5..4dbb22f8 100644 --- a/src/app/api/blocks/route.ts +++ b/src/app/api/blocks/route.ts @@ -1,105 +1,35 @@ -import { NextResponse } from "next/server"; - -const RPC_URL = process.env.TIPS_UI_RPC_URL || "http://localhost:8545"; - -export interface BlockSummary { - hash: string; - number: number; - timestamp: number; - transactionCount: number; -} - -export interface BlocksResponse { - blocks: BlockSummary[]; -} - -async function fetchLatestBlockNumber(): Promise { +import { type NextRequest, NextResponse } from "next/server"; +import { + BlockListUnavailableError, + InvalidBlockListQueryError, + listBlocks, + parseBlockListQuery, +} from "@/lib/block-list"; + +export type { + BlockSummary, + BlocksPage, + BlocksResponse, +} from "@/lib/block-list"; + +export async function GET(request: NextRequest) { try { - const response = await fetch(RPC_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_blockNumber", - params: [], - id: 1, - }), - }); - - const data = await response.json(); - if (data.error || !data.result) { - return null; - } - - return parseInt(data.result, 16); + const query = parseBlockListQuery(request.nextUrl.searchParams); + return NextResponse.json(await listBlocks(query)); } catch (error) { - console.error("Failed to fetch latest block number:", error); - return null; - } -} - -async function fetchBlockByNumber( - blockNumber: number, -): Promise { - try { - const response = await fetch(RPC_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getBlockByNumber", - params: [`0x${blockNumber.toString(16)}`, false], - id: 1, - }), - }); - - const data = await response.json(); - if (data.error || !data.result) { - return null; + if (error instanceof InvalidBlockListQueryError) { + return NextResponse.json({ error: error.message }, { status: 400 }); } - const block = data.result; - return { - hash: block.hash, - number: parseInt(block.number, 16), - timestamp: parseInt(block.timestamp, 16), - transactionCount: block.transactions?.length ?? 0, - }; - } catch (error) { - console.error(`Failed to fetch block ${blockNumber}:`, error); - return null; - } -} - -export async function GET() { - try { - const latestBlockNumber = await fetchLatestBlockNumber(); - if (latestBlockNumber === null) { - return NextResponse.json( - { error: "Failed to fetch latest block" }, - { status: 500 }, - ); - } - - const blockNumbers = Array.from( - { length: 10 }, - (_, i) => latestBlockNumber - i, - ).filter((n) => n >= 0); - - const blocks = await Promise.all(blockNumbers.map(fetchBlockByNumber)); - - const validBlocks = blocks.filter( - (block): block is BlockSummary => block !== null, - ); - - const response: BlocksResponse = { blocks: validBlocks }; - - return NextResponse.json(response); - } catch (error) { - console.error("Error fetching blocks:", error); + console.error("Error fetching block list:", error); return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, + { + error: + error instanceof BlockListUnavailableError + ? "Block list unavailable" + : "Internal server error", + }, + { status: error instanceof BlockListUnavailableError ? 503 : 500 }, ); } } diff --git a/src/app/api/bundle/[hash]/route.ts b/src/app/api/bundle/[hash]/route.ts index 3d9e3f69..d15430ed 100644 --- a/src/app/api/bundle/[hash]/route.ts +++ b/src/app/api/bundle/[hash]/route.ts @@ -1,11 +1,96 @@ import { type NextRequest, NextResponse } from "next/server"; -import { type BundleEvent, getBundleHistory } from "@/lib/s3"; +import { createPublicClient, type Hash, http } from "viem"; +import { mainnet } from "viem/chains"; +import { + bundleHistoryFromAuditEvents, + getJoinedAuditEventsByBundle, + isAuditConfigured, +} from "@/lib/audit-events"; +import { getBundleHistory } from "@/lib/s3"; +import type { BundleEvent, BundleTransaction } from "@/lib/transaction-data"; + +const RPC_URL = process.env.TIPS_UI_RPC_URL || "http://localhost:8545"; + +const client = createPublicClient({ + chain: mainnet, + transport: http(RPC_URL), +}); export interface BundleHistoryResponse { hash: string; history: BundleEvent[]; } +function bigintToHex(value: bigint | null | undefined): string { + return value === null || value === undefined + ? "0x0" + : `0x${value.toString(16)}`; +} + +function numberToHex(value: number | null | undefined): string { + return value === null || value === undefined + ? "0x0" + : `0x${value.toString(16)}`; +} + +async function enrichBundleTransactionsFromRpc( + history: BundleEvent[], +): Promise { + const hashes = Array.from( + new Set( + history.flatMap( + (event) => + event.data.bundle?.txs.map((tx) => tx.hash).filter(Boolean) ?? [], + ), + ), + ); + const transactions = new Map(); + + await Promise.all( + hashes.map(async (hash) => { + try { + const tx = await client.getTransaction({ hash: hash as Hash }); + transactions.set(hash, { + signer: tx.from, + type: tx.typeHex ?? "", + chainId: numberToHex(tx.chainId), + nonce: numberToHex(tx.nonce), + gas: bigintToHex(tx.gas), + maxFeePerGas: bigintToHex(tx.maxFeePerGas ?? tx.gasPrice), + maxPriorityFeePerGas: bigintToHex(tx.maxPriorityFeePerGas), + to: tx.to, + value: bigintToHex(tx.value), + accessList: [...(tx.accessList ?? [])], + input: tx.input, + r: tx.r, + s: tx.s, + yParity: numberToHex(tx.yParity), + v: bigintToHex(tx.v), + hash: tx.hash, + }); + } catch (error) { + console.error(`Failed to fetch transaction ${hash} from RPC:`, error); + } + }), + ); + + return history.map((event) => { + if (!event.data.bundle) return event; + return { + ...event, + data: { + ...event.data, + bundle: { + ...event.data.bundle, + txs: event.data.bundle.txs.map( + (tx) => transactions.get(tx.hash) ?? tx, + ), + }, + }, + }; + }); +} + export async function GET( _request: NextRequest, { params }: { params: Promise<{ hash: string }> }, @@ -13,19 +98,19 @@ export async function GET( try { const { hash } = await params; - const bundle = await getBundleHistory(hash); + const bundle = await getAuditBundleHistory(hash); if (!bundle) { return NextResponse.json({ error: "Bundle not found" }, { status: 404 }); } - const history = bundle.history; + const history = await enrichBundleTransactionsFromRpc(bundle.history); history.sort((lhs, rhs) => lhs.data.timestamp < rhs.data.timestamp ? -1 : 1, ); const response: BundleHistoryResponse = { hash, - history: bundle.history, + history, }; return NextResponse.json(response); @@ -37,3 +122,17 @@ export async function GET( ); } } + +async function getAuditBundleHistory(hash: string) { + if (!isAuditConfigured()) { + return getBundleHistory(hash); + } + + try { + const events = await getJoinedAuditEventsByBundle(hash); + return bundleHistoryFromAuditEvents(hash, events) ?? getBundleHistory(hash); + } catch (error) { + console.error("Falling back to S3 bundle history:", error); + return getBundleHistory(hash); + } +} diff --git a/src/app/api/rejected/route.ts b/src/app/api/rejected/route.ts index aacc63a9..b4c243b0 100644 --- a/src/app/api/rejected/route.ts +++ b/src/app/api/rejected/route.ts @@ -1,9 +1,11 @@ import { NextResponse } from "next/server"; import { - getRejectedTransaction, - listRejectedTransactions, - type RejectedTransaction, -} from "@/lib/s3"; + getAuditRejectedTransactionEvents, + isAuditConfigured, + rejectedTransactionFromAuditEvent, +} from "@/lib/audit-events"; +import { getRejectedTransaction, listRejectedTransactions } from "@/lib/s3"; +import type { RejectedTransaction } from "@/lib/transaction-data"; export interface RejectedTransactionsResponse { transactions: RejectedTransaction[]; @@ -11,13 +13,11 @@ export interface RejectedTransactionsResponse { export async function GET() { try { - const summaries = await listRejectedTransactions(100); - - const transactions = ( - await Promise.all( - summaries.map((s) => getRejectedTransaction(s.blockNumber, s.txHash)), - ) - ).filter((tx): tx is RejectedTransaction => tx !== null); + const auditTransactions = await getAuditRejectedTransactions(); + const transactions = + auditTransactions.length > 0 + ? auditTransactions + : await getS3RejectedTransactions(); const response: RejectedTransactionsResponse = { transactions }; return NextResponse.json(response); @@ -29,3 +29,28 @@ export async function GET() { ); } } + +async function getAuditRejectedTransactions(): Promise { + if (!isAuditConfigured()) { + return []; + } + + try { + return (await getAuditRejectedTransactionEvents(100)) + .map(rejectedTransactionFromAuditEvent) + .filter((tx): tx is RejectedTransaction => tx !== null); + } catch (error) { + console.error("Falling back to S3 rejected transactions:", error); + return []; + } +} + +async function getS3RejectedTransactions(): Promise { + const summaries = await listRejectedTransactions(100); + const transactions = await Promise.all( + summaries.map((summary) => + getRejectedTransaction(summary.blockNumber, summary.txHash), + ), + ); + return transactions.filter((tx): tx is RejectedTransaction => tx !== null); +} diff --git a/src/app/api/txn/[hash]/route.ts b/src/app/api/txn/[hash]/route.ts index 44f68243..94989c55 100644 --- a/src/app/api/txn/[hash]/route.ts +++ b/src/app/api/txn/[hash]/route.ts @@ -1,38 +1,11 @@ import { type NextRequest, NextResponse } from "next/server"; import { - type BundleEvent, - getBundleHistory, - getTransactionMetadataByHash, -} from "@/lib/s3"; + InvalidTransactionHashError, + lookupTransaction, + type TransactionLookupResponse, +} from "@/lib/transaction-lookup"; -export interface TransactionEvent { - type: string; - data: { - bundle_id?: string; - transactions?: Array<{ - id: { - sender: string; - nonce: string; - hash: string; - }; - data: string; - }>; - transaction_ids?: Array<{ - sender: string; - nonce: string; - hash: string; - }>; - block_number?: number; - flashblock_index?: number; - block_hash?: string; - }; -} - -export interface TransactionHistoryResponse { - hash: string; - bundle_ids: string[]; - history: BundleEvent[]; -} +export type TransactionHistoryResponse = TransactionLookupResponse; export async function GET( _request: NextRequest, @@ -40,30 +13,28 @@ export async function GET( ) { try { const { hash } = await params; - - const metadata = await getTransactionMetadataByHash(hash); - - if (!metadata) { + const lookup = await lookupTransaction(hash); + if (!lookup.found) { return NextResponse.json( - { error: "Transaction not found" }, - { status: 404 }, + { + error: lookup.unavailable + ? "Transaction lookup unavailable" + : "Transaction not found", + coverage: lookup.response.coverage, + }, + { status: lookup.unavailable ? 503 : 404 }, ); } - // TODO: Can be in multiple bundles - const bundle = await getBundleHistory(metadata.bundle_ids[0]); - if (!bundle) { - return NextResponse.json({ error: "Bundle not found" }, { status: 404 }); + return NextResponse.json(lookup.response); + } catch (error) { + if (error instanceof InvalidTransactionHashError) { + return NextResponse.json( + { error: "Invalid transaction hash" }, + { status: 400 }, + ); } - const response: TransactionHistoryResponse = { - hash, - bundle_ids: metadata.bundle_ids, - history: bundle.history, - }; - - return NextResponse.json(response); - } catch (error) { console.error("Error fetching transaction data:", error); return NextResponse.json( { error: "Internal server error" }, diff --git a/src/app/api/txs/route.ts b/src/app/api/txs/route.ts new file mode 100644 index 00000000..1157dc18 --- /dev/null +++ b/src/app/api/txs/route.ts @@ -0,0 +1,36 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { + InvalidTransactionListQueryError, + listTransactions, + parseTransactionListQuery, + TransactionListUnavailableError, +} from "@/lib/transaction-list"; + +export type { + TransactionListItem, + TransactionsResponse, +} from "@/lib/transaction-list"; + +export async function GET(request: NextRequest) { + try { + const query = parseTransactionListQuery(request.nextUrl.searchParams); + return NextResponse.json(await listTransactions(query)); + } catch (error) { + if (error instanceof InvalidTransactionListQueryError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + console.error("Error fetching transaction list:", error); + return NextResponse.json( + { + error: + error instanceof TransactionListUnavailableError + ? "Transaction list unavailable" + : "Internal server error", + }, + { + status: error instanceof TransactionListUnavailableError ? 503 : 500, + }, + ); + } +} diff --git a/src/app/block/[hash]/layout.tsx b/src/app/block/[hash]/layout.tsx new file mode 100644 index 00000000..4a14ecfd --- /dev/null +++ b/src/app/block/[hash]/layout.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import { createPublicClient, type Hash, http } from "viem"; +import { mainnet } from "viem/chains"; + +interface BlockLayoutProps { + children: React.ReactNode; + params: Promise<{ hash: string }>; +} + +const RPC_URL = process.env.TIPS_UI_RPC_URL || "http://localhost:8545"; + +const client = createPublicClient({ + chain: mainnet, + transport: http(RPC_URL), +}); + +function blockLabel(value: string): string { + if (/^0x[0-9a-f]+$/i.test(value) && value.length < 66) { + try { + return `#${BigInt(value).toLocaleString()}`; + } catch { + return value; + } + } + + if (/^[0-9]+$/.test(value)) { + return `#${Number(value).toLocaleString()}`; + } + + return `${value.slice(0, 10)}...${value.slice(-8)}`; +} + +async function blockNumberLabel(value: string): Promise { + try { + const block = /^0x[0-9a-f]{64}$/i.test(value) + ? await client.getBlock({ blockHash: value as Hash }) + : await client.getBlock({ blockNumber: BigInt(value) }); + return `#${block.number.toLocaleString()}`; + } catch { + return blockLabel(value); + } +} + +export async function generateMetadata({ + params, +}: BlockLayoutProps): Promise { + const { hash } = await params; + const label = await blockNumberLabel(hash); + + return { + title: `Block ${label}`, + description: `Inspect transactions, execution data, and event history for Base block ${label}.`, + }; +} + +export default function BlockLayout({ children }: BlockLayoutProps) { + return children; +} diff --git a/src/app/block/[hash]/page.tsx b/src/app/block/[hash]/page.tsx index c5d0a4a0..a7438a6d 100644 --- a/src/app/block/[hash]/page.tsx +++ b/src/app/block/[hash]/page.tsx @@ -2,15 +2,36 @@ import Link from "next/link"; import { useEffect, useState } from "react"; -import type { BlockData, BlockTransaction } from "@/lib/s3"; +import { EventHistoryRow } from "@/app/event-history-row"; +import { + TransactionTable, + type TransactionTableItem, +} from "@/components/explorer-tables"; +import { formatGwei, formatInteger } from "@/lib/explorer-format"; +import type { BlockData, BlockTransaction } from "@/lib/transaction-data"; const BLOCK_EXPLORER_URL = process.env.NEXT_PUBLIC_BLOCK_EXPLORER_URL; +interface SerializedMetering { + transaction?: { + executionTimeUs?: number; + gasUsed?: number; + } | null; + bundle?: { + stateRootTimeUs?: number; + } | null; +} + // The API serializes metering as { transaction, bundle } from the raw -// meterBundleResponse. The page receives this shape via JSON. -// biome-ignore lint/suspicious/noExplicitAny: opaque metering JSON -function metering(tx: BlockTransaction): any { - return (tx as any).metering; +// meterBundleResponse. The page receives this shape via JSON. +function metering(tx: BlockTransaction): SerializedMetering | null { + return ( + ( + tx as BlockTransaction & { + metering?: SerializedMetering | null; + } + ).metering ?? null + ); } function executionTimeUs(tx: BlockTransaction): number | null { @@ -21,10 +42,6 @@ function stateRootTimeUs(tx: BlockTransaction): number | null { return metering(tx)?.bundle?.stateRootTimeUs ?? null; } -function gasUsed(tx: BlockTransaction): number | null { - return metering(tx)?.transaction?.gasUsed ?? null; -} - interface PageProps { params: Promise<{ hash: string }>; } @@ -96,95 +113,35 @@ function Card({ ); } -function getHeatmapStyle( - timeUs: number, - maxTime: number, -): { bg: string; text: string } { - if (maxTime === 0) return { bg: "bg-amber-50", text: "text-amber-700" }; - const ratio = Math.min(timeUs / maxTime, 1); - if (ratio < 0.2) return { bg: "bg-amber-100", text: "text-amber-800" }; - if (ratio < 0.4) return { bg: "bg-amber-200", text: "text-amber-900" }; - if (ratio < 0.6) return { bg: "bg-orange-200", text: "text-orange-900" }; - if (ratio < 0.8) return { bg: "bg-orange-300", text: "text-orange-950" }; - return { bg: "bg-red-300", text: "text-red-950" }; -} - -function TransactionRow({ - tx, - maxTotalTime, +function EventHistory({ + events, }: { - tx: BlockTransaction; - maxTotalTime: number; + events: NonNullable; }) { - const hasBundle = tx.bundleId !== null; - const execTime = executionTimeUs(tx); - const srTime = stateRootTimeUs(tx); - const hasMetering = execTime !== null; - const totalTime = (execTime ?? 0) + (srTime ?? 0); - const heatmapStyle = hasMetering - ? getHeatmapStyle(totalTime, maxTotalTime) - : null; - const txGasUsed = gasUsed(tx); - - const content = ( -
-
- {tx.index} -
-
-
- {BLOCK_EXPLORER_URL ? ( - e.stopPropagation()} - > - {tx.hash} - - ) : ( - - {tx.hash} - - )} -
-
- {tx.from.slice(0, 6)}...{tx.from.slice(-4)} - {tx.to && ( - <> - {" → "} - {tx.to.slice(0, 6)}...{tx.to.slice(-4)} - - )} -
-
-
- {hasMetering && heatmapStyle ? ( - - {totalTime.toLocaleString()}μs - - ) : ( -
- )} -
- {txGasUsed != null - ? `${txGasUsed.toLocaleString()} / ${tx.gasLimit.toLocaleString()} gas` - : `${tx.gasLimit.toLocaleString()} gas limit`} + if (events.length === 0) { + return ( + +
+ No transaction events recorded for this block.
-
-
- ); - - if (hasBundle) { - return {content}; + + ); } - return content; + return ( + +
+ {events.map((event, index) => ( + + ))} +
+
+ ); } function BlockStats({ block }: { block: BlockData }) { @@ -210,13 +167,13 @@ function BlockStats({ block }: { block: BlockData }) {
Block Number
- #{block.number.toLocaleString()} + #{formatInteger(block.number)}
Transactions
- {block.transactions.length} + {formatInteger(block.transactions.length)}
@@ -239,17 +196,17 @@ function BlockStats({ block }: { block: BlockData }) {
-
+
Gas Used - {block.gasUsed.toLocaleString()} + {formatInteger(block.gasUsed)}
Gas Limit - {block.gasLimit.toLocaleString()} + {formatInteger(block.gasLimit)}
@@ -258,6 +215,12 @@ function BlockStats({ block }: { block: BlockData }) { {new Date(Number(block.timestamp) * 1000).toLocaleString()}
+
+ Base Fee + + {formatGwei(block.baseFeePerGas)} + +
); @@ -316,14 +279,18 @@ export default function BlockPage({ params }: PageProps) { ); } - const maxTotalTime = data - ? Math.max( - ...data.transactions - .filter((tx) => executionTimeUs(tx) !== null) - .map((tx) => (executionTimeUs(tx) ?? 0) + (stateRootTimeUs(tx) ?? 0)), - 0, - ) - : 0; + const transactionRows: TransactionTableItem[] = + data?.transactions.map((tx) => ({ + hash: tx.hash, + blockNumber: tx.blockNumber ?? data.number, + transactionIndex: tx.index, + blockTimestamp: tx.blockTimestamp ?? data.timestamp, + from: tx.from, + to: tx.to, + input: tx.input, + value: tx.value, + transactionFee: tx.transactionFee, + })) ?? []; return (
@@ -500,27 +467,19 @@ export default function BlockPage({ params }: PageProps) { Transactions -
-
#
-
Transaction
-
Execution
-
-
- {data.transactions.map((tx) => ( - - ))} -
- {data.transactions.length === 0 && ( -
- No transactions in this block -
- )} +
+ +
+

+ Event History +

+ +
)} diff --git a/src/app/blocks/layout.tsx b/src/app/blocks/layout.tsx new file mode 100644 index 00000000..9479f302 --- /dev/null +++ b/src/app/blocks/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Blocks", + description: "Browse recent Base blocks and their execution data.", +}; + +export default function BlocksLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/blocks/page.tsx b/src/app/blocks/page.tsx new file mode 100644 index 00000000..ee16da45 --- /dev/null +++ b/src/app/blocks/page.tsx @@ -0,0 +1,149 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { BlockTable } from "@/components/explorer-tables"; +import type { BlocksResponse } from "@/lib/block-list"; + +const PAGE_LIMIT = 25; + +function Card({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export default function BlocksPage() { + const [cursor, setCursor] = useState(null); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setCursor(new URLSearchParams(window.location.search).get("cursor")); + }, []); + + useEffect(() => { + const controller = new AbortController(); + const query = new URLSearchParams({ limit: String(PAGE_LIMIT) }); + if (cursor !== null) query.set("cursor", cursor); + + setLoading(true); + setError(null); + fetch(`/api/blocks?${query.toString()}`, { + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(body?.error ?? "Failed to fetch blocks"); + } + return response.json() as Promise; + }) + .then(setData) + .catch((reason: unknown) => { + if (reason instanceof DOMException && reason.name === "AbortError") { + return; + } + setError( + reason instanceof Error ? reason.message : "Failed to fetch blocks", + ); + }) + .finally(() => setLoading(false)); + + return () => controller.abort(); + }, [cursor]); + + return ( +
+
+
+
+ + TIPS + + Blocks +
+ + Transactions + +
+
+ +
+
+
+

Blocks

+

+ Browse recent Base blocks and their execution limits. +

+
+ {cursor !== null && ( + + Latest blocks + + )} +
+ + {error && ( + +

{error}

+
+ )} + + + {loading ? ( +
+ Loading blocks... +
+ ) : data && data.blocks.length > 0 ? ( + + ) : ( +
+ No blocks available +
+ )} + + {data && ( +
+ + {data.blocks.length > 0 + ? `Blocks ${data.blocks[0]?.number.toLocaleString()}–${data.blocks.at(-1)?.number.toLocaleString()}` + : "No blocks"} + + {data.page.nextCursor !== null && ( + + Older blocks → + + )} +
+ )} +
+
+
+ ); +} diff --git a/src/app/bundles/[hash]/layout.tsx b/src/app/bundles/[hash]/layout.tsx new file mode 100644 index 00000000..5107f0a3 --- /dev/null +++ b/src/app/bundles/[hash]/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; + +interface BundleLayoutProps { + children: React.ReactNode; + params: Promise<{ hash: string }>; +} + +function shortHash(hash: string): string { + return `${hash.slice(0, 10)}...${hash.slice(-8)}`; +} + +export async function generateMetadata({ + params, +}: BundleLayoutProps): Promise { + const { hash } = await params; + const label = shortHash(hash); + + return { + title: `Bundle ${label}`, + description: `Inspect bundle history and simulation data for Base bundle ${hash}.`, + }; +} + +export default function BundleLayout({ children }: BundleLayoutProps) { + return children; +} diff --git a/src/app/bundles/[hash]/page.tsx b/src/app/bundles/[hash]/page.tsx index 9ea8602a..e80d39e8 100644 --- a/src/app/bundles/[hash]/page.tsx +++ b/src/app/bundles/[hash]/page.tsx @@ -1,9 +1,14 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import type { BundleHistoryResponse } from "@/app/api/bundle/[hash]/route"; -import type { BundleTransaction, MeterBundleResponse } from "@/lib/s3"; +import { EventHistoryRow } from "@/app/event-history-row"; +import type { + BundleTransaction, + MeterBundleResponse, +} from "@/lib/transaction-data"; const WEI_PER_GWEI = 10n ** 9n; const WEI_PER_ETH = 10n ** 18n; @@ -37,6 +42,38 @@ function formatGasPrice(hex: string | undefined): string { return `${formatBigInt(value, 2, WEI_PER_GWEI)} Gwei`; } +function formatHexInteger(hex: string | undefined): string { + if (!hex) return "—"; + try { + return BigInt(hex).toLocaleString(); + } catch { + return "—"; + } +} + +function formatTransactionType(type: string): string { + const names: Record = { + "0x0": "Legacy", + "0x1": "EIP-2930", + "0x2": "EIP-1559", + "0x3": "EIP-4844", + "0x4": "EIP-7702", + }; + const normalized = type.toLowerCase(); + const knownName = names[normalized]; + if (knownName) return knownName; + + if (normalized.startsWith("0x")) { + try { + return `Type ${BigInt(normalized).toString(10)}`; + } catch { + return "Unknown"; + } + } + + return type; +} + function ExplorerLink({ type, value, @@ -116,29 +153,6 @@ function CopyButton({ text }: { text: string }) { ); } -function Badge({ - children, - variant = "default", -}: { - children: React.ReactNode; - variant?: "default" | "success" | "warning" | "error"; -}) { - const variants = { - default: "bg-blue-50 text-blue-700 ring-blue-600/20", - success: "bg-emerald-50 text-emerald-700 ring-emerald-600/20", - warning: "bg-amber-50 text-amber-700 ring-amber-600/20", - error: "bg-red-50 text-red-700 ring-red-600/20", - }; - - return ( - - {children} - - ); -} - function Card({ children, className = "", @@ -192,7 +206,7 @@ function TransactionDetails({
- {parseInt(tx.gas, 16).toLocaleString()} gas + {formatHexInteger(tx.gas)} gas
{formatHexValue(tx.value)} @@ -224,12 +238,14 @@ function TransactionDetails({ Hash - {tx.hash} + + + ↗ @@ -278,7 +294,7 @@ function TransactionDetails({
Nonce - {parseInt(tx.nonce, 16)} + {formatHexInteger(tx.nonce)}
@@ -296,7 +312,7 @@ function TransactionDetails({
Type - {tx.type === "0x2" ? "EIP-1559" : tx.type} + {formatTransactionType(tx.type)}
@@ -405,70 +421,19 @@ function SimulationCard({ meter }: { meter: MeterBundleResponse }) { ); } -function TimelineEventDetails({ - event, -}: { - event: BundleHistoryResponse["history"][0]; -}) { - if (event.event === "BlockIncluded" && event.data?.block_hash) { - return ( -
- {event.event} - - Block #{event.data.block_number} - -
- ); - } - - if (event.event === "BuilderIncluded" && event.data?.builder) { - return ( -
- {event.event} - - {event.data.builder} (flashblock #{event.data.flashblock_index}) - -
- ); - } - - if (event.event === "Dropped" && event.data?.reason) { - return ( -
- {event.event} - {event.data.reason} -
- ); - } - - return {event.event}; -} - function Timeline({ events }: { events: BundleHistoryResponse["history"] }) { if (events.length === 0) return null; return ( -
+
{events.map((event, index) => ( -
-
-
-
-
- - -
-
+ event={event} + isLast={index === events.length - 1} + startTimestamp={events[0]?.data.timestamp ?? event.data.timestamp} + highlightIncluded + /> ))}
); @@ -481,6 +446,7 @@ function SectionTitle({ children }: { children: React.ReactNode }) { } export default function BundlePage({ params }: PageProps) { + const router = useRouter(); const [hash, setHash] = useState(""); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -520,6 +486,19 @@ export default function BundlePage({ params }: PageProps) { return () => clearInterval(interval); }, [hash]); + const latestBundle = data?.history + .filter((e) => e.data?.bundle) + .map((e) => e.data.bundle) + .pop(); + const singleTransactionHash = + latestBundle?.txs.length === 1 ? (latestBundle.txs[0]?.hash ?? null) : null; + + useEffect(() => { + if (singleTransactionHash) { + router.replace(`/txn/${singleTransactionHash}`); + } + }, [router, singleTransactionHash]); + if (!hash || (loading && !data)) { return (
@@ -531,10 +510,16 @@ export default function BundlePage({ params }: PageProps) { ); } - const latestBundle = data?.history - .filter((e) => e.data?.bundle) - .map((e) => e.data.bundle) - .pop(); + if (singleTransactionHash) { + return ( +
+
+
+ Opening transaction... +
+
+ ); + } return (
diff --git a/src/app/event-history-row.tsx b/src/app/event-history-row.tsx new file mode 100644 index 00000000..dcdd1cee --- /dev/null +++ b/src/app/event-history-row.tsx @@ -0,0 +1,201 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import type { BundleEvent } from "@/lib/transaction-data"; + +function EventChip({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function EventMarker({ + success, + isLast, +}: { + success: boolean; + isLast: boolean; +}) { + return ( +
+ {!isLast &&
} +
+
+
+
+ ); +} + +function metadataChips(event: BundleEvent) { + return [ + event.data.producer ? `source: ${event.data.producer}` : null, + event.data.target ? `target: ${event.data.target}` : null, + event.data.reason ? `reason: ${event.data.reason}` : null, + ].filter((value): value is string => value !== null); +} + +function formatLatency(milliseconds: number): string { + if (milliseconds < 1000) { + return `${Math.round(milliseconds)} ms`; + } + return `${(milliseconds / 1000).toFixed(2)} s`; +} + +function BlockFlashblockContext({ event }: { event: BundleEvent }) { + const blockNumber = event.data.block_number; + const flashblockIndex = event.data.flashblock_index; + + if (blockNumber === undefined && flashblockIndex === undefined) { + return null; + } + + const label = [ + blockNumber !== undefined ? `Block #${blockNumber}` : null, + flashblockIndex !== undefined ? `FB${flashblockIndex}` : null, + ] + .filter((value): value is string => value !== null) + .join(" · "); + + const className = + "mt-1 inline-flex text-xs font-medium text-gray-500 tabular-nums"; + + if (event.data.block_hash) { + return ( + + {label} + + ); + } + + return {label}; +} + +function JsonToggle({ + expanded, + onClick, +}: { + expanded: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function EventHistoryRow({ + event, + isLast, + startTimestamp, + highlightIncluded = false, +}: { + event: BundleEvent; + isLast: boolean; + startTimestamp: number; + highlightIncluded?: boolean; +}) { + const [jsonExpanded, setJsonExpanded] = useState(false); + const success = + highlightIncluded && + (event.event === "BUILDER_INCLUDED" || + event.event === "BUILDER_FLASHBLOCK_PUBLISHED"); + const chips = metadataChips(event); + const elapsedMs = event.data.timestamp - startTimestamp; + + return ( +
+ +
+
+
+
+
+ {event.event} +
+ +
+
+ {chips.map((chip) => ( + {chip} + ))} +
+
+ + {event.data.originalEvent !== undefined && ( + setJsonExpanded((value) => !value)} + /> + )} +
+
+ {event.data.originalEvent !== undefined && jsonExpanded && ( +
+              {JSON.stringify(event.data.originalEvent, null, 2)}
+            
+ )} +
+
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index bc9a9f21..de03ddfd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,8 +13,12 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "TIPS", - description: "A beautiful UI for interacting with TIPS", + title: { + default: "TIPS | Base transaction observability", + template: "%s | TIPS", + }, + description: + "Explore Base transaction events, blocks, bundles, and on-chain inclusion.", }; export default function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 1b031193..c638a2d8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,12 +3,13 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; +import { BlockTable } from "@/components/explorer-tables"; +import type { BlockSummary, BlocksResponse } from "@/lib/block-list"; import { formatRejectionReason, type MeterBundleResponse, type RejectedTransaction, -} from "@/lib/s3"; -import type { BlockSummary, BlocksResponse } from "./api/blocks/route"; +} from "@/lib/transaction-data"; import type { RejectedTransactionsResponse } from "./api/rejected/route"; type Tab = "blocks" | "rejected"; @@ -63,13 +64,8 @@ function SearchBar({ onError }: { onError: (error: string | null) => void }) { } return; } - const result = await response.json(); - - if (result.bundle_ids && result.bundle_ids.length > 0) { - router.push(`/bundles/${result.bundle_ids[0]}`); - } else { - onError("No bundle found for this transaction"); - } + await response.json(); + router.push(`/txn/${hash}`); } catch { onError("Failed to fetch transaction data"); } finally { @@ -98,75 +94,6 @@ function SearchBar({ onError }: { onError: (error: string | null) => void }) { ); } -function BlockRow({ block, index }: { block: BlockSummary; index: number }) { - const opacity = Math.max(0.3, 1 - index * 0.08); - const timeSince = Math.floor(Date.now() / 1000 - block.timestamp); - const timeAgo = - timeSince <= 0 - ? "now" - : timeSince < 60 - ? `${timeSince}s ago` - : timeSince < 3600 - ? `${Math.floor(timeSince / 60)}m ago` - : `${Math.floor(timeSince / 3600)}h ago`; - - return ( - -
- - Block - - -
-
-
- - #{block.number.toLocaleString()} - - {timeAgo} -
-
- {block.hash} -
-
-
-
- {block.transactionCount} -
-
txns
-
- - View - - - - ); -} - function Card({ children, className = "", @@ -298,11 +225,7 @@ function RejectedTxRow({ return (
- + + {expanded ? "Collapse" : "Expand"} + + + +
{expanded && (
@@ -423,8 +360,13 @@ function RejectedTxRow({ {tx.metering.results.map((result) => ( - {result.txHash.slice(0, 10)}... - {result.txHash.slice(-6)} + + {result.txHash.slice(0, 10)}... + {result.txHash.slice(-6)} + {result.fromAddress.slice(0, 8)}... @@ -632,6 +574,9 @@ export default function Home() { }, []); const [error, setError] = useState(null); const [blocks, setBlocks] = useState([]); + const [blocksPage, setBlocksPage] = useState( + null, + ); const [loading, setLoading] = useState(true); const switchTab = useCallback((tab: Tab) => { @@ -641,10 +586,11 @@ export default function Home() { const fetchBlocks = useCallback(async () => { try { - const response = await fetch("/api/blocks"); + const response = await fetch("/api/blocks?limit=10"); if (response.ok) { const data: BlocksResponse = await response.json(); setBlocks(data.blocks); + setBlocksPage(data.page); } } catch { console.error("Failed to fetch blocks"); @@ -686,6 +632,18 @@ export default function Home() { > Rejected Transactions + + Blocks + + + Transactions +
{activeTab === "blocks" && }
@@ -749,16 +707,23 @@ export default function Home() {
) : blocks.length > 0 ? ( -
- {blocks.map((block, index) => ( - - ))} -
+ ) : (
No blocks available
)} + {blocksPage?.nextCursor !== null && + blocksPage?.nextCursor !== undefined && ( +
+ + View all blocks → + +
+ )} )} diff --git a/src/app/txn/[hash]/layout.tsx b/src/app/txn/[hash]/layout.tsx new file mode 100644 index 00000000..17b9e9ef --- /dev/null +++ b/src/app/txn/[hash]/layout.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; + +interface TransactionLayoutProps { + children: React.ReactNode; + params: Promise<{ hash: string }>; +} + +function shortHash(hash: string): string { + return `${hash.slice(0, 10)}...${hash.slice(-8)}`; +} + +export async function generateMetadata({ + params, +}: TransactionLayoutProps): Promise { + const { hash } = await params; + const label = shortHash(hash); + + return { + title: `Transaction ${label}`, + description: `Inspect audit events and on-chain status for Base transaction ${hash}.`, + }; +} + +export default function TransactionLayout({ + children, +}: TransactionLayoutProps) { + return children; +} diff --git a/src/app/txn/[hash]/page.tsx b/src/app/txn/[hash]/page.tsx index 76f1dda3..8dfa7f7b 100644 --- a/src/app/txn/[hash]/page.tsx +++ b/src/app/txn/[hash]/page.tsx @@ -1,31 +1,298 @@ "use client"; -import { useRouter } from "next/navigation"; +import Link from "next/link"; import { useEffect, useState } from "react"; import type { TransactionHistoryResponse } from "@/app/api/txn/[hash]/route"; +import { EventHistoryRow } from "@/app/event-history-row"; +import { formatEth, formatGwei, formatInteger } from "@/lib/explorer-format"; +import type { BundleEvent, MeterBundleResponse } from "@/lib/transaction-data"; interface PageProps { params: Promise<{ hash: string }>; } -export default function TransactionRedirectPage({ params }: PageProps) { - const router = useRouter(); +const BLOCK_EXPLORER_URL = process.env.NEXT_PUBLIC_BLOCK_EXPLORER_URL; + +function Card({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function shortHash(value: string): string { + return `${value.slice(0, 10)}...${value.slice(-8)}`; +} + +function formatHexInteger(value: string | null | undefined): string { + if (!value) return "-"; + try { + return BigInt(value).toLocaleString(); + } catch { + return "-"; + } +} + +function SourceBadge({ label, state }: { label: string; state: string }) { + const positive = state === "available"; + return ( + + + {label}: {state.replaceAll("_", " ")} + + ); +} + +function ChainCard({ + data, +}: { + data: NonNullable; +}) { + const receipt = data.receipt; + const included = receipt !== null; + const succeeded = receipt?.status === "success"; + const blockHash = receipt?.blockHash ?? data.transaction.blockHash; + const blockNumberValue = receipt?.blockNumber ?? data.transaction.blockNumber; + + return ( + +
+
+

On-chain status

+

+ Independently queried from the execution RPC +

+
+ + {!included ? "Pending receipt" : succeeded ? "Succeeded" : "Reverted"} + +
+
+
+
Block
+ {blockHash ? ( + + #{formatHexInteger(blockNumberValue)} + + ) : ( +
Pending
+ )} +
+
+
Transaction index
+
+ {formatHexInteger( + receipt?.transactionIndex ?? data.transaction.transactionIndex, + )} +
+
+
+
Gas used
+
+ {formatHexInteger(receipt?.gasUsed)} +
+
+
+ {BLOCK_EXPLORER_URL && ( + + )} +
+ ); +} + +function simulationMeter(history: BundleEvent[]): MeterBundleResponse | null { + const simulationEvent = [...history] + .reverse() + .find( + (event) => + event.event === "SIMULATION_SUCCEEDED" && + event.data.bundle?.meter_bundle_response, + ); + if (simulationEvent?.data.bundle?.meter_bundle_response) { + return simulationEvent.data.bundle.meter_bundle_response; + } + + const fallbackEvent = [...history] + .reverse() + .find((event) => event.data.bundle?.meter_bundle_response); + return fallbackEvent?.data.bundle?.meter_bundle_response ?? null; +} + +function SimulationCard({ meter }: { meter: MeterBundleResponse | null }) { + if (!meter) { + return ( + +
+
+

Simulation results

+

+ Audit or archive metering data for this transaction +

+
+ + Unavailable + +
+
+ No simulation result is available from the configured sources. +
+
+ ); + } + + const executionTimeUs = meter.results.reduce( + (sum, result) => sum + result.executionTimeUs, + 0, + ); + const stateRootTimeUs = meter.stateRootTimeUs ?? 0; + const totalTimeUs = executionTimeUs + stateRootTimeUs; + const accountTrieNodes = + (meter.stateRootAccountLeafCount ?? 0) + + (meter.stateRootAccountBranchCount ?? 0); + const storageTrieNodes = + (meter.stateRootStorageLeafCount ?? 0) + + (meter.stateRootStorageBranchCount ?? 0); + + return ( + +
+
+
+

Simulation results

+

+ Metering data from Audit or the legacy archive +

+
+ + Available + +
+
+
+
+
Execution
+
+ {formatInteger(executionTimeUs)}μs +
+
+
+
State root
+
+ {formatInteger(stateRootTimeUs)}μs +
+
+
+
Total time
+
+ {formatInteger(totalTimeUs)}μs +
+
+
+ {(accountTrieNodes > 0 || storageTrieNodes > 0) && ( +
+
+
Account trie nodes
+
+ {formatInteger(accountTrieNodes)} +
+
+
+
Storage trie nodes
+
+ {formatInteger(storageTrieNodes)} +
+
+
+ )} +
+
+ Total gas + + {formatInteger(meter.totalGasUsed)} + +
+
+ Gas price + + {formatGwei(meter.bundleGasPrice)} + +
+
+ Gas fees + + {formatEth(meter.gasFees)} + +
+
+ Coinbase diff + + {formatEth(meter.coinbaseDiff)} + +
+
+ State block + + #{formatInteger(meter.stateBlockNumber)} + +
+
+
+ ); +} + +export default function TransactionPage({ params }: PageProps) { const [hash, setHash] = useState(""); + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - const initializeParams = async () => { - const resolvedParams = await params; - setHash(resolvedParams.hash); - }; - initializeParams(); + params.then((resolvedParams) => setHash(resolvedParams.hash)); }, [params]); useEffect(() => { if (!hash) return; - const fetchAndRedirect = async () => { + const fetchData = async () => { try { const response = await fetch(`/api/txn/${hash}`); if (!response.ok) { @@ -36,44 +303,195 @@ export default function TransactionRedirectPage({ params }: PageProps) { } return; } - const result: TransactionHistoryResponse = await response.json(); - - if (result.bundle_ids && result.bundle_ids.length > 0) { - router.push(`/bundles/${result.bundle_ids[0]}`); - } else { - setError("No bundle found for this transaction"); - } - } catch (_err) { + setData((await response.json()) as TransactionHistoryResponse); + setError(null); + } catch { setError("Failed to fetch transaction data"); + setData(null); } finally { setLoading(false); } }; - fetchAndRedirect(); - }, [hash, router]); + fetchData(); + }, [hash]); - if (!hash) { + if (!hash || (loading && !data)) { return ( -
-
Loading...
+
+
+
+ Loading transaction... +
); } return ( -
-
-

Transaction {hash}

- {loading && ( -
- Redirecting to bundle page... +
+
+
+
+ + ← + +
+ + TIPS +
- )} +
+ + {hash} + +
+
+
+ +
+
+
+
+

Transaction

+

+ {hash} +

+
+ {BLOCK_EXPLORER_URL && ( + + Explorer ↗ + + )} +
+ {loading && data && ( +

Refreshing...

+ )} +
+ {error && ( -
{error}
+ +

+ Unable to load transaction +

+

{error}

+
)} -
+ + {data && ( + <> + {data.chain && } + + +
+

+ Audit and archive timeline +

+ + {data.history.length > 0 ? ( + data.history.map((event, index) => ( + + )) + ) : ( +
+ No journal or archive events are available for this + transaction. +
+ )} +
+
+ +
+
+
+

+ Data provenance +

+

+ These sources answer different questions and may be + available independently. +

+
+
+ + + + +
+
+
+
+
Audit
+

+ Persisted transaction and proxy lifecycle events. +

+
+
+
Chain
+

+ Execution RPC transaction and receipt; proves inclusion. +

+
+
+
Archive
+

+ Legacy S3 metadata and bundle history during migration. +

+
+
+
Block journal
+

+ Audit block events matched to this transaction after RPC + lookup. +

+
+
+ {data.bundle_ids.length > 0 && ( +
+ Bundle keys + {data.bundle_ids.map((bundleId) => ( + + {shortHash(bundleId)} + + ))} +
+ )} + {data.chain?.receipt && + data.coverage.block_events !== "available" && ( +

+ On-chain inclusion is confirmed independently, but the audit + journal has no matching block-correlation event. +

+ )} +
+ + )} +
); } diff --git a/src/app/txs/layout.tsx b/src/app/txs/layout.tsx new file mode 100644 index 00000000..69f71e2d --- /dev/null +++ b/src/app/txs/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Transactions", + description: "Browse confirmed Base transactions from newest to oldest.", +}; + +export default function TransactionsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/txs/page.tsx b/src/app/txs/page.tsx new file mode 100644 index 00000000..1e6d058a --- /dev/null +++ b/src/app/txs/page.tsx @@ -0,0 +1,201 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { + TransactionTable, + type TransactionTableItem, +} from "@/components/explorer-tables"; +import type { + TransactionListItem, + TransactionsResponse, +} from "@/lib/transaction-list"; + +const PAGE_LIMIT = 50; + +function Card({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function asTableItems( + transactions: TransactionListItem[], +): TransactionTableItem[] { + return transactions.map((transaction) => ({ + hash: transaction.hash, + blockNumber: transaction.blockNumber, + transactionIndex: transaction.transactionIndex, + blockTimestamp: transaction.blockTimestamp, + from: transaction.from, + to: transaction.to, + input: transaction.input, + value: transaction.value, + transactionFee: transaction.transactionFee, + })); +} + +export default function TransactionsPage() { + const [initialCursor, setInitialCursor] = useState( + undefined, + ); + const [transactions, setTransactions] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setInitialCursor(new URLSearchParams(window.location.search).get("cursor")); + }, []); + + useEffect(() => { + if (initialCursor === undefined) return; + + const controller = new AbortController(); + const query = new URLSearchParams({ limit: String(PAGE_LIMIT) }); + if (initialCursor !== null) query.set("cursor", initialCursor); + + setLoading(true); + setError(null); + fetch(`/api/txs?${query.toString()}`, { signal: controller.signal }) + .then(async (response) => { + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(body?.error ?? "Failed to fetch transactions"); + } + return response.json() as Promise; + }) + .then((result) => { + setTransactions(result.transactions); + setNextCursor(result.nextCursor); + }) + .catch((reason: unknown) => { + if (reason instanceof DOMException && reason.name === "AbortError") { + return; + } + setError( + reason instanceof Error + ? reason.message + : "Failed to fetch transactions", + ); + }) + .finally(() => setLoading(false)); + + return () => controller.abort(); + }, [initialCursor]); + + const loadMore = async () => { + if (!nextCursor || loadingMore) return; + + setLoadingMore(true); + setError(null); + try { + const query = new URLSearchParams({ + limit: String(PAGE_LIMIT), + cursor: nextCursor, + }); + const response = await fetch(`/api/txs?${query.toString()}`); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(body?.error ?? "Failed to fetch older transactions"); + } + + const result = (await response.json()) as TransactionsResponse; + setTransactions((current) => [...current, ...result.transactions]); + setNextCursor(result.nextCursor); + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : "Failed to fetch older transactions", + ); + } finally { + setLoadingMore(false); + } + }; + + return ( +
+
+
+
+ + TIPS + + Transactions +
+ + Blocks + +
+
+ +
+
+

Transactions

+

+ Browse confirmed Base transactions from newest to oldest. +

+
+ + {error && ( + +

{error}

+
+ )} + + + {loading ? ( +
+ Loading transactions... +
+ ) : ( + + )} + + {!loading && ( +
+ + {transactions.length.toLocaleString()} transactions loaded + + {nextCursor && ( + + )} +
+ )} +
+
+
+ ); +} diff --git a/src/components/explorer-tables.tsx b/src/components/explorer-tables.tsx new file mode 100644 index 00000000..51d98aaf --- /dev/null +++ b/src/components/explorer-tables.tsx @@ -0,0 +1,168 @@ +import Link from "next/link"; +import type { BlockSummary } from "@/lib/block-list"; +import { + formatAction, + formatAge, + formatEth, + formatGwei, + formatInteger, + type NumericValue, + shortAddress, + shortHash, +} from "@/lib/explorer-format"; + +export interface TransactionTableItem { + hash: string; + blockNumber: NumericValue; + transactionIndex?: NumericValue; + blockTimestamp: NumericValue; + from: string; + to: string | null; + input?: string | null; + value: NumericValue; + transactionFee: NumericValue; +} + +function TableHeader({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function BlockTable({ blocks }: { blocks: BlockSummary[] }) { + return ( +
+ + + + Block + Age + Txn + Gas Used + Gas Limit + Base Fee + + + + {blocks.map((block) => ( + + + + + + + + + ))} + +
+ + #{formatInteger(block.number)} + +
+ {shortHash(block.hash)} +
+
+ {formatAge(block.timestamp)} + + {formatInteger(block.transactionCount)} + + {formatInteger(block.gasUsed)} + + {formatInteger(block.gasLimit)} + + {formatGwei(block.baseFeePerGas)} +
+
+ ); +} + +export function TransactionTable({ + transactions, + emptyMessage = "No transactions found", +}: { + transactions: TransactionTableItem[]; + emptyMessage?: string; +}) { + if (transactions.length === 0) { + return ( +
{emptyMessage}
+ ); + } + + return ( +
+ + + + Transaction Hash + Action + Block + Age + From + To + Amount + Txn Fee + + + + {transactions.map((transaction) => ( + + + + + + + + + + + ))} + +
+ + {shortHash(transaction.hash)} + + + {formatAction(transaction.input, transaction.to)} + + + #{formatInteger(transaction.blockNumber)} + + {transaction.transactionIndex !== undefined && ( +
+ Index {formatInteger(transaction.transactionIndex)} +
+ )} +
+ {formatAge(transaction.blockTimestamp)} + + {shortAddress(transaction.from)} + + {shortAddress(transaction.to)} + + {formatEth(transaction.value)} + + {formatEth(transaction.transactionFee)} +
+
+ ); +} diff --git a/src/lib/audit-events.test.ts b/src/lib/audit-events.test.ts new file mode 100644 index 00000000..8db2ba63 --- /dev/null +++ b/src/lib/audit-events.test.ts @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + type AuditTransactionEventRecord, + bundleHistoryFromAuditEvents, + bundleKeysFromAuditEvents, + mergeAuditEvents, + rejectedTransactionFromAuditEvent, + transactionBundleKeysFromAuditEvents, + transactionHashesFromAuditEvents, + transactionHistoryFromAuditEvents, + transactionMetadataFromAuditEvents, +} from "./audit-events"; + +const acceptedEvent: AuditTransactionEventRecord = { + schema_version: "transaction-event/v1", + event_id: "event-1", + event_time: "2026-06-02T00:00:00Z", + ingested_at: "2026-06-02T00:00:01Z", + producer: "ingress-rpc", + event_type: "SIMULATION_SUCCEEDED", + tx_hash: "0xabc", + block_number: null, + data: { + bundle_hash: "0xbundle", + bundle_id: "bundle-id", + meter_bundle_response: { + bundleHash: "0xbundle", + stateBlockNumber: 123, + totalGasUsed: 21000, + totalExecutionTimeUs: "500", + stateRootTimeUs: "50", + results: [ + { + txHash: "0xabc", + fromAddress: "0xsender", + toAddress: "0xto", + gasUsed: 21000, + executionTimeUs: "500", + }, + ], + }, + }, +}; + +describe("audit event route adapters", () => { + test("adapts audit bundle events to the existing bundle route shape", () => { + const history = bundleHistoryFromAuditEvents("0xbundle", [acceptedEvent]); + + assert.equal(history?.history.length, 1); + assert.equal(history?.history[0]?.event, "SIMULATION_SUCCEEDED"); + assert.equal(history?.history[0]?.data.producer, "ingress-rpc"); + assert.deepEqual(history?.history[0]?.data.originalEvent, acceptedEvent); + assert.equal( + history?.history[0]?.data.bundle?.meter_bundle_response.results[0] + ?.txHash, + "0xabc", + ); + assert.equal(history?.history[0]?.data.bundle?.txs[0]?.hash, "0xabc"); + assert.equal(history?.history[0]?.data.bundle?.txs[0]?.signer, "0xsender"); + assert.equal(history?.history[0]?.data.bundle?.txs[0]?.to, "0xto"); + }); + + test("derives transaction metadata from audit join fields", () => { + const metadata = transactionMetadataFromAuditEvents([acceptedEvent]); + + assert.deepEqual(metadata?.bundle_ids, ["bundle-id"]); + }); + + test("keeps transaction-only events when no bundle key is present", () => { + const proxyEvent: AuditTransactionEventRecord = { + ...acceptedEvent, + event_id: "proxy-event", + event_type: "PROXY_RECEIVED", + producer: "base-routing/proxyd", + data: { + rpc_method: "eth_sendRawTransaction", + }, + }; + + assert.deepEqual(transactionBundleKeysFromAuditEvents([proxyEvent]), { + ids: [], + hashes: [], + all: [], + }); + assert.deepEqual(transactionMetadataFromAuditEvents([proxyEvent]), { + bundle_ids: [], + sender: "", + nonce: "", + }); + + const history = transactionHistoryFromAuditEvents("0xabc", [proxyEvent]); + assert.equal(history.length, 1); + assert.equal(history[0]?.event, "PROXY_RECEIVED"); + assert.equal(history[0]?.data.originalEvent, proxyEvent); + }); + + test("derives related bundle keys for id and hash lookups", () => { + assert.deepEqual(bundleKeysFromAuditEvents("bundle-id", [acceptedEvent]), [ + "bundle-id", + "0xbundle", + ]); + }); + + test("deduplicates events fetched through related bundle keys", () => { + const merged = mergeAuditEvents([[acceptedEvent], [acceptedEvent]]); + + assert.equal(merged.length, 1); + assert.equal(merged[0]?.event_id, "event-1"); + }); + + test("derives transaction hashes from envelope and metering results", () => { + const hashes = transactionHashesFromAuditEvents([ + { + ...acceptedEvent, + tx_hash: null, + }, + ]); + + assert.deepEqual(hashes, ["0xabc"]); + }); + + test("keeps payload and block join fields on builder events", () => { + const payloadEvent = bundleHistoryFromAuditEvents("0xbundle", [ + { + ...acceptedEvent, + event_id: "event-3", + event_type: "BUILDER_ACCEPTED", + producer: "base-builder", + payload_id: "0xpayload", + block_number: 123, + data: { + flashblock_index: 2, + }, + }, + ]); + + const originalEvent = payloadEvent?.history[0]?.data.originalEvent as + | AuditTransactionEventRecord + | undefined; + assert.equal(originalEvent?.payload_id, "0xpayload"); + assert.equal(originalEvent?.block_number, 123); + assert.equal(originalEvent?.data.flashblock_index, 2); + }); + + test("does not decorate audit history with derived display latencies", () => { + const history = bundleHistoryFromAuditEvents("0xbundle", [ + { + ...acceptedEvent, + event_id: "event-proxy", + event_time: "2026-06-02T00:00:00.100Z", + event_type: "PROXY_RECEIVED", + producer: "base-routing/proxyd", + }, + { + ...acceptedEvent, + event_id: "event-builder-considered", + event_time: "2026-06-02T00:00:00.175Z", + event_type: "BUILDER_CONSIDERED", + producer: "base-builder", + }, + { + ...acceptedEvent, + event_id: "event-builder-accepted", + event_time: "2026-06-02T00:00:00.225Z", + event_type: "BUILDER_ACCEPTED", + producer: "base-builder", + }, + { + ...acceptedEvent, + event_id: "event-flashblock-published", + event_time: "2026-06-02T00:00:00.700Z", + event_type: "BUILDER_FLASHBLOCK_PUBLISHED", + producer: "base-builder", + }, + { + ...acceptedEvent, + event_id: "event-builder-included", + event_time: "2026-06-02T00:00:01.350Z", + event_type: "BUILDER_INCLUDED", + producer: "base-builder", + }, + ]); + + assert.deepEqual( + history?.history.map((event) => event.event), + [ + "PROXY_RECEIVED", + "BUILDER_CONSIDERED", + "BUILDER_ACCEPTED", + "BUILDER_FLASHBLOCK_PUBLISHED", + "BUILDER_INCLUDED", + ], + ); + }); + + test("adapts rejected audit events to the existing rejected route shape", () => { + const rejected = rejectedTransactionFromAuditEvent({ + ...acceptedEvent, + event_id: "event-2", + event_type: "BUILDER_REJECTED", + block_number: 123, + data: { + ...acceptedEvent.data, + reason: { + executionTimeExceeded: { + tx_time_us: 1000, + limit_us: 500, + }, + }, + }, + }); + + assert.equal(rejected?.blockNumber, 123); + assert.equal(rejected?.txHash, "0xabc"); + assert.equal(rejected?.metering.totalGasUsed, 21000); + }); +}); diff --git a/src/lib/audit-events.ts b/src/lib/audit-events.ts new file mode 100644 index 00000000..415157d0 --- /dev/null +++ b/src/lib/audit-events.ts @@ -0,0 +1,511 @@ +import type { + BundleEvent, + BundleHistory, + BundleTransaction, + MeterBundleResponse, + RejectedTransaction, + RejectionReason, + TransactionMetadata, +} from "@/lib/transaction-data"; + +const AUDIT_RPC_URL = process.env.TIPS_UI_AUDIT_RPC_URL; +const AUDIT_RPC_TIMEOUT_MS = 3000; +export const DEFAULT_AUDIT_EVENT_QUERY_LIMIT = 2000; + +export function isAuditConfigured(): boolean { + return Boolean(AUDIT_RPC_URL); +} + +export interface AuditTransactionEventRecord { + schema_version: string; + event_id: string; + event_time: string; + ingested_at: string; + producer: string; + event_type: string; + network?: string | null; + tx_hash?: string | null; + block_hash?: string | null; + block_number?: number | null; + payload_id?: string | null; + request_id?: string | null; + data: Record; +} + +interface JsonRpcResponse { + result?: T; + error?: { code: number; message: string }; +} + +async function auditRpc(method: string, params: unknown[]): Promise { + if (!AUDIT_RPC_URL) { + throw new Error("audit RPC URL is not configured"); + } + + const response = await fetch(AUDIT_RPC_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(AUDIT_RPC_TIMEOUT_MS), + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + params, + }), + }); + + if (!response.ok) { + throw new Error(`audit RPC HTTP ${response.status}`); + } + + const body = (await response.json()) as JsonRpcResponse; + if (body.error) { + throw new Error(body.error.message); + } + if (body.result === undefined) { + throw new Error("audit RPC response missing result"); + } + return body.result; +} + +export async function getAuditEventsByTransactionHash( + hash: string, + limit = DEFAULT_AUDIT_EVENT_QUERY_LIMIT, +): Promise { + return auditRpc("base_getTransactionEventsByHash", [hash, limit]); +} + +export async function getAuditEventsByBlockNumber( + blockNumber: number, + limit = DEFAULT_AUDIT_EVENT_QUERY_LIMIT, +): Promise { + return auditRpc("base_getTransactionEventsByBlockNumber", [ + blockNumber, + limit, + ]); +} + +export async function getAuditEventsByBlockHash( + blockHash: string, + limit = DEFAULT_AUDIT_EVENT_QUERY_LIMIT, +): Promise { + return auditRpc("base_getTransactionEventsByBlockHash", [blockHash, limit]); +} + +export async function getAuditEventsByBundle( + bundleKey: string, + limit = DEFAULT_AUDIT_EVENT_QUERY_LIMIT, +): Promise { + return auditRpc("base_getTransactionEventsByBundle", [bundleKey, limit]); +} + +export async function getAuditRejectedTransactionEvents( + limit = 100, +): Promise { + return auditRpc("base_getRejectedTransactionEvents", [{ limit }]); +} + +export function transactionMetadataFromAuditEvents( + events: AuditTransactionEventRecord[], +): TransactionMetadata | null { + if (events.length === 0) return null; + + const bundleKeys = transactionBundleKeysFromAuditEvents(events); + + return { + bundle_ids: bundleKeys.ids.length > 0 ? bundleKeys.ids : bundleKeys.hashes, + sender: "", + nonce: "", + }; +} + +export interface TransactionBundleKeys { + ids: string[]; + hashes: string[]; + all: string[]; +} + +export function transactionBundleKeysFromAuditEvents( + events: AuditTransactionEventRecord[], +): TransactionBundleKeys { + const ids = uniqueStrings( + events.map((event) => stringField(event.data.bundle_id)), + ); + const hashes = uniqueStrings( + events.map((event) => stringField(event.data.bundle_hash)), + ); + + return { + ids, + hashes, + all: uniqueStrings([...ids, ...hashes]), + }; +} + +export function transactionHistoryFromAuditEvents( + txHash: string, + events: AuditTransactionEventRecord[], +): BundleEvent[] { + return bundleHistoryFromAuditEvents(txHash, events)?.history ?? []; +} + +export function bundleHistoryFromAuditEvents( + bundleKey: string, + events: AuditTransactionEventRecord[], +): BundleHistory | null { + if (events.length === 0) return null; + return { + history: events + .map((event) => bundleEventFromAuditEvent(bundleKey, event)) + .sort((lhs, rhs) => lhs.data.timestamp - rhs.data.timestamp), + }; +} + +export function bundleKeysFromAuditEvents( + bundleKey: string, + events: AuditTransactionEventRecord[], +): string[] { + return Array.from( + new Set( + [ + bundleKey, + ...events.flatMap((event) => [ + stringField(event.data.bundle_id), + stringField(event.data.bundle_hash), + ]), + ].filter((value): value is string => value !== null), + ), + ); +} + +export function transactionHashesFromAuditEvents( + events: AuditTransactionEventRecord[], +): string[] { + return Array.from( + new Set( + events + .flatMap((event) => [ + event.tx_hash, + ...arrayField(recordField(event.data.meter_bundle_response)?.results) + .map((result) => stringField(result.txHash)) + .filter((value): value is string => value !== null), + ]) + .filter((value): value is string => value !== null), + ), + ); +} + +interface PayloadFlashblockContext { + payloadId: string; + blockNumber: number; + flashblockIndex: number; +} + +function payloadFlashblockContextsFromAuditEvents( + events: AuditTransactionEventRecord[], +): PayloadFlashblockContext[] { + const contexts = new Map(); + for (const event of events) { + const payloadId = stringField(event.payload_id); + const blockNumber = numberField(event.block_number); + const flashblockIndex = numberField(event.data.flashblock_index); + if ( + payloadId === null || + blockNumber === null || + flashblockIndex === null + ) { + continue; + } + + contexts.set(`${payloadId}:${blockNumber}:${flashblockIndex}`, { + payloadId, + blockNumber, + flashblockIndex, + }); + } + return Array.from(contexts.values()); +} + +async function getPayloadContextEvents( + events: AuditTransactionEventRecord[], +): Promise { + const contexts = payloadFlashblockContextsFromAuditEvents(events); + if (contexts.length === 0) return []; + + const blockNumbers = Array.from( + new Set(contexts.map((context) => context.blockNumber)), + ); + const blockEventResults = await Promise.allSettled( + blockNumbers.map((blockNumber) => getAuditEventsByBlockNumber(blockNumber)), + ); + const blockEventGroups = blockEventResults.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + + return blockEventGroups.flat().filter((event) => { + const payloadId = stringField(event.payload_id); + if (payloadId === null) return false; + + if (event.event_type === "BUILDER_PAYLOAD_FINALIZED") { + return contexts.some((context) => context.payloadId === payloadId); + } + + if ( + event.event_type !== "BUILDER_FLASHBLOCK_STARTED" && + event.event_type !== "BUILDER_FLASHBLOCK_PUBLISHED" + ) { + return false; + } + + const blockNumber = numberField(event.block_number); + const flashblockIndex = numberField(event.data.flashblock_index); + return contexts.some( + (context) => + context.payloadId === payloadId && + context.blockNumber === blockNumber && + context.flashblockIndex === flashblockIndex, + ); + }); +} + +export async function getJoinedAuditEventsByBundle( + bundleKey: string, + limit = DEFAULT_AUDIT_EVENT_QUERY_LIMIT, +): Promise { + const initialEvents = await getAuditEventsByBundle(bundleKey, limit); + const bundleKeys = bundleKeysFromAuditEvents(bundleKey, initialEvents); + const relatedBundleEventResults = await Promise.allSettled( + bundleKeys + .filter((key) => key !== bundleKey) + .map((key) => getAuditEventsByBundle(key, limit)), + ); + const relatedBundleEventGroups = relatedBundleEventResults.flatMap( + (result) => (result.status === "fulfilled" ? [result.value] : []), + ); + const bundleEvents = mergeAuditEvents([ + initialEvents, + ...relatedBundleEventGroups, + ]); + const txHashes = transactionHashesFromAuditEvents(bundleEvents); + const txEventResults = await Promise.allSettled( + txHashes.map((txHash) => getAuditEventsByTransactionHash(txHash, limit)), + ); + const txEventGroups = txEventResults.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + const txEvents = mergeAuditEvents(txEventGroups); + const payloadContextEvents = await getPayloadContextEvents(txEvents); + return mergeAuditEvents([bundleEvents, txEvents, payloadContextEvents]); +} + +export function mergeAuditEvents( + eventGroups: AuditTransactionEventRecord[][], +): AuditTransactionEventRecord[] { + const eventsById = new Map(); + for (const event of eventGroups.flat()) { + eventsById.set(event.event_id, event); + } + return Array.from(eventsById.values()).sort( + (lhs, rhs) => Date.parse(lhs.event_time) - Date.parse(rhs.event_time), + ); +} + +export function meterBundleResponseFromAuditEvent( + event: AuditTransactionEventRecord, +): MeterBundleResponse | null { + if ( + event.event_type !== "SIMULATION_SUCCEEDED" && + event.event_type !== "BUILDER_REJECTED" + ) { + return null; + } + return meterBundleResponseFromData(event.data); +} + +export function rejectedTransactionFromAuditEvent( + event: AuditTransactionEventRecord, +): RejectedTransaction | null { + if ( + event.event_type !== "SIMULATION_FAILED" && + event.event_type !== "BUILDER_REJECTED" + ) { + return null; + } + + const txHash = event.tx_hash; + if (!txHash) return null; + + return { + blockNumber: + event.block_number ?? numberField(event.data.state_block_number) ?? 0, + txHash, + reason: rejectionReasonFromData(event.data), + timestamp: Math.floor(Date.parse(event.event_time) / 1000), + metering: meterBundleResponseFromData(event.data), + }; +} + +function bundleEventFromAuditEvent( + bundleKey: string, + event: AuditTransactionEventRecord, +): BundleEvent { + const timestamp = Date.parse(event.event_time); + if (event.event_type === "SIMULATION_SUCCEEDED") { + return { + event: event.event_type, + data: { + key: event.event_id, + timestamp, + producer: event.producer, + reason: + stringField(event.data.rejection_reason) ?? + stringField(event.data.reason) ?? + undefined, + target: stringField(event.data.target) ?? undefined, + originalEvent: event, + bundle: { + uuid: stringField(event.data.bundle_id) ?? bundleKey, + txs: transactionsFromMeterBundleResponse(event.data), + block_number: String(event.data.state_block_number ?? ""), + max_timestamp: 0, + reverting_tx_hashes: event.tx_hash ? [event.tx_hash] : [], + meter_bundle_response: meterBundleResponseFromData(event.data), + }, + }, + }; + } + + return { + event: event.event_type, + data: { + key: event.event_id, + timestamp, + block_number: + event.block_number ?? + numberField(event.data.state_block_number) ?? + undefined, + block_hash: event.block_hash ?? undefined, + flashblock_index: numberField(event.data.flashblock_index) ?? undefined, + producer: event.producer, + reason: + stringField(event.data.rejection_reason) ?? + stringField(event.data.reason) ?? + undefined, + target: stringField(event.data.target) ?? undefined, + originalEvent: event, + }, + }; +} + +function transactionsFromMeterBundleResponse( + data: Record, +): BundleTransaction[] { + const metering = recordField(data.meter_bundle_response) ?? data; + return arrayField(metering.results).map((result) => ({ + signer: stringField(result.fromAddress) ?? "", + type: "", + chainId: "", + nonce: "0x0", + gas: numberToHex(numberField(result.gasUsed) ?? 0), + maxFeePerGas: stringField(result.gasPrice) ?? "0x0", + maxPriorityFeePerGas: "0x0", + to: stringField(result.toAddress), + value: stringField(result.value) ?? "0x0", + accessList: [], + input: "0x", + r: "", + s: "", + yParity: "", + v: "", + hash: stringField(result.txHash) ?? "", + })); +} + +function meterBundleResponseFromData( + data: Record, +): MeterBundleResponse { + const metering = recordField(data.meter_bundle_response) ?? data; + return { + bundleGasPrice: stringField(metering.bundleGasPrice) ?? "0", + bundleHash: + stringField(metering.bundleHash) ?? stringField(data.bundle_hash) ?? "", + coinbaseDiff: stringField(metering.coinbaseDiff) ?? "0", + ethSentToCoinbase: stringField(metering.ethSentToCoinbase) ?? "0", + gasFees: stringField(metering.gasFees) ?? "0", + results: arrayField(metering.results).map((result) => ({ + coinbaseDiff: stringField(result.coinbaseDiff) ?? "0", + ethSentToCoinbase: stringField(result.ethSentToCoinbase) ?? "0", + fromAddress: stringField(result.fromAddress) ?? "", + gasFees: stringField(result.gasFees) ?? "0", + gasPrice: stringField(result.gasPrice) ?? "0", + gasUsed: numberField(result.gasUsed) ?? 0, + toAddress: stringField(result.toAddress) ?? "", + txHash: stringField(result.txHash) ?? "", + value: stringField(result.value) ?? "0", + executionTimeUs: numberField(result.executionTimeUs) ?? 0, + })), + stateBlockNumber: numberField(metering.stateBlockNumber) ?? 0, + totalGasUsed: numberField(metering.totalGasUsed) ?? 0, + totalExecutionTimeUs: numberField(metering.totalExecutionTimeUs) ?? 0, + stateRootTimeUs: numberField(metering.stateRootTimeUs) ?? 0, + stateRootAccountLeafCount: + numberField(metering.stateRootAccountLeafCount) ?? 0, + stateRootAccountBranchCount: + numberField(metering.stateRootAccountBranchCount) ?? 0, + stateRootStorageLeafCount: + numberField(metering.stateRootStorageLeafCount) ?? 0, + stateRootStorageBranchCount: + numberField(metering.stateRootStorageBranchCount) ?? 0, + }; +} + +function rejectionReasonFromData( + data: Record, +): RejectionReason | string { + const reason = data.reason; + if (isRecord(reason)) { + return reason as RejectionReason; + } + + const rejectionReason = stringField(data.rejection_reason); + if (rejectionReason !== null) { + return rejectionReason; + } + + return {}; +} + +function stringField(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function uniqueStrings(values: Array): string[] { + return Array.from( + new Set(values.filter((value): value is string => value !== null)), + ); +} + +function numberField(value: unknown): number | null { + if (typeof value === "number") return value; + if (typeof value === "string" && value.length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function numberToHex(value: number): string { + return `0x${Math.max(0, Math.trunc(value)).toString(16)}`; +} + +function arrayField(value: unknown): Record[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function recordField(value: unknown): Record | null { + return isRecord(value) ? value : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/block-list.test.ts b/src/lib/block-list.test.ts new file mode 100644 index 00000000..423390ce --- /dev/null +++ b/src/lib/block-list.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + blockNumbersForPage, + nextBlockCursor, + parseBlockListQuery, +} from "./block-list"; + +describe("block list pagination", () => { + test("starts at the inclusive cursor", () => { + assert.deepEqual(blockNumbersForPage(100, 90, 3), [90, 89, 88]); + assert.deepEqual(blockNumbersForPage(100, null, 3), [100, 99, 98]); + }); + + test("does not move below genesis", () => { + assert.deepEqual(blockNumbersForPage(2, 1, 10), [1, 0]); + assert.equal(nextBlockCursor([{ number: 1 }, { number: 0 }]), null); + assert.equal(nextBlockCursor([{ number: 10 }]), 9); + }); + + test("validates cursors and limits", () => { + assert.deepEqual( + parseBlockListQuery(new URLSearchParams("cursor=90&limit=25")), + { cursor: 90, limit: 25 }, + ); + assert.throws( + () => parseBlockListQuery(new URLSearchParams("cursor=-1")), + /cursor must be a non-negative integer/, + ); + assert.throws( + () => parseBlockListQuery(new URLSearchParams("limit=101")), + /limit must be between/, + ); + }); +}); diff --git a/src/lib/block-list.ts b/src/lib/block-list.ts new file mode 100644 index 00000000..736ba71e --- /dev/null +++ b/src/lib/block-list.ts @@ -0,0 +1,216 @@ +export const HOME_BLOCK_LIMIT = 10; +export const DEFAULT_BLOCKS_PAGE_LIMIT = 25; +export const MAX_BLOCKS_PAGE_LIMIT = 100; + +const RPC_URL = process.env.TIPS_UI_RPC_URL || "http://localhost:8545"; + +export interface BlockSummary { + hash: string; + number: number; + timestamp: number; + transactionCount: number; + gasUsed: string | null; + gasLimit: string | null; + baseFeePerGas: string | null; +} + +export interface BlocksPage { + cursor: number | null; + limit: number; + latestBlockNumber: number; + nextCursor: number | null; + hasMore: boolean; +} + +export interface BlocksResponse { + blocks: BlockSummary[]; + page: BlocksPage; +} + +export interface BlockListQuery { + cursor: number | null; + limit: number; +} + +export class InvalidBlockListQueryError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidBlockListQueryError"; + } +} + +export class BlockListUnavailableError extends Error { + constructor(message = "block list unavailable") { + super(message); + this.name = "BlockListUnavailableError"; + } +} + +function parseNonNegativeInteger( + value: string | null, + name: string, +): number | null { + if (value === null) return null; + if (!/^(0|[1-9]\d*)$/.test(value)) { + throw new InvalidBlockListQueryError( + `${name} must be a non-negative integer`, + ); + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new InvalidBlockListQueryError(`${name} is too large`); + } + return parsed; +} + +export function parseBlockListQuery( + searchParams: URLSearchParams, +): BlockListQuery { + const cursor = parseNonNegativeInteger(searchParams.get("cursor"), "cursor"); + const rawLimit = searchParams.get("limit"); + const limit = + rawLimit === null + ? HOME_BLOCK_LIMIT + : parseNonNegativeInteger(rawLimit, "limit"); + + if (limit === null || limit < 1 || limit > MAX_BLOCKS_PAGE_LIMIT) { + throw new InvalidBlockListQueryError( + `limit must be between 1 and ${MAX_BLOCKS_PAGE_LIMIT}`, + ); + } + + return { cursor, limit }; +} + +export function blockNumbersForPage( + latestBlockNumber: number, + cursor: number | null, + limit: number, +): number[] { + const start = Math.min(cursor ?? latestBlockNumber, latestBlockNumber); + return Array.from({ length: limit }, (_, index) => start - index).filter( + (number) => number >= 0, + ); +} + +export function nextBlockCursor( + blocks: Pick[], +): number | null { + const oldest = blocks.at(-1)?.number; + return oldest === undefined || oldest === 0 ? null : oldest - 1; +} + +interface RpcResponse { + result?: unknown; + error?: { message?: string }; +} + +async function rpcRequest( + method: string, + params: unknown[], +): Promise { + try { + const response = await fetch(RPC_URL, { + method: "POST", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method, + params, + id: 1, + }), + }); + + if (!response.ok) return null; + const data = (await response.json()) as RpcResponse; + return data.error || data.result === undefined ? null : data.result; + } catch { + return null; + } +} + +function decimalQuantity(value: unknown): string | null { + if (typeof value !== "string") return null; + try { + return BigInt(value).toString(); + } catch { + return null; + } +} + +function numberQuantity(value: unknown): number | null { + const decimal = decimalQuantity(value); + if (decimal === null) return null; + const parsed = Number(decimal); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +async function fetchLatestBlockNumber(): Promise { + const result = await rpcRequest("eth_blockNumber", []); + return numberQuantity(result); +} + +async function fetchBlockByNumber( + blockNumber: number, +): Promise { + const result = await rpcRequest("eth_getBlockByNumber", [ + `0x${blockNumber.toString(16)}`, + false, + ]); + + if (!result || typeof result !== "object") return null; + const block = result as Record; + const number = numberQuantity(block.number); + const timestamp = numberQuantity(block.timestamp); + if (block.hash === undefined || number === null || timestamp === null) { + return null; + } + + return { + hash: String(block.hash), + number, + timestamp, + transactionCount: Array.isArray(block.transactions) + ? block.transactions.length + : 0, + gasUsed: decimalQuantity(block.gasUsed), + gasLimit: decimalQuantity(block.gasLimit), + baseFeePerGas: decimalQuantity(block.baseFeePerGas), + }; +} + +export async function listBlocks( + query: BlockListQuery, +): Promise { + const latestBlockNumber = await fetchLatestBlockNumber(); + if (latestBlockNumber === null) { + throw new BlockListUnavailableError("failed to fetch latest block"); + } + + const blockNumbers = blockNumbersForPage( + latestBlockNumber, + query.cursor, + query.limit, + ); + const blocks = await Promise.all(blockNumbers.map(fetchBlockByNumber)); + + if (blocks.some((block) => block === null)) { + throw new BlockListUnavailableError("failed to fetch one or more blocks"); + } + + const validBlocks = blocks as BlockSummary[]; + const nextCursor = nextBlockCursor(validBlocks); + + return { + blocks: validBlocks, + page: { + cursor: query.cursor, + limit: query.limit, + latestBlockNumber, + nextCursor, + hasMore: nextCursor !== null, + }, + }; +} diff --git a/src/lib/explorer-format.test.ts b/src/lib/explorer-format.test.ts new file mode 100644 index 00000000..b3dfbe74 --- /dev/null +++ b/src/lib/explorer-format.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + calculateTransactionFee, + formatAction, + formatAge, + formatEth, + formatGwei, + formatInteger, + shortAddress, +} from "./explorer-format"; + +describe("explorer formatters", () => { + test("formats decimal quantities without hexadecimal output", () => { + assert.equal(formatInteger("21000"), "21,000"); + assert.equal(formatGwei("5000000000"), "5 Gwei"); + assert.equal(formatEth("1234567890000000000"), "1.234567 ETH"); + assert.equal( + calculateTransactionFee("21000", "1000000000"), + 21000000000000n, + ); + assert.equal(calculateTransactionFee(null, "1000000000"), null); + }); + + test("formats ages from unix timestamps", () => { + assert.equal(formatAge(1_000, 1_000), "now"); + assert.equal(formatAge(940, 1_000), "1m ago"); + assert.equal(formatAge(1_000 - 86_400 * 2, 1_000), "2d ago"); + }); + + test("uses safe action fallbacks", () => { + assert.equal(formatAction("0x", "0xto"), "Transfer"); + assert.equal(formatAction("0x", null), "Contract Creation"); + assert.equal(formatAction("0xa9059cbb0000", "0xto"), "0xa9059cbb"); + assert.equal(formatAction("not-calldata", "0xto"), "Contract Call"); + }); + + test("shortens addresses without changing their identity", () => { + assert.equal(shortAddress("0x1234567890abcdef"), "0x1234...cdef"); + assert.equal(shortAddress(null), "—"); + }); +}); diff --git a/src/lib/explorer-format.ts b/src/lib/explorer-format.ts new file mode 100644 index 00000000..673fc5de --- /dev/null +++ b/src/lib/explorer-format.ts @@ -0,0 +1,107 @@ +export type NumericValue = bigint | number | string | null | undefined; + +const WEI_PER_GWEI = 10n ** 9n; +const WEI_PER_ETH = 10n ** 18n; + +function toBigInt(value: NumericValue): bigint | null { + if (value === null || value === undefined || value === "") { + return null; + } + + if (typeof value === "bigint") { + return value; + } + + if (typeof value === "number") { + return Number.isSafeInteger(value) ? BigInt(value) : null; + } + + try { + return BigInt(value); + } catch { + return null; + } +} + +function formatUnits( + value: NumericValue, + scale: bigint, + decimals: number, +): string { + const parsed = toBigInt(value); + if (parsed === null) return "—"; + + const whole = parsed / scale; + const fraction = ((parsed % scale) * 10n ** BigInt(decimals)) / scale; + const fractionText = fraction + .toString() + .padStart(decimals, "0") + .replace(/0+$/, ""); + + return fractionText ? `${whole}.${fractionText}` : whole.toString(); +} + +export function formatInteger(value: NumericValue): string { + const parsed = toBigInt(value); + return parsed === null ? "—" : parsed.toLocaleString(); +} + +export function formatEth(value: NumericValue): string { + const formatted = formatUnits(value, WEI_PER_ETH, 6); + return formatted === "—" ? formatted : `${formatted} ETH`; +} + +export function formatGwei(value: NumericValue): string { + const formatted = formatUnits(value, WEI_PER_GWEI, 9); + return formatted === "—" ? formatted : `${formatted} Gwei`; +} + +export function formatAge( + timestamp: NumericValue, + nowSeconds = Math.floor(Date.now() / 1000), +): string { + const parsed = toBigInt(timestamp); + if (parsed === null) return "—"; + + const seconds = Math.max(0, nowSeconds - Number(parsed)); + if (seconds < 60) return seconds <= 0 ? "now" : `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} + +export function shortHash(value: string, prefix = 10, suffix = 8): string { + if (value.length <= prefix + suffix + 3) return value; + return `${value.slice(0, prefix)}...${value.slice(-suffix)}`; +} + +export function shortAddress(value: string | null | undefined): string { + if (!value) return "—"; + return shortHash(value, 6, 4); +} + +export function formatAction( + input: string | null | undefined, + to: string | null | undefined, +): string { + if (!to) return "Contract Creation"; + if (!input || input === "0x") return "Transfer"; + if (/^0x[0-9a-f]{8}/i.test(input)) return input.slice(0, 10); + return "Contract Call"; +} + +export function weiToBigInt(value: NumericValue): bigint | null { + return toBigInt(value); +} + +export function calculateTransactionFee( + gasUsed: NumericValue, + effectiveGasPrice: NumericValue, +): bigint | null { + const parsedGasUsed = toBigInt(gasUsed); + const parsedEffectiveGasPrice = toBigInt(effectiveGasPrice); + if (parsedGasUsed === null || parsedEffectiveGasPrice === null) { + return null; + } + return parsedGasUsed * parsedEffectiveGasPrice; +} diff --git a/src/lib/receipts.ts b/src/lib/receipts.ts new file mode 100644 index 00000000..c1ac0cc2 --- /dev/null +++ b/src/lib/receipts.ts @@ -0,0 +1,70 @@ +const RPC_URL = process.env.TIPS_UI_RPC_URL || "http://localhost:8545"; + +export interface ReceiptSummary { + gasUsed: bigint; + effectiveGasPrice: bigint | null; +} + +interface RpcReceipt { + transactionHash?: unknown; + gasUsed?: unknown; + effectiveGasPrice?: unknown; + error?: unknown; + result?: RpcReceipt; +} + +function quantity(value: unknown): bigint | null { + if (typeof value !== "string") return null; + try { + return BigInt(value); + } catch { + return null; + } +} + +function receiptFromResponse(value: RpcReceipt): ReceiptSummary | null { + const gasUsed = quantity(value.gasUsed); + if (gasUsed === null) return null; + return { + gasUsed, + effectiveGasPrice: quantity(value.effectiveGasPrice), + }; +} + +export async function getTransactionReceiptSummaries( + hashes: string[], +): Promise> { + const receipts = new Map(); + if (hashes.length === 0) return receipts; + + try { + const response = await fetch(RPC_URL, { + method: "POST", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + hashes.map((hash, index) => ({ + jsonrpc: "2.0", + method: "eth_getTransactionReceipt", + params: [hash], + id: index + 1, + })), + ), + }); + + if (!response.ok) return receipts; + const data = (await response.json()) as RpcReceipt[]; + if (!Array.isArray(data)) return receipts; + + for (const entry of data) { + const receipt = entry.result ? receiptFromResponse(entry.result) : null; + if (receipt && typeof entry.result?.transactionHash === "string") { + receipts.set(entry.result.transactionHash, receipt); + } + } + } catch { + // Receipt data is enrichment; list and block queries remain usable without it. + } + + return receipts; +} diff --git a/src/lib/s3.ts b/src/lib/s3.ts index 6e11aa1b..98645019 100644 --- a/src/lib/s3.ts +++ b/src/lib/s3.ts @@ -1,17 +1,22 @@ import { GetObjectCommand, ListObjectsV2Command, - PutObjectCommand, S3Client, type S3ClientConfig, } from "@aws-sdk/client-s3"; +import type { + BlockData, + BundleEvent, + BundleHistory, + RejectedTransaction, + TransactionMetadata, +} from "@/lib/transaction-data"; function createS3Client(): S3Client { const configType = process.env.TIPS_UI_S3_CONFIG_TYPE || "aws"; const region = process.env.TIPS_UI_AWS_REGION || "us-east-1"; if (configType === "manual") { - console.log("Using Manual S3 configuration"); const config: S3ClientConfig = { region, forcePathStyle: true, @@ -34,33 +39,22 @@ function createS3Client(): S3Client { return new S3Client(config); } - console.log("Using AWS S3 configuration"); - return new S3Client({ - region, - }); + return new S3Client({ region }); } const s3Client = createS3Client(); - const BUCKET_NAME = process.env.TIPS_UI_S3_BUCKET_NAME || "tips"; -export interface TransactionMetadata { - bundle_ids: string[]; - sender: string; - nonce: string; -} - async function getObjectContent(key: string): Promise { try { - const command = new GetObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - }); - - const response = await s3Client.send(command); - const body = await response.Body?.transformToString(); - return body || null; - } catch (_error) { + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + }), + ); + return (await response.Body?.transformToString()) || null; + } catch { return null; } } @@ -68,12 +62,8 @@ async function getObjectContent(key: string): Promise { export async function getTransactionMetadataByHash( hash: string, ): Promise { - const key = `transactions/by_hash/${hash}`; - const content = await getObjectContent(key); - - if (!content) { - return null; - } + const content = await getObjectContent(`transactions/by_hash/${hash}`); + if (!content) return null; try { return JSON.parse(content) as TransactionMetadata; @@ -86,146 +76,44 @@ export async function getTransactionMetadataByHash( } } -export interface BundleTransaction { - signer: string; - type: string; - chainId: string; - nonce: string; - gas: string; - maxFeePerGas: string; - maxPriorityFeePerGas: string; - to: string | null; - value: string; - accessList: unknown[]; - input: string; - r: string; - s: string; - yParity: string; - v: string; - hash: string; -} - -export interface MeterBundleResult { - coinbaseDiff: string; - ethSentToCoinbase: string; - fromAddress: string; - gasFees: string; - gasPrice: string; - gasUsed: number; - toAddress: string; - txHash: string; - value: string; - executionTimeUs: number; -} - -export interface MeterBundleResponse { - bundleGasPrice: string; - bundleHash: string; - coinbaseDiff: string; - ethSentToCoinbase: string; - gasFees: string; - results: MeterBundleResult[]; - stateBlockNumber: number; - totalGasUsed: number; - totalExecutionTimeUs: number; - stateRootTimeUs: number; - stateRootAccountLeafCount: number; - stateRootAccountBranchCount: number; - stateRootStorageLeafCount: number; - stateRootStorageBranchCount: number; -} - -export interface BundleData { - uuid: string; - txs: BundleTransaction[]; - block_number: string; - max_timestamp: number; - reverting_tx_hashes: string[]; - meter_bundle_response: MeterBundleResponse; -} - -export interface BundleEventData { - key: string; - timestamp: number; - bundle?: BundleData; - block_number?: number; - block_hash?: string; - builder?: string; - flashblock_index?: number; - reason?: string; -} - -export interface BundleEvent { - event: string; - data: BundleEventData; -} - -export interface BundleHistory { - history: BundleEvent[]; -} - export async function getBundleHistory( bundleKey: string, ): Promise { - const prefix = `bundles/${bundleKey}/`; - const listCommand = new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - Prefix: prefix, - }); - - const listResponse = await s3Client.send(listCommand); - const keys = listResponse.Contents?.map((obj) => obj.Key).filter( - Boolean, - ) as string[]; - - if (!keys || keys.length === 0) { - return null; - } - - const history: BundleEvent[] = []; - for (const key of keys) { - const content = await getObjectContent(key); - if (content) { + try { + const listResponse = await s3Client.send( + new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + Prefix: `bundles/${bundleKey}/`, + }), + ); + const keys = listResponse.Contents?.map((obj) => obj.Key).filter( + Boolean, + ) as string[]; + if (!keys || keys.length === 0) return null; + + const history: BundleEvent[] = []; + for (const key of keys) { + const content = await getObjectContent(key); + if (!content) continue; try { history.push(JSON.parse(content) as BundleEvent); } catch (error) { console.error(`Failed to parse event at ${key}:`, error); } } - } - return { history }; -} - -export interface BlockTransaction { - hash: string; - from: string; - to: string | null; - gasLimit: bigint; - bundleId: string | null; - index: number; - meterBundleResponse: Record | null; -} - -export interface BlockData { - hash: string; - number: bigint; - timestamp: bigint; - transactions: BlockTransaction[]; - gasUsed: bigint; - gasLimit: bigint; - cachedAt: number; + return { history }; + } catch (error) { + console.error(`Failed to list bundle history for ${bundleKey}:`, error); + return null; + } } export async function getBlockFromCache( blockHash: string, ): Promise { - const key = `blocks/${blockHash}`; - const content = await getObjectContent(key); - - if (!content) { - return null; - } + const content = await getObjectContent(`blocks/${blockHash}`); + if (!content) return null; try { const parsed = JSON.parse(content); @@ -235,10 +123,31 @@ export async function getBlockFromCache( timestamp: BigInt(parsed.timestamp), gasUsed: BigInt(parsed.gasUsed), gasLimit: BigInt(parsed.gasLimit), + baseFeePerGas: + parsed.baseFeePerGas === null || parsed.baseFeePerGas === undefined + ? null + : BigInt(parsed.baseFeePerGas), transactions: parsed.transactions.map( (tx: { gasLimit?: string; [key: string]: unknown }) => ({ ...tx, + blockHash: String(tx.blockHash ?? parsed.hash ?? blockHash), + blockNumber: BigInt(tx.blockNumber ?? parsed.number), + blockTimestamp: BigInt(tx.blockTimestamp ?? parsed.timestamp), + input: String(tx.input ?? "0x"), + value: BigInt(String(tx.value ?? "0")), gasLimit: BigInt(tx.gasLimit ?? "0"), + gasUsed: + tx.gasUsed === null || tx.gasUsed === undefined + ? null + : BigInt(tx.gasUsed as string), + effectiveGasPrice: + tx.effectiveGasPrice === null || tx.effectiveGasPrice === undefined + ? null + : BigInt(tx.effectiveGasPrice as string), + transactionFee: + tx.transactionFee === null || tx.transactionFee === undefined + ? null + : BigInt(tx.transactionFee as string), meterBundleResponse: tx.meterBundleResponse ?? null, }), ), @@ -249,33 +158,7 @@ export async function getBlockFromCache( } } -export interface RejectionReason { - executionTimeExceeded?: { - tx_time_us: number; - limit_us: number; - }; -} - -export interface RejectedTransaction { - blockNumber: number; - txHash: string; - reason: RejectionReason; - timestamp: number; - metering: MeterBundleResponse; -} - -export function formatRejectionReason( - reason: RejectionReason | string, -): string { - if (typeof reason === "string") return reason; - if (reason?.executionTimeExceeded) { - const { tx_time_us, limit_us } = reason.executionTimeExceeded; - return `Execution time exceeded: ${tx_time_us.toLocaleString()}μs > ${limit_us.toLocaleString()}μs limit`; - } - return "Unknown reason"; -} - -export interface RejectedTransactionSummary { +interface RejectedTransactionSummary { blockNumber: number; txHash: string; } @@ -284,29 +167,26 @@ export async function listRejectedTransactions( limit = 100, ): Promise { try { - const command = new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - Prefix: "rejected/", - MaxKeys: limit, - }); - - const response = await s3Client.send(command); - const contents = response.Contents || []; + const response = await s3Client.send( + new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + Prefix: "rejected/", + MaxKeys: limit, + }), + ); const summaries: RejectedTransactionSummary[] = []; - for (const obj of contents) { + for (const obj of response.Contents || []) { if (!obj.Key) continue; - // S3 key format matches Rust S3Key::Rejected: rejected/{block_number}/{tx_hash} const parts = obj.Key.split("/"); if (parts.length !== 3) continue; - const blockNumber = parseInt(parts[1], 10); + const blockNumber = Number.parseInt(parts[1] ?? "", 10); const txHash = parts[2]; if (Number.isNaN(blockNumber) || !txHash) continue; summaries.push({ blockNumber, txHash }); } - summaries.sort((a, b) => b.blockNumber - a.blockNumber); - return summaries; + return summaries.sort((a, b) => b.blockNumber - a.blockNumber); } catch (error) { console.error("Failed to list rejected transactions:", error); return []; @@ -317,12 +197,8 @@ export async function getRejectedTransaction( blockNumber: number, txHash: string, ): Promise { - const key = `rejected/${blockNumber}/${txHash}`; - const content = await getObjectContent(key); - - if (!content) { - return null; - } + const content = await getObjectContent(`rejected/${blockNumber}/${txHash}`); + if (!content) return null; try { return JSON.parse(content) as RejectedTransaction; @@ -334,22 +210,3 @@ export async function getRejectedTransaction( return null; } } - -export async function cacheBlockData(blockData: BlockData): Promise { - const key = `blocks/${blockData.hash}`; - - try { - const command = new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - Body: JSON.stringify(blockData, (_, value) => - typeof value === "bigint" ? value.toString() : value, - ), - ContentType: "application/json", - }); - - await s3Client.send(command); - } catch (error) { - console.error(`Failed to cache block data for ${blockData.hash}:`, error); - } -} diff --git a/src/lib/transaction-data.ts b/src/lib/transaction-data.ts new file mode 100644 index 00000000..cd7f5330 --- /dev/null +++ b/src/lib/transaction-data.ts @@ -0,0 +1,142 @@ +export interface TransactionMetadata { + bundle_ids: string[]; + sender: string; + nonce: string; +} + +export interface BundleTransaction { + signer: string; + type: string; + chainId: string; + nonce: string; + gas: string; + maxFeePerGas: string; + maxPriorityFeePerGas: string; + to: string | null; + value: string; + accessList: unknown[]; + input: string; + r: string; + s: string; + yParity: string; + v: string; + hash: string; +} + +export interface MeterBundleResult { + coinbaseDiff: string; + ethSentToCoinbase: string; + fromAddress: string; + gasFees: string; + gasPrice: string; + gasUsed: number; + toAddress: string; + txHash: string; + value: string; + executionTimeUs: number; +} + +export interface MeterBundleResponse { + bundleGasPrice: string; + bundleHash: string; + coinbaseDiff: string; + ethSentToCoinbase: string; + gasFees: string; + results: MeterBundleResult[]; + stateBlockNumber: number; + totalGasUsed: number; + totalExecutionTimeUs: number; + stateRootTimeUs: number; + stateRootAccountLeafCount: number; + stateRootAccountBranchCount: number; + stateRootStorageLeafCount: number; + stateRootStorageBranchCount: number; +} + +export interface BundleData { + uuid: string; + txs: BundleTransaction[]; + block_number: string; + max_timestamp: number; + reverting_tx_hashes: string[]; + meter_bundle_response: MeterBundleResponse; +} + +export interface BundleEventData { + key: string; + timestamp: number; + bundle?: BundleData; + block_number?: number; + block_hash?: string; + builder?: string; + flashblock_index?: number; + producer?: string; + reason?: string; + target?: string; + originalEvent?: unknown; +} + +export interface BundleEvent { + event: string; + data: BundleEventData; +} + +export interface BundleHistory { + history: BundleEvent[]; +} + +export interface BlockTransaction { + hash: string; + blockHash: string; + blockNumber: bigint; + blockTimestamp: bigint; + from: string; + to: string | null; + input: string; + value: bigint; + gasLimit: bigint; + gasUsed: bigint | null; + effectiveGasPrice: bigint | null; + transactionFee: bigint | null; + bundleId: string | null; + index: number; + meterBundleResponse: Record | null; +} + +export interface BlockData { + hash: string; + number: bigint; + timestamp: bigint; + transactions: BlockTransaction[]; + eventHistory?: BundleEvent[]; + gasUsed: bigint; + gasLimit: bigint; + baseFeePerGas: bigint | null; + cachedAt: number; +} + +export interface RejectionReason { + executionTimeExceeded?: { + tx_time_us: number; + limit_us: number; + }; +} + +export interface RejectedTransaction { + blockNumber: number; + txHash: string; + reason: RejectionReason | string; + timestamp: number; + metering: MeterBundleResponse; +} + +export function formatRejectionReason( + reason: RejectionReason | string, +): string { + if (typeof reason === "string") return reason; + if (reason?.executionTimeExceeded) { + const { tx_time_us, limit_us } = reason.executionTimeExceeded; + return `Execution time exceeded: ${tx_time_us.toLocaleString()}μs > ${limit_us.toLocaleString()}μs limit`; + } + return "Unknown reason"; +} diff --git a/src/lib/transaction-list.test.ts b/src/lib/transaction-list.test.ts new file mode 100644 index 00000000..9723ddc1 --- /dev/null +++ b/src/lib/transaction-list.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + formatTransactionCursor, + parseTransactionCursor, + parseTransactionListQuery, +} from "./transaction-list"; + +describe("transaction list cursors", () => { + test("round-trips compound positions", () => { + const position = parseTransactionCursor("100:7"); + assert.deepEqual(position, { blockNumber: 100, transactionIndex: 7 }); + assert.equal( + formatTransactionCursor( + position ?? { blockNumber: 0, transactionIndex: 0 }, + ), + "100:7", + ); + }); + + test("rejects malformed cursors", () => { + assert.throws( + () => parseTransactionCursor("100"), + /blockNumber:transactionIndex/, + ); + assert.throws( + () => parseTransactionCursor("100:-1"), + /transaction index must be a non-negative integer/, + ); + assert.throws( + () => parseTransactionCursor("100:1:2"), + /blockNumber:transactionIndex/, + ); + }); + + test("parses bounded page sizes", () => { + assert.deepEqual( + parseTransactionListQuery(new URLSearchParams("limit=50&cursor=100:7")), + { + limit: 50, + cursor: { blockNumber: 100, transactionIndex: 7 }, + }, + ); + assert.throws( + () => parseTransactionListQuery(new URLSearchParams("limit=101")), + /limit must be between/, + ); + }); +}); diff --git a/src/lib/transaction-list.ts b/src/lib/transaction-list.ts new file mode 100644 index 00000000..b9370a00 --- /dev/null +++ b/src/lib/transaction-list.ts @@ -0,0 +1,270 @@ +import { createPublicClient, http } from "viem"; +import { mainnet } from "viem/chains"; +import { calculateTransactionFee } from "@/lib/explorer-format"; +import { + getTransactionReceiptSummaries, + type ReceiptSummary, +} from "@/lib/receipts"; + +const RPC_URL = process.env.TIPS_UI_RPC_URL || "http://localhost:8545"; +export const DEFAULT_TRANSACTION_PAGE_LIMIT = 50; +export const MAX_TRANSACTION_PAGE_LIMIT = 100; + +const client = createPublicClient({ + chain: mainnet, + transport: http(RPC_URL), +}); + +export interface TransactionPosition { + blockNumber: number; + transactionIndex: number; +} + +export interface TransactionListItem { + hash: string; + blockHash: string; + blockNumber: number; + transactionIndex: number; + blockTimestamp: number; + from: string; + to: string | null; + input: string; + value: string; + gasLimit: string; + gasUsed: string | null; + effectiveGasPrice: string | null; + transactionFee: string | null; + bundleId: string | null; + metering: { + transaction: unknown; + bundle: unknown; + } | null; +} + +export interface TransactionsResponse { + transactions: TransactionListItem[]; + nextCursor: string | null; + hasMore: boolean; + latestBlockNumber: number; +} + +export interface TransactionListQuery { + cursor: TransactionPosition | null; + limit: number; +} + +export class InvalidTransactionListQueryError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidTransactionListQueryError"; + } +} + +export class TransactionListUnavailableError extends Error { + constructor(message = "transaction list unavailable") { + super(message); + this.name = "TransactionListUnavailableError"; + } +} + +function parseNonNegativeInteger(value: string, name: string): number { + if (!/^(0|[1-9]\d*)$/.test(value)) { + throw new InvalidTransactionListQueryError( + `${name} must be a non-negative integer`, + ); + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new InvalidTransactionListQueryError(`${name} is too large`); + } + return parsed; +} + +export function formatTransactionCursor(position: TransactionPosition): string { + return `${position.blockNumber}:${position.transactionIndex}`; +} + +export function parseTransactionCursor( + value: string | null, +): TransactionPosition | null { + if (value === null) return null; + const parts = value.split(":"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new InvalidTransactionListQueryError( + "cursor must be formatted as blockNumber:transactionIndex", + ); + } + + return { + blockNumber: parseNonNegativeInteger(parts[0], "cursor block number"), + transactionIndex: parseNonNegativeInteger( + parts[1], + "cursor transaction index", + ), + }; +} + +export function parseTransactionListQuery( + searchParams: URLSearchParams, +): TransactionListQuery { + const rawLimit = searchParams.get("limit"); + const limit = + rawLimit === null + ? DEFAULT_TRANSACTION_PAGE_LIMIT + : parseNonNegativeInteger(rawLimit, "limit"); + if (limit < 1 || limit > MAX_TRANSACTION_PAGE_LIMIT) { + throw new InvalidTransactionListQueryError( + `limit must be between 1 and ${MAX_TRANSACTION_PAGE_LIMIT}`, + ); + } + + return { + cursor: parseTransactionCursor(searchParams.get("cursor")), + limit, + }; +} + +function serializeTransaction( + block: Awaited> & { + transactions: readonly unknown[]; + }, + transaction: { + hash: string; + from: string; + to: string | null; + input: string; + value: bigint; + gas: bigint; + }, + transactionIndex: number, + receipt: ReceiptSummary | null, +): TransactionListItem { + const effectiveGasPrice = receipt?.effectiveGasPrice ?? null; + const transactionFee = calculateTransactionFee( + receipt?.gasUsed, + effectiveGasPrice, + ); + + return { + hash: transaction.hash, + blockHash: block.hash ?? "0x", + blockNumber: Number(block.number), + transactionIndex, + blockTimestamp: Number(block.timestamp), + from: transaction.from, + to: transaction.to, + input: transaction.input, + value: transaction.value.toString(), + gasLimit: transaction.gas.toString(), + gasUsed: receipt?.gasUsed.toString() ?? null, + effectiveGasPrice: effectiveGasPrice?.toString() ?? null, + transactionFee: transactionFee?.toString() ?? null, + bundleId: null, + metering: null, + }; +} + +export async function listTransactions( + query: TransactionListQuery, +): Promise { + let latestBlockNumber: bigint; + try { + latestBlockNumber = await client.getBlockNumber(); + } catch { + throw new TransactionListUnavailableError("failed to fetch latest block"); + } + + const latestBlockNumberAsNumber = Number(latestBlockNumber); + if (!Number.isSafeInteger(latestBlockNumberAsNumber)) { + throw new TransactionListUnavailableError("latest block is too large"); + } + + let currentBlockNumber = BigInt( + Math.min( + query.cursor?.blockNumber ?? latestBlockNumberAsNumber, + latestBlockNumberAsNumber, + ), + ); + let maxTransactionIndex = query.cursor + ? query.cursor.transactionIndex - 1 + : Number.POSITIVE_INFINITY; + const rawTransactions: Array<{ + block: Awaited> & { + transactions: readonly unknown[]; + }; + transaction: { + hash: string; + from: string; + to: string | null; + input: string; + value: bigint; + gas: bigint; + }; + transactionIndex: number; + }> = []; + + while (rawTransactions.length < query.limit && currentBlockNumber >= 0n) { + let block: Awaited>; + try { + block = await client.getBlock({ + blockNumber: currentBlockNumber, + includeTransactions: true, + }); + } catch { + throw new TransactionListUnavailableError( + `failed to fetch block ${currentBlockNumber.toString()}`, + ); + } + + const transactions = block.transactions; + const startIndex = Math.min(maxTransactionIndex, transactions.length - 1); + for ( + let transactionIndex = startIndex; + transactionIndex >= 0 && rawTransactions.length < query.limit; + transactionIndex -= 1 + ) { + const transaction = transactions[transactionIndex]; + if (!transaction || typeof transaction !== "object") continue; + rawTransactions.push({ + block: block as (typeof rawTransactions)[number]["block"], + transaction: + transaction as (typeof rawTransactions)[number]["transaction"], + transactionIndex, + }); + } + + currentBlockNumber -= 1n; + maxTransactionIndex = Number.POSITIVE_INFINITY; + } + + const receiptByHash = await getTransactionReceiptSummaries( + rawTransactions.map(({ transaction }) => transaction.hash), + ); + const transactions = rawTransactions.map( + ({ block, transaction, transactionIndex }) => { + const receipt = receiptByHash.get(transaction.hash) ?? null; + return serializeTransaction( + block, + transaction, + transactionIndex, + receipt, + ); + }, + ); + const hasMore = + transactions.length === query.limit && currentBlockNumber >= 0n; + + return { + transactions, + nextCursor: + hasMore && transactions.length > 0 + ? formatTransactionCursor({ + blockNumber: transactions.at(-1)?.blockNumber ?? 0, + transactionIndex: transactions.at(-1)?.transactionIndex ?? 0, + }) + : null, + hasMore, + latestBlockNumber: latestBlockNumberAsNumber, + }; +} diff --git a/src/lib/transaction-lookup.test.ts b/src/lib/transaction-lookup.test.ts new file mode 100644 index 00000000..01797354 --- /dev/null +++ b/src/lib/transaction-lookup.test.ts @@ -0,0 +1,273 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import type { AuditTransactionEventRecord } from "./audit-events"; +import type { BundleHistory } from "./transaction-data"; +import { + type ChainLookupResult, + type ChainTransactionData, + lookupTransaction, + type TransactionLookupDependencies, +} from "./transaction-lookup"; + +const txHash = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const blockHash = + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +function event( + overrides: Partial = {}, +): AuditTransactionEventRecord { + return { + schema_version: "transaction-event/v1", + event_id: "event-1", + event_time: "2026-06-02T00:00:00Z", + ingested_at: "2026-06-02T00:00:01Z", + producer: "base-routing/proxyd", + event_type: "PROXY_RECEIVED", + tx_hash: txHash, + block_hash: null, + block_number: null, + payload_id: null, + request_id: null, + data: {}, + ...overrides, + }; +} + +function chainData(): ChainTransactionData { + return { + transaction: { + hash: txHash, + blockHash, + blockNumber: "0x10", + transactionIndex: "0x1", + from: "0x1111111111111111111111111111111111111111", + to: "0x2222222222222222222222222222222222222222", + nonce: "0x0", + type: "0x0", + chainId: "0x14a34", + value: "0x0", + gas: "0x5208", + gasPrice: "0x1", + maxFeePerGas: null, + maxPriorityFeePerGas: null, + input: "0x", + accessList: [], + r: null, + s: null, + v: null, + yParity: null, + }, + receipt: { + transactionHash: txHash, + blockHash, + blockNumber: "0x10", + transactionIndex: "0x1", + status: "success", + gasUsed: "0x5208", + cumulativeGasUsed: "0x5208", + effectiveGasPrice: "0x1", + contractAddress: null, + }, + }; +} + +function dependencies( + overrides: Partial = {}, +): TransactionLookupDependencies { + return { + auditConfigured: true, + getTransactionEventsByHash: async () => [], + getJoinedAuditEventsByBundle: async () => [], + getAuditEventsByBlockHash: async () => [], + getAuditEventsByBlockNumber: async () => [], + getTransactionMetadataByHash: async () => null, + getBundleHistory: async () => null, + getChainData: async (): Promise => ({ + status: "empty", + data: null, + }), + ...overrides, + }; +} + +describe("transaction lookup", () => { + test("returns transaction-scoped audit events without a bundle", async () => { + const proxyEvent = event(); + const result = await lookupTransaction( + txHash, + dependencies({ + getTransactionEventsByHash: async () => [proxyEvent], + getChainData: async () => ({ + status: "available", + data: chainData(), + }), + }), + ); + + assert.equal(result.found, true); + assert.deepEqual(result.response.bundle_ids, []); + assert.deepEqual(result.response.audit.transaction_events, [proxyEvent]); + assert.equal(result.response.history[0]?.event, "PROXY_RECEIVED"); + assert.equal(result.response.coverage.audit, "available"); + assert.equal(result.response.coverage.block_events, "empty"); + }); + + test("enriches audit events from bundle and block queries", async () => { + const simulationEvent = event({ + event_id: "simulation-event", + event_type: "SIMULATION_SUCCEEDED", + producer: "ingress-rpc", + data: { + bundle_hash: "0xbundle", + meter_bundle_response: { + bundleHash: "0xbundle", + results: [], + }, + }, + }); + const includedEvent = event({ + event_id: "included-event", + event_type: "TXPOOL_BLOCK_INCLUDED", + producer: "base-reth-node", + block_hash: blockHash, + block_number: 16, + }); + + const result = await lookupTransaction( + txHash, + dependencies({ + getTransactionEventsByHash: async () => [simulationEvent], + getJoinedAuditEventsByBundle: async () => [simulationEvent], + getAuditEventsByBlockHash: async () => [includedEvent], + getAuditEventsByBlockNumber: async () => [includedEvent], + getChainData: async () => ({ + status: "available", + data: chainData(), + }), + }), + ); + + assert.equal(result.found, true); + assert.deepEqual(result.response.bundle_ids, ["0xbundle"]); + assert.equal(result.response.audit.block_events.length, 1); + assert.equal(result.response.audit.events.length, 2); + assert.equal(result.response.coverage.block_events, "available"); + assert.equal(result.response.history.length, 2); + }); + + test("returns a chain-only transaction when audit is disabled", async () => { + const result = await lookupTransaction( + txHash, + dependencies({ + auditConfigured: false, + getChainData: async () => ({ + status: "available", + data: chainData(), + }), + }), + ); + + assert.equal(result.found, true); + assert.ok(result.response.chain); + assert.equal(result.response.coverage.audit, "disabled"); + assert.equal(result.response.coverage.block_events, "disabled"); + }); + + test("merges audit events with every available archive history", async () => { + const proxyEvent = event(); + const archiveHistory: BundleHistory = { + history: [ + { + event: "Received", + data: { + key: "archive-event", + timestamp: Date.parse("2026-06-02T00:00:02Z"), + originalEvent: { source: "s3" }, + }, + }, + ], + }; + const result = await lookupTransaction( + txHash, + dependencies({ + getTransactionEventsByHash: async () => [ + event({ + event_id: "bundle-event", + data: { bundle_hash: "0xbundle" }, + }), + proxyEvent, + ], + getTransactionMetadataByHash: async () => ({ + bundle_ids: ["0xbundle"], + sender: "", + nonce: "", + }), + getBundleHistory: async () => archiveHistory, + }), + ); + + assert.equal(result.found, true); + assert.deepEqual(result.response.archive.histories, [ + { key: "0xbundle", history: archiveHistory.history }, + ]); + assert.equal(result.response.history.length, 3); + assert.equal(result.response.coverage.archive, "available"); + }); + + test("keeps chain data when audit and archive queries fail", async () => { + const result = await lookupTransaction( + txHash, + dependencies({ + getTransactionEventsByHash: async () => { + throw new Error("audit unavailable"); + }, + getTransactionMetadataByHash: async () => { + throw new Error("archive unavailable"); + }, + getChainData: async () => ({ + status: "available", + data: chainData(), + }), + }), + ); + + assert.equal(result.found, true); + assert.ok(result.response.chain); + assert.equal(result.response.coverage.audit, "unavailable"); + assert.equal(result.response.coverage.archive, "unavailable"); + }); + + test("reports a true all-sources-missing lookup", async () => { + const result = await lookupTransaction(txHash, dependencies()); + + assert.equal(result.found, false); + assert.equal(result.unavailable, false); + assert.equal(result.response.coverage.audit, "empty"); + assert.equal(result.response.coverage.chain, "empty"); + assert.equal(result.response.coverage.archive, "empty"); + }); + + test("distinguishes unavailable sources from a missing transaction", async () => { + const result = await lookupTransaction( + txHash, + dependencies({ + getTransactionEventsByHash: async () => { + throw new Error("audit unavailable"); + }, + getTransactionMetadataByHash: async () => { + throw new Error("archive unavailable"); + }, + getChainData: async () => { + throw new Error("rpc unavailable"); + }, + }), + ); + + assert.equal(result.found, false); + assert.equal(result.unavailable, true); + assert.equal(result.response.coverage.audit, "unavailable"); + assert.equal(result.response.coverage.chain, "unavailable"); + assert.equal(result.response.coverage.archive, "unavailable"); + }); +}); diff --git a/src/lib/transaction-lookup.ts b/src/lib/transaction-lookup.ts new file mode 100644 index 00000000..911d4057 --- /dev/null +++ b/src/lib/transaction-lookup.ts @@ -0,0 +1,526 @@ +import { createPublicClient, type Hash, http } from "viem"; +import { mainnet } from "viem/chains"; +import { + type AuditTransactionEventRecord, + DEFAULT_AUDIT_EVENT_QUERY_LIMIT, + getAuditEventsByBlockHash, + getAuditEventsByBlockNumber, + getAuditEventsByTransactionHash, + getJoinedAuditEventsByBundle, + isAuditConfigured, + mergeAuditEvents, + transactionBundleKeysFromAuditEvents, + transactionHistoryFromAuditEvents, +} from "@/lib/audit-events"; +import { getBundleHistory, getTransactionMetadataByHash } from "@/lib/s3"; +import type { + BundleEvent, + BundleHistory, + TransactionMetadata, +} from "@/lib/transaction-data"; + +const RPC_URL = process.env.TIPS_UI_RPC_URL || "http://localhost:8545"; + +const client = createPublicClient({ + chain: mainnet, + transport: http(RPC_URL), +}); + +export type CoverageState = + | "available" + | "empty" + | "disabled" + | "unavailable" + | "not_applicable"; + +export interface ChainTransaction { + hash: string; + blockHash: string | null; + blockNumber: string | null; + transactionIndex: string | null; + from: string; + to: string | null; + nonce: string; + type: string; + chainId: string | null; + value: string; + gas: string; + gasPrice: string | null; + maxFeePerGas: string | null; + maxPriorityFeePerGas: string | null; + input: string; + accessList: unknown[]; + r: string | null; + s: string | null; + v: string | null; + yParity: string | null; +} + +export interface ChainReceipt { + transactionHash: string; + blockHash: string; + blockNumber: string; + transactionIndex: string; + status: string; + gasUsed: string; + cumulativeGasUsed: string; + effectiveGasPrice: string | null; + contractAddress: string | null; +} + +export interface ChainTransactionData { + transaction: ChainTransaction; + receipt: ChainReceipt | null; +} + +export interface TransactionArchiveHistory { + key: string; + history: BundleEvent[]; +} + +export interface TransactionAuditSource { + configured: boolean; + events: AuditTransactionEventRecord[]; + transaction_events: AuditTransactionEventRecord[]; + related_events: AuditTransactionEventRecord[]; + block_events: AuditTransactionEventRecord[]; +} + +export interface TransactionArchiveSource { + metadata: TransactionMetadata | null; + histories: TransactionArchiveHistory[]; +} + +export interface TransactionCoverage { + audit: CoverageState; + chain: CoverageState; + archive: CoverageState; + block_events: CoverageState; +} + +export interface TransactionLookupResponse { + hash: string; + bundle_ids: string[]; + history: BundleEvent[]; + audit: TransactionAuditSource; + chain: ChainTransactionData | null; + archive: TransactionArchiveSource; + coverage: TransactionCoverage; +} + +export interface ChainLookupResult { + status: Extract; + data: ChainTransactionData | null; +} + +export interface TransactionLookupDependencies { + auditConfigured: boolean; + getTransactionEventsByHash: ( + hash: string, + limit: number, + ) => Promise; + getJoinedAuditEventsByBundle: ( + bundleKey: string, + limit: number, + ) => Promise; + getAuditEventsByBlockHash: ( + blockHash: string, + limit: number, + ) => Promise; + getAuditEventsByBlockNumber: ( + blockNumber: number, + limit: number, + ) => Promise; + getTransactionMetadataByHash: ( + hash: string, + ) => Promise; + getBundleHistory: (bundleKey: string) => Promise; + getChainData: (hash: string) => Promise; +} + +export class InvalidTransactionHashError extends Error { + constructor() { + super("invalid transaction hash"); + this.name = "InvalidTransactionHashError"; + } +} + +export async function lookupTransaction( + inputHash: string, + dependencies: TransactionLookupDependencies = defaultDependencies(), +): Promise<{ + found: boolean; + unavailable: boolean; + response: TransactionLookupResponse; +}> { + const hash = normalizeTransactionHash(inputHash); + + const [auditResult, chainResult, archiveResult] = await Promise.all([ + loadTransactionAuditEvents(hash, dependencies), + loadChainData(hash, dependencies), + loadArchiveSource(hash, dependencies), + ]); + + const bundleKeys = uniqueStrings([ + ...transactionBundleKeysFromAuditEvents(auditResult.transactionEvents).all, + ...(archiveResult.metadata?.bundle_ids ?? []), + ]); + + const relatedEvents = await loadRelatedAuditEvents(bundleKeys, dependencies); + const blockResult = await loadBlockAuditEvents( + hash, + chainResult.data, + dependencies, + ); + const archiveHistories = await loadArchiveHistories( + bundleKeys, + archiveResult.histories, + dependencies, + ); + + const auditEvents = mergeAuditEvents([ + auditResult.transactionEvents, + relatedEvents, + blockResult.events, + ]); + const auditHistory = transactionHistoryFromAuditEvents(hash, auditEvents); + const history = mergeBundleEvents([ + auditHistory, + ...archiveHistories.map((entry) => entry.history), + ]); + + const auditState = + auditResult.status === "disabled" + ? "disabled" + : auditEvents.length > 0 + ? "available" + : auditResult.status; + const archiveState = + archiveResult.metadata !== null || archiveHistories.length > 0 + ? "available" + : archiveResult.status; + const found = + auditEvents.length > 0 || + chainResult.data !== null || + archiveResult.metadata !== null || + archiveHistories.length > 0; + const unavailable = + !found && + [auditState, chainResult.status, archiveState].includes("unavailable"); + + return { + found, + unavailable, + response: { + hash, + bundle_ids: bundleKeys, + history, + audit: { + configured: dependencies.auditConfigured, + events: auditEvents, + transaction_events: auditResult.transactionEvents, + related_events: relatedEvents, + block_events: blockResult.events, + }, + chain: chainResult.data, + archive: { + metadata: archiveResult.metadata, + histories: archiveHistories, + }, + coverage: { + audit: auditState, + chain: chainResult.status, + archive: archiveState, + block_events: blockResult.status, + }, + }, + }; +} + +async function loadChainData( + hash: string, + dependencies: TransactionLookupDependencies, +): Promise { + try { + return await dependencies.getChainData(hash); + } catch { + return { status: "unavailable", data: null }; + } +} + +function defaultDependencies(): TransactionLookupDependencies { + return { + auditConfigured: isAuditConfigured(), + getTransactionEventsByHash: getAuditEventsByTransactionHash, + getJoinedAuditEventsByBundle, + getAuditEventsByBlockHash, + getAuditEventsByBlockNumber, + getTransactionMetadataByHash, + getBundleHistory, + getChainData: getChainDataFromRpc, + }; +} + +async function loadTransactionAuditEvents( + hash: string, + dependencies: TransactionLookupDependencies, +): Promise<{ + status: Extract< + CoverageState, + "available" | "empty" | "disabled" | "unavailable" + >; + transactionEvents: AuditTransactionEventRecord[]; +}> { + if (!dependencies.auditConfigured) { + return { status: "disabled", transactionEvents: [] }; + } + + try { + const transactionEvents = await dependencies.getTransactionEventsByHash( + hash, + DEFAULT_AUDIT_EVENT_QUERY_LIMIT, + ); + return { + status: transactionEvents.length > 0 ? "available" : "empty", + transactionEvents, + }; + } catch { + return { status: "unavailable", transactionEvents: [] }; + } +} + +async function loadRelatedAuditEvents( + bundleKeys: string[], + dependencies: TransactionLookupDependencies, +): Promise { + if (!dependencies.auditConfigured || bundleKeys.length === 0) { + return []; + } + + const results = await Promise.allSettled( + bundleKeys.map((bundleKey) => + dependencies.getJoinedAuditEventsByBundle( + bundleKey, + DEFAULT_AUDIT_EVENT_QUERY_LIMIT, + ), + ), + ); + + return mergeAuditEvents( + results.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ), + ); +} + +async function loadBlockAuditEvents( + hash: string, + chain: ChainTransactionData | null, + dependencies: TransactionLookupDependencies, +): Promise<{ + status: Extract< + CoverageState, + "available" | "empty" | "disabled" | "unavailable" | "not_applicable" + >; + events: AuditTransactionEventRecord[]; +}> { + if (!dependencies.auditConfigured) { + return { status: "disabled", events: [] }; + } + + const blockHash = + chain?.receipt?.blockHash ?? chain?.transaction.blockHash ?? null; + const blockNumber = + chain?.receipt?.blockNumber ?? chain?.transaction.blockNumber ?? null; + if (blockHash === null && blockNumber === null) { + return { status: "not_applicable", events: [] }; + } + + const queries: Array> = []; + if (blockHash !== null) { + queries.push( + dependencies.getAuditEventsByBlockHash( + blockHash, + DEFAULT_AUDIT_EVENT_QUERY_LIMIT, + ), + ); + } + if (blockNumber !== null) { + const parsedBlockNumber = Number.parseInt(blockNumber, 16); + if (Number.isSafeInteger(parsedBlockNumber)) { + queries.push( + dependencies.getAuditEventsByBlockNumber( + parsedBlockNumber, + DEFAULT_AUDIT_EVENT_QUERY_LIMIT, + ), + ); + } + } + + const results = await Promise.allSettled(queries); + const successfulQueries = results.filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled", + ); + if (successfulQueries.length === 0) { + return { status: "unavailable", events: [] }; + } + + const events = mergeAuditEvents( + successfulQueries.map((result) => + result.value.filter((event) => event.tx_hash?.toLowerCase() === hash), + ), + ); + return { + status: events.length > 0 ? "available" : "empty", + events, + }; +} + +async function loadArchiveSource( + hash: string, + dependencies: TransactionLookupDependencies, +): Promise<{ + status: Extract; + metadata: TransactionMetadata | null; + histories: TransactionArchiveHistory[]; +}> { + try { + const metadata = await dependencies.getTransactionMetadataByHash(hash); + return { + status: metadata === null ? "empty" : "available", + metadata, + histories: [], + }; + } catch { + return { status: "unavailable", metadata: null, histories: [] }; + } +} + +async function loadArchiveHistories( + bundleKeys: string[], + existing: TransactionArchiveHistory[], + dependencies: TransactionLookupDependencies, +): Promise { + const existingKeys = new Set(existing.map((entry) => entry.key)); + const additionalKeys = bundleKeys.filter((key) => !existingKeys.has(key)); + const results = await Promise.all( + additionalKeys.map(async (key) => { + try { + const bundle = await dependencies.getBundleHistory(key); + return bundle === null ? null : { key, history: bundle.history }; + } catch { + return null; + } + }), + ); + return [ + ...existing, + ...results.filter( + (entry): entry is TransactionArchiveHistory => entry !== null, + ), + ]; +} + +async function getChainDataFromRpc(hash: string): Promise { + try { + const transaction = await client.getTransaction({ hash: hash as Hash }); + if (!transaction) { + return { status: "empty", data: null }; + } + + let receipt: ChainReceipt | null = null; + try { + receipt = serializeReceipt( + await client.getTransactionReceipt({ hash: hash as Hash }), + ); + } catch { + // The transaction itself is still useful while receipt indexing catches up. + } + + return { + status: "available", + data: { + transaction: serializeTransaction(transaction), + receipt, + }, + }; + } catch { + return { status: "unavailable", data: null }; + } +} + +function serializeTransaction( + transaction: Awaited>, +): ChainTransaction { + return { + hash: transaction.hash, + blockHash: transaction.blockHash, + blockNumber: numericHex(transaction.blockNumber), + transactionIndex: numericHex(transaction.transactionIndex), + from: transaction.from, + to: transaction.to, + nonce: numericHex(transaction.nonce) ?? "0x0", + type: transaction.typeHex ?? transaction.type, + chainId: numericHex(transaction.chainId), + value: numericHex(transaction.value) ?? "0x0", + gas: numericHex(transaction.gas) ?? "0x0", + gasPrice: numericHex(transaction.gasPrice), + maxFeePerGas: numericHex(transaction.maxFeePerGas), + maxPriorityFeePerGas: numericHex(transaction.maxPriorityFeePerGas), + input: transaction.input, + accessList: Array.from(transaction.accessList ?? []), + r: transaction.r ?? null, + s: transaction.s ?? null, + v: numericHex(transaction.v), + yParity: numericHex(transaction.yParity), + }; +} + +function serializeReceipt( + receipt: Awaited>, +): ChainReceipt { + return { + transactionHash: receipt.transactionHash, + blockHash: receipt.blockHash, + blockNumber: numericHex(receipt.blockNumber) ?? "0x0", + transactionIndex: numericHex(receipt.transactionIndex) ?? "0x0", + status: receipt.status, + gasUsed: numericHex(receipt.gasUsed) ?? "0x0", + cumulativeGasUsed: numericHex(receipt.cumulativeGasUsed) ?? "0x0", + effectiveGasPrice: numericHex(receipt.effectiveGasPrice), + contractAddress: receipt.contractAddress ?? null, + }; +} + +function numericHex(value: bigint | number | null | undefined): string | null { + return value === null || value === undefined + ? null + : `0x${value.toString(16)}`; +} + +function mergeBundleEvents(eventGroups: BundleEvent[][]): BundleEvent[] { + const events = new Map(); + for (const group of eventGroups) { + for (const event of group) { + const key = `${event.event}:${event.data.key ?? `${event.data.timestamp}`}`; + events.set(key, event); + } + } + + return Array.from(events.values()).sort( + (lhs, rhs) => lhs.data.timestamp - rhs.data.timestamp, + ); +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.filter((value) => value.length > 0))); +} + +function normalizeTransactionHash(hash: string): string { + const normalized = hash.trim().toLowerCase(); + if (!/^0x[0-9a-f]{64}$/.test(normalized)) { + throw new InvalidTransactionHashError(); + } + return normalized; +}