diff --git a/backend/README.md b/backend/README.md index 823998fdc..3d7c23eaa 100644 --- a/backend/README.md +++ b/backend/README.md @@ -110,6 +110,11 @@ Run the Mempool backend: ``` npm run start + +``` +You can also set env var `MEMPOOL_CONFIG_FILE` to specify a custom config file location: +``` +MEMPOOL_CONFIG_FILE=/path/to/mempool-config.json npm run start ``` When it's running, you should see output like this: diff --git a/backend/package.json b/backend/package.json index 082449dac..3cd05e8ab 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,7 +22,10 @@ "main": "index.ts", "scripts": { "tsc": "./node_modules/typescript/bin/tsc -p tsconfig.build.json", - "build": "npm run tsc", + "build": "npm run tsc && npm run create-resources", + "create-resources": "cp ./src/tasks/price-feeds/mtgox-weekly.json ./dist/tasks && node dist/api/fetch-version.js", + "package": "npm run build && rm -rf package && mv dist package && mv node_modules package && npm run package-rm-build-deps", + "package-rm-build-deps": "(cd package/node_modules; rm -r typescript @typescript-eslint)", "start": "node --max-old-space-size=2048 dist/index.js", "start-production": "node --max-old-space-size=4096 dist/index.js", "test": "./node_modules/.bin/jest --coverage", diff --git a/backend/src/api/backend-info.ts b/backend/src/api/backend-info.ts index d98675671..57bb5fe13 100644 --- a/backend/src/api/backend-info.ts +++ b/backend/src/api/backend-info.ts @@ -1,60 +1,37 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import logger from '../logger'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; import { IBackendInfo } from '../mempool.interfaces'; -const { spawnSync } = require('child_process'); class BackendInfo { - private gitCommitHash = ''; - private hostname = ''; - private version = ''; + private backendInfo: IBackendInfo; constructor() { - this.setLatestCommitHash(); - this.setVersion(); - this.hostname = os.hostname(); - } - - public getBackendInfo(): IBackendInfo { - return { - hostname: this.hostname, - gitCommit: this.gitCommitHash, - version: this.version, + // This file is created by ./fetch-version.ts during building + const versionFile = path.join(__dirname, 'version.json') + var versionInfo; + if (fs.existsSync(versionFile)) { + versionInfo = JSON.parse(fs.readFileSync(versionFile).toString()); + } else { + // Use dummy values if `versionFile` doesn't exist (e.g., during testing) + versionInfo = { + version: '?', + gitCommit: '?' + }; + } + this.backendInfo = { + hostname: os.hostname(), + version: versionInfo.version, + gitCommit: versionInfo.gitCommit }; } + public getBackendInfo(): IBackendInfo { + return this.backendInfo; + } + public getShortCommitHash() { - return this.gitCommitHash.slice(0, 7); - } - - private setLatestCommitHash(): void { - //TODO: share this logic with `generate-config.js` - if (process.env.DOCKER_COMMIT_HASH) { - this.gitCommitHash = process.env.DOCKER_COMMIT_HASH; - } else { - try { - const gitRevParse = spawnSync('git', ['rev-parse', '--short', 'HEAD']); - if (!gitRevParse.error) { - const output = gitRevParse.stdout.toString('utf-8').replace(/[\n\r\s]+$/, ''); - this.gitCommitHash = output ? output : '?'; - } else if (gitRevParse.error.code === 'ENOENT') { - console.log('git not found, cannot parse git hash'); - this.gitCommitHash = '?'; - } - } catch (e: any) { - console.log('Could not load git commit info: ' + e.message); - this.gitCommitHash = '?'; - } - } - } - - private setVersion(): void { - try { - const packageJson = fs.readFileSync('package.json').toString(); - this.version = JSON.parse(packageJson).version; - } catch (e) { - throw new Error(e instanceof Error ? e.message : 'Error'); - } + return this.backendInfo.gitCommit.slice(0, 7); } } diff --git a/backend/src/api/explorer/channels.api.ts b/backend/src/api/explorer/channels.api.ts index b396e4808..a52b0f28f 100644 --- a/backend/src/api/explorer/channels.api.ts +++ b/backend/src/api/explorer/channels.api.ts @@ -39,7 +39,8 @@ class ChannelsApi { FROM channels JOIN nodes AS nodes_1 on nodes_1.public_key = channels.node1_public_key JOIN nodes AS nodes_2 on nodes_2.public_key = channels.node2_public_key - WHERE nodes_1.latitude IS NOT NULL AND nodes_1.longitude IS NOT NULL + WHERE channels.status = 1 + AND nodes_1.latitude IS NOT NULL AND nodes_1.longitude IS NOT NULL AND nodes_2.latitude IS NOT NULL AND nodes_2.longitude IS NOT NULL `; @@ -374,6 +375,7 @@ class ChannelsApi { 'transaction_vout': channel.transaction_vout, 'closing_transaction_id': channel.closing_transaction_id, 'closing_reason': channel.closing_reason, + 'closing_date': channel.closing_date, 'updated_at': channel.updated_at, 'created': channel.created, 'status': channel.status, diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index 128405ffd..9d82dc83d 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -5,6 +5,49 @@ import { ILightningApi } from '../lightning/lightning-api.interface'; import { ITopNodesPerCapacity, ITopNodesPerChannels } from '../../mempool.interfaces'; class NodesApi { + public async $getWorldNodes(): Promise { + try { + let query = ` + SELECT nodes.public_key as publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias, + CAST(COALESCE(nodes.capacity, 0) as INT) as capacity, + CAST(COALESCE(nodes.channels, 0) as INT) as channels, + nodes.longitude, nodes.latitude, + geo_names_country.names as country, geo_names_iso.names as isoCode + FROM nodes + LEFT JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country' + LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code' + WHERE status = 1 AND nodes.as_number IS NOT NULL + ORDER BY capacity + `; + + const [nodes]: any[] = await DB.query(query); + + for (let i = 0; i < nodes.length; ++i) { + nodes[i].country = JSON.parse(nodes[i].country); + } + + query = ` + SELECT MAX(nodes.capacity) as maxLiquidity, MAX(nodes.channels) as maxChannels + FROM nodes + WHERE status = 1 AND nodes.as_number IS NOT NULL + `; + + const [maximums]: any[] = await DB.query(query); + + return { + maxLiquidity: maximums[0].maxLiquidity, + maxChannels: maximums[0].maxChannels, + nodes: nodes.map(node => [ + node.longitude, node.latitude, + node.publicKey, node.alias, node.capacity, node.channels, + node.country, node.isoCode + ]) + }; + } catch (e) { + logger.err(`Can't get world nodes list. Reason: ${e instanceof Error ? e.message : e}`); + } + } + public async $getNode(public_key: string): Promise { try { // General info @@ -133,10 +176,13 @@ class NodesApi { CAST(COALESCE(nodes.capacity, 0) as INT) as capacity, CAST(COALESCE(nodes.channels, 0) as INT) as channels, UNIX_TIMESTAMP(nodes.first_seen) as firstSeen, UNIX_TIMESTAMP(nodes.updated_at) as updatedAt, - geo_names_city.names as city, geo_names_country.names as country + geo_names_city.names as city, geo_names_country.names as country, + geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision FROM nodes LEFT JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country' LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city' + LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code' + LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division' ORDER BY capacity DESC LIMIT 100 `; @@ -175,10 +221,13 @@ class NodesApi { CAST(COALESCE(nodes.channels, 0) as INT) as channels, CAST(COALESCE(nodes.capacity, 0) as INT) as capacity, UNIX_TIMESTAMP(nodes.first_seen) as firstSeen, UNIX_TIMESTAMP(nodes.updated_at) as updatedAt, - geo_names_city.names as city, geo_names_country.names as country + geo_names_city.names as city, geo_names_country.names as country, + geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision FROM nodes LEFT JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country' LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city' + LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code' + LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division' ORDER BY channels DESC LIMIT 100 `; @@ -221,11 +270,14 @@ class NodesApi { CAST(COALESCE(node_stats.channels, 0) as INT) as channels, CAST(COALESCE(node_stats.capacity, 0) as INT) as capacity, UNIX_TIMESTAMP(nodes.first_seen) as firstSeen, UNIX_TIMESTAMP(nodes.updated_at) as updatedAt, - geo_names_city.names as city, geo_names_country.names as country + geo_names_city.names as city, geo_names_country.names as country, + geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision FROM node_stats RIGHT JOIN nodes ON nodes.public_key = node_stats.public_key LEFT JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country' LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city' + LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code' + LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division' WHERE added = FROM_UNIXTIME(${latestDate}) ORDER BY first_seen LIMIT 100 diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts index 7a5ff880a..cf3f75208 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -9,6 +9,7 @@ class NodesRoutes { public initRoutes(app: Application) { app + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/world', this.$getWorldNodes) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/country/:country', this.$getNodesPerCountry) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/search/:search', this.$searchNode) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp-ranking', this.$getISPRanking) @@ -115,7 +116,6 @@ class NodesRoutes { private async $getISPRanking(req: Request, res: Response): Promise { try { const nodesPerAs = await nodesApi.$getNodesISPRanking(); - res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); @@ -125,6 +125,18 @@ class NodesRoutes { } } + private async $getWorldNodes(req: Request, res: Response) { + try { + const worldNodes = await nodesApi.$getWorldNodes(); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); + res.json(worldNodes); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async $getNodesPerCountry(req: Request, res: Response) { try { const [country]: any[] = await DB.query( diff --git a/backend/src/api/fetch-version.ts b/backend/src/api/fetch-version.ts new file mode 100644 index 000000000..cb0813c35 --- /dev/null +++ b/backend/src/api/fetch-version.ts @@ -0,0 +1,37 @@ +import fs from 'fs'; +import path from "path"; +const { spawnSync } = require('child_process'); + +function getVersion(): string { + const packageJson = fs.readFileSync('package.json').toString(); + return JSON.parse(packageJson).version; +} + +function getGitCommit(): string { + if (process.env.MEMPOOL_COMMIT_HASH) { + return process.env.MEMPOOL_COMMIT_HASH; + } else { + const gitRevParse = spawnSync('git', ['rev-parse', '--short', 'HEAD']); + if (!gitRevParse.error) { + const output = gitRevParse.stdout.toString('utf-8').replace(/[\n\r\s]+$/, ''); + if (output) { + return output; + } else { + console.log('Could not fetch git commit: No repo available'); + } + } else if (gitRevParse.error.code === 'ENOENT') { + console.log('Could not fetch git commit: Command `git` is unavailable'); + } + } + return '?'; +} + +const versionInfo = { + version: getVersion(), + gitCommit: getGitCommit() +} + +fs.writeFileSync( + path.join(__dirname, 'version.json'), + JSON.stringify(versionInfo, null, 2) + "\n" +); diff --git a/backend/src/config.ts b/backend/src/config.ts index 8c91e104b..43ba16cb3 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,4 +1,6 @@ -const configFile = require('../mempool-config.json'); +const configFromFile = require( + process.env.MEMPOOL_CONFIG_FILE ? process.env.MEMPOOL_CONFIG_FILE : '../mempool-config.json' +); interface IConfig { MEMPOOL: { @@ -249,7 +251,7 @@ class Config implements IConfig { MAXMIND: IConfig['MAXMIND']; constructor() { - const configs = this.merge(configFile, defaults); + const configs = this.merge(configFromFile, defaults); this.MEMPOOL = configs.MEMPOOL; this.ESPLORA = configs.ESPLORA; this.ELECTRUM = configs.ELECTRUM; diff --git a/backend/src/tasks/lightning/network-sync.service.ts b/backend/src/tasks/lightning/network-sync.service.ts index b9b7df92c..8d8f1759f 100644 --- a/backend/src/tasks/lightning/network-sync.service.ts +++ b/backend/src/tasks/lightning/network-sync.service.ts @@ -12,9 +12,11 @@ import { ResultSetHeader } from 'mysql2'; import fundingTxFetcher from './sync-tasks/funding-tx-fetcher'; import NodesSocketsRepository from '../../repositories/NodesSocketsRepository'; import { Common } from '../../api/common'; +import blocks from '../../api/blocks'; class NetworkSyncService { loggerTimer = 0; + closedChannelsScanBlock = 0; constructor() {} @@ -240,10 +242,22 @@ class NetworkSyncService { } private async $scanForClosedChannels(): Promise { + if (this.closedChannelsScanBlock === blocks.getCurrentBlockHeight()) { + logger.debug(`We've already scan closed channels for this block, skipping.`); + return; + } + let progress = 0; try { - logger.info(`Starting closed channels scan`); + let log = `Starting closed channels scan`; + if (this.closedChannelsScanBlock > 0) { + log += `. Last scan was at block ${this.closedChannelsScanBlock}`; + } else { + log += ` for the first time`; + } + logger.info(log); + const channels = await channelsApi.$getChannelsByStatus([0, 1]); for (const channel of channels) { const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout); @@ -263,7 +277,9 @@ class NetworkSyncService { this.loggerTimer = new Date().getTime() / 1000; } } - logger.info(`Closed channels scan complete.`); + + this.closedChannelsScanBlock = blocks.getCurrentBlockHeight(); + logger.info(`Closed channels scan completed at block ${this.closedChannelsScanBlock}`); } catch (e) { logger.err('$scanForClosedChannels() error: ' + (e instanceof Error ? e.message : e)); } diff --git a/backend/src/tasks/price-updater.ts b/backend/src/tasks/price-updater.ts index 81066efb2..891e2bce3 100644 --- a/backend/src/tasks/price-updater.ts +++ b/backend/src/tasks/price-updater.ts @@ -1,4 +1,5 @@ import * as fs from 'fs'; +import path from "path"; import { Common } from '../api/common'; import config from '../config'; import logger from '../logger'; @@ -159,7 +160,7 @@ class PriceUpdater { const existingPriceTimes = await PricesRepository.$getPricesTimes(); // Insert MtGox weekly prices - const pricesJson: any[] = JSON.parse(fs.readFileSync('./src/tasks/price-feeds/mtgox-weekly.json').toString()); + const pricesJson: any[] = JSON.parse(fs.readFileSync(path.join(__dirname, 'mtgox-weekly.json')).toString()); const prices = this.getEmptyPricesObj(); let insertedCount: number = 0; for (const price of pricesJson) { diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 8d13bc7f4..9be457bb2 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -1,7 +1,7 @@ FROM node:16.16.0-buster-slim AS builder ARG commitHash -ENV DOCKER_COMMIT_HASH=${commitHash} +ENV MEMPOOL_COMMIT_HASH=${commitHash} WORKDIR /build COPY . . @@ -9,18 +9,15 @@ COPY . . RUN apt-get update RUN apt-get install -y build-essential python3 pkg-config RUN npm install --omit=dev --omit=optional -RUN npm run build +RUN npm run package FROM node:16.16.0-buster-slim WORKDIR /backend -COPY --from=builder /build/ . - -RUN chmod +x /backend/start.sh -RUN chmod +x /backend/wait-for-it.sh - -RUN chown -R 1000:1000 /backend && chmod -R 755 /backend +RUN chown 1000:1000 ./ +COPY --from=builder --chown=1000:1000 /build/package ./package/ +COPY --from=builder --chown=1000:1000 /build/mempool-config.json /build/start.sh /build/wait-for-it.sh ./ USER 1000 diff --git a/docker/backend/start.sh b/docker/backend/start.sh old mode 100644 new mode 100755 index 71c59cb3d..b4254568f --- a/docker/backend/start.sh +++ b/docker/backend/start.sh @@ -205,4 +205,4 @@ sed -i "s!__LND_REST_API_URL__!${__LND_REST_API_URL__}!g" mempool-config.json # CLN sed -i "s!__CLIGHTNING_SOCKET__!${__CLIGHTNING_SOCKET__}!g" mempool-config.json -node /backend/dist/index.js +node /backend/package/index.js diff --git a/docker/backend/wait-for-it.sh b/docker/backend/wait-for-it.sh old mode 100644 new mode 100755 diff --git a/docker/init.sh b/docker/init.sh index 49b53d646..4eb06f0c1 100755 --- a/docker/init.sh +++ b/docker/init.sh @@ -1,10 +1,7 @@ #!/bin/sh #backend -gitMaster="\.\.\/\.git\/refs\/heads\/master" -git ls-remote https://github.com/mempool/mempool.git "$1^{}" | awk '{ print $1}' > ./backend/master cp ./docker/backend/* ./backend/ -sed -i "s/${gitMaster}/master/g" ./backend/src/api/backend-info.ts #frontend localhostIP="127.0.0.1" diff --git a/frontend/src/app/components/address/address-preview.component.html b/frontend/src/app/components/address/address-preview.component.html index 30b9c29e6..6a2d1efee 100644 --- a/frontend/src/app/components/address/address-preview.component.html +++ b/frontend/src/app/components/address/address-preview.component.html @@ -1,12 +1,12 @@
+

Address

-
-

Address

+
+
+

{{addressString.slice(0,-4)}}{{addressString.slice(-4)}}

+
- - {{addressString.slice(0,-4)}}{{addressString.slice(-4)}} - diff --git a/frontend/src/app/components/address/address-preview.component.scss b/frontend/src/app/components/address/address-preview.component.scss index 2de368547..afa8cb4b4 100644 --- a/frontend/src/app/components/address/address-preview.component.scss +++ b/frontend/src/app/components/address/address-preview.component.scss @@ -1,6 +1,5 @@ -h1 { - font-size: 52px; - margin: 0; +.title-wrapper { + padding: 0 15px; } .qr-wrapper { @@ -23,27 +22,9 @@ h1 { .table { font-size: 32px; + margin-top: 48px; ::ng-deep .symbol { font-size: 24px; } -} - -.address-link { - font-size: 24px; - margin-bottom: 0.5em; - display: flex; - flex-direction: row; - align-items: baseline; - .truncated-address { - text-overflow: ellipsis; - overflow: hidden; - max-width: calc(640px - 4em); - display: inline-block; - white-space: nowrap; - } - .last-four { - display: inline-block; - white-space: nowrap; - } -} +} \ No newline at end of file diff --git a/frontend/src/app/components/address/address-preview.component.ts b/frontend/src/app/components/address/address-preview.component.ts index c0f6fff81..35e72435b 100644 --- a/frontend/src/app/components/address/address-preview.component.ts +++ b/frontend/src/app/components/address/address-preview.component.ts @@ -19,6 +19,7 @@ import { AddressInformation } from 'src/app/interfaces/node-api.interface'; export class AddressPreviewComponent implements OnInit, OnDestroy { network = ''; + rawAddress: string; address: Address; addressString: string; isLoadingAddress = true; @@ -55,7 +56,8 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { this.mainSubscription = this.route.paramMap .pipe( switchMap((params: ParamMap) => { - this.openGraphService.waitFor('address-data'); + this.rawAddress = params.get('id') || ''; + this.openGraphService.waitFor('address-data-' + this.rawAddress); this.error = undefined; this.isLoadingAddress = true; this.loadedConfirmedTxCount = 0; @@ -73,7 +75,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { this.isLoadingAddress = false; this.error = err; console.log(err); - this.openGraphService.fail('address-data'); + this.openGraphService.fail('address-data-' + this.rawAddress); return of(null); }) ); @@ -91,7 +93,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { this.address = address; this.updateChainStats(); this.isLoadingAddress = false; - this.openGraphService.waitOver('address-data'); + this.openGraphService.waitOver('address-data-' + this.rawAddress); }) ) .subscribe(() => {}, @@ -99,7 +101,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { console.log(error); this.error = error; this.isLoadingAddress = false; - this.openGraphService.fail('address-data'); + this.openGraphService.fail('address-data-' + this.rawAddress); } ); } diff --git a/frontend/src/app/components/asset-circulation/asset-circulation.component.ts b/frontend/src/app/components/asset-circulation/asset-circulation.component.ts index 4d65417e6..d64607fe1 100644 --- a/frontend/src/app/components/asset-circulation/asset-circulation.component.ts +++ b/frontend/src/app/components/asset-circulation/asset-circulation.component.ts @@ -4,7 +4,6 @@ import { map } from 'rxjs/operators'; import { moveDec } from 'src/app/bitcoin.utils'; import { AssetsService } from 'src/app/services/assets.service'; import { ElectrsApiService } from 'src/app/services/electrs-api.service'; -import { formatNumber } from '@angular/common'; import { environment } from 'src/environments/environment'; @Component({ diff --git a/frontend/src/app/components/block/block-preview.component.html b/frontend/src/app/components/block/block-preview.component.html index 768bc3da3..38018bbb4 100644 --- a/frontend/src/app/components/block/block-preview.component.html +++ b/frontend/src/app/components/block/block-preview.component.html @@ -1,19 +1,16 @@
+

Block

-

- Genesis - - {{ blockHeight }} - - - Block - - - {{ blockHeight }} - - -

+
+
+

+ Genesis + {{ blockHeight }} +

+
+
+ {{blockHash.slice(0,-4)}}{{blockHash.slice(-4)}}