From 64884d102c6b570c8c732e3a026e6264ec6c6f36 Mon Sep 17 00:00:00 2001 From: adefemiesther1-debug Date: Wed, 24 Jun 2026 15:54:49 +0000 Subject: [PATCH 1/3] feat(frontend): implement advanced analytics user tracking funnel metrics and ab testing framework --- frontend/package-lock.json | 456 +----------------- frontend/src/__tests__/analytics.test.ts | 194 ++++++++ frontend/src/app/analytics/page.tsx | 179 +++++++ frontend/src/app/layout.tsx | 9 +- .../providers/AnalyticsProvider.tsx | 85 ++++ .../components/providers/ConsentBanner.tsx | 53 ++ frontend/src/hooks/useAnalytics.ts | 2 + frontend/src/lib/abTesting.ts | 90 ++++ frontend/src/lib/analytics.ts | 170 +++++++ frontend/src/lib/analyticsAdapters.ts | 24 + frontend/src/types/analytics.ts | 143 ++++++ 11 files changed, 956 insertions(+), 449 deletions(-) create mode 100644 frontend/src/__tests__/analytics.test.ts create mode 100644 frontend/src/app/analytics/page.tsx create mode 100644 frontend/src/components/providers/AnalyticsProvider.tsx create mode 100644 frontend/src/components/providers/ConsentBanner.tsx create mode 100644 frontend/src/hooks/useAnalytics.ts create mode 100644 frontend/src/lib/abTesting.ts create mode 100644 frontend/src/lib/analytics.ts create mode 100644 frontend/src/lib/analyticsAdapters.ts create mode 100644 frontend/src/types/analytics.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 13b08049..75b7937a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3753,7 +3753,7 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", @@ -3767,7 +3767,7 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -3777,7 +3777,7 @@ "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -3788,7 +3788,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -3857,13 +3857,6 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "license": "MIT", - "peer": true - }, "node_modules/@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -4185,7 +4178,7 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/qrcode": { @@ -4202,7 +4195,7 @@ "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -4758,181 +4751,6 @@ "win32" ] }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -4945,19 +4763,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -6209,16 +6014,6 @@ "node": ">= 6" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.0" - } - }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -6478,7 +6273,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/d3-array": { @@ -7033,20 +6828,6 @@ "node": ">= 4" } }, - "node_modules/enhanced-resolve": { - "version": "5.22.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", - "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -7204,13 +6985,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "license": "MIT", - "peer": true - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -7748,16 +7522,6 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/eventsource": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", @@ -8404,13 +8168,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -10855,20 +10612,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/loader-utils": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", @@ -11176,13 +10919,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "license": "MIT" }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT", - "peer": true - }, "node_modules/next": { "version": "14.2.25", "resolved": "https://registry.npmjs.org/next/-/next-14.2.25.tgz", @@ -12293,6 +12029,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, "license": "MIT" }, "node_modules/react-redux": { @@ -13516,20 +13253,6 @@ "tailwindcss": ">=3.0.0 || insiders" } }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -14092,6 +13815,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -14350,20 +14074,6 @@ "makeerror": "1.0.12" } }, - "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "license": "MIT", - "peer": true, - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -14374,154 +14084,6 @@ "node": ">=12" } }, - "node_modules/webpack": { - "version": "5.107.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", - "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.22.0", - "es-module-lexer": "^2.1.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "loader-runner": "^4.3.2", - "mime-db": "^1.54.0", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.5.0", - "watchpack": "^2.5.1", - "webpack-sources": "^3.5.0" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-sources": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", - "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true - }, - "node_modules/webpack/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", diff --git a/frontend/src/__tests__/analytics.test.ts b/frontend/src/__tests__/analytics.test.ts new file mode 100644 index 00000000..d77655ff --- /dev/null +++ b/frontend/src/__tests__/analytics.test.ts @@ -0,0 +1,194 @@ +/** + * @jest-environment jsdom + */ + +import { AnalyticsService } from "@/lib/analytics"; +import { ABTestingService } from "@/lib/abTesting"; +import type { AnalyticsAdapter, AnalyticsPayload } from "@/types/analytics"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeAdapter(): AnalyticsAdapter & { events: AnalyticsPayload[] } { + const events: AnalyticsPayload[] = []; + return { + name: "test", + events, + track(payload) { + events.push(payload); + }, + }; +} + +// ── AnalyticsService ───────────────────────────────────────────────────────── + +describe("AnalyticsService", () => { + let service: AnalyticsService; + let adapter: ReturnType; + + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + service = new AnalyticsService(); + adapter = makeAdapter(); + service.registerAdapter(adapter); + }); + + it("does NOT dispatch events when consent is pending", () => { + service.track("game_start", { gameMode: "1v1" }); + expect(adapter.events).toHaveLength(0); + }); + + it("dispatches events after consent is granted", () => { + service.setConsent("granted"); + service.track("game_start", { gameMode: "1v1" }); + expect(adapter.events).toHaveLength(1); + expect(adapter.events[0].event).toBe("game_start"); + }); + + it("packs required base fields in every payload", () => { + service.setConsent("granted"); + service.track("page_view", { path: "/dashboard" }); + + const payload = adapter.events[0]; + expect(payload.event).toBe("page_view"); + expect(typeof payload.timestamp).toBe("number"); + expect(typeof payload.sessionId).toBe("string"); + }); + + it("stops dispatching after consent is denied", () => { + service.setConsent("granted"); + service.track("game_start", { gameMode: "1v1" }); + service.setConsent("denied"); + service.track("game_end", { gameMode: "1v1", durationMs: 500, outcome: "win" }); + + expect(adapter.events).toHaveLength(1); + }); + + it("scrubs session identifiers on opt-out", () => { + service.setConsent("granted"); + service.identify("user-123"); + service.setConsent("denied"); + + const session = service.getSession(); + expect(session.sessionId).toBe("anonymous"); + expect(session.userId).toBeUndefined(); + }); + + it("includes userId after identify()", () => { + service.setConsent("granted"); + service.identify("user-42"); + service.track("profile_viewed"); + + expect(adapter.events[0].userId).toBe("user-42"); + }); + + it("persists consent to localStorage", () => { + service.setConsent("granted"); + const raw = localStorage.getItem("arenax:analytics:consent"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(parsed.analytics).toBe("granted"); + }); + + it("does not register the same adapter twice", () => { + service.registerAdapter(adapter); // duplicate + service.setConsent("granted"); + service.track("auth_login"); + expect(adapter.events).toHaveLength(1); + }); +}); + +// ── ABTestingService ────────────────────────────────────────────────────────── + +describe("ABTestingService", () => { + let abService: ABTestingService; + + beforeEach(() => { + localStorage.clear(); + abService = new ABTestingService(); + }); + + it("assigns either control or variant", () => { + const result = abService.getVariant({ id: "exp-1", splitRatio: 0.5 }, "user-1"); + expect(["control", "variant"]).toContain(result); + }); + + it("returns the same variant on repeated calls (deterministic)", () => { + const exp = { id: "exp-stable", splitRatio: 0.5 }; + const v1 = abService.getVariant(exp, "user-abc"); + const v2 = abService.getVariant(exp, "user-abc"); + expect(v1).toBe(v2); + }); + + it("persists assignment to localStorage", () => { + abService.getVariant({ id: "exp-persist", splitRatio: 0.5 }, "user-x"); + const raw = localStorage.getItem("arenax:ab:assignments"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(parsed["exp-persist"]).toBeDefined(); + }); + + it("uses stored assignment instead of recomputing", () => { + const exp = { id: "exp-cached", splitRatio: 0.5 }; + const first = abService.getVariant(exp, "user-y"); + // Create fresh service (re-reads localStorage) to test persistence + const fresh = new ABTestingService(); + const second = fresh.getVariant(exp, "user-y"); + expect(first).toBe(second); + }); + + it("clears all assignments", () => { + abService.getVariant({ id: "exp-clear", splitRatio: 0.5 }, "user-z"); + abService.clearAssignments(); + expect(abService.getAllAssignments()).toHaveLength(0); + expect(localStorage.getItem("arenax:ab:assignments")).toBeNull(); + }); + + it("distributes users roughly 50/50 with 0.5 split", () => { + const exp = { id: "exp-dist", splitRatio: 0.5 }; + const variants = Array.from({ length: 200 }, (_, i) => + abService.getVariant(exp, `user-${i}`) + ); + // Use a fresh instance per user so persistence doesn't skew counts + const freshVariants = Array.from({ length: 200 }, (_, i) => { + localStorage.clear(); + const svc = new ABTestingService(); + return svc.getVariant(exp, `user-${i}`); + }); + const variantCount = freshVariants.filter((v) => v === "variant").length; + // Expect between 30%–70% to be variant (loose bound for deterministic hash) + expect(variantCount).toBeGreaterThan(40); + expect(variantCount).toBeLessThan(160); + void variants; // silence unused warning + }); + + it("always assigns variant when splitRatio is 1", () => { + const exp = { id: "exp-all-variant", splitRatio: 1 }; + const results = Array.from({ length: 10 }, (_, i) => { + localStorage.clear(); + return new ABTestingService().getVariant(exp, `u-${i}`); + }); + expect(results.every((v) => v === "variant")).toBe(true); + }); + + it("always assigns control when splitRatio is 0", () => { + const exp = { id: "exp-all-control", splitRatio: 0 }; + const results = Array.from({ length: 10 }, (_, i) => { + localStorage.clear(); + return new ABTestingService().getVariant(exp, `u-${i}`); + }); + expect(results.every((v) => v === "control")).toBe(true); + }); +}); + +// ── Consent persistence ─────────────────────────────────────────────────────── + +describe("Consent persistence", () => { + it("new service instance inherits previously saved consent", () => { + const s1 = new AnalyticsService(); + s1.setConsent("granted"); + + const s2 = new AnalyticsService(); + expect(s2.getConsent().analytics).toBe("granted"); + }); +}); diff --git a/frontend/src/app/analytics/page.tsx b/frontend/src/app/analytics/page.tsx new file mode 100644 index 00000000..ed4e509f --- /dev/null +++ b/frontend/src/app/analytics/page.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { + AreaChart, + Area, + BarChart, + Bar, + FunnelChart, + Funnel, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, + LabelList, +} from "recharts"; + +// ── Mock data (replace with real API calls) ─────────────────────────────────── + +const dailyEvents = [ + { date: "Jun 18", events: 420 }, + { date: "Jun 19", events: 610 }, + { date: "Jun 20", events: 540 }, + { date: "Jun 21", events: 780 }, + { date: "Jun 22", events: 920 }, + { date: "Jun 23", events: 860 }, + { date: "Jun 24", events: 1040 }, +]; + +const funnelSteps = [ + { name: "Page Visit", value: 1000, fill: "#6366f1" }, + { name: "Registration", value: 640, fill: "#818cf8" }, + { name: "Game Start", value: 420, fill: "#a5b4fc" }, + { name: "Tournament Joined", value: 210, fill: "#c7d2fe" }, + { name: "Purchase Completed", value: 88, fill: "#e0e7ff" }, +]; + +const abData = [ + { variant: "Control", conversions: 12.4 }, + { variant: "Variant", conversions: 17.8 }, +]; + +const topEvents = [ + { event: "page_view", count: 4210 }, + { event: "game_start", count: 1840 }, + { event: "matchmaking_queued", count: 1320 }, + { event: "tournament_joined", count: 870 }, + { event: "purchase_completed", count: 310 }, +]; + +// ── Sub-components ───────────────────────────────────────────────────────────── + +function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +// ── Main component ───────────────────────────────────────────────────────────── + +export default function AnalyticsDashboardPage() { + return ( +
+

Analytics Dashboard

+ + {/* KPI cards */} +
+ + + + +
+ + {/* Event trend */} +
+

Daily Events (7d)

+ + + + + + + + + + + + + + + +
+ +
+ {/* Activation funnel */} +
+

Activation Funnel

+ + + + + + {funnelSteps.map((entry) => ( + + ))} + + + +
+ + {/* A/B test comparison */} +
+

A/B Test — Conversion %

+ + + + + + [`${v}%`, "Conversion"]} + /> + + + + + + +
+
+ + {/* Top events table */} +
+

Top Events (7d)

+ + + + + + + + + {topEvents.map((row, i) => ( + + + + + ))} + +
EventCount
{row.event} + {row.count.toLocaleString()} +
+
+
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 2784b020..3390e103 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -9,6 +9,8 @@ import { TxStatusProvider } from "@/hooks/useTxStatus"; import { WalletProvider } from "@/hooks/useWallet"; import { NotificationProvider } from "@/contexts/NotificationContext"; import { WebVitalsInit } from "@/components/providers/WebVitalsInit"; +import { AnalyticsProvider } from "@/components/providers/AnalyticsProvider"; +import { ConsentBanner } from "@/components/providers/ConsentBanner"; export const metadata: Metadata = { title: "ArenaX", @@ -49,8 +51,11 @@ export default function RootLayout({ - - {children} + + + {children} + + diff --git a/frontend/src/components/providers/AnalyticsProvider.tsx b/frontend/src/components/providers/AnalyticsProvider.tsx new file mode 100644 index 00000000..7504395c --- /dev/null +++ b/frontend/src/components/providers/AnalyticsProvider.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React, { createContext, useCallback, useContext, useEffect, useMemo } from "react"; +import { getAnalyticsService } from "@/lib/analytics"; +import { getABTestingService } from "@/lib/abTesting"; +import { consoleAdapter } from "@/lib/analyticsAdapters"; +import type { ABExperiment, ABVariant, ConsentState } from "@/types/analytics"; +import type { AnalyticsEventName } from "@/types/analytics"; + +interface AnalyticsContextValue { + track: (event: AnalyticsEventName, props?: Record) => void; + identify: (userId: string, traits?: Record) => void; + reset: () => void; + setConsent: (status: "granted" | "denied") => void; + getConsent: () => ConsentState; + getVariant: (experiment: ABExperiment, userId: string) => ABVariant; +} + +const AnalyticsContext = createContext(null); + +export function AnalyticsProvider({ children }: { children: React.ReactNode }) { + const service = useMemo(() => { + const svc = getAnalyticsService(); + svc.registerAdapter(consoleAdapter); + return svc; + }, []); + + const abService = useMemo(() => getABTestingService(), []); + + // Auto-track page views on route changes (pathname changes) + useEffect(() => { + const consent = service.getConsent(); + if (consent.analytics !== "granted") return; + service.track("page_view", { + path: window.location.pathname, + referrer: document.referrer || undefined, + }); + }, [service]); + + const track = useCallback( + (event: AnalyticsEventName, props?: Record) => { + service.track(event, props); + }, + [service] + ); + + const identify = useCallback( + (userId: string, traits?: Record) => { + service.identify(userId, traits); + }, + [service] + ); + + const reset = useCallback(() => { + service.reset(); + abService.clearAssignments(); + }, [service, abService]); + + const setConsent = useCallback( + (status: "granted" | "denied") => { + service.setConsent(status); + }, + [service] + ); + + const getConsent = useCallback(() => service.getConsent(), [service]); + + const getVariant = useCallback( + (experiment: ABExperiment, userId: string) => abService.getVariant(experiment, userId), + [abService] + ); + + const value = useMemo( + () => ({ track, identify, reset, setConsent, getConsent, getVariant }), + [track, identify, reset, setConsent, getConsent, getVariant] + ); + + return {children}; +} + +export function useAnalytics(): AnalyticsContextValue { + const ctx = useContext(AnalyticsContext); + if (!ctx) throw new Error("useAnalytics must be used within "); + return ctx; +} diff --git a/frontend/src/components/providers/ConsentBanner.tsx b/frontend/src/components/providers/ConsentBanner.tsx new file mode 100644 index 00000000..fc81a10b --- /dev/null +++ b/frontend/src/components/providers/ConsentBanner.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAnalytics } from "@/hooks/useAnalytics"; + +export function ConsentBanner() { + const { getConsent, setConsent } = useAnalytics(); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const { analytics } = getConsent(); + setVisible(analytics === "pending"); + }, [getConsent]); + + if (!visible) return null; + + function handleAccept() { + setConsent("granted"); + setVisible(false); + } + + function handleDecline() { + setConsent("denied"); + setVisible(false); + } + + return ( +
+

+ We use analytics to improve your experience. You can opt out at any time in{" "} + Settings → Privacy. +

+
+ + +
+
+ ); +} diff --git a/frontend/src/hooks/useAnalytics.ts b/frontend/src/hooks/useAnalytics.ts new file mode 100644 index 00000000..a207c3f6 --- /dev/null +++ b/frontend/src/hooks/useAnalytics.ts @@ -0,0 +1,2 @@ +// Re-export so consumers can import from the hooks dir +export { useAnalytics } from "@/components/providers/AnalyticsProvider"; diff --git a/frontend/src/lib/abTesting.ts b/frontend/src/lib/abTesting.ts new file mode 100644 index 00000000..fb6c5ee5 --- /dev/null +++ b/frontend/src/lib/abTesting.ts @@ -0,0 +1,90 @@ +"use client"; + +import type { ABAssignment, ABExperiment, ABVariant } from "@/types/analytics"; + +const AB_STORAGE_KEY = "arenax:ab:assignments"; + +function loadAssignments(): Record { + if (typeof window === "undefined") return {}; + try { + const raw = localStorage.getItem(AB_STORAGE_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } +} + +function saveAssignments(assignments: Record): void { + if (typeof window === "undefined") return; + localStorage.setItem(AB_STORAGE_KEY, JSON.stringify(assignments)); +} + +/** + * Deterministically assign a user to a variant for a given experiment. + * Uses a simple hash of userId + experimentId so the assignment is stable + * across page loads before being persisted. + */ +function deterministicVariant( + userId: string, + experimentId: string, + splitRatio: number +): ABVariant { + const str = `${userId}:${experimentId}`; + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash * 31 + str.charCodeAt(i)) >>> 0; + } + // Normalize to 0–1 + const normalized = (hash % 1000) / 1000; + return normalized < splitRatio ? "variant" : "control"; +} + +export class ABTestingService { + private assignments: Record; + + constructor() { + this.assignments = loadAssignments(); + } + + /** + * Get (or create and persist) the variant assignment for a user/experiment pair. + */ + getVariant(experiment: ABExperiment, userId: string): ABVariant { + const existing = this.assignments[experiment.id]; + if (existing) return existing.variant; + + const variant = deterministicVariant(userId, experiment.id, experiment.splitRatio); + const assignment: ABAssignment = { + experimentId: experiment.id, + variant, + assignedAt: Date.now(), + }; + + this.assignments[experiment.id] = assignment; + saveAssignments(this.assignments); + return variant; + } + + getAssignment(experimentId: string): ABAssignment | null { + return this.assignments[experimentId] ?? null; + } + + getAllAssignments(): ABAssignment[] { + return Object.values(this.assignments); + } + + /** Clear all stored assignments (e.g., on logout). */ + clearAssignments(): void { + this.assignments = {}; + if (typeof window !== "undefined") { + localStorage.removeItem(AB_STORAGE_KEY); + } + } +} + +let _abInstance: ABTestingService | null = null; + +export function getABTestingService(): ABTestingService { + if (!_abInstance) _abInstance = new ABTestingService(); + return _abInstance; +} diff --git a/frontend/src/lib/analytics.ts b/frontend/src/lib/analytics.ts new file mode 100644 index 00000000..e42818bb --- /dev/null +++ b/frontend/src/lib/analytics.ts @@ -0,0 +1,170 @@ +"use client"; + +import type { + AnalyticsAdapter, + AnalyticsEventName, + AnalyticsPayload, + ConsentState, + SessionProperties, +} from "@/types/analytics"; + +const CONSENT_KEY = "arenax:analytics:consent"; +const SESSION_KEY = "arenax:analytics:session"; + +function generateId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; +} + +function loadConsent(): ConsentState { + if (typeof window === "undefined") return { analytics: "pending", updatedAt: null }; + try { + const raw = localStorage.getItem(CONSENT_KEY); + if (raw) return JSON.parse(raw) as ConsentState; + } catch { + // ignore + } + return { analytics: "pending", updatedAt: null }; +} + +function saveConsent(state: ConsentState): void { + if (typeof window === "undefined") return; + localStorage.setItem(CONSENT_KEY, JSON.stringify(state)); +} + +function loadOrCreateSession(userId?: string): SessionProperties { + if (typeof window === "undefined") { + return { + sessionId: generateId(), + userId, + deviceType: "unknown", + screenWidth: 0, + screenHeight: 0, + sessionStart: Date.now(), + }; + } + try { + const raw = sessionStorage.getItem(SESSION_KEY); + if (raw) { + const existing = JSON.parse(raw) as SessionProperties; + if (userId && !existing.userId) { + existing.userId = userId; + sessionStorage.setItem(SESSION_KEY, JSON.stringify(existing)); + } + return existing; + } + } catch { + // ignore + } + const session: SessionProperties = { + sessionId: generateId(), + userId, + deviceType: getDeviceType(), + screenWidth: window.screen.width, + screenHeight: window.screen.height, + sessionStart: Date.now(), + }; + sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)); + return session; +} + +function getDeviceType(): string { + if (typeof navigator === "undefined") return "unknown"; + if (/Mobi|Android/i.test(navigator.userAgent)) return "mobile"; + if (/Tablet|iPad/i.test(navigator.userAgent)) return "tablet"; + return "desktop"; +} + +export class AnalyticsService { + private adapters: AnalyticsAdapter[] = []; + private consent: ConsentState; + private session: SessionProperties; + + constructor() { + this.consent = loadConsent(); + this.session = loadOrCreateSession(); + } + + // ── Adapter management ──────────────────────────────────────────────────── + + registerAdapter(adapter: AnalyticsAdapter): void { + if (!this.adapters.find((a) => a.name === adapter.name)) { + this.adapters.push(adapter); + } + } + + // ── Consent ─────────────────────────────────────────────────────────────── + + getConsent(): ConsentState { + return { ...this.consent }; + } + + setConsent(analytics: "granted" | "denied"): void { + this.consent = { analytics, updatedAt: Date.now() }; + saveConsent(this.consent); + + if (analytics === "denied") { + this.scrubSession(); + } + } + + private scrubSession(): void { + if (typeof window === "undefined") return; + // Remove ephemeral identifiers on opt-out + sessionStorage.removeItem(SESSION_KEY); + this.session = { + sessionId: "anonymous", + deviceType: "unknown", + screenWidth: 0, + screenHeight: 0, + sessionStart: 0, + }; + } + + // ── Session ─────────────────────────────────────────────────────────────── + + getSession(): SessionProperties { + return { ...this.session }; + } + + identify(userId: string, traits?: Record): void { + if (this.consent.analytics !== "granted") return; + this.session.userId = userId; + if (typeof window !== "undefined") { + try { + sessionStorage.setItem(SESSION_KEY, JSON.stringify(this.session)); + } catch { + // ignore + } + } + this.adapters.forEach((a) => a.identify?.(userId, traits)); + } + + reset(): void { + this.scrubSession(); + this.adapters.forEach((a) => a.reset?.()); + } + + // ── Tracking ────────────────────────────────────────────────────────────── + + track(event: AnalyticsEventName, props?: Record): void { + if (this.consent.analytics !== "granted") return; + + const payload: AnalyticsPayload = { + event, + timestamp: Date.now(), + sessionId: this.session.sessionId, + userId: this.session.userId, + ...(props ?? {}), + } as AnalyticsPayload; + + this.adapters.forEach((a) => a.track(payload)); + } +} + +// Singleton +let _instance: AnalyticsService | null = null; + +export function getAnalyticsService(): AnalyticsService { + if (!_instance) _instance = new AnalyticsService(); + return _instance; +} diff --git a/frontend/src/lib/analyticsAdapters.ts b/frontend/src/lib/analyticsAdapters.ts new file mode 100644 index 00000000..091beefd --- /dev/null +++ b/frontend/src/lib/analyticsAdapters.ts @@ -0,0 +1,24 @@ +import type { AnalyticsAdapter, AnalyticsPayload } from "@/types/analytics"; + +/** + * Console adapter — logs events in dev; swap for Mixpanel/PostHog/GA in prod + * by implementing the same AnalyticsAdapter interface. + */ +export const consoleAdapter: AnalyticsAdapter = { + name: "console", + track(payload: AnalyticsPayload) { + if (process.env.NODE_ENV === "development") { + console.log("[Analytics]", payload.event, payload); + } + }, + identify(userId: string, traits?: Record) { + if (process.env.NODE_ENV === "development") { + console.log("[Analytics] identify", userId, traits); + } + }, + reset() { + if (process.env.NODE_ENV === "development") { + console.log("[Analytics] reset"); + } + }, +}; diff --git a/frontend/src/types/analytics.ts b/frontend/src/types/analytics.ts new file mode 100644 index 00000000..17acd6cb --- /dev/null +++ b/frontend/src/types/analytics.ts @@ -0,0 +1,143 @@ +// Analytics types and event schema for ArenaX + +export type ConsentStatus = "granted" | "denied" | "pending"; + +export interface ConsentState { + analytics: ConsentStatus; + updatedAt: number | null; +} + +// Strongly-typed event names +export type AnalyticsEventName = + | "page_view" + | "game_start" + | "game_end" + | "matchmaking_queued" + | "matchmaking_matched" + | "matchmaking_cancelled" + | "tournament_viewed" + | "tournament_joined" + | "tournament_left" + | "match_score_reported" + | "match_disputed" + | "purchase_initiated" + | "purchase_completed" + | "purchase_failed" + | "wallet_connected" + | "wallet_deposited" + | "wallet_withdrawn" + | "auth_signup" + | "auth_login" + | "auth_logout" + | "profile_viewed" + | "profile_edited" + | "achievement_unlocked" + | "ab_test_assigned" + | "funnel_step"; + +export interface SessionProperties { + sessionId: string; + userId?: string; + deviceType: string; + screenWidth: number; + screenHeight: number; + sessionStart: number; +} + +export interface BaseEventPayload { + event: AnalyticsEventName; + timestamp: number; + sessionId: string; + userId?: string; +} + +// Per-event payload shapes +export interface PageViewPayload extends BaseEventPayload { + event: "page_view"; + path: string; + referrer?: string; +} + +export interface GameStartPayload extends BaseEventPayload { + event: "game_start"; + gameMode: string; + tournamentId?: string; +} + +export interface GameEndPayload extends BaseEventPayload { + event: "game_end"; + gameMode: string; + durationMs: number; + outcome: "win" | "loss" | "draw"; +} + +export interface MatchmakingPayload extends BaseEventPayload { + event: "matchmaking_queued" | "matchmaking_matched" | "matchmaking_cancelled"; + gameMode: string; + waitTimeMs?: number; +} + +export interface TournamentPayload extends BaseEventPayload { + event: "tournament_viewed" | "tournament_joined" | "tournament_left"; + tournamentId: string; + entryFee?: number; +} + +export interface PurchasePayload extends BaseEventPayload { + event: "purchase_initiated" | "purchase_completed" | "purchase_failed"; + amount: number; + currency: string; + method?: string; +} + +export interface ABTestPayload extends BaseEventPayload { + event: "ab_test_assigned"; + experimentId: string; + variant: string; +} + +export interface FunnelStepPayload extends BaseEventPayload { + event: "funnel_step"; + funnelName: string; + stepName: string; + stepIndex: number; +} + +export interface GenericPayload extends BaseEventPayload { + event: AnalyticsEventName; + [key: string]: unknown; +} + +export type AnalyticsPayload = + | PageViewPayload + | GameStartPayload + | GameEndPayload + | MatchmakingPayload + | TournamentPayload + | PurchasePayload + | ABTestPayload + | FunnelStepPayload + | GenericPayload; + +// Adapter interface for third-party integrations +export interface AnalyticsAdapter { + name: string; + track(payload: AnalyticsPayload): void; + identify?(userId: string, traits?: Record): void; + reset?(): void; +} + +// A/B test types +export type ABVariant = "control" | "variant"; + +export interface ABExperiment { + id: string; + /** 0–1 fraction of users assigned to variant */ + splitRatio: number; +} + +export interface ABAssignment { + experimentId: string; + variant: ABVariant; + assignedAt: number; +} From f1dc02e6f16c7a729fe57cc9135bb6110114c4ac Mon Sep 17 00:00:00 2001 From: adefemiesther1-debug Date: Wed, 24 Jun 2026 16:19:01 +0000 Subject: [PATCH 2/3] feat(contracts): implement gas-optimized batch operations for bulk system tasks --- contracts/Cargo.toml | 1 + contracts/batch-operations/Cargo.toml | 20 + contracts/batch-operations/src/lib.rs | 538 ++++++++++++++++++++++++ contracts/batch-operations/src/test.rs | 559 +++++++++++++++++++++++++ 4 files changed, 1118 insertions(+) create mode 100644 contracts/batch-operations/Cargo.toml create mode 100644 contracts/batch-operations/src/lib.rs create mode 100644 contracts/batch-operations/src/test.rs diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 53a44972..b2e12067 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "batch-operations", "arenax-events", "cross-contract-utils", "oracle-integration", diff --git a/contracts/batch-operations/Cargo.toml b/contracts/batch-operations/Cargo.toml new file mode 100644 index 00000000..2988cd17 --- /dev/null +++ b/contracts/batch-operations/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "batch-operations" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "ArenaX Batch Operations — gas-optimized bulk execution for token transfers, tournament registrations, reputation updates, and NFT minting" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/batch-operations/src/lib.rs b/contracts/batch-operations/src/lib.rs new file mode 100644 index 00000000..246b6984 --- /dev/null +++ b/contracts/batch-operations/src/lib.rs @@ -0,0 +1,538 @@ +#![no_std] + +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Vec}; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/// Maximum items allowed in a single batch call. +/// Prevents out-of-gas / DoS exploits from unbounded loops. +pub const MAX_BATCH_SIZE: u32 = 100; + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum BatchError { + /// Contract has not been initialized yet. + NotInitialized = 1, + /// Caller is not the admin. + Unauthorized = 2, + /// Input vectors have mismatched lengths. + LengthMismatch = 3, + /// Empty batch — nothing to do. + EmptyBatch = 4, + /// Batch exceeds MAX_BATCH_SIZE. + BatchTooLarge = 5, + /// A token amount is zero or negative. + InvalidAmount = 6, + /// A token transfer failed (insufficient sender balance). + InsufficientBalance = 7, + /// Already initialized. + AlreadyInitialized = 8, + /// Player not registered (used in reputation batches). + PlayerNotFound = 9, + /// Tournament ID is invalid / not open for registration. + InvalidTournament = 10, + /// Player is already registered for a tournament. + AlreadyRegistered = 11, + /// Achievement ID is out of valid range (0–63). + InvalidAchievementId = 12, + /// Achievement already unlocked for this player. + AchievementAlreadyUnlocked = 13, + /// Invalid reputation delta (must be non-zero). + InvalidDelta = 14, +} + +// ─── Storage Keys ───────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + /// Token balance for an address. + Balance(Address), + /// Total token supply. + TotalSupply, + /// Player reputation score. + Reputation(Address), + /// Whether a player is registered for a tournament. + TournamentRegistration(Address, u32), + /// Achievement bitmask for a player (u64, supports 0–63). + AchievementMask(Address), + /// NFT owner mapping (token_id → owner). + NftOwner(u32), + /// Total NFTs minted. + NftCount, +} + +// ─── Result types for partial-success reporting ─────────────────────────────── + +/// Per-item result used in partial-result batch operations. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ItemResult { + /// 0-based index within the batch. + pub index: u32, + /// true = success, false = failure. + pub success: bool, + /// Error code on failure (0 when success = true). + pub error_code: u32, +} + +// ─── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct BatchOperations; + +#[contractimpl] +impl BatchOperations { + // ── Initialization ───────────────────────────────────────────────────── + + pub fn initialize(env: Env, admin: Address) -> Result<(), BatchError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(BatchError::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::TotalSupply, &0i128); + env.storage() + .instance() + .set(&DataKey::NftCount, &0u32); + Ok(()) + } + + // ── View helpers ─────────────────────────────────────────────────────── + + pub fn balance(env: Env, addr: Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::Balance(addr)) + .unwrap_or(0i128) + } + + pub fn total_supply(env: Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::TotalSupply) + .unwrap_or(0i128) + } + + pub fn reputation(env: Env, player: Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::Reputation(player)) + .unwrap_or(0i128) + } + + pub fn is_registered(env: Env, player: Address, tournament_id: u32) -> bool { + env.storage() + .instance() + .get(&DataKey::TournamentRegistration(player, tournament_id)) + .unwrap_or(false) + } + + pub fn achievement_mask(env: Env, player: Address) -> u64 { + env.storage() + .instance() + .get(&DataKey::AchievementMask(player)) + .unwrap_or(0u64) + } + + pub fn nft_owner(env: Env, token_id: u32) -> Option
{ + env.storage() + .instance() + .get(&DataKey::NftOwner(token_id)) + } + + pub fn nft_count(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::NftCount) + .unwrap_or(0u32) + } + + // ── 1. batch_transfer ────────────────────────────────────────────────── + // + // ATOMIC: entire batch reverts if any transfer fails. + // Gas optimization: sender balance read once, decremented cumulatively; + // recipient reads batched per unique address via single pass. + // + /// Transfer tokens from `from` to multiple recipients atomically. + /// `recipients` and `amounts` must be the same length. + pub fn batch_transfer( + env: Env, + from: Address, + recipients: Vec
, + amounts: Vec, + ) -> Result<(), BatchError> { + Self::require_initialized(&env)?; + from.require_auth(); + + let n = recipients.len(); + Self::validate_batch(n, amounts.len())?; + + // Cache sender balance once — avoids repeated storage reads in the loop. + let mut from_balance: i128 = env + .storage() + .instance() + .get(&DataKey::Balance(from.clone())) + .unwrap_or(0); + + // Validate all amounts and total deduction before mutating any state. + let mut total_deduction: i128 = 0; + for i in 0..n { + let amt = amounts.get(i).unwrap(); + if amt <= 0 { + return Err(BatchError::InvalidAmount); + } + total_deduction = total_deduction + .checked_add(amt) + .ok_or(BatchError::InvalidAmount)?; + } + if from_balance < total_deduction { + return Err(BatchError::InsufficientBalance); + } + + // Apply all transfers atomically. + from_balance -= total_deduction; + for i in 0..n { + let to = recipients.get(i).unwrap(); + let amt = amounts.get(i).unwrap(); + + // Skip self-transfers without aborting (balance math is already correct). + if to == from { + continue; + } + + let to_balance: i128 = env + .storage() + .instance() + .get(&DataKey::Balance(to.clone())) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::Balance(to), &(to_balance + amt)); + } + env.storage() + .instance() + .set(&DataKey::Balance(from), &from_balance); + + Ok(()) + } + + // ── 2. batch_mint ────────────────────────────────────────────────────── + // + // ATOMIC: admin mints tokens to multiple recipients in one call. + // Gas optimization: total_supply updated once after loop. + // + /// Mint tokens to multiple recipients atomically. + pub fn batch_mint( + env: Env, + recipients: Vec
, + amounts: Vec, + ) -> Result<(), BatchError> { + Self::require_initialized(&env)?; + Self::require_admin(&env)?; + + let n = recipients.len(); + Self::validate_batch(n, amounts.len())?; + + // Validate all amounts up front (fail-fast, no partial state). + for i in 0..n { + if amounts.get(i).unwrap() <= 0 { + return Err(BatchError::InvalidAmount); + } + } + + // Cache total_supply once — single read, single write. + let mut supply: i128 = env + .storage() + .instance() + .get(&DataKey::TotalSupply) + .unwrap_or(0); + + for i in 0..n { + let to = recipients.get(i).unwrap(); + let amt = amounts.get(i).unwrap(); + + let bal: i128 = env + .storage() + .instance() + .get(&DataKey::Balance(to.clone())) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::Balance(to), &(bal + amt)); + supply += amt; + } + + // Single write for supply — avoids n storage writes. + env.storage() + .instance() + .set(&DataKey::TotalSupply, &supply); + + Ok(()) + } + + // ── 3. batch_register_tournaments ───────────────────────────────────── + // + // PARTIAL-RESULT: each item is attempted independently. + // Caller receives per-item success/error codes so upstream can retry + // individual failures without losing successful registrations. + // + /// Register `player` for multiple tournaments. + /// Returns per-item results (partial success is allowed). + pub fn batch_register_tournaments( + env: Env, + player: Address, + tournament_ids: Vec, + ) -> Result, BatchError> { + Self::require_initialized(&env)?; + player.require_auth(); + + let n = tournament_ids.len(); + if n == 0 { + return Err(BatchError::EmptyBatch); + } + if n > MAX_BATCH_SIZE { + return Err(BatchError::BatchTooLarge); + } + + let mut results: Vec = Vec::new(&env); + + for i in 0..n { + let tid = tournament_ids.get(i).unwrap(); + + let already: bool = env + .storage() + .instance() + .get(&DataKey::TournamentRegistration(player.clone(), tid)) + .unwrap_or(false); + + if already { + results.push_back(ItemResult { + index: i, + success: false, + error_code: BatchError::AlreadyRegistered as u32, + }); + continue; + } + + env.storage() + .instance() + .set(&DataKey::TournamentRegistration(player.clone(), tid), &true); + + results.push_back(ItemResult { + index: i, + success: true, + error_code: 0, + }); + } + + Ok(results) + } + + // ── 4. batch_update_reputation ───────────────────────────────────────── + // + // ATOMIC: all reputation updates applied or none. + // Gas optimization: each player's score loaded and written once via + // pre-validated iteration; no redundant storage round-trips. + // + /// Apply reputation deltas to multiple players atomically. + /// `players` and `deltas` must have the same length. + /// Positive delta = increase, negative = decrease. + pub fn batch_update_reputation( + env: Env, + players: Vec
, + deltas: Vec, + ) -> Result<(), BatchError> { + Self::require_initialized(&env)?; + Self::require_admin(&env)?; + + let n = players.len(); + Self::validate_batch(n, deltas.len())?; + + // Validate all deltas before writing (full atomicity). + for i in 0..n { + if deltas.get(i).unwrap() == 0 { + return Err(BatchError::InvalidDelta); + } + } + + for i in 0..n { + let player = players.get(i).unwrap(); + let delta = deltas.get(i).unwrap(); + + let current: i128 = env + .storage() + .instance() + .get(&DataKey::Reputation(player.clone())) + .unwrap_or(0); + + let new_score = current.saturating_add(delta).max(0); + env.storage() + .instance() + .set(&DataKey::Reputation(player), &new_score); + } + + Ok(()) + } + + // ── 5. batch_unlock_achievements ────────────────────────────────────── + // + // PARTIAL-RESULT: unlocks achievements for a single player. + // Uses a bitmask to collapse N storage reads into 1 read + 1 write. + // Each bit position (0–63) corresponds to an achievement ID. + // + /// Unlock multiple achievements for a single player using bitmask optimization. + /// Returns per-item results (already-unlocked items marked as failed, not reverted). + pub fn batch_unlock_achievements( + env: Env, + player: Address, + achievement_ids: Vec, + ) -> Result, BatchError> { + Self::require_initialized(&env)?; + Self::require_admin(&env)?; + + let n = achievement_ids.len(); + if n == 0 { + return Err(BatchError::EmptyBatch); + } + if n > MAX_BATCH_SIZE { + return Err(BatchError::BatchTooLarge); + } + + // Single storage read for the entire achievement set. + let mut mask: u64 = env + .storage() + .instance() + .get(&DataKey::AchievementMask(player.clone())) + .unwrap_or(0u64); + + let mut results: Vec = Vec::new(&env); + + for i in 0..n { + let aid = achievement_ids.get(i).unwrap(); + + if aid > 63 { + results.push_back(ItemResult { + index: i, + success: false, + error_code: BatchError::InvalidAchievementId as u32, + }); + continue; + } + + let bit = 1u64 << aid; + if mask & bit != 0 { + results.push_back(ItemResult { + index: i, + success: false, + error_code: BatchError::AchievementAlreadyUnlocked as u32, + }); + continue; + } + + mask |= bit; + results.push_back(ItemResult { + index: i, + success: true, + error_code: 0, + }); + } + + // Single storage write — regardless of how many achievements were unlocked. + env.storage() + .instance() + .set(&DataKey::AchievementMask(player), &mask); + + Ok(results) + } + + // ── 6. batch_mint_nft ───────────────────────────────────────────────── + // + // ATOMIC: mint multiple NFTs to their respective owners. + // Gas optimization: NftCount loaded once, incremented in-memory, written once. + // + /// Mint NFTs to multiple owners atomically. + pub fn batch_mint_nft( + env: Env, + owners: Vec
, + ) -> Result, BatchError> { + Self::require_initialized(&env)?; + Self::require_admin(&env)?; + + let n = owners.len(); + if n == 0 { + return Err(BatchError::EmptyBatch); + } + if n > MAX_BATCH_SIZE { + return Err(BatchError::BatchTooLarge); + } + + // Load count once. + let mut next_id: u32 = env + .storage() + .instance() + .get(&DataKey::NftCount) + .unwrap_or(0u32); + + let mut minted_ids: Vec = Vec::new(&env); + + for i in 0..n { + let owner = owners.get(i).unwrap(); + env.storage() + .instance() + .set(&DataKey::NftOwner(next_id), &owner); + minted_ids.push_back(next_id); + next_id += 1; + } + + // Single write for the updated count. + env.storage() + .instance() + .set(&DataKey::NftCount, &next_id); + + Ok(minted_ids) + } + + // ─── Private helpers ────────────────────────────────────────────────── + + fn require_initialized(env: &Env) -> Result<(), BatchError> { + if !env.storage().instance().has(&DataKey::Admin) { + return Err(BatchError::NotInitialized); + } + Ok(()) + } + + fn require_admin(env: &Env) -> Result<(), BatchError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(BatchError::NotInitialized)?; + admin.require_auth(); + Ok(()) + } + + /// Validate that both lengths are equal, non-zero, and within MAX_BATCH_SIZE. + fn validate_batch(len_a: u32, len_b: u32) -> Result<(), BatchError> { + if len_a == 0 { + return Err(BatchError::EmptyBatch); + } + if len_a != len_b { + return Err(BatchError::LengthMismatch); + } + if len_a > MAX_BATCH_SIZE { + return Err(BatchError::BatchTooLarge); + } + Ok(()) + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod test; diff --git a/contracts/batch-operations/src/test.rs b/contracts/batch-operations/src/test.rs new file mode 100644 index 00000000..aaf07d7d --- /dev/null +++ b/contracts/batch-operations/src/test.rs @@ -0,0 +1,559 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +fn setup() -> (Env, Address, Address) { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = env.register(BatchOperations, ()); + let client = BatchOperationsClient::new(&env, &contract_id); + env.mock_all_auths(); + client.initialize(&admin); + (env, contract_id, admin) +} + +fn client<'a>(env: &'a Env, contract_id: &'a Address) -> BatchOperationsClient<'a> { + BatchOperationsClient::new(env, contract_id) +} + +fn vec_addresses(env: &Env, n: usize) -> soroban_sdk::Vec
{ + let mut v = soroban_sdk::Vec::new(env); + for _ in 0..n { + v.push_back(Address::generate(env)); + } + v +} + +fn vec_i128(env: &Env, vals: &[i128]) -> soroban_sdk::Vec { + let mut v = soroban_sdk::Vec::new(env); + for &x in vals { + v.push_back(x); + } + v +} + +fn vec_u32(env: &Env, vals: &[u32]) -> soroban_sdk::Vec { + let mut v = soroban_sdk::Vec::new(env); + for &x in vals { + v.push_back(x); + } + v +} + +/// Mint tokens to a fresh address and return it. +fn funded_sender(env: &Env, contract_id: &Address, amount: i128) -> Address { + let c = client(env, contract_id); + let sender = Address::generate(env); + let mut r = soroban_sdk::Vec::new(env); + r.push_back(sender.clone()); + let mut a = soroban_sdk::Vec::new(env); + a.push_back(amount); + c.batch_mint(&r, &a); + sender +} + +// ─── Initialize ─────────────────────────────────────────────────────────────── + +#[test] +fn test_initialize() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + assert_eq!(c.total_supply(), 0); + assert_eq!(c.nft_count(), 0); +} + +#[test] +fn test_double_initialize_fails() { + let (env, contract_id, admin) = setup(); + let c = client(&env, &contract_id); + assert_eq!( + c.try_initialize(&admin), + Err(Ok(BatchError::AlreadyInitialized)) + ); +} + +#[test] +fn test_calls_without_initialize_fail() { + let env = Env::default(); + let contract_id = env.register(BatchOperations, ()); + let c = client(&env, &contract_id); + env.mock_all_auths(); + let r = vec_addresses(&env, 1); + let a = vec_i128(&env, &[100]); + assert_eq!( + c.try_batch_mint(&r, &a), + Err(Ok(BatchError::NotInitialized)) + ); +} + +// ─── batch_mint ─────────────────────────────────────────────────────────────── + +#[test] +fn test_batch_mint_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let recipients = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(r1.clone()); + v.push_back(r2.clone()); + v + }; + let amounts = vec_i128(&env, &[500, 300]); + c.batch_mint(&recipients, &amounts); + assert_eq!(c.balance(&r1), 500); + assert_eq!(c.balance(&r2), 300); + assert_eq!(c.total_supply(), 800); +} + +#[test] +fn test_batch_mint_empty_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let empty_addr: soroban_sdk::Vec
= soroban_sdk::Vec::new(&env); + let empty_amt: soroban_sdk::Vec = soroban_sdk::Vec::new(&env); + assert_eq!( + c.try_batch_mint(&empty_addr, &empty_amt), + Err(Ok(BatchError::EmptyBatch)) + ); +} + +#[test] +fn test_batch_mint_length_mismatch_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let r = vec_addresses(&env, 2); + let a = vec_i128(&env, &[100]); + assert_eq!( + c.try_batch_mint(&r, &a), + Err(Ok(BatchError::LengthMismatch)) + ); +} + +#[test] +fn test_batch_mint_zero_amount_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let r = vec_addresses(&env, 2); + let a = vec_i128(&env, &[100, 0]); + assert_eq!( + c.try_batch_mint(&r, &a), + Err(Ok(BatchError::InvalidAmount)) + ); +} + +#[test] +fn test_batch_mint_negative_amount_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let r = vec_addresses(&env, 1); + let a = vec_i128(&env, &[-50]); + assert_eq!( + c.try_batch_mint(&r, &a), + Err(Ok(BatchError::InvalidAmount)) + ); +} + +#[test] +fn test_batch_mint_exceeds_max_size_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let n = (MAX_BATCH_SIZE + 1) as usize; + let r = vec_addresses(&env, n); + let amounts = { + let mut v = soroban_sdk::Vec::new(&env); + for _ in 0..n { + v.push_back(1i128); + } + v + }; + assert_eq!( + c.try_batch_mint(&r, &amounts), + Err(Ok(BatchError::BatchTooLarge)) + ); +} + +// ─── batch_transfer ─────────────────────────────────────────────────────────── + +#[test] +fn test_batch_transfer_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let sender = funded_sender(&env, &contract_id, 1000); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let recipients = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(r1.clone()); + v.push_back(r2.clone()); + v + }; + let amounts = vec_i128(&env, &[300, 200]); + c.batch_transfer(&sender, &recipients, &amounts); + assert_eq!(c.balance(&sender), 500); + assert_eq!(c.balance(&r1), 300); + assert_eq!(c.balance(&r2), 200); +} + +#[test] +fn test_batch_transfer_insufficient_balance_atomic_rollback() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let sender = funded_sender(&env, &contract_id, 100); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let recipients = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(r1.clone()); + v.push_back(r2.clone()); + v + }; + // Total 150 > 100 — entire batch must fail atomically. + let amounts = vec_i128(&env, &[80, 70]); + assert_eq!( + c.try_batch_transfer(&sender, &recipients, &amounts), + Err(Ok(BatchError::InsufficientBalance)) + ); + // State must be unchanged. + assert_eq!(c.balance(&sender), 100); + assert_eq!(c.balance(&r1), 0); + assert_eq!(c.balance(&r2), 0); +} + +#[test] +fn test_batch_transfer_length_mismatch_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let sender = funded_sender(&env, &contract_id, 500); + let r = vec_addresses(&env, 2); + let a = vec_i128(&env, &[100]); + assert_eq!( + c.try_batch_transfer(&sender, &r, &a), + Err(Ok(BatchError::LengthMismatch)) + ); +} + +#[test] +fn test_batch_transfer_zero_amount_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let sender = funded_sender(&env, &contract_id, 500); + let r = vec_addresses(&env, 1); + let a = vec_i128(&env, &[0]); + assert_eq!( + c.try_batch_transfer(&sender, &r, &a), + Err(Ok(BatchError::InvalidAmount)) + ); +} + +#[test] +fn test_batch_transfer_exceeds_max_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let sender = funded_sender(&env, &contract_id, 100_000); + let n = (MAX_BATCH_SIZE + 1) as usize; + let r = vec_addresses(&env, n); + let amounts = { + let mut v = soroban_sdk::Vec::new(&env); + for _ in 0..n { + v.push_back(1i128); + } + v + }; + assert_eq!( + c.try_batch_transfer(&sender, &r, &amounts), + Err(Ok(BatchError::BatchTooLarge)) + ); +} + +// ─── batch_register_tournaments ────────────────────────────────────────────── + +#[test] +fn test_batch_register_tournaments_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let ids = vec_u32(&env, &[1, 2, 3]); + let results = c.batch_register_tournaments(&player, &ids); + assert_eq!(results.len(), 3); + for i in 0..3u32 { + let r = results.get(i).unwrap(); + assert!(r.success); + assert_eq!(r.error_code, 0); + } + assert!(c.is_registered(&player, &1)); + assert!(c.is_registered(&player, &2)); + assert!(c.is_registered(&player, &3)); +} + +#[test] +fn test_batch_register_duplicate_partial_result() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + // First registration. + c.batch_register_tournaments(&player, &vec_u32(&env, &[5])); + // Re-registering 5 alongside new id 6. + let ids = vec_u32(&env, &[5, 6]); + let results = c.batch_register_tournaments(&player, &ids); + assert_eq!(results.len(), 2); + let r0 = results.get(0).unwrap(); + let r1 = results.get(1).unwrap(); + assert!(!r0.success); + assert_eq!(r0.error_code, BatchError::AlreadyRegistered as u32); + assert!(r1.success); + assert!(c.is_registered(&player, &6)); +} + +#[test] +fn test_batch_register_empty_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let empty: soroban_sdk::Vec = soroban_sdk::Vec::new(&env); + assert_eq!( + c.try_batch_register_tournaments(&player, &empty), + Err(Ok(BatchError::EmptyBatch)) + ); +} + +#[test] +fn test_batch_register_exceeds_max_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let ids = { + let mut v = soroban_sdk::Vec::new(&env); + for i in 0..(MAX_BATCH_SIZE + 1) { + v.push_back(i); + } + v + }; + assert_eq!( + c.try_batch_register_tournaments(&player, &ids), + Err(Ok(BatchError::BatchTooLarge)) + ); +} + +// ─── batch_update_reputation ───────────────────────────────────────────────── + +#[test] +fn test_batch_update_reputation_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let p1 = Address::generate(&env); + let p2 = Address::generate(&env); + let players = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(p1.clone()); + v.push_back(p2.clone()); + v + }; + let deltas = vec_i128(&env, &[100, -30]); + c.batch_update_reputation(&players, &deltas); + assert_eq!(c.reputation(&p1), 100); + assert_eq!(c.reputation(&p2), 0); // clamped at 0 +} + +#[test] +fn test_batch_update_reputation_atomic_zero_delta_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let p1 = Address::generate(&env); + let p2 = Address::generate(&env); + let players = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(p1.clone()); + v.push_back(p2.clone()); + v + }; + // Zero delta on p2 — entire batch must fail. + let deltas = vec_i128(&env, &[50, 0]); + assert_eq!( + c.try_batch_update_reputation(&players, &deltas), + Err(Ok(BatchError::InvalidDelta)) + ); + // p1 must not have been updated (atomic rollback). + assert_eq!(c.reputation(&p1), 0); +} + +#[test] +fn test_batch_update_reputation_length_mismatch_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let players = vec_addresses(&env, 2); + let deltas = vec_i128(&env, &[10]); + assert_eq!( + c.try_batch_update_reputation(&players, &deltas), + Err(Ok(BatchError::LengthMismatch)) + ); +} + +#[test] +fn test_batch_update_reputation_negative_clamps_to_zero() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let p = Address::generate(&env); + let players = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(p.clone()); + v + }; + c.batch_update_reputation(&players, &vec_i128(&env, &[-999])); + assert_eq!(c.reputation(&p), 0); +} + +// ─── batch_unlock_achievements ─────────────────────────────────────────────── + +#[test] +fn test_batch_unlock_achievements_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let ids = vec_u32(&env, &[0, 5, 63]); + let results = c.batch_unlock_achievements(&player, &ids); + assert_eq!(results.len(), 3); + for i in 0..3u32 { + assert!(results.get(i).unwrap().success); + } + let mask = c.achievement_mask(&player); + assert_ne!(mask & (1u64 << 0), 0); + assert_ne!(mask & (1u64 << 5), 0); + assert_ne!(mask & (1u64 << 63), 0); +} + +#[test] +fn test_batch_unlock_achievements_duplicate_partial() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + c.batch_unlock_achievements(&player, &vec_u32(&env, &[3])); + let ids = vec_u32(&env, &[3, 7]); + let results = c.batch_unlock_achievements(&player, &ids); + let r0 = results.get(0).unwrap(); + let r1 = results.get(1).unwrap(); + assert!(!r0.success); + assert_eq!(r0.error_code, BatchError::AchievementAlreadyUnlocked as u32); + assert!(r1.success); +} + +#[test] +fn test_batch_unlock_achievements_out_of_range_partial() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + // ID 64 is out of range (max is 63). + let ids = vec_u32(&env, &[1, 64]); + let results = c.batch_unlock_achievements(&player, &ids); + assert!(results.get(0).unwrap().success); + let r1 = results.get(1).unwrap(); + assert!(!r1.success); + assert_eq!(r1.error_code, BatchError::InvalidAchievementId as u32); +} + +#[test] +fn test_batch_unlock_achievements_bitmask_collapsed() { + // 10 achievements → still a single u64 mask value stored. + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let ids = vec_u32(&env, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + c.batch_unlock_achievements(&player, &ids); + let mask = c.achievement_mask(&player); + assert_eq!(mask, 0b11_1111_1111u64); // bits 0-9 set +} + +#[test] +fn test_batch_unlock_achievements_empty_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let empty: soroban_sdk::Vec = soroban_sdk::Vec::new(&env); + assert_eq!( + c.try_batch_unlock_achievements(&player, &empty), + Err(Ok(BatchError::EmptyBatch)) + ); +} + +// ─── batch_mint_nft ─────────────────────────────────────────────────────────── + +#[test] +fn test_batch_mint_nft_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let o1 = Address::generate(&env); + let o2 = Address::generate(&env); + let owners = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(o1.clone()); + v.push_back(o2.clone()); + v + }; + let ids = c.batch_mint_nft(&owners); + assert_eq!(ids.len(), 2); + assert_eq!(ids.get(0).unwrap(), 0); + assert_eq!(ids.get(1).unwrap(), 1); + assert_eq!(c.nft_count(), 2); + assert_eq!(c.nft_owner(&0).unwrap(), o1); + assert_eq!(c.nft_owner(&1).unwrap(), o2); +} + +#[test] +fn test_batch_mint_nft_sequential_ids() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let owners1 = vec_addresses(&env, 3); + let owners2 = vec_addresses(&env, 2); + let ids1 = c.batch_mint_nft(&owners1); + let ids2 = c.batch_mint_nft(&owners2); + assert_eq!(ids1.get(0).unwrap(), 0); + assert_eq!(ids1.get(2).unwrap(), 2); + assert_eq!(ids2.get(0).unwrap(), 3); + assert_eq!(ids2.get(1).unwrap(), 4); + assert_eq!(c.nft_count(), 5); +} + +#[test] +fn test_batch_mint_nft_empty_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let empty: soroban_sdk::Vec
= soroban_sdk::Vec::new(&env); + assert_eq!( + c.try_batch_mint_nft(&empty), + Err(Ok(BatchError::EmptyBatch)) + ); +} + +#[test] +fn test_batch_mint_nft_exceeds_max_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let owners = vec_addresses(&env, (MAX_BATCH_SIZE + 1) as usize); + assert_eq!( + c.try_batch_mint_nft(&owners), + Err(Ok(BatchError::BatchTooLarge)) + ); +} + +// ─── MAX_BATCH_SIZE boundary ────────────────────────────────────────────────── + +#[test] +fn test_exact_max_batch_size_succeeds() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let n = MAX_BATCH_SIZE as usize; + let recipients = vec_addresses(&env, n); + let amounts = { + let mut v = soroban_sdk::Vec::new(&env); + for _ in 0..n { + v.push_back(1i128); + } + v + }; + c.batch_mint(&recipients, &amounts); + assert_eq!(c.total_supply(), MAX_BATCH_SIZE as i128); +} From 18a73c63a96fc1d7faa9325c02664ce32850bc47 Mon Sep 17 00:00:00 2001 From: adefemiesther1-debug Date: Thu, 25 Jun 2026 05:31:34 +0000 Subject: [PATCH 3/3] feat(server): implement distributed request correlation id middleware and context propagation --- server/src/app.ts | 4 +- server/src/config/database.ts | 8 +++ .../src/middleware/correlation.middleware.ts | 64 +++++++++++++++++++ server/src/services/correlation.service.ts | 25 ++++++++ server/src/services/http-client.service.ts | 27 ++++++++ server/src/services/logger.service.ts | 19 +++++- server/src/services/stellar-tx.service.ts | 39 ++++++++--- server/src/types/express.d.ts | 3 + 8 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 server/src/middleware/correlation.middleware.ts create mode 100644 server/src/services/correlation.service.ts create mode 100644 server/src/services/http-client.service.ts diff --git a/server/src/app.ts b/server/src/app.ts index 1706901e..6a834f02 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -4,7 +4,7 @@ import helmet from 'helmet'; import passport from 'passport'; import { configurePassport } from './middleware/auth.middleware'; import { errorHandler } from './middleware/error.middleware'; -import { requestIdMiddleware } from './middleware/request-id.middleware'; +import { correlationMiddleware } from './middleware/correlation.middleware'; import { metricsMiddleware } from './middleware/metrics.middleware'; import routes from './routes/index'; import { getEnv } from './config/env'; @@ -77,7 +77,7 @@ export const createApp = (): Express => { }) ); app.use(express.json()); - app.use(requestIdMiddleware); + app.use(correlationMiddleware); app.use(passport.initialize()); app.use(metricsMiddleware); app.use('/api', routes); diff --git a/server/src/config/database.ts b/server/src/config/database.ts index b71112fa..0848a475 100644 --- a/server/src/config/database.ts +++ b/server/src/config/database.ts @@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client'; import { logger } from '../services/logger.service'; import { metricsService } from '../services/metrics.service'; import { getEnv } from './env'; +import { getCorrelationId } from '../services/correlation.service'; // Database connection pool configuration — read from the validated env singleton. const buildPoolConfig = () => { @@ -55,6 +56,13 @@ prisma.$use(async (params: Prisma.MiddlewareParams, next: (params: Prisma.Middle const before = Date.now(); connectionCount++; + // Decorate raw queries with a correlation_id comment for cross-DB auditing. + // e.g. SELECT 1 /* correlation_id: abc-123 */ + const correlationId = getCorrelationId(); + if (correlationId && params.args && typeof params.args === 'object' && 'query' in params.args && typeof params.args.query === 'string') { + params.args.query = `${params.args.query} /* correlation_id: ${correlationId} */`; + } + const model = params.model ?? 'unknown'; const action = params.action; diff --git a/server/src/middleware/correlation.middleware.ts b/server/src/middleware/correlation.middleware.ts new file mode 100644 index 00000000..aeadcf58 --- /dev/null +++ b/server/src/middleware/correlation.middleware.ts @@ -0,0 +1,64 @@ +/** + * correlation.middleware.ts + * + * Ingress correlation-ID middleware. + * + * For every inbound HTTP request: + * 1. Reads `X-Correlation-ID` or `traceparent` header; generates a + * UUIDv4 if neither is present. + * 2. Binds the ID to the AsyncLocalStorage context so it flows through + * every downstream `await` / callback without manual passing. + * 3. Attaches `req.correlationId` and a correlation-scoped child logger + * `req.log` for controller-level use. + * 4. Echoes the ID back on the response as `X-Correlation-ID`. + */ + +import { randomUUID } from 'node:crypto'; +import type { NextFunction, Request, Response } from 'express'; +import { logger } from '../services/logger.service'; +import { correlationStore } from '../services/correlation.service'; + +/** Header names accepted as incoming correlation carrier. */ +const INCOMING_HEADERS = ['x-correlation-id', 'x-request-id', 'traceparent'] as const; + +const extractIncoming = (req: Request): string | undefined => { + for (const name of INCOMING_HEADERS) { + const val = req.header(name); + if (val?.trim()) return val.trim(); + } + return undefined; +}; + +export const correlationMiddleware = (req: Request, res: Response, next: NextFunction): void => { + const correlationId = extractIncoming(req) ?? randomUUID(); + + // Echo back on response. + res.setHeader('X-Correlation-ID', correlationId); + + // Attach to request object for backward compat with existing code that + // already reads `req.requestId` / `req.log`. + req.requestId = correlationId; + req.correlationId = correlationId; + req.log = logger.child({ correlation_id: correlationId }); + + const startTime = Date.now(); + + req.log.info('Request started', { + method: req.method, + path: req.originalUrl, + ip: req.ip, + }); + + res.on('finish', () => { + req.log.info('Request completed', { + method: req.method, + path: req.originalUrl, + statusCode: res.statusCode, + durationMs: Date.now() - startTime, + }); + }); + + // Run the rest of the middleware / handler chain inside the async store + // so getCorrelationId() returns the right value everywhere downstream. + correlationStore.run({ correlationId }, next); +}; diff --git a/server/src/services/correlation.service.ts b/server/src/services/correlation.service.ts new file mode 100644 index 00000000..18209a8e --- /dev/null +++ b/server/src/services/correlation.service.ts @@ -0,0 +1,25 @@ +/** + * correlation.service.ts + * + * Async-context store for request correlation IDs. + * + * AsyncLocalStorage propagates the store automatically through every + * `await`, callback, and Promise continuation that runs within the + * same async context — no manual variable passing required. + * + * Usage: + * - Middleware: `correlationStore.run({ correlationId }, next)` + * - Anywhere: `getCorrelationId()` → returns the active ID or undefined + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +export interface CorrelationContext { + correlationId: string; +} + +export const correlationStore = new AsyncLocalStorage(); + +/** Returns the active correlation ID, or undefined outside a request context. */ +export const getCorrelationId = (): string | undefined => + correlationStore.getStore()?.correlationId; diff --git a/server/src/services/http-client.service.ts b/server/src/services/http-client.service.ts new file mode 100644 index 00000000..4cc11477 --- /dev/null +++ b/server/src/services/http-client.service.ts @@ -0,0 +1,27 @@ +/** + * http-client.service.ts + * + * Thin wrapper around the native `fetch` API that automatically injects + * the active `X-Correlation-ID` header on every outbound HTTP request. + * + * Usage (drop-in replacement for `fetch`): + * import { correlatedFetch } from '../services/http-client.service'; + * const res = await correlatedFetch('https://api.example.com/data'); + * + * Existing call-sites can pass their own `X-Correlation-ID` in `init` + * and it will be used as-is (no override). + */ + +import { getCorrelationId } from './correlation.service'; + +export const correlatedFetch: typeof fetch = (input, init) => { + const correlationId = getCorrelationId(); + if (!correlationId) return fetch(input, init); + + const headers = new Headers(init?.headers); + if (!headers.has('X-Correlation-ID')) { + headers.set('X-Correlation-ID', correlationId); + } + + return fetch(input, { ...init, headers }); +}; diff --git a/server/src/services/logger.service.ts b/server/src/services/logger.service.ts index 594bc74b..f84a5e32 100644 --- a/server/src/services/logger.service.ts +++ b/server/src/services/logger.service.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import winston from 'winston'; import DailyRotateFile from 'winston-daily-rotate-file'; +import { getCorrelationId } from './correlation.service'; // Logger is initialised before initEnv() runs (it is imported by env.ts // indirectly), so we fall back to process.env directly here. All other @@ -16,7 +17,19 @@ if (!fs.existsSync(logDirectory)) { fs.mkdirSync(logDirectory, { recursive: true }); } +/** + * Winston format that injects the active `correlation_id` from the + * AsyncLocalStorage context into every structured log entry. + * Falls back gracefully when called outside a request context. + */ +const correlationFormat = winston.format((info) => { + const id = getCorrelationId(); + if (id) info.correlation_id = id; + return info; +}); + const baseFormat = winston.format.combine( + correlationFormat(), winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() @@ -26,14 +39,16 @@ const transports: winston.transport[] = [ new winston.transports.Console({ level: logLevel, format: winston.format.combine( + correlationFormat(), winston.format.colorize(), winston.format.timestamp(), - winston.format.printf(({ level, message, timestamp, ...metadata }) => { + winston.format.printf(({ level, message, timestamp, correlation_id, ...metadata }) => { + const cid = correlation_id ? ` [${correlation_id}]` : ''; const metadataPayload = Object.keys(metadata).length > 0 ? ` ${JSON.stringify(metadata)}` : ''; - return `${timestamp} ${level}: ${message}${metadataPayload}`; + return `${timestamp} ${level}${cid}: ${message}${metadataPayload}`; }) ) }), diff --git a/server/src/services/stellar-tx.service.ts b/server/src/services/stellar-tx.service.ts index f0a0b88d..28d19fbc 100644 --- a/server/src/services/stellar-tx.service.ts +++ b/server/src/services/stellar-tx.service.ts @@ -1,7 +1,9 @@ -import { Horizon, Transaction, FeeBumpTransaction, TransactionBuilder } from '@stellar/stellar-sdk'; +import { Horizon, Transaction, FeeBumpTransaction, TransactionBuilder, Memo } from '@stellar/stellar-sdk'; import prisma from './database.service'; import { TxStatus } from '@prisma/client'; import stellarSigningService from './stellar-signing.service'; +import { getCorrelationId } from './correlation.service'; +import { logger } from './logger.service'; export class StellarTxService { private server: Horizon.Server; @@ -14,6 +16,14 @@ export class StellarTxService { /** * Submits a transaction to the Stellar network. * Supports sponsored transactions (Fee Payers). + * + * When a correlation ID is active, it is stored in the DB log record + * as `metadata.correlationId` so cross-database auditing can link a + * Stellar tx back to the originating HTTP request. + * + * Note: Stellar memos have a 28-byte limit (MEMO_TEXT). We store the + * full correlation ID only in the DB metadata; a truncated tag is + * added to MEMO_TEXT only if the transaction carries no existing memo. */ async submitTransaction( tx: Transaction | FeeBumpTransaction, @@ -23,9 +33,17 @@ export class StellarTxService { ): Promise { let finalTx = tx; let txHash: string = ''; + const correlationId = getCorrelationId(); try { - // 1. If sponsored, wrap in a FeeBumpTransaction + // 1. Inject correlation memo on plain Transactions (not FeeBump). + if (correlationId && tx instanceof Transaction && tx.memo.type === 'none') { + // MEMO_TEXT is capped at 28 bytes; use last 27 chars prefixed with 'c:'. + const memoText = `c:${correlationId.slice(-26)}`; + (tx as any).memo = Memo.text(memoText); + } + + // 2. If sponsored, wrap in a FeeBumpTransaction if (options.sponsored && !(tx instanceof FeeBumpTransaction)) { finalTx = TransactionBuilder.buildFeeBumpTransaction( process.env.FEE_PAYER_PUBLIC_KEY || '', @@ -42,22 +60,24 @@ export class StellarTxService { txHash = (finalTx as any).hash().toString('hex'); - // 2. Initial log as PENDING - await this.logTransaction(txHash, userId, type, TxStatus.PENDING, finalTx.toXDR()); + // 3. Initial log as PENDING (includes correlationId in payload metadata) + await this.logTransaction(txHash, userId, type, TxStatus.PENDING, finalTx.toXDR(), correlationId); - // 3. Submit to Horizon + // 4. Submit to Horizon const response = await this.server.submitTransaction(finalTx); - // 4. Update to SUCCESS + // 5. Update to SUCCESS await this.updateTransactionStatus(txHash, TxStatus.SUCCESS); + logger.info('Stellar transaction submitted', { txHash, type, userId, correlationId }); + return response; } catch (error: any) { const errorMsg = error.response?.data?.extras?.result_codes?.operations ? JSON.stringify(error.response.data.extras.result_codes) : error.message; - console.error(`Transaction submission failed [${txHash}]:`, errorMsg); + logger.error(`Stellar transaction submission failed`, { txHash, errorMsg, correlationId }); if (txHash) { await this.updateTransactionStatus(txHash, TxStatus.FAILED, errorMsg); @@ -72,7 +92,8 @@ export class StellarTxService { userId: string | undefined, type: string, status: TxStatus, - payloadXdr: string + payloadXdr: string, + correlationId?: string ) { await prisma.blockchainTransaction.upsert({ where: { txHash }, @@ -82,7 +103,7 @@ export class StellarTxService { userId, type, status, - payload: { xdr: payloadXdr }, + payload: { xdr: payloadXdr, ...(correlationId ? { correlationId } : {}) }, createdAt: new Date(), updatedAt: new Date() } diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index ee8b658d..b062e42a 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -9,7 +9,10 @@ declare global { username: string; } interface Request { + /** Legacy alias — equals correlationId. Kept for backward compatibility. */ requestId: string; + /** Active correlation ID for this request. */ + correlationId: string; log: Logger; user?: User; }