@cartridge/controller 0.5.8 → 0.6.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/.turbo/turbo-build$colon$deps.log +77 -110
- package/.turbo/turbo-build.log +78 -111
- package/dist/__tests__/parseChainId.test.d.ts +2 -0
- package/dist/__tests__/parseChainId.test.js +89 -0
- package/dist/__tests__/parseChainId.test.js.map +1 -0
- package/dist/account.js +6 -0
- package/dist/account.js.map +1 -1
- package/dist/controller.d.ts +1 -1
- package/dist/controller.js +218 -136
- package/dist/controller.js.map +1 -1
- package/dist/iframe/base.js +4 -0
- package/dist/iframe/base.js.map +1 -1
- package/dist/iframe/index.js +4 -0
- package/dist/iframe/index.js.map +1 -1
- package/dist/iframe/keychain.js +4 -0
- package/dist/iframe/keychain.js.map +1 -1
- package/dist/iframe/profile.js +4 -0
- package/dist/iframe/profile.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +222 -138
- package/dist/index.js.map +1 -1
- package/dist/mutex.d.ts +14 -0
- package/dist/mutex.js +22 -0
- package/dist/mutex.js.map +1 -0
- package/dist/policies.d.ts +19 -0
- package/dist/policies.js +26 -0
- package/dist/policies.js.map +1 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +167 -109
- package/dist/provider.js.map +1 -1
- package/dist/session/account.js +4 -0
- package/dist/session/account.js.map +1 -1
- package/dist/session/index.d.ts +1 -0
- package/dist/session/index.js +292 -121
- package/dist/session/index.js.map +1 -1
- package/dist/session/provider.d.ts +7 -2
- package/dist/session/provider.js +292 -121
- package/dist/session/provider.js.map +1 -1
- package/dist/telegram/provider.d.ts +2 -1
- package/dist/telegram/provider.js +204 -112
- package/dist/telegram/provider.js.map +1 -1
- package/dist/utils.d.ts +5 -3
- package/dist/utils.js +29 -2
- package/dist/utils.js.map +1 -1
- package/jest.config.ts +13 -0
- package/package.json +26 -8
- package/src/__tests__/parseChainId.test.ts +60 -0
- package/src/controller.ts +25 -29
- package/src/mutex.ts +22 -0
- package/src/policies.ts +49 -0
- package/src/provider.ts +33 -2
- package/src/session/account.ts +1 -0
- package/src/session/provider.ts +139 -10
- package/src/telegram/provider.ts +3 -2
- package/src/utils.ts +32 -1
- package/tsconfig.json +1 -2
package/src/policies.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ContractPolicy,
|
|
3
|
+
Method,
|
|
4
|
+
SessionPolicies,
|
|
5
|
+
SignMessagePolicy,
|
|
6
|
+
} from "@cartridge/presets";
|
|
7
|
+
|
|
8
|
+
export type ParsedSessionPolicies = {
|
|
9
|
+
verified: boolean;
|
|
10
|
+
contracts?: SessionContracts;
|
|
11
|
+
messages?: SessionMessages;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SessionContracts = Record<
|
|
15
|
+
string,
|
|
16
|
+
Omit<ContractPolicy, "methods"> & {
|
|
17
|
+
methods: (Method & { authorized?: boolean })[];
|
|
18
|
+
}
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
export type SessionMessages = (SignMessagePolicy & {
|
|
22
|
+
authorized?: boolean;
|
|
23
|
+
})[];
|
|
24
|
+
|
|
25
|
+
export function parsePolicies(
|
|
26
|
+
policies: SessionPolicies,
|
|
27
|
+
): ParsedSessionPolicies {
|
|
28
|
+
return {
|
|
29
|
+
verified: false,
|
|
30
|
+
contracts: policies.contracts
|
|
31
|
+
? Object.fromEntries(
|
|
32
|
+
Object.entries(policies.contracts).map(([address, contract]) => [
|
|
33
|
+
address,
|
|
34
|
+
{
|
|
35
|
+
...contract,
|
|
36
|
+
methods: contract.methods.map((method) => ({
|
|
37
|
+
...method,
|
|
38
|
+
authorized: true,
|
|
39
|
+
})),
|
|
40
|
+
},
|
|
41
|
+
]),
|
|
42
|
+
)
|
|
43
|
+
: undefined,
|
|
44
|
+
messages: policies.messages?.map((message) => ({
|
|
45
|
+
...message,
|
|
46
|
+
authorized: true,
|
|
47
|
+
})),
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
import manifest from "../package.json";
|
|
17
17
|
|
|
18
18
|
import { icon } from "./icon";
|
|
19
|
+
import { Mutex } from "./mutex";
|
|
20
|
+
|
|
21
|
+
const mutex = new Mutex();
|
|
19
22
|
|
|
20
23
|
export default abstract class BaseProvider implements StarknetWindowObject {
|
|
21
24
|
public id = "controller";
|
|
@@ -26,10 +29,37 @@ export default abstract class BaseProvider implements StarknetWindowObject {
|
|
|
26
29
|
public account?: WalletAccount;
|
|
27
30
|
public subscriptions: WalletEvents[] = [];
|
|
28
31
|
|
|
32
|
+
private _probePromise: Promise<WalletAccount | undefined> | null = null;
|
|
33
|
+
|
|
34
|
+
protected async safeProbe(): Promise<WalletAccount | undefined> {
|
|
35
|
+
// If we already have an account, return it
|
|
36
|
+
if (this.account) {
|
|
37
|
+
return this.account;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// If we're already probing, wait for the existing probe
|
|
41
|
+
if (this._probePromise) {
|
|
42
|
+
return this._probePromise;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const release = await mutex.obtain();
|
|
46
|
+
return await new Promise<WalletAccount | undefined>(async (resolve) => {
|
|
47
|
+
try {
|
|
48
|
+
this._probePromise = this.probe();
|
|
49
|
+
const result = await this._probePromise;
|
|
50
|
+
resolve(result);
|
|
51
|
+
} finally {
|
|
52
|
+
this._probePromise = null;
|
|
53
|
+
}
|
|
54
|
+
}).finally(() => {
|
|
55
|
+
release();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
29
59
|
request: RequestFn = async (call) => {
|
|
30
60
|
switch (call.type) {
|
|
31
61
|
case "wallet_getPermissions":
|
|
32
|
-
await this.
|
|
62
|
+
await this.safeProbe();
|
|
33
63
|
|
|
34
64
|
if (this.account) {
|
|
35
65
|
return [Permission.ACCOUNTS];
|
|
@@ -45,7 +75,8 @@ export default abstract class BaseProvider implements StarknetWindowObject {
|
|
|
45
75
|
const silentMode =
|
|
46
76
|
call.params && (call.params as RequestAccountsParameters).silent_mode;
|
|
47
77
|
|
|
48
|
-
this.account = await this.
|
|
78
|
+
this.account = await this.safeProbe();
|
|
79
|
+
|
|
49
80
|
if (!this.account && !silentMode) {
|
|
50
81
|
this.account = await this.connect();
|
|
51
82
|
}
|
package/src/session/account.ts
CHANGED
package/src/session/provider.ts
CHANGED
|
@@ -6,6 +6,7 @@ import BaseProvider from "../provider";
|
|
|
6
6
|
import { toWasmPolicies } from "../utils";
|
|
7
7
|
import { SessionPolicies } from "@cartridge/presets";
|
|
8
8
|
import { AddStarknetChainParameters } from "@starknet-io/types-js";
|
|
9
|
+
import { ParsedSessionPolicies } from "../policies";
|
|
9
10
|
|
|
10
11
|
interface SessionRegistration {
|
|
11
12
|
username: string;
|
|
@@ -20,6 +21,7 @@ export type SessionOptions = {
|
|
|
20
21
|
chainId: string;
|
|
21
22
|
policies: SessionPolicies;
|
|
22
23
|
redirectUrl: string;
|
|
24
|
+
keychainUrl?: string;
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
export default class SessionProvider extends BaseProvider {
|
|
@@ -30,39 +32,110 @@ export default class SessionProvider extends BaseProvider {
|
|
|
30
32
|
protected _rpcUrl: string;
|
|
31
33
|
protected _username?: string;
|
|
32
34
|
protected _redirectUrl: string;
|
|
33
|
-
protected _policies:
|
|
35
|
+
protected _policies: ParsedSessionPolicies;
|
|
36
|
+
protected _keychainUrl: string;
|
|
34
37
|
|
|
35
|
-
constructor({
|
|
38
|
+
constructor({
|
|
39
|
+
rpc,
|
|
40
|
+
chainId,
|
|
41
|
+
policies,
|
|
42
|
+
redirectUrl,
|
|
43
|
+
keychainUrl,
|
|
44
|
+
}: SessionOptions) {
|
|
36
45
|
super();
|
|
37
46
|
|
|
47
|
+
this._policies = {
|
|
48
|
+
verified: false,
|
|
49
|
+
contracts: policies.contracts
|
|
50
|
+
? Object.fromEntries(
|
|
51
|
+
Object.entries(policies.contracts).map(([address, contract]) => [
|
|
52
|
+
address,
|
|
53
|
+
{
|
|
54
|
+
...contract,
|
|
55
|
+
methods: contract.methods.map((method) => ({
|
|
56
|
+
...method,
|
|
57
|
+
authorized: true,
|
|
58
|
+
})),
|
|
59
|
+
},
|
|
60
|
+
]),
|
|
61
|
+
)
|
|
62
|
+
: undefined,
|
|
63
|
+
messages: policies.messages?.map((message) => ({
|
|
64
|
+
...message,
|
|
65
|
+
authorized: true,
|
|
66
|
+
})),
|
|
67
|
+
};
|
|
68
|
+
|
|
38
69
|
this._rpcUrl = rpc;
|
|
39
70
|
this._chainId = chainId;
|
|
40
71
|
this._redirectUrl = redirectUrl;
|
|
41
|
-
this.
|
|
72
|
+
this._keychainUrl = keychainUrl || KEYCHAIN_URL;
|
|
42
73
|
|
|
43
74
|
if (typeof window !== "undefined") {
|
|
44
75
|
(window as any).starknet_controller_session = this;
|
|
45
76
|
}
|
|
46
77
|
}
|
|
47
78
|
|
|
79
|
+
private validatePoliciesSubset(
|
|
80
|
+
newPolicies: ParsedSessionPolicies,
|
|
81
|
+
existingPolicies: ParsedSessionPolicies,
|
|
82
|
+
): boolean {
|
|
83
|
+
if (newPolicies.contracts) {
|
|
84
|
+
if (!existingPolicies.contracts) return false;
|
|
85
|
+
|
|
86
|
+
for (const [address, contract] of Object.entries(newPolicies.contracts)) {
|
|
87
|
+
const existingContract = existingPolicies.contracts[address];
|
|
88
|
+
if (!existingContract) return false;
|
|
89
|
+
|
|
90
|
+
for (const method of contract.methods) {
|
|
91
|
+
const existingMethod = existingContract.methods.find(
|
|
92
|
+
(m) => m.entrypoint === method.entrypoint,
|
|
93
|
+
);
|
|
94
|
+
if (!existingMethod || !existingMethod.authorized) return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (newPolicies.messages) {
|
|
100
|
+
if (!existingPolicies.messages) return false;
|
|
101
|
+
|
|
102
|
+
for (const message of newPolicies.messages) {
|
|
103
|
+
const existingMessage = existingPolicies.messages.find(
|
|
104
|
+
(m) =>
|
|
105
|
+
JSON.stringify(m.domain) === JSON.stringify(message.domain) &&
|
|
106
|
+
JSON.stringify(m.types) === JSON.stringify(message.types),
|
|
107
|
+
);
|
|
108
|
+
if (!existingMessage || !existingMessage.authorized) return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
48
115
|
async username() {
|
|
49
116
|
await this.tryRetrieveFromQueryOrStorage();
|
|
50
117
|
return this._username;
|
|
51
118
|
}
|
|
52
119
|
|
|
53
120
|
async probe(): Promise<WalletAccount | undefined> {
|
|
54
|
-
|
|
55
|
-
|
|
121
|
+
if (this.account) {
|
|
122
|
+
return this.account;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.account = await this.tryRetrieveFromQueryOrStorage();
|
|
126
|
+
return this.account;
|
|
56
127
|
}
|
|
57
128
|
|
|
58
129
|
async connect(): Promise<WalletAccount | undefined> {
|
|
59
|
-
|
|
130
|
+
if (this.account) {
|
|
131
|
+
return this.account;
|
|
132
|
+
}
|
|
60
133
|
|
|
134
|
+
this.account = await this.tryRetrieveFromQueryOrStorage();
|
|
61
135
|
if (this.account) {
|
|
62
|
-
return;
|
|
136
|
+
return this.account;
|
|
63
137
|
}
|
|
64
138
|
|
|
65
|
-
// Generate a random local key pair
|
|
66
139
|
const pk = stark.randomAddress();
|
|
67
140
|
const publicKey = ec.starkCurve.getStarkKey(pk);
|
|
68
141
|
|
|
@@ -74,7 +147,11 @@ export default class SessionProvider extends BaseProvider {
|
|
|
74
147
|
}),
|
|
75
148
|
);
|
|
76
149
|
|
|
77
|
-
|
|
150
|
+
localStorage.setItem("sessionPolicies", JSON.stringify(this._policies));
|
|
151
|
+
|
|
152
|
+
const url = `${
|
|
153
|
+
this._keychainUrl
|
|
154
|
+
}/session?public_key=${publicKey}&redirect_uri=${
|
|
78
155
|
this._redirectUrl
|
|
79
156
|
}&redirect_query_name=startapp&policies=${JSON.stringify(
|
|
80
157
|
this._policies,
|
|
@@ -83,7 +160,7 @@ export default class SessionProvider extends BaseProvider {
|
|
|
83
160
|
localStorage.setItem("lastUsedConnector", this.id);
|
|
84
161
|
window.open(url, "_blank");
|
|
85
162
|
|
|
86
|
-
return;
|
|
163
|
+
return this.account;
|
|
87
164
|
}
|
|
88
165
|
|
|
89
166
|
switchStarknetChain(_chainId: string): Promise<boolean> {
|
|
@@ -97,12 +174,17 @@ export default class SessionProvider extends BaseProvider {
|
|
|
97
174
|
disconnect(): Promise<void> {
|
|
98
175
|
localStorage.removeItem("sessionSigner");
|
|
99
176
|
localStorage.removeItem("session");
|
|
177
|
+
localStorage.removeItem("sessionPolicies");
|
|
100
178
|
this.account = undefined;
|
|
101
179
|
this._username = undefined;
|
|
102
180
|
return Promise.resolve();
|
|
103
181
|
}
|
|
104
182
|
|
|
105
183
|
async tryRetrieveFromQueryOrStorage() {
|
|
184
|
+
if (this.account) {
|
|
185
|
+
return this.account;
|
|
186
|
+
}
|
|
187
|
+
|
|
106
188
|
const signerString = localStorage.getItem("sessionSigner");
|
|
107
189
|
const signer = signerString ? JSON.parse(signerString) : null;
|
|
108
190
|
let sessionRegistration: SessionRegistration | null = null;
|
|
@@ -135,6 +217,47 @@ export default class SessionProvider extends BaseProvider {
|
|
|
135
217
|
return;
|
|
136
218
|
}
|
|
137
219
|
|
|
220
|
+
// Check expiration
|
|
221
|
+
const expirationTime = parseInt(sessionRegistration.expiresAt) * 1000;
|
|
222
|
+
console.log("Session expiration check:", {
|
|
223
|
+
expirationTime,
|
|
224
|
+
currentTime: Date.now(),
|
|
225
|
+
expired: Date.now() >= expirationTime,
|
|
226
|
+
});
|
|
227
|
+
if (Date.now() >= expirationTime) {
|
|
228
|
+
console.log("Session expired, clearing stored session");
|
|
229
|
+
this.clearStoredSession();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Check stored policies
|
|
234
|
+
const storedPoliciesStr = localStorage.getItem("sessionPolicies");
|
|
235
|
+
console.log("Checking stored policies:", {
|
|
236
|
+
storedPoliciesStr,
|
|
237
|
+
currentPolicies: this._policies,
|
|
238
|
+
});
|
|
239
|
+
if (storedPoliciesStr) {
|
|
240
|
+
const storedPolicies = JSON.parse(
|
|
241
|
+
storedPoliciesStr,
|
|
242
|
+
) as ParsedSessionPolicies;
|
|
243
|
+
|
|
244
|
+
const isValid = this.validatePoliciesSubset(
|
|
245
|
+
this._policies,
|
|
246
|
+
storedPolicies,
|
|
247
|
+
);
|
|
248
|
+
console.log("Policy validation result:", {
|
|
249
|
+
isValid,
|
|
250
|
+
storedPolicies,
|
|
251
|
+
requestedPolicies: this._policies,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (!isValid) {
|
|
255
|
+
console.log("Policy validation failed, clearing stored session");
|
|
256
|
+
this.clearStoredSession();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
138
261
|
this._username = sessionRegistration.username;
|
|
139
262
|
this.account = new SessionAccount(this, {
|
|
140
263
|
rpcUrl: this._rpcUrl,
|
|
@@ -148,4 +271,10 @@ export default class SessionProvider extends BaseProvider {
|
|
|
148
271
|
|
|
149
272
|
return this.account;
|
|
150
273
|
}
|
|
274
|
+
|
|
275
|
+
private clearStoredSession(): void {
|
|
276
|
+
localStorage.removeItem("sessionSigner");
|
|
277
|
+
localStorage.removeItem("session");
|
|
278
|
+
localStorage.removeItem("sessionPolicies");
|
|
279
|
+
}
|
|
151
280
|
}
|
package/src/telegram/provider.ts
CHANGED
|
@@ -12,6 +12,7 @@ import BaseProvider from "../provider";
|
|
|
12
12
|
import { toWasmPolicies } from "../utils";
|
|
13
13
|
import { SessionPolicies } from "@cartridge/presets";
|
|
14
14
|
import { AddStarknetChainParameters } from "@starknet-io/types-js";
|
|
15
|
+
import { ParsedSessionPolicies, parsePolicies } from "../policies";
|
|
15
16
|
|
|
16
17
|
interface SessionRegistration {
|
|
17
18
|
username: string;
|
|
@@ -25,7 +26,7 @@ export default class TelegramProvider extends BaseProvider {
|
|
|
25
26
|
private _tmaUrl: string;
|
|
26
27
|
protected _chainId: string;
|
|
27
28
|
protected _username?: string;
|
|
28
|
-
protected _policies:
|
|
29
|
+
protected _policies: ParsedSessionPolicies;
|
|
29
30
|
private _rpcUrl: string;
|
|
30
31
|
|
|
31
32
|
constructor({
|
|
@@ -44,7 +45,7 @@ export default class TelegramProvider extends BaseProvider {
|
|
|
44
45
|
this._rpcUrl = rpc;
|
|
45
46
|
this._tmaUrl = tmaUrl;
|
|
46
47
|
this._chainId = chainId;
|
|
47
|
-
this._policies = policies;
|
|
48
|
+
this._policies = parsePolicies(policies);
|
|
48
49
|
|
|
49
50
|
if (typeof window !== "undefined") {
|
|
50
51
|
(window as any).starknet_controller = this;
|
package/src/utils.ts
CHANGED
|
@@ -2,13 +2,17 @@ import {
|
|
|
2
2
|
addAddressPadding,
|
|
3
3
|
Call,
|
|
4
4
|
CallData,
|
|
5
|
+
constants,
|
|
5
6
|
getChecksumAddress,
|
|
6
7
|
hash,
|
|
8
|
+
shortString,
|
|
7
9
|
typedData,
|
|
8
10
|
TypedDataRevision,
|
|
9
11
|
} from "starknet";
|
|
10
12
|
import wasm from "@cartridge/account-wasm/controller";
|
|
11
13
|
import { Policies, SessionPolicies } from "@cartridge/presets";
|
|
14
|
+
import { ChainId } from "@starknet-io/types-js";
|
|
15
|
+
import { ParsedSessionPolicies } from "./policies";
|
|
12
16
|
|
|
13
17
|
// Whitelist of allowed property names to prevent prototype pollution
|
|
14
18
|
const ALLOWED_PROPERTIES = new Set([
|
|
@@ -85,13 +89,14 @@ export function toSessionPolicies(policies: Policies): SessionPolicies {
|
|
|
85
89
|
: policies;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
export function toWasmPolicies(policies:
|
|
92
|
+
export function toWasmPolicies(policies: ParsedSessionPolicies): wasm.Policy[] {
|
|
89
93
|
return [
|
|
90
94
|
...Object.entries(policies.contracts ?? {}).flatMap(
|
|
91
95
|
([target, { methods }]) =>
|
|
92
96
|
toArray(methods).map((m) => ({
|
|
93
97
|
target,
|
|
94
98
|
method: m.entrypoint,
|
|
99
|
+
authorized: m.authorized,
|
|
95
100
|
})),
|
|
96
101
|
),
|
|
97
102
|
...(policies.messages ?? []).map((p) => {
|
|
@@ -109,6 +114,7 @@ export function toWasmPolicies(policies: SessionPolicies): wasm.Policy[] {
|
|
|
109
114
|
|
|
110
115
|
return {
|
|
111
116
|
scope_hash: hash.computePoseidonHash(domainHash, typeHash),
|
|
117
|
+
authorized: p.authorized,
|
|
112
118
|
};
|
|
113
119
|
}),
|
|
114
120
|
];
|
|
@@ -129,3 +135,28 @@ export function humanizeString(str: string): string {
|
|
|
129
135
|
.replace(/^\w/, (c) => c.toUpperCase())
|
|
130
136
|
);
|
|
131
137
|
}
|
|
138
|
+
|
|
139
|
+
export function parseChainId(url: URL): ChainId {
|
|
140
|
+
const parts = url.pathname.split("/");
|
|
141
|
+
|
|
142
|
+
if (parts.includes("starknet")) {
|
|
143
|
+
if (parts.includes("mainnet")) {
|
|
144
|
+
return constants.StarknetChainId.SN_MAIN;
|
|
145
|
+
} else if (parts.includes("sepolia")) {
|
|
146
|
+
return constants.StarknetChainId.SN_SEPOLIA;
|
|
147
|
+
}
|
|
148
|
+
} else if (parts.length >= 3) {
|
|
149
|
+
const projectName = parts[2];
|
|
150
|
+
if (parts.includes("katana")) {
|
|
151
|
+
return shortString.encodeShortString(
|
|
152
|
+
`WP_${projectName.toUpperCase().replace(/-/g, "_")}`,
|
|
153
|
+
) as ChainId;
|
|
154
|
+
} else if (parts.includes("mainnet")) {
|
|
155
|
+
return shortString.encodeShortString(
|
|
156
|
+
`GG_${projectName.toUpperCase().replace(/-/g, "_")}`,
|
|
157
|
+
) as ChainId;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
throw new Error(`Chain ${url.toString()} not supported`);
|
|
162
|
+
}
|