From 4b97f14edf0fcc4d3ad931a079f0d69769c1bcf3 Mon Sep 17 00:00:00 2001 From: Audric Ackermann <audric@loki.network> Date: Mon, 7 Nov 2022 17:48:12 +1100 Subject: [PATCH] fix: added batch requests for snode but signature fails --- ts/node/database_utility.ts | 1 + .../apis/open_group_api/sogsv3/sogsApiV3.ts | 1 - .../apis/snode_api/SnodeRequestTypes.ts | 26 +++++- ts/session/apis/snode_api/batchRequest.ts | 7 +- ts/session/apis/snode_api/onsResolve.ts | 4 +- ts/session/apis/snode_api/retrieveRequest.ts | 89 ++++++++++++++----- ts/session/apis/snode_api/sessionRpc.ts | 4 +- ts/session/apis/snode_api/storeMessage.ts | 1 - ts/session/apis/snode_api/swarmPolling.ts | 76 +++++++++++----- ts/session/conversations/createClosedGroup.ts | 2 +- ts/session/types/PubKey.ts | 1 - ts/session/utils/Bencoding.ts | 5 +- .../v3/GroupInviteMessage_test.ts | 4 +- .../v3/GroupMemberLeftMessage_test.ts | 7 +- .../v3/GroupPromoteMessage_test.ts | 4 +- ts/types/Util.ts | 2 + tslint.json | 1 + 17 files changed, 162 insertions(+), 73 deletions(-) diff --git a/ts/node/database_utility.ts b/ts/node/database_utility.ts index 3b8dca00f..cbab12e81 100644 --- a/ts/node/database_utility.ts +++ b/ts/node/database_utility.ts @@ -79,6 +79,7 @@ const allowedKeysFormatRowOfConversation = [ 'identityPrivateKey', ]; +//tslint-disable cyclomatic-complexity export function formatRowOfConversation(row?: Record<string, any>): ConversationAttributes | null { if (!row) { return null; diff --git a/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts b/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts index b51047417..731fb6f34 100644 --- a/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts +++ b/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts @@ -38,7 +38,6 @@ import { processMessagesUsingCache } from './sogsV3MutationCache'; import { destroyMessagesAndUpdateRedux } from '../../../../util/expiringMessages'; import { sogsRollingDeletions } from './sogsRollingDeletions'; - /** * Get the convo matching those criteria and make sure it is an opengroup convo, or return null. * If you get null, you most likely need to cancel the processing of whatever you are doing diff --git a/ts/session/apis/snode_api/SnodeRequestTypes.ts b/ts/session/apis/snode_api/SnodeRequestTypes.ts index 4716d05bd..b040b8da1 100644 --- a/ts/session/apis/snode_api/SnodeRequestTypes.ts +++ b/ts/session/apis/snode_api/SnodeRequestTypes.ts @@ -1,3 +1,4 @@ + export type SwarmForSubRequest = { method: 'get_swarm'; params: { pubkey: string } }; type RetrieveMaxCountSize = { max_count?: number; max_size?: number }; @@ -18,6 +19,22 @@ export type RetrievePubkeySubRequestType = { RetrieveMaxCountSize; }; +/** Those namespaces do not require to be authenticated for storing messages. + * -> 0 is used for our swarm, and anyone needs to be able to send message to us. + * -> -10 is used for legacy closed group and we do not have authentication for them yet (but we will with the new closed groups) + * -> others are currently unused + * + */ +// type UnauthenticatedStoreNamespaces = -30 | -20 | -10 | 0 | 10 | 20 | 30; + +export type RetrieveLegacyClosedGroupSubRequestType = { + method: 'retrieve'; + params: { + namespace: -10; // legacy closed groups retrieve are not authenticated because the clients do not have a shared key + } & RetrieveAlwaysNeeded & + RetrieveMaxCountSize; +}; + export type RetrieveSubKeySubRequestType = { method: 'retrieve'; params: { @@ -28,7 +45,10 @@ export type RetrieveSubKeySubRequestType = { RetrieveMaxCountSize; }; -export type RetrieveSubRequestType = RetrievePubkeySubRequestType | RetrieveSubKeySubRequestType; +export type RetrieveSubRequestType = + | RetrieveLegacyClosedGroupSubRequestType + | RetrievePubkeySubRequestType + | RetrieveSubKeySubRequestType; // FIXME those store types are not right // type StoreAlwaysNeeded = { pubkey: string; timestamp: number; data: string }; @@ -38,7 +58,7 @@ export type RetrieveSubRequestType = RetrievePubkeySubRequestType | RetrieveSubK // params: { // ttl?: string; // required, unless expiry is given // expiry?: number; // required, unless ttl is given -// namespace: -60 | -50 | -40 | -30 | -20 | -10 | 0 | 10 | 20 | 30 | 40 | 50 | 60; // we can only store without authentication on namespaces divisible by 10 (...-60...0...60...) +// namespace: UnauthenticatedNamespaces; // we can only store without authentication on namespaces divisible by 10 (...-60...0...60...) // } & StoreAlwaysNeeded; // }; @@ -104,9 +124,11 @@ export type SnodeApiSubRequests = | StoreOnNodeSubRequest | NetworkTimeSubRequest; +//tslint-disable: array-type export type NonEmptyArray<T> = [T, ...T[]]; export type NotEmptyArrayOfBatchResults = NonEmptyArray<{ code: number; body: Record<string, any>; }>; + diff --git a/ts/session/apis/snode_api/batchRequest.ts b/ts/session/apis/snode_api/batchRequest.ts index 0aa5ba6cd..eef3aff04 100644 --- a/ts/session/apis/snode_api/batchRequest.ts +++ b/ts/session/apis/snode_api/batchRequest.ts @@ -10,8 +10,8 @@ import { NotEmptyArrayOfBatchResults, SnodeApiSubRequests } from './SnodeRequest * The body is already parsed from json and is enforced to be an Array of at least one element * @param subRequests the list of requests to do * @param targetNode the node to do the request to, once all the onion routing is done - * @param timeout - * @param associatedWith + * @param timeout the timeout at which we should cancel this request. + * @param associatedWith used mostly for handling 421 errors, we need the pubkey the change is associated to * @returns */ export async function doSnodeBatchRequest( @@ -45,13 +45,14 @@ export async function doSnodeBatchRequest( */ function decodeBatchRequest(snodeResponse: SnodeResponse): NotEmptyArrayOfBatchResults { try { + console.warn('decodeBatch: ', snodeResponse); if (snodeResponse.status !== 200) { throw new Error(`decodeBatchRequest invalid status code: ${snodeResponse.status}`); } const parsed = JSON.parse(snodeResponse.body); if (!isArray(parsed.results)) { - throw new Error(`decodeBatchRequest results is not an array`); + throw new Error('decodeBatchRequest results is not an array'); } if (!parsed.results.length) { diff --git a/ts/session/apis/snode_api/onsResolve.ts b/ts/session/apis/snode_api/onsResolve.ts index 13b66a083..4cfc7724e 100644 --- a/ts/session/apis/snode_api/onsResolve.ts +++ b/ts/session/apis/snode_api/onsResolve.ts @@ -1,9 +1,9 @@ import _, { range } from 'lodash'; import { getSodiumRenderer } from '../../crypto'; import { - stringToUint8Array, - fromUInt8ArrayToBase64, fromHexToArray, + fromUInt8ArrayToBase64, + stringToUint8Array, toHex, } from '../../utils/String'; import { doSnodeBatchRequest } from './batchRequest'; diff --git a/ts/session/apis/snode_api/retrieveRequest.ts b/ts/session/apis/snode_api/retrieveRequest.ts index cc36e95fd..399f0edd3 100644 --- a/ts/session/apis/snode_api/retrieveRequest.ts +++ b/ts/session/apis/snode_api/retrieveRequest.ts @@ -1,35 +1,41 @@ -import { isEmpty } from 'lodash'; import { Snode } from '../../../data/data'; import { updateIsOnline } from '../../../state/ducks/onion'; import { getSodiumRenderer } from '../../crypto'; -import { UserUtils, StringUtils } from '../../utils'; +import { StringUtils, UserUtils } from '../../utils'; import { fromHexToArray, fromUInt8ArrayToBase64 } from '../../utils/String'; import { doSnodeBatchRequest } from './batchRequest'; import { GetNetworkTime } from './getNetworkTime'; -import { RetrievePubkeySubRequestType, RetrieveSubRequestType } from './SnodeRequestTypes'; +import { + RetrieveLegacyClosedGroupSubRequestType, + RetrieveSubRequestType, +} from './SnodeRequestTypes'; async function getRetrieveSignatureParams(params: { pubkey: string; - lastHash: string; namespace: number; + ourPubkey: string; }): Promise<{ timestamp: number; signature: string; pubkey_ed25519: string; namespace: number; -} | null> { - const ourPubkey = UserUtils.getOurPubKeyFromCache(); +}> { const ourEd25519Key = await UserUtils.getUserED25519KeyPair(); - if (isEmpty(params?.pubkey) || ourPubkey.key !== params.pubkey || !ourEd25519Key) { - return null; + if (!ourEd25519Key) { + window.log.warn('getRetrieveSignatureParams: User has no getUserED25519KeyPair()'); + throw new Error('getRetrieveSignatureParams: User has no getUserED25519KeyPair()'); } const namespace = params.namespace || 0; const edKeyPrivBytes = fromHexToArray(ourEd25519Key?.privKey); const signatureTimestamp = GetNetworkTime.getNowWithNetworkOffset(); - const verificationData = StringUtils.encode(`retrieve${namespace}${signatureTimestamp}`, 'utf8'); + const verificationData = + namespace === 0 + ? StringUtils.encode(`retrieve${signatureTimestamp}`, 'utf8') + : StringUtils.encode(`retrieve${namespace}${signatureTimestamp}`, 'utf8'); + const message = new Uint8Array(verificationData); const sodium = await getSodiumRenderer(); @@ -45,27 +51,56 @@ async function getRetrieveSignatureParams(params: { }; } catch (e) { window.log.warn('getSignatureParams failed with: ', e.message); - return null; + throw e; } } async function buildRetrieveRequest( lastHashes: Array<string>, pubkey: string, - namespaces: Array<number> + namespaces: Array<number>, + ourPubkey: string ): Promise<Array<RetrieveSubRequestType>> { const retrieveRequestsParams = await Promise.all( namespaces.map(async (namespace, index) => { const retrieveParam = { pubkey, - lastHash: lastHashes.at(index) || '', + last_hash: lastHashes.at(index) || '', namespace, + timestamp: GetNetworkTime.getNowWithNetworkOffset(), }; - const signatureBuilt = await getRetrieveSignatureParams(retrieveParam); - const signatureParams = signatureBuilt || {}; - const retrieve: RetrievePubkeySubRequestType = { + + if (namespace === -10) { + if (pubkey === ourPubkey || !pubkey.startsWith('05')) { + throw new Error( + 'namespace -10 can only be used to retrieve messages from a legacy closed group' + ); + } + const retrieveLegacyClosedGroup = { + ...retrieveParam, + namespace: namespace as -10, + }; + const retrieveParamsLegacy: RetrieveLegacyClosedGroupSubRequestType = { + method: 'retrieve', + params: { ...retrieveLegacyClosedGroup }, + }; + + return retrieveParamsLegacy; + } + + // all legacy closed group retrieves are unauthenticated and run above. + // if we get here, this can only be a retrieve for our own swarm, which needs to be authenticated + if (namespace !== 0) { + throw new Error('not a legacy closed group. namespace can only be 0'); + } + if (pubkey !== ourPubkey) { + throw new Error('not a legacy closed group. pubkey can only be our number'); + } + const signatureArgs = { ...retrieveParam, ourPubkey }; + const signatureBuilt = await getRetrieveSignatureParams(signatureArgs); + const retrieve: RetrieveSubRequestType = { method: 'retrieve', - params: { ...signatureParams, ...retrieveParam }, + params: { ...retrieveParam, ...signatureBuilt }, }; return retrieve; }) @@ -79,16 +114,23 @@ async function retrieveNextMessages( targetNode: Snode, lastHashes: Array<string>, associatedWith: string, - namespaces: Array<number> -): Promise<Array<any>> { + namespaces: Array<number>, + ourPubkey: string +): Promise<Array<{ code: number; messages: Array<Record<string, any>> }>> { if (namespaces.length !== lastHashes.length) { throw new Error('namespaces and lasthashes does not match'); } - const retrieveRequestsParams = await buildRetrieveRequest(lastHashes, associatedWith, namespaces); + const retrieveRequestsParams = await buildRetrieveRequest( + lastHashes, + associatedWith, + namespaces, + ourPubkey + ); // let exceptions bubble up // no retry for this one as this a call we do every few seconds while polling for messages + console.warn('retrieveRequestsParams', retrieveRequestsParams); const results = await doSnodeBatchRequest(retrieveRequestsParams, targetNode, 4000); if (!results || !results.length) { @@ -118,15 +160,18 @@ async function retrieveNextMessages( ); } + console.warn('what should we do if we dont get a 200 on any of those fetches?'); + try { - const json = firstResult.body; + // we rely on the code of the + const bodyFirstResult = firstResult.body; if (!window.inboxStore?.getState().onionPaths.isOnline) { window.inboxStore?.dispatch(updateIsOnline(true)); } - GetNetworkTime.handleTimestampOffsetFromNetwork('retrieve', json.t); + GetNetworkTime.handleTimestampOffsetFromNetwork('retrieve', bodyFirstResult.t); - return json.messages || []; + return results.map(result => ({ code: result.code, messages: result.body as Array<any> })); } catch (e) { window?.log?.warn('exception while parsing json of nextMessage:', e); if (!window.inboxStore?.getState().onionPaths.isOnline) { diff --git a/ts/session/apis/snode_api/sessionRpc.ts b/ts/session/apis/snode_api/sessionRpc.ts index 089b71121..db6597167 100644 --- a/ts/session/apis/snode_api/sessionRpc.ts +++ b/ts/session/apis/snode_api/sessionRpc.ts @@ -123,14 +123,14 @@ export async function snodeRpc( ): Promise<undefined | SnodeResponse> { const url = `https://${targetNode.ip}:${targetNode.port}/storage_rpc/v1`; - - const body = { jsonrpc: '2.0', method, params: clone(params), }; + console.warn('snodeRPC', body); + const fetchOptions: LokiFetchOptions = { method: 'POST', body: JSON.stringify(body), diff --git a/ts/session/apis/snode_api/storeMessage.ts b/ts/session/apis/snode_api/storeMessage.ts index 51034e27c..98bd5f8dd 100644 --- a/ts/session/apis/snode_api/storeMessage.ts +++ b/ts/session/apis/snode_api/storeMessage.ts @@ -17,7 +17,6 @@ async function storeOnNode( ): Promise<string | null | boolean> { try { const subRequests = buildStoreRequests(params); - const result = await doSnodeBatchRequest(subRequests, targetNode, 4000, params.pubkey); if (!result || !result.length) { diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts index 115d7e49f..db00e9113 100644 --- a/ts/session/apis/snode_api/swarmPolling.ts +++ b/ts/session/apis/snode_api/swarmPolling.ts @@ -3,7 +3,7 @@ import * as snodePool from './snodePool'; import { ERROR_CODE_NO_CONNECT } from './SNodeAPI'; import { SignalService } from '../../../protobuf'; import * as Receiver from '../../../receiver/receiver'; -import _, { concat } from 'lodash'; +import _, { concat, last } from 'lodash'; import { Data, Snode } from '../../../data/data'; import { StringUtils, UserUtils } from '../../utils'; @@ -142,6 +142,7 @@ export class SwarmPolling { } // we always poll as often as possible for our pubkey const ourPubkey = UserUtils.getOurPubKeyFromCache(); + // const directPromises = Promise.resolve(); const directPromises = Promise.all([this.pollOnceForKey(ourPubkey, false, [0])]).then( () => undefined ); @@ -243,6 +244,7 @@ export class SwarmPolling { return group; }); } else if (isGroup) { + debugger; window?.log?.info( `Polled for group(${ed25519Str( pubkey.key @@ -269,8 +271,11 @@ export class SwarmPolling { pubkey: PubKey, namespaces: Array<number> ): Promise<Array<any> | null> { + const namespaceLength = namespaces.length; + if (namespaceLength > 2 || namespaceLength <= 0) { + throw new Error('pollNodeForKey needs 1 or 2 namespaces to be given at all times'); + } const edkey = node.pubkey_ed25519; - const pkStr = pubkey.key; try { @@ -279,27 +284,47 @@ export class SwarmPolling { const prevHashes = await Promise.all( namespaces.map(namespace => this.getLastHash(edkey, pkStr, namespace)) ); - const messages = await SnodeAPIRetrieve.retrieveNextMessages( + const results = await SnodeAPIRetrieve.retrieveNextMessages( node, prevHashes, pkStr, - namespaces + namespaces, + UserUtils.getOurPubKeyStrFromCache() ); - if (!messages.length) { + debugger; + if (!results.length) { return []; } - // const lastMessage = _.last(messages); - // const newHashes = // TODO - - // await this.updateLastHashes({ - // edkey: edkey, - // pubkey, - // namespaces, - // hash: lastMessage.hash, - // expiration: lastMessage.expiration, - // }); - return messages; + if (results.length !== namespaceLength) { + window.log.error( + `pollNodeForKey asked for ${namespaceLength} namespaces but received only messages about ${results.length} namespaces` + ); + throw new Error( + `pollNodeForKey asked for ${namespaceLength} namespaces but received only messages about ${results.length} namespaces` + ); + } + + const lastMessages = results.map(r => { + return last(r.messages); + }); + + await Promise.all( + lastMessages.map(async (lastMessage, index) => { + if (!lastMessage) { + return Promise.resolve(); + } + return this.updateLastHash({ + edkey: edkey, + pubkey, + namespace: namespaces[index], + hash: lastMessage.hash, + expiration: lastMessage.expiration, + }); + }) + ); + + return results; }, { minTimeout: 100, @@ -374,14 +399,17 @@ export class SwarmPolling { expiration: number; }): Promise<void> { const pkStr = pubkey.key; - - await Data.updateLastHash({ - convoId: pkStr, - snode: edkey, - hash, - expiresAt: expiration, - namespace, - }); + const cached = await this.getLastHash(edkey, pubkey.key, namespace); + + if (!cached || cached !== hash) { + await Data.updateLastHash({ + convoId: pkStr, + snode: edkey, + hash, + expiresAt: expiration, + namespace, + }); + } if (!this.lastHashes[edkey]) { this.lastHashes[edkey] = {}; diff --git a/ts/session/conversations/createClosedGroup.ts b/ts/session/conversations/createClosedGroup.ts index 7554e33f3..7851c3dbb 100644 --- a/ts/session/conversations/createClosedGroup.ts +++ b/ts/session/conversations/createClosedGroup.ts @@ -13,8 +13,8 @@ import { generateGroupV3Keypair, } from '../crypto'; import { - ClosedGroupNewMessageParams, ClosedGroupNewMessage, + ClosedGroupNewMessageParams, } from '../messages/outgoing/controlMessage/group/ClosedGroupNewMessage'; import { PubKey } from '../types'; import { UserUtils } from '../utils'; diff --git a/ts/session/types/PubKey.ts b/ts/session/types/PubKey.ts index 86939d53b..44654ebfb 100644 --- a/ts/session/types/PubKey.ts +++ b/ts/session/types/PubKey.ts @@ -1,6 +1,5 @@ import { fromHexToArray } from '../utils/String'; - export enum KeyPrefixType { /** * Used for keys which have the blinding update and aren't using blinding diff --git a/ts/session/utils/Bencoding.ts b/ts/session/utils/Bencoding.ts index 6bdf56c8f..fef98289d 100644 --- a/ts/session/utils/Bencoding.ts +++ b/ts/session/utils/Bencoding.ts @@ -66,7 +66,7 @@ export class BDecode { return parsed; } - parseList(): BencodeArrayType { + private parseList(): BencodeArrayType { const parsed: BencodeArrayType = []; if (this.currentParsingIndex >= this.content.length) { @@ -134,7 +134,6 @@ export class BDecode { throw new Error('parseString: cannot parse string without length'); } - if (strLength === 0) { return ''; } @@ -193,7 +192,6 @@ export class BEncode { } private encodeItem(item: BencodeElementType): Uint8Array { - if (isNumber(item) && isFinite(item)) { return from_string(`i${item}e`); } @@ -216,6 +214,7 @@ export class BEncode { if (isArray(item)) { let content = new Uint8Array(); + //tslint disable prefer-for-of for (let index = 0; index < item.length; index++) { const encodedItem = this.encodeItem(item[index]); const encodedItemLength = encodedItem.length; diff --git a/ts/test/session/unit/messages/closed_groups/v3/GroupInviteMessage_test.ts b/ts/test/session/unit/messages/closed_groups/v3/GroupInviteMessage_test.ts index 724068248..3d1b7e3c9 100644 --- a/ts/test/session/unit/messages/closed_groups/v3/GroupInviteMessage_test.ts +++ b/ts/test/session/unit/messages/closed_groups/v3/GroupInviteMessage_test.ts @@ -7,9 +7,7 @@ import { Constants } from '../../../../../../session'; import { from_hex } from 'libsodium-wrappers-sumo'; describe('GroupInviteMessage', () => { - beforeEach(async () => {}); - - it('can create valid message', async () => { + it('can create valid message', () => { const message = new GroupInviteMessage({ timestamp: 12345, memberPrivateKey: '654321', diff --git a/ts/test/session/unit/messages/closed_groups/v3/GroupMemberLeftMessage_test.ts b/ts/test/session/unit/messages/closed_groups/v3/GroupMemberLeftMessage_test.ts index 42690f3c8..4e7ee82e1 100644 --- a/ts/test/session/unit/messages/closed_groups/v3/GroupMemberLeftMessage_test.ts +++ b/ts/test/session/unit/messages/closed_groups/v3/GroupMemberLeftMessage_test.ts @@ -6,9 +6,7 @@ import { Constants } from '../../../../../../session'; import { GroupMemberLeftMessage } from '../../../../../../session/messages/outgoing/controlMessage/group/v3/GroupMemberLeftMessage'; describe('GroupMemberLeftMessage', () => { - beforeEach(async () => {}); - - it('can create valid message', async () => { + it('can create valid message', () => { const message = new GroupMemberLeftMessage({ timestamp: 12345, identifier: v4(), @@ -23,7 +21,7 @@ describe('GroupMemberLeftMessage', () => { .to.have.property('groupMessage') .to.have.property('memberLeftMessage').to.be.not.null; - expect(decoded.dataMessage) + expect(decoded.dataMessage) .to.have.property('groupMessage') .to.have.property('memberLeftMessage').to.be.empty; expect(message) @@ -48,5 +46,4 @@ describe('GroupMemberLeftMessage', () => { expect(message.identifier).to.not.equal(null, 'identifier cannot be null'); expect(message.identifier).to.not.equal(undefined, 'identifier cannot be undefined'); }); - }); diff --git a/ts/test/session/unit/messages/closed_groups/v3/GroupPromoteMessage_test.ts b/ts/test/session/unit/messages/closed_groups/v3/GroupPromoteMessage_test.ts index 287a49603..16299d138 100644 --- a/ts/test/session/unit/messages/closed_groups/v3/GroupPromoteMessage_test.ts +++ b/ts/test/session/unit/messages/closed_groups/v3/GroupPromoteMessage_test.ts @@ -7,9 +7,7 @@ import { Constants } from '../../../../../../session'; import { from_hex } from 'libsodium-wrappers-sumo'; describe('GroupPromoteMessage', () => { - beforeEach(async () => {}); - - it('can create valid message', async () => { + it('can create valid message', () => { const message = new GroupPromoteMessage({ timestamp: 12345, identifier: v4(), diff --git a/ts/types/Util.ts b/ts/types/Util.ts index ccea6b792..5e494da0d 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -7,3 +7,5 @@ export type RenderTextCallbackType = (options: { }) => JSX.Element; export type LocalizerType = (key: LocalizerKeys, values?: Array<string>) => string; + +export type FixedLengthArray<T, Length extends number> = Array<T> & { length: Length }; diff --git a/tslint.json b/tslint.json index 2d46bc9b5..4e6e28901 100644 --- a/tslint.json +++ b/tslint.json @@ -25,6 +25,7 @@ "object-literal-key-quotes": [true, "as-needed"], "object-literal-sort-keys": false, "no-async-without-await": true, + "no-empty-interface": false, "ordered-imports": [ true, {