From dcc605518e3718c63401bff1418d38218b8bdd72 Mon Sep 17 00:00:00 2001 From: Ibinola Date: Mon, 22 Jun 2026 14:54:24 +0100 Subject: [PATCH] fix: wire Soroban service to live RPC --- Backend/package-lock.json | 255 +++++++++++++++-- Backend/package.json | 1 + Backend/src/gists/dto/query-gists.dto.ts | 5 +- Backend/src/gists/gist.repository.ts | 5 +- Backend/src/gists/gists.service.spec.ts | 12 +- Backend/src/gists/gists.service.ts | 17 +- Backend/src/indexer/indexer.service.spec.ts | 11 + Backend/src/indexer/indexer.service.ts | 3 +- Backend/src/soroban/soroban.module.ts | 2 + Backend/src/soroban/soroban.service.ts | 294 +++++++++++++++++++- 10 files changed, 552 insertions(+), 53 deletions(-) diff --git a/Backend/package-lock.json b/Backend/package-lock.json index 63c7cefd..c80c7412 100644 --- a/Backend/package-lock.json +++ b/Backend/package-lock.json @@ -18,6 +18,7 @@ "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", + "@stellar/stellar-sdk": "^16.0.1", "@willsoto/nestjs-prometheus": "^6.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", @@ -2899,6 +2900,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/ed25519": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", + "integrity": "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -3084,6 +3094,87 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@stellar/js-xdr": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", + "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9.0.0" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.0.1.tgz", + "integrity": "sha512-bxKohaiyKVqoudRhbOOHeHhHIaeYV5Zab4rCjxhP4Ty1h1ozTLBOv8lWFnZz9ilBzXG8Bb7usQI3rlEcfvUynA==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "4.0.0", + "axios": "1.16.1", + "base32.js": "^0.1.0", + "bignumber.js": "^11.1.1", + "buffer": "^6.0.3", + "commander": "^14.0.3", + "eventsource": "^4.1.0", + "feaxios": "^0.0.23", + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@swc/cli": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@swc/cli/-/cli-0.6.0.tgz", @@ -4837,6 +4928,18 @@ "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5069,7 +5172,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": { @@ -5087,6 +5189,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/b4a": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", @@ -5234,6 +5348,15 @@ "license": "Apache-2.0", "optional": true }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5254,6 +5377,12 @@ ], "license": "MIT" }, + "node_modules/bignumber.js": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.4.tgz", + "integrity": "sha512-AJ9dSeaUGj2xu7tEwmdqb51dqdb633xo4njI9K8ZFfcLrNr0XN8/EPkkZUNaF9fkCblGt2zVwZymesUdGynEkQ==", + "license": "MIT" + }, "node_modules/bin-version": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bin-version/-/bin-version-6.0.0.tgz", @@ -5894,7 +6023,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" @@ -6259,7 +6387,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" @@ -6502,7 +6629,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", @@ -6838,6 +6964,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.0.tgz", + "integrity": "sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -7112,6 +7259,15 @@ "bser": "2.1.1" } }, + "node_modules/feaxios": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -7316,6 +7472,26 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/follow-redirects": { + "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==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -7376,17 +7552,16 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", - "dev": true, + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -7406,7 +7581,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" @@ -7416,7 +7590,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" @@ -7778,9 +7951,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "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==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7842,6 +8015,19 @@ "node": ">=10.19.0" } }, + "node_modules/https-proxy-agent": { + "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==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -8091,6 +8277,18 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-retry-allowed": { + "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==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -10398,6 +10596,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "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==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11078,6 +11285,18 @@ "node": ">=8" } }, + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/sort-keys": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", @@ -12430,9 +12649,9 @@ } }, "node_modules/uint8array-extras": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", - "integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", "license": "MIT", "engines": { "node": ">=18" diff --git a/Backend/package.json b/Backend/package.json index c0e7bf32..f5c30d53 100644 --- a/Backend/package.json +++ b/Backend/package.json @@ -32,6 +32,7 @@ "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", + "@stellar/stellar-sdk": "^16.0.1", "@willsoto/nestjs-prometheus": "^6.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", diff --git a/Backend/src/gists/dto/query-gists.dto.ts b/Backend/src/gists/dto/query-gists.dto.ts index 3b4a029b..28b60005 100644 --- a/Backend/src/gists/dto/query-gists.dto.ts +++ b/Backend/src/gists/dto/query-gists.dto.ts @@ -10,8 +10,7 @@ import { MaxLength, IsBoolean, } from 'class-validator'; -import { IsLatitude, IsLongitude, IsOptional, IsNumber, Min, Max, IsString, IsBoolean, MaxLength } from 'class-validator'; -import { Type, Transform } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; export class QueryGistsDto { @ApiProperty({ description: 'Latitude to search from', example: 9.0579 }) @@ -63,8 +62,6 @@ export class QueryGistsDto { authorAddress?: string; @ApiPropertyOptional({ - description: 'Return count grouped by location_cell for heatmap data', - example: true, description: 'When true, count endpoint returns breakdown by location_cell', example: false, }) diff --git a/Backend/src/gists/gist.repository.ts b/Backend/src/gists/gist.repository.ts index 3a630f06..faaa04cf 100644 --- a/Backend/src/gists/gist.repository.ts +++ b/Backend/src/gists/gist.repository.ts @@ -189,6 +189,7 @@ export class GistRepository { ); return parseInt(result[0].count, 10); } + async countNearby(lat: number, lon: number, radiusMeters: number): Promise { const [row] = await this.dataSource.query>( `SELECT COUNT(*) AS count FROM gists @@ -206,10 +207,6 @@ export class GistRepository { query: Pick, ): Promise> { const { lat, lon, radiusMeters = 500 } = query; - lat: number, - lon: number, - radiusMeters: number, - ): Promise> { const rows = await this.dataSource.query>( `SELECT location_cell, COUNT(*) AS count FROM gists WHERE ST_DWithin( diff --git a/Backend/src/gists/gists.service.spec.ts b/Backend/src/gists/gists.service.spec.ts index 62f159d8..ca7855a6 100644 --- a/Backend/src/gists/gists.service.spec.ts +++ b/Backend/src/gists/gists.service.spec.ts @@ -8,6 +8,10 @@ import { IpfsService } from '../ipfs/ipfs.service'; import { SorobanService } from '../soroban/soroban.service'; import { Gist } from './entities/gist.entity'; +jest.mock('../soroban/soroban.service', () => ({ + SorobanService: class SorobanService {}, +})); + /** * Unit tests for GistsService. * @@ -50,6 +54,8 @@ describe('GistsService', () => { findByStellarGistId: jest.fn(), existsByStellarGistId: jest.fn(), findNearby: jest.fn(), + countNearby: jest.fn(), + countNearbyByCell: jest.fn(), deleteExpired: jest.fn(), }; @@ -197,11 +203,7 @@ describe('GistsService', () => { const result = await service.countNearby(baseQuery as any); - expect(gistRepository.countNearby).toHaveBeenCalledWith({ - lat: 9.0579, - lon: 7.4951, - radiusMeters: 500, - }); + expect(gistRepository.countNearby).toHaveBeenCalledWith(9.0579, 7.4951, 500); expect(result).toEqual({ count: 12, radius: 500, lat: 9.0579, lon: 7.4951 }); }); diff --git a/Backend/src/gists/gists.service.ts b/Backend/src/gists/gists.service.ts index ee9ebd24..27041676 100644 --- a/Backend/src/gists/gists.service.ts +++ b/Backend/src/gists/gists.service.ts @@ -13,6 +13,14 @@ import { stripHtml } from '../common/utils/sanitize'; const DEFAULT_TTL_HOURS = 24; +export interface CountNearbyResult { + count: number; + radius: number; + lat: number; + lon: number; + breakdown?: Array<{ cell: string; count: number }>; +} + @Injectable() export class GistsService { private readonly logger = new Logger(GistsService.name); @@ -122,16 +130,7 @@ export class GistsService { return { count: total, radius, lat, lon, breakdown: rows }; } - const count = await this.gistRepository.countNearby({ lat, lon, radiusMeters: radius }); - async countNearby( - query: QueryGistsDto, - ): Promise<{ count: number; radius: number; lat: number; lon: number; breakdown?: Array<{ cell: string; count: number }> }> { - const { lat, lon, radius = 500, breakdown } = query; const count = await this.gistRepository.countNearby(lat, lon, radius); - if (breakdown) { - const cells = await this.gistRepository.countNearbyByCell(lat, lon, radius); - return { count, radius, lat, lon, breakdown: cells }; - } return { count, radius, lat, lon }; } } diff --git a/Backend/src/indexer/indexer.service.spec.ts b/Backend/src/indexer/indexer.service.spec.ts index b55bd71a..29204d0c 100644 --- a/Backend/src/indexer/indexer.service.spec.ts +++ b/Backend/src/indexer/indexer.service.spec.ts @@ -5,6 +5,10 @@ import { GistRepository } from '../gists/gist.repository'; import { GeoService } from '../geo/geo.service'; import { GistEvent } from '../soroban/soroban.service'; +jest.mock('../soroban/soroban.service', () => ({ + SorobanService: class SorobanService {}, +})); + function makeEvent(overrides: Partial = {}): GistEvent { return { gistId: 'gist-1', @@ -31,6 +35,7 @@ describe('IndexerService', () => { } as unknown as jest.Mocked; gistRepo = { + findByStellarGistId: jest.fn(), existsByStellarGistId: jest.fn(), create: jest.fn(), } as unknown as jest.Mocked; @@ -58,6 +63,7 @@ describe('IndexerService', () => { it('persists a new event to the DB', async () => { soroban.getEventsSince.mockResolvedValue([makeEvent()]); + gistRepo.findByStellarGistId.mockResolvedValue(null); gistRepo.existsByStellarGistId.mockResolvedValue(false); gistRepo.create.mockResolvedValue({} as never); @@ -76,6 +82,7 @@ describe('IndexerService', () => { it('decodes the locationCell via GeoService', async () => { soroban.getEventsSince.mockResolvedValue([makeEvent({ locationCell: 'u4pruyd' })]); + gistRepo.findByStellarGistId.mockResolvedValue(null); gistRepo.existsByStellarGistId.mockResolvedValue(false); gistRepo.create.mockResolvedValue({} as never); @@ -86,6 +93,7 @@ describe('IndexerService', () => { it('skips an event that is already indexed', async () => { soroban.getEventsSince.mockResolvedValue([makeEvent()]); + gistRepo.findByStellarGistId.mockResolvedValue(null); gistRepo.existsByStellarGistId.mockResolvedValue(true); await service.poll(); @@ -100,6 +108,7 @@ describe('IndexerService', () => { makeEvent({ gistId: 'g3', ledger: 275 }), ]; soroban.getEventsSince.mockResolvedValue(events); + gistRepo.findByStellarGistId.mockResolvedValue(null); gistRepo.existsByStellarGistId.mockResolvedValue(false); gistRepo.create.mockResolvedValue({} as never); @@ -113,6 +122,7 @@ describe('IndexerService', () => { it('also advances lastProcessedLedger for already-indexed events', async () => { soroban.getEventsSince.mockResolvedValue([makeEvent({ ledger: 500 })]); + gistRepo.findByStellarGistId.mockResolvedValue(null); gistRepo.existsByStellarGistId.mockResolvedValue(true); await service.poll(); @@ -124,6 +134,7 @@ describe('IndexerService', () => { it('logs the number of events fetched', async () => { soroban.getEventsSince.mockResolvedValue([makeEvent(), makeEvent({ gistId: 'g2' })]); + gistRepo.findByStellarGistId.mockResolvedValue(null); gistRepo.existsByStellarGistId.mockResolvedValue(false); gistRepo.create.mockResolvedValue({} as never); diff --git a/Backend/src/indexer/indexer.service.ts b/Backend/src/indexer/indexer.service.ts index 56b934c2..5f927682 100644 --- a/Backend/src/indexer/indexer.service.ts +++ b/Backend/src/indexer/indexer.service.ts @@ -40,12 +40,11 @@ export class IndexerService implements OnModuleInit, OnModuleDestroy { const existing = await this.gistRepository.findByStellarGistId(event.gistId); if (existing) { this.logger.debug(`Skipping already-indexed gist ${event.gistId}`); - this.lastProcessedLedger = Math.max(this.lastProcessedLedger, event.createdAt); + this.lastProcessedLedger = Math.max(this.lastProcessedLedger, event.ledger); continue; } this.logger.debug(`Indexed gist ${event.gistId} @ cell ${event.locationCell}`); - this.lastProcessedLedger = Math.max(this.lastProcessedLedger, event.createdAt); const alreadyIndexed = await this.gistRepository.existsByStellarGistId(event.gistId); if (alreadyIndexed) { diff --git a/Backend/src/soroban/soroban.module.ts b/Backend/src/soroban/soroban.module.ts index 0119b325..8753ad95 100644 --- a/Backend/src/soroban/soroban.module.ts +++ b/Backend/src/soroban/soroban.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { SorobanService } from './soroban.service'; @Module({ + imports: [ConfigModule], providers: [SorobanService], exports: [SorobanService], }) diff --git a/Backend/src/soroban/soroban.service.ts b/Backend/src/soroban/soroban.service.ts index ce924188..c7d1897c 100644 --- a/Backend/src/soroban/soroban.service.ts +++ b/Backend/src/soroban/soroban.service.ts @@ -1,5 +1,17 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { + Address, + BASE_FEE, + Contract, + Keypair, + Networks, + TransactionBuilder, + nativeToScVal, + rpc as SorobanRpc, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; import { randomBytes } from 'crypto'; export interface PostGistResult { @@ -14,6 +26,7 @@ export interface GetGistResult { contentHash: string; createdAt: number; mock: boolean; + author?: string | null; } export interface GistEvent { @@ -51,14 +64,30 @@ export class SorobanService { private readonly logger = new Logger(SorobanService.name); private readonly mockMode: boolean; private readonly maxRetries: number; + private readonly rpcServer: SorobanRpc.Server | null; + private readonly contract: Contract | null; + private readonly signer: Keypair | null; + private readonly networkPassphrase: string; constructor(private readonly config: ConfigService) { - const contractId = this.config.get('CONTRACT_ID_GIST_REGISTRY'); + const contractId = this.config.get('CONTRACT_ID_GIST_REGISTRY') ?? ''; + const rpcUrl = this.config.get('SOROBAN_RPC_URL', 'https://soroban-testnet.stellar.org'); this.mockMode = !contractId; this.maxRetries = this.config.get('SOROBAN_RETRY_ATTEMPTS', 3); + this.networkPassphrase = this.config.get( + 'STELLAR_NETWORK_PASSPHRASE', + Networks.TESTNET, + ); + this.rpcServer = this.mockMode ? null : new SorobanRpc.Server(rpcUrl); + this.contract = this.mockMode ? null : new Contract(contractId); + this.signer = this.resolveSigner(); if (this.mockMode) { this.logger.warn('Soroban running in MOCK MODE — no blockchain calls will be made'); + } else if (!this.signer) { + this.logger.warn( + 'Soroban live mode is enabled but STELLAR_SECRET_KEY is missing; write calls will fail', + ); } } @@ -76,10 +105,7 @@ export class SorobanService { } return withRetry( - async () => { - // TODO: real Soroban RPC call - throw new Error('Real Soroban integration not yet implemented'); - }, + async () => this.postGistLive(locationCell, contentHash, _author), 'Soroban.postGist', this.maxRetries, this.logger, @@ -99,9 +125,7 @@ export class SorobanService { } return withRetry( - async () => { - throw new Error('Real Soroban integration not yet implemented'); - }, + async () => this.getGistLive(gistId), 'Soroban.getGist', this.maxRetries, this.logger, @@ -115,15 +139,263 @@ export class SorobanService { } return withRetry( - async () => { - throw new Error('Real Soroban getEvents not yet implemented'); - }, + async () => this.getEventsSinceLive(ledger), 'Soroban.getEventsSince', this.maxRetries, this.logger, ); } + private resolveSigner(): Keypair | null { + const secretKey = this.config.get('STELLAR_SECRET_KEY') ?? ''; + if (!secretKey) { + return null; + } + + try { + return Keypair.fromSecret(secretKey); + } catch (err) { + this.logger.warn(`Invalid STELLAR_SECRET_KEY: ${(err as Error).message}`); + return null; + } + } + + private getRpcServer(): SorobanRpc.Server { + if (!this.rpcServer) { + throw new Error('Soroban live mode is unavailable without a contract id'); + } + return this.rpcServer; + } + + private getContract(): Contract { + if (!this.contract) { + throw new Error('Soroban live mode is unavailable without a contract id'); + } + return this.contract; + } + + private getSigner(): Keypair { + if (!this.signer) { + throw new Error('STELLAR_SECRET_KEY is required for Soroban live mode'); + } + return this.signer; + } + + private async postGistLive( + locationCell: string, + contentHash: string, + author?: string, + ): Promise { + const rpcServer = this.getRpcServer(); + const contract = this.getContract(); + const signer = this.getSigner(); + const sourceAccount = await rpcServer.getAccount(signer.publicKey()); + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'post_gist', + this.encodeOptionalAuthor(author), + nativeToScVal(locationCell), + nativeToScVal(contentHash), + ), + ) + .setTimeout(30) + .build(); + + const preparedTx = await rpcServer.prepareTransaction(tx); + preparedTx.sign(signer); + + const sendResult = await rpcServer.sendTransaction(preparedTx); + if (sendResult.status === 'ERROR') { + throw new Error( + `Soroban post_gist rejected: ${sendResult.errorResult?.toXDR('base64') ?? 'unknown error'}`, + ); + } + + const txResult = await this.waitForTransaction(rpcServer, sendResult.hash); + if (txResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS || !txResult.returnValue) { + throw new Error(`Soroban post_gist did not return a successful result for ${sendResult.hash}`); + } + + return { + gistId: this.scValToString(txResult.returnValue), + txHash: sendResult.hash, + mock: false, + }; + } + + private async getGistLive(gistId: string): Promise { + const rpcServer = this.getRpcServer(); + const contract = this.getContract(); + const signer = this.getSigner(); + const sourceAccount = await rpcServer.getAccount(signer.publicKey()); + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call('get_gist', nativeToScVal(BigInt(gistId), { type: 'u64' })), + ) + .setTimeout(30) + .build(); + + const simulation = await rpcServer.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simulation) || !simulation.result) { + throw new Error(`Soroban get_gist failed for gist ${gistId}`); + } + + if (!simulation.result.retval) { + throw new Error(`Soroban get_gist returned no value for gist ${gistId}`); + } + + const native = scValToNative(simulation.result.retval); + if (!native) { + throw new Error(`Soroban get_gist returned null for gist ${gistId}`); + } + + return this.normalizeGistRecord(gistId, native); + } + + private async getEventsSinceLive(ledger: number): Promise { + const rpcServer = this.getRpcServer(); + const contract = this.getContract(); + + const response = await rpcServer.getEvents({ + filters: [{ type: 'contract', contractIds: [contract.contractId()] }], + startLedger: ledger, + }); + + return response.events + .map((event) => this.decodeGistEvent(event)) + .filter((event): event is GistEvent => event !== null); + } + + private async waitForTransaction( + rpcServer: SorobanRpc.Server, + hash: string, + ): Promise { + const maxAttempts = Math.max(this.maxRetries * 4, 8); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const txResult = await rpcServer.getTransaction(hash); + if (txResult.status !== SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) { + return txResult; + } + + if (attempt < maxAttempts) { + await sleep(500 * attempt); + } + } + + throw new Error(`Timed out waiting for Soroban transaction ${hash}`); + } + + private encodeOptionalAuthor(author?: string): xdr.ScVal { + if (!author) { + return nativeToScVal(null); + } + + return nativeToScVal(Address.fromString(author)); + } + + private scValToString(value: xdr.ScVal): string { + const native = scValToNative(value); + if (typeof native === 'bigint') { + return native.toString(); + } + + return String(native); + } + + private normalizeGistRecord(gistId: string, native: unknown): GetGistResult { + const record = native as Record; + const normalizedGistId = this.readString(record.gist_id ?? record.gistId ?? gistId); + const author = this.readMaybeString(record.author); + + return { + gistId: normalizedGistId, + locationCell: this.readString(record.location_cell ?? record.locationCell), + contentHash: this.readString(record.content_hash ?? record.contentHash), + createdAt: this.readNumber(record.created_at ?? record.createdAt), + author, + mock: false, + }; + } + + private decodeGistEvent(event: SorobanRpc.Api.EventResponse): GistEvent | null { + const topic = event.topic.map((value) => scValToNative(value)); + if (topic.length === 0) { + return null; + } + + const eventName = typeof topic[0] === 'string' ? topic[0] : ''; + if (eventName && eventName !== 'post_gist' && eventName !== 'gist_posted') { + return null; + } + + const payload = scValToNative(event.value) as Record; + if (!payload || typeof payload !== 'object') { + return null; + } + + return { + gistId: this.readString(payload.gist_id ?? payload.gistId), + locationCell: this.readString(payload.location_cell ?? payload.locationCell), + contentHash: this.readString(payload.content_hash ?? payload.contentHash), + author: this.readMaybeString(payload.author), + ledger: event.ledger, + createdAt: this.readNumber(payload.created_at ?? payload.createdAt ?? event.ledger), + }; + } + + private readString(value: unknown): string { + if (typeof value === 'bigint') { + return value.toString(); + } + + if (typeof value === 'number') { + return String(value); + } + + if (typeof value === 'string') { + return value; + } + + if (value && typeof value === 'object' && 'toString' in value) { + return String(value); + } + + throw new Error('Soroban response was missing a required string field'); + } + + private readMaybeString(value: unknown): string | null { + if (value == null) { + return null; + } + + return this.readString(value); + } + + private readNumber(value: unknown): number { + if (typeof value === 'number') { + return value; + } + + if (typeof value === 'bigint') { + return Number(value); + } + + const parsed = Number(value); + if (Number.isNaN(parsed)) { + throw new Error('Soroban response was missing a required numeric field'); + } + + return parsed; + } + private simulateDelay(): Promise { const ms = 100 + Math.floor(Math.random() * 200); return new Promise((resolve) => setTimeout(resolve, ms));