From e5077a76658a509f2a1a264e6528db4c79c1352a Mon Sep 17 00:00:00 2001 From: real-venus Date: Thu, 25 Jun 2026 04:29:39 -0700 Subject: [PATCH] feat: real-time Soroban log event parser and decoded alert pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode raw contract log events (hex XDR topics + base64 SCVal body) from the Soroban RPC into typed, human-readable alerts instead of showing operators raw cryptographic hex. - types/ledgerEvents.ts: LedgerEvent discriminated union over the seven known event types plus UnknownEvent, with severity/title metadata - utils/hexDecoder.ts: total decodeLedgerEvent() — parses the topic[0] signature via @stellar/stellar-sdk ScVal, matches a lookup table, decodes the subject topic + body map per the matched schema, and degrades any unrecognized/malformed input to UnknownEvent (never throws) - hooks/useLedgerEvents: bounded newest-first raw event stream - hooks/useDecodedLedgerEvents: wraps it and decodes each event at most once (memoized by raw-event identity via a WeakMap) - components/alerts/AlertBanner: human-readable title, severity color coding, details deep-link, relative timestamp; UnknownEvent fallback with Copy Raw + Report (logs the shape to Sentry); optional Web Audio tone for high-severity (slash / reject) events, off by default - utils/alertSound: in-memory synthesized alert tone (no network asset) - services/sentry: captureUnknownLedgerEvent reporter - move @stellar/stellar-sdk to runtime dependencies (decoder uses it) Tested with vitest: encodes events with @stellar/stellar-sdk, asserts each known type's decoded output, that unknown/garbage input yields an UnknownEvent without throwing, and that 1,000 events decode in <100ms. --- package-lock.json | 43 +---- package.json | 2 +- src/components/alerts/AlertBanner.tsx | 189 ++++++++++++++++++++++ src/hooks/useDecodedLedgerEvents.ts | 46 ++++++ src/hooks/useLedgerEvents.ts | 95 +++++++++++ src/services/sentry.ts | 31 ++++ src/types/ledgerEvents.ts | 113 ++++++++++++++ src/utils/alertSound.ts | 65 ++++++++ src/utils/hexDecoder.test.ts | 177 +++++++++++++++++++++ src/utils/hexDecoder.ts | 216 ++++++++++++++++++++++++++ 10 files changed, 934 insertions(+), 43 deletions(-) create mode 100644 src/components/alerts/AlertBanner.tsx create mode 100644 src/hooks/useDecodedLedgerEvents.ts create mode 100644 src/hooks/useLedgerEvents.ts create mode 100644 src/types/ledgerEvents.ts create mode 100644 src/utils/alertSound.ts create mode 100644 src/utils/hexDecoder.test.ts create mode 100644 src/utils/hexDecoder.ts diff --git a/package-lock.json b/package-lock.json index 80b681f..2d5cc2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "verinode-frontend", "version": "0.1.0", "dependencies": { + "@stellar/stellar-sdk": "^16.0.1", "@tanstack/react-query": "^5.101.0", "chart.js": "^4.5.1", "chartjs-adapter-date-fns": "^3.0.0", @@ -23,7 +24,6 @@ "devDependencies": { "@axe-core/playwright": "^4.12.1", "@playwright/test": "^1.52.0", - "@stellar/stellar-sdk": "^16.0.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/qrcode": "^1.5.6", @@ -1879,7 +1879,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", "integrity": "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==", - "dev": true, "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" @@ -1889,7 +1888,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -2323,7 +2321,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=20.0.0", @@ -2334,7 +2331,6 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.0.1.tgz", "integrity": "sha512-bxKohaiyKVqoudRhbOOHeHhHIaeYV5Zab4rCjxhP4Ty1h1ozTLBOv8lWFnZz9ilBzXG8Bb7usQI3rlEcfvUynA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@noble/ed25519": "^3.1.0", @@ -3500,7 +3496,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "4" @@ -3758,7 +3753,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -3791,7 +3785,6 @@ "version": "1.16.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", - "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -3821,7 +3814,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -3831,7 +3823,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -3870,7 +3861,6 @@ "version": "11.1.4", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.4.tgz", "integrity": "sha512-AJ9dSeaUGj2xu7tEwmdqb51dqdb633xo4njI9K8ZFfcLrNr0XN8/EPkkZUNaF9fkCblGt2zVwZymesUdGynEkQ==", - "dev": true, "license": "MIT" }, "node_modules/brace-expansion": { @@ -3935,7 +3925,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, "funding": [ { "type": "github", @@ -3989,7 +3978,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4160,7 +4148,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -4173,7 +4160,6 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -4425,7 +4411,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -4473,7 +4458,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4597,7 +4581,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4607,7 +4590,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4652,7 +4634,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4665,7 +4646,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5211,7 +5191,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.0.tgz", "integrity": "sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==", - "dev": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -5224,7 +5203,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -5328,7 +5306,6 @@ "version": "0.0.23", "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", - "dev": true, "license": "MIT", "dependencies": { "is-retry-allowed": "^3.0.0" @@ -5402,7 +5379,6 @@ "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, "funding": [ { "type": "individual", @@ -5439,7 +5415,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -5470,7 +5445,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5540,7 +5514,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5565,7 +5538,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5653,7 +5625,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5725,7 +5696,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5738,7 +5708,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -5754,7 +5723,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5818,7 +5786,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "6", @@ -5832,7 +5799,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -6209,7 +6175,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6916,7 +6881,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6956,7 +6920,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -6966,7 +6929,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -7526,7 +7488,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -8129,7 +8090,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -8771,7 +8731,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 876ba5c..0f1f011 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:e2e:ci": "playwright test --project=wallet-ci" }, "dependencies": { + "@stellar/stellar-sdk": "^16.0.1", "@tanstack/react-query": "^5.101.0", "chart.js": "^4.5.1", "chartjs-adapter-date-fns": "^3.0.0", @@ -33,7 +34,6 @@ "devDependencies": { "@axe-core/playwright": "^4.12.1", "@playwright/test": "^1.52.0", - "@stellar/stellar-sdk": "^16.0.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/qrcode": "^1.5.6", diff --git a/src/components/alerts/AlertBanner.tsx b/src/components/alerts/AlertBanner.tsx new file mode 100644 index 0000000..9f08d58 --- /dev/null +++ b/src/components/alerts/AlertBanner.tsx @@ -0,0 +1,189 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import type { AlertSeverity, LedgerEvent } from '@/src/types/ledgerEvents' +import { playAlertTone, primeAlertAudio } from '@/src/utils/alertSound' +import { captureUnknownLedgerEvent } from '@/src/services/sentry' + +interface AlertBannerProps { + events: LedgerEvent[] + /** Audible alert for high-severity events. Off by default. */ + soundEnabled?: boolean + onToggleSound?: (enabled: boolean) => void +} + +const SEVERITY_STYLES: Record = { + error: { border: 'border-l-red-500', chip: 'bg-red-100 text-red-800', dot: 'bg-red-500' }, + warning: { border: 'border-l-yellow-500', chip: 'bg-yellow-100 text-yellow-800', dot: 'bg-yellow-500' }, + success: { border: 'border-l-green-500', chip: 'bg-green-100 text-green-800', dot: 'bg-green-500' }, + info: { border: 'border-l-zinc-400', chip: 'bg-zinc-100 text-zinc-700', dot: 'bg-zinc-400' }, +} + +function relativeTime(ts: number, now: number): string { + const s = Math.max(0, Math.floor((now - ts) / 1000)) + if (s < 5) return 'just now' + if (s < 60) return `${s}s ago` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + return `${Math.floor(h / 24)}d ago` +} + +function truncate(s: string, head = 6, tail = 4): string { + if (!s || s.length <= head + tail + 1) return s + return `${s.slice(0, head)}…${s.slice(-tail)}` +} + +/** Build a short, type-specific detail line for a decoded event. */ +function detailText(event: LedgerEvent): string { + switch (event.type) { + case 'approve_attestation': + return `Node ${truncate(event.nodeId)} · attestation ${truncate(event.attestationId)}` + case 'reject_attestation': + return `Node ${truncate(event.nodeId)} · ${event.reason || 'rejected'}` + case 'slash_node': + return `Node ${truncate(event.nodeId)} slashed ${event.amount}${event.reason ? ` · ${event.reason}` : ''}` + case 'reward_distributed': + return `Node ${truncate(event.nodeId)} · +${event.amount}` + case 'node_registered': + return `Node ${truncate(event.nodeId)} · operator ${truncate(event.operator)}` + case 'node_deregistered': + return `Node ${truncate(event.nodeId)}${event.reason ? ` · ${event.reason}` : ''}` + case 'parameter_changed': + return `${event.key}: ${event.oldValue} → ${event.newValue}` + case 'unknown': + return `Raw: 0x${(event.rawTopics[0] ?? '').slice(0, 16)}…` + } +} + +/** Details deep-link for events that map to a node. */ +function detailsHref(event: LedgerEvent): string | null { + if ('nodeId' in event && event.nodeId) { + return `/network?node=${encodeURIComponent(event.nodeId)}` + } + return null +} + +function UnknownActions({ event }: { event: LedgerEvent & { type: 'unknown' } }) { + const [copied, setCopied] = useState(false) + const [reported, setReported] = useState(false) + + const copyRaw = async () => { + const payload = JSON.stringify({ topics: event.rawTopics, body: event.rawBody }) + try { + await navigator.clipboard.writeText(payload) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + /* clipboard unavailable — no-op */ + } + } + + const report = () => { + void captureUnknownLedgerEvent({ + signature: event.signature, + rawTopics: event.rawTopics, + rawBody: event.rawBody, + }) + setReported(true) + } + + return ( +
+ + +
+ ) +} + +/** + * Real-time alert panel for decoded Soroban contract log events. Renders each + * event with a human-readable title, severity color coding, a details link and + * a relative timestamp. Unknown events degrade to a raw-hex fallback with copy + * and report affordances. High-severity events (slash / reject) optionally play + * an audible tone. + */ +export function AlertBanner({ events, soundEnabled = false, onToggleSound }: AlertBannerProps) { + const lastHighSeverityId = useRef(events.find((e) => e.highSeverity)?.id ?? null) + // Re-render relative timestamps roughly once per second. + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(t) + }, []) + + // Ring the alert when a *new* high-severity event arrives and sound is on. + useEffect(() => { + const newest = events.find((e) => e.highSeverity) + if (newest && newest.id !== lastHighSeverityId.current) { + lastHighSeverityId.current = newest.id + if (soundEnabled) playAlertTone() + } + }, [events, soundEnabled]) + + if (events.length === 0) return null + + return ( +
+
+

Live Contract Events

+ +
+ +
    + {events.map((event) => { + const styles = SEVERITY_STYLES[event.severity] + const href = detailsHref(event) + return ( +
  • +
    +
    + + + {event.title} + + + {event.severity} + +
    + +
    + +

    {detailText(event)}

    + + {event.type === 'unknown' ? ( + + ) : ( + href && ( + + View details → + + ) + )} +
  • + ) + })} +
+
+ ) +} diff --git a/src/hooks/useDecodedLedgerEvents.ts b/src/hooks/useDecodedLedgerEvents.ts new file mode 100644 index 0000000..29cbc91 --- /dev/null +++ b/src/hooks/useDecodedLedgerEvents.ts @@ -0,0 +1,46 @@ +'use client' + +import { useMemo } from 'react' +import { useLedgerEvents } from '@/src/hooks/useLedgerEvents' +import { decodeLedgerEvent } from '@/src/utils/hexDecoder' +import type { LedgerEvent, RawLedgerEvent } from '@/src/types/ledgerEvents' + +// Per-event decode cache keyed by raw-event identity (1:1 with its eventId). +// A WeakMap means entries are reclaimed automatically once a raw event ages out +// of the source buffer and is garbage-collected — no manual eviction, and no +// ref read during render. +const decodeCache = new WeakMap() + +function decodeOnce(raw: RawLedgerEvent): LedgerEvent { + const cached = decodeCache.get(raw) + if (cached) return cached + const event = decodeLedgerEvent(raw.topics, raw.body, { + id: raw.id, + timestamp: raw.timestamp, + ledgerSeq: raw.ledgerSeq, + }) + decodeCache.set(raw, event) + return event +} + +/** + * Wraps {@link useLedgerEvents} and decodes each raw event into a typed + * `LedgerEvent`. Each raw event is decoded at most once (memoized by identity), + * keeping the hot path well inside the per-event latency budget even as the + * buffer churns. + */ +export function useDecodedLedgerEvents( + options: Parameters[0] = {}, +): { + events: LedgerEvent[] + connected: boolean + error: string | null + push: (event: RawLedgerEvent) => void + clear: () => void +} { + const { events: rawEvents, connected, error, push, clear } = useLedgerEvents(options) + + const events = useMemo(() => rawEvents.map(decodeOnce), [rawEvents]) + + return { events, connected, error, push, clear } +} diff --git a/src/hooks/useLedgerEvents.ts b/src/hooks/useLedgerEvents.ts new file mode 100644 index 0000000..8918caf --- /dev/null +++ b/src/hooks/useLedgerEvents.ts @@ -0,0 +1,95 @@ +'use client' + +import { useEffect, useRef, useState, useCallback } from 'react' +import type { RawLedgerEvent } from '@/src/types/ledgerEvents' + +interface UseLedgerEventsOptions { + /** WebSocket URL streaming raw ledger events as JSON `RawLedgerEvent`s. */ + url?: string + enabled?: boolean + /** Ring-buffer cap so the dashboard never grows unbounded. */ + maxEvents?: number +} + +interface UseLedgerEventsResult { + events: RawLedgerEvent[] + connected: boolean + error: string | null + /** Manually inject an event (used by the RPC poller / tests). */ + push: (event: RawLedgerEvent) => void + clear: () => void +} + +const DEFAULT_MAX_EVENTS = 100 + +let autoSeq = 0 + +function normalize(raw: Partial): RawLedgerEvent | null { + if (!raw || !Array.isArray(raw.topics) || typeof raw.body !== 'string') return null + return { + id: raw.id ?? `evt-${raw.ledgerSeq ?? 'x'}-${autoSeq++}`, + topics: raw.topics, + body: raw.body, + ledgerSeq: raw.ledgerSeq, + timestamp: raw.timestamp ?? Date.now(), + } +} + +/** + * Streams raw Soroban contract log events (hex topics + base64 body) from a + * WebSocket source into a bounded, newest-first buffer. Decoding is handled + * separately by {@link useDecodedLedgerEvents} so this hook stays cheap and the + * decode work can be memoized. + */ +export function useLedgerEvents({ + url, + enabled = true, + maxEvents = DEFAULT_MAX_EVENTS, +}: UseLedgerEventsOptions = {}): UseLedgerEventsResult { + const [events, setEvents] = useState([]) + const [connected, setConnected] = useState(false) + const [error, setError] = useState(null) + const wsRef = useRef(null) + + const push = useCallback( + (event: RawLedgerEvent) => { + const normalized = normalize(event) + if (!normalized) return + setEvents((prev) => [normalized, ...prev].slice(0, maxEvents)) + }, + [maxEvents], + ) + + const clear = useCallback(() => setEvents([]), []) + + useEffect(() => { + if (!enabled || !url) return + + const ws = new WebSocket(url) + wsRef.current = ws + + // Connection state is driven entirely by socket callbacks (never set + // synchronously in the effect body), including the close fired by cleanup. + ws.onopen = () => setConnected(true) + ws.onerror = () => setError('Ledger event stream error') + ws.onclose = () => setConnected(false) + ws.onmessage = (msg) => { + try { + const parsed = JSON.parse(msg.data) as Partial + const normalized = normalize(parsed) + if (normalized) { + setEvents((prev) => [normalized, ...prev].slice(0, maxEvents)) + } + } catch { + // Ignore malformed frames; a single bad message shouldn't kill the feed. + } + } + + return () => { + ws.close() + wsRef.current = null + } + }, [url, enabled, maxEvents]) + + return { events, connected, error, push, clear } +} diff --git a/src/services/sentry.ts b/src/services/sentry.ts index 41e8e07..28cba83 100644 --- a/src/services/sentry.ts +++ b/src/services/sentry.ts @@ -23,3 +23,34 @@ export async function captureLogoutEvent(event: LogoutEvent) { console.info("[Sentry audit]", event) } } + +interface UnknownLedgerEventReport { + /** Decoded signature symbol if it parsed, else null. */ + signature: string | null + rawTopics: string[] + rawBody: string +} + +/** + * Report an unrecognized Soroban contract log event so its shape can be added + * to the decoder's lookup table. Triggered by the "Report Unknown Event" action + * in the alert banner. + */ +export async function captureUnknownLedgerEvent(event: UnknownLedgerEventReport) { + if (typeof process !== "undefined" && process.env.NEXT_PUBLIC_SENTRY_DSN) { + try { + // @ts-expect-error - @sentry/nextjs is optional, caught at runtime + const { captureEvent } = await import("@sentry/nextjs") + captureEvent({ + message: "Unrecognized Soroban ledger event", + level: "info", + tags: { signature: event.signature ?? "unparseable" }, + extra: event, + }) + } catch { + console.warn("[Sentry] @sentry/nextjs not configured; skipping event", event) + } + } else { + console.info("[Sentry audit] unknown ledger event", event) + } +} diff --git a/src/types/ledgerEvents.ts b/src/types/ledgerEvents.ts new file mode 100644 index 0000000..67b0b45 --- /dev/null +++ b/src/types/ledgerEvents.ts @@ -0,0 +1,113 @@ +// Decoded Soroban contract log events surfaced in the real-time alert pipeline. +// +// Raw events arrive from the Soroban RPC `getTransaction` response as an array +// of hex-encoded XDR topics plus a base64-encoded SCVal body. `decodeLedgerEvent` +// (see src/utils/hexDecoder.ts) turns those into one of the typed shapes below. + +/** Contract event signatures we know how to decode. */ +export type KnownLedgerEventType = + | 'approve_attestation' + | 'reject_attestation' + | 'slash_node' + | 'reward_distributed' + | 'node_registered' + | 'node_deregistered' + | 'parameter_changed' + +export type LedgerEventType = KnownLedgerEventType | 'unknown' + +/** Drives color coding in the alert UI. */ +export type AlertSeverity = 'error' | 'warning' | 'success' | 'info' + +/** Fields shared by every decoded event, known or not. */ +export interface BaseLedgerEvent { + /** Stable id for memoization / dedupe (tx hash + event index when available). */ + id: string + /** Human-readable title, e.g. "Node Slashed". */ + title: string + severity: AlertSeverity + /** True for events that may trigger an audible alert (slash / reject). */ + highSeverity: boolean + /** Event time in epoch ms (ledger close time when known, else decode time). */ + timestamp: number + /** Ledger sequence the event was emitted in, when provided by the source. */ + ledgerSeq: number | null + /** Original wire data, retained for the "copy raw" / report affordances. */ + rawTopics: string[] + rawBody: string +} + +export interface ApproveAttestationEvent extends BaseLedgerEvent { + type: 'approve_attestation' + nodeId: string + attestationId: string + epoch: number | null +} + +export interface RejectAttestationEvent extends BaseLedgerEvent { + type: 'reject_attestation' + nodeId: string + attestationId: string + reason: string +} + +export interface SlashNodeEvent extends BaseLedgerEvent { + type: 'slash_node' + nodeId: string + amount: string + reason: string +} + +export interface RewardDistributedEvent extends BaseLedgerEvent { + type: 'reward_distributed' + nodeId: string + amount: string + epoch: number | null +} + +export interface NodeRegisteredEvent extends BaseLedgerEvent { + type: 'node_registered' + nodeId: string + operator: string +} + +export interface NodeDeregisteredEvent extends BaseLedgerEvent { + type: 'node_deregistered' + nodeId: string + reason: string +} + +export interface ParameterChangedEvent extends BaseLedgerEvent { + type: 'parameter_changed' + key: string + oldValue: string + newValue: string +} + +export interface UnknownEvent extends BaseLedgerEvent { + type: 'unknown' + /** Decoded signature symbol if it parsed but wasn't recognized, else null. */ + signature: string | null +} + +export type LedgerEvent = + | ApproveAttestationEvent + | RejectAttestationEvent + | SlashNodeEvent + | RewardDistributedEvent + | NodeRegisteredEvent + | NodeDeregisteredEvent + | ParameterChangedEvent + | UnknownEvent + +/** Raw event envelope as it leaves the RPC / websocket source, pre-decode. */ +export interface RawLedgerEvent { + id: string + /** Hex-encoded XDR topics (topic[0] is the event signature). */ + topics: string[] + /** Base64-encoded SCVal event body. */ + body: string + ledgerSeq?: number + /** Ledger close time in epoch ms. */ + timestamp?: number +} diff --git a/src/utils/alertSound.ts b/src/utils/alertSound.ts new file mode 100644 index 0000000..5bcce33 --- /dev/null +++ b/src/utils/alertSound.ts @@ -0,0 +1,65 @@ +// Audible alert for high-severity ledger events. +// +// Uses the Web Audio API with a short, synthesized buffer generated in-memory +// at first use — no network request, no asset to preload over HTTP. Browser +// autoplay policies require a prior user gesture; `playAlertTone` resumes a +// suspended context and silently no-ops if playback is still blocked. + +let audioContext: AudioContext | null = null +let toneBuffer: AudioBuffer | null = null + +function getContext(): AudioContext | null { + if (typeof window === 'undefined') return null + const Ctor = + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + if (!Ctor) return null + if (!audioContext) audioContext = new Ctor() + return audioContext +} + +/** Build a ~150ms decaying two-tone "alert" buffer once, then reuse it. */ +function getToneBuffer(ctx: AudioContext): AudioBuffer { + if (toneBuffer) return toneBuffer + const duration = 0.15 + const sampleRate = ctx.sampleRate + const length = Math.floor(duration * sampleRate) + const buffer = ctx.createBuffer(1, length, sampleRate) + const data = buffer.getChannelData(0) + for (let i = 0; i < length; i++) { + const t = i / sampleRate + // Blend 880Hz + 660Hz with an exponential decay envelope. + const envelope = Math.exp(-18 * t) + data[i] = + 0.5 * envelope * (Math.sin(2 * Math.PI * 880 * t) + Math.sin(2 * Math.PI * 660 * t)) + } + toneBuffer = buffer + return buffer +} + +/** + * Play the alert tone. Safe to call from anywhere — returns `false` (without + * throwing) when audio is unavailable or still blocked by autoplay policy. + */ +export function playAlertTone(): boolean { + const ctx = getContext() + if (!ctx) return false + try { + if (ctx.state === 'suspended') void ctx.resume() + const source = ctx.createBufferSource() + source.buffer = getToneBuffer(ctx) + const gain = ctx.createGain() + gain.gain.value = 0.4 + source.connect(gain).connect(ctx.destination) + source.start() + return true + } catch { + return false + } +} + +/** Eagerly warm up the audio context/buffer from within a user gesture. */ +export function primeAlertAudio(): void { + const ctx = getContext() + if (ctx) getToneBuffer(ctx) +} diff --git a/src/utils/hexDecoder.test.ts b/src/utils/hexDecoder.test.ts new file mode 100644 index 0000000..f677e72 --- /dev/null +++ b/src/utils/hexDecoder.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from 'vitest' +import { nativeToScVal } from '@stellar/stellar-sdk' +import { decodeLedgerEvent } from '@/src/utils/hexDecoder' +import type { LedgerEvent } from '@/src/types/ledgerEvents' + +/** + * Encode a contract log event the same way the on-chain emitter would, using + * the real @stellar/stellar-sdk SCVal serialization the decoder consumes: + * topic[0] = symbol signature, topic[1] = subject, body = SCMap of fields. + */ +function encodeEvent( + signature: string, + subject: string, + body: Record = {}, +): { topics: string[]; body: string } { + const topics = [ + nativeToScVal(signature, { type: 'symbol' }).toXDR('hex'), + nativeToScVal(subject).toXDR('hex'), + ] + return { topics, body: nativeToScVal(body).toXDR('base64') } +} + +const NODE = 'GNODE000000000000000000000000000000000000000000000000000' + +describe('decodeLedgerEvent — known event types', () => { + it('decodes approve_attestation with the correct title and fields', () => { + const { topics, body } = encodeEvent('approve_attestation', NODE, { + attestation_id: 'att-123', + epoch: 42, + }) + const ev = decodeLedgerEvent(topics, body) + expect(ev.type).toBe('approve_attestation') + expect(ev.title).toBe('Attestation Approved') + expect(ev.severity).toBe('success') + if (ev.type === 'approve_attestation') { + expect(ev.nodeId).toBe(NODE) + expect(ev.attestationId).toBe('att-123') + expect(ev.epoch).toBe(42) + } + }) + + it('decodes reject_attestation as a high-severity error', () => { + const { topics, body } = encodeEvent('reject_attestation', NODE, { + attestation_id: 'att-9', + reason: 'stale signature', + }) + const ev = decodeLedgerEvent(topics, body) + expect(ev.title).toBe('Attestation Rejected') + expect(ev.severity).toBe('error') + expect(ev.highSeverity).toBe(true) + if (ev.type === 'reject_attestation') { + expect(ev.reason).toBe('stale signature') + } + }) + + it('decodes slash_node with amount and reason, marked high severity', () => { + const { topics, body } = encodeEvent('slash_node', NODE, { + amount: '5000', + reason: 'double_sign', + }) + const ev = decodeLedgerEvent(topics, body) + expect(ev.title).toBe('Node Slashed') + expect(ev.severity).toBe('error') + expect(ev.highSeverity).toBe(true) + if (ev.type === 'slash_node') { + expect(ev.amount).toBe('5000') + expect(ev.reason).toBe('double_sign') + } + }) + + it('decodes reward_distributed as a success event', () => { + const { topics, body } = encodeEvent('reward_distributed', NODE, { + amount: '120', + epoch: 7, + }) + const ev = decodeLedgerEvent(topics, body) + expect(ev.title).toBe('Reward Distributed') + expect(ev.severity).toBe('success') + expect(ev.highSeverity).toBe(false) + if (ev.type === 'reward_distributed') { + expect(ev.amount).toBe('120') + expect(ev.epoch).toBe(7) + } + }) + + it('decodes node_registered and node_deregistered', () => { + const reg = decodeLedgerEvent( + ...Object.values(encodeEvent('node_registered', NODE, { operator: 'GOPERATOR' })) as [string[], string], + ) + expect(reg.title).toBe('Node Registered') + expect(reg.severity).toBe('success') + if (reg.type === 'node_registered') expect(reg.operator).toBe('GOPERATOR') + + const dereg = decodeLedgerEvent( + ...Object.values(encodeEvent('node_deregistered', NODE, { reason: 'voluntary' })) as [string[], string], + ) + expect(dereg.title).toBe('Node Deregistered') + expect(dereg.severity).toBe('warning') + if (dereg.type === 'node_deregistered') expect(dereg.reason).toBe('voluntary') + }) + + it('decodes parameter_changed with old/new values', () => { + const { topics, body } = encodeEvent('parameter_changed', 'min_stake', { + old_value: '100', + new_value: '250', + }) + const ev = decodeLedgerEvent(topics, body) + expect(ev.title).toBe('Parameter Changed') + expect(ev.severity).toBe('warning') + if (ev.type === 'parameter_changed') { + expect(ev.key).toBe('min_stake') + expect(ev.oldValue).toBe('100') + expect(ev.newValue).toBe('250') + } + }) + + it('honors envelope metadata (id, timestamp, ledgerSeq)', () => { + const { topics, body } = encodeEvent('slash_node', NODE, { amount: '1', reason: 'x' }) + const ev = decodeLedgerEvent(topics, body, { id: 'tx-abc/0', timestamp: 1234, ledgerSeq: 99 }) + expect(ev.id).toBe('tx-abc/0') + expect(ev.timestamp).toBe(1234) + expect(ev.ledgerSeq).toBe(99) + }) +}) + +describe('decodeLedgerEvent — unknown / malformed input', () => { + it('returns an UnknownEvent for an unrecognized signature without throwing', () => { + const { topics, body } = encodeEvent('teleport_node', NODE, { foo: 'bar' }) + let ev!: LedgerEvent + expect(() => { + ev = decodeLedgerEvent(topics, body) + }).not.toThrow() + expect(ev.type).toBe('unknown') + expect(ev.title).toBe('Unknown Event') + expect(ev.severity).toBe('info') + if (ev.type === 'unknown') expect(ev.signature).toBe('teleport_node') + }) + + it('returns an UnknownEvent (signature null) for non-XDR garbage hex', () => { + const ev = decodeLedgerEvent(['zzzznothex'], 'not-base64-@@@') + expect(ev.type).toBe('unknown') + if (ev.type === 'unknown') expect(ev.signature).toBeNull() + }) + + it('handles empty topics gracefully', () => { + const ev = decodeLedgerEvent([], '') + expect(ev.type).toBe('unknown') + }) +}) + +describe('decodeLedgerEvent — performance', () => { + it('decodes 1,000 events in under 100ms', () => { + const signatures = [ + 'approve_attestation', + 'reject_attestation', + 'slash_node', + 'reward_distributed', + 'node_registered', + 'node_deregistered', + 'parameter_changed', + ] + // Pre-encode so we measure decode cost only. + const encoded = Array.from({ length: 1000 }, (_, i) => { + const sig = signatures[i % signatures.length] + return encodeEvent(sig, NODE, { amount: String(i), reason: 'r', attestation_id: `a${i}`, epoch: i }) + }) + + const start = performance.now() + for (let i = 0; i < encoded.length; i++) { + decodeLedgerEvent(encoded[i].topics, encoded[i].body, { id: `e${i}` }) + } + const elapsed = performance.now() - start + + expect(encoded).toHaveLength(1000) + expect(elapsed).toBeLessThan(100) + }) +}) diff --git a/src/utils/hexDecoder.ts b/src/utils/hexDecoder.ts new file mode 100644 index 0000000..ae08e9a --- /dev/null +++ b/src/utils/hexDecoder.ts @@ -0,0 +1,216 @@ +// Decode raw Soroban contract log events into typed, human-readable alerts. +// +// Wire format (also produced by the test encoder so the two stay in lockstep): +// topic[0] : XDR ScVal symbol -> the event signature (e.g. "slash_node") +// topic[1] : XDR ScVal -> the primary subject (node id / param key) +// body : base64 XDR ScVal -> an SCMap of the remaining fields +// +// `decodeLedgerEvent` is total: it never throws. Anything it can't recognize or +// parse degrades to an `UnknownEvent` so the alert pipeline stays live. + +import { xdr, scValToNative } from '@stellar/stellar-sdk' +import type { + AlertSeverity, + KnownLedgerEventType, + LedgerEvent, + UnknownEvent, +} from '@/src/types/ledgerEvents' + +/** Human-readable titles for each known event type. */ +export const EVENT_TITLES: Record = { + approve_attestation: 'Attestation Approved', + reject_attestation: 'Attestation Rejected', + slash_node: 'Node Slashed', + reward_distributed: 'Reward Distributed', + node_registered: 'Node Registered', + node_deregistered: 'Node Deregistered', + parameter_changed: 'Parameter Changed', +} + +/** Severity (color) for each known event type. */ +export const EVENT_SEVERITY: Record = { + approve_attestation: 'success', + reject_attestation: 'error', + slash_node: 'error', + reward_distributed: 'success', + node_registered: 'success', + node_deregistered: 'warning', + parameter_changed: 'warning', +} + +/** Events that warrant an audible alert. */ +export const HIGH_SEVERITY_EVENTS: ReadonlySet = new Set([ + 'slash_node', + 'reject_attestation', +]) + +const KNOWN_TYPES = new Set(Object.keys(EVENT_TITLES)) + +export interface DecodeMeta { + id?: string + timestamp?: number + ledgerSeq?: number +} + +// ── small, allocation-free coercion helpers ────────────────────────────────── + +function asString(v: unknown): string { + if (v == null) return '' + if (typeof v === 'string') return v + if (typeof v === 'bigint' || typeof v === 'number' || typeof v === 'boolean') { + return v.toString() + } + try { + return JSON.stringify(v) + } catch { + return String(v) + } +} + +function asNumber(v: unknown): number | null { + if (typeof v === 'number') return Number.isFinite(v) ? v : null + if (typeof v === 'bigint') return Number(v) + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v) + return Number.isFinite(n) ? n : null + } + return null +} + +/** Decode a single hex-encoded XDR ScVal topic to its native JS value. */ +function decodeTopic(hex: string): unknown { + return scValToNative(xdr.ScVal.fromXDR(hex, 'hex')) +} + +/** Decode the base64 XDR ScVal body to a plain record (empty if not a map). */ +function decodeBody(base64: string): Record { + if (!base64) return {} + const native = scValToNative(xdr.ScVal.fromXDR(base64, 'base64')) + return native && typeof native === 'object' && !Array.isArray(native) + ? (native as Record) + : {} +} + +function fallbackId(topics: string[]): string { + return topics[0] ? topics[0].slice(0, 32) : 'ledger-event' +} + +/** + * Decode a raw Soroban contract log event into a typed `LedgerEvent`. + * + * @param rawHexTopics Hex-encoded XDR topics; topic[0] is the signature. + * @param rawBase64Body Base64-encoded XDR SCVal body. + * @param meta Optional envelope metadata (id, timestamp, ledgerSeq). + */ +export function decodeLedgerEvent( + rawHexTopics: string[], + rawBase64Body: string, + meta: DecodeMeta = {}, +): LedgerEvent { + const topics = Array.isArray(rawHexTopics) ? rawHexTopics : [] + const id = meta.id ?? fallbackId(topics) + const timestamp = meta.timestamp ?? Date.now() + const ledgerSeq = meta.ledgerSeq ?? null + + const base = { + id, + timestamp, + ledgerSeq, + rawTopics: topics, + rawBody: rawBase64Body, + } + + const unknown = (signature: string | null): UnknownEvent => ({ + ...base, + type: 'unknown', + title: 'Unknown Event', + severity: 'info', + highSeverity: false, + signature, + }) + + let signature: string | null = null + try { + if (topics.length === 0) return unknown(null) + + const decoded = decodeTopic(topics[0]) + signature = typeof decoded === 'string' ? decoded : asString(decoded) + + if (!KNOWN_TYPES.has(signature)) { + return unknown(signature) + } + + const type = signature as KnownLedgerEventType + const subject = topics.length > 1 ? asString(decodeTopic(topics[1])) : '' + const body = decodeBody(rawBase64Body) + + const common = { + ...base, + title: EVENT_TITLES[type], + severity: EVENT_SEVERITY[type], + highSeverity: HIGH_SEVERITY_EVENTS.has(type), + } + + switch (type) { + case 'approve_attestation': + return { + ...common, + type, + nodeId: subject || asString(body.node_id), + attestationId: asString(body.attestation_id), + epoch: asNumber(body.epoch), + } + case 'reject_attestation': + return { + ...common, + type, + nodeId: subject || asString(body.node_id), + attestationId: asString(body.attestation_id), + reason: asString(body.reason), + } + case 'slash_node': + return { + ...common, + type, + nodeId: subject || asString(body.node_id), + amount: asString(body.amount), + reason: asString(body.reason), + } + case 'reward_distributed': + return { + ...common, + type, + nodeId: subject || asString(body.node_id), + amount: asString(body.amount), + epoch: asNumber(body.epoch), + } + case 'node_registered': + return { + ...common, + type, + nodeId: subject || asString(body.node_id), + operator: asString(body.operator), + } + case 'node_deregistered': + return { + ...common, + type, + nodeId: subject || asString(body.node_id), + reason: asString(body.reason), + } + case 'parameter_changed': + return { + ...common, + type, + key: subject || asString(body.key), + oldValue: asString(body.old_value ?? body.old), + newValue: asString(body.new_value ?? body.new), + } + } + } catch { + // Malformed XDR, unexpected SCVal shape, etc. — degrade gracefully. + return unknown(signature) + } + + return unknown(signature) +}