@zkpassport/sdk 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/assets/abi/ZKPassportVerifier.json +14 -14
- package/dist/cjs/index.d.ts +10 -5
- package/dist/cjs/index.js +85 -104
- package/dist/esm/assets/abi/ZKPassportVerifier.json +14 -14
- package/dist/esm/index.d.ts +10 -5
- package/dist/esm/index.js +86 -105
- package/package.json +3 -2
- package/src/assets/abi/ZKPassportVerifier.json +14 -14
- package/src/index.ts +94 -119
- package/src/encryption.ts +0 -45
- package/src/json-rpc.ts +0 -61
- package/src/mobile.ts +0 -186
- package/src/websocket.ts +0 -16
package/dist/cjs/index.js
CHANGED
|
@@ -2,21 +2,17 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ZKPassport = exports.MERCOSUR_COUNTRIES = exports.ASEAN_COUNTRIES = exports.SCHENGEN_COUNTRIES = exports.EEA_COUNTRIES = exports.EU_COUNTRIES = exports.SANCTIONED_COUNTRIES = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
|
-
const crypto_1 = require("crypto");
|
|
6
5
|
const i18n_iso_countries_1 = require("i18n-iso-countries");
|
|
7
6
|
const utils_1 = require("@zkpassport/utils");
|
|
8
7
|
const utils_2 = require("@noble/ciphers/utils");
|
|
9
|
-
const websocket_1 = require("./websocket");
|
|
10
|
-
const json_rpc_1 = require("./json-rpc");
|
|
11
|
-
const encryption_1 = require("./encryption");
|
|
12
8
|
const logger_1 = require("./logger");
|
|
13
|
-
const pako_1 = require("pako");
|
|
14
9
|
const en_json_1 = tslib_1.__importDefault(require("i18n-iso-countries/langs/en.json"));
|
|
15
10
|
const buffer_1 = require("buffer/");
|
|
16
11
|
const sha2_1 = require("@noble/hashes/sha2");
|
|
17
12
|
const utils_3 = require("@noble/hashes/utils");
|
|
18
13
|
const ZKPassportVerifier_json_1 = tslib_1.__importDefault(require("./assets/abi/ZKPassportVerifier.json"));
|
|
19
14
|
const registry_1 = require("@zkpassport/registry");
|
|
15
|
+
const bridge_1 = require("@obsidion/bridge");
|
|
20
16
|
const DEFAULT_DATE_VALUE = new Date(1111, 10, 11);
|
|
21
17
|
// If Buffer is not defined, then we use the Buffer from the buffer package
|
|
22
18
|
if (typeof globalThis.Buffer === "undefined") {
|
|
@@ -25,6 +21,24 @@ if (typeof globalThis.Buffer === "undefined") {
|
|
|
25
21
|
window.Buffer = buffer_1.Buffer;
|
|
26
22
|
}
|
|
27
23
|
}
|
|
24
|
+
function getChainIdFromEVMChain(chain) {
|
|
25
|
+
if (chain === "ethereum_sepolia") {
|
|
26
|
+
return 11155111;
|
|
27
|
+
}
|
|
28
|
+
else if (chain === "local_anvil") {
|
|
29
|
+
return 31337;
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Unsupported chain: ${chain}`);
|
|
32
|
+
}
|
|
33
|
+
function getEVMChainFromChainId(chainId) {
|
|
34
|
+
if (chainId === 11155111) {
|
|
35
|
+
return "ethereum_sepolia";
|
|
36
|
+
}
|
|
37
|
+
else if (chainId === 31337) {
|
|
38
|
+
return "local_anvil";
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Unsupported chain ID: ${chainId}`);
|
|
41
|
+
}
|
|
28
42
|
(0, i18n_iso_countries_1.registerLocale)(en_json_1.default);
|
|
29
43
|
function hasRequestedAccessToField(credentialsRequest, field) {
|
|
30
44
|
const fieldValue = credentialsRequest[field];
|
|
@@ -79,9 +93,8 @@ class ZKPassport {
|
|
|
79
93
|
constructor(_domain) {
|
|
80
94
|
this.topicToConfig = {};
|
|
81
95
|
this.topicToLocalConfig = {};
|
|
82
|
-
this.
|
|
83
|
-
this.
|
|
84
|
-
this.topicToSharedSecret = {};
|
|
96
|
+
this.topicToPublicKey = {};
|
|
97
|
+
this.topicToBridge = {};
|
|
85
98
|
this.topicToRequestReceived = {};
|
|
86
99
|
this.topicToService = {};
|
|
87
100
|
this.topicToProofs = {};
|
|
@@ -110,6 +123,9 @@ class ZKPassport {
|
|
|
110
123
|
queryResult: result,
|
|
111
124
|
validity: this.topicToLocalConfig[topic]?.validity,
|
|
112
125
|
scope: this.topicToService[topic]?.scope,
|
|
126
|
+
evmChain: this.topicToService[topic]?.chainId
|
|
127
|
+
? getEVMChainFromChainId(this.topicToService[topic]?.chainId)
|
|
128
|
+
: undefined,
|
|
113
129
|
devMode: this.topicToLocalConfig[topic]?.devMode,
|
|
114
130
|
});
|
|
115
131
|
delete this.topicToProofs[topic];
|
|
@@ -197,7 +213,7 @@ class ZKPassport {
|
|
|
197
213
|
* @param request The request.
|
|
198
214
|
* @param outerRequest The outer request.
|
|
199
215
|
*/
|
|
200
|
-
async handleEncryptedMessage(topic, request
|
|
216
|
+
async handleEncryptedMessage(topic, request) {
|
|
201
217
|
logger_1.noLogger.debug("Received encrypted message:", request);
|
|
202
218
|
if (request.method === "accept") {
|
|
203
219
|
logger_1.noLogger.debug(`User accepted the request and is generating a proof`);
|
|
@@ -209,30 +225,8 @@ class ZKPassport {
|
|
|
209
225
|
}
|
|
210
226
|
else if (request.method === "proof") {
|
|
211
227
|
logger_1.noLogger.debug(`User generated proof`);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const bytesCommittedInputs = request.params.committedInputs
|
|
215
|
-
? buffer_1.Buffer.from(request.params.committedInputs, "base64")
|
|
216
|
-
: null;
|
|
217
|
-
const uncompressedProof = (0, pako_1.inflate)(bytesProof);
|
|
218
|
-
const uncompressedCommittedInputs = bytesCommittedInputs
|
|
219
|
-
? (0, pako_1.inflate)(bytesCommittedInputs)
|
|
220
|
-
: null;
|
|
221
|
-
// The gzip lib in the app compress the proof as ASCII
|
|
222
|
-
// and since the app passes the proof as a hex string, we can
|
|
223
|
-
// just decode the bytes as hex characters using the TextDecoder
|
|
224
|
-
const hexProof = new TextDecoder().decode(uncompressedProof);
|
|
225
|
-
const processedProof = {
|
|
226
|
-
proof: hexProof,
|
|
227
|
-
vkeyHash: request.params.vkeyHash,
|
|
228
|
-
name: request.params.name,
|
|
229
|
-
version: request.params.version,
|
|
230
|
-
committedInputs: uncompressedCommittedInputs
|
|
231
|
-
? JSON.parse(new TextDecoder().decode(uncompressedCommittedInputs))
|
|
232
|
-
: undefined,
|
|
233
|
-
};
|
|
234
|
-
this.topicToProofs[topic].push(processedProof);
|
|
235
|
-
await Promise.all(this.onProofGeneratedCallbacks[topic].map((callback) => callback(processedProof)));
|
|
228
|
+
this.topicToProofs[topic].push(request.params);
|
|
229
|
+
await Promise.all(this.onProofGeneratedCallbacks[topic].map((callback) => callback(request.params)));
|
|
236
230
|
// If the results were received before all the proofs were generated,
|
|
237
231
|
// we can handle the result now
|
|
238
232
|
if (this.topicToResults[topic] &&
|
|
@@ -332,7 +326,7 @@ class ZKPassport {
|
|
|
332
326
|
done: () => {
|
|
333
327
|
const base64Config = buffer_1.Buffer.from(JSON.stringify(this.topicToConfig[topic])).toString("base64");
|
|
334
328
|
const base64Service = buffer_1.Buffer.from(JSON.stringify(this.topicToService[topic])).toString("base64");
|
|
335
|
-
const pubkey =
|
|
329
|
+
const pubkey = this.topicToPublicKey[topic];
|
|
336
330
|
this.setExpectedProofCount(topic);
|
|
337
331
|
return {
|
|
338
332
|
url: `https://zkpassport.id/r?d=${this.domain}&t=${topic}&c=${base64Config}&s=${base64Service}&p=${pubkey}&m=${this.topicToLocalConfig[topic].mode}`,
|
|
@@ -344,7 +338,7 @@ class ZKPassport {
|
|
|
344
338
|
onResult: (callback) => this.onResultCallbacks[topic].push(callback),
|
|
345
339
|
onReject: (callback) => this.onRejectCallbacks[topic].push(callback),
|
|
346
340
|
onError: (callback) => this.onErrorCallbacks[topic].push(callback),
|
|
347
|
-
isBridgeConnected: () => this.
|
|
341
|
+
isBridgeConnected: () => this.topicToBridge[topic].isBridgeConnected(),
|
|
348
342
|
requestReceived: () => this.topicToRequestReceived[topic] === true,
|
|
349
343
|
};
|
|
350
344
|
},
|
|
@@ -358,17 +352,23 @@ class ZKPassport {
|
|
|
358
352
|
* @param scope Scope this request to a specific use case
|
|
359
353
|
* @param validity How many days ago should have the ID been last scanned by the user?
|
|
360
354
|
* @param devMode Whether to enable dev mode. This will allow you to verify mock proofs (i.e. from ZKR)
|
|
355
|
+
* @param evmChain The EVM chain to use for the request (if using the proof onchain)
|
|
361
356
|
* @returns The query builder object.
|
|
362
357
|
*/
|
|
363
|
-
async request({ name, logo, purpose, scope, mode, validity, devMode, topicOverride, keyPairOverride, }) {
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
};
|
|
358
|
+
async request({ name, logo, purpose, scope, mode, evmChain, validity, devMode, topicOverride, keyPairOverride, }) {
|
|
359
|
+
const bridge = await bridge_1.Bridge.create({
|
|
360
|
+
keyPair: keyPairOverride,
|
|
361
|
+
bridgeId: topicOverride,
|
|
362
|
+
});
|
|
363
|
+
const topic = bridge.connection.getBridgeId();
|
|
370
364
|
this.topicToConfig[topic] = {};
|
|
371
|
-
this.topicToService[topic] = {
|
|
365
|
+
this.topicToService[topic] = {
|
|
366
|
+
name,
|
|
367
|
+
logo,
|
|
368
|
+
purpose,
|
|
369
|
+
scope,
|
|
370
|
+
chainId: evmChain ? getChainIdFromEVMChain(evmChain) : undefined,
|
|
371
|
+
};
|
|
372
372
|
this.topicToProofs[topic] = [];
|
|
373
373
|
this.topicToExpectedProofCount[topic] = 0;
|
|
374
374
|
this.topicToLocalConfig[topic] = {
|
|
@@ -384,53 +384,21 @@ class ZKPassport {
|
|
|
384
384
|
this.onResultCallbacks[topic] = [];
|
|
385
385
|
this.onRejectCallbacks[topic] = [];
|
|
386
386
|
this.onErrorCallbacks[topic] = [];
|
|
387
|
-
|
|
388
|
-
this.
|
|
389
|
-
|
|
390
|
-
logger_1.noLogger.
|
|
387
|
+
this.topicToPublicKey[topic] = bridge.getPublicKey();
|
|
388
|
+
this.topicToBridge[topic] = bridge;
|
|
389
|
+
bridge.onConnect(async (reconnection) => {
|
|
390
|
+
logger_1.noLogger.debug("Bridge connected");
|
|
391
|
+
logger_1.noLogger.debug("Is reconnection:", reconnection);
|
|
391
392
|
await Promise.all(this.onBridgeConnectCallbacks[topic].map((callback) => callback()));
|
|
392
|
-
};
|
|
393
|
-
wsClient.addEventListener("message", async (event) => {
|
|
394
|
-
logger_1.noLogger.debug("[frontend] Received message:", event.data);
|
|
395
|
-
try {
|
|
396
|
-
const data = JSON.parse(event.data);
|
|
397
|
-
// Handshake happens when the mobile app scans the QR code and connects to the bridge
|
|
398
|
-
if (data.method === "handshake") {
|
|
399
|
-
logger_1.noLogger.debug("[frontend] Received handshake:", event.data);
|
|
400
|
-
this.topicToRequestReceived[topic] = true;
|
|
401
|
-
this.topicToSharedSecret[topic] = await (0, encryption_1.getSharedSecret)((0, utils_2.bytesToHex)(keyPair.privateKey), data.params.pubkey);
|
|
402
|
-
logger_1.noLogger.debug("[frontend] Shared secret:", buffer_1.Buffer.from(this.topicToSharedSecret[topic]).toString("hex"));
|
|
403
|
-
const encryptedMessage = await (0, json_rpc_1.createEncryptedJsonRpcRequest)("hello", null, this.topicToSharedSecret[topic], topic);
|
|
404
|
-
logger_1.noLogger.debug("[frontend] Sending encrypted message:", encryptedMessage);
|
|
405
|
-
wsClient.send(JSON.stringify(encryptedMessage));
|
|
406
|
-
await Promise.all(this.onRequestReceivedCallbacks[topic].map((callback) => callback()));
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
// Handle encrypted messages
|
|
410
|
-
if (data.method === "encryptedMessage") {
|
|
411
|
-
// Decode the payload from base64 to Uint8Array
|
|
412
|
-
const payload = new Uint8Array(atob(data.params.payload)
|
|
413
|
-
.split("")
|
|
414
|
-
.map((c) => c.charCodeAt(0)));
|
|
415
|
-
try {
|
|
416
|
-
// Decrypt the payload using the shared secret
|
|
417
|
-
const decrypted = await (0, encryption_1.decrypt)(payload, this.topicToSharedSecret[topic], topic);
|
|
418
|
-
const decryptedJson = JSON.parse(decrypted);
|
|
419
|
-
this.handleEncryptedMessage(topic, decryptedJson, data);
|
|
420
|
-
}
|
|
421
|
-
catch (error) {
|
|
422
|
-
logger_1.noLogger.error("[frontend] Error decrypting message:", error);
|
|
423
|
-
}
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
catch (error) {
|
|
428
|
-
logger_1.noLogger.error("[frontend] Error:", error);
|
|
429
|
-
}
|
|
430
393
|
});
|
|
431
|
-
|
|
432
|
-
logger_1.noLogger.
|
|
433
|
-
|
|
394
|
+
bridge.onSecureChannelEstablished(async () => {
|
|
395
|
+
logger_1.noLogger.debug("Secure channel established");
|
|
396
|
+
await Promise.all(this.onRequestReceivedCallbacks[topic].map((callback) => callback()));
|
|
397
|
+
});
|
|
398
|
+
bridge.onSecureMessage(async (message) => {
|
|
399
|
+
logger_1.noLogger.debug("Received message:", message);
|
|
400
|
+
this.handleEncryptedMessage(topic, message);
|
|
401
|
+
});
|
|
434
402
|
return this.getZkPassportRequest(topic);
|
|
435
403
|
}
|
|
436
404
|
checkDiscloseBytesPublicInputs(proof, queryResult) {
|
|
@@ -1252,13 +1220,14 @@ class ZKPassport {
|
|
|
1252
1220
|
}
|
|
1253
1221
|
return { isCorrect, queryResultErrors };
|
|
1254
1222
|
}
|
|
1255
|
-
checkScopeFromDisclosureProof(proofData, queryResultErrors, key, scope) {
|
|
1223
|
+
checkScopeFromDisclosureProof(proofData, queryResultErrors, key, scope, chainId) {
|
|
1256
1224
|
let isCorrect = true;
|
|
1257
|
-
if (this.domain &&
|
|
1225
|
+
if (this.domain &&
|
|
1226
|
+
(0, utils_1.getServiceScopeHash)(this.domain, chainId) !== BigInt(proofData.publicInputs[1])) {
|
|
1258
1227
|
console.warn("The proof comes from a different domain than the one expected");
|
|
1259
1228
|
isCorrect = false;
|
|
1260
1229
|
queryResultErrors[key].scope = {
|
|
1261
|
-
expected: `Scope: ${(0, utils_1.
|
|
1230
|
+
expected: `Scope: ${(0, utils_1.getServiceScopeHash)(this.domain, chainId).toString()}`,
|
|
1262
1231
|
received: `Scope: ${BigInt(proofData.publicInputs[1]).toString()}`,
|
|
1263
1232
|
message: "The proof comes from a different domain than the one expected",
|
|
1264
1233
|
};
|
|
@@ -1302,7 +1271,7 @@ class ZKPassport {
|
|
|
1302
1271
|
}
|
|
1303
1272
|
return { isCorrect, queryResultErrors };
|
|
1304
1273
|
}
|
|
1305
|
-
async checkPublicInputs(proofs, queryResult, validity, scope) {
|
|
1274
|
+
async checkPublicInputs(proofs, queryResult, validity, scope, chainId) {
|
|
1306
1275
|
let commitmentIn;
|
|
1307
1276
|
let commitmentOut;
|
|
1308
1277
|
let isCorrect = true;
|
|
@@ -1387,11 +1356,12 @@ class ZKPassport {
|
|
|
1387
1356
|
message: "The proof does not verify all the requested conditions and information",
|
|
1388
1357
|
};
|
|
1389
1358
|
}
|
|
1390
|
-
if (this.domain &&
|
|
1359
|
+
if (this.domain &&
|
|
1360
|
+
(0, utils_1.getServiceScopeHash)(this.domain, chainId) !== (0, utils_1.getScopeFromOuterProof)(proofData)) {
|
|
1391
1361
|
console.warn("The proof comes from a different domain than the one expected");
|
|
1392
1362
|
isCorrect = false;
|
|
1393
1363
|
queryResultErrors.outer.scope = {
|
|
1394
|
-
expected: `Scope: ${(0, utils_1.
|
|
1364
|
+
expected: `Scope: ${(0, utils_1.getServiceScopeHash)(this.domain, chainId).toString()}`,
|
|
1395
1365
|
received: `Scope: ${(0, utils_1.getScopeFromOuterProof)(proofData).toString()}`,
|
|
1396
1366
|
message: "The proof comes from a different domain than the one expected",
|
|
1397
1367
|
};
|
|
@@ -1906,10 +1876,21 @@ class ZKPassport {
|
|
|
1906
1876
|
* @param proofs The proofs to verify.
|
|
1907
1877
|
* @param queryResult The query result to verify against
|
|
1908
1878
|
* @param validity How many days ago should have the ID been last scanned by the user?
|
|
1879
|
+
* @param scope Scope this request to a specific use case
|
|
1880
|
+
* @param evmChain The EVM chain to use for the verification (if using the proof onchain)
|
|
1881
|
+
* @param devMode Whether to enable dev mode. This will allow you to verify mock proofs (i.e. from ZKR)
|
|
1909
1882
|
* @returns An object containing the unique identifier associated to the user
|
|
1910
1883
|
* and a boolean indicating whether the proofs were successfully verified.
|
|
1911
1884
|
*/
|
|
1912
|
-
async verify({ proofs, queryResult, validity, scope, devMode = false, }) {
|
|
1885
|
+
async verify({ proofs, queryResult, validity, scope, evmChain, devMode = false, }) {
|
|
1886
|
+
// If no proofs were generated, the results can't be trusted.
|
|
1887
|
+
// We still return it but verified will be false
|
|
1888
|
+
if (!proofs || proofs.length === 0) {
|
|
1889
|
+
return {
|
|
1890
|
+
uniqueIdentifier: undefined,
|
|
1891
|
+
verified: false,
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1913
1894
|
const formattedResult = queryResult;
|
|
1914
1895
|
// Make sure to reconvert the dates to Date objects
|
|
1915
1896
|
if (formattedResult.birthdate && formattedResult.birthdate.disclose) {
|
|
@@ -1923,7 +1904,8 @@ class ZKPassport {
|
|
|
1923
1904
|
let verified = true;
|
|
1924
1905
|
let uniqueIdentifier;
|
|
1925
1906
|
let queryResultErrors;
|
|
1926
|
-
const
|
|
1907
|
+
const chainId = evmChain ? getChainIdFromEVMChain(evmChain) : undefined;
|
|
1908
|
+
const { isCorrect, uniqueIdentifier: uniqueIdentifierFromPublicInputs, queryResultErrors: queryResultErrorsFromPublicInputs, } = await this.checkPublicInputs(proofs, formattedResult, validity, scope, chainId);
|
|
1927
1909
|
uniqueIdentifier = uniqueIdentifierFromPublicInputs;
|
|
1928
1910
|
verified = isCorrect;
|
|
1929
1911
|
queryResultErrors = isCorrect ? undefined : queryResultErrorsFromPublicInputs;
|
|
@@ -2001,7 +1983,7 @@ class ZKPassport {
|
|
|
2001
1983
|
if (network === "ethereum_sepolia") {
|
|
2002
1984
|
return {
|
|
2003
1985
|
...baseConfig,
|
|
2004
|
-
address: "
|
|
1986
|
+
address: "0xDfE02DFd5c208854884B58bFf6522De5c42F73E3",
|
|
2005
1987
|
};
|
|
2006
1988
|
}
|
|
2007
1989
|
else if (network === "local_anvil") {
|
|
@@ -2150,7 +2132,7 @@ class ZKPassport {
|
|
|
2150
2132
|
* @returns The URL of the request.
|
|
2151
2133
|
*/
|
|
2152
2134
|
getUrl(requestId) {
|
|
2153
|
-
const pubkey =
|
|
2135
|
+
const pubkey = this.topicToPublicKey[requestId];
|
|
2154
2136
|
const base64Config = buffer_1.Buffer.from(JSON.stringify(this.topicToConfig[requestId])).toString("base64");
|
|
2155
2137
|
const base64Service = buffer_1.Buffer.from(JSON.stringify(this.topicToService[requestId])).toString("base64");
|
|
2156
2138
|
return `https://zkpassport.id/r?d=${this.domain}&t=${requestId}&c=${base64Config}&s=${base64Service}&p=${pubkey}&m=${this.topicToLocalConfig[requestId].mode}`;
|
|
@@ -2160,14 +2142,13 @@ class ZKPassport {
|
|
|
2160
2142
|
* @param requestId The request ID.
|
|
2161
2143
|
*/
|
|
2162
2144
|
cancelRequest(requestId) {
|
|
2163
|
-
if (this.
|
|
2164
|
-
this.
|
|
2165
|
-
delete this.
|
|
2145
|
+
if (this.topicToBridge[requestId]) {
|
|
2146
|
+
this.topicToBridge[requestId].close();
|
|
2147
|
+
delete this.topicToBridge[requestId];
|
|
2166
2148
|
}
|
|
2167
|
-
delete this.
|
|
2149
|
+
delete this.topicToPublicKey[requestId];
|
|
2168
2150
|
delete this.topicToConfig[requestId];
|
|
2169
2151
|
delete this.topicToLocalConfig[requestId];
|
|
2170
|
-
delete this.topicToSharedSecret[requestId];
|
|
2171
2152
|
delete this.topicToProofs[requestId];
|
|
2172
2153
|
delete this.topicToExpectedProofCount[requestId];
|
|
2173
2154
|
delete this.topicToFailedProofCount[requestId];
|
|
@@ -2183,7 +2164,7 @@ class ZKPassport {
|
|
|
2183
2164
|
* @notice Clears all requests.
|
|
2184
2165
|
*/
|
|
2185
2166
|
clearAllRequests() {
|
|
2186
|
-
for (const requestId in this.
|
|
2167
|
+
for (const requestId in this.topicToBridge) {
|
|
2187
2168
|
this.cancelRequest(requestId);
|
|
2188
2169
|
}
|
|
2189
2170
|
}
|