From 778630296681b80feb3d8d42668fef7ac676ba43 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:41:35 -0600 Subject: [PATCH 01/32] docker --- package-lock.json | 868 ++++++++++++-------------------------- package.json | 5 +- tests/api/setup.ts | 12 +- tests/docker-container.ts | 259 ------------ tests/krypton/setup.ts | 14 +- tests/snp/setup.ts | 15 +- 6 files changed, 281 insertions(+), 892 deletions(-) delete mode 100644 tests/docker-container.ts diff --git a/package-lock.json b/package-lock.json index 03fadc9fb..ca598d64d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@fastify/http-proxy": "11.4.4", "@fastify/type-provider-typebox": "5.2.0", "@sinclair/typebox": "0.34.48", - "@stacks/api-toolkit": "1.12.2", + "@stacks/api-toolkit": "1.13.0", "@stacks/codec": "1.6.0", "@stacks/common": "7.3.1", "@stacks/encryption": "7.4.0", @@ -46,14 +46,13 @@ }, "devDependencies": { "@fastify/swagger": "9.7.0", + "@stacks/api-test-toolkit": "1.13.0", "@stacks/eslint-config": "3.0.0-develop.2", - "@types/dockerode": "3.3.39", "@types/node": "24.12.0", "@types/source-map-support": "0.5.4", "@types/split2": "2.1.6", "@types/supertest": "7.2.0", "@types/ws": "8.18.1", - "dockerode": "4.0.6", "eslint": "10.1.0", "eslint-plugin-prettier": "5.5.5", "eslint-plugin-tsdoc": "0.5.2", @@ -1016,7 +1015,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.7.0.tgz", "integrity": "sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==", - "dev": true, "funding": [ { "type": "github", @@ -1040,7 +1038,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "dev": true, "funding": [ { "type": "github", @@ -1600,180 +1597,97 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, - "node_modules/@stacks/api-toolkit": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@stacks/api-toolkit/-/api-toolkit-1.12.2.tgz", - "integrity": "sha512-CXVKs+WSIsEB7UhUHe9MCWc5A6IHQs6dt219UO5kAnRDOFarG8krZyzoI20GWZ98KJ7+M4sNeYjsgtNTxRHuKA==", + "node_modules/@stacks/api-test-toolkit": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@stacks/api-test-toolkit/-/api-test-toolkit-1.13.0.tgz", + "integrity": "sha512-PP1ilUWCAPAVnToltR4HU+f88QrwuWXrWjmH/PXwO8NLeq+ng7SMcnntj5CpWCEaHq5ANSKAWCKuO5nxNm1/fg==", + "dev": true, "license": "Apache 2.0", "dependencies": { - "@fastify/cors": "^8.0.0", - "@fastify/swagger": "^8.3.1", - "@fastify/type-provider-typebox": "^3.2.0", - "@sinclair/typebox": "^0.28.20", - "@types/node": "^22.14.1", - "fastify": "^4.3.0", - "fastify-metrics": "^10.2.0", - "node-pg-migrate": "^6.2.2", - "pino": "^8.11.0", - "postgres": "^3.3.4" - }, - "bin": { - "api-toolkit-git-info": "bin/api-toolkit-git-info.js" + "dockerode": "^4.0.10" }, "engines": { "node": ">=22" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/@fastify/ajv-compiler": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", - "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.11.0", - "ajv-formats": "^2.1.1", - "fast-uri": "^2.0.0" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/@fastify/ajv-compiler/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" }, "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "@stacks/api-toolkit": "^1.12.2" } }, - "node_modules/@stacks/api-toolkit/node_modules/@fastify/cors": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz", - "integrity": "sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==", - "license": "MIT", - "dependencies": { - "fastify-plugin": "^4.0.0", - "mnemonist": "0.39.6" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/@fastify/error": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", - "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", - "license": "MIT" - }, - "node_modules/@stacks/api-toolkit/node_modules/@fastify/fast-json-stringify-compiler": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", - "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", - "license": "MIT", - "dependencies": { - "fast-json-stringify": "^5.7.0" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/@fastify/merge-json-schemas": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", - "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", - "license": "MIT", + "node_modules/@stacks/api-test-toolkit/node_modules/dockerode": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.10.tgz", + "integrity": "sha512-8L/P9JynLBiG7/coiA4FlQXegHltRqS0a+KqI44P1zgQh8QLHTg7FKOwhkBgSJwZTeHsq30WRoVFLuwkfK0YFg==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "fast-deep-equal": "^3.1.3" + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" } }, - "node_modules/@stacks/api-toolkit/node_modules/@fastify/swagger": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-8.15.0.tgz", - "integrity": "sha512-zy+HEEKFqPMS2sFUsQU5X0MHplhKJvWeohBwTCkBAJA/GDYGLGUWQaETEhptiqxK7Hs0fQB9B4MDb3pbwIiCwA==", - "license": "MIT", + "node_modules/@stacks/api-toolkit": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@stacks/api-toolkit/-/api-toolkit-1.13.0.tgz", + "integrity": "sha512-1w2B1M5U+Xv8d2YqCRmaeTYMLU/QBy9y/uYEejz0albDStF09scVNd9FBHRBvAVGRaPTee9orh1+D4p6Zcg0vg==", + "license": "Apache 2.0", "dependencies": { - "fastify-plugin": "^4.0.0", - "json-schema-resolver": "^2.0.0", - "openapi-types": "^12.0.0", - "rfdc": "^1.3.0", - "yaml": "^2.2.2" + "@fastify/cors": "^11.2.0", + "@fastify/swagger": "^9.7.0", + "@fastify/type-provider-typebox": "^6.1.0", + "@sinclair/typebox": "^0.34.48", + "@types/node": "^24.12.2", + "fastify": "^5.8.4", + "fastify-metrics": "^12.1.0", + "node-pg-migrate": "^7.9.1", + "pino": "^8.11.0", + "postgres": "^3.4.8" + }, + "bin": { + "api-toolkit-git-info": "bin/api-toolkit-git-info.js" + }, + "engines": { + "node": ">=22" } }, "node_modules/@stacks/api-toolkit/node_modules/@fastify/type-provider-typebox": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@fastify/type-provider-typebox/-/type-provider-typebox-3.6.0.tgz", - "integrity": "sha512-HTeOLvirfGg0u1KGao3iXn5rZpYNqlrOmyDnXSXAbWVPa+mDQTTBNs/x5uZzOB6vFAqr0Xcf7x1lxOamNSYKjw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@fastify/type-provider-typebox/-/type-provider-typebox-6.1.0.tgz", + "integrity": "sha512-k29cOitDRcZhMXVjtRq0+caKxdWoArz7su+dQWGzGWnFG+fSKhevgiZ7nexHWuXOEEQzgJlh6cptIMu69beaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "peerDependencies": { - "@sinclair/typebox": ">=0.26 <=0.32" + "typebox": "^1.0.13" } }, - "node_modules/@stacks/api-toolkit/node_modules/@sinclair/typebox": { - "version": "0.28.20", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.28.20.tgz", - "integrity": "sha512-QCF3BGfacwD+3CKhGsMeixnwOmX4AWgm61nKkNdRStyLVu0mpVFYlDSY8gVBOOED1oSwzbJauIWl/+REj8K5+w==", - "license": "MIT", - "peer": true - }, "node_modules/@stacks/api-toolkit/node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/avvio": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", - "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "license": "MIT", "dependencies": { - "@fastify/error": "^3.3.0", - "fastq": "^1.17.1" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/fast-content-type-parse": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", - "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", - "license": "MIT" - }, - "node_modules/@stacks/api-toolkit/node_modules/fast-json-stringify": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", - "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.1.0", - "ajv": "^8.10.0", - "ajv-formats": "^3.0.1", - "fast-deep-equal": "^3.1.3", - "fast-uri": "^2.1.0", - "json-schema-ref-resolver": "^1.0.1", - "rfdc": "^1.2.0" + "undici-types": "~7.16.0" } }, - "node_modules/@stacks/api-toolkit/node_modules/fast-uri": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", - "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", - "license": "MIT" - }, "node_modules/@stacks/api-toolkit/node_modules/fastify": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", - "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", "funding": [ { "type": "github", @@ -1786,130 +1700,49 @@ ], "license": "MIT", "dependencies": { - "@fastify/ajv-compiler": "^3.5.0", - "@fastify/error": "^3.4.0", - "@fastify/fast-json-stringify-compiler": "^4.3.0", + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", - "avvio": "^8.3.0", - "fast-content-type-parse": "^1.1.0", - "fast-json-stringify": "^5.8.0", - "find-my-way": "^8.0.0", - "light-my-request": "^5.11.0", - "pino": "^9.0.0", - "process-warning": "^3.0.0", - "proxy-addr": "^2.0.7", - "rfdc": "^1.3.0", - "secure-json-parse": "^2.7.0", - "semver": "^7.5.4", - "toad-cache": "^3.3.0" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/fastify-metrics": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/fastify-metrics/-/fastify-metrics-10.6.0.tgz", - "integrity": "sha512-QIPncCnwBOEObMn+VaRhsBC1ox8qEsaiYF2sV/A1UbXj7ic70W8/HNn/hlEC2W8JQbBeZMx++o1um2fPfhsFDQ==", - "license": "MIT", - "dependencies": { - "fastify-plugin": "^4.3.0", - "prom-client": "^14.2.0" - }, - "peerDependencies": { - "fastify": ">=4" + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" } }, "node_modules/@stacks/api-toolkit/node_modules/fastify/node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", + "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" + "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, - "node_modules/@stacks/api-toolkit/node_modules/fastify/node_modules/pino/node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@stacks/api-toolkit/node_modules/find-my-way": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", - "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^3.1.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/json-schema-ref-resolver": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", - "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/json-schema-resolver": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-2.0.0.tgz", - "integrity": "sha512-pJ4XLQP4Q9HTxl6RVDLJ8Cyh1uitSs0CzDBAz1uoJ4sRD/Bk7cFSXL1FUXDW3zJ7YnfliJx6eu8Jn283bpZ4Yg==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "rfdc": "^1.1.4", - "uri-js": "^4.2.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/light-my-request": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", - "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", - "license": "BSD-3-Clause", - "dependencies": { - "cookie": "^0.7.0", - "process-warning": "^3.0.0", - "set-cookie-parser": "^2.4.1" - } - }, "node_modules/@stacks/api-toolkit/node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", "license": "MIT", "dependencies": { "split2": "^4.0.0" @@ -1921,48 +1754,6 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, - "node_modules/@stacks/api-toolkit/node_modules/process-warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", - "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", - "license": "MIT" - }, - "node_modules/@stacks/api-toolkit/node_modules/prom-client": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-14.2.0.tgz", - "integrity": "sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==", - "license": "Apache-2.0", - "dependencies": { - "tdigest": "^0.1.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/ret": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", - "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/safe-regex2": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", - "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", - "license": "MIT", - "dependencies": { - "ret": "~0.4.0" - } - }, - "node_modules/@stacks/api-toolkit/node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", - "license": "BSD-3-Clause" - }, "node_modules/@stacks/api-toolkit/node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -1982,18 +1773,21 @@ } }, "node_modules/@stacks/api-toolkit/node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", "license": "MIT", "dependencies": { "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/@stacks/api-toolkit/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/@stacks/codec": { @@ -2174,29 +1968,6 @@ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" }, - "node_modules/@types/docker-modem": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", - "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/ssh2": "*" - } - }, - "node_modules/@types/dockerode": { - "version": "3.3.39", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.39.tgz", - "integrity": "sha512-uMPmxehH6ofeYjaslASPtjvyH8FRJdM9fZ+hjhGzL4Jq3bGjr9D7TKmp9soSwgFncNk0HOwmyBxjqOb3ikjjsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/docker-modem": "*", - "@types/node": "*", - "@types/ssh2": "*" - } - }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2275,26 +2046,6 @@ "@types/node": "*" } }, - "node_modules/@types/ssh2": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", - "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "^18.11.18" - } - }, - "node_modules/@types/ssh2/node_modules/@types/node": { - "version": "18.19.103", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.103.tgz", - "integrity": "sha512-hHTHp+sEz6SxFsp+SA+Tqrua3AbmlAw+Y//aEwdHrdZkYVRWdvWD3y5uPZ0flYOkgskaFWqZ/YGFm3FaFQ0pRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, "node_modules/@types/superagent": { "version": "8.1.8", "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.8.tgz", @@ -3497,7 +3248,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -3666,7 +3416,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3703,17 +3452,6 @@ } } }, - "node_modules/decamelize": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", - "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3776,9 +3514,9 @@ } }, "node_modules/docker-modem": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", - "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3791,39 +3529,6 @@ "node": ">= 8.0" } }, - "node_modules/dockerode": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.6.tgz", - "integrity": "sha512-FbVf3Z8fY/kALB9s+P9epCpWhfi/r0N2DgYYcYpsAUlaTxPjdsitsFobnltb+lyCgAIvf9C+4PSWlTnHlJMf1w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@balena/dockerignore": "^1.0.2", - "@grpc/grpc-js": "^1.11.1", - "@grpc/proto-loader": "^0.7.13", - "docker-modem": "^5.0.6", - "protobufjs": "^7.3.2", - "tar-fs": "~2.1.2", - "uuid": "^10.0.0" - }, - "engines": { - "node": ">= 8.0" - } - }, - "node_modules/dockerode/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4723,11 +4428,6 @@ ], "license": "MIT" }, - "node_modules/fastify-plugin": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", - "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==" - }, "node_modules/fastify/node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -4824,12 +4524,12 @@ "dev": true }, "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -4881,15 +4581,6 @@ "url": "https://ko-fi.com/tunnckoCore/commissions" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -4990,6 +4681,106 @@ "assert-plus": "^1.0.0" } }, + "node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5247,8 +5038,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/isomorphic-ws": { "version": "4.0.1", @@ -5347,7 +5137,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", @@ -5490,6 +5279,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -5560,15 +5358,13 @@ "node": ">=10" } }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "bin": { - "mkdirp": "bin/cmd.js" - }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" } }, "node_modules/mkdirp-classic": { @@ -5578,14 +5374,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mnemonist": { - "version": "0.39.6", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", - "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==", - "dependencies": { - "obliterator": "^2.0.1" - } - }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -5628,114 +5416,30 @@ } }, "node_modules/node-pg-migrate": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-6.2.2.tgz", - "integrity": "sha512-0WYLTXpWu2doeZhiwJUW/1u21OqAFU2CMQ8YZ8VBcJ0xrdqYAjtd8GGFe5A5DM4NJdIZsqJcLPDFqY0FQsmivw==", - "deprecated": "Version no longer supported. Upgrade to @latest", + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-7.9.1.tgz", + "integrity": "sha512-6z4OSN27ye8aYdX9ZU7NN2PTI5pOp34hTr+22Ej12djIYECq++gT7LPLZVOQXEeVCBOZQLqf87kC3Y36G434OQ==", "license": "MIT", "dependencies": { - "@types/pg": "^8.0.0", - "decamelize": "^5.0.0", - "mkdirp": "~1.0.0", - "yargs": "~17.3.0" + "glob": "~11.0.0", + "yargs": "~17.7.0" }, "bin": { - "node-pg-migrate": "bin/node-pg-migrate" + "node-pg-migrate": "bin/node-pg-migrate.js", + "node-pg-migrate-cjs": "bin/node-pg-migrate.js", + "node-pg-migrate-esm": "bin/node-pg-migrate.mjs" }, "engines": { - "node": ">=12.20.0" + "node": ">=18.19.0" }, "peerDependencies": { + "@types/pg": ">=6.0.0 <9.0.0", "pg": ">=4.3.0 <9.0.0" - } - }, - "node_modules/node-pg-migrate/node_modules/@types/pg": { - "version": "8.11.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", - "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^4.0.1" - } - }, - "node_modules/node-pg-migrate/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/node-pg-migrate/node_modules/pg-types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", - "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", - "dependencies": { - "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-pg-migrate/node_modules/postgres-array": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", - "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", - "engines": { - "node": ">=12" - } - }, - "node_modules/node-pg-migrate/node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", - "dependencies": { - "obuf": "~1.1.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/node-pg-migrate/node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/node-pg-migrate/node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", - "engines": { - "node": ">=12" - } - }, - "node_modules/node-pg-migrate/node_modules/yargs": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz", - "integrity": "sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA==", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.0.0" - }, - "engines": { - "node": ">=12" + "peerDependenciesMeta": { + "@types/pg": { + "optional": true + } } }, "node_modules/oauth-sign": { @@ -5767,16 +5471,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obliterator": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", - "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==" - }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" - }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -5921,14 +5615,12 @@ "node_modules/package-json-from-dist": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", - "dev": true + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==" }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -5961,15 +5653,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, - "node_modules/path-scurry/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -6020,18 +5703,11 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", "engines": { "node": ">=4.0.0" } }, - "node_modules/pg-numeric": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", - "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", - "engines": { - "node": ">=4" - } - }, "node_modules/pg-pool": { "version": "3.13.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", @@ -6235,11 +5911,6 @@ "node": ">=0.10.0" } }, - "node_modules/postgres-range": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", - "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6349,28 +6020,6 @@ "node": ">=12.0.0" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -6695,15 +6344,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ripemd160-min": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/ripemd160-min/-/ripemd160-min-0.0.6.tgz", @@ -6881,7 +6521,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -6893,7 +6532,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -6978,7 +6616,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -7575,6 +7212,13 @@ "node": ">= 0.8.0" } }, + "node_modules/typebox": { + "version": "1.1.32", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.32.tgz", + "integrity": "sha512-cbGoj7BCxGcFDJ/RR7wbyMe9IkO2SeNhwLdZWQ+xRtun9+ze9iM1pBND4SoFAxgonuJYrCIWnEQ7sE4bMVDYHA==", + "license": "MIT", + "peer": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -7656,12 +7300,6 @@ "node": ">=18.17" } }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -7682,6 +7320,20 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -7749,7 +7401,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -7886,7 +7537,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", diff --git a/package.json b/package.json index 64cf4d104..0da0d2a35 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@fastify/http-proxy": "11.4.4", "@fastify/type-provider-typebox": "5.2.0", "@sinclair/typebox": "0.34.48", - "@stacks/api-toolkit": "1.12.2", + "@stacks/api-toolkit": "1.13.0", "@stacks/codec": "1.6.0", "@stacks/common": "7.3.1", "@stacks/encryption": "7.4.0", @@ -96,14 +96,13 @@ }, "devDependencies": { "@fastify/swagger": "9.7.0", + "@stacks/api-test-toolkit": "1.13.0", "@stacks/eslint-config": "3.0.0-develop.2", - "@types/dockerode": "3.3.39", "@types/node": "24.12.0", "@types/source-map-support": "0.5.4", "@types/split2": "2.1.6", "@types/supertest": "7.2.0", "@types/ws": "8.18.1", - "dockerode": "4.0.6", "eslint": "10.1.0", "eslint-plugin-prettier": "5.5.5", "eslint-plugin-tsdoc": "0.5.2", diff --git a/tests/api/setup.ts b/tests/api/setup.ts index f5b6c6369..200504181 100644 --- a/tests/api/setup.ts +++ b/tests/api/setup.ts @@ -1,8 +1,8 @@ -import type { ContainerConfig } from '../docker-container.ts'; -import { runDown, runUp } from '../docker-container.ts'; +import type { DockerTestContainerConfig } from '@stacks/api-test-toolkit'; +import { dockerTestDown, dockerTestUp } from '@stacks/api-test-toolkit'; -function defaultContainers(): ContainerConfig[] { - const postgres: ContainerConfig = { +function defaultContainers(): DockerTestContainerConfig[] { + const postgres: DockerTestContainerConfig = { image: 'postgres:17', name: `stacks-api-test-postgres`, ports: [{ host: 5490, container: 5432 }], @@ -27,7 +27,7 @@ function defaultContainers(): ContainerConfig[] { export async function globalSetup() { const containers = defaultContainers(); for (const config of containers) { - await runUp(config); + await dockerTestUp({ config }); } process.stdout.write(`[testenv:api] all containers ready\n`); } @@ -35,7 +35,7 @@ export async function globalSetup() { export async function globalTeardown() { const containers = defaultContainers(); for (const config of [...containers].reverse()) { - await runDown(config); + await dockerTestDown({ config }); } process.stdout.write(`[testenv:api] all containers removed\n`); } diff --git a/tests/docker-container.ts b/tests/docker-container.ts deleted file mode 100644 index d68d54856..000000000 --- a/tests/docker-container.ts +++ /dev/null @@ -1,259 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -import { strict as assert } from 'node:assert'; -import * as net from 'node:net'; -import Docker from 'dockerode'; - -export interface PortMapping { - host: number; - container: number; -} - -export interface ContainerConfig { - /** Docker image (e.g. "postgres:17") */ - image: string; - /** Container name */ - name: string; - /** Host to bind to (default: "127.0.0.1") */ - host?: string; - /** Port mappings (host → container) */ - ports: PortMapping[]; - /** Port to wait on before declaring the container ready (default: first port's host side) */ - waitPort?: number; - /** Set to false to skip the port-readiness check (e.g. for one-shot sidecars) */ - waitForReady?: boolean; - /** Environment variables */ - env?: string[]; - /** Override the image entrypoint */ - entrypoint?: string[]; - /** Override the image command */ - command?: string[]; - /** Bind-mount volumes ("host:container") */ - volumes?: string[]; - /** Extra /etc/hosts entries ("hostname:ip") */ - extraHosts?: string[]; - /** Docker healthcheck command (passed after CMD-SHELL) */ - healthcheck?: string; - /** Restart policy (default: no) */ - restartPolicy?: 'no' | 'always' | 'on-failure' | 'unless-stopped'; - /** Labels to attach to the container */ - labels?: Record; - /** Startup timeout in ms (default: 120_000) */ - timeoutMs?: number; -} - -const DEFAULTS = { - host: '127.0.0.1', - timeoutMs: 120_000, -} as const; - -function createDockerClient(): Docker { - if (process.env.DOCKER_HOST) { - const dockerHost = new URL(process.env.DOCKER_HOST); - return new Docker({ - host: dockerHost.hostname, - port: Number(dockerHost.port), - protocol: dockerHost.protocol.replace(':', '') as 'http' | 'https' | 'ssh', - }); - } - return new Docker({ socketPath: process.env.DOCKER_SOCKET_PATH ?? '/var/run/docker.sock' }); -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function streamToPromise(stream: NodeJS.ReadableStream): Promise { - return new Promise((resolve, reject) => { - stream.on('end', () => resolve()); - stream.on('error', reject); - }); -} - -async function pullImageIfMissing(docker: Docker, image: string): Promise { - const images = (await docker.listImages()) as { RepoTags?: string[] }[]; - const hasImage = images.some(img => img.RepoTags?.includes(image)); - if (hasImage) return; - - process.stdout.write(`[testenv] pulling image ${image}\n`); - const stream = await docker.pull(image); - await new Promise((resolve, reject) => { - docker.modem.followProgress(stream, err => { - if (err) { - reject(err instanceof Error ? err : new Error(String(err))); - return; - } - resolve(); - }); - }); -} - -async function getContainer(docker: Docker, name: string) { - const containers = await docker.listContainers({ - all: true, - filters: { name: [name] }, - }); - // Docker's name filter does substring matching, so we need an exact match. - // Container names are stored with a leading slash (e.g. "/my-container"). - const exact = containers.find(c => c.Names.some(n => n === `/${name}` || n === name)); - if (!exact) return undefined; - assert.ok(exact.Id); - return docker.getContainer(exact.Id); -} - -async function ensureContainerRunning(docker: Docker, config: ContainerConfig) { - const host = config.host ?? DEFAULTS.host; - const { - name, - image, - ports, - env, - entrypoint, - command, - volumes, - extraHosts, - healthcheck, - restartPolicy, - labels, - } = config; - - const existing = await getContainer(docker, name); - if (existing) { - const inspect = await existing.inspect(); - if (!inspect.State.Running) { - process.stdout.write(`[testenv] starting existing container ${name}\n`); - await existing.start(); - } else { - process.stdout.write(`[testenv] container ${name} already running\n`); - } - return existing; - } - - const exposedPorts: Record = {}; - const portBindings: Record = {}; - for (const { host: hostPort, container: containerPort } of ports) { - const key = `${containerPort}/tcp`; - exposedPorts[key] = {}; - portBindings[key] = [{ HostPort: String(hostPort), HostIp: host }]; - } - - const binds = volumes?.map(v => { - // Resolve relative paths from the project root - if (!v.startsWith('/')) { - const [hostPath, ...rest] = v.split(':'); - const resolved = `${process.cwd()}/${hostPath}`; - return [resolved, ...rest].join(':'); - } - return v; - }); - - process.stdout.write(`[testenv] creating container ${name}\n`); - const container = await docker.createContainer({ - name, - Image: image, - Env: env, - ...(entrypoint && { Entrypoint: entrypoint }), - ...(command && { Cmd: command }), - ExposedPorts: exposedPorts, - HostConfig: { - PortBindings: portBindings, - AutoRemove: false, - ...(binds && { Binds: binds }), - ...(extraHosts && { ExtraHosts: extraHosts }), - RestartPolicy: { Name: restartPolicy ?? 'no' }, - }, - Labels: labels, - ...(healthcheck && { - Healthcheck: { - Test: ['CMD-SHELL', healthcheck], - Interval: 2_000_000_000, - Timeout: 2_000_000_000, - Retries: 30, - StartPeriod: 2_000_000_000, - }, - }), - }); - await container.start(); - return container; -} - -async function waitForPort(host: string, port: number, timeoutMs: number): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const ok = await new Promise(resolve => { - const socket = net.createConnection(port, host); - socket.setTimeout(1_000); - socket.on('connect', () => { - socket.end(); - resolve(true); - }); - socket.on('timeout', () => { - socket.destroy(); - resolve(false); - }); - socket.on('error', () => resolve(false)); - }); - if (ok) return; - await sleep(500); - } - throw new Error(`timed out waiting for ${host}:${port}`); -} - -export async function runUp(config: ContainerConfig): Promise { - const host = config.host ?? DEFAULTS.host; - const timeoutMs = config.timeoutMs ?? DEFAULTS.timeoutMs; - const docker = createDockerClient(); - await pullImageIfMissing(docker, config.image); - await ensureContainerRunning(docker, config); - if (config.waitForReady !== false && config.ports.length > 0) { - const port = config.waitPort ?? config.ports[0].host; - await waitForPort(host, port, timeoutMs); - process.stdout.write(`[testenv] ${config.name} ready on ${host}:${port}\n`); - } else { - process.stdout.write(`[testenv] ${config.name} started (no readiness check)\n`); - } -} - -export async function runDown(config: ContainerConfig): Promise { - const docker = createDockerClient(); - const container = await getContainer(docker, config.name); - if (!container) { - process.stdout.write(`[testenv] container ${config.name} is already absent\n`); - return; - } - const inspect = await container.inspect(); - if (inspect.State.Running) { - process.stdout.write(`[testenv] stopping ${config.name}\n`); - await container.stop({ t: 0 }); - } - process.stdout.write(`[testenv] removing ${config.name}\n`); - await container.remove({ force: true, v: true }); -} - -export async function runLogs(config: ContainerConfig, argv: string[]): Promise { - const follow = argv.includes('-f') || argv.includes('--follow') || !argv.includes('--once'); - const docker = createDockerClient(); - const container = await getContainer(docker, config.name); - if (!container) { - throw new Error(`container ${config.name} not found`); - } - if (follow) { - const logStream = await container.logs({ - stdout: true, - stderr: true, - follow: true, - timestamps: true, - tail: 200, - }); - container.modem.demuxStream(logStream, process.stdout, process.stderr); - await streamToPromise(logStream); - return; - } - const output = await container.logs({ - stdout: true, - stderr: true, - follow: false, - timestamps: true, - tail: 200, - }); - process.stdout.write(output.toString('utf8')); -} diff --git a/tests/krypton/setup.ts b/tests/krypton/setup.ts index ca55c463f..668304ad4 100644 --- a/tests/krypton/setup.ts +++ b/tests/krypton/setup.ts @@ -1,8 +1,8 @@ -import type { ContainerConfig } from '../docker-container.ts'; -import { runDown, runUp } from '../docker-container.ts'; +import type { DockerTestContainerConfig } from '@stacks/api-test-toolkit'; +import { dockerTestDown, dockerTestUp } from '@stacks/api-test-toolkit'; -function kryptonContainers(): ContainerConfig[] { - const postgres: ContainerConfig = { +function kryptonContainers(): DockerTestContainerConfig[] { + const postgres: DockerTestContainerConfig = { image: 'postgres:17', name: `stacks-api-test-krypton-postgres`, ports: [{ host: 5490, container: 5432 }], @@ -20,7 +20,7 @@ function kryptonContainers(): ContainerConfig[] { healthcheck: 'cat /ready.txt && pg_isready -U postgres', }; - const stacksBlockchain: ContainerConfig = { + const stacksBlockchain: DockerTestContainerConfig = { image: 'hirosystems/stacks-api-e2e:stacks3.0-0a2c0e2', name: `stacks-api-test-krypton-stacks-blockchain`, ports: [ @@ -40,7 +40,7 @@ function kryptonContainers(): ContainerConfig[] { export async function globalSetup() { const containers = kryptonContainers(); for (const config of containers) { - await runUp(config); + await dockerTestUp({ config }); } process.stdout.write(`[testenv:krypton] all containers ready\n`); } @@ -48,7 +48,7 @@ export async function globalSetup() { export async function globalTeardown() { const containers = kryptonContainers(); for (const config of [...containers].reverse()) { - await runDown(config); + await dockerTestDown({ config }); } process.stdout.write(`[testenv:krypton] all containers removed\n`); } diff --git a/tests/snp/setup.ts b/tests/snp/setup.ts index 36e9cfe63..44c74ac41 100644 --- a/tests/snp/setup.ts +++ b/tests/snp/setup.ts @@ -1,10 +1,9 @@ import { connectPostgres, timeout } from '@stacks/api-toolkit'; -import type { ContainerConfig } from '../docker-container.ts'; -import { runDown, runLogs, runUp } from '../docker-container.ts'; +import { dockerTestDown, dockerTestUp, type DockerTestContainerConfig } from '@stacks/api-test-toolkit'; import { createClient } from 'redis'; -function snpContainers(): ContainerConfig[] { - const postgres: ContainerConfig = { +function snpContainers(): DockerTestContainerConfig[] { + const postgres: DockerTestContainerConfig = { image: 'postgres:17', name: `stacks-api-test-snp-postgres`, host: '0.0.0.0', @@ -18,7 +17,7 @@ function snpContainers(): ContainerConfig[] { healthcheck: 'cat /ready.txt && pg_isready -U postgres', }; - const redis: ContainerConfig = { + const redis: DockerTestContainerConfig = { image: 'redis:7', name: `stacks-api-test-snp-redis`, host: '0.0.0.0', @@ -26,7 +25,7 @@ function snpContainers(): ContainerConfig[] { waitPort: 6379, }; - const snp: ContainerConfig = { + const snp: DockerTestContainerConfig = { image: 'ghcr.io/stx-labs/stacks-node-publisher:latest', name: `stacks-api-test-snp`, ports: [{ host: 3022, container: 3022 }], @@ -113,7 +112,7 @@ async function waitForSNP(): Promise { export async function globalSetup() { const containers = snpContainers(); for (const config of containers) { - await runUp(config); + await dockerTestUp({ config }); } await waitForPostgres(); await waitForRedis(); @@ -124,7 +123,7 @@ export async function globalSetup() { export async function globalTeardown() { const containers = snpContainers(); for (const config of [...containers].reverse()) { - await runDown(config); + await dockerTestDown({ config }); } process.stdout.write(`[testenv:snp] all containers removed\n`); } From 0237d40328c61a3a862873dc748b5191d52f26f5 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:48:47 -0600 Subject: [PATCH 02/32] v3 transactions draft --- src/api/controllers/db-controller.ts | 2 +- src/api/routes/v3/helpers.ts | 129 ++++++++++ src/api/routes/v3/transactions.ts | 56 +++++ .../schemas/entities/transaction-summaries.ts | 220 ++++++++++++++++++ src/datastore/pg-store.ts | 3 + src/datastore/v3/pg-store-v3.ts | 37 +++ src/datastore/v3/types.ts | 30 +++ 7 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 src/api/routes/v3/helpers.ts create mode 100644 src/api/routes/v3/transactions.ts create mode 100644 src/api/schemas/entities/transaction-summaries.ts create mode 100644 src/datastore/v3/pg-store-v3.ts create mode 100644 src/datastore/v3/types.ts diff --git a/src/api/controllers/db-controller.ts b/src/api/controllers/db-controller.ts index df2ee8b63..353f3fc76 100644 --- a/src/api/controllers/db-controller.ts +++ b/src/api/controllers/db-controller.ts @@ -137,7 +137,7 @@ function getTxAnchorModeString(anchorMode: number): TransactionAnchorModeType { } } -function getTxTenureChangeCauseString(cause: number) { +export function getTxTenureChangeCauseString(cause: number) { switch (cause) { case 0: return 'block_found'; diff --git a/src/api/routes/v3/helpers.ts b/src/api/routes/v3/helpers.ts new file mode 100644 index 000000000..c53db42b2 --- /dev/null +++ b/src/api/routes/v3/helpers.ts @@ -0,0 +1,129 @@ +import { + BaseTransactionSummary, + CoinbaseTransactionSummary, + ContractCallTransactionSummary, + PoisonMicroblockTransactionSummary, + SmartContractTransactionSummary, + TenureChangeTransactionSummary, + TokenTransferTransactionSummary, + TransactionSummary, + TransactionSummaryStatus, +} from '../../schemas/entities/transaction-summaries.js'; +import { DbTransactionSummary } from '../../../datastore/v3/types.js'; +import { DbTxStatus, DbTxTypeId } from '../../../datastore/common.js'; +import { getTxTenureChangeCauseString } from '../../controllers/db-controller.js'; + +function parseDbTransactionSummaryStatus(status: DbTxStatus): TransactionSummaryStatus { + switch (status) { + case DbTxStatus.AbortByResponse: + return 'abort_by_response'; + case DbTxStatus.AbortByPostCondition: + return 'abort_by_post_condition'; + case DbTxStatus.Success: + return 'success'; + default: + throw new Error(`Unexpected DbTxStatus: ${status}`); + } +} + +/** + * Parses a database transaction summary into a transaction summary. + * @param summary - The database transaction summary to parse. + * @returns The parsed transaction summary. + */ +export function parseDbTransactionSummary(summary: DbTransactionSummary): TransactionSummary { + const result: BaseTransactionSummary = { + tx_id: summary.tx_id, + sender: { + address: summary.sender_address, + nonce: summary.nonce, + }, + sponsor: + summary.sponsor_address !== null && summary.sponsor_nonce !== null + ? { + address: summary.sponsor_address, + nonce: summary.sponsor_nonce, + } + : null, + fee_rate: summary.fee_rate, + block: { + height: summary.block_height, + hash: summary.block_hash, + index_hash: summary.index_block_hash, + time: summary.block_time, + tx_index: summary.tx_index, + tenure_height: summary.tenure_height, + }, + burn_block: { + height: summary.burn_block_height, + time: summary.burn_block_time, + }, + canonical: summary.canonical, + status: parseDbTransactionSummaryStatus(summary.status), + }; + switch (summary.type_id) { + case DbTxTypeId.TokenTransfer: { + const tokenTransfer: TokenTransferTransactionSummary = { + ...result, + type: 'token_transfer', + token_transfer: { + recipient: summary.token_transfer_recipient_address!, + amount: summary.token_transfer_amount!, + memo: summary.token_transfer_memo, + }, + }; + return tokenTransfer; + } + case DbTxTypeId.SmartContract: { + const smartContract: SmartContractTransactionSummary = { + ...result, + type: 'smart_contract', + smart_contract: { + clarity_version: summary.smart_contract_clarity_version, + contract_id: summary.smart_contract_contract_id!, + }, + }; + return smartContract; + } + case DbTxTypeId.ContractCall: { + const contractCall: ContractCallTransactionSummary = { + ...result, + type: 'contract_call', + contract_call: { + contract_id: summary.contract_call_contract_id!, + function_name: summary.contract_call_function_name!, + }, + }; + return contractCall; + } + case DbTxTypeId.PoisonMicroblock: { + const poisonMicroblock: PoisonMicroblockTransactionSummary = { + ...result, + type: 'poison_microblock', + }; + return poisonMicroblock; + } + case DbTxTypeId.Coinbase: { + const coinbase: CoinbaseTransactionSummary = { + ...result, + type: 'coinbase', + coinbase: { + alt_recipient: summary.coinbase_alt_recipient, + }, + }; + return coinbase; + } + case DbTxTypeId.TenureChange: { + const tenureChange: TenureChangeTransactionSummary = { + ...result, + type: 'tenure_change', + tenure_change: { + cause: getTxTenureChangeCauseString(summary.tenure_change_cause!), + }, + }; + return tenureChange; + } + default: + throw new Error(`Unexpected DbTxTypeId: ${summary.type_id}`); + } +} diff --git a/src/api/routes/v3/transactions.ts b/src/api/routes/v3/transactions.ts new file mode 100644 index 000000000..f99aa0080 --- /dev/null +++ b/src/api/routes/v3/transactions.ts @@ -0,0 +1,56 @@ +import { handleChainTipCache } from '../../controllers/cache-controller.js'; +import { parseDbTransactionSummary } from './helpers.js'; +import { NotFoundError } from '../../../errors.js'; +import { FastifyPluginAsync } from 'fastify'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Server } from 'node:http'; +import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; +import { PaginatedCursorResponse } from '../../schemas/util.js'; +import { TransactionSummarySchema } from '../../schemas/entities/transaction-summaries.js'; +import { LimitParam } from '../../schemas/params.js'; +import { BlockCursorParamSchema } from '../v2/schemas.js'; + +export const V3TransactionsRoutes: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + fastify.get( + '/', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_transaction_summaries', + summary: 'Get transaction summaries', + description: `Retrieves a list of recently mined transaction summaries`, + tags: ['Transactions'], + querystring: Type.Object({ + limit: LimitParam(ResourceType.Tx), + cursor: Type.Optional(BlockCursorParamSchema), + }), + response: { + 200: PaginatedCursorResponse(TransactionSummarySchema), + }, + }, + }, + async (req, reply) => { + const query = req.query; + const limit = getPagingQueryLimit(ResourceType.Tx, req.query.limit); + const results = await fastify.db.v3.getTransactionSummaries({ ...query, limit }); + if (query.cursor && !results.current_cursor) { + throw new NotFoundError('Cursor not found'); + } + await reply.send({ + limit: results.limit, + offset: results.offset, + total: results.total, + next_cursor: results.next_cursor, + prev_cursor: results.prev_cursor, + cursor: results.current_cursor, + results: results.results.map(r => parseDbTransactionSummary(r)), + }); + } + ); + + await Promise.resolve(); +}; diff --git a/src/api/schemas/entities/transaction-summaries.ts b/src/api/schemas/entities/transaction-summaries.ts new file mode 100644 index 000000000..2fb2440fa --- /dev/null +++ b/src/api/schemas/entities/transaction-summaries.ts @@ -0,0 +1,220 @@ +import { Static, Type } from '@sinclair/typebox'; +import { Nullable } from '../util.js'; + +const TransactionSenderSchema = Type.Object({ + address: Type.String({ + description: 'Address of the transaction initiator', + }), + nonce: Type.Integer({ + description: 'Nonce of the transaction initiator', + }), +}); + +const TenureChangeCauseSchema = Type.Union( + [ + Type.Literal('block_found'), + Type.Literal('extended'), + Type.Literal('extended_runtime'), + Type.Literal('extended_read_count'), + Type.Literal('extended_read_length'), + Type.Literal('extended_write_count'), + Type.Literal('extended_write_length'), + ], + { + description: + 'Cause of change in mining tenure. Depending on cause, tenure can be ended or extended.', + } +); + +const TransactionSummaryStatusSchema = Type.Union( + [ + Type.Literal('success'), + Type.Literal('abort_by_response'), + Type.Literal('abort_by_post_condition'), + ], + { description: 'Status of the transaction' } +); +export type TransactionSummaryStatus = Static; + +const BaseTransactionSummarySchema = Type.Object({ + tx_id: Type.String({ + description: 'Transaction ID', + }), + sender: TransactionSenderSchema, + sponsor: Nullable(TransactionSenderSchema), + fee_rate: Type.String({ + description: 'Transaction fee as Integer string (64-bit unsigned integer).', + }), + block: Type.Object({ + height: Type.Integer({ + description: 'Height of the block this transactions was associated with', + }), + hash: Type.String({ + description: 'Hash of the blocked this transactions was associated with', + }), + index_hash: Type.String({ + description: 'Hash of the index block this transactions was associated with', + }), + time: Type.Number({ + description: 'Unix timestamp (in seconds) indicating when this block was mined.', + }), + tx_index: Type.Integer({ + description: + 'Index of the transaction, indicating the order. Starts at `0` and increases with each transaction', + }), + tenure_height: Type.Integer({ + description: 'Height of the tenure this transactions was associated with', + }), + }), + burn_block: Type.Object({ + height: Type.Integer({ + description: 'Height of the anchor burn block.', + }), + time: Type.Number({ + description: 'Unix timestamp (in seconds) indicating when this block was mined.', + }), + }), + canonical: Type.Boolean({ + description: 'Set to `true` if block corresponds to the canonical chain tip', + }), + status: TransactionSummaryStatusSchema, +}); +export type BaseTransactionSummary = Static; + +export const TokenTransferTransactionSummarySchema = Type.Composite( + [ + BaseTransactionSummarySchema, + Type.Object({ + type: Type.Literal('token_transfer'), + token_transfer: Type.Object({ + recipient: Type.String(), + amount: Type.String({ + description: 'Transfer amount as Integer string (64-bit unsigned integer)', + }), + memo: Nullable( + Type.String({ + description: + 'Hex encoded arbitrary message, up to 34 bytes length (should try decoding to an ASCII string)', + }) + ), + }), + }), + ], + { + title: 'TokenTransferTransactionSummary', + description: 'Token transfer transaction summary', + } +); +export type TokenTransferTransactionSummary = Static; + +export const SmartContractTransactionSummarySchema = Type.Composite( + [ + BaseTransactionSummarySchema, + Type.Object({ + type: Type.Literal('smart_contract'), + smart_contract: Type.Object({ + clarity_version: Nullable( + Type.Number({ + description: + 'The Clarity version of the contract, only specified for versioned contract transactions, otherwise null', + }) + ), + contract_id: Type.String({ + description: 'Contract identifier formatted as `.`', + }), + }), + }), + ], + { + title: 'SmartContractTransactionSummary', + description: 'Smart contract transaction summary', + } +); +export type SmartContractTransactionSummary = Static; + +export const ContractCallTransactionSummarySchema = Type.Composite( + [ + BaseTransactionSummarySchema, + Type.Object({ + type: Type.Literal('contract_call'), + contract_call: Type.Object({ + contract_id: Type.String({ + description: 'Contract identifier formatted as `.`', + }), + function_name: Type.String({ + description: 'Name of the Clarity function to be invoked', + }), + }), + }), + ], + { + title: 'ContractCallTransactionSummary', + description: 'Contract call transaction summary', + } +); +export type ContractCallTransactionSummary = Static; + +export const PoisonMicroblockTransactionSummarySchema = Type.Composite( + [ + BaseTransactionSummarySchema, + Type.Object({ + type: Type.Literal('poison_microblock'), + }), + ], + { + title: 'PoisonMicroblockTransactionSummary', + description: 'Poison microblock transaction summary', + } +); +export type PoisonMicroblockTransactionSummary = Static< + typeof PoisonMicroblockTransactionSummarySchema +>; + +export const CoinbaseTransactionSummarySchema = Type.Composite( + [ + BaseTransactionSummarySchema, + Type.Object({ + type: Type.Literal('coinbase'), + coinbase: Type.Object({ + alt_recipient: Nullable( + Type.String({ + description: + 'A principal that will receive the miner rewards for this coinbase transaction. Can be either a standard principal or contract principal. Only specified for `coinbase-to-alt-recipient` transaction types, otherwise null.', + }) + ), + }), + }), + ], + { + title: 'CoinbaseTransactionSummary', + description: 'Coinbase transaction summary', + } +); +export type CoinbaseTransactionSummary = Static; + +export const TenureChangeTransactionSummarySchema = Type.Composite( + [ + BaseTransactionSummarySchema, + Type.Object({ + type: Type.Literal('tenure_change'), + tenure_change: Type.Object({ + cause: TenureChangeCauseSchema, + }), + }), + ], + { + title: 'TenureChangeTransactionSummary', + description: 'Tenure change transaction summary', + } +); +export type TenureChangeTransactionSummary = Static; + +export const TransactionSummarySchema = Type.Union([ + TokenTransferTransactionSummarySchema, + SmartContractTransactionSummarySchema, + ContractCallTransactionSummarySchema, + PoisonMicroblockTransactionSummarySchema, + CoinbaseTransactionSummarySchema, + TenureChangeTransactionSummarySchema, +]); +export type TransactionSummary = Static; diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index 8c6605501..9a4d08353 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -93,6 +93,7 @@ import * as path from 'path'; import { PgStoreV2 } from './pg-store-v2.js'; import { ENV } from '../env.js'; import { BlockIdParam } from 'src/api/routes/v2/schemas.js'; +import { PgStoreV3 } from './v3/pg-store-v3.js'; export const MIGRATIONS_DIR = path.join(REPO_DIR, 'migrations'); @@ -104,6 +105,7 @@ export const MIGRATIONS_DIR = path.join(REPO_DIR, 'migrations'); */ export class PgStore extends BasePgStore { readonly v2: PgStoreV2; + readonly v3: PgStoreV3; readonly eventEmitter: PgStoreEventEmitter; readonly notifier?: PgNotifier; @@ -112,6 +114,7 @@ export class PgStore extends BasePgStore { this.notifier = notifier; this.eventEmitter = new PgStoreEventEmitter(); this.v2 = new PgStoreV2(this); + this.v3 = new PgStoreV3(this); } static async connect({ diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts new file mode 100644 index 000000000..1dc6cf2fc --- /dev/null +++ b/src/datastore/v3/pg-store-v3.ts @@ -0,0 +1,37 @@ +import { BasePgStoreModule } from '@stacks/api-toolkit'; +import { DbCursorPaginatedResult } from '../common.js'; +import { DbTransactionSummary } from './types.js'; + +export class PgStoreV3 extends BasePgStoreModule { + async getTransactionSummaries(args: { + limit: number; + cursor?: string; + }): Promise> { + const results = await this.sql<(DbTransactionSummary & { total: number })[]>` + WITH total AS ( + SELECT tx_count FROM chain_tip + ) + SELECT + tx_id, sender_address, sponsor_address, sponsor_nonce, nonce, fee_rate, + block_height, block_hash, index_block_hash, block_time, tx_index, tenure_height, + burn_block_height, burn_block_time, canonical, status, type_id, + token_transfer_recipient_address, token_transfer_amount, token_transfer_memo, + smart_contract_clarity_version, smart_contract_contract_id, contract_call_contract_id, + contract_call_function_name, coinbase_alt_recipient, tenure_change_cause, + (SELECT total FROM total)::int AS total + FROM txs + WHERE canonical = true + ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC + LIMIT ${args.limit} + `; + return { + limit: args.limit, + offset: 0, + next_cursor: null, + prev_cursor: null, + current_cursor: null, + total: results[0]?.total ?? 0, + results: results, + }; + } +} diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts new file mode 100644 index 000000000..b39cddece --- /dev/null +++ b/src/datastore/v3/types.ts @@ -0,0 +1,30 @@ +import { DbTxStatus, DbTxTypeId } from '../common.js'; + +export interface DbTransactionSummary { + tx_id: string; + sender_address: string; + nonce: number; + sponsor_address: string | null; + sponsor_nonce: number | null; + fee_rate: string; + block_height: number; + block_hash: string; + index_block_hash: string; + block_time: number; + tx_index: number; + tenure_height: number; + burn_block_height: number; + burn_block_time: number; + canonical: boolean; + status: DbTxStatus; + type_id: DbTxTypeId; + token_transfer_recipient_address: string | null; + token_transfer_amount: string | null; + token_transfer_memo: string | null; + smart_contract_clarity_version: number | null; + smart_contract_contract_id: string | null; + contract_call_contract_id: string | null; + contract_call_function_name: string | null; + coinbase_alt_recipient: string | null; + tenure_change_cause: number | null; +} From 51fbe5f54b3e9bd0ea1e32ffdf848c00f05b4dda Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:01:11 -0600 Subject: [PATCH 03/32] txs cursor --- src/api/routes/v3/transactions.ts | 8 +- src/datastore/v3/pg-store-v3.ts | 129 ++++++++++++++++++++++++------ 2 files changed, 109 insertions(+), 28 deletions(-) diff --git a/src/api/routes/v3/transactions.ts b/src/api/routes/v3/transactions.ts index f99aa0080..9302e8bbe 100644 --- a/src/api/routes/v3/transactions.ts +++ b/src/api/routes/v3/transactions.ts @@ -8,7 +8,11 @@ import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; import { PaginatedCursorResponse } from '../../schemas/util.js'; import { TransactionSummarySchema } from '../../schemas/entities/transaction-summaries.js'; import { LimitParam } from '../../schemas/params.js'; -import { BlockCursorParamSchema } from '../v2/schemas.js'; + +const TransactionSummaryCursorParamSchema = Type.String({ + pattern: '^\\d+:\\d+:\\d+$', + description: 'Cursor for transaction summary pagination', +}); export const V3TransactionsRoutes: FastifyPluginAsync< Record, @@ -26,7 +30,7 @@ export const V3TransactionsRoutes: FastifyPluginAsync< tags: ['Transactions'], querystring: Type.Object({ limit: LimitParam(ResourceType.Tx), - cursor: Type.Optional(BlockCursorParamSchema), + cursor: Type.Optional(TransactionSummaryCursorParamSchema), }), response: { 200: PaginatedCursorResponse(TransactionSummarySchema), diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 1dc6cf2fc..f22267896 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -1,37 +1,114 @@ import { BasePgStoreModule } from '@stacks/api-toolkit'; import { DbCursorPaginatedResult } from '../common.js'; import { DbTransactionSummary } from './types.js'; +import { InvalidRequestError, InvalidRequestErrorType } from '../../errors.js'; + +type TransactionSummaryQueryResult = DbTransactionSummary & { + microblock_sequence: number; + total: number; +}; export class PgStoreV3 extends BasePgStoreModule { async getTransactionSummaries(args: { limit: number; cursor?: string; }): Promise> { - const results = await this.sql<(DbTransactionSummary & { total: number })[]>` - WITH total AS ( - SELECT tx_count FROM chain_tip - ) - SELECT - tx_id, sender_address, sponsor_address, sponsor_nonce, nonce, fee_rate, - block_height, block_hash, index_block_hash, block_time, tx_index, tenure_height, - burn_block_height, burn_block_time, canonical, status, type_id, - token_transfer_recipient_address, token_transfer_amount, token_transfer_memo, - smart_contract_clarity_version, smart_contract_contract_id, contract_call_contract_id, - contract_call_function_name, coinbase_alt_recipient, tenure_change_cause, - (SELECT total FROM total)::int AS total - FROM txs - WHERE canonical = true - ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC - LIMIT ${args.limit} - `; - return { - limit: args.limit, - offset: 0, - next_cursor: null, - prev_cursor: null, - current_cursor: null, - total: results[0]?.total ?? 0, - results: results, - }; + return await this.sqlTransaction(async sql => { + let cursorFilter = sql``; + if (args.cursor) { + const parts = args.cursor.split(':'); + if (parts.length !== 3) { + throw new InvalidRequestError( + 'Invalid cursor format', + InvalidRequestErrorType.invalid_param + ); + } + const [blockHeightStr, microblockSequenceStr, txIndexStr] = parts; + const blockHeight = parseInt(blockHeightStr, 10); + const microblockSequence = parseInt(microblockSequenceStr, 10); + const txIndex = parseInt(txIndexStr, 10); + if (isNaN(blockHeight) || isNaN(microblockSequence) || isNaN(txIndex)) { + throw new InvalidRequestError( + 'Invalid cursor format', + InvalidRequestErrorType.invalid_param + ); + } + + cursorFilter = sql` + AND (block_height, microblock_sequence, tx_index) + <= (${blockHeight}, ${microblockSequence}, ${txIndex}) + `; + } + + const resultQuery = await sql` + WITH total AS ( + SELECT tx_count FROM chain_tip + ) + SELECT + tx_id, sender_address, sponsor_address, sponsor_nonce, nonce, fee_rate, + block_height, block_hash, index_block_hash, block_time, tx_index, tenure_height, + microblock_sequence, burn_block_height, burn_block_time, canonical, status, type_id, + token_transfer_recipient_address, token_transfer_amount, token_transfer_memo, + smart_contract_clarity_version, smart_contract_contract_id, contract_call_contract_id, + contract_call_function_name, coinbase_alt_recipient, tenure_change_cause, + (SELECT total FROM total)::int AS total + FROM txs + WHERE canonical = true + AND microblock_canonical = true + ${cursorFilter} + ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC + LIMIT ${args.limit + 1} + `; + + const hasNextPage = resultQuery.count > args.limit; + const results = hasNextPage ? resultQuery.slice(0, args.limit) : resultQuery; + const total = resultQuery.count > 0 ? resultQuery[0].total : 0; + + const lastResult = resultQuery[resultQuery.length - 1]; + const prevCursor = + hasNextPage && lastResult + ? `${lastResult.block_height}:${lastResult.microblock_sequence}:${lastResult.tx_index}` + : null; + + const firstResult = results[0]; + const currentCursor = firstResult + ? `${firstResult.block_height}:${firstResult.microblock_sequence}:${firstResult.tx_index}` + : null; + + let nextCursor: string | null = null; + if (firstResult) { + const prevQuery = await sql< + { block_height: number; microblock_sequence: number; tx_index: number }[] + >` + SELECT block_height, microblock_sequence, tx_index + FROM txs + WHERE canonical = true + AND microblock_canonical = true + AND (block_height, microblock_sequence, tx_index) + > ( + ${firstResult.block_height}, + ${firstResult.microblock_sequence}, + ${firstResult.tx_index} + ) + ORDER BY block_height ASC, microblock_sequence ASC, tx_index ASC + OFFSET ${args.limit - 1} + LIMIT 1 + `; + if (prevQuery.length > 0) { + const prev = prevQuery[0]; + nextCursor = `${prev.block_height}:${prev.microblock_sequence}:${prev.tx_index}`; + } + } + + return { + limit: args.limit, + offset: 0, + next_cursor: nextCursor, + prev_cursor: prevCursor, + current_cursor: currentCursor, + total, + results, + }; + }); } } From 9f9f80232354114a9361c4a5a60f9370cfb1cef7 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:18:36 -0600 Subject: [PATCH 04/32] tx detail --- src/api/routes/v3/helpers.ts | 129 --------- src/api/routes/v3/transactions.ts | 39 ++- .../{ => v3}/transaction-summaries.ts | 6 +- src/api/schemas/entities/v3/transactions.ts | 186 +++++++++++++ src/api/serializers/transactions.ts | 253 ++++++++++++++++++ src/datastore/v3/pg-store-v3.ts | 75 +++++- src/datastore/v3/types.ts | 23 ++ 7 files changed, 566 insertions(+), 145 deletions(-) delete mode 100644 src/api/routes/v3/helpers.ts rename src/api/schemas/entities/{ => v3}/transaction-summaries.ts (97%) create mode 100644 src/api/schemas/entities/v3/transactions.ts create mode 100644 src/api/serializers/transactions.ts diff --git a/src/api/routes/v3/helpers.ts b/src/api/routes/v3/helpers.ts deleted file mode 100644 index c53db42b2..000000000 --- a/src/api/routes/v3/helpers.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { - BaseTransactionSummary, - CoinbaseTransactionSummary, - ContractCallTransactionSummary, - PoisonMicroblockTransactionSummary, - SmartContractTransactionSummary, - TenureChangeTransactionSummary, - TokenTransferTransactionSummary, - TransactionSummary, - TransactionSummaryStatus, -} from '../../schemas/entities/transaction-summaries.js'; -import { DbTransactionSummary } from '../../../datastore/v3/types.js'; -import { DbTxStatus, DbTxTypeId } from '../../../datastore/common.js'; -import { getTxTenureChangeCauseString } from '../../controllers/db-controller.js'; - -function parseDbTransactionSummaryStatus(status: DbTxStatus): TransactionSummaryStatus { - switch (status) { - case DbTxStatus.AbortByResponse: - return 'abort_by_response'; - case DbTxStatus.AbortByPostCondition: - return 'abort_by_post_condition'; - case DbTxStatus.Success: - return 'success'; - default: - throw new Error(`Unexpected DbTxStatus: ${status}`); - } -} - -/** - * Parses a database transaction summary into a transaction summary. - * @param summary - The database transaction summary to parse. - * @returns The parsed transaction summary. - */ -export function parseDbTransactionSummary(summary: DbTransactionSummary): TransactionSummary { - const result: BaseTransactionSummary = { - tx_id: summary.tx_id, - sender: { - address: summary.sender_address, - nonce: summary.nonce, - }, - sponsor: - summary.sponsor_address !== null && summary.sponsor_nonce !== null - ? { - address: summary.sponsor_address, - nonce: summary.sponsor_nonce, - } - : null, - fee_rate: summary.fee_rate, - block: { - height: summary.block_height, - hash: summary.block_hash, - index_hash: summary.index_block_hash, - time: summary.block_time, - tx_index: summary.tx_index, - tenure_height: summary.tenure_height, - }, - burn_block: { - height: summary.burn_block_height, - time: summary.burn_block_time, - }, - canonical: summary.canonical, - status: parseDbTransactionSummaryStatus(summary.status), - }; - switch (summary.type_id) { - case DbTxTypeId.TokenTransfer: { - const tokenTransfer: TokenTransferTransactionSummary = { - ...result, - type: 'token_transfer', - token_transfer: { - recipient: summary.token_transfer_recipient_address!, - amount: summary.token_transfer_amount!, - memo: summary.token_transfer_memo, - }, - }; - return tokenTransfer; - } - case DbTxTypeId.SmartContract: { - const smartContract: SmartContractTransactionSummary = { - ...result, - type: 'smart_contract', - smart_contract: { - clarity_version: summary.smart_contract_clarity_version, - contract_id: summary.smart_contract_contract_id!, - }, - }; - return smartContract; - } - case DbTxTypeId.ContractCall: { - const contractCall: ContractCallTransactionSummary = { - ...result, - type: 'contract_call', - contract_call: { - contract_id: summary.contract_call_contract_id!, - function_name: summary.contract_call_function_name!, - }, - }; - return contractCall; - } - case DbTxTypeId.PoisonMicroblock: { - const poisonMicroblock: PoisonMicroblockTransactionSummary = { - ...result, - type: 'poison_microblock', - }; - return poisonMicroblock; - } - case DbTxTypeId.Coinbase: { - const coinbase: CoinbaseTransactionSummary = { - ...result, - type: 'coinbase', - coinbase: { - alt_recipient: summary.coinbase_alt_recipient, - }, - }; - return coinbase; - } - case DbTxTypeId.TenureChange: { - const tenureChange: TenureChangeTransactionSummary = { - ...result, - type: 'tenure_change', - tenure_change: { - cause: getTxTenureChangeCauseString(summary.tenure_change_cause!), - }, - }; - return tenureChange; - } - default: - throw new Error(`Unexpected DbTxTypeId: ${summary.type_id}`); - } -} diff --git a/src/api/routes/v3/transactions.ts b/src/api/routes/v3/transactions.ts index 9302e8bbe..f49bd418c 100644 --- a/src/api/routes/v3/transactions.ts +++ b/src/api/routes/v3/transactions.ts @@ -1,13 +1,14 @@ -import { handleChainTipCache } from '../../controllers/cache-controller.js'; -import { parseDbTransactionSummary } from './helpers.js'; +import { handleChainTipCache, handleTransactionCache } from '../../controllers/cache-controller.js'; +import { parseDbTransaction, parseDbTransactionSummary } from '../../serializers/transactions.js'; import { NotFoundError } from '../../../errors.js'; import { FastifyPluginAsync } from 'fastify'; import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { Server } from 'node:http'; import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; import { PaginatedCursorResponse } from '../../schemas/util.js'; -import { TransactionSummarySchema } from '../../schemas/entities/transaction-summaries.js'; -import { LimitParam } from '../../schemas/params.js'; +import { TransactionSummarySchema } from '../../schemas/entities/v3/transaction-summaries.js'; +import { LimitParam, TransactionIdParamSchema } from '../../schemas/params.js'; +import { TransactionSchema } from 'src/api/schemas/entities/v3/transactions.js'; const TransactionSummaryCursorParamSchema = Type.String({ pattern: '^\\d+:\\d+:\\d+$', @@ -40,7 +41,7 @@ export const V3TransactionsRoutes: FastifyPluginAsync< async (req, reply) => { const query = req.query; const limit = getPagingQueryLimit(ResourceType.Tx, req.query.limit); - const results = await fastify.db.v3.getTransactionSummaries({ ...query, limit }); + const results = await fastify.db.v3.getTransactionSummaryList({ ...query, limit }); if (query.cursor && !results.current_cursor) { throw new NotFoundError('Cursor not found'); } @@ -56,5 +57,33 @@ export const V3TransactionsRoutes: FastifyPluginAsync< } ); + fastify.get( + '/:tx_id', + { + preHandler: handleTransactionCache, + schema: { + operationId: 'get_transaction_details', + summary: 'Get transaction details', + description: `Retrieves details for a given transaction ID`, + tags: ['Transactions'], + params: Type.Object({ + tx_id: TransactionIdParamSchema, + }), + response: { + 200: TransactionSchema, + }, + }, + }, + async (req, reply) => { + const { tx_id } = req.params; + const transaction = await fastify.db.v3.getTransaction({ txId: tx_id }); + if (!transaction) { + throw new NotFoundError('Transaction not found'); + } + const result = parseDbTransaction(transaction); + await reply.send(result); + } + ); + await Promise.resolve(); }; diff --git a/src/api/schemas/entities/transaction-summaries.ts b/src/api/schemas/entities/v3/transaction-summaries.ts similarity index 97% rename from src/api/schemas/entities/transaction-summaries.ts rename to src/api/schemas/entities/v3/transaction-summaries.ts index 2fb2440fa..d4463f546 100644 --- a/src/api/schemas/entities/transaction-summaries.ts +++ b/src/api/schemas/entities/v3/transaction-summaries.ts @@ -1,5 +1,5 @@ import { Static, Type } from '@sinclair/typebox'; -import { Nullable } from '../util.js'; +import { Nullable } from '../../util.js'; const TransactionSenderSchema = Type.Object({ address: Type.String({ @@ -10,7 +10,7 @@ const TransactionSenderSchema = Type.Object({ }), }); -const TenureChangeCauseSchema = Type.Union( +export const TenureChangeCauseSchema = Type.Union( [ Type.Literal('block_found'), Type.Literal('extended'), @@ -36,7 +36,7 @@ const TransactionSummaryStatusSchema = Type.Union( ); export type TransactionSummaryStatus = Static; -const BaseTransactionSummarySchema = Type.Object({ +export const BaseTransactionSummarySchema = Type.Object({ tx_id: Type.String({ description: 'Transaction ID', }), diff --git a/src/api/schemas/entities/v3/transactions.ts b/src/api/schemas/entities/v3/transactions.ts new file mode 100644 index 000000000..0a59b682f --- /dev/null +++ b/src/api/schemas/entities/v3/transactions.ts @@ -0,0 +1,186 @@ +import { Static, Type } from '@sinclair/typebox'; +import { BaseTransactionSummarySchema, TenureChangeCauseSchema } from './transaction-summaries.js'; +import { PostConditionSchema } from '../post-conditions.js'; +import { Nullable } from '../../util.js'; + +const DecodedClarityValueSchema = Type.Object({ + hex: Type.String(), + repr: Type.String(), +}); + +const ExecutionCostSchema = Type.Object({ + read_count: Type.Integer({ + description: 'Number of reads in the transaction', + }), + read_length: Type.Integer({ + description: 'Length of reads in the transaction', + }), + runtime: Type.Integer({ + description: 'Runtime of the transaction', + }), + write_count: Type.Integer({ + description: 'Number of writes in the transaction', + }), + write_length: Type.Integer({ + description: 'Length of writes in the transaction', + }), +}); + +const BaseTransactionSchema = Type.Composite([ + BaseTransactionSummarySchema, + Type.Object({ + parent_block: Type.Object({ + hash: Type.String({ + description: 'Hash of the parent block', + }), + index_hash: Type.String({ + description: 'Index block hash of the parent block', + }), + }), + post_conditions: Type.Array(PostConditionSchema), + event_count: Type.Integer({ + description: 'Number of events in the transaction', + }), + execution_cost: ExecutionCostSchema, + vm_error: Nullable( + Type.String({ + description: 'VM error of the transaction', + }) + ), + }), +]); +export type BaseTransaction = Static; + +const TokenTransferTransactionSchema = Type.Composite([ + BaseTransactionSchema, + Type.Object({ + type: Type.Literal('token_transfer'), + token_transfer: Type.Object({ + recipient: Type.String({ + description: 'Recipient of the token transfer', + }), + amount: Type.String({ + description: 'Amount of the token transfer', + }), + memo: Nullable( + Type.String({ + description: 'Memo of the token transfer', + }) + ), + }), + }), +]); +export type TokenTransferTransaction = Static; + +const SmartContractTransactionSchema = Type.Composite([ + BaseTransactionSchema, + Type.Object({ + type: Type.Literal('smart_contract'), + smart_contract: Type.Object({ + contract_id: Type.String({ + description: 'Contract ID of the smart contract', + }), + clarity_version: Nullable( + Type.Number({ + description: 'Clarity version of the smart contract', + }) + ), + source_code: Type.String({ + description: 'Source code of the smart contract', + }), + }), + }), +]); +export type SmartContractTransaction = Static; + +const ContractCallTransactionSchema = Type.Composite([ + BaseTransactionSchema, + Type.Object({ + type: Type.Literal('contract_call'), + contract_call: Type.Object({ + contract_id: Type.String({ + description: 'Contract ID of the contract call', + }), + function_name: Type.String({ + description: 'Function name of the contract call', + }), + function_args: Type.Array(DecodedClarityValueSchema, { + description: 'List of arguments used to invoke the function', + }), + }), + }), +]); +export type ContractCallTransaction = Static; + +const PoisonMicroblockTransactionSchema = Type.Composite([ + BaseTransactionSchema, + Type.Object({ + type: Type.Literal('poison_microblock'), + }), +]); +export type PoisonMicroblockTransaction = Static; + +const TenureChangeTransactionSchema = Type.Composite([ + BaseTransactionSchema, + Type.Object({ + type: Type.Literal('tenure_change'), + tenure_change: Type.Object({ + tenure_consensus_hash: Type.String({ + description: + 'Consensus hash of this tenure. Corresponds to the sortition in which the miner of this block was chosen.', + }), + prev_tenure_consensus_hash: Type.String({ + description: + 'Consensus hash of the previous tenure. Corresponds to the sortition of the previous winning block-commit.', + }), + burn_view_consensus_hash: Type.String({ + description: + 'Current consensus hash on the underlying burnchain. Corresponds to the last-seen sortition.', + }), + previous_tenure_end: Type.String({ + description: '(Hex string) Stacks Block hash', + }), + previous_tenure_blocks: Type.Integer({ + description: 'The number of blocks produced in the previous tenure.', + }), + cause: TenureChangeCauseSchema, + pubkey_hash: Type.String({ + description: '(Hex string) The ECDSA public key hash of the current tenure.', + }), + }), + }), +]); +export type TenureChangeTransaction = Static; + +const CoinbaseTransactionSchema = Type.Composite([ + BaseTransactionSchema, + Type.Object({ + type: Type.Literal('coinbase'), + coinbase: Type.Object({ + payload: Type.String({ + description: 'Payload of the coinbase transaction', + }), + alt_recipient: Nullable( + Type.String({ + description: 'Alt recipient of the coinbase transaction', + }) + ), + vrf_proof: Nullable( + Type.String({ + description: 'VRF proof of the coinbase transaction', + }) + ), + }), + }), +]); +export type CoinbaseTransaction = Static; + +export const TransactionSchema = Type.Union([ + TokenTransferTransactionSchema, + SmartContractTransactionSchema, + ContractCallTransactionSchema, + PoisonMicroblockTransactionSchema, + TenureChangeTransactionSchema, + CoinbaseTransactionSchema, +]); +export type Transaction = Static; diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts new file mode 100644 index 000000000..b6fa7c2a0 --- /dev/null +++ b/src/api/serializers/transactions.ts @@ -0,0 +1,253 @@ +import { + BaseTransactionSummary, + CoinbaseTransactionSummary, + ContractCallTransactionSummary, + PoisonMicroblockTransactionSummary, + SmartContractTransactionSummary, + TenureChangeTransactionSummary, + TokenTransferTransactionSummary, + TransactionSummary, + TransactionSummaryStatus, +} from '../schemas/entities/v3/transaction-summaries.js'; +import { DbTransaction, DbTransactionSummary } from '../../datastore/v3/types.js'; +import { DbTxStatus, DbTxTypeId } from '../../datastore/common.js'; +import { getTxTenureChangeCauseString } from '../controllers/db-controller.js'; +import { + BaseTransaction, + CoinbaseTransaction, + ContractCallTransaction, + PoisonMicroblockTransaction, + SmartContractTransaction, + TenureChangeTransaction, + TokenTransferTransaction, + Transaction, +} from '../schemas/entities/v3/transactions.js'; +import codec from '@stacks/codec'; +import { serializePostCondition } from './post-conditions.js'; + +/** + * Parses a database transaction summary status into a transaction summary status. + * @param status - The database transaction status. + * @returns The parsed transaction summary status. + */ +function parseDbTransactionSummaryStatus(status: DbTxStatus): TransactionSummaryStatus { + switch (status) { + case DbTxStatus.AbortByResponse: + return 'abort_by_response'; + case DbTxStatus.AbortByPostCondition: + return 'abort_by_post_condition'; + case DbTxStatus.Success: + return 'success'; + default: + throw new Error(`Unexpected DbTxStatus: ${status}`); + } +} + +/** + * Parses a database transaction summary into a transaction summary. + * @param summary - The database transaction summary to parse. + * @returns The parsed transaction summary. + */ +export function parseDbTransactionSummary(summary: DbTransactionSummary): TransactionSummary { + const result: BaseTransactionSummary = { + tx_id: summary.tx_id, + sender: { + address: summary.sender_address, + nonce: summary.nonce, + }, + sponsor: + summary.sponsor_address !== null && summary.sponsor_nonce !== null + ? { + address: summary.sponsor_address, + nonce: summary.sponsor_nonce, + } + : null, + fee_rate: summary.fee_rate, + block: { + height: summary.block_height, + hash: summary.block_hash, + index_hash: summary.index_block_hash, + time: summary.block_time, + tx_index: summary.tx_index, + tenure_height: summary.tenure_height, + }, + burn_block: { + height: summary.burn_block_height, + time: summary.burn_block_time, + }, + canonical: summary.canonical, + status: parseDbTransactionSummaryStatus(summary.status), + }; + switch (summary.type_id) { + case DbTxTypeId.TokenTransfer: { + const tokenTransfer: TokenTransferTransactionSummary = { + ...result, + type: 'token_transfer', + token_transfer: { + recipient: summary.token_transfer_recipient_address!, + amount: summary.token_transfer_amount!, + memo: summary.token_transfer_memo, + }, + }; + return tokenTransfer; + } + case DbTxTypeId.SmartContract: { + const smartContract: SmartContractTransactionSummary = { + ...result, + type: 'smart_contract', + smart_contract: { + clarity_version: summary.smart_contract_clarity_version, + contract_id: summary.smart_contract_contract_id!, + }, + }; + return smartContract; + } + case DbTxTypeId.ContractCall: { + const contractCall: ContractCallTransactionSummary = { + ...result, + type: 'contract_call', + contract_call: { + contract_id: summary.contract_call_contract_id!, + function_name: summary.contract_call_function_name!, + }, + }; + return contractCall; + } + case DbTxTypeId.PoisonMicroblock: { + const poisonMicroblock: PoisonMicroblockTransactionSummary = { + ...result, + type: 'poison_microblock', + }; + return poisonMicroblock; + } + case DbTxTypeId.Coinbase: { + const coinbase: CoinbaseTransactionSummary = { + ...result, + type: 'coinbase', + coinbase: { + alt_recipient: summary.coinbase_alt_recipient, + }, + }; + return coinbase; + } + case DbTxTypeId.TenureChange: { + const tenureChange: TenureChangeTransactionSummary = { + ...result, + type: 'tenure_change', + tenure_change: { + cause: getTxTenureChangeCauseString(summary.tenure_change_cause!), + }, + }; + return tenureChange; + } + default: + throw new Error(`Unexpected DbTxTypeId: ${summary.type_id}`); + } +} + +/** + * Parses a database transaction into a transaction. + * @param transaction - The database transaction to parse. + * @returns The parsed transaction. + */ +export function parseDbTransaction(transaction: DbTransaction): Transaction { + const summary = parseDbTransactionSummary(transaction); + const decodedPostConditions = codec.decodePostConditions(transaction.post_conditions); + const result: BaseTransaction = { + ...summary, + parent_block: { + hash: transaction.parent_block_hash, + index_hash: transaction.parent_index_block_hash, + }, + post_conditions: decodedPostConditions.post_conditions.map(pc => serializePostCondition(pc)), + event_count: transaction.event_count, + execution_cost: { + read_count: transaction.execution_cost_read_count, + read_length: transaction.execution_cost_read_length, + runtime: transaction.execution_cost_runtime, + write_count: transaction.execution_cost_write_count, + write_length: transaction.execution_cost_write_length, + }, + vm_error: transaction.vm_error, + }; + switch (transaction.type_id) { + case DbTxTypeId.TokenTransfer: { + const tokenTransfer: TokenTransferTransaction = { + ...result, + type: 'token_transfer', + token_transfer: { + recipient: transaction.token_transfer_recipient_address!, + amount: transaction.token_transfer_amount!, + memo: transaction.token_transfer_memo, + }, + }; + return tokenTransfer; + } + case DbTxTypeId.SmartContract: { + const smartContract: SmartContractTransaction = { + ...result, + type: 'smart_contract', + smart_contract: { + clarity_version: transaction.smart_contract_clarity_version, + contract_id: transaction.smart_contract_contract_id!, + source_code: transaction.smart_contract_source_code!, + }, + }; + return smartContract; + } + case DbTxTypeId.ContractCall: { + const contractCall: ContractCallTransaction = { + ...result, + type: 'contract_call', + contract_call: { + contract_id: transaction.contract_call_contract_id!, + function_name: transaction.contract_call_function_name!, + function_args: codec + .decodeClarityValueList(transaction.contract_call_function_args!) + .map(c => ({ + hex: c.hex, + repr: c.repr, + })), + }, + }; + return contractCall; + } + case DbTxTypeId.PoisonMicroblock: { + const poisonMicroblock: PoisonMicroblockTransaction = { + ...result, + type: 'poison_microblock', + }; + return poisonMicroblock; + } + case DbTxTypeId.Coinbase: { + const coinbase: CoinbaseTransaction = { + ...result, + type: 'coinbase', + coinbase: { + alt_recipient: transaction.coinbase_alt_recipient, + payload: transaction.coinbase_payload!, + vrf_proof: transaction.coinbase_vrf_proof, + }, + }; + return coinbase; + } + case DbTxTypeId.TenureChange: { + const tenureChange: TenureChangeTransaction = { + ...result, + type: 'tenure_change', + tenure_change: { + cause: getTxTenureChangeCauseString(transaction.tenure_change_cause!), + tenure_consensus_hash: transaction.tenure_change_tenure_consensus_hash!, + prev_tenure_consensus_hash: transaction.tenure_change_prev_tenure_consensus_hash!, + burn_view_consensus_hash: transaction.tenure_change_burn_view_consensus_hash!, + previous_tenure_end: transaction.tenure_change_previous_tenure_end!, + previous_tenure_blocks: transaction.tenure_change_previous_tenure_blocks!, + pubkey_hash: transaction.tenure_change_pubkey_hash!, + }, + }; + return tenureChange; + } + default: + throw new Error(`Unexpected DbTxTypeId: ${transaction.type_id}`); + } +} diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index f22267896..20f14132c 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -1,6 +1,6 @@ import { BasePgStoreModule } from '@stacks/api-toolkit'; import { DbCursorPaginatedResult } from '../common.js'; -import { DbTransactionSummary } from './types.js'; +import { DbTransaction, DbTransactionSummary } from './types.js'; import { InvalidRequestError, InvalidRequestErrorType } from '../../errors.js'; type TransactionSummaryQueryResult = DbTransactionSummary & { @@ -8,8 +8,62 @@ type TransactionSummaryQueryResult = DbTransactionSummary & { total: number; }; +const TX_SUMMARY_COLUMNS = [ + 'tx_id', + 'sender_address', + 'sponsor_address', + 'sponsor_nonce', + 'nonce', + 'fee_rate', + 'block_height', + 'block_hash', + 'index_block_hash', + 'block_time', + 'tx_index', + 'tenure_height', + 'microblock_sequence', + 'burn_block_height', + 'burn_block_time', + 'canonical', + 'status', + 'type_id', + 'token_transfer_recipient_address', + 'token_transfer_amount', + 'token_transfer_memo', + 'smart_contract_clarity_version', + 'smart_contract_contract_id', + 'contract_call_contract_id', + 'contract_call_function_name', + 'coinbase_alt_recipient', + 'tenure_change_cause', +]; + +const TX_COLUMNS = [ + ...TX_SUMMARY_COLUMNS, + 'parent_block_hash', + 'parent_index_block_hash', + 'post_conditions', + 'event_count', + 'execution_cost_read_count', + 'execution_cost_read_length', + 'execution_cost_runtime', + 'execution_cost_write_count', + 'execution_cost_write_length', + 'vm_error', + 'smart_contract_source_code', + 'contract_call_function_args', + 'coinbase_payload', + 'coinbase_vrf_proof', + 'tenure_change_tenure_consensus_hash', + 'tenure_change_prev_tenure_consensus_hash', + 'tenure_change_burn_view_consensus_hash', + 'tenure_change_previous_tenure_end', + 'tenure_change_previous_tenure_blocks', + 'tenure_change_pubkey_hash', +]; + export class PgStoreV3 extends BasePgStoreModule { - async getTransactionSummaries(args: { + async getTransactionSummaryList(args: { limit: number; cursor?: string; }): Promise> { @@ -45,12 +99,7 @@ export class PgStoreV3 extends BasePgStoreModule { SELECT tx_count FROM chain_tip ) SELECT - tx_id, sender_address, sponsor_address, sponsor_nonce, nonce, fee_rate, - block_height, block_hash, index_block_hash, block_time, tx_index, tenure_height, - microblock_sequence, burn_block_height, burn_block_time, canonical, status, type_id, - token_transfer_recipient_address, token_transfer_amount, token_transfer_memo, - smart_contract_clarity_version, smart_contract_contract_id, contract_call_contract_id, - contract_call_function_name, coinbase_alt_recipient, tenure_change_cause, + ${sql(TX_SUMMARY_COLUMNS)}, (SELECT total FROM total)::int AS total FROM txs WHERE canonical = true @@ -111,4 +160,14 @@ export class PgStoreV3 extends BasePgStoreModule { }; }); } + + async getTransaction(args: { txId: string }): Promise { + const result = await this.sql` + SELECT ${this.sql(TX_COLUMNS)} + FROM txs + WHERE tx_id = ${args.txId} AND canonical = true AND microblock_canonical = true + `; + if (result.length === 0) return null; + return result[0]; + } } diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index b39cddece..6789e3240 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -28,3 +28,26 @@ export interface DbTransactionSummary { coinbase_alt_recipient: string | null; tenure_change_cause: number | null; } + +export interface DbTransaction extends DbTransactionSummary { + parent_block_hash: string; + parent_index_block_hash: string; + post_conditions: string; + event_count: number; + execution_cost_read_count: number; + execution_cost_read_length: number; + execution_cost_runtime: number; + execution_cost_write_count: number; + execution_cost_write_length: number; + vm_error: string | null; + smart_contract_source_code: string | null; + contract_call_function_args: string | null; + coinbase_payload: string | null; + coinbase_vrf_proof: string | null; + tenure_change_tenure_consensus_hash: string | null; + tenure_change_prev_tenure_consensus_hash: string | null; + tenure_change_burn_view_consensus_hash: string | null; + tenure_change_previous_tenure_end: string | null; + tenure_change_previous_tenure_blocks: number | null; + tenure_change_pubkey_hash: string | null; +} From bc06c89e42bb4210102a761d1ec2c241a81dbacb Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:53:23 -0600 Subject: [PATCH 05/32] tx events --- src/api/routes/v3/transactions.ts | 42 ++++++++++++- src/datastore/v3/pg-store-v3.ts | 98 ++++++++++++++++++++++++++----- src/datastore/v3/types.ts | 13 +++- 3 files changed, 135 insertions(+), 18 deletions(-) diff --git a/src/api/routes/v3/transactions.ts b/src/api/routes/v3/transactions.ts index f49bd418c..aff224705 100644 --- a/src/api/routes/v3/transactions.ts +++ b/src/api/routes/v3/transactions.ts @@ -62,8 +62,8 @@ export const V3TransactionsRoutes: FastifyPluginAsync< { preHandler: handleTransactionCache, schema: { - operationId: 'get_transaction_details', - summary: 'Get transaction details', + operationId: 'get_transaction_by_id', + summary: 'Get transaction', description: `Retrieves details for a given transaction ID`, tags: ['Transactions'], params: Type.Object({ @@ -85,5 +85,43 @@ export const V3TransactionsRoutes: FastifyPluginAsync< } ); + fastify.get( + '/:tx_id/events', + { + preHandler: handleTransactionCache, + schema: { + operationId: 'get_transaction_events', + summary: 'Get transaction events', + description: `Retrieves events for a given transaction ID`, + tags: ['Transactions'], + params: Type.Object({ + tx_id: TransactionIdParamSchema, + }), + querystring: Type.Object({ + limit: LimitParam(ResourceType.Event), + cursor: Type.Optional( + Type.String({ + pattern: '^\\d+$', + description: 'Cursor for transaction event pagination', + }) + ), + }), + }, + }, + async (req, reply) => { + const { tx_id } = req.params; + const query = req.query; + const events = await fastify.db.v3.getTransactionEvents({ + txId: tx_id, + limit: getPagingQueryLimit(ResourceType.Event, query.limit), + cursor: query.cursor, + }); + if (query.cursor && !events.current_cursor) { + throw new NotFoundError('Cursor not found'); + } + await reply.send(events); + } + ); + await Promise.resolve(); }; diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 20f14132c..208edcf2b 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -1,7 +1,8 @@ import { BasePgStoreModule } from '@stacks/api-toolkit'; -import { DbCursorPaginatedResult } from '../common.js'; -import { DbTransaction, DbTransactionSummary } from './types.js'; +import { DbCursorPaginatedResult, DbEventTypeId } from '../common.js'; +import { DbTransaction, DbTransactionEvent, DbTransactionSummary } from './types.js'; import { InvalidRequestError, InvalidRequestErrorType } from '../../errors.js'; +import { TransactionLimitParamSchema } from 'src/api/routes/v2/schemas.js'; type TransactionSummaryQueryResult = DbTransactionSummary & { microblock_sequence: number; @@ -71,23 +72,10 @@ export class PgStoreV3 extends BasePgStoreModule { let cursorFilter = sql``; if (args.cursor) { const parts = args.cursor.split(':'); - if (parts.length !== 3) { - throw new InvalidRequestError( - 'Invalid cursor format', - InvalidRequestErrorType.invalid_param - ); - } const [blockHeightStr, microblockSequenceStr, txIndexStr] = parts; const blockHeight = parseInt(blockHeightStr, 10); const microblockSequence = parseInt(microblockSequenceStr, 10); const txIndex = parseInt(txIndexStr, 10); - if (isNaN(blockHeight) || isNaN(microblockSequence) || isNaN(txIndex)) { - throw new InvalidRequestError( - 'Invalid cursor format', - InvalidRequestErrorType.invalid_param - ); - } - cursorFilter = sql` AND (block_height, microblock_sequence, tx_index) <= (${blockHeight}, ${microblockSequence}, ${txIndex}) @@ -170,4 +158,84 @@ export class PgStoreV3 extends BasePgStoreModule { if (result.length === 0) return null; return result[0]; } + + async getTransactionEvents(args: { + txId: string; + limit: number; + cursor?: string; + }): Promise> { + return await this.sqlTransaction(async sql => { + const limit = args.limit ?? TransactionLimitParamSchema.default; + const txCheck = await sql<{ event_count: number }[]>` + SELECT event_count + FROM txs + WHERE tx_id = ${args.txId} AND canonical = true AND microblock_canonical = true + LIMIT 1 + `; + if (txCheck.count === 0) + throw new InvalidRequestError( + `Transaction not found`, + InvalidRequestErrorType.invalid_param + ); + + let cursorFilter = sql``; + if (args.cursor) { + cursorFilter = sql`AND event_index >= ${parseInt(args.cursor, 10)}`; + } + + const eventCond = sql` + canonical = true AND microblock_canonical = true AND tx_id = ${args.txId} ${cursorFilter} + `; + const resultQuery = await sql` + WITH events AS ( + ( + SELECT + sender, recipient, event_index, amount, NULL as asset_identifier, + NULL::bytea as value, ${DbEventTypeId.StxAsset}::int as event_type_id, + asset_event_type_id + FROM stx_events + WHERE ${eventCond} + ) + UNION + ( + SELECT + sender, recipient, event_index, amount, asset_identifier, NULL::bytea as value, + ${DbEventTypeId.FungibleTokenAsset}::int as event_type_id, asset_event_type_id + FROM ft_events + WHERE ${eventCond} + ) + UNION + ( + SELECT + sender, recipient, event_index, 0 as amount, asset_identifier, value, + ${DbEventTypeId.NonFungibleTokenAsset}::int as event_type_id, asset_event_type_id + FROM nft_events + WHERE ${eventCond} + ) + ) + SELECT * + FROM events + ORDER BY event_index ASC + LIMIT ${limit + 1} + `; + const hasNextPage = resultQuery.count > limit; + const results = hasNextPage ? resultQuery.slice(0, limit) : resultQuery; + const firstResult = results[0]; + const extraResult = hasNextPage ? resultQuery[limit] : null; + const prevCursor = + firstResult && firstResult.event_index > 0 + ? Math.max(firstResult.event_index - limit, 0).toString() + : null; + + return { + total: txCheck[0].event_count, + limit, + offset: 0, + next_cursor: extraResult ? extraResult.event_index.toString() : null, + prev_cursor: prevCursor, + current_cursor: firstResult ? firstResult.event_index.toString() : null, + results, + }; + }); + } } diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index 6789e3240..f559c1169 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -1,4 +1,4 @@ -import { DbTxStatus, DbTxTypeId } from '../common.js'; +import { DbAssetEventTypeId, DbEventTypeId, DbTxStatus, DbTxTypeId } from '../common.js'; export interface DbTransactionSummary { tx_id: string; @@ -51,3 +51,14 @@ export interface DbTransaction extends DbTransactionSummary { tenure_change_previous_tenure_blocks: number | null; tenure_change_pubkey_hash: string | null; } + +export interface DbTransactionEvent { + event_index: number; + amount: string; + event_type_id: DbEventTypeId; + asset_event_type_id: DbAssetEventTypeId; + sender: string | null; + recipient: string | null; + asset_identifier: string | null; + value: string | null; +} From 4a9eb2dbd2f188c1d25ddbb6cca891d0a65c682c Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:17:26 -0600 Subject: [PATCH 06/32] cursor names --- src/api/init.ts | 3 +++ src/api/routes/v3/transactions.ts | 8 ++++---- src/datastore/v3/pg-store-v3.ts | 18 +++++++++--------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/api/init.ts b/src/api/init.ts index 58ba98cb3..4689e0ba5 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -49,6 +49,7 @@ import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import * as promClient from 'prom-client'; import DeprecationPlugin from './deprecation-plugin.js'; import { BlockTenureRoutes } from './routes/v2/block-tenures.js'; +import { TransactionRoutes } from './routes/v3/transactions.js'; export interface ApiServer { fastifyApp: FastifyInstance; @@ -99,6 +100,8 @@ export const StacksApiRoutes: FastifyPluginAsync< { prefix: '/extended/v2' } ); + await fastify.register(TransactionRoutes, { prefix: '/extended/v3' }); + // Setup legacy API v1 and v2 routes await fastify.register(BnsNameRoutes, { prefix: '/v1/names' }); await fastify.register(BnsNamespaceRoutes, { prefix: '/v1/namespaces' }); diff --git a/src/api/routes/v3/transactions.ts b/src/api/routes/v3/transactions.ts index aff224705..bb9450ab2 100644 --- a/src/api/routes/v3/transactions.ts +++ b/src/api/routes/v3/transactions.ts @@ -15,13 +15,13 @@ const TransactionSummaryCursorParamSchema = Type.String({ description: 'Cursor for transaction summary pagination', }); -export const V3TransactionsRoutes: FastifyPluginAsync< +export const TransactionRoutes: FastifyPluginAsync< Record, Server, TypeBoxTypeProvider > = async fastify => { fastify.get( - '/', + '/transactions', { preHandler: handleChainTipCache, schema: { @@ -58,7 +58,7 @@ export const V3TransactionsRoutes: FastifyPluginAsync< ); fastify.get( - '/:tx_id', + '/transactions/:tx_id', { preHandler: handleTransactionCache, schema: { @@ -86,7 +86,7 @@ export const V3TransactionsRoutes: FastifyPluginAsync< ); fastify.get( - '/:tx_id/events', + '/transactions/:tx_id/events', { preHandler: handleTransactionCache, schema: { diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 208edcf2b..1dc639d15 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -101,10 +101,10 @@ export class PgStoreV3 extends BasePgStoreModule { const results = hasNextPage ? resultQuery.slice(0, args.limit) : resultQuery; const total = resultQuery.count > 0 ? resultQuery[0].total : 0; - const lastResult = resultQuery[resultQuery.length - 1]; - const prevCursor = - hasNextPage && lastResult - ? `${lastResult.block_height}:${lastResult.microblock_sequence}:${lastResult.tx_index}` + const nextResult = resultQuery[resultQuery.length - 1]; + const nextCursor = + hasNextPage && nextResult + ? `${nextResult.block_height}:${nextResult.microblock_sequence}:${nextResult.tx_index}` : null; const firstResult = results[0]; @@ -112,9 +112,9 @@ export class PgStoreV3 extends BasePgStoreModule { ? `${firstResult.block_height}:${firstResult.microblock_sequence}:${firstResult.tx_index}` : null; - let nextCursor: string | null = null; + let prevCursor: string | null = null; if (firstResult) { - const prevQuery = await sql< + const prevPageQuery = await sql< { block_height: number; microblock_sequence: number; tx_index: number }[] >` SELECT block_height, microblock_sequence, tx_index @@ -131,9 +131,9 @@ export class PgStoreV3 extends BasePgStoreModule { OFFSET ${args.limit - 1} LIMIT 1 `; - if (prevQuery.length > 0) { - const prev = prevQuery[0]; - nextCursor = `${prev.block_height}:${prev.microblock_sequence}:${prev.tx_index}`; + if (prevPageQuery.length > 0) { + const prevPage = prevPageQuery[0]; + prevCursor = `${prevPage.block_height}:${prevPage.microblock_sequence}:${prevPage.tx_index}`; } } From 1feb1a07a66d495be42909bad88766bcc24085c9 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:27:42 -0600 Subject: [PATCH 07/32] mempool schemas --- src/api/routes/v3/mempool.ts | 82 ++++++ src/api/routes/v3/transactions.ts | 14 +- .../v3/mempool-transaction-summaries.ts | 176 +++++++++++++ .../entities/v3/mempool-transactions.ts | 118 +++++++++ .../entities/v3/transaction-summaries.ts | 8 +- src/api/schemas/entities/v3/transactions.ts | 2 +- src/api/serializers/transactions.ts | 238 +++++++++++++++++- src/datastore/v3/pg-store-v3.ts | 103 +++++++- src/datastore/v3/types.ts | 37 +++ 9 files changed, 756 insertions(+), 22 deletions(-) create mode 100644 src/api/routes/v3/mempool.ts create mode 100644 src/api/schemas/entities/v3/mempool-transaction-summaries.ts create mode 100644 src/api/schemas/entities/v3/mempool-transactions.ts diff --git a/src/api/routes/v3/mempool.ts b/src/api/routes/v3/mempool.ts new file mode 100644 index 000000000..95afd60ad --- /dev/null +++ b/src/api/routes/v3/mempool.ts @@ -0,0 +1,82 @@ +import { handleChainTipCache } from '../../controllers/cache-controller.js'; +import { parseDbMempoolTransactionSummary } from '../../serializers/transactions.js'; +import { FastifyPluginAsync } from 'fastify'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Server } from 'node:http'; +import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; +import { PaginatedCursorResponse } from '../../schemas/util.js'; +import { LimitParam } from '../../schemas/params.js'; +import { MempoolTransactionSummarySchema } from 'src/api/schemas/entities/v3/mempool-transaction-summaries.js'; + +export const MempoolRoutes: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + fastify.get( + '/mempool/transactions', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_mempool_transaction_summaries', + summary: 'Get mempool transaction summaries', + description: `Retrieves a list of recently broadcasted transaction summaries`, + tags: ['Mempool'], + querystring: Type.Object({ + limit: LimitParam(ResourceType.Tx), + // cursor: Type.Optional(TransactionSummaryCursorParamSchema), + }), + response: { + 200: PaginatedCursorResponse(MempoolTransactionSummarySchema), + }, + }, + }, + async (req, reply) => { + const query = req.query; + const limit = getPagingQueryLimit(ResourceType.Tx, req.query.limit); + const results = await fastify.db.v3.getMempoolTransactionSummaryList({ ...query, limit }); + // if (query.cursor && !results.current_cursor) { + // throw new NotFoundError('Cursor not found'); + // } + await reply.send({ + limit: results.limit, + offset: results.offset, + total: results.total, + next_cursor: results.next_cursor, + prev_cursor: results.prev_cursor, + cursor: results.current_cursor, + results: results.results.map(r => parseDbMempoolTransactionSummary(r)), + }); + } + ); + + // fastify.get( + // '/mempool/transactions/:tx_id', + // { + // preHandler: handleTransactionCache, + // schema: { + // operationId: 'get_transaction_by_id', + // summary: 'Get transaction', + // description: `Retrieves details for a given transaction ID`, + // tags: ['Transactions'], + // params: Type.Object({ + // tx_id: TransactionIdParamSchema, + // }), + // response: { + // 200: TransactionSchema, + // }, + // }, + // }, + // async (req, reply) => { + // const { tx_id } = req.params; + // const transaction = await fastify.db.v3.getTransaction({ txId: tx_id }); + // if (!transaction) { + // throw new NotFoundError('Transaction not found'); + // } + // const result = parseDbTransaction(transaction); + // await reply.send(result); + // } + // ); + + await Promise.resolve(); +}; diff --git a/src/api/routes/v3/transactions.ts b/src/api/routes/v3/transactions.ts index bb9450ab2..c2b8d8985 100644 --- a/src/api/routes/v3/transactions.ts +++ b/src/api/routes/v3/transactions.ts @@ -1,5 +1,8 @@ import { handleChainTipCache, handleTransactionCache } from '../../controllers/cache-controller.js'; -import { parseDbTransaction, parseDbTransactionSummary } from '../../serializers/transactions.js'; +import { + parseDbTransactionOrMempoolTransaction, + parseDbTransactionSummary, +} from '../../serializers/transactions.js'; import { NotFoundError } from '../../../errors.js'; import { FastifyPluginAsync } from 'fastify'; import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; @@ -8,7 +11,8 @@ import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; import { PaginatedCursorResponse } from '../../schemas/util.js'; import { TransactionSummarySchema } from '../../schemas/entities/v3/transaction-summaries.js'; import { LimitParam, TransactionIdParamSchema } from '../../schemas/params.js'; -import { TransactionSchema } from 'src/api/schemas/entities/v3/transactions.js'; +import { TransactionSchema } from '../../schemas/entities/v3/transactions.js'; +import { MempoolTransactionSchema } from '../../schemas/entities/v3/mempool-transactions.js'; const TransactionSummaryCursorParamSchema = Type.String({ pattern: '^\\d+:\\d+:\\d+$', @@ -64,13 +68,13 @@ export const TransactionRoutes: FastifyPluginAsync< schema: { operationId: 'get_transaction_by_id', summary: 'Get transaction', - description: `Retrieves details for a given transaction ID`, + description: `Retrieves details for a given transaction ID, including both mined and mempool transactions`, tags: ['Transactions'], params: Type.Object({ tx_id: TransactionIdParamSchema, }), response: { - 200: TransactionSchema, + 200: Type.Union([TransactionSchema, MempoolTransactionSchema]), }, }, }, @@ -80,7 +84,7 @@ export const TransactionRoutes: FastifyPluginAsync< if (!transaction) { throw new NotFoundError('Transaction not found'); } - const result = parseDbTransaction(transaction); + const result = parseDbTransactionOrMempoolTransaction(transaction); await reply.send(result); } ); diff --git a/src/api/schemas/entities/v3/mempool-transaction-summaries.ts b/src/api/schemas/entities/v3/mempool-transaction-summaries.ts new file mode 100644 index 000000000..811b0f5d8 --- /dev/null +++ b/src/api/schemas/entities/v3/mempool-transaction-summaries.ts @@ -0,0 +1,176 @@ +import { Static, Type } from '@sinclair/typebox'; +import { TransactionSenderSchema } from './transaction-summaries.js'; +import { Nullable } from '../../util.js'; + +const MempoolTransactionStatusSchema = Type.Union( + [ + Type.Literal('pending'), + Type.Literal('dropped_replace_by_fee'), + Type.Literal('dropped_replace_across_fork'), + Type.Literal('dropped_too_expensive'), + Type.Literal('dropped_stale_garbage_collect'), + Type.Literal('dropped_problematic'), + ], + { description: 'Status of the mempool transaction' } +); +export type MempoolTransactionStatus = Static; + +export const BaseMempoolTransactionSummarySchema = Type.Object({ + tx_id: Type.String({ + description: 'Transaction ID', + }), + sender: TransactionSenderSchema, + sponsor: Nullable(TransactionSenderSchema), + fee_rate: Type.String({ + description: 'Transaction fee as Integer string (64-bit unsigned integer).', + }), + receipt_time: Type.Integer({ + description: + 'A unix timestamp (in seconds) indicating when the transaction broadcast was received by the node.', + }), + receipt_block_height: Type.Integer({ + description: 'Height of the block this transaction was received by the node', + }), + status: MempoolTransactionStatusSchema, +}); +export type BaseMempoolTransactionSummary = Static; + +export const TokenTransferMempoolTransactionSummarySchema = Type.Composite( + [ + BaseMempoolTransactionSummarySchema, + Type.Object({ + type: Type.Literal('token_transfer'), + token_transfer: Type.Object({ + recipient: Type.String(), + amount: Type.String({ + description: 'Transfer amount as Integer string (64-bit unsigned integer)', + }), + memo: Nullable( + Type.String({ + description: + 'Hex encoded arbitrary message, up to 34 bytes length (should try decoding to an ASCII string)', + }) + ), + }), + }), + ], + { + title: 'TokenTransferMempoolTransactionSummary', + description: 'Token transfer mempool transaction summary', + } +); +export type TokenTransferMempoolTransactionSummary = Static< + typeof TokenTransferMempoolTransactionSummarySchema +>; + +export const SmartContractMempoolTransactionSummarySchema = Type.Composite( + [ + BaseMempoolTransactionSummarySchema, + Type.Object({ + type: Type.Literal('smart_contract'), + smart_contract: Type.Object({ + clarity_version: Nullable( + Type.Number({ + description: + 'The Clarity version of the contract, only specified for versioned contract transactions, otherwise null', + }) + ), + contract_id: Type.String({ + description: 'Contract identifier formatted as `.`', + }), + }), + }), + ], + { + title: 'SmartContractMempoolTransactionSummary', + description: 'Smart contract mempool transaction summary', + } +); +export type SmartContractMempoolTransactionSummary = Static< + typeof SmartContractMempoolTransactionSummarySchema +>; + +export const ContractCallMempoolTransactionSummarySchema = Type.Composite( + [ + BaseMempoolTransactionSummarySchema, + Type.Object({ + type: Type.Literal('contract_call'), + contract_call: Type.Object({ + contract_id: Type.String({ + description: 'Contract identifier formatted as `.`', + }), + function_name: Type.String({ + description: 'Name of the Clarity function to be invoked', + }), + }), + }), + ], + { + title: 'ContractCallMempoolTransactionSummary', + description: 'Contract call mempool transaction summary', + } +); +export type ContractCallMempoolTransactionSummary = Static< + typeof ContractCallMempoolTransactionSummarySchema +>; + +// Included for completeness, but not used in the mempool. +export const PoisonMicroblockMempoolTransactionSummarySchema = Type.Composite( + [ + BaseMempoolTransactionSummarySchema, + Type.Object({ + type: Type.Literal('poison_microblock'), + }), + ], + { + title: 'PoisonMicroblockMempoolTransactionSummary', + description: 'Poison microblock mempool transaction summary', + } +); +export type PoisonMicroblockMempoolTransactionSummary = Static< + typeof PoisonMicroblockMempoolTransactionSummarySchema +>; + +// Included for completeness, but not used in the mempool. +export const CoinbaseMempoolTransactionSummarySchema = Type.Composite( + [ + BaseMempoolTransactionSummarySchema, + Type.Object({ + type: Type.Literal('coinbase'), + }), + ], + { + title: 'CoinbaseMempoolTransactionSummary', + description: 'Coinbase mempool transaction summary', + } +); +export type CoinbaseMempoolTransactionSummary = Static< + typeof CoinbaseMempoolTransactionSummarySchema +>; + +// Included for completeness, but not used in the mempool. +export const TenureChangeMempoolTransactionSummarySchema = Type.Composite( + [ + BaseMempoolTransactionSummarySchema, + Type.Object({ + type: Type.Literal('tenure_change'), + }), + ], + { + title: 'TenureChangeMempoolTransactionSummary', + description: 'Tenure change mempool transaction summary', + } +); +export type TenureChangeMempoolTransactionSummary = Static< + typeof TenureChangeMempoolTransactionSummarySchema +>; + +export const MempoolTransactionSummarySchema = Type.Union([ + TokenTransferMempoolTransactionSummarySchema, + SmartContractMempoolTransactionSummarySchema, + ContractCallMempoolTransactionSummarySchema, + PoisonMicroblockMempoolTransactionSummarySchema, + CoinbaseMempoolTransactionSummarySchema, + TenureChangeMempoolTransactionSummarySchema, +]); +export type MempoolTransactionSummary = Static; diff --git a/src/api/schemas/entities/v3/mempool-transactions.ts b/src/api/schemas/entities/v3/mempool-transactions.ts new file mode 100644 index 000000000..21fdb4331 --- /dev/null +++ b/src/api/schemas/entities/v3/mempool-transactions.ts @@ -0,0 +1,118 @@ +import { Static, Type } from '@sinclair/typebox'; +import { BaseMempoolTransactionSummarySchema } from './mempool-transaction-summaries.js'; +import { PostConditionSchema } from '../post-conditions.js'; +import { Nullable } from '../../util.js'; +import { DecodedClarityValueSchema } from './transactions.js'; + +const BaseMempoolTransactionSchema = Type.Composite([ + BaseMempoolTransactionSummarySchema, + Type.Object({ + post_conditions: Type.Array(PostConditionSchema), + replaced_by_tx_id: Nullable( + Type.String({ + description: 'ID of another transaction which replaced this one', + }) + ), + }), +]); +export type BaseMempoolTransaction = Static; + +const TokenTransferMempoolTransactionSchema = Type.Composite([ + BaseMempoolTransactionSchema, + Type.Object({ + type: Type.Literal('token_transfer'), + token_transfer: Type.Object({ + recipient: Type.String({ + description: 'Recipient of the token transfer', + }), + amount: Type.String({ + description: 'Amount of the token transfer', + }), + memo: Nullable( + Type.String({ + description: 'Memo of the token transfer', + }) + ), + }), + }), +]); +export type TokenTransferMempoolTransaction = Static; + +const SmartContractMempoolTransactionSchema = Type.Composite([ + BaseMempoolTransactionSchema, + Type.Object({ + type: Type.Literal('smart_contract'), + smart_contract: Type.Object({ + contract_id: Type.String({ + description: 'Contract ID of the smart contract', + }), + clarity_version: Nullable( + Type.Number({ + description: 'Clarity version of the smart contract', + }) + ), + source_code: Type.String({ + description: 'Source code of the smart contract', + }), + }), + }), +]); +export type SmartContractMempoolTransaction = Static; + +const ContractCallMempoolTransactionSchema = Type.Composite([ + BaseMempoolTransactionSchema, + Type.Object({ + type: Type.Literal('contract_call'), + contract_call: Type.Object({ + contract_id: Type.String({ + description: 'Contract ID of the contract call', + }), + function_name: Type.String({ + description: 'Function name of the contract call', + }), + function_args: Type.Array(DecodedClarityValueSchema, { + description: 'List of arguments used to invoke the function', + }), + }), + }), +]); +export type ContractCallMempoolTransaction = Static; + +// Included for completeness, but not used in the mempool. +const PoisonMicroblockMempoolTransactionSchema = Type.Composite([ + BaseMempoolTransactionSchema, + Type.Object({ + type: Type.Literal('poison_microblock'), + }), +]); +export type PoisonMicroblockMempoolTransaction = Static< + typeof PoisonMicroblockMempoolTransactionSchema +>; + +// Included for completeness, but not used in the mempool. +const TenureChangeMempoolTransactionSchema = Type.Composite([ + BaseMempoolTransactionSchema, + Type.Object({ + type: Type.Literal('tenure_change'), + }), +]); +export type TenureChangeMempoolTransaction = Static; + +// Included for completeness, but not used in the mempool. +const CoinbaseMempoolTransactionSchema = Type.Composite([ + BaseMempoolTransactionSchema, + Type.Object({ + type: Type.Literal('coinbase'), + }), +]); +export type CoinbaseMempoolTransaction = Static; + +export const MempoolTransactionSchema = Type.Union([ + TokenTransferMempoolTransactionSchema, + SmartContractMempoolTransactionSchema, + ContractCallMempoolTransactionSchema, + PoisonMicroblockMempoolTransactionSchema, + TenureChangeMempoolTransactionSchema, + CoinbaseMempoolTransactionSchema, +]); +export type MempoolTransaction = Static; diff --git a/src/api/schemas/entities/v3/transaction-summaries.ts b/src/api/schemas/entities/v3/transaction-summaries.ts index d4463f546..b78e33431 100644 --- a/src/api/schemas/entities/v3/transaction-summaries.ts +++ b/src/api/schemas/entities/v3/transaction-summaries.ts @@ -1,7 +1,7 @@ import { Static, Type } from '@sinclair/typebox'; import { Nullable } from '../../util.js'; -const TransactionSenderSchema = Type.Object({ +export const TransactionSenderSchema = Type.Object({ address: Type.String({ description: 'Address of the transaction initiator', }), @@ -26,7 +26,7 @@ export const TenureChangeCauseSchema = Type.Union( } ); -const TransactionSummaryStatusSchema = Type.Union( +const TransactionStatusSchema = Type.Union( [ Type.Literal('success'), Type.Literal('abort_by_response'), @@ -34,7 +34,7 @@ const TransactionSummaryStatusSchema = Type.Union( ], { description: 'Status of the transaction' } ); -export type TransactionSummaryStatus = Static; +export type TransactionStatus = Static; export const BaseTransactionSummarySchema = Type.Object({ tx_id: Type.String({ @@ -77,7 +77,7 @@ export const BaseTransactionSummarySchema = Type.Object({ canonical: Type.Boolean({ description: 'Set to `true` if block corresponds to the canonical chain tip', }), - status: TransactionSummaryStatusSchema, + status: TransactionStatusSchema, }); export type BaseTransactionSummary = Static; diff --git a/src/api/schemas/entities/v3/transactions.ts b/src/api/schemas/entities/v3/transactions.ts index 0a59b682f..1828091c2 100644 --- a/src/api/schemas/entities/v3/transactions.ts +++ b/src/api/schemas/entities/v3/transactions.ts @@ -3,7 +3,7 @@ import { BaseTransactionSummarySchema, TenureChangeCauseSchema } from './transac import { PostConditionSchema } from '../post-conditions.js'; import { Nullable } from '../../util.js'; -const DecodedClarityValueSchema = Type.Object({ +export const DecodedClarityValueSchema = Type.Object({ hex: Type.String(), repr: Type.String(), }); diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts index b6fa7c2a0..9e1653aec 100644 --- a/src/api/serializers/transactions.ts +++ b/src/api/serializers/transactions.ts @@ -7,9 +7,14 @@ import { TenureChangeTransactionSummary, TokenTransferTransactionSummary, TransactionSummary, - TransactionSummaryStatus, + TransactionStatus, } from '../schemas/entities/v3/transaction-summaries.js'; -import { DbTransaction, DbTransactionSummary } from '../../datastore/v3/types.js'; +import { + DbMempoolTransaction, + DbMempoolTransactionSummary, + DbTransaction, + DbTransactionSummary, +} from '../../datastore/v3/types.js'; import { DbTxStatus, DbTxTypeId } from '../../datastore/common.js'; import { getTxTenureChangeCauseString } from '../controllers/db-controller.js'; import { @@ -24,13 +29,34 @@ import { } from '../schemas/entities/v3/transactions.js'; import codec from '@stacks/codec'; import { serializePostCondition } from './post-conditions.js'; +import { + BaseMempoolTransactionSummary, + CoinbaseMempoolTransactionSummary, + ContractCallMempoolTransactionSummary, + MempoolTransactionStatus, + MempoolTransactionSummary, + PoisonMicroblockMempoolTransactionSummary, + SmartContractMempoolTransactionSummary, + TenureChangeMempoolTransactionSummary, + TokenTransferMempoolTransactionSummary, +} from '../schemas/entities/v3/mempool-transaction-summaries.js'; +import { + BaseMempoolTransaction, + CoinbaseMempoolTransaction, + ContractCallMempoolTransaction, + MempoolTransaction, + PoisonMicroblockMempoolTransaction, + SmartContractMempoolTransaction, + TenureChangeMempoolTransaction, + TokenTransferMempoolTransaction, +} from '../schemas/entities/v3/mempool-transactions.js'; /** * Parses a database transaction summary status into a transaction summary status. * @param status - The database transaction status. * @returns The parsed transaction summary status. */ -function parseDbTransactionSummaryStatus(status: DbTxStatus): TransactionSummaryStatus { +function parseDbTransactionSummaryStatus(status: DbTxStatus): TransactionStatus { switch (status) { case DbTxStatus.AbortByResponse: return 'abort_by_response'; @@ -43,6 +69,30 @@ function parseDbTransactionSummaryStatus(status: DbTxStatus): TransactionSummary } } +/** + * Parses a database mempool transaction summary status into a mempool transaction summary status. + * @param status - The database mempool transaction status. + * @returns The parsed mempool transaction status. + */ +function parseDbMempoolTransactionSummaryStatus(status: DbTxStatus): MempoolTransactionStatus { + switch (status) { + case DbTxStatus.Pending: + return 'pending'; + case DbTxStatus.DroppedReplaceByFee: + return 'dropped_replace_by_fee'; + case DbTxStatus.DroppedReplaceAcrossFork: + return 'dropped_replace_across_fork'; + case DbTxStatus.DroppedTooExpensive: + return 'dropped_too_expensive'; + case DbTxStatus.DroppedStaleGarbageCollect: + return 'dropped_stale_garbage_collect'; + case DbTxStatus.DroppedProblematic: + return 'dropped_problematic'; + default: + throw new Error(`Unexpected DbTxStatus: ${status}`); + } +} + /** * Parses a database transaction summary into a transaction summary. * @param summary - The database transaction summary to parse. @@ -251,3 +301,185 @@ export function parseDbTransaction(transaction: DbTransaction): Transaction { throw new Error(`Unexpected DbTxTypeId: ${transaction.type_id}`); } } + +/** + * Parses a database mempool transaction summary into a mempool transaction summary. + * @param summary - The database mempool transaction summary to parse. + * @returns The parsed mempool transaction summary. + */ +export function parseDbMempoolTransactionSummary( + summary: DbMempoolTransactionSummary +): MempoolTransactionSummary { + const result: BaseMempoolTransactionSummary = { + tx_id: summary.tx_id, + sender: { + address: summary.sender_address, + nonce: summary.nonce, + }, + sponsor: + summary.sponsor_address !== null && summary.sponsor_nonce !== null + ? { + address: summary.sponsor_address, + nonce: summary.sponsor_nonce, + } + : null, + fee_rate: summary.fee_rate, + receipt_time: summary.receipt_time, + receipt_block_height: summary.receipt_block_height, + status: parseDbMempoolTransactionSummaryStatus(summary.status), + }; + switch (summary.type_id) { + case DbTxTypeId.TokenTransfer: { + const tokenTransfer: TokenTransferMempoolTransactionSummary = { + ...result, + type: 'token_transfer', + token_transfer: { + recipient: summary.token_transfer_recipient_address!, + amount: summary.token_transfer_amount!, + memo: summary.token_transfer_memo, + }, + }; + return tokenTransfer; + } + case DbTxTypeId.SmartContract: { + const smartContract: SmartContractMempoolTransactionSummary = { + ...result, + type: 'smart_contract', + smart_contract: { + clarity_version: summary.smart_contract_clarity_version, + contract_id: summary.smart_contract_contract_id!, + }, + }; + return smartContract; + } + case DbTxTypeId.ContractCall: { + const contractCall: ContractCallMempoolTransactionSummary = { + ...result, + type: 'contract_call', + contract_call: { + contract_id: summary.contract_call_contract_id!, + function_name: summary.contract_call_function_name!, + }, + }; + return contractCall; + } + case DbTxTypeId.PoisonMicroblock: { + const poisonMicroblock: PoisonMicroblockMempoolTransactionSummary = { + ...result, + type: 'poison_microblock', + }; + return poisonMicroblock; + } + case DbTxTypeId.Coinbase: { + const coinbase: CoinbaseMempoolTransactionSummary = { + ...result, + type: 'coinbase', + }; + return coinbase; + } + case DbTxTypeId.TenureChange: { + const tenureChange: TenureChangeMempoolTransactionSummary = { + ...result, + type: 'tenure_change', + }; + return tenureChange; + } + default: + throw new Error(`Unexpected DbTxTypeId: ${summary.type_id}`); + } +} + +/** + * Parses a database mempool transaction into a mempool transaction. + * @param transaction - The database mempool transaction to parse. + * @returns The parsed mempool transaction. + */ +export function parseDbMempoolTransaction(transaction: DbMempoolTransaction): MempoolTransaction { + const summary = parseDbMempoolTransactionSummary(transaction); + const decodedPostConditions = codec.decodePostConditions(transaction.post_conditions); + const result: BaseMempoolTransaction = { + ...summary, + post_conditions: decodedPostConditions.post_conditions.map(pc => serializePostCondition(pc)), + replaced_by_tx_id: transaction.replaced_by_tx_id, + }; + switch (transaction.type_id) { + case DbTxTypeId.TokenTransfer: { + const tokenTransfer: TokenTransferMempoolTransaction = { + ...result, + type: 'token_transfer', + token_transfer: { + recipient: transaction.token_transfer_recipient_address!, + amount: transaction.token_transfer_amount!, + memo: transaction.token_transfer_memo, + }, + }; + return tokenTransfer; + } + case DbTxTypeId.SmartContract: { + const smartContract: SmartContractMempoolTransaction = { + ...result, + type: 'smart_contract', + smart_contract: { + clarity_version: transaction.smart_contract_clarity_version, + contract_id: transaction.smart_contract_contract_id!, + source_code: transaction.smart_contract_source_code!, + }, + }; + return smartContract; + } + case DbTxTypeId.ContractCall: { + const contractCall: ContractCallMempoolTransaction = { + ...result, + type: 'contract_call', + contract_call: { + contract_id: transaction.contract_call_contract_id!, + function_name: transaction.contract_call_function_name!, + function_args: codec + .decodeClarityValueList(transaction.contract_call_function_args!) + .map(c => ({ + hex: c.hex, + repr: c.repr, + })), + }, + }; + return contractCall; + } + case DbTxTypeId.PoisonMicroblock: { + const poisonMicroblock: PoisonMicroblockMempoolTransaction = { + ...result, + type: 'poison_microblock', + }; + return poisonMicroblock; + } + case DbTxTypeId.Coinbase: { + const coinbase: CoinbaseMempoolTransaction = { + ...result, + type: 'coinbase', + }; + return coinbase; + } + case DbTxTypeId.TenureChange: { + const tenureChange: TenureChangeMempoolTransaction = { + ...result, + type: 'tenure_change', + }; + return tenureChange; + } + default: + throw new Error(`Unexpected DbTxTypeId: ${transaction.type_id}`); + } +} + +/** + * Parses a database transaction or mempool transaction into a transaction or mempool transaction. + * @param transaction - The database transaction or mempool transaction to parse. + * @returns The parsed transaction or mempool transaction. + */ +export function parseDbTransactionOrMempoolTransaction( + transaction: DbTransaction | DbMempoolTransaction +): Transaction | MempoolTransaction { + if ('index_block_hash' in transaction) { + return parseDbTransaction(transaction); + } + return parseDbMempoolTransaction(transaction); +} diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 1dc639d15..fd30e0dd6 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -1,6 +1,12 @@ import { BasePgStoreModule } from '@stacks/api-toolkit'; import { DbCursorPaginatedResult, DbEventTypeId } from '../common.js'; -import { DbTransaction, DbTransactionEvent, DbTransactionSummary } from './types.js'; +import { + DbMempoolTransaction, + DbMempoolTransactionSummary, + DbTransaction, + DbTransactionEvent, + DbTransactionSummary, +} from './types.js'; import { InvalidRequestError, InvalidRequestErrorType } from '../../errors.js'; import { TransactionLimitParamSchema } from 'src/api/routes/v2/schemas.js'; @@ -63,6 +69,44 @@ const TX_COLUMNS = [ 'tenure_change_pubkey_hash', ]; +const MEMPOOL_TX_SUMMARY_COLUMNS = [ + 'tx_id', + 'type_id', + 'status', + 'sender_address', + 'nonce', + 'sponsor_address', + 'sponsor_nonce', + 'fee_rate', + 'receipt_time', + 'receipt_block_height', + 'token_transfer_recipient_address', + 'token_transfer_amount', + 'token_transfer_memo', + 'smart_contract_clarity_version', + 'smart_contract_contract_id', + 'contract_call_contract_id', + 'contract_call_function_name', + 'coinbase_alt_recipient', + 'tenure_change_cause', +]; + +const MEMPOOL_TX_COLUMNS = [ + ...MEMPOOL_TX_SUMMARY_COLUMNS, + 'replaced_by_tx_id', + 'post_conditions', + 'smart_contract_source_code', + 'contract_call_function_args', + 'coinbase_payload', + 'coinbase_vrf_proof', + 'tenure_change_tenure_consensus_hash', + 'tenure_change_prev_tenure_consensus_hash', + 'tenure_change_burn_view_consensus_hash', + 'tenure_change_previous_tenure_end', + 'tenure_change_previous_tenure_blocks', + 'tenure_change_pubkey_hash', +]; + export class PgStoreV3 extends BasePgStoreModule { async getTransactionSummaryList(args: { limit: number; @@ -149,14 +193,55 @@ export class PgStoreV3 extends BasePgStoreModule { }); } - async getTransaction(args: { txId: string }): Promise { - const result = await this.sql` - SELECT ${this.sql(TX_COLUMNS)} - FROM txs - WHERE tx_id = ${args.txId} AND canonical = true AND microblock_canonical = true - `; - if (result.length === 0) return null; - return result[0]; + async getMempoolTransactionSummaryList(args: { + limit: number; + cursor?: string; + }): Promise> { + return await this.sqlTransaction(async sql => { + const resultQuery = await sql<(DbMempoolTransactionSummary & { total: number })[]>` + SELECT + ${sql(MEMPOOL_TX_SUMMARY_COLUMNS)}, + (SELECT mempool_tx_count FROM chain_tip) AS total + FROM mempool_txs + WHERE pruned = false + ORDER BY receipt_time DESC + LIMIT ${args.limit} + `; + + return { + limit: args.limit, + offset: 0, + next_cursor: null, + prev_cursor: null, + current_cursor: null, + total: resultQuery[0]?.total ?? 0, + results: resultQuery, + }; + }); + } + + async getTransaction(args: { + txId: string; + }): Promise { + return await this.sqlTransaction(async sql => { + const result = await this.sql` + SELECT ${this.sql(TX_COLUMNS)} + FROM txs + WHERE tx_id = ${args.txId} AND canonical = true AND microblock_canonical = true + `; + if (result.count > 0) { + return result[0]; + } + const mempoolResult = await sql` + SELECT ${this.sql(MEMPOOL_TX_COLUMNS)} + FROM mempool_txs + WHERE tx_id = ${args.txId} AND pruned = false + `; + if (mempoolResult.count > 0) { + return mempoolResult[0]; + } + return null; + }); } async getTransactionEvents(args: { diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index f559c1169..dda73cda0 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -62,3 +62,40 @@ export interface DbTransactionEvent { asset_identifier: string | null; value: string | null; } + +export interface DbMempoolTransactionSummary { + tx_id: string; + type_id: DbTxTypeId; + status: DbTxStatus; + sender_address: string; + nonce: number; + sponsor_address: string | null; + sponsor_nonce: number | null; + fee_rate: string; + receipt_time: number; + receipt_block_height: number; + token_transfer_recipient_address: string | null; + token_transfer_amount: string | null; + token_transfer_memo: string | null; + smart_contract_clarity_version: number | null; + smart_contract_contract_id: string | null; + contract_call_contract_id: string | null; + contract_call_function_name: string | null; + coinbase_alt_recipient: string | null; + tenure_change_cause: number | null; +} + +export interface DbMempoolTransaction extends DbMempoolTransactionSummary { + post_conditions: string; + replaced_by_tx_id: string | null; + smart_contract_source_code: string | null; + contract_call_function_args: string | null; + coinbase_payload: string | null; + coinbase_vrf_proof: string | null; + tenure_change_tenure_consensus_hash: string | null; + tenure_change_prev_tenure_consensus_hash: string | null; + tenure_change_burn_view_consensus_hash: string | null; + tenure_change_previous_tenure_end: string | null; + tenure_change_previous_tenure_blocks: number | null; + tenure_change_pubkey_hash: string | null; +} From ee7acb4f9069a0f51db268cd58457f72adcbb097 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:50:53 -0600 Subject: [PATCH 08/32] mempool list cursor --- ...00_mempool-txs-receipt-time-tx-id-index.ts | 19 +++++++ src/api/routes/v3/mempool.ts | 14 +++-- src/datastore/v3/pg-store-v3.ts | 52 ++++++++++++++++--- 3 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 migrations/1775000000000_mempool-txs-receipt-time-tx-id-index.ts diff --git a/migrations/1775000000000_mempool-txs-receipt-time-tx-id-index.ts b/migrations/1775000000000_mempool-txs-receipt-time-tx-id-index.ts new file mode 100644 index 000000000..83824f653 --- /dev/null +++ b/migrations/1775000000000_mempool-txs-receipt-time-tx-id-index.ts @@ -0,0 +1,19 @@ +import type { MigrationBuilder } from 'node-pg-migrate'; + +export const up = (pgm: MigrationBuilder) => { + pgm.createIndex( + 'mempool_txs', + [ + { name: 'receipt_time', sort: 'DESC' }, + { name: 'tx_id', sort: 'DESC' }, + ], + { + name: 'mempool_txs_unpruned_receipt_time_tx_id_idx', + where: 'pruned = FALSE', + } + ); +}; + +export const down = (pgm: MigrationBuilder) => { + pgm.dropIndex('mempool_txs', [], { name: 'mempool_txs_unpruned_receipt_time_tx_id_idx' }); +}; diff --git a/src/api/routes/v3/mempool.ts b/src/api/routes/v3/mempool.ts index 95afd60ad..0c2bad346 100644 --- a/src/api/routes/v3/mempool.ts +++ b/src/api/routes/v3/mempool.ts @@ -1,5 +1,6 @@ import { handleChainTipCache } from '../../controllers/cache-controller.js'; import { parseDbMempoolTransactionSummary } from '../../serializers/transactions.js'; +import { NotFoundError } from '../../../errors.js'; import { FastifyPluginAsync } from 'fastify'; import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { Server } from 'node:http'; @@ -8,6 +9,11 @@ import { PaginatedCursorResponse } from '../../schemas/util.js'; import { LimitParam } from '../../schemas/params.js'; import { MempoolTransactionSummarySchema } from 'src/api/schemas/entities/v3/mempool-transaction-summaries.js'; +const MempoolTransactionSummaryCursorParamSchema = Type.String({ + pattern: '^\\d+:(0x)?[a-fA-F0-9]{64}$', + description: 'Cursor for mempool transaction summary pagination', +}); + export const MempoolRoutes: FastifyPluginAsync< Record, Server, @@ -24,7 +30,7 @@ export const MempoolRoutes: FastifyPluginAsync< tags: ['Mempool'], querystring: Type.Object({ limit: LimitParam(ResourceType.Tx), - // cursor: Type.Optional(TransactionSummaryCursorParamSchema), + cursor: Type.Optional(MempoolTransactionSummaryCursorParamSchema), }), response: { 200: PaginatedCursorResponse(MempoolTransactionSummarySchema), @@ -35,9 +41,9 @@ export const MempoolRoutes: FastifyPluginAsync< const query = req.query; const limit = getPagingQueryLimit(ResourceType.Tx, req.query.limit); const results = await fastify.db.v3.getMempoolTransactionSummaryList({ ...query, limit }); - // if (query.cursor && !results.current_cursor) { - // throw new NotFoundError('Cursor not found'); - // } + if (query.cursor && !results.current_cursor) { + throw new NotFoundError('Cursor not found'); + } await reply.send({ limit: results.limit, offset: results.offset, diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index fd30e0dd6..8ad45a097 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -107,6 +107,12 @@ const MEMPOOL_TX_COLUMNS = [ 'tenure_change_pubkey_hash', ]; +function encodeMempoolTxSummaryCursor( + tx: Pick +) { + return `${tx.receipt_time}:${tx.tx_id}`; +} + export class PgStoreV3 extends BasePgStoreModule { async getTransactionSummaryList(args: { limit: number; @@ -198,24 +204,56 @@ export class PgStoreV3 extends BasePgStoreModule { cursor?: string; }): Promise> { return await this.sqlTransaction(async sql => { + let cursorFilter = sql``; + if (args.cursor) { + const [receiptTime, txId] = args.cursor.split(':'); + cursorFilter = sql` + AND (receipt_time, tx_id) <= (${parseInt(receiptTime, 10)}, ${txId}) + `; + } + const resultQuery = await sql<(DbMempoolTransactionSummary & { total: number })[]>` SELECT ${sql(MEMPOOL_TX_SUMMARY_COLUMNS)}, (SELECT mempool_tx_count FROM chain_tip) AS total FROM mempool_txs WHERE pruned = false - ORDER BY receipt_time DESC - LIMIT ${args.limit} + ${cursorFilter} + ORDER BY receipt_time DESC, tx_id DESC + LIMIT ${args.limit + 1} `; + const hasNextPage = resultQuery.count > args.limit; + const results = hasNextPage ? resultQuery.slice(0, args.limit) : resultQuery; + const total = resultQuery.count > 0 ? resultQuery[0].total : 0; + const firstResult = results[0]; + const extraResult = hasNextPage ? resultQuery[args.limit] : null; + + let prevCursor: string | null = null; + if (firstResult) { + const prevPageQuery = await sql< + Pick[] + >` + SELECT receipt_time, tx_id + FROM mempool_txs + WHERE pruned = false + AND (receipt_time, tx_id) > (${firstResult.receipt_time}, ${firstResult.tx_id}) + ORDER BY receipt_time ASC, tx_id ASC + OFFSET ${args.limit - 1} + LIMIT 1 + `; + prevCursor = + prevPageQuery.length > 0 ? encodeMempoolTxSummaryCursor(prevPageQuery[0]) : null; + } + return { limit: args.limit, offset: 0, - next_cursor: null, - prev_cursor: null, - current_cursor: null, - total: resultQuery[0]?.total ?? 0, - results: resultQuery, + next_cursor: extraResult ? encodeMempoolTxSummaryCursor(extraResult) : null, + prev_cursor: prevCursor, + current_cursor: firstResult ? encodeMempoolTxSummaryCursor(firstResult) : null, + total, + results, }; }); } From 1e94fcc2123cefc75a9ad49b9b0f2e1a88534869 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:36:30 -0600 Subject: [PATCH 09/32] fix fastify plugin --- package-lock.json | 83 ++--------------------------------------------- package.json | 1 + 2 files changed, 3 insertions(+), 81 deletions(-) diff --git a/package-lock.json b/package-lock.json index ca598d64d..192d88f8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "env-schema": "7.0.0", "fastify": "5.8.2", "fastify-metrics": "12.1.0", + "fastify-plugin": "5.1.0", "getopts": "2.3.0", "ioredis": "5.10.1", "jsonrpc-lite": "2.2.0", @@ -781,22 +782,6 @@ "toad-cache": "^3.7.0" } }, - "node_modules/@fastify/cors/node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -852,22 +837,6 @@ "fastify-plugin": "^5.0.0" } }, - "node_modules/@fastify/formbody/node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@fastify/forwarded": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", @@ -906,22 +875,6 @@ "ws": "^8.18.3" } }, - "node_modules/@fastify/http-proxy/node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", @@ -986,22 +939,6 @@ "undici": "^7.0.0" } }, - "node_modules/@fastify/reply-from/node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@fastify/reply-from/node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -1034,22 +971,6 @@ "yaml": "^2.4.2" } }, - "node_modules/@fastify/swagger/node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@fastify/type-provider-typebox": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@fastify/type-provider-typebox/-/type-provider-typebox-5.2.0.tgz", @@ -4412,7 +4333,7 @@ "fastify": ">=5" } }, - "node_modules/fastify-metrics/node_modules/fastify-plugin": { + "node_modules/fastify-plugin": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", diff --git a/package.json b/package.json index 0da0d2a35..79793a731 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "env-schema": "7.0.0", "fastify": "5.8.2", "fastify-metrics": "12.1.0", + "fastify-plugin": "5.1.0", "getopts": "2.3.0", "ioredis": "5.10.1", "jsonrpc-lite": "2.2.0", From f0d9c3e15a4b99d9bf5d5b61f05beabb7ee83fa1 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:46:07 -0600 Subject: [PATCH 10/32] first tests --- package.json | 1 + src/api/init.ts | 9 +- src/api/routes/v3/transactions.ts | 13 +- .../entities/v3/transaction-summaries.ts | 3 - src/api/schemas/util.ts | 15 ++ src/api/serializers/transactions.ts | 1 - src/datastore/v3/pg-store-v3.ts | 3 +- tests/api/v3/transactions.test.ts | 226 ++++++++++++++++++ 8 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 tests/api/v3/transactions.test.ts diff --git a/package.json b/package.json index 79793a731..36e63863d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:api:synthetic-txs": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/api/setup.ts --test-concurrency=1 ./tests/api/synthetic-txs/**/*.test.ts", "test:api:transactions": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/api/setup.ts --test-concurrency=1 ./tests/api/transactions/**/*.test.ts", "test:api:v2-proxy": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/api/setup.ts --test-concurrency=1 ./tests/api/v2-proxy/**/*.test.ts", + "test:api:v3": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/api/setup.ts --test-concurrency=1 ./tests/api/v3/**/*.test.ts", "test:snp": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/snp/setup.ts --test-concurrency=1 ./tests/snp/**/*.test.ts", "test:krypton:bns-e2e": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/krypton/setup.ts --test-concurrency=1 ./tests/krypton/bns-e2e/**/*.test.ts", "test:krypton:faucet-btc": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/krypton/setup.ts --test-concurrency=1 ./tests/krypton/faucet-btc/**/*.test.ts", diff --git a/src/api/init.ts b/src/api/init.ts index 4689e0ba5..4ab2ea5cc 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -50,6 +50,7 @@ import * as promClient from 'prom-client'; import DeprecationPlugin from './deprecation-plugin.js'; import { BlockTenureRoutes } from './routes/v2/block-tenures.js'; import { TransactionRoutes } from './routes/v3/transactions.js'; +import { MempoolRoutes } from './routes/v3/mempool.js'; export interface ApiServer { fastifyApp: FastifyInstance; @@ -100,7 +101,13 @@ export const StacksApiRoutes: FastifyPluginAsync< { prefix: '/extended/v2' } ); - await fastify.register(TransactionRoutes, { prefix: '/extended/v3' }); + await fastify.register( + async fastify => { + await fastify.register(TransactionRoutes); + await fastify.register(MempoolRoutes); + }, + { prefix: '/extended/v3' } + ); // Setup legacy API v1 and v2 routes await fastify.register(BnsNameRoutes, { prefix: '/v1/names' }); diff --git a/src/api/routes/v3/transactions.ts b/src/api/routes/v3/transactions.ts index c2b8d8985..ea2422990 100644 --- a/src/api/routes/v3/transactions.ts +++ b/src/api/routes/v3/transactions.ts @@ -8,7 +8,7 @@ import { FastifyPluginAsync } from 'fastify'; import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { Server } from 'node:http'; import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; -import { PaginatedCursorResponse } from '../../schemas/util.js'; +import { CursorResponse } from '../../schemas/util.js'; import { TransactionSummarySchema } from '../../schemas/entities/v3/transaction-summaries.js'; import { LimitParam, TransactionIdParamSchema } from '../../schemas/params.js'; import { TransactionSchema } from '../../schemas/entities/v3/transactions.js'; @@ -38,7 +38,7 @@ export const TransactionRoutes: FastifyPluginAsync< cursor: Type.Optional(TransactionSummaryCursorParamSchema), }), response: { - 200: PaginatedCursorResponse(TransactionSummarySchema), + 200: CursorResponse(TransactionSummarySchema), }, }, }, @@ -51,11 +51,12 @@ export const TransactionRoutes: FastifyPluginAsync< } await reply.send({ limit: results.limit, - offset: results.offset, total: results.total, - next_cursor: results.next_cursor, - prev_cursor: results.prev_cursor, - cursor: results.current_cursor, + cursor: { + next: results.next_cursor, + previous: results.prev_cursor, + current: results.current_cursor, + }, results: results.results.map(r => parseDbTransactionSummary(r)), }); } diff --git a/src/api/schemas/entities/v3/transaction-summaries.ts b/src/api/schemas/entities/v3/transaction-summaries.ts index b78e33431..86fff63c3 100644 --- a/src/api/schemas/entities/v3/transaction-summaries.ts +++ b/src/api/schemas/entities/v3/transaction-summaries.ts @@ -62,9 +62,6 @@ export const BaseTransactionSummarySchema = Type.Object({ description: 'Index of the transaction, indicating the order. Starts at `0` and increases with each transaction', }), - tenure_height: Type.Integer({ - description: 'Height of the tenure this transactions was associated with', - }), }), burn_block: Type.Object({ height: Type.Integer({ diff --git a/src/api/schemas/util.ts b/src/api/schemas/util.ts index 5905e21f6..01c8afc44 100644 --- a/src/api/schemas/util.ts +++ b/src/api/schemas/util.ts @@ -26,3 +26,18 @@ export const PaginatedCursorResponse = (type: T, options?: Ob }, options ); + +export const CursorResponse = (type: T, options?: ObjectOptions) => + Type.Object( + { + total: Type.Integer({ examples: [1] }), + limit: Type.Integer({ examples: [20] }), + cursor: Type.Object({ + next: Nullable(Type.String({ description: 'Next page cursor' })), + previous: Nullable(Type.String({ description: 'Previous page cursor' })), + current: Nullable(Type.String({ description: 'Current page cursor' })), + }), + results: Type.Array(type), + }, + options + ); diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts index 9e1653aec..b08e5d020 100644 --- a/src/api/serializers/transactions.ts +++ b/src/api/serializers/transactions.ts @@ -119,7 +119,6 @@ export function parseDbTransactionSummary(summary: DbTransactionSummary): Transa index_hash: summary.index_block_hash, time: summary.block_time, tx_index: summary.tx_index, - tenure_height: summary.tenure_height, }, burn_block: { height: summary.burn_block_height, diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 8ad45a097..5a0ef1380 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -27,7 +27,6 @@ const TX_SUMMARY_COLUMNS = [ 'index_block_hash', 'block_time', 'tx_index', - 'tenure_height', 'microblock_sequence', 'burn_block_height', 'burn_block_time', @@ -138,7 +137,7 @@ export class PgStoreV3 extends BasePgStoreModule { ) SELECT ${sql(TX_SUMMARY_COLUMNS)}, - (SELECT total FROM total)::int AS total + (SELECT tx_count FROM total)::int AS total FROM txs WHERE canonical = true AND microblock_canonical = true diff --git a/tests/api/v3/transactions.test.ts b/tests/api/v3/transactions.test.ts new file mode 100644 index 000000000..06de29ddd --- /dev/null +++ b/tests/api/v3/transactions.test.ts @@ -0,0 +1,226 @@ +import { describe, test, beforeEach, afterEach } from 'node:test'; +import { PgWriteStore } from '../../../src/datastore/pg-write-store.ts'; +import { ApiServer, startApiServer } from '../../../src/api/init.ts'; +import { migrate } from '../../test-helpers.ts'; +import { STACKS_TESTNET } from '@stacks/network'; +import * as assert from 'node:assert/strict'; +import { TestBlockBuilder } from '../test-builders.ts'; +import { DbTxStatus, DbTxTypeId } from 'src/datastore/common.ts'; + +describe('transactions', () => { + let db: PgWriteStore; + let api: ApiServer; + + beforeEach(async () => { + await migrate('up'); + db = await PgWriteStore.connect({ + usageName: 'tests', + withNotifier: false, + skipMigrations: true, + }); + api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + describe('/v3/transactions', () => { + test('should return an empty list', async () => { + const response = await api.fastifyApp.inject({ + method: 'GET', + url: '/extended/v3/transactions', + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.equal(body.limit, 20); + assert.equal(body.total, 0); + assert.equal(body.cursor.next, null); + assert.equal(body.cursor.previous, null); + assert.equal(body.cursor.current, null); + assert.equal(body.results.length, 0); + }); + + test('should return a list of transaction summaries', async () => { + await db.update( + new TestBlockBuilder({ + block_height: 1, + index_block_hash: '0x0001', + parent_index_block_hash: '0x0000', + parent_block_hash: '0x0000', + }) + .addTx({ + tx_id: '0x0001', + block_hash: '0x0001', + index_block_hash: '0x0001', + block_time: 1000, + burn_block_height: 1, + burn_block_time: 1000, + tx_index: 0, + fee_rate: 50n, + type_id: DbTxTypeId.Coinbase, + status: DbTxStatus.Success, + sender_address: 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27', + }) + .build() + ); + await db.update( + new TestBlockBuilder({ + block_height: 2, + index_block_hash: '0x0002', + parent_index_block_hash: '0x0001', + parent_block_hash: '0x0001', + }) + .addTx({ + tx_id: '0x0002', + tx_index: 0, + fee_rate: 50n, + block_hash: '0x0002', + index_block_hash: '0x0002', + block_time: 2000, + burn_block_height: 2, + burn_block_time: 2000, + type_id: DbTxTypeId.TokenTransfer, + status: DbTxStatus.Success, + sender_address: 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27', + token_transfer_recipient_address: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6', + token_transfer_amount: 100n, + token_transfer_memo: '0x', + }) + .build() + ); + + const response = await api.fastifyApp.inject({ + method: 'GET', + url: '/extended/v3/transactions', + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.equal(body.total, 2); + assert.equal(body.limit, 20); + assert.equal(body.cursor.next, null); + assert.equal(body.cursor.previous, null); + assert.equal(body.cursor.current, '2:0:0'); + assert.equal(body.results.length, 2); + assert.deepEqual(body.results[0], { + tx_id: '0x0002', + type: 'token_transfer', + status: 'success', + block: { + height: 2, + hash: '0x0002', + index_hash: '0x0002', + time: 2000, + tx_index: 0, + }, + burn_block: { + height: 2, + time: 2000, + }, + canonical: true, + sender: { + address: 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27', + nonce: 0, + }, + sponsor: null, + fee_rate: '50', + token_transfer: { + recipient: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6', + amount: '100', + memo: '0x', + }, + }); + assert.deepEqual(body.results[1], { + tx_id: '0x0001', + type: 'coinbase', + status: 'success', + block: { + height: 1, + hash: '0x0001', + index_hash: '0x0001', + time: 1000, + tx_index: 0, + }, + burn_block: { + height: 1, + time: 1000, + }, + canonical: true, + sender: { + address: 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27', + nonce: 0, + }, + sponsor: null, + fee_rate: '50', + coinbase: { + alt_recipient: null, + }, + }); + }); + + test('should allow cursor pagination', async () => { + for (let i = 1; i <= 10; i++) { + const hex = i.toString(16).padStart(64, '0'); + const prevHex = (i - 1).toString(16).padStart(64, '0'); + const builder = new TestBlockBuilder({ + block_height: i, + index_block_hash: `0x${hex}`, + parent_index_block_hash: `0x${prevHex}`, + parent_block_hash: `0x${prevHex}`, + }); + for (let j = 1; j <= 5; j++) { + builder.addTx({ + tx_id: `0x${(i * j).toString(16).padStart(8, '0')}`, + block_hash: `0x${hex}`, + index_block_hash: `0x${hex}`, + block_time: i * 1000, + burn_block_height: i, + burn_block_time: i * 1000, + }); + } + await db.update(builder.build()); + } + + // Fetch first page + const page1 = await api.fastifyApp.inject({ + method: 'GET', + url: '/extended/v3/transactions', + query: { + limit: '5', + }, + }); + assert.equal(page1.statusCode, 200); + const body1 = JSON.parse(page1.body); + assert.equal(body1.total, 50); + assert.equal(body1.limit, 5); + assert.equal(body1.results.length, 5); + assert.deepEqual(body1.cursor, { + next: '9:0:4', + previous: null, + current: '10:0:4', + }); + + // Fetch second page + const page2 = await api.fastifyApp.inject({ + method: 'GET', + url: '/extended/v3/transactions', + query: { + limit: '5', + cursor: '9:0:4', + }, + }); + assert.equal(page2.statusCode, 200); + const body2 = JSON.parse(page2.body); + assert.equal(body2.total, 50); + assert.equal(body2.limit, 5); + assert.equal(body2.results.length, 5); + assert.deepEqual(body2.cursor, { + next: '8:0:4', + previous: '10:0:4', + current: '9:0:4', + }); + }); + }); +}); From d5cbb004cb47a7b26383dd1519f1ecfd2ad02bf3 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:35:20 -0600 Subject: [PATCH 11/32] block schemas --- .../entities/v3/bitcoin-block-summaries.ts | 15 ++++++++ src/api/schemas/entities/v3/bitcoin-blocks.ts | 12 ++++++ .../schemas/entities/v3/block-summaries.ts | 37 +++++++++++++++++++ src/api/schemas/entities/v3/blocks.ts | 22 +++++++++++ src/api/schemas/entities/v3/common.ts | 26 +++++++++++++ .../entities/v3/mempool-transactions.ts | 2 +- .../entities/v3/transaction-summaries.ts | 2 +- src/api/schemas/entities/v3/transactions.ts | 24 +----------- src/api/serializers/transactions.ts | 2 +- 9 files changed, 116 insertions(+), 26 deletions(-) create mode 100644 src/api/schemas/entities/v3/bitcoin-block-summaries.ts create mode 100644 src/api/schemas/entities/v3/bitcoin-blocks.ts create mode 100644 src/api/schemas/entities/v3/block-summaries.ts create mode 100644 src/api/schemas/entities/v3/blocks.ts create mode 100644 src/api/schemas/entities/v3/common.ts diff --git a/src/api/schemas/entities/v3/bitcoin-block-summaries.ts b/src/api/schemas/entities/v3/bitcoin-block-summaries.ts new file mode 100644 index 000000000..2bc34e964 --- /dev/null +++ b/src/api/schemas/entities/v3/bitcoin-block-summaries.ts @@ -0,0 +1,15 @@ +import { Static, Type } from '@sinclair/typebox'; + +export const BitcoinBlockSummarySchema = Type.Object({ + height: Type.Integer({ description: 'Height of the bitcoin block' }), + hash: Type.String({ description: 'Hash of the bitcoin block' }), + time: Type.Integer({ + description: 'Unix timestamp (in seconds) indicating when this block was mined.', + }), + blocks_total: Type.Integer({ description: 'Total number of stacks blocks in the bitcoin block' }), + transactions_total: Type.Integer({ + description: + 'Total number of transactions in the Stacks blocks associated with this bitcoin block', + }), +}); +export type BitcoinBlockSummary = Static; diff --git a/src/api/schemas/entities/v3/bitcoin-blocks.ts b/src/api/schemas/entities/v3/bitcoin-blocks.ts new file mode 100644 index 000000000..c306cc6eb --- /dev/null +++ b/src/api/schemas/entities/v3/bitcoin-blocks.ts @@ -0,0 +1,12 @@ +import { Static, Type } from '@sinclair/typebox'; +import { BitcoinBlockSummarySchema } from './bitcoin-block-summaries.js'; + +export const BitcoinBlockSchema = Type.Composite([ + BitcoinBlockSummarySchema, + Type.Object({ + avg_block_time_seconds: Type.Integer({ + description: 'Average time between blocks in seconds.', + }), + }), +]); +export type BitcoinBlock = Static; diff --git a/src/api/schemas/entities/v3/block-summaries.ts b/src/api/schemas/entities/v3/block-summaries.ts new file mode 100644 index 000000000..b1102a118 --- /dev/null +++ b/src/api/schemas/entities/v3/block-summaries.ts @@ -0,0 +1,37 @@ +import { Static, Type } from '@sinclair/typebox'; + +export const BlockSummarySchema = Type.Object({ + height: Type.Integer({ + description: 'Height of the block', + }), + hash: Type.String({ + description: 'Hash of the block', + }), + index_hash: Type.String({ + description: 'Index block hash of the block', + }), + time: Type.Number({ + description: 'Unix timestamp (in seconds) indicating when this block was mined.', + }), + canonical: Type.Boolean({ + description: 'Set to `true` if block corresponds to the canonical chain tip', + }), + tenure_height: Type.Integer({ + description: 'The tenure height (AKA coinbase height) of this block', + }), + bitcoin_block: Type.Object({ + height: Type.Integer({ + description: 'Height of the bitcoin block', + }), + hash: Type.String({ + description: 'Hash of the bitcoin block', + }), + time: Type.Number({ + description: 'Unix timestamp (in seconds) indicating when this bitcoin block was mined.', + }), + }), + transactions_total: Type.Integer({ + description: 'Number of transactions in the block', + }), +}); +export type BlockSummary = Static; diff --git a/src/api/schemas/entities/v3/blocks.ts b/src/api/schemas/entities/v3/blocks.ts new file mode 100644 index 000000000..5b68ce1e7 --- /dev/null +++ b/src/api/schemas/entities/v3/blocks.ts @@ -0,0 +1,22 @@ +import { Static, Type } from '@sinclair/typebox'; +import { BlockSummarySchema } from './block-summaries.js'; +import { ExecutionCostSchema } from './common.js'; + +export const BlockSchema = Type.Composite([ + BlockSummarySchema, + Type.Object({ + parent_block: Type.Object({ + hash: Type.String({ + description: 'Hash of the parent block', + }), + index_hash: Type.String({ + description: 'Index block hash of the parent block', + }), + }), + bitcoin_tx_id: Type.String({ + description: 'Bitcoin transaction ID that anchors this block', + }), + execution_cost: ExecutionCostSchema, + }), +]); +export type BlockSummary = Static; diff --git a/src/api/schemas/entities/v3/common.ts b/src/api/schemas/entities/v3/common.ts new file mode 100644 index 000000000..839dc2d08 --- /dev/null +++ b/src/api/schemas/entities/v3/common.ts @@ -0,0 +1,26 @@ +import { Static, Type } from '@sinclair/typebox'; + +export const DecodedClarityValueSchema = Type.Object({ + hex: Type.String(), + repr: Type.String(), +}); +export type DecodedClarityValue = Static; + +export const ExecutionCostSchema = Type.Object({ + read_count: Type.Integer({ + description: 'Number of reads in the transaction', + }), + read_length: Type.Integer({ + description: 'Length of reads in the transaction', + }), + runtime: Type.Integer({ + description: 'Runtime of the transaction', + }), + write_count: Type.Integer({ + description: 'Number of writes in the transaction', + }), + write_length: Type.Integer({ + description: 'Length of writes in the transaction', + }), +}); +export type ExecutionCost = Static; diff --git a/src/api/schemas/entities/v3/mempool-transactions.ts b/src/api/schemas/entities/v3/mempool-transactions.ts index 21fdb4331..4053f1243 100644 --- a/src/api/schemas/entities/v3/mempool-transactions.ts +++ b/src/api/schemas/entities/v3/mempool-transactions.ts @@ -2,7 +2,7 @@ import { Static, Type } from '@sinclair/typebox'; import { BaseMempoolTransactionSummarySchema } from './mempool-transaction-summaries.js'; import { PostConditionSchema } from '../post-conditions.js'; import { Nullable } from '../../util.js'; -import { DecodedClarityValueSchema } from './transactions.js'; +import { DecodedClarityValueSchema } from './common.js'; const BaseMempoolTransactionSchema = Type.Composite([ BaseMempoolTransactionSummarySchema, diff --git a/src/api/schemas/entities/v3/transaction-summaries.ts b/src/api/schemas/entities/v3/transaction-summaries.ts index 86fff63c3..97d7f7b2a 100644 --- a/src/api/schemas/entities/v3/transaction-summaries.ts +++ b/src/api/schemas/entities/v3/transaction-summaries.ts @@ -63,7 +63,7 @@ export const BaseTransactionSummarySchema = Type.Object({ 'Index of the transaction, indicating the order. Starts at `0` and increases with each transaction', }), }), - burn_block: Type.Object({ + bitcoin_block: Type.Object({ height: Type.Integer({ description: 'Height of the anchor burn block.', }), diff --git a/src/api/schemas/entities/v3/transactions.ts b/src/api/schemas/entities/v3/transactions.ts index 1828091c2..912f5e65d 100644 --- a/src/api/schemas/entities/v3/transactions.ts +++ b/src/api/schemas/entities/v3/transactions.ts @@ -2,29 +2,7 @@ import { Static, Type } from '@sinclair/typebox'; import { BaseTransactionSummarySchema, TenureChangeCauseSchema } from './transaction-summaries.js'; import { PostConditionSchema } from '../post-conditions.js'; import { Nullable } from '../../util.js'; - -export const DecodedClarityValueSchema = Type.Object({ - hex: Type.String(), - repr: Type.String(), -}); - -const ExecutionCostSchema = Type.Object({ - read_count: Type.Integer({ - description: 'Number of reads in the transaction', - }), - read_length: Type.Integer({ - description: 'Length of reads in the transaction', - }), - runtime: Type.Integer({ - description: 'Runtime of the transaction', - }), - write_count: Type.Integer({ - description: 'Number of writes in the transaction', - }), - write_length: Type.Integer({ - description: 'Length of writes in the transaction', - }), -}); +import { DecodedClarityValueSchema, ExecutionCostSchema } from './common.js'; const BaseTransactionSchema = Type.Composite([ BaseTransactionSummarySchema, diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts index b08e5d020..abfa7b101 100644 --- a/src/api/serializers/transactions.ts +++ b/src/api/serializers/transactions.ts @@ -120,7 +120,7 @@ export function parseDbTransactionSummary(summary: DbTransactionSummary): Transa time: summary.block_time, tx_index: summary.tx_index, }, - burn_block: { + bitcoin_block: { height: summary.burn_block_height, time: summary.burn_block_time, }, From 583e31590f299fa177c558b7c88afbf875051aa7 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Thu, 7 May 2026 16:17:54 -0600 Subject: [PATCH 12/32] new schemas --- .../schemas/entities/v3/bond-registrations.ts | 29 ++++++++++++++++++ src/api/schemas/entities/v3/bonds.ts | 30 +++++++++++++++++++ src/api/schemas/entities/v3/principals.ts | 24 +++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 src/api/schemas/entities/v3/bond-registrations.ts create mode 100644 src/api/schemas/entities/v3/bonds.ts create mode 100644 src/api/schemas/entities/v3/principals.ts diff --git a/src/api/schemas/entities/v3/bond-registrations.ts b/src/api/schemas/entities/v3/bond-registrations.ts new file mode 100644 index 000000000..dda804390 --- /dev/null +++ b/src/api/schemas/entities/v3/bond-registrations.ts @@ -0,0 +1,29 @@ +import { Static, Type } from '@sinclair/typebox'; + +export const BondRegistrationSchema = Type.Object({ + bond_index: Type.Integer(), + pox_address: Type.Optional( + Type.String({ + description: + 'Where they want to receive BTC rewards. If this is none, rewards are received as sBTC.', + }) + ), + signer_manager: Type.String(), + btc_lockup: Type.Union([ + Type.Object({ + type: Type.Literal('outputs'), + outputs: Type.Object({ + amount: Type.String(), + tx_id: Type.String(), + output_index: Type.Integer(), + }), + unlock_bytes: Type.String(), + }), + Type.Object({ + type: Type.Literal('sbtc'), + amount: Type.String(), + }), + ]), + signer_calldata: Type.Optional(Type.String()), +}); +export type BondRegistration = Static; diff --git a/src/api/schemas/entities/v3/bonds.ts b/src/api/schemas/entities/v3/bonds.ts new file mode 100644 index 000000000..2619d1ecf --- /dev/null +++ b/src/api/schemas/entities/v3/bonds.ts @@ -0,0 +1,30 @@ +import { Static, Type } from '@sinclair/typebox'; + +export const BondSummarySchema = Type.Object({ + tx_id: Type.String(), + index: Type.Integer(), + yield_rate: Type.Integer({ description: 'The target yield rate (APY) in basis points' }), + stx_value_ratio: Type.Integer({ + description: + 'This is a representation of the STXBTC price. The value represents "uSTX per 100 sats"', + }), + minimum_stx_ratio: Type.Integer({ + description: + 'The amount of STX that must be locked relative to BTC, in equal-valued terms (ie in USD terms). This value is represented in basis points.', + }), +}); +export type BondSummary = Static; + +export const BondAllowlistSchema = Type.Object({ + staker: Type.String(), + max_sats: Type.String(), +}); +export type BondAllowlist = Static; + +export const BondSchema = Type.Composite([ + BondSummarySchema, + Type.Object({ + early_unlock_signers: Type.String(), + }), +]); +export type Bond = Static; diff --git a/src/api/schemas/entities/v3/principals.ts b/src/api/schemas/entities/v3/principals.ts new file mode 100644 index 000000000..298641bb8 --- /dev/null +++ b/src/api/schemas/entities/v3/principals.ts @@ -0,0 +1,24 @@ +import { Static, Type } from '@sinclair/typebox'; +import { Nullable } from '../../util.js'; + +export const PrincipalStxBalanceSchema = Type.Object({ + liquid_balance: Type.String(), + locked_balance: Type.String(), + total_balance: Type.String(), + lock: Nullable( + Type.Object({ + tx_id: Type.String(), + height: Type.Integer(), + bitcoin_height: Type.Integer(), + bitcoin_unlock_height: Type.Integer(), + }) + ), + mempool: Type.Optional( + Type.Object({ + inbound: Type.String(), + outbound: Type.String(), + pending_liquid_balance: Type.String(), + }) + ), +}); +export type PrincipalStxBalance = Static; From d9f6372fc09778311a78b580139dae4b041ece79 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Fri, 8 May 2026 11:08:49 -0600 Subject: [PATCH 13/32] principal txs draft --- src/api/init.ts | 6 +- src/api/routes/v3/mempool.ts | 88 ----- src/api/routes/v3/principals.ts | 52 +++ src/api/routes/v3/transactions.ts | 132 -------- .../entities/v3/bitcoin-block-summaries.ts | 15 - src/api/schemas/entities/v3/bitcoin-blocks.ts | 12 - .../schemas/entities/v3/block-summaries.ts | 37 --- src/api/schemas/entities/v3/blocks.ts | 22 -- .../schemas/entities/v3/bond-registrations.ts | 29 -- src/api/schemas/entities/v3/bonds.ts | 30 -- .../v3/mempool-transaction-summaries.ts | 176 ---------- .../entities/v3/mempool-transactions.ts | 118 ------- src/api/schemas/entities/v3/principals.ts | 24 -- .../{entities/v3 => v3/entities}/common.ts | 19 ++ .../v3/entities/principal-transactions.ts | 13 + .../entities}/transaction-summaries.ts | 0 .../v3 => v3/entities}/transactions.ts | 2 +- src/api/schemas/v3/params.ts | 50 +++ src/api/serializers/transactions.ts | 242 +------------- src/datastore/v3/constants.ts | 52 +++ src/datastore/v3/pg-store-v3.ts | 314 ++---------------- src/datastore/v3/types.ts | 14 + 22 files changed, 243 insertions(+), 1204 deletions(-) delete mode 100644 src/api/routes/v3/mempool.ts create mode 100644 src/api/routes/v3/principals.ts delete mode 100644 src/api/routes/v3/transactions.ts delete mode 100644 src/api/schemas/entities/v3/bitcoin-block-summaries.ts delete mode 100644 src/api/schemas/entities/v3/bitcoin-blocks.ts delete mode 100644 src/api/schemas/entities/v3/block-summaries.ts delete mode 100644 src/api/schemas/entities/v3/blocks.ts delete mode 100644 src/api/schemas/entities/v3/bond-registrations.ts delete mode 100644 src/api/schemas/entities/v3/bonds.ts delete mode 100644 src/api/schemas/entities/v3/mempool-transaction-summaries.ts delete mode 100644 src/api/schemas/entities/v3/mempool-transactions.ts delete mode 100644 src/api/schemas/entities/v3/principals.ts rename src/api/schemas/{entities/v3 => v3/entities}/common.ts (50%) create mode 100644 src/api/schemas/v3/entities/principal-transactions.ts rename src/api/schemas/{entities/v3 => v3/entities}/transaction-summaries.ts (100%) rename src/api/schemas/{entities/v3 => v3/entities}/transactions.ts (98%) create mode 100644 src/api/schemas/v3/params.ts create mode 100644 src/datastore/v3/constants.ts diff --git a/src/api/init.ts b/src/api/init.ts index 4ab2ea5cc..b1cb079cc 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -49,8 +49,7 @@ import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import * as promClient from 'prom-client'; import DeprecationPlugin from './deprecation-plugin.js'; import { BlockTenureRoutes } from './routes/v2/block-tenures.js'; -import { TransactionRoutes } from './routes/v3/transactions.js'; -import { MempoolRoutes } from './routes/v3/mempool.js'; +import { PrincipalsRoutes } from './routes/v3/principals.js'; export interface ApiServer { fastifyApp: FastifyInstance; @@ -103,8 +102,7 @@ export const StacksApiRoutes: FastifyPluginAsync< await fastify.register( async fastify => { - await fastify.register(TransactionRoutes); - await fastify.register(MempoolRoutes); + await fastify.register(PrincipalsRoutes); }, { prefix: '/extended/v3' } ); diff --git a/src/api/routes/v3/mempool.ts b/src/api/routes/v3/mempool.ts deleted file mode 100644 index 0c2bad346..000000000 --- a/src/api/routes/v3/mempool.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { handleChainTipCache } from '../../controllers/cache-controller.js'; -import { parseDbMempoolTransactionSummary } from '../../serializers/transactions.js'; -import { NotFoundError } from '../../../errors.js'; -import { FastifyPluginAsync } from 'fastify'; -import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; -import { Server } from 'node:http'; -import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; -import { PaginatedCursorResponse } from '../../schemas/util.js'; -import { LimitParam } from '../../schemas/params.js'; -import { MempoolTransactionSummarySchema } from 'src/api/schemas/entities/v3/mempool-transaction-summaries.js'; - -const MempoolTransactionSummaryCursorParamSchema = Type.String({ - pattern: '^\\d+:(0x)?[a-fA-F0-9]{64}$', - description: 'Cursor for mempool transaction summary pagination', -}); - -export const MempoolRoutes: FastifyPluginAsync< - Record, - Server, - TypeBoxTypeProvider -> = async fastify => { - fastify.get( - '/mempool/transactions', - { - preHandler: handleChainTipCache, - schema: { - operationId: 'get_mempool_transaction_summaries', - summary: 'Get mempool transaction summaries', - description: `Retrieves a list of recently broadcasted transaction summaries`, - tags: ['Mempool'], - querystring: Type.Object({ - limit: LimitParam(ResourceType.Tx), - cursor: Type.Optional(MempoolTransactionSummaryCursorParamSchema), - }), - response: { - 200: PaginatedCursorResponse(MempoolTransactionSummarySchema), - }, - }, - }, - async (req, reply) => { - const query = req.query; - const limit = getPagingQueryLimit(ResourceType.Tx, req.query.limit); - const results = await fastify.db.v3.getMempoolTransactionSummaryList({ ...query, limit }); - if (query.cursor && !results.current_cursor) { - throw new NotFoundError('Cursor not found'); - } - await reply.send({ - limit: results.limit, - offset: results.offset, - total: results.total, - next_cursor: results.next_cursor, - prev_cursor: results.prev_cursor, - cursor: results.current_cursor, - results: results.results.map(r => parseDbMempoolTransactionSummary(r)), - }); - } - ); - - // fastify.get( - // '/mempool/transactions/:tx_id', - // { - // preHandler: handleTransactionCache, - // schema: { - // operationId: 'get_transaction_by_id', - // summary: 'Get transaction', - // description: `Retrieves details for a given transaction ID`, - // tags: ['Transactions'], - // params: Type.Object({ - // tx_id: TransactionIdParamSchema, - // }), - // response: { - // 200: TransactionSchema, - // }, - // }, - // }, - // async (req, reply) => { - // const { tx_id } = req.params; - // const transaction = await fastify.db.v3.getTransaction({ txId: tx_id }); - // if (!transaction) { - // throw new NotFoundError('Transaction not found'); - // } - // const result = parseDbTransaction(transaction); - // await reply.send(result); - // } - // ); - - await Promise.resolve(); -}; diff --git a/src/api/routes/v3/principals.ts b/src/api/routes/v3/principals.ts new file mode 100644 index 000000000..d67a7a348 --- /dev/null +++ b/src/api/routes/v3/principals.ts @@ -0,0 +1,52 @@ +import { handlePrincipalCache } from '../../controllers/cache-controller.js'; +import { FastifyPluginAsync } from 'fastify'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Server } from 'node:http'; +import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; +import { PrincipalSchema } from '../../schemas/v3/entities/common.js'; +import { CursorPaginationQuerystring, CursorPaginatedResponse } from '../../schemas/v3/params.js'; +import { PrincipalTransactionSummarySchema } from '../../schemas/v3/entities/principal-transactions.js'; +import { parsePrincipalTransactionSummary } from '../../serializers/transactions.js'; + +export const PrincipalsRoutes: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + fastify.get( + '/principals/:principal/transactions', + { + preHandler: handlePrincipalCache, + schema: { + operationId: 'get_principal_transactions', + summary: 'Get principal transactions', + description: `Retrieves a paginated list of confirmed transactions sent or received by a STX address or Smart Contract ID`, + tags: ['Transactions'], + params: Type.Object({ principal: PrincipalSchema }), + querystring: CursorPaginationQuerystring(ResourceType.Tx, Type.String()), + response: { + 200: CursorPaginatedResponse(PrincipalTransactionSummarySchema), + }, + }, + }, + async (req, reply) => { + const results = await fastify.db.v3.getPrincipalTransactionSummaryList({ + principal: req.params.principal, + limit: req.query.limit ?? getPagingQueryLimit(ResourceType.Tx), + cursor: req.query.cursor, + }); + await reply.send({ + limit: results.limit, + total: results.total, + cursor: { + next: results.next_cursor, + previous: results.prev_cursor, + current: results.current_cursor, + }, + results: results.results.map(r => parsePrincipalTransactionSummary(r)), + }); + } + ); + + await Promise.resolve(); +}; diff --git a/src/api/routes/v3/transactions.ts b/src/api/routes/v3/transactions.ts deleted file mode 100644 index ea2422990..000000000 --- a/src/api/routes/v3/transactions.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { handleChainTipCache, handleTransactionCache } from '../../controllers/cache-controller.js'; -import { - parseDbTransactionOrMempoolTransaction, - parseDbTransactionSummary, -} from '../../serializers/transactions.js'; -import { NotFoundError } from '../../../errors.js'; -import { FastifyPluginAsync } from 'fastify'; -import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; -import { Server } from 'node:http'; -import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; -import { CursorResponse } from '../../schemas/util.js'; -import { TransactionSummarySchema } from '../../schemas/entities/v3/transaction-summaries.js'; -import { LimitParam, TransactionIdParamSchema } from '../../schemas/params.js'; -import { TransactionSchema } from '../../schemas/entities/v3/transactions.js'; -import { MempoolTransactionSchema } from '../../schemas/entities/v3/mempool-transactions.js'; - -const TransactionSummaryCursorParamSchema = Type.String({ - pattern: '^\\d+:\\d+:\\d+$', - description: 'Cursor for transaction summary pagination', -}); - -export const TransactionRoutes: FastifyPluginAsync< - Record, - Server, - TypeBoxTypeProvider -> = async fastify => { - fastify.get( - '/transactions', - { - preHandler: handleChainTipCache, - schema: { - operationId: 'get_transaction_summaries', - summary: 'Get transaction summaries', - description: `Retrieves a list of recently mined transaction summaries`, - tags: ['Transactions'], - querystring: Type.Object({ - limit: LimitParam(ResourceType.Tx), - cursor: Type.Optional(TransactionSummaryCursorParamSchema), - }), - response: { - 200: CursorResponse(TransactionSummarySchema), - }, - }, - }, - async (req, reply) => { - const query = req.query; - const limit = getPagingQueryLimit(ResourceType.Tx, req.query.limit); - const results = await fastify.db.v3.getTransactionSummaryList({ ...query, limit }); - if (query.cursor && !results.current_cursor) { - throw new NotFoundError('Cursor not found'); - } - await reply.send({ - limit: results.limit, - total: results.total, - cursor: { - next: results.next_cursor, - previous: results.prev_cursor, - current: results.current_cursor, - }, - results: results.results.map(r => parseDbTransactionSummary(r)), - }); - } - ); - - fastify.get( - '/transactions/:tx_id', - { - preHandler: handleTransactionCache, - schema: { - operationId: 'get_transaction_by_id', - summary: 'Get transaction', - description: `Retrieves details for a given transaction ID, including both mined and mempool transactions`, - tags: ['Transactions'], - params: Type.Object({ - tx_id: TransactionIdParamSchema, - }), - response: { - 200: Type.Union([TransactionSchema, MempoolTransactionSchema]), - }, - }, - }, - async (req, reply) => { - const { tx_id } = req.params; - const transaction = await fastify.db.v3.getTransaction({ txId: tx_id }); - if (!transaction) { - throw new NotFoundError('Transaction not found'); - } - const result = parseDbTransactionOrMempoolTransaction(transaction); - await reply.send(result); - } - ); - - fastify.get( - '/transactions/:tx_id/events', - { - preHandler: handleTransactionCache, - schema: { - operationId: 'get_transaction_events', - summary: 'Get transaction events', - description: `Retrieves events for a given transaction ID`, - tags: ['Transactions'], - params: Type.Object({ - tx_id: TransactionIdParamSchema, - }), - querystring: Type.Object({ - limit: LimitParam(ResourceType.Event), - cursor: Type.Optional( - Type.String({ - pattern: '^\\d+$', - description: 'Cursor for transaction event pagination', - }) - ), - }), - }, - }, - async (req, reply) => { - const { tx_id } = req.params; - const query = req.query; - const events = await fastify.db.v3.getTransactionEvents({ - txId: tx_id, - limit: getPagingQueryLimit(ResourceType.Event, query.limit), - cursor: query.cursor, - }); - if (query.cursor && !events.current_cursor) { - throw new NotFoundError('Cursor not found'); - } - await reply.send(events); - } - ); - - await Promise.resolve(); -}; diff --git a/src/api/schemas/entities/v3/bitcoin-block-summaries.ts b/src/api/schemas/entities/v3/bitcoin-block-summaries.ts deleted file mode 100644 index 2bc34e964..000000000 --- a/src/api/schemas/entities/v3/bitcoin-block-summaries.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; - -export const BitcoinBlockSummarySchema = Type.Object({ - height: Type.Integer({ description: 'Height of the bitcoin block' }), - hash: Type.String({ description: 'Hash of the bitcoin block' }), - time: Type.Integer({ - description: 'Unix timestamp (in seconds) indicating when this block was mined.', - }), - blocks_total: Type.Integer({ description: 'Total number of stacks blocks in the bitcoin block' }), - transactions_total: Type.Integer({ - description: - 'Total number of transactions in the Stacks blocks associated with this bitcoin block', - }), -}); -export type BitcoinBlockSummary = Static; diff --git a/src/api/schemas/entities/v3/bitcoin-blocks.ts b/src/api/schemas/entities/v3/bitcoin-blocks.ts deleted file mode 100644 index c306cc6eb..000000000 --- a/src/api/schemas/entities/v3/bitcoin-blocks.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; -import { BitcoinBlockSummarySchema } from './bitcoin-block-summaries.js'; - -export const BitcoinBlockSchema = Type.Composite([ - BitcoinBlockSummarySchema, - Type.Object({ - avg_block_time_seconds: Type.Integer({ - description: 'Average time between blocks in seconds.', - }), - }), -]); -export type BitcoinBlock = Static; diff --git a/src/api/schemas/entities/v3/block-summaries.ts b/src/api/schemas/entities/v3/block-summaries.ts deleted file mode 100644 index b1102a118..000000000 --- a/src/api/schemas/entities/v3/block-summaries.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; - -export const BlockSummarySchema = Type.Object({ - height: Type.Integer({ - description: 'Height of the block', - }), - hash: Type.String({ - description: 'Hash of the block', - }), - index_hash: Type.String({ - description: 'Index block hash of the block', - }), - time: Type.Number({ - description: 'Unix timestamp (in seconds) indicating when this block was mined.', - }), - canonical: Type.Boolean({ - description: 'Set to `true` if block corresponds to the canonical chain tip', - }), - tenure_height: Type.Integer({ - description: 'The tenure height (AKA coinbase height) of this block', - }), - bitcoin_block: Type.Object({ - height: Type.Integer({ - description: 'Height of the bitcoin block', - }), - hash: Type.String({ - description: 'Hash of the bitcoin block', - }), - time: Type.Number({ - description: 'Unix timestamp (in seconds) indicating when this bitcoin block was mined.', - }), - }), - transactions_total: Type.Integer({ - description: 'Number of transactions in the block', - }), -}); -export type BlockSummary = Static; diff --git a/src/api/schemas/entities/v3/blocks.ts b/src/api/schemas/entities/v3/blocks.ts deleted file mode 100644 index 5b68ce1e7..000000000 --- a/src/api/schemas/entities/v3/blocks.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; -import { BlockSummarySchema } from './block-summaries.js'; -import { ExecutionCostSchema } from './common.js'; - -export const BlockSchema = Type.Composite([ - BlockSummarySchema, - Type.Object({ - parent_block: Type.Object({ - hash: Type.String({ - description: 'Hash of the parent block', - }), - index_hash: Type.String({ - description: 'Index block hash of the parent block', - }), - }), - bitcoin_tx_id: Type.String({ - description: 'Bitcoin transaction ID that anchors this block', - }), - execution_cost: ExecutionCostSchema, - }), -]); -export type BlockSummary = Static; diff --git a/src/api/schemas/entities/v3/bond-registrations.ts b/src/api/schemas/entities/v3/bond-registrations.ts deleted file mode 100644 index dda804390..000000000 --- a/src/api/schemas/entities/v3/bond-registrations.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; - -export const BondRegistrationSchema = Type.Object({ - bond_index: Type.Integer(), - pox_address: Type.Optional( - Type.String({ - description: - 'Where they want to receive BTC rewards. If this is none, rewards are received as sBTC.', - }) - ), - signer_manager: Type.String(), - btc_lockup: Type.Union([ - Type.Object({ - type: Type.Literal('outputs'), - outputs: Type.Object({ - amount: Type.String(), - tx_id: Type.String(), - output_index: Type.Integer(), - }), - unlock_bytes: Type.String(), - }), - Type.Object({ - type: Type.Literal('sbtc'), - amount: Type.String(), - }), - ]), - signer_calldata: Type.Optional(Type.String()), -}); -export type BondRegistration = Static; diff --git a/src/api/schemas/entities/v3/bonds.ts b/src/api/schemas/entities/v3/bonds.ts deleted file mode 100644 index 2619d1ecf..000000000 --- a/src/api/schemas/entities/v3/bonds.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; - -export const BondSummarySchema = Type.Object({ - tx_id: Type.String(), - index: Type.Integer(), - yield_rate: Type.Integer({ description: 'The target yield rate (APY) in basis points' }), - stx_value_ratio: Type.Integer({ - description: - 'This is a representation of the STXBTC price. The value represents "uSTX per 100 sats"', - }), - minimum_stx_ratio: Type.Integer({ - description: - 'The amount of STX that must be locked relative to BTC, in equal-valued terms (ie in USD terms). This value is represented in basis points.', - }), -}); -export type BondSummary = Static; - -export const BondAllowlistSchema = Type.Object({ - staker: Type.String(), - max_sats: Type.String(), -}); -export type BondAllowlist = Static; - -export const BondSchema = Type.Composite([ - BondSummarySchema, - Type.Object({ - early_unlock_signers: Type.String(), - }), -]); -export type Bond = Static; diff --git a/src/api/schemas/entities/v3/mempool-transaction-summaries.ts b/src/api/schemas/entities/v3/mempool-transaction-summaries.ts deleted file mode 100644 index 811b0f5d8..000000000 --- a/src/api/schemas/entities/v3/mempool-transaction-summaries.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; -import { TransactionSenderSchema } from './transaction-summaries.js'; -import { Nullable } from '../../util.js'; - -const MempoolTransactionStatusSchema = Type.Union( - [ - Type.Literal('pending'), - Type.Literal('dropped_replace_by_fee'), - Type.Literal('dropped_replace_across_fork'), - Type.Literal('dropped_too_expensive'), - Type.Literal('dropped_stale_garbage_collect'), - Type.Literal('dropped_problematic'), - ], - { description: 'Status of the mempool transaction' } -); -export type MempoolTransactionStatus = Static; - -export const BaseMempoolTransactionSummarySchema = Type.Object({ - tx_id: Type.String({ - description: 'Transaction ID', - }), - sender: TransactionSenderSchema, - sponsor: Nullable(TransactionSenderSchema), - fee_rate: Type.String({ - description: 'Transaction fee as Integer string (64-bit unsigned integer).', - }), - receipt_time: Type.Integer({ - description: - 'A unix timestamp (in seconds) indicating when the transaction broadcast was received by the node.', - }), - receipt_block_height: Type.Integer({ - description: 'Height of the block this transaction was received by the node', - }), - status: MempoolTransactionStatusSchema, -}); -export type BaseMempoolTransactionSummary = Static; - -export const TokenTransferMempoolTransactionSummarySchema = Type.Composite( - [ - BaseMempoolTransactionSummarySchema, - Type.Object({ - type: Type.Literal('token_transfer'), - token_transfer: Type.Object({ - recipient: Type.String(), - amount: Type.String({ - description: 'Transfer amount as Integer string (64-bit unsigned integer)', - }), - memo: Nullable( - Type.String({ - description: - 'Hex encoded arbitrary message, up to 34 bytes length (should try decoding to an ASCII string)', - }) - ), - }), - }), - ], - { - title: 'TokenTransferMempoolTransactionSummary', - description: 'Token transfer mempool transaction summary', - } -); -export type TokenTransferMempoolTransactionSummary = Static< - typeof TokenTransferMempoolTransactionSummarySchema ->; - -export const SmartContractMempoolTransactionSummarySchema = Type.Composite( - [ - BaseMempoolTransactionSummarySchema, - Type.Object({ - type: Type.Literal('smart_contract'), - smart_contract: Type.Object({ - clarity_version: Nullable( - Type.Number({ - description: - 'The Clarity version of the contract, only specified for versioned contract transactions, otherwise null', - }) - ), - contract_id: Type.String({ - description: 'Contract identifier formatted as `.`', - }), - }), - }), - ], - { - title: 'SmartContractMempoolTransactionSummary', - description: 'Smart contract mempool transaction summary', - } -); -export type SmartContractMempoolTransactionSummary = Static< - typeof SmartContractMempoolTransactionSummarySchema ->; - -export const ContractCallMempoolTransactionSummarySchema = Type.Composite( - [ - BaseMempoolTransactionSummarySchema, - Type.Object({ - type: Type.Literal('contract_call'), - contract_call: Type.Object({ - contract_id: Type.String({ - description: 'Contract identifier formatted as `.`', - }), - function_name: Type.String({ - description: 'Name of the Clarity function to be invoked', - }), - }), - }), - ], - { - title: 'ContractCallMempoolTransactionSummary', - description: 'Contract call mempool transaction summary', - } -); -export type ContractCallMempoolTransactionSummary = Static< - typeof ContractCallMempoolTransactionSummarySchema ->; - -// Included for completeness, but not used in the mempool. -export const PoisonMicroblockMempoolTransactionSummarySchema = Type.Composite( - [ - BaseMempoolTransactionSummarySchema, - Type.Object({ - type: Type.Literal('poison_microblock'), - }), - ], - { - title: 'PoisonMicroblockMempoolTransactionSummary', - description: 'Poison microblock mempool transaction summary', - } -); -export type PoisonMicroblockMempoolTransactionSummary = Static< - typeof PoisonMicroblockMempoolTransactionSummarySchema ->; - -// Included for completeness, but not used in the mempool. -export const CoinbaseMempoolTransactionSummarySchema = Type.Composite( - [ - BaseMempoolTransactionSummarySchema, - Type.Object({ - type: Type.Literal('coinbase'), - }), - ], - { - title: 'CoinbaseMempoolTransactionSummary', - description: 'Coinbase mempool transaction summary', - } -); -export type CoinbaseMempoolTransactionSummary = Static< - typeof CoinbaseMempoolTransactionSummarySchema ->; - -// Included for completeness, but not used in the mempool. -export const TenureChangeMempoolTransactionSummarySchema = Type.Composite( - [ - BaseMempoolTransactionSummarySchema, - Type.Object({ - type: Type.Literal('tenure_change'), - }), - ], - { - title: 'TenureChangeMempoolTransactionSummary', - description: 'Tenure change mempool transaction summary', - } -); -export type TenureChangeMempoolTransactionSummary = Static< - typeof TenureChangeMempoolTransactionSummarySchema ->; - -export const MempoolTransactionSummarySchema = Type.Union([ - TokenTransferMempoolTransactionSummarySchema, - SmartContractMempoolTransactionSummarySchema, - ContractCallMempoolTransactionSummarySchema, - PoisonMicroblockMempoolTransactionSummarySchema, - CoinbaseMempoolTransactionSummarySchema, - TenureChangeMempoolTransactionSummarySchema, -]); -export type MempoolTransactionSummary = Static; diff --git a/src/api/schemas/entities/v3/mempool-transactions.ts b/src/api/schemas/entities/v3/mempool-transactions.ts deleted file mode 100644 index 4053f1243..000000000 --- a/src/api/schemas/entities/v3/mempool-transactions.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; -import { BaseMempoolTransactionSummarySchema } from './mempool-transaction-summaries.js'; -import { PostConditionSchema } from '../post-conditions.js'; -import { Nullable } from '../../util.js'; -import { DecodedClarityValueSchema } from './common.js'; - -const BaseMempoolTransactionSchema = Type.Composite([ - BaseMempoolTransactionSummarySchema, - Type.Object({ - post_conditions: Type.Array(PostConditionSchema), - replaced_by_tx_id: Nullable( - Type.String({ - description: 'ID of another transaction which replaced this one', - }) - ), - }), -]); -export type BaseMempoolTransaction = Static; - -const TokenTransferMempoolTransactionSchema = Type.Composite([ - BaseMempoolTransactionSchema, - Type.Object({ - type: Type.Literal('token_transfer'), - token_transfer: Type.Object({ - recipient: Type.String({ - description: 'Recipient of the token transfer', - }), - amount: Type.String({ - description: 'Amount of the token transfer', - }), - memo: Nullable( - Type.String({ - description: 'Memo of the token transfer', - }) - ), - }), - }), -]); -export type TokenTransferMempoolTransaction = Static; - -const SmartContractMempoolTransactionSchema = Type.Composite([ - BaseMempoolTransactionSchema, - Type.Object({ - type: Type.Literal('smart_contract'), - smart_contract: Type.Object({ - contract_id: Type.String({ - description: 'Contract ID of the smart contract', - }), - clarity_version: Nullable( - Type.Number({ - description: 'Clarity version of the smart contract', - }) - ), - source_code: Type.String({ - description: 'Source code of the smart contract', - }), - }), - }), -]); -export type SmartContractMempoolTransaction = Static; - -const ContractCallMempoolTransactionSchema = Type.Composite([ - BaseMempoolTransactionSchema, - Type.Object({ - type: Type.Literal('contract_call'), - contract_call: Type.Object({ - contract_id: Type.String({ - description: 'Contract ID of the contract call', - }), - function_name: Type.String({ - description: 'Function name of the contract call', - }), - function_args: Type.Array(DecodedClarityValueSchema, { - description: 'List of arguments used to invoke the function', - }), - }), - }), -]); -export type ContractCallMempoolTransaction = Static; - -// Included for completeness, but not used in the mempool. -const PoisonMicroblockMempoolTransactionSchema = Type.Composite([ - BaseMempoolTransactionSchema, - Type.Object({ - type: Type.Literal('poison_microblock'), - }), -]); -export type PoisonMicroblockMempoolTransaction = Static< - typeof PoisonMicroblockMempoolTransactionSchema ->; - -// Included for completeness, but not used in the mempool. -const TenureChangeMempoolTransactionSchema = Type.Composite([ - BaseMempoolTransactionSchema, - Type.Object({ - type: Type.Literal('tenure_change'), - }), -]); -export type TenureChangeMempoolTransaction = Static; - -// Included for completeness, but not used in the mempool. -const CoinbaseMempoolTransactionSchema = Type.Composite([ - BaseMempoolTransactionSchema, - Type.Object({ - type: Type.Literal('coinbase'), - }), -]); -export type CoinbaseMempoolTransaction = Static; - -export const MempoolTransactionSchema = Type.Union([ - TokenTransferMempoolTransactionSchema, - SmartContractMempoolTransactionSchema, - ContractCallMempoolTransactionSchema, - PoisonMicroblockMempoolTransactionSchema, - TenureChangeMempoolTransactionSchema, - CoinbaseMempoolTransactionSchema, -]); -export type MempoolTransaction = Static; diff --git a/src/api/schemas/entities/v3/principals.ts b/src/api/schemas/entities/v3/principals.ts deleted file mode 100644 index 298641bb8..000000000 --- a/src/api/schemas/entities/v3/principals.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; -import { Nullable } from '../../util.js'; - -export const PrincipalStxBalanceSchema = Type.Object({ - liquid_balance: Type.String(), - locked_balance: Type.String(), - total_balance: Type.String(), - lock: Nullable( - Type.Object({ - tx_id: Type.String(), - height: Type.Integer(), - bitcoin_height: Type.Integer(), - bitcoin_unlock_height: Type.Integer(), - }) - ), - mempool: Type.Optional( - Type.Object({ - inbound: Type.String(), - outbound: Type.String(), - pending_liquid_balance: Type.String(), - }) - ), -}); -export type PrincipalStxBalance = Static; diff --git a/src/api/schemas/entities/v3/common.ts b/src/api/schemas/v3/entities/common.ts similarity index 50% rename from src/api/schemas/entities/v3/common.ts rename to src/api/schemas/v3/entities/common.ts index 839dc2d08..885c5ab22 100644 --- a/src/api/schemas/entities/v3/common.ts +++ b/src/api/schemas/v3/entities/common.ts @@ -1,5 +1,24 @@ import { Static, Type } from '@sinclair/typebox'; +export const AddressParamSchema = Type.String({ + pattern: '^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}', + title: 'Stacks Address', + description: 'Stacks Address', + examples: ['SP318Q55DEKHRXJK696033DQN5C54D9K2EE6DHRWP'], +}); +export type Address = Static; + +export const SmartContractIdParamSchema = Type.String({ + pattern: '^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39}$', + title: 'Smart Contract ID', + description: 'Smart Contract ID', + examples: ['SP000000000000000000002Q6VF78.pox-3'], +}); +export type SmartContractId = Static; + +export const PrincipalSchema = Type.Union([AddressParamSchema, SmartContractIdParamSchema]); +export type Principal = Static; + export const DecodedClarityValueSchema = Type.Object({ hex: Type.String(), repr: Type.String(), diff --git a/src/api/schemas/v3/entities/principal-transactions.ts b/src/api/schemas/v3/entities/principal-transactions.ts new file mode 100644 index 000000000..09b7b0f4f --- /dev/null +++ b/src/api/schemas/v3/entities/principal-transactions.ts @@ -0,0 +1,13 @@ +import { Static, Type } from '@sinclair/typebox'; +import { TransactionSummarySchema } from './transaction-summaries.js'; + +export const PrincipalTransactionSummarySchema = Type.Object({ + transaction: TransactionSummarySchema, + balance_changes: Type.Object({ + stx: Type.Object({ + sent: Type.String({ description: 'STX sent' }), + received: Type.String({ description: 'STX received' }), + }), + }), +}); +export type PrincipalTransactionSummary = Static; diff --git a/src/api/schemas/entities/v3/transaction-summaries.ts b/src/api/schemas/v3/entities/transaction-summaries.ts similarity index 100% rename from src/api/schemas/entities/v3/transaction-summaries.ts rename to src/api/schemas/v3/entities/transaction-summaries.ts diff --git a/src/api/schemas/entities/v3/transactions.ts b/src/api/schemas/v3/entities/transactions.ts similarity index 98% rename from src/api/schemas/entities/v3/transactions.ts rename to src/api/schemas/v3/entities/transactions.ts index 912f5e65d..7b054e41e 100644 --- a/src/api/schemas/entities/v3/transactions.ts +++ b/src/api/schemas/v3/entities/transactions.ts @@ -1,6 +1,6 @@ import { Static, Type } from '@sinclair/typebox'; import { BaseTransactionSummarySchema, TenureChangeCauseSchema } from './transaction-summaries.js'; -import { PostConditionSchema } from '../post-conditions.js'; +import { PostConditionSchema } from '../../entities/post-conditions.js'; import { Nullable } from '../../util.js'; import { DecodedClarityValueSchema, ExecutionCostSchema } from './common.js'; diff --git a/src/api/schemas/v3/params.ts b/src/api/schemas/v3/params.ts new file mode 100644 index 000000000..70ed591b4 --- /dev/null +++ b/src/api/schemas/v3/params.ts @@ -0,0 +1,50 @@ +import { ObjectOptions, TSchema, Type } from '@sinclair/typebox'; +import { pagingQueryLimits, ResourceType } from '../../pagination.js'; +import { Nullable } from '../util.js'; + +/** + * Cursor pagination querystring + * @param resource - Resource type to determine the default limit and max limit + * @param type - Type of the cursor to paginate by + * @returns Cursor pagination querystring + */ +export const CursorPaginationQuerystring = ( + resource: ResourceType, + type: T, + title?: string, + description?: string, + limitOverride?: number +) => + Type.Object({ + limit: Type.Optional( + Type.Integer({ + minimum: 0, + default: pagingQueryLimits[resource].defaultLimit, + maximum: limitOverride ?? pagingQueryLimits[resource].maxLimit, + title: title ?? 'Limit', + description: description ?? 'Results per page', + }) + ), + cursor: Type.Optional(type), + }); + +/** + * Cursor pagination response + * @param type - Type of the response object + * @param options - Options for the response + * @returns Cursor pagination response schema + */ +export const CursorPaginatedResponse = (type: T, options?: ObjectOptions) => + Type.Object( + { + total: Type.Integer({ examples: [1] }), + limit: Type.Integer({ examples: [20] }), + cursor: Type.Object({ + next: Nullable(Type.String({ description: 'Next page cursor' })), + previous: Nullable(Type.String({ description: 'Previous page cursor' })), + current: Nullable(Type.String({ description: 'Current page cursor' })), + }), + results: Type.Array(type), + }, + options + ); diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts index abfa7b101..de90fe3d9 100644 --- a/src/api/serializers/transactions.ts +++ b/src/api/serializers/transactions.ts @@ -8,10 +8,9 @@ import { TokenTransferTransactionSummary, TransactionSummary, TransactionStatus, -} from '../schemas/entities/v3/transaction-summaries.js'; +} from '../schemas/v3/entities/transaction-summaries.js'; import { - DbMempoolTransaction, - DbMempoolTransactionSummary, + DbPrincipalTransactionSummary, DbTransaction, DbTransactionSummary, } from '../../datastore/v3/types.js'; @@ -26,30 +25,10 @@ import { TenureChangeTransaction, TokenTransferTransaction, Transaction, -} from '../schemas/entities/v3/transactions.js'; +} from '../schemas/v3/entities/transactions.js'; import codec from '@stacks/codec'; import { serializePostCondition } from './post-conditions.js'; -import { - BaseMempoolTransactionSummary, - CoinbaseMempoolTransactionSummary, - ContractCallMempoolTransactionSummary, - MempoolTransactionStatus, - MempoolTransactionSummary, - PoisonMicroblockMempoolTransactionSummary, - SmartContractMempoolTransactionSummary, - TenureChangeMempoolTransactionSummary, - TokenTransferMempoolTransactionSummary, -} from '../schemas/entities/v3/mempool-transaction-summaries.js'; -import { - BaseMempoolTransaction, - CoinbaseMempoolTransaction, - ContractCallMempoolTransaction, - MempoolTransaction, - PoisonMicroblockMempoolTransaction, - SmartContractMempoolTransaction, - TenureChangeMempoolTransaction, - TokenTransferMempoolTransaction, -} from '../schemas/entities/v3/mempool-transactions.js'; +import { PrincipalTransactionSummary } from '../schemas/v3/entities/principal-transactions.js'; /** * Parses a database transaction summary status into a transaction summary status. @@ -69,30 +48,6 @@ function parseDbTransactionSummaryStatus(status: DbTxStatus): TransactionStatus } } -/** - * Parses a database mempool transaction summary status into a mempool transaction summary status. - * @param status - The database mempool transaction status. - * @returns The parsed mempool transaction status. - */ -function parseDbMempoolTransactionSummaryStatus(status: DbTxStatus): MempoolTransactionStatus { - switch (status) { - case DbTxStatus.Pending: - return 'pending'; - case DbTxStatus.DroppedReplaceByFee: - return 'dropped_replace_by_fee'; - case DbTxStatus.DroppedReplaceAcrossFork: - return 'dropped_replace_across_fork'; - case DbTxStatus.DroppedTooExpensive: - return 'dropped_too_expensive'; - case DbTxStatus.DroppedStaleGarbageCollect: - return 'dropped_stale_garbage_collect'; - case DbTxStatus.DroppedProblematic: - return 'dropped_problematic'; - default: - throw new Error(`Unexpected DbTxStatus: ${status}`); - } -} - /** * Parses a database transaction summary into a transaction summary. * @param summary - The database transaction summary to parse. @@ -302,183 +257,20 @@ export function parseDbTransaction(transaction: DbTransaction): Transaction { } /** - * Parses a database mempool transaction summary into a mempool transaction summary. - * @param summary - The database mempool transaction summary to parse. - * @returns The parsed mempool transaction summary. + * Parses a database principal transaction summary into a principal transaction summary. + * @param summary - The database principal transaction summary to parse. + * @returns The parsed principal transaction summary. */ -export function parseDbMempoolTransactionSummary( - summary: DbMempoolTransactionSummary -): MempoolTransactionSummary { - const result: BaseMempoolTransactionSummary = { - tx_id: summary.tx_id, - sender: { - address: summary.sender_address, - nonce: summary.nonce, +export function parsePrincipalTransactionSummary( + summary: DbPrincipalTransactionSummary +): PrincipalTransactionSummary { + return { + transaction: parseDbTransactionSummary(summary), + balance_changes: { + stx: { + sent: summary.stx_sent, + received: summary.stx_received, + }, }, - sponsor: - summary.sponsor_address !== null && summary.sponsor_nonce !== null - ? { - address: summary.sponsor_address, - nonce: summary.sponsor_nonce, - } - : null, - fee_rate: summary.fee_rate, - receipt_time: summary.receipt_time, - receipt_block_height: summary.receipt_block_height, - status: parseDbMempoolTransactionSummaryStatus(summary.status), - }; - switch (summary.type_id) { - case DbTxTypeId.TokenTransfer: { - const tokenTransfer: TokenTransferMempoolTransactionSummary = { - ...result, - type: 'token_transfer', - token_transfer: { - recipient: summary.token_transfer_recipient_address!, - amount: summary.token_transfer_amount!, - memo: summary.token_transfer_memo, - }, - }; - return tokenTransfer; - } - case DbTxTypeId.SmartContract: { - const smartContract: SmartContractMempoolTransactionSummary = { - ...result, - type: 'smart_contract', - smart_contract: { - clarity_version: summary.smart_contract_clarity_version, - contract_id: summary.smart_contract_contract_id!, - }, - }; - return smartContract; - } - case DbTxTypeId.ContractCall: { - const contractCall: ContractCallMempoolTransactionSummary = { - ...result, - type: 'contract_call', - contract_call: { - contract_id: summary.contract_call_contract_id!, - function_name: summary.contract_call_function_name!, - }, - }; - return contractCall; - } - case DbTxTypeId.PoisonMicroblock: { - const poisonMicroblock: PoisonMicroblockMempoolTransactionSummary = { - ...result, - type: 'poison_microblock', - }; - return poisonMicroblock; - } - case DbTxTypeId.Coinbase: { - const coinbase: CoinbaseMempoolTransactionSummary = { - ...result, - type: 'coinbase', - }; - return coinbase; - } - case DbTxTypeId.TenureChange: { - const tenureChange: TenureChangeMempoolTransactionSummary = { - ...result, - type: 'tenure_change', - }; - return tenureChange; - } - default: - throw new Error(`Unexpected DbTxTypeId: ${summary.type_id}`); - } -} - -/** - * Parses a database mempool transaction into a mempool transaction. - * @param transaction - The database mempool transaction to parse. - * @returns The parsed mempool transaction. - */ -export function parseDbMempoolTransaction(transaction: DbMempoolTransaction): MempoolTransaction { - const summary = parseDbMempoolTransactionSummary(transaction); - const decodedPostConditions = codec.decodePostConditions(transaction.post_conditions); - const result: BaseMempoolTransaction = { - ...summary, - post_conditions: decodedPostConditions.post_conditions.map(pc => serializePostCondition(pc)), - replaced_by_tx_id: transaction.replaced_by_tx_id, }; - switch (transaction.type_id) { - case DbTxTypeId.TokenTransfer: { - const tokenTransfer: TokenTransferMempoolTransaction = { - ...result, - type: 'token_transfer', - token_transfer: { - recipient: transaction.token_transfer_recipient_address!, - amount: transaction.token_transfer_amount!, - memo: transaction.token_transfer_memo, - }, - }; - return tokenTransfer; - } - case DbTxTypeId.SmartContract: { - const smartContract: SmartContractMempoolTransaction = { - ...result, - type: 'smart_contract', - smart_contract: { - clarity_version: transaction.smart_contract_clarity_version, - contract_id: transaction.smart_contract_contract_id!, - source_code: transaction.smart_contract_source_code!, - }, - }; - return smartContract; - } - case DbTxTypeId.ContractCall: { - const contractCall: ContractCallMempoolTransaction = { - ...result, - type: 'contract_call', - contract_call: { - contract_id: transaction.contract_call_contract_id!, - function_name: transaction.contract_call_function_name!, - function_args: codec - .decodeClarityValueList(transaction.contract_call_function_args!) - .map(c => ({ - hex: c.hex, - repr: c.repr, - })), - }, - }; - return contractCall; - } - case DbTxTypeId.PoisonMicroblock: { - const poisonMicroblock: PoisonMicroblockMempoolTransaction = { - ...result, - type: 'poison_microblock', - }; - return poisonMicroblock; - } - case DbTxTypeId.Coinbase: { - const coinbase: CoinbaseMempoolTransaction = { - ...result, - type: 'coinbase', - }; - return coinbase; - } - case DbTxTypeId.TenureChange: { - const tenureChange: TenureChangeMempoolTransaction = { - ...result, - type: 'tenure_change', - }; - return tenureChange; - } - default: - throw new Error(`Unexpected DbTxTypeId: ${transaction.type_id}`); - } -} - -/** - * Parses a database transaction or mempool transaction into a transaction or mempool transaction. - * @param transaction - The database transaction or mempool transaction to parse. - * @returns The parsed transaction or mempool transaction. - */ -export function parseDbTransactionOrMempoolTransaction( - transaction: DbTransaction | DbMempoolTransaction -): Transaction | MempoolTransaction { - if ('index_block_hash' in transaction) { - return parseDbTransaction(transaction); - } - return parseDbMempoolTransaction(transaction); } diff --git a/src/datastore/v3/constants.ts b/src/datastore/v3/constants.ts new file mode 100644 index 000000000..9bb221a27 --- /dev/null +++ b/src/datastore/v3/constants.ts @@ -0,0 +1,52 @@ +export const TX_SUMMARY_COLUMNS = [ + 'tx_id', + 'sender_address', + 'sponsor_address', + 'sponsor_nonce', + 'nonce', + 'fee_rate', + 'block_height', + 'block_hash', + 'index_block_hash', + 'block_time', + 'tx_index', + 'microblock_sequence', + 'burn_block_height', + 'burn_block_time', + 'canonical', + 'status', + 'type_id', + 'token_transfer_recipient_address', + 'token_transfer_amount', + 'token_transfer_memo', + 'smart_contract_clarity_version', + 'smart_contract_contract_id', + 'contract_call_contract_id', + 'contract_call_function_name', + 'coinbase_alt_recipient', + 'tenure_change_cause', +]; + +export const TX_COLUMNS = [ + ...TX_SUMMARY_COLUMNS, + 'parent_block_hash', + 'parent_index_block_hash', + 'post_conditions', + 'event_count', + 'execution_cost_read_count', + 'execution_cost_read_length', + 'execution_cost_runtime', + 'execution_cost_write_count', + 'execution_cost_write_length', + 'vm_error', + 'smart_contract_source_code', + 'contract_call_function_args', + 'coinbase_payload', + 'coinbase_vrf_proof', + 'tenure_change_tenure_consensus_hash', + 'tenure_change_prev_tenure_consensus_hash', + 'tenure_change_burn_view_consensus_hash', + 'tenure_change_previous_tenure_end', + 'tenure_change_previous_tenure_blocks', + 'tenure_change_pubkey_hash', +]; diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 5a0ef1380..2a732be36 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -1,122 +1,14 @@ import { BasePgStoreModule } from '@stacks/api-toolkit'; -import { DbCursorPaginatedResult, DbEventTypeId } from '../common.js'; -import { - DbMempoolTransaction, - DbMempoolTransactionSummary, - DbTransaction, - DbTransactionEvent, - DbTransactionSummary, -} from './types.js'; -import { InvalidRequestError, InvalidRequestErrorType } from '../../errors.js'; -import { TransactionLimitParamSchema } from 'src/api/routes/v2/schemas.js'; - -type TransactionSummaryQueryResult = DbTransactionSummary & { - microblock_sequence: number; - total: number; -}; - -const TX_SUMMARY_COLUMNS = [ - 'tx_id', - 'sender_address', - 'sponsor_address', - 'sponsor_nonce', - 'nonce', - 'fee_rate', - 'block_height', - 'block_hash', - 'index_block_hash', - 'block_time', - 'tx_index', - 'microblock_sequence', - 'burn_block_height', - 'burn_block_time', - 'canonical', - 'status', - 'type_id', - 'token_transfer_recipient_address', - 'token_transfer_amount', - 'token_transfer_memo', - 'smart_contract_clarity_version', - 'smart_contract_contract_id', - 'contract_call_contract_id', - 'contract_call_function_name', - 'coinbase_alt_recipient', - 'tenure_change_cause', -]; - -const TX_COLUMNS = [ - ...TX_SUMMARY_COLUMNS, - 'parent_block_hash', - 'parent_index_block_hash', - 'post_conditions', - 'event_count', - 'execution_cost_read_count', - 'execution_cost_read_length', - 'execution_cost_runtime', - 'execution_cost_write_count', - 'execution_cost_write_length', - 'vm_error', - 'smart_contract_source_code', - 'contract_call_function_args', - 'coinbase_payload', - 'coinbase_vrf_proof', - 'tenure_change_tenure_consensus_hash', - 'tenure_change_prev_tenure_consensus_hash', - 'tenure_change_burn_view_consensus_hash', - 'tenure_change_previous_tenure_end', - 'tenure_change_previous_tenure_blocks', - 'tenure_change_pubkey_hash', -]; - -const MEMPOOL_TX_SUMMARY_COLUMNS = [ - 'tx_id', - 'type_id', - 'status', - 'sender_address', - 'nonce', - 'sponsor_address', - 'sponsor_nonce', - 'fee_rate', - 'receipt_time', - 'receipt_block_height', - 'token_transfer_recipient_address', - 'token_transfer_amount', - 'token_transfer_memo', - 'smart_contract_clarity_version', - 'smart_contract_contract_id', - 'contract_call_contract_id', - 'contract_call_function_name', - 'coinbase_alt_recipient', - 'tenure_change_cause', -]; - -const MEMPOOL_TX_COLUMNS = [ - ...MEMPOOL_TX_SUMMARY_COLUMNS, - 'replaced_by_tx_id', - 'post_conditions', - 'smart_contract_source_code', - 'contract_call_function_args', - 'coinbase_payload', - 'coinbase_vrf_proof', - 'tenure_change_tenure_consensus_hash', - 'tenure_change_prev_tenure_consensus_hash', - 'tenure_change_burn_view_consensus_hash', - 'tenure_change_previous_tenure_end', - 'tenure_change_previous_tenure_blocks', - 'tenure_change_pubkey_hash', -]; - -function encodeMempoolTxSummaryCursor( - tx: Pick -) { - return `${tx.receipt_time}:${tx.tx_id}`; -} +import { DbCursorPaginatedResult, DbPrincipalTransactionSummary } from './types.js'; +import { TX_SUMMARY_COLUMNS } from './constants.js'; +import { prefixedCols } from '../helpers.js'; export class PgStoreV3 extends BasePgStoreModule { - async getTransactionSummaryList(args: { + async getPrincipalTransactionSummaryList(args: { + principal: string; limit: number; cursor?: string; - }): Promise> { + }): Promise> { return await this.sqlTransaction(async sql => { let cursorFilter = sql``; if (args.cursor) { @@ -126,23 +18,27 @@ export class PgStoreV3 extends BasePgStoreModule { const microblockSequence = parseInt(microblockSequenceStr, 10); const txIndex = parseInt(txIndexStr, 10); cursorFilter = sql` - AND (block_height, microblock_sequence, tx_index) + AND (p.block_height, p.microblock_sequence, p.tx_index) <= (${blockHeight}, ${microblockSequence}, ${txIndex}) `; } - - const resultQuery = await sql` - WITH total AS ( - SELECT tx_count FROM chain_tip - ) + const resultQuery = await sql< + (DbPrincipalTransactionSummary & { microblock_sequence: number; total: number })[] + >` SELECT - ${sql(TX_SUMMARY_COLUMNS)}, - (SELECT tx_count FROM total)::int AS total - FROM txs - WHERE canonical = true - AND microblock_canonical = true + ${sql(prefixedCols(TX_SUMMARY_COLUMNS, 't'))}, + t.microblock_sequence, + p.stx_sent, + p.stx_received, + ( + SELECT COALESCE(count, 0)::int FROM principal_tx_counts WHERE principal = ${args.principal} + ) AS total + FROM principal_txs AS p + INNER JOIN txs AS t USING (tx_id, index_block_hash, microblock_hash) + WHERE p.canonical = true + AND p.microblock_canonical = true ${cursorFilter} - ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC + ORDER BY p.block_height DESC, p.microblock_sequence DESC, p.tx_index DESC LIMIT ${args.limit + 1} `; @@ -167,7 +63,7 @@ export class PgStoreV3 extends BasePgStoreModule { { block_height: number; microblock_sequence: number; tx_index: number }[] >` SELECT block_height, microblock_sequence, tx_index - FROM txs + FROM principal_txs WHERE canonical = true AND microblock_canonical = true AND (block_height, microblock_sequence, tx_index) @@ -188,7 +84,6 @@ export class PgStoreV3 extends BasePgStoreModule { return { limit: args.limit, - offset: 0, next_cursor: nextCursor, prev_cursor: prevCursor, current_cursor: currentCursor, @@ -197,167 +92,4 @@ export class PgStoreV3 extends BasePgStoreModule { }; }); } - - async getMempoolTransactionSummaryList(args: { - limit: number; - cursor?: string; - }): Promise> { - return await this.sqlTransaction(async sql => { - let cursorFilter = sql``; - if (args.cursor) { - const [receiptTime, txId] = args.cursor.split(':'); - cursorFilter = sql` - AND (receipt_time, tx_id) <= (${parseInt(receiptTime, 10)}, ${txId}) - `; - } - - const resultQuery = await sql<(DbMempoolTransactionSummary & { total: number })[]>` - SELECT - ${sql(MEMPOOL_TX_SUMMARY_COLUMNS)}, - (SELECT mempool_tx_count FROM chain_tip) AS total - FROM mempool_txs - WHERE pruned = false - ${cursorFilter} - ORDER BY receipt_time DESC, tx_id DESC - LIMIT ${args.limit + 1} - `; - - const hasNextPage = resultQuery.count > args.limit; - const results = hasNextPage ? resultQuery.slice(0, args.limit) : resultQuery; - const total = resultQuery.count > 0 ? resultQuery[0].total : 0; - const firstResult = results[0]; - const extraResult = hasNextPage ? resultQuery[args.limit] : null; - - let prevCursor: string | null = null; - if (firstResult) { - const prevPageQuery = await sql< - Pick[] - >` - SELECT receipt_time, tx_id - FROM mempool_txs - WHERE pruned = false - AND (receipt_time, tx_id) > (${firstResult.receipt_time}, ${firstResult.tx_id}) - ORDER BY receipt_time ASC, tx_id ASC - OFFSET ${args.limit - 1} - LIMIT 1 - `; - prevCursor = - prevPageQuery.length > 0 ? encodeMempoolTxSummaryCursor(prevPageQuery[0]) : null; - } - - return { - limit: args.limit, - offset: 0, - next_cursor: extraResult ? encodeMempoolTxSummaryCursor(extraResult) : null, - prev_cursor: prevCursor, - current_cursor: firstResult ? encodeMempoolTxSummaryCursor(firstResult) : null, - total, - results, - }; - }); - } - - async getTransaction(args: { - txId: string; - }): Promise { - return await this.sqlTransaction(async sql => { - const result = await this.sql` - SELECT ${this.sql(TX_COLUMNS)} - FROM txs - WHERE tx_id = ${args.txId} AND canonical = true AND microblock_canonical = true - `; - if (result.count > 0) { - return result[0]; - } - const mempoolResult = await sql` - SELECT ${this.sql(MEMPOOL_TX_COLUMNS)} - FROM mempool_txs - WHERE tx_id = ${args.txId} AND pruned = false - `; - if (mempoolResult.count > 0) { - return mempoolResult[0]; - } - return null; - }); - } - - async getTransactionEvents(args: { - txId: string; - limit: number; - cursor?: string; - }): Promise> { - return await this.sqlTransaction(async sql => { - const limit = args.limit ?? TransactionLimitParamSchema.default; - const txCheck = await sql<{ event_count: number }[]>` - SELECT event_count - FROM txs - WHERE tx_id = ${args.txId} AND canonical = true AND microblock_canonical = true - LIMIT 1 - `; - if (txCheck.count === 0) - throw new InvalidRequestError( - `Transaction not found`, - InvalidRequestErrorType.invalid_param - ); - - let cursorFilter = sql``; - if (args.cursor) { - cursorFilter = sql`AND event_index >= ${parseInt(args.cursor, 10)}`; - } - - const eventCond = sql` - canonical = true AND microblock_canonical = true AND tx_id = ${args.txId} ${cursorFilter} - `; - const resultQuery = await sql` - WITH events AS ( - ( - SELECT - sender, recipient, event_index, amount, NULL as asset_identifier, - NULL::bytea as value, ${DbEventTypeId.StxAsset}::int as event_type_id, - asset_event_type_id - FROM stx_events - WHERE ${eventCond} - ) - UNION - ( - SELECT - sender, recipient, event_index, amount, asset_identifier, NULL::bytea as value, - ${DbEventTypeId.FungibleTokenAsset}::int as event_type_id, asset_event_type_id - FROM ft_events - WHERE ${eventCond} - ) - UNION - ( - SELECT - sender, recipient, event_index, 0 as amount, asset_identifier, value, - ${DbEventTypeId.NonFungibleTokenAsset}::int as event_type_id, asset_event_type_id - FROM nft_events - WHERE ${eventCond} - ) - ) - SELECT * - FROM events - ORDER BY event_index ASC - LIMIT ${limit + 1} - `; - const hasNextPage = resultQuery.count > limit; - const results = hasNextPage ? resultQuery.slice(0, limit) : resultQuery; - const firstResult = results[0]; - const extraResult = hasNextPage ? resultQuery[limit] : null; - const prevCursor = - firstResult && firstResult.event_index > 0 - ? Math.max(firstResult.event_index - limit, 0).toString() - : null; - - return { - total: txCheck[0].event_count, - limit, - offset: 0, - next_cursor: extraResult ? extraResult.event_index.toString() : null, - prev_cursor: prevCursor, - current_cursor: firstResult ? firstResult.event_index.toString() : null, - results, - }; - }); - } } diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index dda73cda0..041308ed0 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -1,5 +1,14 @@ import { DbAssetEventTypeId, DbEventTypeId, DbTxStatus, DbTxTypeId } from '../common.js'; +export type DbCursorPaginatedResult = { + limit: number; + next_cursor: string | null; + prev_cursor: string | null; + current_cursor: string | null; + total: number; + results: T[]; +}; + export interface DbTransactionSummary { tx_id: string; sender_address: string; @@ -29,6 +38,11 @@ export interface DbTransactionSummary { tenure_change_cause: number | null; } +export interface DbPrincipalTransactionSummary extends DbTransactionSummary { + stx_sent: string; + stx_received: string; +} + export interface DbTransaction extends DbTransactionSummary { parent_block_hash: string; parent_index_block_hash: string; From c3baa7d9892ec4b1a22b2f7ffaa205b832e1ae43 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Fri, 8 May 2026 11:25:20 -0600 Subject: [PATCH 14/32] remove mempool --- ...00_mempool-txs-receipt-time-tx-id-index.ts | 19 ---------- src/api/schemas/util.ts | 15 -------- src/datastore/v3/types.ts | 37 ------------------- 3 files changed, 71 deletions(-) delete mode 100644 migrations/1775000000000_mempool-txs-receipt-time-tx-id-index.ts diff --git a/migrations/1775000000000_mempool-txs-receipt-time-tx-id-index.ts b/migrations/1775000000000_mempool-txs-receipt-time-tx-id-index.ts deleted file mode 100644 index 83824f653..000000000 --- a/migrations/1775000000000_mempool-txs-receipt-time-tx-id-index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { MigrationBuilder } from 'node-pg-migrate'; - -export const up = (pgm: MigrationBuilder) => { - pgm.createIndex( - 'mempool_txs', - [ - { name: 'receipt_time', sort: 'DESC' }, - { name: 'tx_id', sort: 'DESC' }, - ], - { - name: 'mempool_txs_unpruned_receipt_time_tx_id_idx', - where: 'pruned = FALSE', - } - ); -}; - -export const down = (pgm: MigrationBuilder) => { - pgm.dropIndex('mempool_txs', [], { name: 'mempool_txs_unpruned_receipt_time_tx_id_idx' }); -}; diff --git a/src/api/schemas/util.ts b/src/api/schemas/util.ts index 01c8afc44..5905e21f6 100644 --- a/src/api/schemas/util.ts +++ b/src/api/schemas/util.ts @@ -26,18 +26,3 @@ export const PaginatedCursorResponse = (type: T, options?: Ob }, options ); - -export const CursorResponse = (type: T, options?: ObjectOptions) => - Type.Object( - { - total: Type.Integer({ examples: [1] }), - limit: Type.Integer({ examples: [20] }), - cursor: Type.Object({ - next: Nullable(Type.String({ description: 'Next page cursor' })), - previous: Nullable(Type.String({ description: 'Previous page cursor' })), - current: Nullable(Type.String({ description: 'Current page cursor' })), - }), - results: Type.Array(type), - }, - options - ); diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index 041308ed0..43375396b 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -76,40 +76,3 @@ export interface DbTransactionEvent { asset_identifier: string | null; value: string | null; } - -export interface DbMempoolTransactionSummary { - tx_id: string; - type_id: DbTxTypeId; - status: DbTxStatus; - sender_address: string; - nonce: number; - sponsor_address: string | null; - sponsor_nonce: number | null; - fee_rate: string; - receipt_time: number; - receipt_block_height: number; - token_transfer_recipient_address: string | null; - token_transfer_amount: string | null; - token_transfer_memo: string | null; - smart_contract_clarity_version: number | null; - smart_contract_contract_id: string | null; - contract_call_contract_id: string | null; - contract_call_function_name: string | null; - coinbase_alt_recipient: string | null; - tenure_change_cause: number | null; -} - -export interface DbMempoolTransaction extends DbMempoolTransactionSummary { - post_conditions: string; - replaced_by_tx_id: string | null; - smart_contract_source_code: string | null; - contract_call_function_args: string | null; - coinbase_payload: string | null; - coinbase_vrf_proof: string | null; - tenure_change_tenure_consensus_hash: string | null; - tenure_change_prev_tenure_consensus_hash: string | null; - tenure_change_burn_view_consensus_hash: string | null; - tenure_change_previous_tenure_end: string | null; - tenure_change_previous_tenure_blocks: number | null; - tenure_change_pubkey_hash: string | null; -} From 86c9b9b5c0a7924f53ecd71658b353b691ad8fac Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Fri, 8 May 2026 16:34:55 -0600 Subject: [PATCH 15/32] tests --- src/api/schemas/v3/entities/transactions.ts | 164 ----------- src/api/serializers/transactions.ts | 125 +------- src/datastore/v3/pg-store-v3.ts | 2 + src/datastore/v3/types.ts | 36 +-- tests/api/v3/principals.test.ts | 306 ++++++++++++++++++++ tests/api/v3/transactions.test.ts | 226 --------------- 6 files changed, 310 insertions(+), 549 deletions(-) delete mode 100644 src/api/schemas/v3/entities/transactions.ts create mode 100644 tests/api/v3/principals.test.ts delete mode 100644 tests/api/v3/transactions.test.ts diff --git a/src/api/schemas/v3/entities/transactions.ts b/src/api/schemas/v3/entities/transactions.ts deleted file mode 100644 index 7b054e41e..000000000 --- a/src/api/schemas/v3/entities/transactions.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; -import { BaseTransactionSummarySchema, TenureChangeCauseSchema } from './transaction-summaries.js'; -import { PostConditionSchema } from '../../entities/post-conditions.js'; -import { Nullable } from '../../util.js'; -import { DecodedClarityValueSchema, ExecutionCostSchema } from './common.js'; - -const BaseTransactionSchema = Type.Composite([ - BaseTransactionSummarySchema, - Type.Object({ - parent_block: Type.Object({ - hash: Type.String({ - description: 'Hash of the parent block', - }), - index_hash: Type.String({ - description: 'Index block hash of the parent block', - }), - }), - post_conditions: Type.Array(PostConditionSchema), - event_count: Type.Integer({ - description: 'Number of events in the transaction', - }), - execution_cost: ExecutionCostSchema, - vm_error: Nullable( - Type.String({ - description: 'VM error of the transaction', - }) - ), - }), -]); -export type BaseTransaction = Static; - -const TokenTransferTransactionSchema = Type.Composite([ - BaseTransactionSchema, - Type.Object({ - type: Type.Literal('token_transfer'), - token_transfer: Type.Object({ - recipient: Type.String({ - description: 'Recipient of the token transfer', - }), - amount: Type.String({ - description: 'Amount of the token transfer', - }), - memo: Nullable( - Type.String({ - description: 'Memo of the token transfer', - }) - ), - }), - }), -]); -export type TokenTransferTransaction = Static; - -const SmartContractTransactionSchema = Type.Composite([ - BaseTransactionSchema, - Type.Object({ - type: Type.Literal('smart_contract'), - smart_contract: Type.Object({ - contract_id: Type.String({ - description: 'Contract ID of the smart contract', - }), - clarity_version: Nullable( - Type.Number({ - description: 'Clarity version of the smart contract', - }) - ), - source_code: Type.String({ - description: 'Source code of the smart contract', - }), - }), - }), -]); -export type SmartContractTransaction = Static; - -const ContractCallTransactionSchema = Type.Composite([ - BaseTransactionSchema, - Type.Object({ - type: Type.Literal('contract_call'), - contract_call: Type.Object({ - contract_id: Type.String({ - description: 'Contract ID of the contract call', - }), - function_name: Type.String({ - description: 'Function name of the contract call', - }), - function_args: Type.Array(DecodedClarityValueSchema, { - description: 'List of arguments used to invoke the function', - }), - }), - }), -]); -export type ContractCallTransaction = Static; - -const PoisonMicroblockTransactionSchema = Type.Composite([ - BaseTransactionSchema, - Type.Object({ - type: Type.Literal('poison_microblock'), - }), -]); -export type PoisonMicroblockTransaction = Static; - -const TenureChangeTransactionSchema = Type.Composite([ - BaseTransactionSchema, - Type.Object({ - type: Type.Literal('tenure_change'), - tenure_change: Type.Object({ - tenure_consensus_hash: Type.String({ - description: - 'Consensus hash of this tenure. Corresponds to the sortition in which the miner of this block was chosen.', - }), - prev_tenure_consensus_hash: Type.String({ - description: - 'Consensus hash of the previous tenure. Corresponds to the sortition of the previous winning block-commit.', - }), - burn_view_consensus_hash: Type.String({ - description: - 'Current consensus hash on the underlying burnchain. Corresponds to the last-seen sortition.', - }), - previous_tenure_end: Type.String({ - description: '(Hex string) Stacks Block hash', - }), - previous_tenure_blocks: Type.Integer({ - description: 'The number of blocks produced in the previous tenure.', - }), - cause: TenureChangeCauseSchema, - pubkey_hash: Type.String({ - description: '(Hex string) The ECDSA public key hash of the current tenure.', - }), - }), - }), -]); -export type TenureChangeTransaction = Static; - -const CoinbaseTransactionSchema = Type.Composite([ - BaseTransactionSchema, - Type.Object({ - type: Type.Literal('coinbase'), - coinbase: Type.Object({ - payload: Type.String({ - description: 'Payload of the coinbase transaction', - }), - alt_recipient: Nullable( - Type.String({ - description: 'Alt recipient of the coinbase transaction', - }) - ), - vrf_proof: Nullable( - Type.String({ - description: 'VRF proof of the coinbase transaction', - }) - ), - }), - }), -]); -export type CoinbaseTransaction = Static; - -export const TransactionSchema = Type.Union([ - TokenTransferTransactionSchema, - SmartContractTransactionSchema, - ContractCallTransactionSchema, - PoisonMicroblockTransactionSchema, - TenureChangeTransactionSchema, - CoinbaseTransactionSchema, -]); -export type Transaction = Static; diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts index de90fe3d9..b3c98b847 100644 --- a/src/api/serializers/transactions.ts +++ b/src/api/serializers/transactions.ts @@ -9,25 +9,9 @@ import { TransactionSummary, TransactionStatus, } from '../schemas/v3/entities/transaction-summaries.js'; -import { - DbPrincipalTransactionSummary, - DbTransaction, - DbTransactionSummary, -} from '../../datastore/v3/types.js'; +import { DbPrincipalTransactionSummary, DbTransactionSummary } from '../../datastore/v3/types.js'; import { DbTxStatus, DbTxTypeId } from '../../datastore/common.js'; import { getTxTenureChangeCauseString } from '../controllers/db-controller.js'; -import { - BaseTransaction, - CoinbaseTransaction, - ContractCallTransaction, - PoisonMicroblockTransaction, - SmartContractTransaction, - TenureChangeTransaction, - TokenTransferTransaction, - Transaction, -} from '../schemas/v3/entities/transactions.js'; -import codec from '@stacks/codec'; -import { serializePostCondition } from './post-conditions.js'; import { PrincipalTransactionSummary } from '../schemas/v3/entities/principal-transactions.js'; /** @@ -149,113 +133,6 @@ export function parseDbTransactionSummary(summary: DbTransactionSummary): Transa } } -/** - * Parses a database transaction into a transaction. - * @param transaction - The database transaction to parse. - * @returns The parsed transaction. - */ -export function parseDbTransaction(transaction: DbTransaction): Transaction { - const summary = parseDbTransactionSummary(transaction); - const decodedPostConditions = codec.decodePostConditions(transaction.post_conditions); - const result: BaseTransaction = { - ...summary, - parent_block: { - hash: transaction.parent_block_hash, - index_hash: transaction.parent_index_block_hash, - }, - post_conditions: decodedPostConditions.post_conditions.map(pc => serializePostCondition(pc)), - event_count: transaction.event_count, - execution_cost: { - read_count: transaction.execution_cost_read_count, - read_length: transaction.execution_cost_read_length, - runtime: transaction.execution_cost_runtime, - write_count: transaction.execution_cost_write_count, - write_length: transaction.execution_cost_write_length, - }, - vm_error: transaction.vm_error, - }; - switch (transaction.type_id) { - case DbTxTypeId.TokenTransfer: { - const tokenTransfer: TokenTransferTransaction = { - ...result, - type: 'token_transfer', - token_transfer: { - recipient: transaction.token_transfer_recipient_address!, - amount: transaction.token_transfer_amount!, - memo: transaction.token_transfer_memo, - }, - }; - return tokenTransfer; - } - case DbTxTypeId.SmartContract: { - const smartContract: SmartContractTransaction = { - ...result, - type: 'smart_contract', - smart_contract: { - clarity_version: transaction.smart_contract_clarity_version, - contract_id: transaction.smart_contract_contract_id!, - source_code: transaction.smart_contract_source_code!, - }, - }; - return smartContract; - } - case DbTxTypeId.ContractCall: { - const contractCall: ContractCallTransaction = { - ...result, - type: 'contract_call', - contract_call: { - contract_id: transaction.contract_call_contract_id!, - function_name: transaction.contract_call_function_name!, - function_args: codec - .decodeClarityValueList(transaction.contract_call_function_args!) - .map(c => ({ - hex: c.hex, - repr: c.repr, - })), - }, - }; - return contractCall; - } - case DbTxTypeId.PoisonMicroblock: { - const poisonMicroblock: PoisonMicroblockTransaction = { - ...result, - type: 'poison_microblock', - }; - return poisonMicroblock; - } - case DbTxTypeId.Coinbase: { - const coinbase: CoinbaseTransaction = { - ...result, - type: 'coinbase', - coinbase: { - alt_recipient: transaction.coinbase_alt_recipient, - payload: transaction.coinbase_payload!, - vrf_proof: transaction.coinbase_vrf_proof, - }, - }; - return coinbase; - } - case DbTxTypeId.TenureChange: { - const tenureChange: TenureChangeTransaction = { - ...result, - type: 'tenure_change', - tenure_change: { - cause: getTxTenureChangeCauseString(transaction.tenure_change_cause!), - tenure_consensus_hash: transaction.tenure_change_tenure_consensus_hash!, - prev_tenure_consensus_hash: transaction.tenure_change_prev_tenure_consensus_hash!, - burn_view_consensus_hash: transaction.tenure_change_burn_view_consensus_hash!, - previous_tenure_end: transaction.tenure_change_previous_tenure_end!, - previous_tenure_blocks: transaction.tenure_change_previous_tenure_blocks!, - pubkey_hash: transaction.tenure_change_pubkey_hash!, - }, - }; - return tenureChange; - } - default: - throw new Error(`Unexpected DbTxTypeId: ${transaction.type_id}`); - } -} - /** * Parses a database principal transaction summary into a principal transaction summary. * @param summary - The database principal transaction summary to parse. diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 2a732be36..7283c02f2 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -37,6 +37,7 @@ export class PgStoreV3 extends BasePgStoreModule { INNER JOIN txs AS t USING (tx_id, index_block_hash, microblock_hash) WHERE p.canonical = true AND p.microblock_canonical = true + AND p.principal = ${args.principal} ${cursorFilter} ORDER BY p.block_height DESC, p.microblock_sequence DESC, p.tx_index DESC LIMIT ${args.limit + 1} @@ -66,6 +67,7 @@ export class PgStoreV3 extends BasePgStoreModule { FROM principal_txs WHERE canonical = true AND microblock_canonical = true + AND principal = ${args.principal} AND (block_height, microblock_sequence, tx_index) > ( ${firstResult.block_height}, diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index 43375396b..ece19ef44 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -1,4 +1,4 @@ -import { DbAssetEventTypeId, DbEventTypeId, DbTxStatus, DbTxTypeId } from '../common.js'; +import { DbTxStatus, DbTxTypeId } from '../common.js'; export type DbCursorPaginatedResult = { limit: number; @@ -42,37 +42,3 @@ export interface DbPrincipalTransactionSummary extends DbTransactionSummary { stx_sent: string; stx_received: string; } - -export interface DbTransaction extends DbTransactionSummary { - parent_block_hash: string; - parent_index_block_hash: string; - post_conditions: string; - event_count: number; - execution_cost_read_count: number; - execution_cost_read_length: number; - execution_cost_runtime: number; - execution_cost_write_count: number; - execution_cost_write_length: number; - vm_error: string | null; - smart_contract_source_code: string | null; - contract_call_function_args: string | null; - coinbase_payload: string | null; - coinbase_vrf_proof: string | null; - tenure_change_tenure_consensus_hash: string | null; - tenure_change_prev_tenure_consensus_hash: string | null; - tenure_change_burn_view_consensus_hash: string | null; - tenure_change_previous_tenure_end: string | null; - tenure_change_previous_tenure_blocks: number | null; - tenure_change_pubkey_hash: string | null; -} - -export interface DbTransactionEvent { - event_index: number; - amount: string; - event_type_id: DbEventTypeId; - asset_event_type_id: DbAssetEventTypeId; - sender: string | null; - recipient: string | null; - asset_identifier: string | null; - value: string | null; -} diff --git a/tests/api/v3/principals.test.ts b/tests/api/v3/principals.test.ts new file mode 100644 index 000000000..55d0b0093 --- /dev/null +++ b/tests/api/v3/principals.test.ts @@ -0,0 +1,306 @@ +import { describe, test, beforeEach, afterEach } from 'node:test'; +import { PgWriteStore } from '../../../src/datastore/pg-write-store.ts'; +import { ApiServer, startApiServer } from '../../../src/api/init.ts'; +import { migrate } from '../../test-helpers.ts'; +import { STACKS_TESTNET } from '@stacks/network'; +import * as assert from 'node:assert/strict'; +import { TestBlockBuilder } from '../test-builders.ts'; +import { DbTxStatus, DbTxTypeId } from 'src/datastore/common.ts'; + +describe('principals', () => { + let db: PgWriteStore; + let api: ApiServer; + + beforeEach(async () => { + await migrate('up'); + db = await PgWriteStore.connect({ + usageName: 'tests', + withNotifier: false, + skipMigrations: true, + }); + api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + describe('/v3/principals/:principal/transactions', () => { + test('should return an empty list', async () => { + const response = await api.fastifyApp.inject({ + method: 'GET', + url: '/extended/v3/principals/SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27/transactions', + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.deepEqual(body, { + limit: 20, + total: 0, + cursor: { + next: null, + previous: null, + current: null, + }, + results: [], + }); + }); + + test('should return a list of principal transaction summaries', async () => { + const testAddr1 = 'ST3J8EVYHVKH6XXPD61EE8XEHW4Y2K83861225AB1'; + const testAddr2 = 'ST1HB64MAJ1MBV4CQ80GF01DZS4T1DSMX20ADCRA4'; + const testContractAddr = 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y.hello-world'; + const testAddr4 = 'ST3DWSXBPYDB484QXFTR81K4AWG4ZB5XZNFF3H70C'; + + await db.update( + new TestBlockBuilder({ + block_height: 1, + block_hash: '0x0001', + index_block_hash: '0x0001', + parent_index_block_hash: '0x0000', + parent_block_hash: '0x0000', + }) + .addTx({ + tx_id: '0x0001', + block_hash: '0x0001', + index_block_hash: '0x0001', + block_time: 1000, + burn_block_height: 1, + burn_block_time: 1000, + tx_index: 0, + fee_rate: 50n, + type_id: DbTxTypeId.Coinbase, + status: DbTxStatus.Success, + sender_address: testAddr1, + }) + .build() + ); + const block2 = new TestBlockBuilder({ + block_height: 2, + block_hash: '0x0002', + index_block_hash: '0x0002', + parent_index_block_hash: '0x0001', + parent_block_hash: '0x0001', + }); + let indexIdIndex = 0; + const createTx = ( + block: TestBlockBuilder, + sender: string, + recipient: string, + amount: number, + stxEventCount = 1, + ftEventCount = 1, + nftEventCount = 1 + ) => { + const tx_id = `0x${indexIdIndex.toString(16).padStart(64, '0')}`; + block.addTx({ + tx_id, + fee_rate: 50n, + block_hash: '0x0002', + index_block_hash: '0x0002', + block_time: 2000, + burn_block_height: 2, + burn_block_time: 2000, + type_id: DbTxTypeId.TokenTransfer, + status: DbTxStatus.Success, + sender_address: sender, + nonce: indexIdIndex, + }); + for (let i = 0; i < stxEventCount; i++) { + block.addTxStxEvent({ + amount: BigInt(amount), + recipient, + sender, + }); + } + for (let i = 0; i < ftEventCount; i++) { + block.addTxFtEvent({ + amount: BigInt(amount), + recipient, + sender, + }); + } + for (let i = 0; i < nftEventCount; i++) { + block.addTxNftEvent({ + recipient, + sender, + }); + } + indexIdIndex++; + }; + createTx(block2, testAddr4, testAddr2, 0, 1, 0, 0); + createTx(block2, testAddr4, testAddr2, 0, 0, 1, 0); + createTx(block2, testAddr4, testAddr2, 0, 0, 0, 1); + createTx(block2, testAddr1, testAddr2, 100_000, 1, 1, 1); + createTx(block2, testAddr2, testContractAddr, 100, 1, 2, 1); + createTx(block2, testAddr2, testContractAddr, 250, 1, 0, 1); + createTx(block2, testAddr2, testContractAddr, 40, 1, 1, 1); + createTx(block2, testContractAddr, testAddr4, 15, 1, 1, 0); + createTx(block2, testAddr2, testAddr4, 35, 3, 1, 2); + await db.update(block2.build()); + + // Try for address 1 + const response1 = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/transactions`, + }); + assert.equal(response1.statusCode, 200); + const body1 = JSON.parse(response1.body); + assert.equal(body1.total, 2); + assert.equal(body1.limit, 20); + assert.equal(body1.results.length, 2); + assert.deepEqual(body1.results[0], { + transaction: { + tx_id: '0x0000000000000000000000000000000000000000000000000000000000000003', + sender: { + address: 'ST3J8EVYHVKH6XXPD61EE8XEHW4Y2K83861225AB1', + nonce: 3, + }, + sponsor: null, + fee_rate: '50', + block: { + height: 2, + hash: '0x0002', + index_hash: '0x0002', + time: 2000, + tx_index: 3, + }, + bitcoin_block: { + height: 2, + time: 2000, + }, + canonical: true, + status: 'success', + type: 'token_transfer', + token_transfer: { + recipient: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6', + amount: '100', + memo: '0x', + }, + }, + balance_changes: { + stx: { + sent: '100050', + received: '0', + }, + }, + }); + assert.deepEqual(body1.results[1], { + transaction: { + tx_id: '0x0001', + sender: { + address: 'ST3J8EVYHVKH6XXPD61EE8XEHW4Y2K83861225AB1', + nonce: 0, + }, + sponsor: null, + fee_rate: '50', + block: { + height: 1, + hash: '0x0001', + index_hash: '0x0001', + time: 1000, + tx_index: 0, + }, + bitcoin_block: { + height: 1, + time: 1000, + }, + canonical: true, + status: 'success', + type: 'coinbase', + coinbase: { + alt_recipient: null, + }, + }, + balance_changes: { + stx: { + sent: '50', + received: '0', + }, + }, + }); + + // Try for address 4 + const response4 = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr4}/transactions`, + }); + assert.equal(response4.statusCode, 200); + const body4 = JSON.parse(response4.body); + assert.equal(body4.total, 5); + assert.equal(body4.limit, 20); + assert.equal(body4.results.length, 5); + assert.equal(body4.results[0].transaction.tx_id, '0x0000000000000000000000000000000000000000000000000000000000000008'); + assert.equal(body4.results[1].transaction.tx_id, '0x0000000000000000000000000000000000000000000000000000000000000007'); + assert.equal(body4.results[2].transaction.tx_id, '0x0000000000000000000000000000000000000000000000000000000000000002'); + assert.equal(body4.results[3].transaction.tx_id, '0x0000000000000000000000000000000000000000000000000000000000000001'); + assert.equal(body4.results[4].transaction.tx_id, '0x0000000000000000000000000000000000000000000000000000000000000000'); + }); + + test('should allow cursor pagination', async () => { + const testAddr1 = 'ST3J8EVYHVKH6XXPD61EE8XEHW4Y2K83861225AB1'; + for (let i = 1; i <= 10; i++) { + const hex = i.toString(16).padStart(64, '0'); + const prevHex = (i - 1).toString(16).padStart(64, '0'); + const builder = new TestBlockBuilder({ + block_height: i, + index_block_hash: `0x${hex}`, + parent_index_block_hash: `0x${prevHex}`, + parent_block_hash: `0x${prevHex}`, + }); + for (let j = 1; j <= 5; j++) { + builder.addTx({ + tx_id: `0x${(i * j).toString(16).padStart(8, '0')}`, + block_hash: `0x${hex}`, + index_block_hash: `0x${hex}`, + block_time: i * 1000, + burn_block_height: i, + burn_block_time: i * 1000, + sender_address: testAddr1, + }); + } + await db.update(builder.build()); + } + + // Fetch first page + const page1 = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/transactions`, + query: { + limit: '5', + }, + }); + assert.equal(page1.statusCode, 200); + const body1 = JSON.parse(page1.body); + assert.equal(body1.total, 50); + assert.equal(body1.limit, 5); + assert.equal(body1.results.length, 5); + assert.deepEqual(body1.cursor, { + next: '9:0:4', + previous: null, + current: '10:0:4', + }); + + // Fetch second page + const page2 = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/transactions`, + query: { + limit: '5', + cursor: '9:0:4', + }, + }); + assert.equal(page2.statusCode, 200); + const body2 = JSON.parse(page2.body); + assert.equal(body2.total, 50); + assert.equal(body2.limit, 5); + assert.equal(body2.results.length, 5); + assert.deepEqual(body2.cursor, { + next: '8:0:4', + previous: '10:0:4', + current: '9:0:4', + }); + }); + }); +}); diff --git a/tests/api/v3/transactions.test.ts b/tests/api/v3/transactions.test.ts deleted file mode 100644 index 06de29ddd..000000000 --- a/tests/api/v3/transactions.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { describe, test, beforeEach, afterEach } from 'node:test'; -import { PgWriteStore } from '../../../src/datastore/pg-write-store.ts'; -import { ApiServer, startApiServer } from '../../../src/api/init.ts'; -import { migrate } from '../../test-helpers.ts'; -import { STACKS_TESTNET } from '@stacks/network'; -import * as assert from 'node:assert/strict'; -import { TestBlockBuilder } from '../test-builders.ts'; -import { DbTxStatus, DbTxTypeId } from 'src/datastore/common.ts'; - -describe('transactions', () => { - let db: PgWriteStore; - let api: ApiServer; - - beforeEach(async () => { - await migrate('up'); - db = await PgWriteStore.connect({ - usageName: 'tests', - withNotifier: false, - skipMigrations: true, - }); - api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); - }); - - afterEach(async () => { - await api.terminate(); - await db?.close(); - await migrate('down'); - }); - - describe('/v3/transactions', () => { - test('should return an empty list', async () => { - const response = await api.fastifyApp.inject({ - method: 'GET', - url: '/extended/v3/transactions', - }); - assert.equal(response.statusCode, 200); - const body = JSON.parse(response.body); - assert.equal(body.limit, 20); - assert.equal(body.total, 0); - assert.equal(body.cursor.next, null); - assert.equal(body.cursor.previous, null); - assert.equal(body.cursor.current, null); - assert.equal(body.results.length, 0); - }); - - test('should return a list of transaction summaries', async () => { - await db.update( - new TestBlockBuilder({ - block_height: 1, - index_block_hash: '0x0001', - parent_index_block_hash: '0x0000', - parent_block_hash: '0x0000', - }) - .addTx({ - tx_id: '0x0001', - block_hash: '0x0001', - index_block_hash: '0x0001', - block_time: 1000, - burn_block_height: 1, - burn_block_time: 1000, - tx_index: 0, - fee_rate: 50n, - type_id: DbTxTypeId.Coinbase, - status: DbTxStatus.Success, - sender_address: 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27', - }) - .build() - ); - await db.update( - new TestBlockBuilder({ - block_height: 2, - index_block_hash: '0x0002', - parent_index_block_hash: '0x0001', - parent_block_hash: '0x0001', - }) - .addTx({ - tx_id: '0x0002', - tx_index: 0, - fee_rate: 50n, - block_hash: '0x0002', - index_block_hash: '0x0002', - block_time: 2000, - burn_block_height: 2, - burn_block_time: 2000, - type_id: DbTxTypeId.TokenTransfer, - status: DbTxStatus.Success, - sender_address: 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27', - token_transfer_recipient_address: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6', - token_transfer_amount: 100n, - token_transfer_memo: '0x', - }) - .build() - ); - - const response = await api.fastifyApp.inject({ - method: 'GET', - url: '/extended/v3/transactions', - }); - assert.equal(response.statusCode, 200); - const body = JSON.parse(response.body); - assert.equal(body.total, 2); - assert.equal(body.limit, 20); - assert.equal(body.cursor.next, null); - assert.equal(body.cursor.previous, null); - assert.equal(body.cursor.current, '2:0:0'); - assert.equal(body.results.length, 2); - assert.deepEqual(body.results[0], { - tx_id: '0x0002', - type: 'token_transfer', - status: 'success', - block: { - height: 2, - hash: '0x0002', - index_hash: '0x0002', - time: 2000, - tx_index: 0, - }, - burn_block: { - height: 2, - time: 2000, - }, - canonical: true, - sender: { - address: 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27', - nonce: 0, - }, - sponsor: null, - fee_rate: '50', - token_transfer: { - recipient: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6', - amount: '100', - memo: '0x', - }, - }); - assert.deepEqual(body.results[1], { - tx_id: '0x0001', - type: 'coinbase', - status: 'success', - block: { - height: 1, - hash: '0x0001', - index_hash: '0x0001', - time: 1000, - tx_index: 0, - }, - burn_block: { - height: 1, - time: 1000, - }, - canonical: true, - sender: { - address: 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27', - nonce: 0, - }, - sponsor: null, - fee_rate: '50', - coinbase: { - alt_recipient: null, - }, - }); - }); - - test('should allow cursor pagination', async () => { - for (let i = 1; i <= 10; i++) { - const hex = i.toString(16).padStart(64, '0'); - const prevHex = (i - 1).toString(16).padStart(64, '0'); - const builder = new TestBlockBuilder({ - block_height: i, - index_block_hash: `0x${hex}`, - parent_index_block_hash: `0x${prevHex}`, - parent_block_hash: `0x${prevHex}`, - }); - for (let j = 1; j <= 5; j++) { - builder.addTx({ - tx_id: `0x${(i * j).toString(16).padStart(8, '0')}`, - block_hash: `0x${hex}`, - index_block_hash: `0x${hex}`, - block_time: i * 1000, - burn_block_height: i, - burn_block_time: i * 1000, - }); - } - await db.update(builder.build()); - } - - // Fetch first page - const page1 = await api.fastifyApp.inject({ - method: 'GET', - url: '/extended/v3/transactions', - query: { - limit: '5', - }, - }); - assert.equal(page1.statusCode, 200); - const body1 = JSON.parse(page1.body); - assert.equal(body1.total, 50); - assert.equal(body1.limit, 5); - assert.equal(body1.results.length, 5); - assert.deepEqual(body1.cursor, { - next: '9:0:4', - previous: null, - current: '10:0:4', - }); - - // Fetch second page - const page2 = await api.fastifyApp.inject({ - method: 'GET', - url: '/extended/v3/transactions', - query: { - limit: '5', - cursor: '9:0:4', - }, - }); - assert.equal(page2.statusCode, 200); - const body2 = JSON.parse(page2.body); - assert.equal(body2.total, 50); - assert.equal(body2.limit, 5); - assert.equal(body2.results.length, 5); - assert.deepEqual(body2.cursor, { - next: '8:0:4', - previous: '10:0:4', - current: '9:0:4', - }); - }); - }); -}); From 281988335b9135dccfe88eeb7b8a21c3202a7178 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Sat, 9 May 2026 11:18:38 -0600 Subject: [PATCH 16/32] extra fields --- src/api/routes/v3/principals.ts | 2 +- .../v3/entities/principal-transactions.ts | 25 ++++++++++++++--- src/api/serializers/transactions.ts | 14 ++++++---- src/datastore/v3/pg-store-v3.ts | 4 +++ src/datastore/v3/types.ts | 4 +++ tests/api/v3/principals.test.ts | 28 ++++++++++++------- 6 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/api/routes/v3/principals.ts b/src/api/routes/v3/principals.ts index d67a7a348..8d38bf7cc 100644 --- a/src/api/routes/v3/principals.ts +++ b/src/api/routes/v3/principals.ts @@ -20,7 +20,7 @@ export const PrincipalsRoutes: FastifyPluginAsync< schema: { operationId: 'get_principal_transactions', summary: 'Get principal transactions', - description: `Retrieves a paginated list of confirmed transactions sent or received by a STX address or Smart Contract ID`, + description: `Returns a list of confirmed transactions sent or received by a Stacks principal`, tags: ['Transactions'], params: Type.Object({ principal: PrincipalSchema }), querystring: CursorPaginationQuerystring(ResourceType.Tx, Type.String()), diff --git a/src/api/schemas/v3/entities/principal-transactions.ts b/src/api/schemas/v3/entities/principal-transactions.ts index 09b7b0f4f..92dac416e 100644 --- a/src/api/schemas/v3/entities/principal-transactions.ts +++ b/src/api/schemas/v3/entities/principal-transactions.ts @@ -3,10 +3,27 @@ import { TransactionSummarySchema } from './transaction-summaries.js'; export const PrincipalTransactionSummarySchema = Type.Object({ transaction: TransactionSummarySchema, - balance_changes: Type.Object({ - stx: Type.Object({ - sent: Type.String({ description: 'STX sent' }), - received: Type.String({ description: 'STX received' }), + stx_balance_change: Type.Object({ + sent: Type.String({ + description: + 'STX sent by the principal, including any fees paid, in micro-STX as an integer string', + }), + received: Type.String({ + description: 'STX received by the principal, in micro-STX as an integer string', + }), + net: Type.String({ + description: 'Net STX balance change for the principal, in micro-STX as an integer string', + }), + }), + affected_asset_balances: Type.Object({ + stx: Type.Boolean({ + description: 'Whether the STX balance was affected by the transaction', + }), + ft: Type.Boolean({ + description: 'Whether the FT balance was affected by the transaction', + }), + nft: Type.Boolean({ + description: 'Whether the NFT balance was affected by the transaction', }), }), }); diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts index b3c98b847..cfd542e14 100644 --- a/src/api/serializers/transactions.ts +++ b/src/api/serializers/transactions.ts @@ -143,11 +143,15 @@ export function parsePrincipalTransactionSummary( ): PrincipalTransactionSummary { return { transaction: parseDbTransactionSummary(summary), - balance_changes: { - stx: { - sent: summary.stx_sent, - received: summary.stx_received, - }, + stx_balance_change: { + sent: summary.stx_sent, + received: summary.stx_received, + net: summary.stx_net, + }, + affected_asset_balances: { + stx: summary.stx_balance_affected, + ft: summary.ft_balance_affected, + nft: summary.nft_balance_affected, }, }; } diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 7283c02f2..23291ec89 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -30,6 +30,10 @@ export class PgStoreV3 extends BasePgStoreModule { t.microblock_sequence, p.stx_sent, p.stx_received, + (p.stx_received - p.stx_sent) AS stx_net, + p.stx_balance_affected, + p.ft_balance_affected, + p.nft_balance_affected, ( SELECT COALESCE(count, 0)::int FROM principal_tx_counts WHERE principal = ${args.principal} ) AS total diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index ece19ef44..6e54021b2 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -41,4 +41,8 @@ export interface DbTransactionSummary { export interface DbPrincipalTransactionSummary extends DbTransactionSummary { stx_sent: string; stx_received: string; + stx_net: string; + stx_balance_affected: boolean; + ft_balance_affected: boolean; + nft_balance_affected: boolean; } diff --git a/tests/api/v3/principals.test.ts b/tests/api/v3/principals.test.ts index 55d0b0093..2bdfcfbf4 100644 --- a/tests/api/v3/principals.test.ts +++ b/tests/api/v3/principals.test.ts @@ -179,11 +179,15 @@ describe('principals', () => { memo: '0x', }, }, - balance_changes: { - stx: { - sent: '100050', - received: '0', - }, + stx_balance_change: { + sent: '100050', + received: '0', + net: '-100050', + }, + affected_asset_balances: { + stx: true, + ft: true, + nft: true, }, }); assert.deepEqual(body1.results[1], { @@ -213,11 +217,15 @@ describe('principals', () => { alt_recipient: null, }, }, - balance_changes: { - stx: { - sent: '50', - received: '0', - }, + stx_balance_change: { + sent: '50', + received: '0', + net: '-50', + }, + affected_asset_balances: { + stx: true, + ft: false, + nft: false, }, }); From 42e138d3b43a10dd1800dd959be32f3b9a5373f7 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Sat, 9 May 2026 23:17:57 -0600 Subject: [PATCH 17/32] involvement --- .../v3/entities/principal-transactions.ts | 42 ++++++++++++------- src/api/serializers/transactions.ts | 13 +++--- src/datastore/v3/pg-store-v3.ts | 5 +++ src/datastore/v3/types.ts | 3 ++ tests/api/v3/principals.test.ts | 26 +++++++----- 5 files changed, 59 insertions(+), 30 deletions(-) diff --git a/src/api/schemas/v3/entities/principal-transactions.ts b/src/api/schemas/v3/entities/principal-transactions.ts index 92dac416e..279ccac46 100644 --- a/src/api/schemas/v3/entities/principal-transactions.ts +++ b/src/api/schemas/v3/entities/principal-transactions.ts @@ -1,29 +1,41 @@ import { Static, Type } from '@sinclair/typebox'; import { TransactionSummarySchema } from './transaction-summaries.js'; +export const PrincipalTransactionBalanceChangeSchema = Type.Object({ + sent: Type.String({ + description: 'Amount sent by the principal', + }), + received: Type.String({ + description: 'Amount received by the principal', + }), + net: Type.String({ + description: 'Net balance change for the principal', + }), +}); +export type PrincipalTransactionBalanceChange = Static< + typeof PrincipalTransactionBalanceChangeSchema +>; + export const PrincipalTransactionSummarySchema = Type.Object({ transaction: TransactionSummarySchema, - stx_balance_change: Type.Object({ - sent: Type.String({ - description: - 'STX sent by the principal, including any fees paid, in micro-STX as an integer string', - }), - received: Type.String({ - description: 'STX received by the principal, in micro-STX as an integer string', - }), - net: Type.String({ - description: 'Net STX balance change for the principal, in micro-STX as an integer string', - }), + involvement: Type.Union( + [Type.Literal('sender'), Type.Literal('sponsor'), Type.Literal('affected')], + { + description: 'How the principal is involved in the transaction.', + } + ), + balance_changes: Type.Object({ + stx: PrincipalTransactionBalanceChangeSchema, }), - affected_asset_balances: Type.Object({ + affected_asset_types: Type.Object({ stx: Type.Boolean({ - description: 'Whether the STX balance was affected by the transaction', + description: "Whether the principal's STX balance was affected by the transaction", }), ft: Type.Boolean({ - description: 'Whether the FT balance was affected by the transaction', + description: "Whether the principal's FT balance was affected by the transaction", }), nft: Type.Boolean({ - description: 'Whether the NFT balance was affected by the transaction', + description: "Whether the principal's NFT balance was affected by the transaction", }), }), }); diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts index cfd542e14..2f8fa04a5 100644 --- a/src/api/serializers/transactions.ts +++ b/src/api/serializers/transactions.ts @@ -143,12 +143,15 @@ export function parsePrincipalTransactionSummary( ): PrincipalTransactionSummary { return { transaction: parseDbTransactionSummary(summary), - stx_balance_change: { - sent: summary.stx_sent, - received: summary.stx_received, - net: summary.stx_net, + involvement: summary.involvement, + balance_changes: { + stx: { + sent: summary.stx_sent, + received: summary.stx_received, + net: summary.stx_net, + }, }, - affected_asset_balances: { + affected_asset_types: { stx: summary.stx_balance_affected, ft: summary.ft_balance_affected, nft: summary.nft_balance_affected, diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 23291ec89..27c1038a7 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -34,6 +34,11 @@ export class PgStoreV3 extends BasePgStoreModule { p.stx_balance_affected, p.ft_balance_affected, p.nft_balance_affected, + CASE + WHEN t.sender_address = ${args.principal} THEN 'sender' + WHEN t.sponsor_address = ${args.principal} THEN 'sponsor' + ELSE 'affected' + END AS involvement, ( SELECT COALESCE(count, 0)::int FROM principal_tx_counts WHERE principal = ${args.principal} ) AS total diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index 6e54021b2..2f00035ee 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -38,6 +38,8 @@ export interface DbTransactionSummary { tenure_change_cause: number | null; } +export type DbPrincipalTransactionInvolvement = 'sender' | 'sponsor' | 'affected'; + export interface DbPrincipalTransactionSummary extends DbTransactionSummary { stx_sent: string; stx_received: string; @@ -45,4 +47,5 @@ export interface DbPrincipalTransactionSummary extends DbTransactionSummary { stx_balance_affected: boolean; ft_balance_affected: boolean; nft_balance_affected: boolean; + involvement: DbPrincipalTransactionInvolvement; } diff --git a/tests/api/v3/principals.test.ts b/tests/api/v3/principals.test.ts index 2bdfcfbf4..47fca64d5 100644 --- a/tests/api/v3/principals.test.ts +++ b/tests/api/v3/principals.test.ts @@ -179,12 +179,15 @@ describe('principals', () => { memo: '0x', }, }, - stx_balance_change: { - sent: '100050', - received: '0', - net: '-100050', + involvement: 'sender', + balance_changes: { + stx: { + sent: '100050', + received: '0', + net: '-100050', + }, }, - affected_asset_balances: { + affected_asset_types: { stx: true, ft: true, nft: true, @@ -217,12 +220,15 @@ describe('principals', () => { alt_recipient: null, }, }, - stx_balance_change: { - sent: '50', - received: '0', - net: '-50', + involvement: 'sender', + balance_changes: { + stx: { + sent: '50', + received: '0', + net: '-50', + }, }, - affected_asset_balances: { + affected_asset_types: { stx: true, ft: false, nft: false, From 6289d379b7c5bdda91eba8e4f2f2786498b53700 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 11 May 2026 08:52:22 -0600 Subject: [PATCH 18/32] dont show canonical status --- src/api/schemas/v3/entities/transaction-summaries.ts | 3 --- src/api/serializers/transactions.ts | 1 - tests/api/v3/principals.test.ts | 2 -- 3 files changed, 6 deletions(-) diff --git a/src/api/schemas/v3/entities/transaction-summaries.ts b/src/api/schemas/v3/entities/transaction-summaries.ts index 97d7f7b2a..a9657a56f 100644 --- a/src/api/schemas/v3/entities/transaction-summaries.ts +++ b/src/api/schemas/v3/entities/transaction-summaries.ts @@ -71,9 +71,6 @@ export const BaseTransactionSummarySchema = Type.Object({ description: 'Unix timestamp (in seconds) indicating when this block was mined.', }), }), - canonical: Type.Boolean({ - description: 'Set to `true` if block corresponds to the canonical chain tip', - }), status: TransactionStatusSchema, }); export type BaseTransactionSummary = Static; diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts index 2f8fa04a5..9a28ea428 100644 --- a/src/api/serializers/transactions.ts +++ b/src/api/serializers/transactions.ts @@ -63,7 +63,6 @@ export function parseDbTransactionSummary(summary: DbTransactionSummary): Transa height: summary.burn_block_height, time: summary.burn_block_time, }, - canonical: summary.canonical, status: parseDbTransactionSummaryStatus(summary.status), }; switch (summary.type_id) { diff --git a/tests/api/v3/principals.test.ts b/tests/api/v3/principals.test.ts index 47fca64d5..86e013558 100644 --- a/tests/api/v3/principals.test.ts +++ b/tests/api/v3/principals.test.ts @@ -170,7 +170,6 @@ describe('principals', () => { height: 2, time: 2000, }, - canonical: true, status: 'success', type: 'token_transfer', token_transfer: { @@ -213,7 +212,6 @@ describe('principals', () => { height: 1, time: 1000, }, - canonical: true, status: 'success', type: 'coinbase', coinbase: { From 877411b5ff4cfa4f0d483c0fcecaa758bdc558bc Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 12 May 2026 12:47:23 -0600 Subject: [PATCH 19/32] balance change table --- ...8599015338_principal-tx-balance-changes.ts | 78 +++++++ package.json | 2 +- src/datastore/common.ts | 23 +++ src/datastore/pg-write-store.ts | 195 ++++++++++++++++-- 4 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 migrations/1778599015338_principal-tx-balance-changes.ts diff --git a/migrations/1778599015338_principal-tx-balance-changes.ts b/migrations/1778599015338_principal-tx-balance-changes.ts new file mode 100644 index 000000000..571be24da --- /dev/null +++ b/migrations/1778599015338_principal-tx-balance-changes.ts @@ -0,0 +1,78 @@ +import { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +export function up(pgm: MigrationBuilder) { + pgm.createTable('principal_tx_balance_changes', { + principal: { + type: 'text', + notNull: true, + }, + tx_id: { + type: 'bytea', + notNull: true, + }, + block_height: { + type: 'integer', + notNull: true, + }, + index_block_hash: { + type: 'bytea', + notNull: true, + }, + microblock_hash: { + type: 'bytea', + notNull: true, + }, + microblock_sequence: { + type: 'integer', + notNull: true, + }, + tx_index: { + type: 'smallint', + notNull: true, + }, + canonical: { + type: 'boolean', + notNull: true, + }, + microblock_canonical: { + type: 'boolean', + notNull: true, + }, + asset_type: { + type: 'smallint', // 1: STX, 2: FT, 3: NFT + notNull: true, + }, + asset_identifier: { + type: 'text', + notNull: true, + }, + sent: { + type: 'numeric', + notNull: true, + }, + received: { + type: 'numeric', + notNull: true, + }, + }); + + pgm.addConstraint( + 'principal_tx_balance_changes', + 'unique_principal_tx_balance_changes', + 'UNIQUE(principal, tx_id, index_block_hash, microblock_hash, asset_type, asset_identifier)' + ); + + pgm.createIndex('principal_tx_balance_changes', 'tx_id'); + pgm.createIndex('principal_tx_balance_changes', ['index_block_hash', 'canonical']); + pgm.createIndex('principal_tx_balance_changes', 'microblock_hash'); + + pgm.addColumn('principal_txs', { + balance_change_count: { + type: 'integer', + notNull: true, + default: 0, + }, + }); +} diff --git a/package.json b/package.json index 36e63863d..3193db0ae 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "lint:eslint": "eslint .", "lint:prettier": "prettier --check src/**/*.ts", "lint:fix": "eslint . --fix && prettier --write --check src/**/*.{ts,json}", - "migrate": "node-pg-migrate -m migrations" + "migrate": "node-pg-migrate -m migrations -j ts" }, "repository": { "type": "git", diff --git a/src/datastore/common.ts b/src/datastore/common.ts index ba6c5109b..6faac16ad 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -576,6 +576,12 @@ export enum DbAssetEventTypeId { Burn = 3, } +export enum DbAssetType { + Stx = 1, + Ft = 2, + Nft = 3, +} + interface DbAssetEvent extends DbEventBase { asset_event_type_id: DbAssetEventTypeId; sender?: string; @@ -1698,6 +1704,23 @@ export interface PrincipalTxsInsertValues { nft_mint_event_count: number; nft_burn_event_count: number; nft_transfer_event_count: number; + balance_change_count: number; +} + +export interface PrincipalTxBalanceChangeInsertValues { + principal: string; + tx_id: PgBytea; + block_height: number; + index_block_hash: PgBytea; + microblock_hash: PgBytea; + microblock_sequence: number; + tx_index: number; + canonical: boolean; + microblock_canonical: boolean; + asset_type: DbAssetType; + asset_identifier: string; + sent: bigint; + received: bigint; } export interface RewardSlotHolderInsertValues { diff --git a/src/datastore/pg-write-store.ts b/src/datastore/pg-write-store.ts index 4ed3046fa..6af058e90 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -63,7 +63,9 @@ import { PoxSetSignerValues, PoxCycleInsertValues, DbAssetEventTypeId, + DbAssetType, DbBurnBlockPoxTx, + PrincipalTxBalanceChangeInsertValues, } from './common.js'; import { BLOCK_COLUMNS, @@ -1394,6 +1396,8 @@ export class PgWriteStore extends PgStore { /** * Update the `principal_txs` table with the latest `tx_id`s that resulted in activity for a * principal (contract or address), and mark the type of token balance that was affected. + * Also populates the `principal_tx_balance_changes` table with one row per + * (principal, asset_type, asset_identifier) touched by each tx. * @param sql - DB client * @param txs - list of transactions */ @@ -1414,7 +1418,16 @@ export class PgWriteStore extends PgStore { nft_burns: number; nft_transfers: number; }; + type BalanceChangeRow = { + principal: string; + asset_type: DbAssetType; + asset_identifier: string; + sent: bigint; + received: bigint; + }; + const STX_ASSET_IDENTIFIER = 'stx'; const values: PrincipalTxsInsertValues[] = []; + const balanceChangeValues: PrincipalTxBalanceChangeInsertValues[] = []; for (const { tx, stxEvents, ftEvents, nftEvents } of txs) { // Mark principals who participated in this transaction, along with the type of token balance // they affected. @@ -1439,6 +1452,28 @@ export class PgWriteStore extends PgStore { }); }; + // Per-asset balance changes for this tx, keyed by `${principal}|${asset_type}|${asset_id}`. + // Note: for NFTs we count tokens moved (each event contributes 1 to sent/received) since + // the schema stores numeric counts rather than the underlying token values. + const balanceChanges = new Map(); + const addBalanceChange = ( + principal: string, + asset_type: DbAssetType, + asset_identifier: string, + sent: bigint, + received: bigint + ) => { + const key = `${principal}|${asset_type}|${asset_identifier}`; + const entry = balanceChanges.get(key); + balanceChanges.set(key, { + principal, + asset_type, + asset_identifier, + sent: (entry?.sent ?? 0n) + sent, + received: (entry?.received ?? 0n) + received, + }); + }; + // Record participating principals. No amounts yet, that will be included in stx_events below. addPrincipal(tx.sender_address); if (tx.token_transfer_recipient_address) @@ -1447,72 +1482,158 @@ export class PgWriteStore extends PgStore { if (tx.smart_contract_contract_id) addPrincipal(tx.smart_contract_contract_id); // Record fee paid. - if (tx.sponsor_address) { - addPrincipal(tx.sponsor_address, { stx: true, stx_sent: BigInt(tx.fee_rate) }); - } else { - addPrincipal(tx.sender_address, { stx: true, stx_sent: BigInt(tx.fee_rate) }); - } + const feePayer = tx.sponsor_address ?? tx.sender_address; + const feeAmount = BigInt(tx.fee_rate); + addPrincipal(feePayer, { stx: true, stx_sent: feeAmount }); + addBalanceChange(feePayer, DbAssetType.Stx, STX_ASSET_IDENTIFIER, feeAmount, 0n); // Record token amounts and event counts. for (const event of stxEvents) { switch (event.asset_event_type_id) { case DbAssetEventTypeId.Mint: - if (event.recipient) + if (event.recipient) { addPrincipal(event.recipient, { stx: true, stx_received: event.amount, stx_mints: 1, }); + addBalanceChange( + event.recipient, + DbAssetType.Stx, + STX_ASSET_IDENTIFIER, + 0n, + event.amount + ); + } break; case DbAssetEventTypeId.Burn: - if (event.sender) + if (event.sender) { addPrincipal(event.sender, { stx: true, stx_sent: event.amount, stx_burns: 1 }); + addBalanceChange( + event.sender, + DbAssetType.Stx, + STX_ASSET_IDENTIFIER, + event.amount, + 0n + ); + } break; case DbAssetEventTypeId.Transfer: - if (event.sender) + if (event.sender) { addPrincipal(event.sender, { stx: true, stx_sent: event.amount, stx_transfers: 1 }); - if (event.recipient) + addBalanceChange( + event.sender, + DbAssetType.Stx, + STX_ASSET_IDENTIFIER, + event.amount, + 0n + ); + } + if (event.recipient) { addPrincipal(event.recipient, { stx: true, stx_received: event.amount, stx_transfers: 1, }); + addBalanceChange( + event.recipient, + DbAssetType.Stx, + STX_ASSET_IDENTIFIER, + 0n, + event.amount + ); + } break; } } for (const event of ftEvents) { switch (event.asset_event_type_id) { case DbAssetEventTypeId.Mint: - if (event.recipient) addPrincipal(event.recipient, { ft: true, ft_mints: 1 }); + if (event.recipient) { + addPrincipal(event.recipient, { ft: true, ft_mints: 1 }); + addBalanceChange( + event.recipient, + DbAssetType.Ft, + event.asset_identifier, + 0n, + event.amount + ); + } break; case DbAssetEventTypeId.Burn: - if (event.sender) addPrincipal(event.sender, { ft: true, ft_burns: 1 }); + if (event.sender) { + addPrincipal(event.sender, { ft: true, ft_burns: 1 }); + addBalanceChange( + event.sender, + DbAssetType.Ft, + event.asset_identifier, + event.amount, + 0n + ); + } break; case DbAssetEventTypeId.Transfer: - if (event.sender) addPrincipal(event.sender, { ft: true, ft_transfers: 1 }); - if (event.recipient) + if (event.sender) { + addPrincipal(event.sender, { ft: true, ft_transfers: 1 }); + addBalanceChange( + event.sender, + DbAssetType.Ft, + event.asset_identifier, + event.amount, + 0n + ); + } + if (event.recipient) { addPrincipal(event.recipient, { ft: true, ft_transfers: 1, }); + addBalanceChange( + event.recipient, + DbAssetType.Ft, + event.asset_identifier, + 0n, + event.amount + ); + } break; } } for (const event of nftEvents) { switch (event.asset_event_type_id) { case DbAssetEventTypeId.Mint: - if (event.recipient) addPrincipal(event.recipient, { nft: true, nft_mints: 1 }); + if (event.recipient) { + addPrincipal(event.recipient, { nft: true, nft_mints: 1 }); + addBalanceChange(event.recipient, DbAssetType.Nft, event.asset_identifier, 0n, 1n); + } break; case DbAssetEventTypeId.Burn: - if (event.sender) addPrincipal(event.sender, { nft: true, nft_burns: 1 }); + if (event.sender) { + addPrincipal(event.sender, { nft: true, nft_burns: 1 }); + addBalanceChange(event.sender, DbAssetType.Nft, event.asset_identifier, 1n, 0n); + } break; case DbAssetEventTypeId.Transfer: - if (event.sender) addPrincipal(event.sender, { nft: true, nft_transfers: 1 }); - if (event.recipient) addPrincipal(event.recipient, { nft: true, nft_transfers: 1 }); + if (event.sender) { + addPrincipal(event.sender, { nft: true, nft_transfers: 1 }); + addBalanceChange(event.sender, DbAssetType.Nft, event.asset_identifier, 1n, 0n); + } + if (event.recipient) { + addPrincipal(event.recipient, { nft: true, nft_transfers: 1 }); + addBalanceChange(event.recipient, DbAssetType.Nft, event.asset_identifier, 0n, 1n); + } break; } } + // Count balance change rows per principal so the principal_txs row carries + // `balance_change_count` — used by the API to know how many rows to expect from + // the drill-in endpoint without an extra COUNT(*) query. + const balanceChangeCounts = new Map(); + for (const row of balanceChanges.values()) { + balanceChangeCounts.set(row.principal, (balanceChangeCounts.get(row.principal) ?? 0) + 1); + } + for (const [principal, data] of principals.entries()) { values.push({ principal, @@ -1538,6 +1659,25 @@ export class PgWriteStore extends PgStore { nft_mint_event_count: data.nft_mints, nft_burn_event_count: data.nft_burns, nft_transfer_event_count: data.nft_transfers, + balance_change_count: balanceChangeCounts.get(principal) ?? 0, + }); + } + + for (const change of balanceChanges.values()) { + balanceChangeValues.push({ + principal: change.principal, + tx_id: tx.tx_id, + block_height: tx.block_height, + index_block_hash: tx.index_block_hash, + microblock_hash: tx.microblock_hash, + microblock_sequence: tx.microblock_sequence, + tx_index: tx.tx_index, + canonical: tx.canonical, + microblock_canonical: tx.microblock_canonical, + asset_type: change.asset_type, + asset_identifier: change.asset_identifier, + sent: change.sent, + received: change.received, }); } } @@ -1559,6 +1699,12 @@ export class PgWriteStore extends PgStore { ON CONFLICT (principal) DO UPDATE SET count = principal_tx_counts.count + EXCLUDED.count `; } + for (const batch of batchIterate(balanceChangeValues, INSERT_BATCH_SIZE)) { + await sql` + INSERT INTO principal_tx_balance_changes ${sql(batch)} + ON CONFLICT ON CONSTRAINT unique_principal_tx_balance_changes DO NOTHING + `; + } } async updateBatchZonefiles( @@ -3014,6 +3160,14 @@ export class PgWriteStore extends PgStore { AND (index_block_hash = ${args.indexBlockHash} OR index_block_hash = '\\x'::bytea) AND tx_id IN ${sql(txIds)} `; + await sql` + UPDATE principal_tx_balance_changes + SET microblock_canonical = ${args.isMicroCanonical}, + canonical = ${args.isCanonical}, index_block_hash = ${args.indexBlockHash} + WHERE microblock_hash IN ${sql(args.microblocks)} + AND (index_block_hash = ${args.indexBlockHash} OR index_block_hash = '\\x'::bytea) + AND tx_id IN ${sql(txIds)} + `; } // Update unanchored tx count in `chain_tip` table @@ -3467,6 +3621,13 @@ export class PgWriteStore extends PgStore { FROM count_deltas AS cd WHERE pc.principal = cd.principal `; + await sql` + UPDATE principal_tx_balance_changes + SET canonical = ${canonical} + WHERE tx_id IN ${sql(txs.map(t => t.txId))} + AND index_block_hash = ${indexBlockHash} + AND canonical != ${canonical} + `; } }); q.enqueue(async () => { From 11dd9bd6f751c14a47fb78252a9fedf4a20620a3 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 12 May 2026 16:27:44 -0600 Subject: [PATCH 20/32] start wiring balances query --- ...8599015338_principal-tx-balance-changes.ts | 138 +++++++++++++++++- src/api/routes/v3/principals.ts | 45 +++++- src/api/schemas/v3/entities/common.ts | 18 ++- .../v3/entities/principal-balance-changes.ts | 43 ++++++ .../v3/entities/principal-transactions.ts | 18 +-- src/api/serializers/transactions.ts | 50 ++++++- src/datastore/v3/pg-store-v3.ts | 44 +++++- src/datastore/v3/types.ts | 19 ++- 8 files changed, 341 insertions(+), 34 deletions(-) create mode 100644 src/api/schemas/v3/entities/principal-balance-changes.ts diff --git a/migrations/1778599015338_principal-tx-balance-changes.ts b/migrations/1778599015338_principal-tx-balance-changes.ts index 571be24da..953a2c721 100644 --- a/migrations/1778599015338_principal-tx-balance-changes.ts +++ b/migrations/1778599015338_principal-tx-balance-changes.ts @@ -58,6 +58,133 @@ export function up(pgm: MigrationBuilder) { }, }); + pgm.addColumn('principal_txs', { + balance_change_count: { + type: 'integer', + notNull: true, + default: 0, + }, + }); + + // Backfill `principal_tx_balance_changes` from existing event/tx tables. This must mirror + // the write path in `PgWriteStore.updatePrincipalTxs` exactly: + // - The tx fee always contributes an STX `sent` row from the fee payer + // (sponsor if sponsored, otherwise the sender). + // - For STX/FT/NFT events, the sender contributes `sent` and the recipient contributes + // `received`. NFT events count tokens moved (1 per event), matching the `numeric` + // `sent`/`received` semantics of this table. + // The event-table CHECK constraints guarantee `sender IS NULL` on mints and + // `recipient IS NULL` on burns, so the `IS NOT NULL` filters are sufficient — no need to + // also gate on `asset_event_type_id`. + pgm.sql(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, + SUM(sent) AS sent, + SUM(received) AS received + FROM ( + -- Tx fee paid by sponsor (if sponsored) or sender. + SELECT + COALESCE(sponsor_address, sender_address) AS principal, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 1::smallint AS asset_type, + 'stx'::text AS asset_identifier, + fee_rate::numeric AS sent, + 0::numeric AS received + FROM txs + UNION ALL + -- STX sender side (transfer + burn). + SELECT + sender AS principal, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 1::smallint, 'stx'::text, + amount::numeric, 0::numeric + FROM stx_events + WHERE sender IS NOT NULL + UNION ALL + -- STX recipient side (transfer + mint). + SELECT + recipient AS principal, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 1::smallint, 'stx'::text, + 0::numeric, amount::numeric + FROM stx_events + WHERE recipient IS NOT NULL + UNION ALL + -- FT sender side. + SELECT + sender AS principal, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 2::smallint, asset_identifier, + amount::numeric, 0::numeric + FROM ft_events + WHERE sender IS NOT NULL + UNION ALL + -- FT recipient side. + SELECT + recipient AS principal, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 2::smallint, asset_identifier, + 0::numeric, amount::numeric + FROM ft_events + WHERE recipient IS NOT NULL + UNION ALL + -- NFT sender side, counted as 1 token per event. + SELECT + sender AS principal, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 3::smallint, asset_identifier, + 1::numeric, 0::numeric + FROM nft_events + WHERE sender IS NOT NULL + UNION ALL + -- NFT recipient side, counted as 1 token per event. + SELECT + recipient AS principal, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 3::smallint, asset_identifier, + 0::numeric, 1::numeric + FROM nft_events + WHERE recipient IS NOT NULL + ) AS src + GROUP BY + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier + ON CONFLICT ON CONSTRAINT unique_principal_tx_balance_changes DO NOTHING; + `); + + // Backfill the newly added `principal_txs.balance_change_count` from the rows we just + // inserted, so the column reflects reality instead of the column default of 0. + pgm.sql(` + WITH counts AS ( + SELECT principal, tx_id, index_block_hash, microblock_hash, + COUNT(*)::integer AS cnt + FROM principal_tx_balance_changes + GROUP BY principal, tx_id, index_block_hash, microblock_hash + ) + UPDATE principal_txs AS pt + SET balance_change_count = c.cnt + FROM counts AS c + WHERE pt.principal = c.principal + AND pt.tx_id = c.tx_id + AND pt.index_block_hash = c.index_block_hash + AND pt.microblock_hash = c.microblock_hash; + `); + pgm.addConstraint( 'principal_tx_balance_changes', 'unique_principal_tx_balance_changes', @@ -67,12 +194,9 @@ export function up(pgm: MigrationBuilder) { pgm.createIndex('principal_tx_balance_changes', 'tx_id'); pgm.createIndex('principal_tx_balance_changes', ['index_block_hash', 'canonical']); pgm.createIndex('principal_tx_balance_changes', 'microblock_hash'); +} - pgm.addColumn('principal_txs', { - balance_change_count: { - type: 'integer', - notNull: true, - default: 0, - }, - }); +export function down(pgm: MigrationBuilder) { + pgm.dropTable('principal_tx_balance_changes'); + pgm.dropColumn('principal_txs', 'balance_change_count'); } diff --git a/src/api/routes/v3/principals.ts b/src/api/routes/v3/principals.ts index 8d38bf7cc..61a619ed0 100644 --- a/src/api/routes/v3/principals.ts +++ b/src/api/routes/v3/principals.ts @@ -3,10 +3,14 @@ import { FastifyPluginAsync } from 'fastify'; import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { Server } from 'node:http'; import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; -import { PrincipalSchema } from '../../schemas/v3/entities/common.js'; +import { PrincipalSchema, TransactionIdSchema } from '../../schemas/v3/entities/common.js'; import { CursorPaginationQuerystring, CursorPaginatedResponse } from '../../schemas/v3/params.js'; import { PrincipalTransactionSummarySchema } from '../../schemas/v3/entities/principal-transactions.js'; -import { parsePrincipalTransactionSummary } from '../../serializers/transactions.js'; +import { + parsePrincipalTransactionBalanceChange, + parsePrincipalTransactionSummary, +} from '../../serializers/transactions.js'; +import { PrincipalTransactionBalanceChangeSchema } from '../../schemas/v3/entities/principal-balance-changes.js'; export const PrincipalsRoutes: FastifyPluginAsync< Record, @@ -48,5 +52,42 @@ export const PrincipalsRoutes: FastifyPluginAsync< } ); + fastify.get( + '/principals/:principal/transactions/:tx_id/balance-changes', + { + // TODO: Etag should really be based on both the transaction id and principal. + preHandler: handlePrincipalCache, + schema: { + operationId: 'get_principal_transaction_balance_changes', + summary: 'Get principal transaction balance changes', + description: `Returns the balance changes for a principal's transaction`, + tags: ['Transactions'], + params: Type.Object({ principal: PrincipalSchema, tx_id: TransactionIdSchema }), + querystring: CursorPaginationQuerystring(ResourceType.Tx, Type.String()), + response: { + 200: CursorPaginatedResponse(PrincipalTransactionBalanceChangeSchema), + }, + }, + }, + async (req, reply) => { + const results = await fastify.db.v3.getPrincipalTransactionBalanceChanges({ + principal: req.params.principal, + tx_id: req.params.tx_id, + limit: req.query.limit ?? getPagingQueryLimit(ResourceType.Tx), + cursor: req.query.cursor, + }); + await reply.send({ + limit: results.limit, + total: results.total, + cursor: { + next: results.next_cursor, + previous: results.prev_cursor, + current: results.current_cursor, + }, + results: results.results.map(r => parsePrincipalTransactionBalanceChange(r)), + }); + } + ); + await Promise.resolve(); }; diff --git a/src/api/schemas/v3/entities/common.ts b/src/api/schemas/v3/entities/common.ts index 885c5ab22..d371c8747 100644 --- a/src/api/schemas/v3/entities/common.ts +++ b/src/api/schemas/v3/entities/common.ts @@ -1,24 +1,32 @@ import { Static, Type } from '@sinclair/typebox'; -export const AddressParamSchema = Type.String({ +export const AddressSchema = Type.String({ pattern: '^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}', title: 'Stacks Address', description: 'Stacks Address', examples: ['SP318Q55DEKHRXJK696033DQN5C54D9K2EE6DHRWP'], }); -export type Address = Static; +export type Address = Static; -export const SmartContractIdParamSchema = Type.String({ +export const SmartContractIdSchema = Type.String({ pattern: '^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39}$', title: 'Smart Contract ID', description: 'Smart Contract ID', examples: ['SP000000000000000000002Q6VF78.pox-3'], }); -export type SmartContractId = Static; +export type SmartContractId = Static; -export const PrincipalSchema = Type.Union([AddressParamSchema, SmartContractIdParamSchema]); +export const PrincipalSchema = Type.Union([AddressSchema, SmartContractIdSchema]); export type Principal = Static; +export const TransactionIdSchema = Type.String({ + pattern: '^(0x)?[a-fA-F0-9]{64}$', + title: 'Transaction ID', + description: 'Transaction ID', + examples: ['0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382a7e4b6a13df3dd7a91c6'], +}); +export type TransactionId = Static; + export const DecodedClarityValueSchema = Type.Object({ hex: Type.String(), repr: Type.String(), diff --git a/src/api/schemas/v3/entities/principal-balance-changes.ts b/src/api/schemas/v3/entities/principal-balance-changes.ts new file mode 100644 index 000000000..5798cc7e0 --- /dev/null +++ b/src/api/schemas/v3/entities/principal-balance-changes.ts @@ -0,0 +1,43 @@ +import { Static, Type } from '@sinclair/typebox'; +import { TransactionIdSchema } from './common.js'; + +export const BalanceChangeSchema = Type.Object({ + sent: Type.String({ + description: 'Amount sent by the principal', + }), + received: Type.String({ + description: 'Amount received by the principal', + }), + net: Type.String({ + description: 'Net balance change for the principal', + }), +}); +export type BalanceChange = Static; + +export const PrincipalTransactionBalanceChangeSchema = Type.Object({ + asset: Type.Union([ + Type.Object({ + type: Type.Literal('stx'), + }), + Type.Object({ + type: Type.Union([Type.Literal('ft'), Type.Literal('nft')], { + description: 'The asset type that was affected by the balance change.', + }), + identifier: Type.String({ + description: 'The identifier of the asset that was affected by the balance change.', + }), + }), + ]), + balance_change: BalanceChangeSchema, +}); +export type PrincipalTransactionBalanceChange = Static< + typeof PrincipalTransactionBalanceChangeSchema +>; + +export const PrincipalBalanceChangeSchema = Type.Composite([ + Type.Object({ + tx_id: TransactionIdSchema, + }), + PrincipalTransactionBalanceChangeSchema, +]); +export type PrincipalBalanceChange = Static; diff --git a/src/api/schemas/v3/entities/principal-transactions.ts b/src/api/schemas/v3/entities/principal-transactions.ts index 279ccac46..0e3d9b910 100644 --- a/src/api/schemas/v3/entities/principal-transactions.ts +++ b/src/api/schemas/v3/entities/principal-transactions.ts @@ -1,20 +1,6 @@ import { Static, Type } from '@sinclair/typebox'; import { TransactionSummarySchema } from './transaction-summaries.js'; - -export const PrincipalTransactionBalanceChangeSchema = Type.Object({ - sent: Type.String({ - description: 'Amount sent by the principal', - }), - received: Type.String({ - description: 'Amount received by the principal', - }), - net: Type.String({ - description: 'Net balance change for the principal', - }), -}); -export type PrincipalTransactionBalanceChange = Static< - typeof PrincipalTransactionBalanceChangeSchema ->; +import { BalanceChangeSchema } from './principal-balance-changes.js'; export const PrincipalTransactionSummarySchema = Type.Object({ transaction: TransactionSummarySchema, @@ -25,7 +11,7 @@ export const PrincipalTransactionSummarySchema = Type.Object({ } ), balance_changes: Type.Object({ - stx: PrincipalTransactionBalanceChangeSchema, + stx: BalanceChangeSchema, }), affected_asset_types: Type.Object({ stx: Type.Boolean({ diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts index 9a28ea428..ed16240cf 100644 --- a/src/api/serializers/transactions.ts +++ b/src/api/serializers/transactions.ts @@ -9,10 +9,15 @@ import { TransactionSummary, TransactionStatus, } from '../schemas/v3/entities/transaction-summaries.js'; -import { DbPrincipalTransactionSummary, DbTransactionSummary } from '../../datastore/v3/types.js'; -import { DbTxStatus, DbTxTypeId } from '../../datastore/common.js'; +import { + DbPrincipalTransactionBalanceChange, + DbPrincipalTransactionSummary, + DbTransactionSummary, +} from '../../datastore/v3/types.js'; +import { DbAssetType, DbTxStatus, DbTxTypeId } from '../../datastore/common.js'; import { getTxTenureChangeCauseString } from '../controllers/db-controller.js'; import { PrincipalTransactionSummary } from '../schemas/v3/entities/principal-transactions.js'; +import { PrincipalTransactionBalanceChange } from '../schemas/v3/entities/principal-balance-changes.js'; /** * Parses a database transaction summary status into a transaction summary status. @@ -157,3 +162,44 @@ export function parsePrincipalTransactionSummary( }, }; } + +function parseAssetType(type: DbAssetType): 'stx' | 'ft' | 'nft' { + switch (type) { + case DbAssetType.Stx: + return 'stx'; + case DbAssetType.Ft: + return 'ft'; + case DbAssetType.Nft: + return 'nft'; + default: + throw new Error(`Unexpected DbAssetType: ${type}`); + } +} + +/** + * Parses a database principal transaction balance change into a principal transaction balance + * change. + * @param change - The database principal transaction balance change to parse. + * @returns The parsed principal transaction balance change. + */ +export function parsePrincipalTransactionBalanceChange( + change: DbPrincipalTransactionBalanceChange +): PrincipalTransactionBalanceChange { + const assetType = parseAssetType(change.asset_type); + return { + asset: + assetType === 'stx' + ? { + type: 'stx', + } + : { + type: assetType, + identifier: change.asset_identifier, + }, + balance_change: { + sent: change.sent, + received: change.received, + net: change.net, + }, + }; +} diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 27c1038a7..29918bd35 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -1,5 +1,9 @@ import { BasePgStoreModule } from '@stacks/api-toolkit'; -import { DbCursorPaginatedResult, DbPrincipalTransactionSummary } from './types.js'; +import { + DbCursorPaginatedResult, + DbPrincipalTransactionBalanceChange, + DbPrincipalTransactionSummary, +} from './types.js'; import { TX_SUMMARY_COLUMNS } from './constants.js'; import { prefixedCols } from '../helpers.js'; @@ -103,4 +107,42 @@ export class PgStoreV3 extends BasePgStoreModule { }; }); } + + async getPrincipalTransactionBalanceChanges(args: { + principal: string; + tx_id: string; + limit: number; + cursor?: string; + }): Promise> { + return await this.sqlTransaction(async sql => { + const results = await sql<(DbPrincipalTransactionBalanceChange & { total: number })[]>` + WITH total AS ( + SELECT balance_change_count + FROM principal_txs + WHERE principal = ${args.principal} + AND tx_id = ${args.tx_id} + AND canonical = true + AND microblock_canonical = true + ) + SELECT *, + (sent - received) AS net, + (SELECT balance_change_count FROM total) AS total + FROM principal_tx_balance_changes + WHERE principal = ${args.principal} + AND tx_id = ${args.tx_id} + AND canonical = true + AND microblock_canonical = true + ORDER BY asset_type ASC, asset_identifier ASC + `; + + return { + limit: args.limit, + next_cursor: nextCursor, + prev_cursor: prevCursor, + current_cursor: currentCursor, + total: results[0]?.total ?? 0, + results, + } + }); + } } diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index 2f00035ee..dbaf8ba6a 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -1,4 +1,4 @@ -import { DbTxStatus, DbTxTypeId } from '../common.js'; +import { DbAssetType, DbTxStatus, DbTxTypeId } from '../common.js'; export type DbCursorPaginatedResult = { limit: number; @@ -49,3 +49,20 @@ export interface DbPrincipalTransactionSummary extends DbTransactionSummary { nft_balance_affected: boolean; involvement: DbPrincipalTransactionInvolvement; } + +export interface DbPrincipalTransactionBalanceChange { + principal: string; + tx_id: string; + block_height: number; + index_block_hash: string; + microblock_hash: string; + microblock_sequence: number; + tx_index: number; + canonical: boolean; + microblock_canonical: boolean; + asset_type: DbAssetType; + asset_identifier: string; + sent: string; + received: string; + net: string; +} From d9911eebe2a8d2c26768aaec8fab940963b5708c Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 12 May 2026 16:47:01 -0600 Subject: [PATCH 21/32] cursor pagination --- src/api/routes/v3/principals.ts | 16 +++++-- src/api/schemas/v3/params.ts | 16 +++++++ src/datastore/v3/constants.ts | 16 +++++++ src/datastore/v3/pg-store-v3.ts | 80 ++++++++++++++++++++++++++++++--- 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/src/api/routes/v3/principals.ts b/src/api/routes/v3/principals.ts index 61a619ed0..8fc14e205 100644 --- a/src/api/routes/v3/principals.ts +++ b/src/api/routes/v3/principals.ts @@ -4,7 +4,12 @@ import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { Server } from 'node:http'; import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; import { PrincipalSchema, TransactionIdSchema } from '../../schemas/v3/entities/common.js'; -import { CursorPaginationQuerystring, CursorPaginatedResponse } from '../../schemas/v3/params.js'; +import { + CursorPaginationQuerystring, + CursorPaginatedResponse, + TransactionCursorSchema, + PrincipalTransactionBalanceChangeCursorSchema, +} from '../../schemas/v3/params.js'; import { PrincipalTransactionSummarySchema } from '../../schemas/v3/entities/principal-transactions.js'; import { parsePrincipalTransactionBalanceChange, @@ -27,14 +32,14 @@ export const PrincipalsRoutes: FastifyPluginAsync< description: `Returns a list of confirmed transactions sent or received by a Stacks principal`, tags: ['Transactions'], params: Type.Object({ principal: PrincipalSchema }), - querystring: CursorPaginationQuerystring(ResourceType.Tx, Type.String()), + querystring: CursorPaginationQuerystring(ResourceType.Tx, TransactionCursorSchema), response: { 200: CursorPaginatedResponse(PrincipalTransactionSummarySchema), }, }, }, async (req, reply) => { - const results = await fastify.db.v3.getPrincipalTransactionSummaryList({ + const results = await fastify.db.v3.getPrincipalTransactionSummaries({ principal: req.params.principal, limit: req.query.limit ?? getPagingQueryLimit(ResourceType.Tx), cursor: req.query.cursor, @@ -63,7 +68,10 @@ export const PrincipalsRoutes: FastifyPluginAsync< description: `Returns the balance changes for a principal's transaction`, tags: ['Transactions'], params: Type.Object({ principal: PrincipalSchema, tx_id: TransactionIdSchema }), - querystring: CursorPaginationQuerystring(ResourceType.Tx, Type.String()), + querystring: CursorPaginationQuerystring( + ResourceType.Tx, + PrincipalTransactionBalanceChangeCursorSchema + ), response: { 200: CursorPaginatedResponse(PrincipalTransactionBalanceChangeSchema), }, diff --git a/src/api/schemas/v3/params.ts b/src/api/schemas/v3/params.ts index 70ed591b4..d407ffdff 100644 --- a/src/api/schemas/v3/params.ts +++ b/src/api/schemas/v3/params.ts @@ -48,3 +48,19 @@ export const CursorPaginatedResponse = (type: T, options?: Ob }, options ); + +export const TransactionCursorSchema = Type.String({ + description: + 'Cursor for paginating transactions. Format: block_height:microblock_sequence:tx_index', + pattern: '^[0-9]+:[0-9]+:[0-9]+$', +}); + +export const PrincipalTransactionBalanceChangeCursorSchema = Type.String({ + description: + 'Cursor for paginating principal transaction balance changes. Format: ' + + '`:` where `asset_type` is a numeric tag ' + + '(1=STX, 2=FT, 3=NFT) and `asset_identifier` is `` for STX or a ' + + 'fully-qualified Clarity asset id such as `SP000…contract-name::asset-name` ' + + 'for FT/NFT.', + pattern: '^[0-9]+:\\S+$', +}); diff --git a/src/datastore/v3/constants.ts b/src/datastore/v3/constants.ts index 9bb221a27..8f7cf7650 100644 --- a/src/datastore/v3/constants.ts +++ b/src/datastore/v3/constants.ts @@ -50,3 +50,19 @@ export const TX_COLUMNS = [ 'tenure_change_previous_tenure_blocks', 'tenure_change_pubkey_hash', ]; + +export const PRINCIPAL_TRANSACTION_BALANCE_CHANGE_COLUMNS = [ + 'principal', + 'tx_id', + 'block_height', + 'index_block_hash', + 'microblock_hash', + 'microblock_sequence', + 'tx_index', + 'canonical', + 'microblock_canonical', + 'asset_type', + 'asset_identifier', + 'sent', + 'received', +]; diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 29918bd35..a536584ad 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -4,11 +4,16 @@ import { DbPrincipalTransactionBalanceChange, DbPrincipalTransactionSummary, } from './types.js'; -import { TX_SUMMARY_COLUMNS } from './constants.js'; +import { PRINCIPAL_TRANSACTION_BALANCE_CHANGE_COLUMNS, TX_SUMMARY_COLUMNS } from './constants.js'; import { prefixedCols } from '../helpers.js'; export class PgStoreV3 extends BasePgStoreModule { - async getPrincipalTransactionSummaryList(args: { + /** + * Gets the summaries for a principal's transactions. + * @param args - The arguments for the query. + * @returns The summaries for the principal's transactions. + */ + async getPrincipalTransactionSummaries(args: { principal: string; limit: number; cursor?: string; @@ -108,6 +113,11 @@ export class PgStoreV3 extends BasePgStoreModule { }); } + /** + * Gets the balance changes for a principal's transaction. + * @param args - The arguments for the query. + * @returns The balance changes for the principal's transaction. + */ async getPrincipalTransactionBalanceChanges(args: { principal: string; tx_id: string; @@ -115,7 +125,25 @@ export class PgStoreV3 extends BasePgStoreModule { cursor?: string; }): Promise> { return await this.sqlTransaction(async sql => { - const results = await sql<(DbPrincipalTransactionBalanceChange & { total: number })[]>` + // Cursor format: `${asset_type}:${asset_identifier}`. We split on the *first* colon + // only because FT/NFT asset identifiers contain `::` internally (e.g. + // `SP000…contract-name::asset-name`); a naive split would over-split. The cursor is + // inclusive and points at the first row of the current page, matching the convention + // used by `getPrincipalTransactionSummaryList`. + let cursorFilter = sql``; + if (args.cursor) { + const colonIdx = args.cursor.indexOf(':'); + if (colonIdx > 0) { + const cursorAssetType = parseInt(args.cursor.substring(0, colonIdx), 10); + const cursorAssetIdentifier = args.cursor.substring(colonIdx + 1); + cursorFilter = sql` + AND (asset_type, asset_identifier) + >= (${cursorAssetType}, ${cursorAssetIdentifier}) + `; + } + } + + const resultQuery = await sql<(DbPrincipalTransactionBalanceChange & { total: number })[]>` WITH total AS ( SELECT balance_change_count FROM principal_txs @@ -124,25 +152,63 @@ export class PgStoreV3 extends BasePgStoreModule { AND canonical = true AND microblock_canonical = true ) - SELECT *, - (sent - received) AS net, + SELECT ${sql(PRINCIPAL_TRANSACTION_BALANCE_CHANGE_COLUMNS)}, + (received - sent) AS net, (SELECT balance_change_count FROM total) AS total FROM principal_tx_balance_changes WHERE principal = ${args.principal} AND tx_id = ${args.tx_id} AND canonical = true AND microblock_canonical = true + ${cursorFilter} ORDER BY asset_type ASC, asset_identifier ASC + LIMIT ${args.limit + 1} `; + const hasNextPage = resultQuery.count > args.limit; + const results = hasNextPage ? resultQuery.slice(0, args.limit) : resultQuery; + const total = resultQuery.count > 0 ? resultQuery[0].total : 0; + + const peekResult = resultQuery[resultQuery.length - 1]; + const nextCursor = + hasNextPage && peekResult + ? `${peekResult.asset_type}:${peekResult.asset_identifier}` + : null; + + const firstResult = results[0]; + const currentCursor = firstResult + ? `${firstResult.asset_type}:${firstResult.asset_identifier}` + : null; + + let prevCursor: string | null = null; + if (firstResult) { + const prevPageQuery = await sql<{ asset_type: number; asset_identifier: string }[]>` + SELECT asset_type, asset_identifier + FROM principal_tx_balance_changes + WHERE principal = ${args.principal} + AND tx_id = ${args.tx_id} + AND canonical = true + AND microblock_canonical = true + AND (asset_type, asset_identifier) + < (${firstResult.asset_type}, ${firstResult.asset_identifier}) + ORDER BY asset_type DESC, asset_identifier DESC + OFFSET ${args.limit - 1} + LIMIT 1 + `; + if (prevPageQuery.length > 0) { + const prevPage = prevPageQuery[0]; + prevCursor = `${prevPage.asset_type}:${prevPage.asset_identifier}`; + } + } + return { limit: args.limit, next_cursor: nextCursor, prev_cursor: prevCursor, current_cursor: currentCursor, - total: results[0]?.total ?? 0, + total, results, - } + }; }); } } From 9f6fac28478850ea72a7941a2f68551f0facd38d Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Wed, 13 May 2026 09:48:37 -0600 Subject: [PATCH 22/32] more tests --- .vscode/launch.json | 12 +- ...8599015338_principal-tx-balance-changes.ts | 1 - src/api/routes/v3/principals.ts | 74 +++- src/api/schemas/v3/params.ts | 7 + src/api/serializers/transactions.ts | 36 +- src/datastore/v3/pg-store-v3.ts | 149 +++++++ tests/api/test-helpers.ts | 4 + tests/api/v3/principals.test.ts | 382 ++++++++++++------ 8 files changed, 525 insertions(+), 140 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 37f0a025c..bde70ff4a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,6 +8,7 @@ "type": "node", "request": "launch", "name": "start: api", + "runtimeVersion": "24", "runtimeArgs": ["--import", "tsx"], "args": ["${workspaceFolder}/src/index.ts"], "outputCapture": "std", @@ -23,6 +24,7 @@ "skipFiles": [ "/**" ], + "runtimeVersion": "24", "runtimeArgs": ["--import", "tsx"], "args": ["${workspaceFolder}/src/index.ts"], "outputCapture": "std", @@ -42,6 +44,7 @@ "skipFiles": [ "/**" ], + "runtimeVersion": "24", "runtimeArgs": ["--import", "tsx"], "args": ["${workspaceFolder}/src/index.ts"], "outputCapture": "std", @@ -62,6 +65,7 @@ "skipFiles": [ "/**" ], + "runtimeVersion": "24", "runtimeArgs": ["--import", "tsx"], "args": [ "${workspaceFolder}/src/index.ts" @@ -80,14 +84,14 @@ "type": "node", "request": "launch", "name": "test: api", - "runtimeExecutable": "node", + "runtimeVersion": "24", "args": [ "--import", "tsx", "--test", "--test-global-setup=./tests/api/setup.ts", "--test-concurrency=1", - "./tests/api/**/*.test.ts" + "./tests/api/v3/**/*.test.ts" ], "outputCapture": "std", "console": "integratedTerminal", @@ -99,7 +103,7 @@ "type": "node", "request": "launch", "name": "test: krypton", - "runtimeExecutable": "node", + "runtimeVersion": "24", "args": [ "--import", "tsx", @@ -118,7 +122,7 @@ "type": "node", "request": "launch", "name": "test: snp", - "runtimeExecutable": "node", + "runtimeVersion": "24", "args": [ "--import", "tsx", diff --git a/migrations/1778599015338_principal-tx-balance-changes.ts b/migrations/1778599015338_principal-tx-balance-changes.ts index 953a2c721..5f0e75b30 100644 --- a/migrations/1778599015338_principal-tx-balance-changes.ts +++ b/migrations/1778599015338_principal-tx-balance-changes.ts @@ -164,7 +164,6 @@ export function up(pgm: MigrationBuilder) { principal, tx_id, block_height, index_block_hash, microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical, asset_type, asset_identifier - ON CONFLICT ON CONSTRAINT unique_principal_tx_balance_changes DO NOTHING; `); // Backfill the newly added `principal_txs.balance_change_count` from the rows we just diff --git a/src/api/routes/v3/principals.ts b/src/api/routes/v3/principals.ts index 8fc14e205..1488d5cf4 100644 --- a/src/api/routes/v3/principals.ts +++ b/src/api/routes/v3/principals.ts @@ -2,20 +2,25 @@ import { handlePrincipalCache } from '../../controllers/cache-controller.js'; import { FastifyPluginAsync } from 'fastify'; import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { Server } from 'node:http'; -import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; +import { getPagingQueryLimit, pagingQueryLimits, ResourceType } from '../../pagination.js'; import { PrincipalSchema, TransactionIdSchema } from '../../schemas/v3/entities/common.js'; import { CursorPaginationQuerystring, CursorPaginatedResponse, TransactionCursorSchema, PrincipalTransactionBalanceChangeCursorSchema, + PrincipalBalanceChangeCursorSchema, } from '../../schemas/v3/params.js'; import { PrincipalTransactionSummarySchema } from '../../schemas/v3/entities/principal-transactions.js'; import { - parsePrincipalTransactionBalanceChange, - parsePrincipalTransactionSummary, + serializePrincipalBalanceChange, + serializePrincipalTransactionBalanceChange, + serializePrincipalTransactionSummary, } from '../../serializers/transactions.js'; -import { PrincipalTransactionBalanceChangeSchema } from '../../schemas/v3/entities/principal-balance-changes.js'; +import { + PrincipalBalanceChangeSchema, + PrincipalTransactionBalanceChangeSchema, +} from '../../schemas/v3/entities/principal-balance-changes.js'; export const PrincipalsRoutes: FastifyPluginAsync< Record, @@ -52,7 +57,7 @@ export const PrincipalsRoutes: FastifyPluginAsync< previous: results.prev_cursor, current: results.current_cursor, }, - results: results.results.map(r => parsePrincipalTransactionSummary(r)), + results: results.results.map(r => serializePrincipalTransactionSummary(r)), }); } ); @@ -92,7 +97,64 @@ export const PrincipalsRoutes: FastifyPluginAsync< previous: results.prev_cursor, current: results.current_cursor, }, - results: results.results.map(r => parsePrincipalTransactionBalanceChange(r)), + results: results.results.map(r => serializePrincipalTransactionBalanceChange(r)), + }); + } + ); + + fastify.get( + '/principals/:principal/balance-changes', + { + preHandler: handlePrincipalCache, + // Accept both repeated (`?tx_id=A&tx_id=B`) and comma-separated (`?tx_id=A,B`) forms. + // The repeated form is already an array via Fastify's qs parser; this hook normalizes + // the comma-separated form. Mirrors the convention used by `/extended/v1/tx/multiple`. + preValidation: (req, _reply, done) => { + if (typeof req.query.tx_id === 'string') { + req.query.tx_id = (req.query.tx_id as string).split(',') as typeof req.query.tx_id; + } + done(); + }, + schema: { + operationId: 'get_principal_balance_changes', + summary: 'Get principal balance changes', + description: `Returns the balance changes for a principal across one or more transactions, as a single paginated flat array ordered by chain position descending then by asset.`, + tags: ['Transactions'], + params: Type.Object({ principal: PrincipalSchema }), + querystring: Type.Composite([ + CursorPaginationQuerystring(ResourceType.Tx, PrincipalBalanceChangeCursorSchema), + Type.Object({ + tx_id: Type.Array(TransactionIdSchema, { + minItems: 1, + maxItems: pagingQueryLimits[ResourceType.Tx].maxLimit, + description: + 'Transaction IDs to query balance changes for. Provide as repeated ' + + 'querystring values (`?tx_id=A&tx_id=B`) or as a single comma-separated ' + + 'value (`?tx_id=A,B`).', + }), + }), + ]), + response: { + 200: CursorPaginatedResponse(PrincipalBalanceChangeSchema), + }, + }, + }, + async (req, reply) => { + const results = await fastify.db.v3.getPrincipalBalanceChanges({ + principal: req.params.principal, + tx_ids: req.query.tx_id, + limit: req.query.limit ?? getPagingQueryLimit(ResourceType.Tx), + cursor: req.query.cursor, + }); + await reply.send({ + limit: results.limit, + total: results.total, + cursor: { + next: results.next_cursor, + previous: results.prev_cursor, + current: results.current_cursor, + }, + results: results.results.map(r => serializePrincipalBalanceChange(r)), }); } ); diff --git a/src/api/schemas/v3/params.ts b/src/api/schemas/v3/params.ts index d407ffdff..639bdda45 100644 --- a/src/api/schemas/v3/params.ts +++ b/src/api/schemas/v3/params.ts @@ -64,3 +64,10 @@ export const PrincipalTransactionBalanceChangeCursorSchema = Type.String({ 'for FT/NFT.', pattern: '^[0-9]+:\\S+$', }); + +export const PrincipalBalanceChangeCursorSchema = Type.String({ + description: + 'Cursor for paginating principal balance changes across multiple transactions. ' + + 'Format: `::::`.', + pattern: '^[0-9]+:[0-9]+:[0-9]+:[0-9]+:\\S+$', +}); diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts index ed16240cf..4681531b1 100644 --- a/src/api/serializers/transactions.ts +++ b/src/api/serializers/transactions.ts @@ -17,14 +17,17 @@ import { import { DbAssetType, DbTxStatus, DbTxTypeId } from '../../datastore/common.js'; import { getTxTenureChangeCauseString } from '../controllers/db-controller.js'; import { PrincipalTransactionSummary } from '../schemas/v3/entities/principal-transactions.js'; -import { PrincipalTransactionBalanceChange } from '../schemas/v3/entities/principal-balance-changes.js'; +import { + PrincipalBalanceChange, + PrincipalTransactionBalanceChange, +} from '../schemas/v3/entities/principal-balance-changes.js'; /** * Parses a database transaction summary status into a transaction summary status. * @param status - The database transaction status. * @returns The parsed transaction summary status. */ -function parseDbTransactionSummaryStatus(status: DbTxStatus): TransactionStatus { +function serializeDbTransactionSummaryStatus(status: DbTxStatus): TransactionStatus { switch (status) { case DbTxStatus.AbortByResponse: return 'abort_by_response'; @@ -42,7 +45,7 @@ function parseDbTransactionSummaryStatus(status: DbTxStatus): TransactionStatus * @param summary - The database transaction summary to parse. * @returns The parsed transaction summary. */ -export function parseDbTransactionSummary(summary: DbTransactionSummary): TransactionSummary { +export function serializeDbTransactionSummary(summary: DbTransactionSummary): TransactionSummary { const result: BaseTransactionSummary = { tx_id: summary.tx_id, sender: { @@ -68,7 +71,7 @@ export function parseDbTransactionSummary(summary: DbTransactionSummary): Transa height: summary.burn_block_height, time: summary.burn_block_time, }, - status: parseDbTransactionSummaryStatus(summary.status), + status: serializeDbTransactionSummaryStatus(summary.status), }; switch (summary.type_id) { case DbTxTypeId.TokenTransfer: { @@ -142,11 +145,11 @@ export function parseDbTransactionSummary(summary: DbTransactionSummary): Transa * @param summary - The database principal transaction summary to parse. * @returns The parsed principal transaction summary. */ -export function parsePrincipalTransactionSummary( +export function serializePrincipalTransactionSummary( summary: DbPrincipalTransactionSummary ): PrincipalTransactionSummary { return { - transaction: parseDbTransactionSummary(summary), + transaction: serializeDbTransactionSummary(summary), involvement: summary.involvement, balance_changes: { stx: { @@ -163,7 +166,7 @@ export function parsePrincipalTransactionSummary( }; } -function parseAssetType(type: DbAssetType): 'stx' | 'ft' | 'nft' { +function serializeAssetType(type: DbAssetType): 'stx' | 'ft' | 'nft' { switch (type) { case DbAssetType.Stx: return 'stx'; @@ -182,10 +185,10 @@ function parseAssetType(type: DbAssetType): 'stx' | 'ft' | 'nft' { * @param change - The database principal transaction balance change to parse. * @returns The parsed principal transaction balance change. */ -export function parsePrincipalTransactionBalanceChange( +export function serializePrincipalTransactionBalanceChange( change: DbPrincipalTransactionBalanceChange ): PrincipalTransactionBalanceChange { - const assetType = parseAssetType(change.asset_type); + const assetType = serializeAssetType(change.asset_type); return { asset: assetType === 'stx' @@ -203,3 +206,18 @@ export function parsePrincipalTransactionBalanceChange( }, }; } + +/** + * Parses a database principal transaction balance change into a principal balance change + * (the flattened batch shape that carries `tx_id` alongside the asset and balance fields). + * @param change - The database principal transaction balance change to parse. + * @returns The parsed principal balance change. + */ +export function serializePrincipalBalanceChange( + change: DbPrincipalTransactionBalanceChange +): PrincipalBalanceChange { + return { + tx_id: change.tx_id, + ...serializePrincipalTransactionBalanceChange(change), + }; +} diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index a536584ad..1f5ca034f 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -211,4 +211,153 @@ export class PgStoreV3 extends BasePgStoreModule { }; }); } + + /** + * Gets the balance changes for a principal across a batch of transactions, paginated as a + * single flat array ordered by chain position DESC (newest tx first) then by asset + * (STX, FT, NFT) ASC within each tx. + * @param args - The arguments for the query. + * @returns The paginated balance changes for the principal across the given tx ids. + */ + async getPrincipalBalanceChanges(args: { + principal: string; + tx_ids: string[]; + limit: number; + cursor?: string; + }): Promise> { + return await this.sqlTransaction(async sql => { + // Cursor format: `${block_height}:${microblock_sequence}:${tx_index}:${asset_type}:${asset_identifier}`. + // We walk the first 4 colons manually and treat everything after as the asset_identifier, + // because FT/NFT asset_identifier values contain `::` internally — a naive `split(':')` + // would over-split. The cursor is inclusive and points at the first row of the current + // page. + // + // The page direction is mixed: DESC by chain position, ASC by asset within a tx. SQL row + // comparison can only express one direction at a time, so the "row >= cursor in page + // order" predicate is expressed as a two-branch OR. + let cursorFilter = sql``; + if (args.cursor) { + const parts: string[] = []; + let idx = 0; + let valid = true; + for (let i = 0; i < 4; i++) { + const next = args.cursor.indexOf(':', idx); + if (next === -1) { + valid = false; + break; + } + parts.push(args.cursor.substring(idx, next)); + idx = next + 1; + } + if (valid) { + parts.push(args.cursor.substring(idx)); + const blockHeight = parseInt(parts[0], 10); + const microblockSequence = parseInt(parts[1], 10); + const txIndex = parseInt(parts[2], 10); + const cursorAssetType = parseInt(parts[3], 10); + const cursorAssetIdentifier = parts[4]; + cursorFilter = sql` + AND ( + (block_height, microblock_sequence, tx_index) + < (${blockHeight}, ${microblockSequence}, ${txIndex}) + OR ( + (block_height, microblock_sequence, tx_index) + = (${blockHeight}, ${microblockSequence}, ${txIndex}) + AND (asset_type, asset_identifier) + >= (${cursorAssetType}, ${cursorAssetIdentifier}) + ) + ) + `; + } + } + + const resultQuery = await sql<(DbPrincipalTransactionBalanceChange & { total: number })[]>` + WITH total AS ( + SELECT COALESCE(SUM(balance_change_count)::int, 0) AS count + FROM principal_txs + WHERE principal = ${args.principal} + AND tx_id IN ${sql(args.tx_ids)} + AND canonical = true + AND microblock_canonical = true + ) + SELECT ${sql(PRINCIPAL_TRANSACTION_BALANCE_CHANGE_COLUMNS)}, + (received - sent) AS net, + (SELECT count FROM total) AS total + FROM principal_tx_balance_changes + WHERE principal = ${args.principal} + AND tx_id IN ${sql(args.tx_ids)} + AND canonical = true + AND microblock_canonical = true + ${cursorFilter} + ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, + asset_type ASC, asset_identifier ASC + LIMIT ${args.limit + 1} + `; + + const hasNextPage = resultQuery.count > args.limit; + const results = hasNextPage ? resultQuery.slice(0, args.limit) : resultQuery; + const total = resultQuery.count > 0 ? resultQuery[0].total : 0; + + const buildCursor = (row: DbPrincipalTransactionBalanceChange) => + `${row.block_height}:${row.microblock_sequence}:${row.tx_index}:${row.asset_type}:${row.asset_identifier}`; + + const peekResult = resultQuery[resultQuery.length - 1]; + const nextCursor = hasNextPage && peekResult ? buildCursor(peekResult) : null; + + const firstResult = results[0]; + const currentCursor = firstResult ? buildCursor(firstResult) : null; + + // Previous page: rows that come BEFORE firstResult in the forward direction. In our + // mixed DESC/ASC order that means a chain position later than firstResult, or the + // same tx with an earlier asset. Ordered in reverse direction (ASC chain + DESC + // asset) and offset by `limit - 1` so the returned row is the first row of the + // previous page. + let prevCursor: string | null = null; + if (firstResult) { + const prevPageQuery = await sql< + { + block_height: number; + microblock_sequence: number; + tx_index: number; + asset_type: number; + asset_identifier: string; + }[] + >` + SELECT block_height, microblock_sequence, tx_index, asset_type, asset_identifier + FROM principal_tx_balance_changes + WHERE principal = ${args.principal} + AND tx_id IN ${sql(args.tx_ids)} + AND canonical = true + AND microblock_canonical = true + AND ( + (block_height, microblock_sequence, tx_index) + > (${firstResult.block_height}, ${firstResult.microblock_sequence}, ${firstResult.tx_index}) + OR ( + (block_height, microblock_sequence, tx_index) + = (${firstResult.block_height}, ${firstResult.microblock_sequence}, ${firstResult.tx_index}) + AND (asset_type, asset_identifier) + < (${firstResult.asset_type}, ${firstResult.asset_identifier}) + ) + ) + ORDER BY block_height ASC, microblock_sequence ASC, tx_index ASC, + asset_type DESC, asset_identifier DESC + OFFSET ${args.limit - 1} + LIMIT 1 + `; + if (prevPageQuery.length > 0) { + const prevPage = prevPageQuery[0]; + prevCursor = `${prevPage.block_height}:${prevPage.microblock_sequence}:${prevPage.tx_index}:${prevPage.asset_type}:${prevPage.asset_identifier}`; + } + } + + return { + limit: args.limit, + next_cursor: nextCursor, + prev_cursor: prevCursor, + current_cursor: currentCursor, + total, + results, + }; + }); + } } diff --git a/tests/api/test-helpers.ts b/tests/api/test-helpers.ts index 1ca3b878f..d4b120434 100644 --- a/tests/api/test-helpers.ts +++ b/tests/api/test-helpers.ts @@ -78,3 +78,7 @@ export function assertMatchesObject(actual: any, expected: any): void { } assert.deepEqual(actual, expected); } + +export function hex(value: number): string { + return `0x${value.toString(16).padStart(64, '0')}`; +} diff --git a/tests/api/v3/principals.test.ts b/tests/api/v3/principals.test.ts index 86e013558..f65d5327f 100644 --- a/tests/api/v3/principals.test.ts +++ b/tests/api/v3/principals.test.ts @@ -6,11 +6,19 @@ import { STACKS_TESTNET } from '@stacks/network'; import * as assert from 'node:assert/strict'; import { TestBlockBuilder } from '../test-builders.ts'; import { DbTxStatus, DbTxTypeId } from 'src/datastore/common.ts'; +import { hex } from '../test-helpers.ts'; describe('principals', () => { let db: PgWriteStore; let api: ApiServer; + const testAddr1 = 'ST3J8EVYHVKH6XXPD61EE8XEHW4Y2K83861225AB1'; + const testAddr2 = 'ST1HB64MAJ1MBV4CQ80GF01DZS4T1DSMX20ADCRA4'; + const testContractAddr = 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y.hello-world'; + const testAddr4 = 'ST3DWSXBPYDB484QXFTR81K4AWG4ZB5XZNFF3H70C'; + const emptyPrincipal = 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP2X'; + const emptyTxId = '0x0000000000000000000000000000000000000000000000000000000000000000'; + beforeEach(async () => { await migrate('up'); db = await PgWriteStore.connect({ @@ -19,6 +27,94 @@ describe('principals', () => { skipMigrations: true, }); api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + + // Setup test data + await db.update( + new TestBlockBuilder({ + block_height: 1, + block_hash: hex(1), + index_block_hash: hex(1), + parent_index_block_hash: hex(0), + parent_block_hash: hex(0), + }) + .addTx({ + tx_id: hex(1), + block_hash: hex(1), + index_block_hash: hex(1), + block_time: 1000, + burn_block_height: 1, + burn_block_time: 1000, + tx_index: 0, + fee_rate: 50n, + type_id: DbTxTypeId.Coinbase, + status: DbTxStatus.Success, + sender_address: testAddr1, + }) + .build() + ); + const block2 = new TestBlockBuilder({ + block_height: 2, + block_hash: hex(2), + index_block_hash: hex(2), + parent_index_block_hash: hex(1), + parent_block_hash: hex(1), + }); + let indexIdIndex = 0; + const createTx = ( + block: TestBlockBuilder, + sender: string, + recipient: string, + amount: number, + stxEventCount = 1, + ftEventCount = 1, + nftEventCount = 1 + ) => { + const tx_id = hex(indexIdIndex); + block.addTx({ + tx_id, + fee_rate: 50n, + block_hash: hex(2), + index_block_hash: hex(2), + block_time: 2000, + burn_block_height: 2, + burn_block_time: 2000, + type_id: DbTxTypeId.TokenTransfer, + status: DbTxStatus.Success, + sender_address: sender, + nonce: indexIdIndex, + }); + for (let i = 0; i < stxEventCount; i++) { + block.addTxStxEvent({ + amount: BigInt(amount), + recipient, + sender, + }); + } + for (let i = 0; i < ftEventCount; i++) { + block.addTxFtEvent({ + amount: BigInt(amount), + recipient, + sender, + }); + } + for (let i = 0; i < nftEventCount; i++) { + block.addTxNftEvent({ + recipient, + sender, + }); + } + indexIdIndex++; + }; + createTx(block2, testAddr4, testAddr2, 0, 1, 0, 0); + createTx(block2, testAddr4, testAddr2, 0, 0, 1, 0); + createTx(block2, testAddr4, testAddr2, 0, 0, 0, 1); + createTx(block2, testAddr1, testAddr2, 100_000, 1, 1, 1); + createTx(block2, testAddr2, testContractAddr, 100, 1, 2, 1); + createTx(block2, testAddr2, testContractAddr, 250, 1, 0, 1); + createTx(block2, testAddr2, testContractAddr, 40, 1, 1, 1); + createTx(block2, testContractAddr, testAddr4, 15, 1, 1, 0); + createTx(block2, testAddr2, testAddr4, 35, 3, 1, 2); + await db.update(block2.build()); }); afterEach(async () => { @@ -31,7 +127,7 @@ describe('principals', () => { test('should return an empty list', async () => { const response = await api.fastifyApp.inject({ method: 'GET', - url: '/extended/v3/principals/SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27/transactions', + url: `/extended/v3/principals/${emptyPrincipal}/transactions`, }); assert.equal(response.statusCode, 200); const body = JSON.parse(response.body); @@ -48,99 +144,6 @@ describe('principals', () => { }); test('should return a list of principal transaction summaries', async () => { - const testAddr1 = 'ST3J8EVYHVKH6XXPD61EE8XEHW4Y2K83861225AB1'; - const testAddr2 = 'ST1HB64MAJ1MBV4CQ80GF01DZS4T1DSMX20ADCRA4'; - const testContractAddr = 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y.hello-world'; - const testAddr4 = 'ST3DWSXBPYDB484QXFTR81K4AWG4ZB5XZNFF3H70C'; - - await db.update( - new TestBlockBuilder({ - block_height: 1, - block_hash: '0x0001', - index_block_hash: '0x0001', - parent_index_block_hash: '0x0000', - parent_block_hash: '0x0000', - }) - .addTx({ - tx_id: '0x0001', - block_hash: '0x0001', - index_block_hash: '0x0001', - block_time: 1000, - burn_block_height: 1, - burn_block_time: 1000, - tx_index: 0, - fee_rate: 50n, - type_id: DbTxTypeId.Coinbase, - status: DbTxStatus.Success, - sender_address: testAddr1, - }) - .build() - ); - const block2 = new TestBlockBuilder({ - block_height: 2, - block_hash: '0x0002', - index_block_hash: '0x0002', - parent_index_block_hash: '0x0001', - parent_block_hash: '0x0001', - }); - let indexIdIndex = 0; - const createTx = ( - block: TestBlockBuilder, - sender: string, - recipient: string, - amount: number, - stxEventCount = 1, - ftEventCount = 1, - nftEventCount = 1 - ) => { - const tx_id = `0x${indexIdIndex.toString(16).padStart(64, '0')}`; - block.addTx({ - tx_id, - fee_rate: 50n, - block_hash: '0x0002', - index_block_hash: '0x0002', - block_time: 2000, - burn_block_height: 2, - burn_block_time: 2000, - type_id: DbTxTypeId.TokenTransfer, - status: DbTxStatus.Success, - sender_address: sender, - nonce: indexIdIndex, - }); - for (let i = 0; i < stxEventCount; i++) { - block.addTxStxEvent({ - amount: BigInt(amount), - recipient, - sender, - }); - } - for (let i = 0; i < ftEventCount; i++) { - block.addTxFtEvent({ - amount: BigInt(amount), - recipient, - sender, - }); - } - for (let i = 0; i < nftEventCount; i++) { - block.addTxNftEvent({ - recipient, - sender, - }); - } - indexIdIndex++; - }; - createTx(block2, testAddr4, testAddr2, 0, 1, 0, 0); - createTx(block2, testAddr4, testAddr2, 0, 0, 1, 0); - createTx(block2, testAddr4, testAddr2, 0, 0, 0, 1); - createTx(block2, testAddr1, testAddr2, 100_000, 1, 1, 1); - createTx(block2, testAddr2, testContractAddr, 100, 1, 2, 1); - createTx(block2, testAddr2, testContractAddr, 250, 1, 0, 1); - createTx(block2, testAddr2, testContractAddr, 40, 1, 1, 1); - createTx(block2, testContractAddr, testAddr4, 15, 1, 1, 0); - createTx(block2, testAddr2, testAddr4, 35, 3, 1, 2); - await db.update(block2.build()); - - // Try for address 1 const response1 = await api.fastifyApp.inject({ method: 'GET', url: `/extended/v3/principals/${testAddr1}/transactions`, @@ -152,7 +155,7 @@ describe('principals', () => { assert.equal(body1.results.length, 2); assert.deepEqual(body1.results[0], { transaction: { - tx_id: '0x0000000000000000000000000000000000000000000000000000000000000003', + tx_id: hex(3), sender: { address: 'ST3J8EVYHVKH6XXPD61EE8XEHW4Y2K83861225AB1', nonce: 3, @@ -161,8 +164,8 @@ describe('principals', () => { fee_rate: '50', block: { height: 2, - hash: '0x0002', - index_hash: '0x0002', + hash: hex(2), + index_hash: hex(2), time: 2000, tx_index: 3, }, @@ -194,7 +197,7 @@ describe('principals', () => { }); assert.deepEqual(body1.results[1], { transaction: { - tx_id: '0x0001', + tx_id: hex(1), sender: { address: 'ST3J8EVYHVKH6XXPD61EE8XEHW4Y2K83861225AB1', nonce: 0, @@ -203,8 +206,8 @@ describe('principals', () => { fee_rate: '50', block: { height: 1, - hash: '0x0001', - index_hash: '0x0001', + hash: hex(1), + index_hash: hex(1), time: 1000, tx_index: 0, }, @@ -243,33 +246,32 @@ describe('principals', () => { assert.equal(body4.total, 5); assert.equal(body4.limit, 20); assert.equal(body4.results.length, 5); - assert.equal(body4.results[0].transaction.tx_id, '0x0000000000000000000000000000000000000000000000000000000000000008'); - assert.equal(body4.results[1].transaction.tx_id, '0x0000000000000000000000000000000000000000000000000000000000000007'); - assert.equal(body4.results[2].transaction.tx_id, '0x0000000000000000000000000000000000000000000000000000000000000002'); - assert.equal(body4.results[3].transaction.tx_id, '0x0000000000000000000000000000000000000000000000000000000000000001'); - assert.equal(body4.results[4].transaction.tx_id, '0x0000000000000000000000000000000000000000000000000000000000000000'); + assert.equal(body4.results[0].transaction.tx_id, hex(8)); + assert.equal(body4.results[1].transaction.tx_id, hex(7)); + assert.equal(body4.results[2].transaction.tx_id, hex(2)); + assert.equal(body4.results[3].transaction.tx_id, hex(1)); + assert.equal(body4.results[4].transaction.tx_id, hex(0)); }); test('should allow cursor pagination', async () => { - const testAddr1 = 'ST3J8EVYHVKH6XXPD61EE8XEHW4Y2K83861225AB1'; - for (let i = 1; i <= 10; i++) { - const hex = i.toString(16).padStart(64, '0'); - const prevHex = (i - 1).toString(16).padStart(64, '0'); + for (let i = 3; i <= 12; i++) { + const hexValue = hex(i); + const prevHex = hex(i - 1); const builder = new TestBlockBuilder({ block_height: i, - index_block_hash: `0x${hex}`, - parent_index_block_hash: `0x${prevHex}`, - parent_block_hash: `0x${prevHex}`, + index_block_hash: hexValue, + parent_index_block_hash: prevHex, + parent_block_hash: prevHex, }); for (let j = 1; j <= 5; j++) { builder.addTx({ - tx_id: `0x${(i * j).toString(16).padStart(8, '0')}`, - block_hash: `0x${hex}`, - index_block_hash: `0x${hex}`, + tx_id: hex(i * j), + block_hash: hexValue, + index_block_hash: hexValue, block_time: i * 1000, burn_block_height: i, burn_block_time: i * 1000, - sender_address: testAddr1, + sender_address: emptyPrincipal, }); } await db.update(builder.build()); @@ -278,7 +280,7 @@ describe('principals', () => { // Fetch first page const page1 = await api.fastifyApp.inject({ method: 'GET', - url: `/extended/v3/principals/${testAddr1}/transactions`, + url: `/extended/v3/principals/${emptyPrincipal}/transactions`, query: { limit: '5', }, @@ -289,15 +291,15 @@ describe('principals', () => { assert.equal(body1.limit, 5); assert.equal(body1.results.length, 5); assert.deepEqual(body1.cursor, { - next: '9:0:4', + next: '11:0:4', previous: null, - current: '10:0:4', + current: '12:0:4', }); // Fetch second page const page2 = await api.fastifyApp.inject({ method: 'GET', - url: `/extended/v3/principals/${testAddr1}/transactions`, + url: `/extended/v3/principals/${emptyPrincipal}/transactions`, query: { limit: '5', cursor: '9:0:4', @@ -315,4 +317,144 @@ describe('principals', () => { }); }); }); + + describe('/v3/principals/:principal/transactions/:tx_id/balance-changes', () => { + test('should return an empty list', async () => { + const response = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${emptyPrincipal}/transactions/${emptyTxId}/balance-changes`, + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.deepEqual(body, { + limit: 20, + total: 0, + cursor: { + next: null, + previous: null, + current: null, + }, + results: [], + }); + }); + + test('should return a list of balance changes with cursor pagination', async () => { + const response1 = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/transactions/${hex(3)}/balance-changes`, + }); + assert.equal(response1.statusCode, 200); + const body1 = JSON.parse(response1.body); + assert.deepEqual(body1, { + limit: 20, + total: 3, + cursor: { + next: null, + previous: null, + current: '1:stx', + }, + results: [ + { + asset: { + type: 'stx', + }, + balance_change: { + sent: '100050', + received: '0', + net: '-100050', + }, + }, + { + asset: { + type: 'ft', + identifier: + 'SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5.newyorkcitycoin-token::newyorkcitycoin', + }, + balance_change: { + sent: '100000', + received: '0', + net: '-100000', + }, + }, + { + asset: { + type: 'nft', + identifier: 'SP3D6PV2ACBPEKYJTCMH7HEN02KP87QSP8KTEH335.Candies::candy', + }, + balance_change: { + sent: '1', + received: '0', + net: '-1', + }, + }, + ], + }); + + const response2 = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/transactions/${hex(3)}/balance-changes`, + query: { + limit: '1', + cursor: '1:stx', + }, + }); + assert.equal(response2.statusCode, 200); + const body2 = JSON.parse(response2.body); + assert.deepEqual(body2, { + limit: 1, + total: 3, + cursor: { + next: '2:SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5.newyorkcitycoin-token::newyorkcitycoin', + previous: null, + current: '1:stx', + }, + results: [ + { + asset: { + type: 'stx', + }, + balance_change: { + sent: '100050', + received: '0', + net: '-100050', + }, + }, + ], + }); + + const response3 = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/transactions/${hex(3)}/balance-changes`, + query: { + limit: '1', + cursor: '2:SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5.newyorkcitycoin-token::newyorkcitycoin', + }, + }); + assert.equal(response3.statusCode, 200); + const body3 = JSON.parse(response3.body); + assert.deepEqual(body3, { + limit: 1, + total: 3, + cursor: { + next: '3:SP3D6PV2ACBPEKYJTCMH7HEN02KP87QSP8KTEH335.Candies::candy', + previous: '1:stx', + current: '2:SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5.newyorkcitycoin-token::newyorkcitycoin', + }, + results: [ + { + asset: { + type: 'ft', + identifier: + 'SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5.newyorkcitycoin-token::newyorkcitycoin', + }, + balance_change: { + sent: '100000', + received: '0', + net: '-100000', + }, + }, + ], + }); + }); + }); }); From 061bbbaeb3129380bd03051d0489c24cd0b39a6b Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 19 May 2026 10:04:44 -0600 Subject: [PATCH 23/32] cache controller --- src/api/routes/v3/principals.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/api/routes/v3/principals.ts b/src/api/routes/v3/principals.ts index 1e7c88d5c..4407707ad 100644 --- a/src/api/routes/v3/principals.ts +++ b/src/api/routes/v3/principals.ts @@ -1,4 +1,7 @@ -import { handlePrincipalCache } from '../../controllers/cache-controller.js'; +import { + handlePrincipalCache, + handleTransactionCache, +} from '../../controllers/cache-controller.js'; import { FastifyPluginAsync } from 'fastify'; import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { Server } from 'node:http'; @@ -71,8 +74,7 @@ export const PrincipalsRoutes: FastifyPluginAsync< fastify.get( '/principals/:principal/transactions/:tx_id/balance-changes', { - // TODO: Etag should really be based on both the transaction id and principal. - preHandler: handlePrincipalCache, + preHandler: handleTransactionCache, schema: { operationId: 'get_principal_transaction_balance_changes', summary: 'Get principal transaction balance changes', From 64a71b6d117d9aa8c4662aa079a5986da36a3075 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Wed, 20 May 2026 10:03:10 -0600 Subject: [PATCH 24/32] add tests --- tests/api/v3/principals.test.ts | 247 ++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) diff --git a/tests/api/v3/principals.test.ts b/tests/api/v3/principals.test.ts index b4a9c2dcf..cc2fad5f1 100644 --- a/tests/api/v3/principals.test.ts +++ b/tests/api/v3/principals.test.ts @@ -539,5 +539,252 @@ describe('principals', () => { ], }); }); + + test('should return 304 when ETag matches and refresh ETag per transaction', async () => { + // The balance-changes-by-tx endpoint uses the per-transaction ETag, so the cache key + // is scoped to (principal, tx_id). + const first = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/transactions/${hex(3)}/balance-changes`, + }); + assert.equal(first.statusCode, 200); + const etag = first.headers['etag']; + assert.ok(etag, 'expected ETag header to be set'); + + // Same ETag returns 304 with an empty body. + const cached = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/transactions/${hex(3)}/balance-changes`, + headers: { 'if-none-match': etag as string }, + }); + assert.equal(cached.statusCode, 304); + assert.equal(cached.body, ''); + + // A stale ETag returns 200 with the current data and ETag. + const stale = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/transactions/${hex(3)}/balance-changes`, + headers: { 'if-none-match': '"0xdeadbeef"' }, + }); + assert.equal(stale.statusCode, 200); + assert.equal(stale.headers['etag'], etag); + + // A different tx_id returns a distinct ETag and does not 304 against tx hex(3)'s ETag. + const otherTx = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/transactions/${hex(1)}/balance-changes`, + headers: { 'if-none-match': etag as string }, + }); + assert.equal(otherTx.statusCode, 200); + assert.ok(otherTx.headers['etag']); + assert.notEqual(otherTx.headers['etag'], etag); + }); + }); + + describe('/v3/principals/:principal/balance-changes', () => { + test('should require at least one tx_id', async () => { + const response = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/balance-changes`, + }); + assert.equal(response.statusCode, 400); + }); + + test('should return an empty list when the principal has no activity on the requested txs', async () => { + const response = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${emptyPrincipal}/balance-changes`, + query: { tx_id: hex(3) }, + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.deepEqual(body, { + limit: 20, + total: 0, + cursor: { + next: null, + previous: null, + current: null, + }, + results: [], + }); + }); + + test('should return balance changes across multiple txs ordered by chain position desc then asset asc', async () => { + // testAddr1 has activity on: + // - hex(1): coinbase in block 1 → stx fee only + // - hex(3): token transfer in block 2 → stx + ft + nft + const response = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/balance-changes`, + query: { tx_id: [hex(1), hex(3)] }, + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.equal(body.limit, 20); + assert.equal(body.total, 4); + assert.equal(body.results.length, 4); + assert.deepEqual(body.cursor, { + next: null, + previous: null, + current: '2:0:3:1:stx', + }); + assert.deepEqual(body.results, [ + { + tx_id: hex(3), + asset: { type: 'stx' }, + balance_change: { sent: '100050', received: '0', net: '-100050' }, + }, + { + tx_id: hex(3), + asset: { + type: 'ft', + identifier: + 'SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5.newyorkcitycoin-token::newyorkcitycoin', + }, + balance_change: { sent: '100000', received: '0', net: '-100000' }, + }, + { + tx_id: hex(3), + asset: { + type: 'nft', + identifier: 'SP3D6PV2ACBPEKYJTCMH7HEN02KP87QSP8KTEH335.Candies::candy', + }, + balance_change: { sent: '1', received: '0', net: '-1' }, + }, + { + tx_id: hex(1), + asset: { type: 'stx' }, + balance_change: { sent: '50', received: '0', net: '-50' }, + }, + ]); + }); + + test('should accept comma-separated tx_id values', async () => { + const response = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/balance-changes`, + query: { tx_id: `${hex(1)},${hex(3)}` }, + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.equal(body.total, 4); + assert.equal(body.results.length, 4); + assert.equal(body.results[0].tx_id, hex(3)); + assert.equal(body.results[3].tx_id, hex(1)); + }); + + test('should allow cursor pagination', async () => { + // First page: limit 2 → first two entries of tx hex(3). + const page1 = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/balance-changes`, + query: { tx_id: [hex(1), hex(3)], limit: '2' }, + }); + assert.equal(page1.statusCode, 200); + const body1 = JSON.parse(page1.body); + assert.equal(body1.total, 4); + assert.equal(body1.limit, 2); + assert.equal(body1.results.length, 2); + assert.equal(body1.results[0].tx_id, hex(3)); + assert.equal(body1.results[0].asset.type, 'stx'); + assert.equal(body1.results[1].asset.type, 'ft'); + assert.deepEqual(body1.cursor, { + next: '2:0:3:3:SP3D6PV2ACBPEKYJTCMH7HEN02KP87QSP8KTEH335.Candies::candy', + previous: null, + current: '2:0:3:1:stx', + }); + + // Second page: starts at the nft of hex(3), then crosses over to the stx of hex(1). + const page2 = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/balance-changes`, + query: { + tx_id: [hex(1), hex(3)], + limit: '2', + cursor: '2:0:3:3:SP3D6PV2ACBPEKYJTCMH7HEN02KP87QSP8KTEH335.Candies::candy', + }, + }); + assert.equal(page2.statusCode, 200); + const body2 = JSON.parse(page2.body); + assert.equal(body2.results.length, 2); + assert.equal(body2.results[0].tx_id, hex(3)); + assert.equal(body2.results[0].asset.type, 'nft'); + assert.equal(body2.results[1].tx_id, hex(1)); + assert.equal(body2.results[1].asset.type, 'stx'); + assert.deepEqual(body2.cursor, { + next: null, + previous: '2:0:3:1:stx', + current: '2:0:3:3:SP3D6PV2ACBPEKYJTCMH7HEN02KP87QSP8KTEH335.Candies::candy', + }); + }); + + test('should return 304 when ETag matches and refresh ETag on new principal activity', async () => { + // This endpoint uses the principal cache, so the ETag tracks the principal's last + // confirmed activity — independent of the requested tx_id batch. + const first = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/balance-changes`, + query: { tx_id: hex(3) }, + }); + assert.equal(first.statusCode, 200); + const etag = first.headers['etag']; + assert.ok(etag, 'expected ETag header to be set'); + + // Same ETag returns 304 with an empty body. + const cached = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/balance-changes`, + query: { tx_id: hex(3) }, + headers: { 'if-none-match': etag as string }, + }); + assert.equal(cached.statusCode, 304); + assert.equal(cached.body, ''); + + // A stale ETag returns 200 with the current data and ETag. + const stale = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/balance-changes`, + query: { tx_id: hex(3) }, + headers: { 'if-none-match': '"0xdeadbeef"' }, + }); + assert.equal(stale.statusCode, 200); + assert.equal(stale.headers['etag'], etag); + + // New confirmed activity for testAddr1 invalidates its ETag. + await db.update( + new TestBlockBuilder({ + block_height: 3, + block_hash: hex(3), + index_block_hash: hex(3), + parent_index_block_hash: hex(2), + parent_block_hash: hex(2), + }) + .addTx({ + tx_id: hex(0x1001), + fee_rate: 50n, + block_hash: hex(3), + index_block_hash: hex(3), + block_time: 3000, + burn_block_height: 3, + burn_block_time: 3000, + type_id: DbTxTypeId.TokenTransfer, + status: DbTxStatus.Success, + sender_address: testAddr1, + nonce: 100, + }) + .build() + ); + const afterActivity = await api.fastifyApp.inject({ + method: 'GET', + url: `/extended/v3/principals/${testAddr1}/balance-changes`, + query: { tx_id: hex(3) }, + headers: { 'if-none-match': etag as string }, + }); + assert.equal(afterActivity.statusCode, 200); + const newEtag = afterActivity.headers['etag']; + assert.ok(newEtag); + assert.notEqual(newEtag, etag); + }); }); }); From d71c23aa0e14d16811ee5cc7b6a136e9ad4137df Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Fri, 22 May 2026 10:18:24 -0600 Subject: [PATCH 25/32] fix: import ts types in migration --- migrations/1778599015338_principal-tx-balance-changes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/1778599015338_principal-tx-balance-changes.ts b/migrations/1778599015338_principal-tx-balance-changes.ts index 5f0e75b30..47bf86a76 100644 --- a/migrations/1778599015338_principal-tx-balance-changes.ts +++ b/migrations/1778599015338_principal-tx-balance-changes.ts @@ -1,4 +1,4 @@ -import { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; export const shorthands: ColumnDefinitions | undefined = undefined; From 69471439a55aaa628ab459f7a2f29bc47d47e181 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Sat, 23 May 2026 23:02:28 -0600 Subject: [PATCH 26/32] split migration --- ...8599015338_principal-tx-balance-changes.ts | 248 ++++++++++++------ 1 file changed, 161 insertions(+), 87 deletions(-) diff --git a/migrations/1778599015338_principal-tx-balance-changes.ts b/migrations/1778599015338_principal-tx-balance-changes.ts index 47bf86a76..6d1e35b2c 100644 --- a/migrations/1778599015338_principal-tx-balance-changes.ts +++ b/migrations/1778599015338_principal-tx-balance-changes.ts @@ -66,8 +66,23 @@ export function up(pgm: MigrationBuilder) { }, }); - // Backfill `principal_tx_balance_changes` from existing event/tx tables. This must mirror - // the write path in `PgWriteStore.updatePrincipalTxs` exactly: + // Create the unique constraint *before* the backfill. Two reasons: + // 1. The per-source backfill INSERTs below use ON CONFLICT to merge rows when the same + // (principal, tx, asset) is touched by multiple sources (e.g. the fee payer also + // appears as an STX sender for the same tx). This avoids the single huge UNION ALL + // + GROUP BY across every source table, whose hash aggregate was blowing past + // work_mem and getting cancelled. + // 2. (principal, tx_id, index_block_hash, microblock_hash, ...) is a leading prefix of + // what the balance_change_count update at the bottom GROUPs by, so that final + // COUNT(*) UPDATE can use this index instead of a seq scan + hash aggregate. + pgm.addConstraint( + 'principal_tx_balance_changes', + 'unique_principal_tx_balance_changes', + 'UNIQUE(principal, tx_id, index_block_hash, microblock_hash, asset_type, asset_identifier)' + ); + + // Backfill `principal_tx_balance_changes` from existing event/tx tables. Must mirror the + // write path in `PgWriteStore.updatePrincipalTxs`: // - The tx fee always contributes an STX `sent` row from the fee payer // (sponsor if sponsored, otherwise the sender). // - For STX/FT/NFT events, the sender contributes `sent` and the recipient contributes @@ -76,6 +91,36 @@ export function up(pgm: MigrationBuilder) { // The event-table CHECK constraints guarantee `sender IS NULL` on mints and // `recipient IS NULL` on burns, so the `IS NOT NULL` filters are sufficient — no need to // also gate on `asset_event_type_id`. + // + // Each source is its own INSERT so that the hash aggregate stays bounded by a single + // table's cardinality. Within each statement we still GROUP BY so the rows we hand to + // Postgres are already unique on the conflict key (Postgres rejects ON CONFLICT DO UPDATE + // when the same row is affected twice by a single statement). Across statements, + // ON CONFLICT merges sent/received from the prior source. + const conflictMerge = ` + ON CONFLICT ON CONSTRAINT unique_principal_tx_balance_changes DO UPDATE SET + sent = principal_tx_balance_changes.sent + EXCLUDED.sent, + received = principal_tx_balance_changes.received + EXCLUDED.received + `; + + // Tx fee paid by sponsor (if sponsored) or sender. One row per tx — no GROUP BY needed. + pgm.sql(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + COALESCE(sponsor_address, sender_address), + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 1::smallint, 'stx'::text, + fee_rate::numeric, 0::numeric + FROM txs + ${conflictMerge} + `); + + // STX sender side (transfer + burn). pgm.sql(` INSERT INTO principal_tx_balance_changes ( principal, tx_id, block_height, index_block_hash, microblock_hash, @@ -83,91 +128,126 @@ export function up(pgm: MigrationBuilder) { asset_type, asset_identifier, sent, received ) SELECT + sender, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 1::smallint, 'stx'::text, + SUM(amount)::numeric, 0::numeric + FROM stx_events + WHERE sender IS NOT NULL + GROUP BY sender, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical + ${conflictMerge} + `); + + // STX recipient side (transfer + mint). + pgm.sql(` + INSERT INTO principal_tx_balance_changes ( principal, tx_id, block_height, index_block_hash, microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical, - asset_type, asset_identifier, - SUM(sent) AS sent, - SUM(received) AS received - FROM ( - -- Tx fee paid by sponsor (if sponsored) or sender. - SELECT - COALESCE(sponsor_address, sender_address) AS principal, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 1::smallint AS asset_type, - 'stx'::text AS asset_identifier, - fee_rate::numeric AS sent, - 0::numeric AS received - FROM txs - UNION ALL - -- STX sender side (transfer + burn). - SELECT - sender AS principal, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 1::smallint, 'stx'::text, - amount::numeric, 0::numeric - FROM stx_events - WHERE sender IS NOT NULL - UNION ALL - -- STX recipient side (transfer + mint). - SELECT - recipient AS principal, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 1::smallint, 'stx'::text, - 0::numeric, amount::numeric - FROM stx_events - WHERE recipient IS NOT NULL - UNION ALL - -- FT sender side. - SELECT - sender AS principal, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 2::smallint, asset_identifier, - amount::numeric, 0::numeric - FROM ft_events - WHERE sender IS NOT NULL - UNION ALL - -- FT recipient side. - SELECT - recipient AS principal, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 2::smallint, asset_identifier, - 0::numeric, amount::numeric - FROM ft_events - WHERE recipient IS NOT NULL - UNION ALL - -- NFT sender side, counted as 1 token per event. - SELECT - sender AS principal, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 3::smallint, asset_identifier, - 1::numeric, 0::numeric - FROM nft_events - WHERE sender IS NOT NULL - UNION ALL - -- NFT recipient side, counted as 1 token per event. - SELECT - recipient AS principal, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 3::smallint, asset_identifier, - 0::numeric, 1::numeric - FROM nft_events - WHERE recipient IS NOT NULL - ) AS src - GROUP BY + asset_type, asset_identifier, sent, received + ) + SELECT + recipient, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 1::smallint, 'stx'::text, + 0::numeric, SUM(amount)::numeric + FROM stx_events + WHERE recipient IS NOT NULL + GROUP BY recipient, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical + ${conflictMerge} + `); + + // FT sender side. + pgm.sql(` + INSERT INTO principal_tx_balance_changes ( principal, tx_id, block_height, index_block_hash, microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical, - asset_type, asset_identifier + asset_type, asset_identifier, sent, received + ) + SELECT + sender, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 2::smallint, asset_identifier, + SUM(amount)::numeric, 0::numeric + FROM ft_events + WHERE sender IS NOT NULL + GROUP BY sender, asset_identifier, tx_id, block_height, index_block_hash, + microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical + ${conflictMerge} `); - // Backfill the newly added `principal_txs.balance_change_count` from the rows we just - // inserted, so the column reflects reality instead of the column default of 0. + // FT recipient side. + pgm.sql(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + recipient, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 2::smallint, asset_identifier, + 0::numeric, SUM(amount)::numeric + FROM ft_events + WHERE recipient IS NOT NULL + GROUP BY recipient, asset_identifier, tx_id, block_height, index_block_hash, + microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical + ${conflictMerge} + `); + + // NFT sender side, counted as 1 token per event. + pgm.sql(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + sender, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 3::smallint, asset_identifier, + COUNT(*)::numeric, 0::numeric + FROM nft_events + WHERE sender IS NOT NULL + GROUP BY sender, asset_identifier, tx_id, block_height, index_block_hash, + microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical + ${conflictMerge} + `); + + // NFT recipient side, counted as 1 token per event. + pgm.sql(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + recipient, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 3::smallint, asset_identifier, + 0::numeric, COUNT(*)::numeric + FROM nft_events + WHERE recipient IS NOT NULL + GROUP BY recipient, asset_identifier, tx_id, block_height, index_block_hash, + microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical + ${conflictMerge} + `); + + // Refresh stats so the planner picks the unique index for the COUNT(*) below instead of + // falling back to a seq scan based on stale (empty-table) statistics. + pgm.sql(`ANALYZE principal_tx_balance_changes`); + + // Backfill `principal_txs.balance_change_count` from the rows just inserted. The GROUP BY + // columns are the leading prefix of `unique_principal_tx_balance_changes`, so the planner + // can satisfy the aggregate via that index (sort/group rather than seq scan + hash). The + // join target's `principal_txs_unique` covers the same key on the UPDATE side. pgm.sql(` WITH counts AS ( SELECT principal, tx_id, index_block_hash, microblock_hash, @@ -184,12 +264,6 @@ export function up(pgm: MigrationBuilder) { AND pt.microblock_hash = c.microblock_hash; `); - pgm.addConstraint( - 'principal_tx_balance_changes', - 'unique_principal_tx_balance_changes', - 'UNIQUE(principal, tx_id, index_block_hash, microblock_hash, asset_type, asset_identifier)' - ); - pgm.createIndex('principal_tx_balance_changes', 'tx_id'); pgm.createIndex('principal_tx_balance_changes', ['index_block_hash', 'canonical']); pgm.createIndex('principal_tx_balance_changes', 'microblock_hash'); From aa6ae57f2bc54d220d4620c0bf32ed9d3774b6a4 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 25 May 2026 09:09:06 -0600 Subject: [PATCH 27/32] fix: attempt to calculate counts gradually --- ...8599015338_principal-tx-balance-changes.ts | 356 ++++++++++-------- 1 file changed, 193 insertions(+), 163 deletions(-) diff --git a/migrations/1778599015338_principal-tx-balance-changes.ts b/migrations/1778599015338_principal-tx-balance-changes.ts index 6d1e35b2c..1cdad14ee 100644 --- a/migrations/1778599015338_principal-tx-balance-changes.ts +++ b/migrations/1778599015338_principal-tx-balance-changes.ts @@ -66,193 +66,223 @@ export function up(pgm: MigrationBuilder) { }, }); - // Create the unique constraint *before* the backfill. Two reasons: - // 1. The per-source backfill INSERTs below use ON CONFLICT to merge rows when the same - // (principal, tx, asset) is touched by multiple sources (e.g. the fee payer also - // appears as an STX sender for the same tx). This avoids the single huge UNION ALL - // + GROUP BY across every source table, whose hash aggregate was blowing past - // work_mem and getting cancelled. - // 2. (principal, tx_id, index_block_hash, microblock_hash, ...) is a leading prefix of - // what the balance_change_count update at the bottom GROUPs by, so that final - // COUNT(*) UPDATE can use this index instead of a seq scan + hash aggregate. + // Unique constraint created before the backfill so each per-source INSERT below can use + // ON CONFLICT to merge with rows already produced by earlier sources (e.g. the fee row + // for a principal that also appears as an STX event participant). pgm.addConstraint( 'principal_tx_balance_changes', 'unique_principal_tx_balance_changes', 'UNIQUE(principal, tx_id, index_block_hash, microblock_hash, asset_type, asset_identifier)' ); - // Backfill `principal_tx_balance_changes` from existing event/tx tables. Must mirror the - // write path in `PgWriteStore.updatePrincipalTxs`: - // - The tx fee always contributes an STX `sent` row from the fee payer - // (sponsor if sponsored, otherwise the sender). - // - For STX/FT/NFT events, the sender contributes `sent` and the recipient contributes - // `received`. NFT events count tokens moved (1 per event), matching the `numeric` - // `sent`/`received` semantics of this table. - // The event-table CHECK constraints guarantee `sender IS NULL` on mints and - // `recipient IS NULL` on burns, so the `IS NOT NULL` filters are sufficient — no need to - // also gate on `asset_event_type_id`. + // Staging table for balance_change_count deltas. Each per-source INSERT captures the + // rows it actually created (via the xmax = 0 idiom on its RETURNING set — true for fresh + // inserts, false when ON CONFLICT triggered a merge) and writes one partial-count row per + // (principal, tx, index_block_hash, microblock_hash) here. A final UPDATE rolls these into + // principal_txs.balance_change_count. // - // Each source is its own INSERT so that the hash aggregate stays bounded by a single - // table's cardinality. Within each statement we still GROUP BY so the rows we hand to - // Postgres are already unique on the conflict key (Postgres rejects ON CONFLICT DO UPDATE - // when the same row is affected twice by a single statement). Across statements, - // ON CONFLICT merges sent/received from the prior source. - const conflictMerge = ` - ON CONFLICT ON CONSTRAINT unique_principal_tx_balance_changes DO UPDATE SET - sent = principal_tx_balance_changes.sent + EXCLUDED.sent, - received = principal_tx_balance_changes.received + EXCLUDED.received - `; - - // Tx fee paid by sponsor (if sponsored) or sender. One row per tx — no GROUP BY needed. + // Why a staging table instead of either: + // (a) One COUNT(*) over the finished principal_tx_balance_changes (the previous design): + // that aggregate spans billions of rows, its hash exceeds work_mem and spills to + // disk, and the job never finishes. + // (b) Inline UPDATE-per-source against principal_txs: each principal_txs row could be + // touched by up to 7 sources, meaning up to 7 heap rewrites + index updates per row. + // Staging lets the end-of-migration UPDATE touch each row exactly once. + // + // TEMP + ON COMMIT DROP: no WAL for the staging rows, table is gone when the migration's + // transaction commits. pgm.sql(` - INSERT INTO principal_tx_balance_changes ( - principal, tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - asset_type, asset_identifier, sent, received - ) - SELECT - COALESCE(sponsor_address, sender_address), - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 1::smallint, 'stx'::text, - fee_rate::numeric, 0::numeric - FROM txs - ${conflictMerge} + CREATE TEMP TABLE balance_count_deltas ( + principal text NOT NULL, + tx_id bytea NOT NULL, + index_block_hash bytea NOT NULL, + microblock_hash bytea NOT NULL, + delta integer NOT NULL + ) ON COMMIT DROP `); - // STX sender side (transfer + burn). - pgm.sql(` - INSERT INTO principal_tx_balance_changes ( - principal, tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - asset_type, asset_identifier, sent, received + // ===== Per-source backfill ===== + // + // Mirrors PgWriteStore.updatePrincipalTxs: + // - Tx fee always contributes an STX `sent` row from the fee payer (sponsor || sender). + // - STX/FT events: sender contributes `sent`, recipient contributes `received`. + // - NFT events count 1 token per event. + // Event-table CHECK constraints guarantee sender IS NULL on mints and recipient IS NULL on + // burns, so the IS NOT NULL filters are sufficient. + // + // Each source is its own INSERT so per-statement memory stays bounded by one source table. + // The wrapping CTE feeds RETURNING into the deltas staging table — only `is_new` rows + // (newly inserted rather than merged via ON CONFLICT) count as +1 toward + // balance_change_count. + const writeDeltas = (sourceInsert: string) => ` + WITH ins AS ( + ${sourceInsert} + ON CONFLICT ON CONSTRAINT unique_principal_tx_balance_changes DO UPDATE SET + sent = principal_tx_balance_changes.sent + EXCLUDED.sent, + received = principal_tx_balance_changes.received + EXCLUDED.received + RETURNING principal, tx_id, index_block_hash, microblock_hash, (xmax = 0) AS is_new ) - SELECT - sender, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 1::smallint, 'stx'::text, - SUM(amount)::numeric, 0::numeric - FROM stx_events - WHERE sender IS NOT NULL - GROUP BY sender, tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical - ${conflictMerge} - `); + INSERT INTO balance_count_deltas (principal, tx_id, index_block_hash, microblock_hash, delta) + SELECT principal, tx_id, index_block_hash, microblock_hash, COUNT(*)::int + FROM ins + WHERE is_new + GROUP BY principal, tx_id, index_block_hash, microblock_hash + `; + + // Tx fees: one row per tx, no source-side GROUP BY needed. + pgm.sql( + writeDeltas(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + COALESCE(sponsor_address, sender_address), + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 1::smallint, 'stx'::text, + fee_rate::numeric, 0::numeric + FROM txs + `) + ); + + // STX sender side (transfer + burn). + pgm.sql( + writeDeltas(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + sender, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 1::smallint, 'stx'::text, + SUM(amount)::numeric, 0::numeric + FROM stx_events + WHERE sender IS NOT NULL + GROUP BY sender, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical + `) + ); // STX recipient side (transfer + mint). - pgm.sql(` - INSERT INTO principal_tx_balance_changes ( - principal, tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - asset_type, asset_identifier, sent, received - ) - SELECT - recipient, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 1::smallint, 'stx'::text, - 0::numeric, SUM(amount)::numeric - FROM stx_events - WHERE recipient IS NOT NULL - GROUP BY recipient, tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical - ${conflictMerge} - `); + pgm.sql( + writeDeltas(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + recipient, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 1::smallint, 'stx'::text, + 0::numeric, SUM(amount)::numeric + FROM stx_events + WHERE recipient IS NOT NULL + GROUP BY recipient, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical + `) + ); // FT sender side. - pgm.sql(` - INSERT INTO principal_tx_balance_changes ( - principal, tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - asset_type, asset_identifier, sent, received - ) - SELECT - sender, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 2::smallint, asset_identifier, - SUM(amount)::numeric, 0::numeric - FROM ft_events - WHERE sender IS NOT NULL - GROUP BY sender, asset_identifier, tx_id, block_height, index_block_hash, - microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical - ${conflictMerge} - `); + pgm.sql( + writeDeltas(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + sender, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 2::smallint, asset_identifier, + SUM(amount)::numeric, 0::numeric + FROM ft_events + WHERE sender IS NOT NULL + GROUP BY sender, asset_identifier, tx_id, block_height, index_block_hash, + microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical + `) + ); // FT recipient side. - pgm.sql(` - INSERT INTO principal_tx_balance_changes ( - principal, tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - asset_type, asset_identifier, sent, received - ) - SELECT - recipient, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 2::smallint, asset_identifier, - 0::numeric, SUM(amount)::numeric - FROM ft_events - WHERE recipient IS NOT NULL - GROUP BY recipient, asset_identifier, tx_id, block_height, index_block_hash, - microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical - ${conflictMerge} - `); + pgm.sql( + writeDeltas(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + recipient, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 2::smallint, asset_identifier, + 0::numeric, SUM(amount)::numeric + FROM ft_events + WHERE recipient IS NOT NULL + GROUP BY recipient, asset_identifier, tx_id, block_height, index_block_hash, + microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical + `) + ); // NFT sender side, counted as 1 token per event. - pgm.sql(` - INSERT INTO principal_tx_balance_changes ( - principal, tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - asset_type, asset_identifier, sent, received - ) - SELECT - sender, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 3::smallint, asset_identifier, - COUNT(*)::numeric, 0::numeric - FROM nft_events - WHERE sender IS NOT NULL - GROUP BY sender, asset_identifier, tx_id, block_height, index_block_hash, - microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical - ${conflictMerge} - `); + pgm.sql( + writeDeltas(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + sender, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 3::smallint, asset_identifier, + COUNT(*)::numeric, 0::numeric + FROM nft_events + WHERE sender IS NOT NULL + GROUP BY sender, asset_identifier, tx_id, block_height, index_block_hash, + microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical + `) + ); // NFT recipient side, counted as 1 token per event. - pgm.sql(` - INSERT INTO principal_tx_balance_changes ( - principal, tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - asset_type, asset_identifier, sent, received - ) - SELECT - recipient, - tx_id, block_height, index_block_hash, microblock_hash, - microblock_sequence, tx_index, canonical, microblock_canonical, - 3::smallint, asset_identifier, - 0::numeric, COUNT(*)::numeric - FROM nft_events - WHERE recipient IS NOT NULL - GROUP BY recipient, asset_identifier, tx_id, block_height, index_block_hash, - microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical - ${conflictMerge} - `); - - // Refresh stats so the planner picks the unique index for the COUNT(*) below instead of - // falling back to a seq scan based on stale (empty-table) statistics. - pgm.sql(`ANALYZE principal_tx_balance_changes`); + pgm.sql( + writeDeltas(` + INSERT INTO principal_tx_balance_changes ( + principal, tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + asset_type, asset_identifier, sent, received + ) + SELECT + recipient, + tx_id, block_height, index_block_hash, microblock_hash, + microblock_sequence, tx_index, canonical, microblock_canonical, + 3::smallint, asset_identifier, + 0::numeric, COUNT(*)::numeric + FROM nft_events + WHERE recipient IS NOT NULL + GROUP BY recipient, asset_identifier, tx_id, block_height, index_block_hash, + microblock_hash, microblock_sequence, tx_index, canonical, microblock_canonical + `) + ); - // Backfill `principal_txs.balance_change_count` from the rows just inserted. The GROUP BY - // columns are the leading prefix of `unique_principal_tx_balance_changes`, so the planner - // can satisfy the aggregate via that index (sort/group rather than seq scan + hash). The - // join target's `principal_txs_unique` covers the same key on the UPDATE side. + // Roll the staged deltas into principal_txs.balance_change_count. The deltas table holds + // at most one row per (source, principal, tx, block, mblock) — orders of magnitude smaller + // than principal_tx_balance_changes itself — so the SUM aggregation is bounded and the + // join lookups hit principal_txs_unique directly. This is the work that the previous + // COUNT(*) over the full balance_changes table tried (and failed) to do. pgm.sql(` WITH counts AS ( SELECT principal, tx_id, index_block_hash, microblock_hash, - COUNT(*)::integer AS cnt - FROM principal_tx_balance_changes + SUM(delta)::int AS cnt + FROM balance_count_deltas GROUP BY principal, tx_id, index_block_hash, microblock_hash ) UPDATE principal_txs AS pt @@ -261,7 +291,7 @@ export function up(pgm: MigrationBuilder) { WHERE pt.principal = c.principal AND pt.tx_id = c.tx_id AND pt.index_block_hash = c.index_block_hash - AND pt.microblock_hash = c.microblock_hash; + AND pt.microblock_hash = c.microblock_hash `); pgm.createIndex('principal_tx_balance_changes', 'tx_id'); From 6a2b79da5bfd3246700066bcd1f73fadbbf90962 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 26 May 2026 09:15:18 -0600 Subject: [PATCH 28/32] split count even more --- ...8599015338_principal-tx-balance-changes.ts | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/migrations/1778599015338_principal-tx-balance-changes.ts b/migrations/1778599015338_principal-tx-balance-changes.ts index 1cdad14ee..b380e27df 100644 --- a/migrations/1778599015338_principal-tx-balance-changes.ts +++ b/migrations/1778599015338_principal-tx-balance-changes.ts @@ -273,21 +273,38 @@ export function up(pgm: MigrationBuilder) { `) ); - // Roll the staged deltas into principal_txs.balance_change_count. The deltas table holds - // at most one row per (source, principal, tx, block, mblock) — orders of magnitude smaller - // than principal_tx_balance_changes itself — so the SUM aggregation is bounded and the - // join lookups hit principal_txs_unique directly. This is the work that the previous - // COUNT(*) over the full balance_changes table tried (and failed) to do. + // Materialize the aggregated counts into a separate, indexed temp table BEFORE running + // the UPDATE. This splits "aggregate" from "join + update" so each step gets a clean plan: + // 1. SUM / GROUP BY runs once as a standalone scan of balance_count_deltas into + // balance_count_final. The result is one row per UPDATE target — orders of magnitude + // smaller than balance_count_deltas itself. + // 2. The wrapping UPDATE then has an indexed driver on its left side, so the planner + // can pick a merge join or index nested loop along `principal_txs_unique` (which + // covers exactly this key) instead of hash-joining principal_txs — a multi-billion- + // row table — against an unindexed CTE. That converts random heap probes across all + // of principal_txs into ordered ones, which is the fix for the DataFileRead-bound + // UPDATE that took >24h on the prior attempt. + pgm.sql(` + CREATE TEMP TABLE balance_count_final ON COMMIT DROP AS + SELECT principal, tx_id, index_block_hash, microblock_hash, + SUM(delta)::int AS cnt + FROM balance_count_deltas + GROUP BY principal, tx_id, index_block_hash, microblock_hash + `); + // The deltas staging table is no longer needed and is the larger of the two; drop it now + // to free temp space and reduce buffer-cache pressure during the UPDATE. + pgm.sql(`DROP TABLE balance_count_deltas`); + + pgm.sql(` + CREATE INDEX ON balance_count_final + (principal, tx_id, index_block_hash, microblock_hash) + `); + pgm.sql(`ANALYZE balance_count_final`); + pgm.sql(` - WITH counts AS ( - SELECT principal, tx_id, index_block_hash, microblock_hash, - SUM(delta)::int AS cnt - FROM balance_count_deltas - GROUP BY principal, tx_id, index_block_hash, microblock_hash - ) UPDATE principal_txs AS pt SET balance_change_count = c.cnt - FROM counts AS c + FROM balance_count_final AS c WHERE pt.principal = c.principal AND pt.tx_id = c.tx_id AND pt.index_block_hash = c.index_block_hash From 6c593a7b5c7383acc6c6716bd072f7da50e5563a Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 26 May 2026 20:29:14 -0600 Subject: [PATCH 29/32] fix: use BigNumber for balance changes --- src/datastore/common.ts | 8 +-- src/datastore/pg-write-store.ts | 118 +++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 44 deletions(-) diff --git a/src/datastore/common.ts b/src/datastore/common.ts index 64291c3eb..2305b0887 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -1689,8 +1689,8 @@ export interface PrincipalTxsInsertValues { microblock_sequence: number; tx_index: number; canonical: boolean; - stx_sent: bigint; - stx_received: bigint; + stx_sent: PgNumeric; + stx_received: PgNumeric; microblock_canonical: boolean; stx_balance_affected: boolean; ft_balance_affected: boolean; @@ -1719,8 +1719,8 @@ export interface PrincipalTxBalanceChangeInsertValues { microblock_canonical: boolean; asset_type: DbAssetType; asset_identifier: string; - sent: bigint; - received: bigint; + sent: PgNumeric; + received: PgNumeric; } export interface RewardSlotHolderInsertValues { diff --git a/src/datastore/pg-write-store.ts b/src/datastore/pg-write-store.ts index 6af058e90..c9b0bea1b 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -1406,8 +1406,8 @@ export class PgWriteStore extends PgStore { stx: boolean; ft: boolean; nft: boolean; - stx_sent: bigint; - stx_received: bigint; + stx_sent: BigNumber; + stx_received: BigNumber; stx_mints: number; stx_burns: number; stx_transfers: number; @@ -1422,8 +1422,8 @@ export class PgWriteStore extends PgStore { principal: string; asset_type: DbAssetType; asset_identifier: string; - sent: bigint; - received: bigint; + sent: BigNumber; + received: BigNumber; }; const STX_ASSET_IDENTIFIER = 'stx'; const values: PrincipalTxsInsertValues[] = []; @@ -1438,8 +1438,8 @@ export class PgWriteStore extends PgStore { stx: entry?.stx || data?.stx || false, ft: entry?.ft || data?.ft || false, nft: entry?.nft || data?.nft || false, - stx_sent: (entry?.stx_sent ?? 0n) + (data?.stx_sent ?? 0n), - stx_received: (entry?.stx_received ?? 0n) + (data?.stx_received ?? 0n), + stx_sent: (entry?.stx_sent ?? new BigNumber(0)).plus(data?.stx_sent ?? 0n), + stx_received: (entry?.stx_received ?? new BigNumber(0)).plus(data?.stx_received ?? 0n), stx_mints: (entry?.stx_mints ?? 0) + (data?.stx_mints ?? 0), stx_burns: (entry?.stx_burns ?? 0) + (data?.stx_burns ?? 0), stx_transfers: (entry?.stx_transfers ?? 0) + (data?.stx_transfers ?? 0), @@ -1460,8 +1460,8 @@ export class PgWriteStore extends PgStore { principal: string, asset_type: DbAssetType, asset_identifier: string, - sent: bigint, - received: bigint + sent: BigNumber, + received: BigNumber ) => { const key = `${principal}|${asset_type}|${asset_identifier}`; const entry = balanceChanges.get(key); @@ -1469,8 +1469,8 @@ export class PgWriteStore extends PgStore { principal, asset_type, asset_identifier, - sent: (entry?.sent ?? 0n) + sent, - received: (entry?.received ?? 0n) + received, + sent: (entry?.sent ?? new BigNumber(0)).plus(sent), + received: (entry?.received ?? new BigNumber(0)).plus(received), }); }; @@ -1483,9 +1483,15 @@ export class PgWriteStore extends PgStore { // Record fee paid. const feePayer = tx.sponsor_address ?? tx.sender_address; - const feeAmount = BigInt(tx.fee_rate); + const feeAmount = new BigNumber(tx.fee_rate); addPrincipal(feePayer, { stx: true, stx_sent: feeAmount }); - addBalanceChange(feePayer, DbAssetType.Stx, STX_ASSET_IDENTIFIER, feeAmount, 0n); + addBalanceChange( + feePayer, + DbAssetType.Stx, + STX_ASSET_IDENTIFIER, + feeAmount, + new BigNumber(0) + ); // Record token amounts and event counts. for (const event of stxEvents) { @@ -1494,53 +1500,61 @@ export class PgWriteStore extends PgStore { if (event.recipient) { addPrincipal(event.recipient, { stx: true, - stx_received: event.amount, + stx_received: new BigNumber(event.amount), stx_mints: 1, }); addBalanceChange( event.recipient, DbAssetType.Stx, STX_ASSET_IDENTIFIER, - 0n, - event.amount + new BigNumber(0), + new BigNumber(event.amount) ); } break; case DbAssetEventTypeId.Burn: if (event.sender) { - addPrincipal(event.sender, { stx: true, stx_sent: event.amount, stx_burns: 1 }); + addPrincipal(event.sender, { + stx: true, + stx_sent: new BigNumber(event.amount), + stx_burns: 1, + }); addBalanceChange( event.sender, DbAssetType.Stx, STX_ASSET_IDENTIFIER, - event.amount, - 0n + new BigNumber(event.amount), + new BigNumber(0) ); } break; case DbAssetEventTypeId.Transfer: if (event.sender) { - addPrincipal(event.sender, { stx: true, stx_sent: event.amount, stx_transfers: 1 }); + addPrincipal(event.sender, { + stx: true, + stx_sent: new BigNumber(event.amount), + stx_transfers: 1, + }); addBalanceChange( event.sender, DbAssetType.Stx, STX_ASSET_IDENTIFIER, - event.amount, - 0n + new BigNumber(event.amount), + new BigNumber(0) ); } if (event.recipient) { addPrincipal(event.recipient, { stx: true, - stx_received: event.amount, + stx_received: new BigNumber(event.amount), stx_transfers: 1, }); addBalanceChange( event.recipient, DbAssetType.Stx, STX_ASSET_IDENTIFIER, - 0n, - event.amount + new BigNumber(0), + new BigNumber(event.amount) ); } break; @@ -1555,8 +1569,8 @@ export class PgWriteStore extends PgStore { event.recipient, DbAssetType.Ft, event.asset_identifier, - 0n, - event.amount + new BigNumber(0), + new BigNumber(event.amount) ); } break; @@ -1567,8 +1581,8 @@ export class PgWriteStore extends PgStore { event.sender, DbAssetType.Ft, event.asset_identifier, - event.amount, - 0n + new BigNumber(event.amount), + new BigNumber(0) ); } break; @@ -1579,8 +1593,8 @@ export class PgWriteStore extends PgStore { event.sender, DbAssetType.Ft, event.asset_identifier, - event.amount, - 0n + new BigNumber(event.amount), + new BigNumber(0) ); } if (event.recipient) { @@ -1592,8 +1606,8 @@ export class PgWriteStore extends PgStore { event.recipient, DbAssetType.Ft, event.asset_identifier, - 0n, - event.amount + new BigNumber(0), + new BigNumber(event.amount) ); } break; @@ -1604,23 +1618,47 @@ export class PgWriteStore extends PgStore { case DbAssetEventTypeId.Mint: if (event.recipient) { addPrincipal(event.recipient, { nft: true, nft_mints: 1 }); - addBalanceChange(event.recipient, DbAssetType.Nft, event.asset_identifier, 0n, 1n); + addBalanceChange( + event.recipient, + DbAssetType.Nft, + event.asset_identifier, + new BigNumber(0), + new BigNumber(1) + ); } break; case DbAssetEventTypeId.Burn: if (event.sender) { addPrincipal(event.sender, { nft: true, nft_burns: 1 }); - addBalanceChange(event.sender, DbAssetType.Nft, event.asset_identifier, 1n, 0n); + addBalanceChange( + event.sender, + DbAssetType.Nft, + event.asset_identifier, + new BigNumber(1), + new BigNumber(0) + ); } break; case DbAssetEventTypeId.Transfer: if (event.sender) { addPrincipal(event.sender, { nft: true, nft_transfers: 1 }); - addBalanceChange(event.sender, DbAssetType.Nft, event.asset_identifier, 1n, 0n); + addBalanceChange( + event.sender, + DbAssetType.Nft, + event.asset_identifier, + new BigNumber(1), + new BigNumber(0) + ); } if (event.recipient) { addPrincipal(event.recipient, { nft: true, nft_transfers: 1 }); - addBalanceChange(event.recipient, DbAssetType.Nft, event.asset_identifier, 0n, 1n); + addBalanceChange( + event.recipient, + DbAssetType.Nft, + event.asset_identifier, + new BigNumber(0), + new BigNumber(1) + ); } break; } @@ -1648,8 +1686,8 @@ export class PgWriteStore extends PgStore { stx_balance_affected: data.stx, ft_balance_affected: data.ft, nft_balance_affected: data.nft, - stx_sent: data.stx_sent, - stx_received: data.stx_received, + stx_sent: data.stx_sent.toFixed(), + stx_received: data.stx_received.toFixed(), stx_mint_event_count: data.stx_mints, stx_burn_event_count: data.stx_burns, stx_transfer_event_count: data.stx_transfers, @@ -1676,8 +1714,8 @@ export class PgWriteStore extends PgStore { microblock_canonical: tx.microblock_canonical, asset_type: change.asset_type, asset_identifier: change.asset_identifier, - sent: change.sent, - received: change.received, + sent: change.sent.toFixed(), + received: change.received.toFixed(), }); } } From f986c6d04f868e2c0d69d6d1f55d9e9f2521671c Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Wed, 27 May 2026 13:59:27 -0600 Subject: [PATCH 30/32] fix: serializer --- src/api/serializers/transactions.ts | 223 ------------------------- src/api/serializers/v3/transactions.ts | 63 ++++++- 2 files changed, 62 insertions(+), 224 deletions(-) delete mode 100644 src/api/serializers/transactions.ts diff --git a/src/api/serializers/transactions.ts b/src/api/serializers/transactions.ts deleted file mode 100644 index 764176adc..000000000 --- a/src/api/serializers/transactions.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { - BaseTransactionSummary, - CoinbaseTransactionSummary, - ContractCallTransactionSummary, - PoisonMicroblockTransactionSummary, - SmartContractTransactionSummary, - TenureChangeTransactionSummary, - TokenTransferTransactionSummary, - TransactionSummary, - TransactionStatus, -} from '../schemas/v3/entities/transaction-summaries.js'; -import { - DbPrincipalTransactionBalanceChange, - DbPrincipalTransactionSummary, - DbTransactionSummary, -} from '../../datastore/v3/types.js'; -import { DbAssetType, DbTxStatus, DbTxTypeId } from '../../datastore/common.js'; -import { getTxTenureChangeCauseString } from '../controllers/db-controller.js'; -import { PrincipalTransactionSummary } from '../schemas/v3/entities/principal-transactions.js'; -import { - PrincipalBalanceChange, - PrincipalTransactionBalanceChange, -} from '../schemas/v3/entities/principal-balance-changes.js'; - -/** - * Parses a database transaction summary status into a transaction summary status. - * @param status - The database transaction status. - * @returns The parsed transaction summary status. - */ -function serializeDbTransactionSummaryStatus(status: DbTxStatus): TransactionStatus { - switch (status) { - case DbTxStatus.AbortByResponse: - return 'abort_by_response'; - case DbTxStatus.AbortByPostCondition: - return 'abort_by_post_condition'; - case DbTxStatus.Success: - return 'success'; - default: - throw new Error(`Unexpected DbTxStatus: ${status}`); - } -} - -/** - * Parses a database transaction summary into a transaction summary. - * @param summary - The database transaction summary to parse. - * @returns The parsed transaction summary. - */ -export function serializeDbTransactionSummary(summary: DbTransactionSummary): TransactionSummary { - const result: BaseTransactionSummary = { - tx_id: summary.tx_id, - sender: { - address: summary.sender_address, - nonce: summary.nonce, - }, - sponsor: - summary.sponsor_address !== null && summary.sponsor_nonce !== null - ? { - address: summary.sponsor_address, - nonce: summary.sponsor_nonce, - } - : null, - fee_rate: summary.fee_rate, - block: { - height: summary.block_height, - hash: summary.block_hash, - index_hash: summary.index_block_hash, - time: summary.block_time, - tx_index: summary.tx_index, - }, - bitcoin_block: { - height: summary.burn_block_height, - time: summary.burn_block_time, - }, - status: serializeDbTransactionSummaryStatus(summary.status), - }; - switch (summary.type_id) { - case DbTxTypeId.TokenTransfer: { - const tokenTransfer: TokenTransferTransactionSummary = { - ...result, - type: 'token_transfer', - token_transfer: { - recipient: summary.token_transfer_recipient_address!, - amount: summary.token_transfer_amount!, - memo: summary.token_transfer_memo, - }, - }; - return tokenTransfer; - } - case DbTxTypeId.SmartContract: { - const smartContract: SmartContractTransactionSummary = { - ...result, - type: 'smart_contract', - smart_contract: { - clarity_version: summary.smart_contract_clarity_version, - contract_id: summary.smart_contract_contract_id!, - }, - }; - return smartContract; - } - case DbTxTypeId.ContractCall: { - const contractCall: ContractCallTransactionSummary = { - ...result, - type: 'contract_call', - contract_call: { - contract_id: summary.contract_call_contract_id!, - function_name: summary.contract_call_function_name!, - }, - }; - return contractCall; - } - case DbTxTypeId.PoisonMicroblock: { - const poisonMicroblock: PoisonMicroblockTransactionSummary = { - ...result, - type: 'poison_microblock', - }; - return poisonMicroblock; - } - case DbTxTypeId.Coinbase: { - const coinbase: CoinbaseTransactionSummary = { - ...result, - type: 'coinbase', - coinbase: { - alt_recipient: summary.coinbase_alt_recipient, - }, - }; - return coinbase; - } - case DbTxTypeId.TenureChange: { - const tenureChange: TenureChangeTransactionSummary = { - ...result, - type: 'tenure_change', - tenure_change: { - cause: getTxTenureChangeCauseString(summary.tenure_change_cause!), - }, - }; - return tenureChange; - } - default: - throw new Error(`Unexpected DbTxTypeId: ${summary.type_id}`); - } -} - -/** - * Parses a database principal transaction summary into a principal transaction summary. - * @param summary - The database principal transaction summary to parse. - * @returns The parsed principal transaction summary. - */ -export function serializePrincipalTransactionSummary( - summary: DbPrincipalTransactionSummary -): PrincipalTransactionSummary { - return { - transaction: serializeDbTransactionSummary(summary), - involvement: summary.involvement, - balance_changes: { - stx: { - sent: summary.stx_sent, - received: summary.stx_received, - net: summary.stx_net, - }, - }, - affected_balances: { - stx: summary.stx_balance_affected, - ft: summary.ft_balance_affected, - nft: summary.nft_balance_affected, - }, - }; -} - -function serializeAssetType(type: DbAssetType): 'stx' | 'ft' | 'nft' { - switch (type) { - case DbAssetType.Stx: - return 'stx'; - case DbAssetType.Ft: - return 'ft'; - case DbAssetType.Nft: - return 'nft'; - default: - throw new Error(`Unexpected DbAssetType: ${type}`); - } -} - -/** - * Parses a database principal transaction balance change into a principal transaction balance - * change. - * @param change - The database principal transaction balance change to parse. - * @returns The parsed principal transaction balance change. - */ -export function serializePrincipalTransactionBalanceChange( - change: DbPrincipalTransactionBalanceChange -): PrincipalTransactionBalanceChange { - const assetType = serializeAssetType(change.asset_type); - return { - asset: - assetType === 'stx' - ? { - type: 'stx', - } - : { - type: assetType, - identifier: change.asset_identifier, - }, - balance_change: { - sent: change.sent, - received: change.received, - net: change.net, - }, - }; -} - -/** - * Parses a database principal transaction balance change into a principal balance change - * (the flattened batch shape that carries `tx_id` alongside the asset and balance fields). - * @param change - The database principal transaction balance change to parse. - * @returns The parsed principal balance change. - */ -export function serializePrincipalBalanceChange( - change: DbPrincipalTransactionBalanceChange -): PrincipalBalanceChange { - return { - tx_id: change.tx_id, - ...serializePrincipalTransactionBalanceChange(change), - }; -} diff --git a/src/api/serializers/v3/transactions.ts b/src/api/serializers/v3/transactions.ts index d7b46a4bd..d558ed523 100644 --- a/src/api/serializers/v3/transactions.ts +++ b/src/api/serializers/v3/transactions.ts @@ -12,11 +12,12 @@ import { } from '../../schemas/v3/entities/transaction-summaries.js'; import { DbMempoolTransaction, + DbPrincipalTransactionBalanceChange, DbPrincipalTransactionSummary, DbTransaction, DbTransactionSummary, } from '../../../datastore/v3/types.js'; -import { DbTxStatus, DbTxTypeId } from '../../../datastore/common.js'; +import { DbAssetType, DbTxStatus, DbTxTypeId } from '../../../datastore/common.js'; import { PrincipalTransactionSummary } from '../../schemas/v3/entities/principal-transactions.js'; import { BaseTransaction, @@ -37,6 +38,10 @@ import { } from '@stacks/codec'; import { serializePostCondition } from './post-conditions.js'; import { serializeDbMempoolTransaction } from './mempool-transactions.js'; +import { + PrincipalBalanceChange, + PrincipalTransactionBalanceChange, +} from '../../schemas/v3/entities/principal-balance-changes.js'; /** * Serializes a database transaction summary status into a transaction summary status. @@ -356,3 +361,59 @@ export function serializeDbTransactionOrMempoolTransaction( } return serializeDbMempoolTransaction(transaction, include); } + +function serializeAssetType(type: DbAssetType): 'stx' | 'ft' | 'nft' { + switch (type) { + case DbAssetType.Stx: + return 'stx'; + case DbAssetType.Ft: + return 'ft'; + case DbAssetType.Nft: + return 'nft'; + default: + throw new Error(`Unexpected DbAssetType: ${type}`); + } +} + +/** + * Parses a database principal transaction balance change into a principal transaction balance + * change. + * @param change - The database principal transaction balance change to parse. + * @returns The parsed principal transaction balance change. + */ +export function serializePrincipalTransactionBalanceChange( + change: DbPrincipalTransactionBalanceChange +): PrincipalTransactionBalanceChange { + const assetType = serializeAssetType(change.asset_type); + return { + asset: + assetType === 'stx' + ? { + type: 'stx', + } + : { + type: assetType, + identifier: change.asset_identifier, + }, + balance_change: { + sent: change.sent, + received: change.received, + net: change.net, + }, + }; +} + +/** + * Parses a database principal transaction balance change into a principal balance change + * (the flattened batch shape that carries `tx_id` alongside the asset and balance fields). + * @param change - The database principal transaction balance change to parse. + * @returns The parsed principal balance change. + */ +export function serializePrincipalBalanceChange( + change: DbPrincipalTransactionBalanceChange +): PrincipalBalanceChange { + return { + tx_id: change.tx_id, + ...serializePrincipalTransactionBalanceChange(change), + }; +} From 34002b719ee80668441f55bbf5548ab8bb007c48 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Wed, 27 May 2026 14:12:29 -0600 Subject: [PATCH 31/32] fix: import --- src/api/serializers/v3/transaction-events.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/serializers/v3/transaction-events.ts b/src/api/serializers/v3/transaction-events.ts index 9d26c3796..b331d454b 100644 --- a/src/api/serializers/v3/transaction-events.ts +++ b/src/api/serializers/v3/transaction-events.ts @@ -1,6 +1,6 @@ import { TransactionEvent } from '../../schemas/v3/entities/transaction-events.js'; import { DbTransactionEvent } from '../../../datastore/v3/types.js'; -import { DbAssetEventTypeId, DbEventTypeId } from 'src/datastore/common.js'; +import { DbAssetEventTypeId, DbEventTypeId } from '../../../datastore/common.js'; import { decodeClarityValueToRepr } from '@stacks/codec'; /** From 925cea46afd7514732ea5df8e6e3eedf10901f7e Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:48:17 -0600 Subject: [PATCH 32/32] move migration --- ...on-indexes.ts => 1779552862561_tx-event-pagination-indexes.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrations/{1775100000000_tx-event-pagination-indexes.ts => 1779552862561_tx-event-pagination-indexes.ts} (100%) diff --git a/migrations/1775100000000_tx-event-pagination-indexes.ts b/migrations/1779552862561_tx-event-pagination-indexes.ts similarity index 100% rename from migrations/1775100000000_tx-event-pagination-indexes.ts rename to migrations/1779552862561_tx-event-pagination-indexes.ts