diff --git a/backend/errors.txt b/backend/errors.txt new file mode 100644 index 0000000..6b221f0 --- /dev/null +++ b/backend/errors.txt @@ -0,0 +1,5 @@ +src/auth/auth.controller.ts(9,26): error TS2307: Cannot find module '@nestjs/throttler' or its corresponding type declarations. +src/invoices/invoices.controller.ts(10,26): error TS2307: Cannot find module '@nestjs/throttler' or its corresponding type declarations. +src/throttler/throttler-storage-redis.service.ts(2,34): error TS2307: Cannot find module '@nestjs/throttler' or its corresponding type declarations. +src/throttler/throttler.module.ts(2,33): error TS2307: Cannot find module '@nestjs/throttler' or its corresponding type declarations. +src/throttler/throttler.module.ts(30,40): error TS2307: Cannot find module 'ioredis' or its corresponding type declarations. diff --git a/backend/package-lock.json b/backend/package-lock.json index c29964f..1726eb6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -280,6 +280,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -866,7 +867,8 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", "devOptional": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", @@ -2387,6 +2389,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.14.tgz", "integrity": "sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -2446,6 +2449,7 @@ "integrity": "sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2509,6 +2513,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.14.tgz", "integrity": "sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==", "license": "MIT", + "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -2985,6 +2990,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -3088,8 +3094,7 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -3304,6 +3309,7 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3438,6 +3444,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3626,6 +3633,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -4358,6 +4366,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4407,6 +4416,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4540,7 +4550,6 @@ "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 6.0.0" } @@ -4615,6 +4624,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -4882,6 +4892,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5167,6 +5178,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -5224,13 +5236,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -5626,15 +5640,13 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -6037,6 +6049,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6097,6 +6110,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6792,6 +6806,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7280,6 +7295,7 @@ "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -7444,6 +7460,7 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", "license": "MIT", + "peer": true, "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", @@ -7732,6 +7749,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -9149,6 +9167,7 @@ "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", @@ -9503,6 +9522,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -9634,6 +9654,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", @@ -9917,6 +9938,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9975,6 +9997,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "7.4.2", "@prisma/dev": "0.20.0", @@ -10191,6 +10214,7 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-5.11.0.tgz", "integrity": "sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==", "license": "MIT", + "peer": true, "dependencies": { "@redis/bloom": "5.11.0", "@redis/client": "5.11.0", @@ -10227,7 +10251,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/regexp-to-ast": { "version": "0.5.0", @@ -10350,6 +10375,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10385,8 +10411,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/schema-utils": { "version": "4.3.3", @@ -10723,7 +10748,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=14" } @@ -11384,6 +11408,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11569,7 +11594,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -11672,7 +11696,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -11682,7 +11705,6 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=12" }, @@ -11696,7 +11718,6 @@ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", - "peer": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -11716,15 +11737,13 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/typeorm/node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^2.0.2" }, @@ -11740,7 +11759,6 @@ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -11758,6 +11776,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12058,6 +12077,7 @@ "integrity": "sha512-LLBBA4oLmT7sZdHiYE/PeVuifOxYyE2uL/V+9VQP7YSYdJU7bSf7H8bZRRxW8kEPMkmVjnrXmoR3oejIdX0xbg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e117d7e..b919618 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { HealthModule } from "./health/health.module"; import { InvoicesModule } from "./invoices/invoices.module"; import { StellarModule } from "./stellar/stellar.module"; import { HorizonWatcherModule } from "./stellar/horizon-watcher.module"; +import { SorobanEventsModule } from "./stellar/soroban-events.module"; import { AuthModule } from "./auth/auth.module"; import { UsersModule } from "./users/user.module"; import { PrismaModule } from "./prisma/prisma.module"; @@ -50,6 +51,11 @@ import { CustomThrottlerModule } from "./throttler/throttler.module"; USDC_ASSET_CODE: Joi.string().default("USDC"), MEMO_PREFIX: Joi.string().default("invoisio-"), HORIZON_POLL_INTERVAL: Joi.number().integer().min(1000).default(15000), + SOROBAN_RPC_URL: Joi.string() + .uri() + .default("https://soroban-testnet.stellar.org"), + SOROBAN_CONTRACT_ID: Joi.string().optional().allow(""), + SOROBAN_EVENT_TOPIC: Joi.string().default("InvoicePaymentRecorded"), DATABASE_URL: Joi.string().optional(), JWT_SECRET: Joi.string().optional(), // Rate limiting configuration @@ -73,6 +79,7 @@ import { CustomThrottlerModule } from "./throttler/throttler.module"; InvoicesModule, StellarModule, HorizonWatcherModule, + SorobanEventsModule, AuthModule, UsersModule, WebhooksModule, diff --git a/backend/src/config/stellar.config.ts b/backend/src/config/stellar.config.ts index 409ea6b..0365af0 100644 --- a/backend/src/config/stellar.config.ts +++ b/backend/src/config/stellar.config.ts @@ -10,6 +10,11 @@ export default registerAs("stellar", () => ({ process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015", merchantPublicKey: process.env.MERCHANT_PUBLIC_KEY || "", + sorobanRpcUrl: + process.env.SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org", + sorobanContractId: process.env.SOROBAN_CONTRACT_ID || "", + sorobanEventTopic: + process.env.SOROBAN_EVENT_TOPIC || "InvoicePaymentRecorded", usdcIssuer: process.env.USDC_ISSUER || "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", @@ -20,8 +25,6 @@ export default registerAs("stellar", () => ({ 10, ), // Soroban contract integration - sorobanRpcUrl: - process.env.SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org", contractId: process.env.SOROBAN_CONTRACT_ID || "", adminSecretKey: process.env.ADMIN_SECRET_KEY || "", })); diff --git a/backend/src/invoices/invoices.service.soroban.spec.ts b/backend/src/invoices/invoices.service.soroban.spec.ts new file mode 100644 index 0000000..1e9bf9d --- /dev/null +++ b/backend/src/invoices/invoices.service.soroban.spec.ts @@ -0,0 +1,142 @@ +import { InvoicesService } from "./invoices.service"; +import { StellarService } from "../stellar/stellar.service"; +import { SorobanService } from "../soroban/soroban.service"; +import { WebhooksService } from "../webhooks/webhooks.service"; + +class FakePrisma { + invoice = { + _store: new Map(), + findUnique: async ({ where: { id, memo } }: any) => { + if (id) + return (FakePrisma as any).instance.invoice._store.get(id) || null; + if (memo) { + for (const v of (FakePrisma as any).instance.invoice._store.values()) { + if (v.memo === memo) return v; + } + return null; + } + return null; + }, + findFirst: async ({ where: { id, memo } }: any) => { + if (id) + return (FakePrisma as any).instance.invoice._store.get(id) || null; + if (memo) { + for (const v of (FakePrisma as any).instance.invoice._store.values()) { + if (v.memo === memo) return v; + } + return null; + } + return null; + }, + count: async () => (FakePrisma as any).instance.invoice._store.size, + create: async ({ data }: any) => { + (FakePrisma as any).instance.invoice._store.set(data.id, { ...data }); + return { ...data }; + }, + update: async ({ where: { id }, data }: any) => { + const current = (FakePrisma as any).instance.invoice._store.get(id); + const next = { ...current, ...data }; + (FakePrisma as any).instance.invoice._store.set(id, next); + return next; + }, + findMany: async () => + Array.from((FakePrisma as any).instance.invoice._store.values()), + }; + static instance: any; + constructor() { + (FakePrisma as any).instance = this; + } +} + +describe("InvoicesService.applySorobanPaymentEvent", () => { + let service: InvoicesService; + let prisma: any; + + const stellarStub = { + parseMemo: (memo: string) => + memo.startsWith("invoisio-") ? memo.slice("invoisio-".length) : null, + getMerchantPublicKey: () => + "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + } as unknown as StellarService; + + const sorobanStub = {} as unknown as SorobanService; + const webhooksStub = { + enqueueWebhook: async () => {}, + } as unknown as WebhooksService; + + beforeEach(async () => { + prisma = new FakePrisma(); + service = new InvoicesService( + stellarStub, + sorobanStub, + prisma, + webhooksStub, + ); + }); + + it("marks invoice paid and writes soroban metadata", async () => { + const id = "550e8400-e29b-41d4-a716-446655440000"; + await prisma.invoice.create({ + data: { + id, + clientName: "A", + amount: 1000, + asset_code: "XLM", + memo: "123", + memo_type: "ID", + status: "pending", + tx_hash: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const res = await service.applySorobanPaymentEvent({ + eventId: "evt-123", + contractId: "C123", + ledger: 999, + invoice_id: `invoisio-${id}`, + payer: "GPAAYER", + asset_code: "XLM", + asset_issuer: "", + amount: "10000000", + }); + + expect(res?.status).toBe("paid"); + expect(res?.tx_hash).toBe("soroban:evt-123"); + const stored = await prisma.invoice.findUnique({ where: { id } }); + expect(stored.metadata?.soroban?.lastEventId).toBe("evt-123"); + expect(stored.metadata?.soroban?.ledger).toBe(999); + }); + + it("is idempotent on repeated events", async () => { + const id = "4cc74bbf-2a82-4f87-9e44-8b3b3b3b3b3b"; + await prisma.invoice.create({ + data: { + id, + clientName: "B", + amount: 500, + asset_code: "XLM", + memo: "456", + memo_type: "ID", + status: "pending", + tx_hash: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + await service.applySorobanPaymentEvent({ + eventId: "evt-1", + invoice_id: `invoisio-${id}`, + } as any); + const first = await prisma.invoice.findUnique({ where: { id } }); + await service.applySorobanPaymentEvent({ + eventId: "evt-1", + invoice_id: `invoisio-${id}`, + } as any); + const normalized = await service.findOne(id, "dummyMerchantId"); + expect(first.status).toBe("paid"); + expect(normalized.status).toBe("paid"); + expect(normalized.tx_hash).toBe("soroban:evt-1"); + }); +}); diff --git a/backend/src/invoices/invoices.service.ts b/backend/src/invoices/invoices.service.ts index 25c42cc..3d625d8 100644 --- a/backend/src/invoices/invoices.service.ts +++ b/backend/src/invoices/invoices.service.ts @@ -329,6 +329,65 @@ export class InvoicesService implements OnModuleInit { return this.normalizeInvoice(invoice); } + async applySorobanPaymentEvent(evt: { + eventId: string; + contractId?: string; + ledger?: number; + invoice_id: string; + payer?: string; + asset_code?: string; + asset_issuer?: string; + amount?: string | number; + }): Promise { + const maybeId = this.stellarService.parseMemo(evt.invoice_id); + const invoiceId = maybeId ?? evt.invoice_id; + + const existing = await this.prisma.invoice.findUnique({ + where: { id: invoiceId }, + }); + if (!existing) { + return null; + } + + const sorobanMeta = { + lastEventId: evt.eventId, + contractId: evt.contractId ?? null, + ledger: evt.ledger ?? null, + invoice_id: evt.invoice_id, + payer: evt.payer ?? null, + asset_code: evt.asset_code ?? null, + asset_issuer: evt.asset_issuer ?? null, + amount: evt.amount ?? null, + updatedAt: new Date().toISOString(), + }; + + if (existing.status !== "paid") { + const updated = await this.prisma.invoice.update({ + where: { id: invoiceId }, + data: { + status: "paid", + txHash: `soroban:${evt.eventId}`, + metadata: { + ...((existing.metadata as any) ?? {}), + soroban: sorobanMeta, + }, + }, + }); + return this.normalizeInvoice(updated); + } else { + const updated = await this.prisma.invoice.update({ + where: { id: invoiceId }, + data: { + metadata: { + ...((existing.metadata as any) ?? {}), + soroban: sorobanMeta, + }, + }, + }); + return this.normalizeInvoice(updated); + } + } + /** * Reconcile a Horizon-confirmed payment with the Soroban contract and the database. * @@ -397,7 +456,10 @@ export class InvoicesService implements OnModuleInit { } // Step 4 — mark invoice as paid in the database. - const updated = await this.updateStatus(invoice.id, "paid"); + const updated = await this.updateStatus( + invoice.id, + "paid" as InvoiceStatus, + ); return { ...updated, txHash, ledger }; } @@ -470,6 +532,7 @@ export class InvoicesService implements OnModuleInit { asset_code: inv.assetCode, asset_issuer: inv.assetIssuer === null ? undefined : inv.assetIssuer, memo_type: inv.memoType, + tx_hash: inv.txHash, destination_address: inv.destinationAddress || this.stellarService.getMerchantPublicKey(), }; diff --git a/backend/src/prisma/merchant-scope.util.ts b/backend/src/prisma/merchant-scope.util.ts index f42ba8b..a76f817 100644 --- a/backend/src/prisma/merchant-scope.util.ts +++ b/backend/src/prisma/merchant-scope.util.ts @@ -108,7 +108,8 @@ function hasMerchantFilterInWhere(where: unknown): boolean { const and = w.AND; if ( - (Array.isArray(and) && and.some((entry) => hasMerchantFilterInWhere(entry))) || + (Array.isArray(and) && + and.some((entry) => hasMerchantFilterInWhere(entry))) || hasMerchantFilterInWhere(and) ) { return true; @@ -116,7 +117,8 @@ function hasMerchantFilterInWhere(where: unknown): boolean { const or = w.OR; if ( - (Array.isArray(or) && or.some((entry) => hasMerchantFilterInWhere(entry))) || + (Array.isArray(or) && + or.some((entry) => hasMerchantFilterInWhere(entry))) || hasMerchantFilterInWhere(or) ) { return true; @@ -124,7 +126,8 @@ function hasMerchantFilterInWhere(where: unknown): boolean { const not = w.NOT; if ( - (Array.isArray(not) && not.some((entry) => hasMerchantFilterInWhere(entry))) || + (Array.isArray(not) && + not.some((entry) => hasMerchantFilterInWhere(entry))) || hasMerchantFilterInWhere(not) ) { return true; diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts index 88c828e..1c0bb0c 100644 --- a/backend/src/prisma/prisma.service.ts +++ b/backend/src/prisma/prisma.service.ts @@ -31,7 +31,7 @@ export class PrismaService (this as any).$use(async (params: any, next: any) => { applyMerchantScope( - params as any, + params, this.merchantContext.getMerchantId(), this.logger, ); diff --git a/backend/src/stellar/soroban-events.module.ts b/backend/src/stellar/soroban-events.module.ts new file mode 100644 index 0000000..c73fd8e --- /dev/null +++ b/backend/src/stellar/soroban-events.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { SorobanEventsService } from "./soroban-events.service"; +import { InvoicesModule } from "../invoices/invoices.module"; +import { StellarModule } from "./stellar.module"; + +@Module({ + imports: [StellarModule, InvoicesModule], + providers: [SorobanEventsService], + exports: [SorobanEventsService], +}) +export class SorobanEventsModule {} diff --git a/backend/src/stellar/soroban-events.service.spec.ts b/backend/src/stellar/soroban-events.service.spec.ts new file mode 100644 index 0000000..920b699 --- /dev/null +++ b/backend/src/stellar/soroban-events.service.spec.ts @@ -0,0 +1,81 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { SorobanEventsService } from "./soroban-events.service"; +import { ConfigService } from "@nestjs/config"; +import { InvoicesService } from "../invoices/invoices.service"; + +describe("SorobanEventsService", () => { + let service: SorobanEventsService; + const applySpy = jest.fn(); + + const mockConfigService = { + get: jest.fn((key: string) => { + if (key === "stellar") { + return { + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + sorobanContractId: + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + sorobanEventTopic: "InvoicePaymentRecorded", + }; + } + return null; + }), + }; + + const mockInvoicesService = { + applySorobanPaymentEvent: applySpy, + }; + + beforeEach(async () => { + applySpy.mockReset(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SorobanEventsService, + { provide: ConfigService, useValue: mockConfigService }, + { provide: InvoicesService, useValue: mockInvoicesService }, + ], + }).compile(); + + service = module.get(SorobanEventsService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + it("ignores events without matching topic", async () => { + const ev = { + id: "evt1", + topic: ["OtherTopic"], + value: { + invoice_id: "invoisio-123", + }, + }; + await service.handleEvent(ev); + expect(applySpy).not.toHaveBeenCalled(); + }); + + it("parses and forwards payment_recorded events", async () => { + const ev = { + id: "evt2", + topic: ["InvoicePaymentRecorded"], + ledger: 123, + value: { + invoice_id: "invoisio-550e8400-e29b-41d4-a716-446655440000", + payer: "GCBZQY7M2K6Z2QG2Z2Z2Z2Z2Z2Z2Z2Z2Z2Z2Z2Z2Z2Z2Z2Z2Z2Z2Z2", + asset_code: "XLM", + asset_issuer: "", + amount: "10000000", + }, + }; + await service.handleEvent(ev); + expect(applySpy).toHaveBeenCalledTimes(1); + expect(applySpy).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "evt2", + invoice_id: "invoisio-550e8400-e29b-41d4-a716-446655440000", + asset_code: "XLM", + amount: "10000000", + }), + ); + }); +}); diff --git a/backend/src/stellar/soroban-events.service.ts b/backend/src/stellar/soroban-events.service.ts new file mode 100644 index 0000000..caf2071 --- /dev/null +++ b/backend/src/stellar/soroban-events.service.ts @@ -0,0 +1,235 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InvoicesService } from "../invoices/invoices.service"; +import https from "node:https"; +import { URL } from "node:url"; + +type Json = Record; + +@Injectable() +export class SorobanEventsService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(SorobanEventsService.name); + private running = false; + private timer: ReturnType | null = null; + private cursor: string | undefined = undefined; + private backoffMs = 1000; + + constructor( + private readonly config: ConfigService, + private readonly invoices: InvoicesService, + ) {} + + onModuleInit(): void { + const rpcUrl = this.getRpcUrl(); + const contractId = this.getContractId(); + if (!rpcUrl || !contractId) { + this.logger.warn( + "Soroban events disabled (missing SOROBAN_RPC_URL or SOROBAN_CONTRACT_ID)", + ); + return; + } + this.running = true; + this.logger.log( + `Soroban event subscriber started (rpc: ${rpcUrl}, contract: ${contractId})`, + ); + this.scheduleNext(0); + } + + onModuleDestroy(): void { + this.running = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private scheduleNext(delayMs: number) { + if (!this.running) return; + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => void this.tick(), delayMs); + } + + private async tick(): Promise { + if (!this.running) return; + try { + const resp = await this.fetchEvents(); + const events: any[] = resp?.result?.events ?? []; + for (const ev of events) { + await this.handleEvent(ev); + this.cursor = ev?.pagingToken ?? ev?.paging_token ?? this.cursor; + } + this.backoffMs = 1000; + this.scheduleNext(events.length > 0 ? 50 : 500); + } catch (err) { + const msg = (err as Error).message; + this.logger.warn(`Soroban getEvents error: ${msg}`); + this.backoffMs = Math.min(this.backoffMs * 2, 30000); + this.scheduleNext(this.backoffMs); + } + } + + async handleEvent(ev: any): Promise { + const topic = ev?.topic ?? ev?.topics ?? ev?.event?.topics ?? null; + const expect = this.getTopic(); + if (Array.isArray(topic) && expect && topic.length > 0) { + const flat = topic.map((t: any) => + typeof t === "string" ? t : String(t?.symbol ?? t), + ); + const hasTopic = + flat.includes(expect) || + flat.includes(expect.toLowerCase()) || + flat.includes(expect.toUpperCase()); + if (!hasTopic) { + return; + } + } + + const val = + ev?.value ?? + ev?.event?.value ?? + ev?.data ?? + ev?.event?.data ?? + ev?.body ?? + {}; + + const payload = this.coercePaymentRecorded(val); + if (!payload || !payload.invoice_id) { + return; + } + + await this.invoices.applySorobanPaymentEvent({ + eventId: String(ev?.id ?? ev?.eventId ?? ev?.pagingToken ?? Date.now()), + contractId: this.getContractId(), + ledger: + Number(ev?.ledger ?? ev?.inLedger ?? ev?.ledgers ?? 0) || undefined, + invoice_id: String(payload.invoice_id), + payer: payload.payer ? String(payload.payer) : undefined, + asset_code: payload.asset_code ? String(payload.asset_code) : undefined, + asset_issuer: payload.asset_issuer + ? String(payload.asset_issuer) + : undefined, + amount: + payload.amount !== undefined + ? (payload.amount as any).toString() + : undefined, + }); + } + + private coercePaymentRecorded(obj: any): { + invoice_id?: string; + payer?: string; + asset_code?: string; + asset_issuer?: string; + amount?: string | number; + } | null { + if (!obj || typeof obj !== "object") return null; + if ("invoice_id" in obj) return obj; + if (Array.isArray(obj?.map)) { + const out: Record = {}; + for (const entry of obj.map) { + const key = + entry?.key?.symbol ?? entry?.key?.string ?? entry?.key ?? undefined; + const val = + entry?.val?.string ?? + entry?.val?.address ?? + entry?.val?.i128 ?? + entry?.val?.u64 ?? + entry?.val ?? + undefined; + if (key !== undefined) { + out[String(key)] = val; + } + } + return out as any; + } + return null; + } + + private async fetchEvents(): Promise { + const rpc = this.getRpcUrl(); + const topic = this.getTopic(); + const contractId = this.getContractId(); + + const params: Json = { + startLedger: 1, + filters: [ + { + type: "contract", + contractIds: [contractId], + ...(topic ? { topics: [[topic]] } : {}), + }, + ], + pagination: { + ...(this.cursor ? { cursor: this.cursor } : {}), + limit: 100, + }, + }; + + const body: Json = { + jsonrpc: "2.0", + id: 1, + method: "getEvents", + params, + }; + + return await this.postJson(rpc, body); + } + + private postJson(rpcUrl: string, body: Json): Promise { + const url = new URL(rpcUrl); + const data = Buffer.from(JSON.stringify(body)); + const isHttps = url.protocol === "https:"; + const options: https.RequestOptions = { + method: "POST", + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname || "/", + headers: { + "Content-Type": "application/json", + "Content-Length": data.length, + }, + }; + + return new Promise((resolve, reject) => { + const req = https.request(options, (res) => { + const chunks: Buffer[] = []; + res.on("data", (d) => chunks.push(d)); + res.on("end", () => { + const txt = Buffer.concat(chunks).toString("utf8"); + try { + resolve(JSON.parse(txt)); + } catch (e) { + reject( + new Error( + `Invalid JSON from Soroban RPC (status ${res.statusCode}): ${txt.slice(0, 200)}`, + ), + ); + } + }); + }); + req.on("error", (e) => reject(e)); + req.write(data); + req.end(); + }); + } + + private getRpcUrl(): string { + const conf = this.config.get("stellar"); + return conf?.sorobanRpcUrl || ""; + } + + private getContractId(): string { + const conf = this.config.get("stellar"); + return conf?.sorobanContractId || ""; + } + + private getTopic(): string { + const conf = this.config.get("stellar"); + return conf?.sorobanEventTopic || "InvoicePaymentRecorded"; + } +} diff --git a/backend/src/webhooks/webhooks.service.ts b/backend/src/webhooks/webhooks.service.ts index bdf20e8..5d3e12d 100644 --- a/backend/src/webhooks/webhooks.service.ts +++ b/backend/src/webhooks/webhooks.service.ts @@ -20,7 +20,9 @@ export class WebhooksService { txHash: string | null, merchantId?: string, ): Promise { - const where = merchantId ? { id: invoiceId, merchantId } : { id: invoiceId }; + const where = merchantId + ? { id: invoiceId, merchantId } + : { id: invoiceId }; const invoice = await this.prisma.invoice.findFirst({ where, include: { user: true }, diff --git a/backend/test-results.json b/backend/test-results.json new file mode 100644 index 0000000..d98ac81 Binary files /dev/null and b/backend/test-results.json differ diff --git a/backend/typescript-errors.txt b/backend/typescript-errors.txt new file mode 100644 index 0000000..08df8e2 Binary files /dev/null and b/backend/typescript-errors.txt differ diff --git a/web/package-lock.json b/web/package-lock.json index b020d58..f0d940b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -69,6 +69,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1589,6 +1590,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1648,6 +1650,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2173,6 +2176,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2533,6 +2537,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3115,6 +3120,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3300,6 +3306,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5552,6 +5559,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5561,6 +5569,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6249,6 +6258,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6411,6 +6421,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6686,6 +6697,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }