diff --git a/jest.config.ts b/jest.config.ts index 37b7692..984e8d5 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,39 +3,40 @@ import type { Config } from 'jest'; // https://www.jenchan.biz/blog/dissecting-the-hell-jest-setup-esm-typescript-setup // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ const baseConfig: Config = { - moduleFileExtensions: ['js', 'json', 'ts'], - collectCoverageFrom: ['**/*.(t|j)s'], - coverageDirectory: '../coverage', - testEnvironment: 'node', - transform: { - '^.+\\.m?tsx?$': [ - 'ts-jest', - { - useESM: true, - tsconfig: './tsconfig.json', - diagnostics: process.env.CI ? true : false, - }, - ], - }, - transformIgnorePatterns: [], - extensionsToTreatAsEsm: ['.ts'], + moduleFileExtensions: ['js', 'json', 'ts'], + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '../coverage', + testEnvironment: 'node', + transform: { + '^.+\\.m?tsx?$': [ + 'ts-jest', + { + useESM: true, + tsconfig: './tsconfig.json', + diagnostics: process.env.CI ? true : false, + }, + ], + }, + transformIgnorePatterns: [], + extensionsToTreatAsEsm: ['.ts'], }; const config: Config = { - projects: [ - { - ...baseConfig, - displayName: 'Unit tests', - rootDir: './src', - testRegex: '.*\\.spec\\.ts$', - }, - { - ...baseConfig, - displayName: 'End-to-end tests', - rootDir: './test', - testRegex: '.e2e-spec.ts$', - }, - ], + projects: [ + { + ...baseConfig, + displayName: 'Unit tests', + rootDir: './src', + testRegex: '.*\\.spec\\.ts$', + }, + { + ...baseConfig, + displayName: 'End-to-end tests', + rootDir: './test', + testRegex: '.e2e-spec.ts$', + setupFilesAfterEnv: ['./setup.ts'], + }, + ], }; export default config; diff --git a/package.json b/package.json index 018ea1e..2fd928e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tonomy-communication", - "version": "1.6.0-rc.1", + "version": "1.7.0-development.2", "description": "", "author": "Rebal Alhaqash", "private": true, @@ -32,7 +32,7 @@ "@nestjs/swagger": "^7.1.10", "@nestjs/websockets": "^10.2.3", "@safe-global/sdk-starter-kit": "^3.0.2", - "@tonomy/tonomy-id-sdk": "0.36.0-rc.10", + "@tonomy/tonomy-id-sdk": "0.37.0-rc.2", "axios": "1.9.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", diff --git a/src/communication/communication.gateway.ts b/src/communication/communication.gateway.ts index 6e23c99..91d9449 100644 --- a/src/communication/communication.gateway.ts +++ b/src/communication/communication.gateway.ts @@ -28,7 +28,7 @@ import { CommunicationGuard } from './communication.guard'; import { BodyDto } from './dto/body.dto'; import { Server } from 'socket.io'; import { SwapBodyDto } from './dto/swapBody.dto'; -import { Transaction } from 'typeorm'; +import { FaucetBodyDto } from './dto/faucetBody.dto'; export type WebsocketReturnType = { status: HttpStatus; @@ -141,6 +141,31 @@ export class CommunicationGateway } } + /** + * Requests testnet tokens from the faucet + * @param {FaucetBodyDto} body - The faucet token message VC or an error from the transformer + * @param client user socket + */ + @SubscribeMessage('v1/faucet/token/tono') + @UseGuards(CommunicationGuard) + async requestFaucetToken( + @MessageBody() body: FaucetBodyDto, + @ConnectedSocket() client: Client, + ) { + try { + if (body.error) throw body.error; + if (!body.value) throw new Error('Body not found'); + const message = body.value; + + return { + status: HttpStatus.OK, + details: await this.usersService.requestFaucetToken(client, message), + }; + } catch (e) { + return this.usersService.handleError(e); + } + } + /** * safely disconnect the user from the server when user disconnects * @param socket user socket diff --git a/src/communication/communication.service.ts b/src/communication/communication.service.ts index d8b7659..ea1efc4 100644 --- a/src/communication/communication.service.ts +++ b/src/communication/communication.service.ts @@ -21,6 +21,8 @@ import { waitForTonomyTrxFinalization, prepareSafeWalletTransfer, createAntelopeDid, + FaucetTokenMessage, + assetToDecimal, getSigner, } from '@tonomy/tonomy-id-sdk'; import { tonomySigner } from '../signer'; @@ -38,6 +40,12 @@ export class CommunicationService { private readonly loggedInUsers = new Map(); private readonly userSockets = new Map(); + private readonly faucetRequestTracker = new Map< + string, + { amount: Decimal; timestamp: number }[] + >(); + private readonly FAUCET_LIMIT_24H = new Decimal('20000'); + private readonly THROTTLE_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds private server!: Server; @@ -234,6 +242,152 @@ export class CommunicationService { return true; } + /** + * Requests testnet tokens from the faucet service + * @param {Client} socket user socket + * @param {SwapTokenMessage} message signed VC containing faucet request + * @returns boolean if tokens were transferred successfully + */ + async requestFaucetToken( + socket: Client, + message: FaucetTokenMessage, + ): Promise { + const loggerId = randomString(6); + const payload = message.getPayload(); + const issuer = message.getIssuer(); + + this.logger.debug( + `[Faucet: ${loggerId}]: requestFaucetToken() from ${issuer}`, + ); + + if (settings.env === 'production') { + throw new HttpException( + `Faucet service is not available in production`, + HttpStatus.BAD_REQUEST, + ); + } + + await checkIssuerFromTonomyPlatform( + issuer, + payload._testOnly_tonomyAppsWebsiteUsername, + ); + + const tonomyAccount = getAccountNameFromDid(issuer); + const asset = payload.asset; + + if (!asset || asset.trim().length === 0 || !asset.endsWith(' TONO')) { + throw new HttpException( + `Invalid asset format ${asset}`, + HttpStatus.BAD_REQUEST, + ); + } + + const assetAmount = assetToDecimal(asset); + + if (assetAmount.lessThanOrEqualTo(0)) { + throw new HttpException( + `Invalid asset amount ${asset}`, + HttpStatus.BAD_REQUEST, + ); + } else if (assetAmount.greaterThan(1000)) { + throw new HttpException( + `Requested asset amount exceeds faucet limit: ${asset}`, + HttpStatus.BAD_REQUEST, + ); + } + + // Check 24-hour throttling + this.checkFaucetThrottle(issuer, assetAmount, loggerId); + + this.logger.log( + `[Faucet: ${loggerId}]: Transferring ${asset} to Tonomy account ${tonomyAccount}`, + ); + + const trx = await getTokenContract().transfer( + 'ops.tmy', + tonomyAccount, + asset, + `Faucet token request ${loggerId}`, + tonomySigner, + ); + + this.logger.debug( + `[Faucet: ${loggerId}]: Faucet transaction submitted with ID ${trx.transaction_id}`, + ); + + if (settings.env === 'production' || settings.env === 'testnet') { + // Depends on Hyperion API which is only available on testnet and mainnet + await waitForTonomyTrxFinalization(trx.transaction_id); + this.logger.debug( + `[Faucet: ${loggerId}]: Tonomy transaction ${trx.transaction_id} finalized`, + ); + } + + // Track this request for throttling purposes + this.trackFaucetRequest(issuer, assetAmount); + + this.logger.log( + `[Faucet: ${loggerId}]: Faucet transfer completed successfully`, + ); + return true; + } + + /** + * Check if account has exceeded 24-hour faucet limit + * @param issuer the DID of the requester + * @param requestAmount the amount being requested + * @param loggerId logging identifier + * @throws HttpException if throttle limit exceeded + */ + private checkFaucetThrottle( + issuer: string, + requestAmount: Decimal, + loggerId: string, + ): void { + const now = Date.now(); + const requests = this.faucetRequestTracker.get(issuer) || []; + + // Remove requests older than 24 hours + const recentRequests = requests.filter( + (req) => now - req.timestamp < this.THROTTLE_WINDOW_MS, + ); + + // Calculate total requested in last 24 hours + const totalRequested = recentRequests.reduce( + (sum, req) => sum.plus(req.amount), + new Decimal(0), + ); + + const newTotal = totalRequested.plus(requestAmount); + + if (newTotal.greaterThan(this.FAUCET_LIMIT_24H)) { + const remainingAllowance = this.FAUCET_LIMIT_24H.minus(totalRequested); + + this.logger.warn( + `[Faucet: ${loggerId}]: Account ${issuer} exceeded 24-hour limit. Requested: ${requestAmount.toString()}, Remaining allowance: ${remainingAllowance.toString()}`, + ); + throw new HttpException( + `Daily faucet limit exceeded. You have requested ${totalRequested.toString()} TONO in the last 24 hours. Maximum allowed: ${this.FAUCET_LIMIT_24H.toString()} TONO. Remaining allowance: ${remainingAllowance.toString()} TONO`, + HttpStatus.BAD_REQUEST, + ); + } + } + + /** + * Track a faucet request for throttling purposes + * @param issuer the DID of the requester + * @param amount the amount requested + */ + private trackFaucetRequest(issuer: string, amount: Decimal): void { + const requests = this.faucetRequestTracker.get(issuer) || []; + + requests.push({ + amount, + timestamp: Date.now(), + }); + this.faucetRequestTracker.set(issuer, requests); + } + /** * Send a 'veriff' event to a specific user by DID * @param did recipient DID @@ -300,7 +454,7 @@ async function getAccountNameForTonomyAppsPlatform( } else { if (_testOnly_tonomyAppsWebsiteUsername) { throw new Error( - 'tonomyAppsWebsiteUsername can only be used in non-production environments', + 'tonomyAppsWebsiteUsername can only be used in testing/development environments', ); } diff --git a/src/communication/dto/faucetBody.dto.ts b/src/communication/dto/faucetBody.dto.ts new file mode 100644 index 0000000..56a0709 --- /dev/null +++ b/src/communication/dto/faucetBody.dto.ts @@ -0,0 +1,7 @@ +import { FaucetTokenMessage } from '@tonomy/tonomy-id-sdk'; + +export class FaucetBodyDto { + value?: FaucetTokenMessage; + error?: Error | any; + asset?: string; // Format: "1.000000 TONO" +} diff --git a/src/settings.ts b/src/settings.ts index ca01f4c..0345a53 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -112,7 +112,12 @@ if (process.env.ETHEREUM_PRIVATE_KEY) { settings.secrets.basePrivateKey = process.env.ETHEREUM_PRIVATE_KEY; } -console.log( +if (process.env.BASE_TOKEN_ADDRESS) { + logger.log('Using BASE_TOKEN_ADDRESS from env'); + settings.config.baseTokenAddress = process.env.BASE_TOKEN_ADDRESS; +} + +logger.log( `Ethereum signing address: ${new ethers.Wallet(settings.secrets.basePrivateKey).address}`, ); diff --git a/test/communication.e2e-spec.ts b/test/communication.e2e-spec.ts index 2e9a7a9..06efa23 100644 --- a/test/communication.e2e-spec.ts +++ b/test/communication.e2e-spec.ts @@ -6,14 +6,9 @@ import { Socket } from 'socket.io-client'; import { AuthenticationMessage, generateRandomKeyPair, - setSettings, util, } from '@tonomy/tonomy-id-sdk'; -setSettings({ - blockchainUrl: 'http://localhost:8888', -}); - describe('CommunicationGateway (e2e)', () => { let app: INestApplication; let socket: Socket; @@ -24,9 +19,11 @@ describe('CommunicationGateway (e2e)', () => { }).compile(); app = moduleFixture.createNestApplication(); - app.listen(5000); + await app.listen(0); + const server: any = app.getHttpServer(); + const port: number = server.address().port; - socket = await connectSocket(); + socket = await connectSocket(port); }); afterEach(async () => { diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..db77ad7 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,15 @@ +import { setSettings } from '@tonomy/tonomy-id-sdk'; +import commSettings from '../src/settings'; + +// Initialize SDK settings before all e2e tests run +setSettings({ + environment: commSettings.env, + blockchainUrl: commSettings.config.blockchainUrl, + accountSuffix: commSettings.config.accountSuffix, + currencySymbol: commSettings.config.currencySymbol, + basePrivateKey: commSettings.secrets.basePrivateKey, + baseNetwork: commSettings.config.baseNetwork, + baseRpcUrl: commSettings.config.baseRpcUrl, + baseTokenAddress: + process.env.BASE_TOKEN_ADDRESS || commSettings.config.baseTokenAddress, +}); diff --git a/test/veramo.e2e-spec.ts b/test/veramo.e2e-spec.ts index a0f4d86..f55437c 100644 --- a/test/veramo.e2e-spec.ts +++ b/test/veramo.e2e-spec.ts @@ -6,7 +6,20 @@ import { } from '@tonomy/tonomy-id-sdk'; import Debug from 'debug'; -// No longer passing since new Tonomy ID SDK exported DataSource as a type only +/** + * NOTE: These tests are currently skipped because they require the DataSource import + * in the SDK's veramo.ts file to be a runtime import (not type-only). + * + * While changing `import type { DataSource }` to `import { DataSource }` fixes these + * tests, it breaks the SDK's build for UMD/browser bundles because TypeORM's DataSource + * cannot be bundled for browser environments (it depends on Node.js-specific modules). + * + * The veramo functionality is primarily used in Node.js/server contexts, so these tests + * should eventually be moved to a Node.js-specific test suite or the SDK should provide + * separate entry points for Node.js vs browser environments. + * + * For now, these tests remain skipped to avoid breaking the SDK build. + */ describe.skip('veramo', () => { beforeAll(async () => { await setupDatabase(); diff --git a/test/ws-client.helper.ts b/test/ws-client.helper.ts index f91ffeb..87d679d 100644 --- a/test/ws-client.helper.ts +++ b/test/ws-client.helper.ts @@ -4,8 +4,8 @@ import { WebsocketReturnType } from 'src/communication/communication.gateway'; export const SOCKET_TIMEOUT = 1000; -export async function connectSocket(): Promise { - const socket = io('http://localhost:5000', { +export async function connectSocket(port = 5000): Promise { + const socket = io(`http://localhost:${port}`, { autoConnect: false, transports: ['websocket'], }); diff --git a/yarn.lock b/yarn.lock index 8223c44..a0eb2c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3730,9 +3730,9 @@ __metadata: languageName: node linkType: hard -"@tonomy/tonomy-id-sdk@npm:0.36.0-rc.10": - version: 0.36.0-rc.10 - resolution: "@tonomy/tonomy-id-sdk@npm:0.36.0-rc.10" +"@tonomy/tonomy-id-sdk@npm:0.37.0-rc.2": + version: 0.37.0-rc.2 + resolution: "@tonomy/tonomy-id-sdk@npm:0.37.0-rc.2" dependencies: "@consento/sync-randombytes": "npm:^1.0.5" "@tonomy/antelope-did-resolver": "npm:^0.10.0" @@ -3760,7 +3760,7 @@ __metadata: typeorm: "npm:0.3.22" universal-base64url: "npm:^1.1.0" web-did-resolver: "npm:^2.0.27" - checksum: 10c0/4fd74f74b0e7191bf49126c5a780d2762fd38ece803be3734aab702ae259deee2a0ae07fccf1371ec4ec8a180eb38a5deb2538f974bd853b609779fb004fcd35 + checksum: 10c0/f23152b54fe5a9efce7a46f182e86fb06c62f06d0b5943e3f792b5a1907f649dffb577aa76a956c227c7d2005a9718c31f601a0e59f10fdce00de483c55a9536 languageName: node linkType: hard @@ -13790,7 +13790,7 @@ __metadata: "@nestjs/websockets": "npm:^10.2.3" "@safe-global/sdk-starter-kit": "npm:^3.0.2" "@semantic-release/git": "npm:^10.0.1" - "@tonomy/tonomy-id-sdk": "npm:0.36.0-rc.10" + "@tonomy/tonomy-id-sdk": "npm:0.37.0-rc.2" "@types/eslint__js": "npm:^8.42.3" "@types/express": "npm:^4.17.13" "@types/jest": "npm:^29.5.14"