@parity/product-sdk-host 0.11.0 → 0.12.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/src/payments.ts CHANGED
@@ -1,61 +1,91 @@
1
1
  // Copyright 2026 Parity Technologies (UK) Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  /**
4
- * Wrapper for the host's payment manager (RFC-0006).
4
+ * Wrapper for the host's payment manager (RFC-0006), backed by
5
+ * `truApi.payment.*`.
5
6
  *
6
- * Shipped flat-in-host rather than as `getTruApi().payment.*` because the
7
- * upstream JS `hostApi` is itself a flat object - there is no `.payment`
8
- * accessor to mirror. A flat `getPaymentManager()` matches the singleton
9
- * pattern already used by {@link getPreimageManager},
10
- * {@link getHostLocalStorage}, and {@link getAccountsProvider}.
11
- *
12
- * Returns the shared `paymentManager` singleton from
13
- * `@novasamatech/host-api-wrapper` (not a fresh `createPaymentManager()`
14
- * instance) so callers share one wrapper + hostApi closure across the app.
15
- *
16
- * Distinct from the CoinPayment / merchant-payments surface tracked under
17
- * `@parity/product-sdk-merchant-payments` (RFC-0017). RFC-0006 is the
18
- * user-initiated balance / top-up / payment-request flow; RFC-0017 is the
19
- * merchant-initiated checkout flow.
7
+ * Exposes balance subscription, top-up, payment requests, and payment-status
8
+ * subscription. Distinct from the CoinPayment / merchant-payments surface
9
+ * (RFC-0017): RFC-0006 is the user-initiated balance / top-up / payment-request
10
+ * flow.
20
11
  *
21
12
  * @module
22
13
  */
23
14
 
24
- import { createLogger } from "@parity/product-sdk-logger";
25
-
26
15
  import type {
27
- PaymentBalance as NovasamaPaymentBalance,
28
- PaymentStatus as NovasamaPaymentStatus,
29
- TopUpSource as NovasamaTopUpSource,
30
- paymentManager,
31
- } from "@novasamatech/host-api-wrapper";
32
-
33
- const log = createLogger("host:payments");
34
-
35
- /** Available balance for the user's payment account. Re-exported from `@novasamatech/host-api-wrapper`. */
36
- export type PaymentBalance = NovasamaPaymentBalance;
16
+ Balance,
17
+ PaymentPurseId,
18
+ HexString,
19
+ HostPaymentBalanceSubscribeItem,
20
+ HostPaymentStatusSubscribeItem,
21
+ PaymentTopUpSource,
22
+ TrUApiClient,
23
+ } from "@parity/truapi";
37
24
 
38
- /** Status of an in-flight payment request. Re-exported from `@novasamatech/host-api-wrapper`. */
39
- export type PaymentStatus = NovasamaPaymentStatus;
40
-
41
- /** Source for {@link PaymentManager.topUp}. Re-exported from `@novasamatech/host-api-wrapper`. */
42
- export type TopUpSource = NovasamaTopUpSource;
25
+ import { getClient, subscribeWithInterrupt } from "./transport.js";
26
+ import { unwrapHostResult } from "./truapi.js";
27
+ import type { HostSubscription } from "./types.js";
43
28
 
44
29
  /**
45
30
  * Payment manager handle. Exposes balance subscription, top-up, payment
46
- * requests, and payment status subscription.
31
+ * requests, and payment-status subscription.
47
32
  *
48
- * Type identical to `paymentManager` from `@novasamatech/host-api-wrapper`.
33
+ * The balance / status / top-up-source shapes are `@parity/truapi`'s
34
+ * `HostPaymentBalanceSubscribeItem`, `HostPaymentStatusSubscribeItem`, and
35
+ * `PaymentTopUpSource` — used directly rather than re-aliased.
49
36
  */
50
- export type PaymentManager = typeof paymentManager;
37
+ export interface PaymentManager {
38
+ subscribeBalance(
39
+ callback: (balance: HostPaymentBalanceSubscribeItem) => void,
40
+ purse?: PaymentPurseId,
41
+ ): HostSubscription;
42
+ topUp(amount: Balance, source: PaymentTopUpSource, into?: PaymentPurseId): Promise<void>;
43
+ requestPayment(
44
+ amount: Balance,
45
+ destination: HexString,
46
+ from?: PaymentPurseId,
47
+ ): Promise<{ id: string }>;
48
+ subscribePaymentStatus(
49
+ paymentId: string,
50
+ callback: (status: HostPaymentStatusSubscribeItem) => void,
51
+ ): HostSubscription;
52
+ }
53
+
54
+ /** Build a {@link PaymentManager} over a TruAPI client's `payment` domain. */
55
+ function adaptPaymentManager(client: TrUApiClient): PaymentManager {
56
+ const payment = client.payment;
57
+ return {
58
+ subscribeBalance(callback, purse) {
59
+ return subscribeWithInterrupt(
60
+ payment.balanceSubscribe({ request: { purse } }),
61
+ callback,
62
+ );
63
+ },
64
+ topUp(amount, source, into) {
65
+ return unwrapHostResult(
66
+ payment.topUp({ into, amount, source }),
67
+ "payment topUp failed",
68
+ );
69
+ },
70
+ async requestPayment(amount, destination, from) {
71
+ const response = await unwrapHostResult(
72
+ payment.request({ from, amount, destination }),
73
+ "payment requestPayment failed",
74
+ );
75
+ return { id: response.id };
76
+ },
77
+ subscribePaymentStatus(paymentId, callback) {
78
+ return subscribeWithInterrupt(
79
+ payment.statusSubscribe({ request: { paymentId } }),
80
+ callback,
81
+ );
82
+ },
83
+ };
84
+ }
51
85
 
52
86
  /**
53
- * Get the host payment manager.
54
- *
55
- * Returns the shared `paymentManager` singleton from
56
- * `@novasamatech/host-api-wrapper`, or `null` if the package is unavailable
57
- * (running outside a host container or the optional peer dep isn't
58
- * installed).
87
+ * Get the host payment manager, backed by `truApi.payment.*`. Returns `null`
88
+ * when running outside a host container.
59
89
  *
60
90
  * @returns The payment manager, or `null` if unavailable.
61
91
  *
@@ -66,35 +96,21 @@ export type PaymentManager = typeof paymentManager;
66
96
  * const payments = await getPaymentManager();
67
97
  * if (payments) {
68
98
  * const sub = payments.subscribeBalance((b) => { ... });
69
- * await payments.topUp(1_000_000n, { type: "productAccount", derivationIndex: 0 });
70
- * const destination = new Uint8Array(32);
71
- * const { id } = await payments.requestPayment(500n, destination);
99
+ * await payments.topUp(1_000_000n, { tag: "ProductAccount", value: { derivationIndex: 0 } });
100
+ * const { id } = await payments.requestPayment(500n, "0x…");
72
101
  * sub.unsubscribe();
73
102
  * }
74
103
  * ```
75
104
  */
76
105
  export async function getPaymentManager(): Promise<PaymentManager | null> {
77
- try {
78
- const sdk = await import("@novasamatech/host-api-wrapper");
79
- return sdk.paymentManager;
80
- } catch (err) {
81
- log.debug("getPaymentManager unavailable", err);
82
- return null;
83
- }
106
+ const client = await getClient();
107
+ return client ? adaptPaymentManager(client) : null;
84
108
  }
85
109
 
86
110
  if (import.meta.vitest) {
87
111
  const { test, expect } = import.meta.vitest;
88
112
 
89
- test("getPaymentManager returns manager with full RFC-0006 surface when SDK is available", async () => {
90
- const payments = await getPaymentManager();
91
- if (payments === null) {
92
- // Acceptable: SDK couldn't load (e.g. peer dep missing in some envs).
93
- return;
94
- }
95
- expect(typeof payments.subscribeBalance).toBe("function");
96
- expect(typeof payments.topUp).toBe("function");
97
- expect(typeof payments.requestPayment).toBe("function");
98
- expect(typeof payments.subscribePaymentStatus).toBe("function");
113
+ test("getPaymentManager returns null outside a container", async () => {
114
+ expect(await getPaymentManager()).toBeNull();
99
115
  });
100
116
  }
@@ -3,71 +3,68 @@
3
3
  /**
4
4
  * Higher-level wrappers for the host's single-permission flows.
5
5
  *
6
- * `hostApi.permission` / `hostApi.devicePermission` take a versioned
7
- * envelope (`enumValue("v1", ...)`) and return a neverthrow `ResultAsync`
8
- * of an unwrapped versioned response. Consumers rebuild that wrap/unwrap
9
- * dance every time. {@link requestPermission} and
10
- * {@link requestDevicePermission} collapse it to one-liners that match the
11
- * shape of {@link requestResourceAllocation} (throws on error, returns
12
- * the unwrapped payload on success).
6
+ * `truApi.permissions.requestRemotePermission` / `requestDevicePermission`
7
+ * return a neverthrow `ResultAsync` of a `{ granted }` response.
8
+ * {@link requestPermission} and {@link requestDevicePermission} collapse that
9
+ * to one-liners returning a `Result<boolean, HostError>` — the granted flag on
10
+ * success, a typed {@link HostError} on the `err` channel.
13
11
  *
14
12
  * @module
15
13
  */
16
14
 
15
+ import type { HostDevicePermissionRequest } from "@parity/truapi";
17
16
  import { createLogger } from "@parity/product-sdk-logger";
18
17
 
19
- import type { CodecType } from "@novasamatech/host-api";
20
- import type { DevicePermission as DevicePermissionCodec } from "@novasamatech/host-api";
21
-
22
- import { enumValue, formatHostError, getTruApi, type RemotePermission } from "./truapi.js";
18
+ import { type HostError, HostUnavailableError } from "./errors.js";
19
+ import { type Result, err } from "./result.js";
20
+ import { getTruApi, mapHostResult, type RemotePermission } from "./truapi.js";
23
21
 
24
22
  const log = createLogger("host:permissions");
25
23
 
26
24
  /**
27
25
  * Device permission the dapp can ask the host to grant via
28
- * {@link requestDevicePermission}.
29
- *
30
- * Derived from the upstream codec so variant renames surface as compile
31
- * errors, not runtime failures.
26
+ * {@link requestDevicePermission}. A string union (`"Camera"`, `"Microphone"`,
27
+ * …) re-exported from `@parity/truapi`.
32
28
  */
33
- export type DevicePermissionKind = CodecType<typeof DevicePermissionCodec>;
29
+ export type DevicePermissionKind = HostDevicePermissionRequest;
34
30
 
35
31
  /**
36
- * Alias of {@link RemotePermission} matching the upstream
37
- * `@novasamatech/host-api-wrapper` name. Use either freely.
32
+ * Legacy alias of {@link RemotePermission}, kept for back-compat with code that
33
+ * used the older name. Use either freely.
38
34
  */
39
35
  export type RemotePermissionItem = RemotePermission;
40
36
 
41
37
  /**
42
38
  * Request a single remote permission from the host.
43
39
  *
44
- * Builds the `v1` envelope, calls `hostApi.permission`, unwraps the response,
45
- * and returns the host's boolean granted/denied outcome.
40
+ * Calls `truApi.permissions.requestRemotePermission` and returns the host's
41
+ * boolean granted/denied outcome.
46
42
  *
47
43
  * @param permission - The remote permission to request.
48
- * @returns `true` if the host granted the permission, `false` if denied.
49
- * @throws If the host is unavailable or the request fails.
44
+ * @returns `ok(true)` if the host granted the permission, `ok(false)` if denied,
45
+ * or `err(HostUnavailableError | HostCallFailedError)`.
50
46
  *
51
47
  * @example
52
48
  * ```ts
53
- * const granted = await requestPermission({ tag: "ChainSubmit", value: undefined });
54
- * if (!granted) {
49
+ * const r = await requestPermission({ tag: "ChainSubmit", value: undefined });
50
+ * if (!r.ok || !r.value) {
55
51
  * tellUserToReconnect();
56
52
  * }
57
53
  * ```
58
54
  */
59
- export async function requestPermission(permission: RemotePermission): Promise<boolean> {
55
+ export async function requestPermission(
56
+ permission: RemotePermission,
57
+ ): Promise<Result<boolean, HostError>> {
60
58
  const truApi = await getTruApi();
61
59
  if (!truApi) {
62
- throw new Error("requestPermission: TruAPI unavailable");
60
+ return err(new HostUnavailableError("requestPermission: TruAPI unavailable"));
63
61
  }
64
62
  log.debug("requestPermission", { tag: permission.tag });
65
63
 
66
- return await truApi.permission(enumValue("v1", permission)).match(
67
- (envelope: { tag: "v1"; value: boolean }) => envelope.value,
68
- (err: unknown) => {
69
- throw new Error(`requestPermission failed: ${formatHostError(err)}`, { cause: err });
70
- },
64
+ return mapHostResult(
65
+ truApi.permissions.requestRemotePermission({ permission }),
66
+ (response) => response.granted,
67
+ "requestPermission failed",
71
68
  );
72
69
  }
73
70
 
@@ -75,56 +72,57 @@ export async function requestPermission(permission: RemotePermission): Promise<b
75
72
  * Request a single device permission (camera, microphone, etc.) from the
76
73
  * host.
77
74
  *
78
- * Builds the `v1` envelope, calls `hostApi.devicePermission`, unwraps the
79
- * response, and returns the host's boolean granted/denied outcome.
75
+ * Calls `truApi.permissions.requestDevicePermission` and returns the host's
76
+ * boolean granted/denied outcome.
80
77
  *
81
78
  * @param permission - The device permission to request.
82
- * @returns `true` if the host granted the permission, `false` if denied.
83
- * @throws If the host is unavailable or the request fails.
79
+ * @returns `ok(true)` if the host granted the permission, `ok(false)` if denied,
80
+ * or `err(HostUnavailableError | HostCallFailedError)`.
84
81
  *
85
82
  * @example
86
83
  * ```ts
87
- * const granted = await requestDevicePermission("Camera");
88
- * if (!granted) {
84
+ * const r = await requestDevicePermission("Camera");
85
+ * if (!r.ok || !r.value) {
89
86
  * showCameraDeniedMessage();
90
87
  * }
91
88
  * ```
92
89
  */
93
- export async function requestDevicePermission(permission: DevicePermissionKind): Promise<boolean> {
90
+ export async function requestDevicePermission(
91
+ permission: DevicePermissionKind,
92
+ ): Promise<Result<boolean, HostError>> {
94
93
  const truApi = await getTruApi();
95
94
  if (!truApi) {
96
- throw new Error("requestDevicePermission: TruAPI unavailable");
95
+ return err(new HostUnavailableError("requestDevicePermission: TruAPI unavailable"));
97
96
  }
98
97
  log.debug("requestDevicePermission", { permission });
99
98
 
100
- return await truApi.devicePermission(enumValue("v1", permission)).match(
101
- (envelope: { tag: "v1"; value: boolean }) => envelope.value,
102
- (err: unknown) => {
103
- throw new Error(`requestDevicePermission failed: ${formatHostError(err)}`, {
104
- cause: err,
105
- });
106
- },
99
+ return mapHostResult(
100
+ truApi.permissions.requestDevicePermission(permission),
101
+ (response) => response.granted,
102
+ "requestDevicePermission failed",
107
103
  );
108
104
  }
109
105
 
110
106
  if (import.meta.vitest) {
111
107
  const { test, expect, describe, vi } = import.meta.vitest;
112
108
 
109
+ function okAsync<T>(value: T) {
110
+ return { match: async (onOk: (v: T) => unknown) => onOk(value) };
111
+ }
112
+ function errAsync<E>(error: E) {
113
+ return {
114
+ match: async (_onOk: (v: unknown) => unknown, onErr: (e: E) => unknown) => onErr(error),
115
+ };
116
+ }
117
+
113
118
  async function withMockedTruApi<T>(
114
- bridge: {
115
- permission?: (req: unknown) => unknown;
116
- devicePermission?: (req: unknown) => unknown;
117
- } | null,
119
+ client: unknown,
118
120
  fn: (mod: typeof import("./permissions.js")) => Promise<T>,
119
121
  ): Promise<T> {
120
122
  vi.resetModules();
121
123
  vi.doMock("./truapi.js", async (importOriginal) => {
122
124
  const original = await importOriginal<typeof import("./truapi.js")>();
123
- return {
124
- ...original,
125
- getTruApi: async () => bridge,
126
- enumValue: (version: string, value: unknown) => ({ tag: version, value }),
127
- };
125
+ return { ...original, getTruApi: async () => client };
128
126
  });
129
127
  try {
130
128
  const mod = await import("./permissions.js");
@@ -136,97 +134,101 @@ if (import.meta.vitest) {
136
134
  }
137
135
 
138
136
  describe("requestPermission", () => {
139
- test("throws when TruAPI is unavailable", async () => {
137
+ test("returns err(HostUnavailableError) when TruAPI is unavailable", async () => {
140
138
  await withMockedTruApi(null, async (mod) => {
141
- await expect(
142
- mod.requestPermission({ tag: "ChainSubmit", value: undefined }),
143
- ).rejects.toThrow(/TruAPI unavailable/);
139
+ const result = await mod.requestPermission({
140
+ tag: "ChainSubmit",
141
+ value: undefined,
142
+ });
143
+ expect(result.ok).toBe(false);
144
+ if (!result.ok) {
145
+ expect(result.error.name).toBe("HostUnavailableError");
146
+ }
144
147
  });
145
148
  });
146
149
 
147
- test("unwraps the v1 boolean outcome", async () => {
150
+ test("returns ok with the granted flag", async () => {
148
151
  await withMockedTruApi(
149
152
  {
150
- permission: vi.fn().mockReturnValue({
151
- match: async (onOk: (v: unknown) => unknown) =>
152
- onOk({ tag: "v1", value: true }),
153
- }),
153
+ permissions: {
154
+ requestRemotePermission: vi.fn(() => okAsync({ granted: true })),
155
+ },
154
156
  },
155
157
  async (mod) => {
156
- const granted = await mod.requestPermission({
158
+ const result = await mod.requestPermission({
157
159
  tag: "ChainSubmit",
158
160
  value: undefined,
159
161
  });
160
- expect(granted).toBe(true);
162
+ expect(result).toEqual({ ok: true, value: true });
161
163
  },
162
164
  );
163
165
  });
164
166
 
165
- test("wraps host errors with a diagnostic message", async () => {
167
+ test("wraps host errors in err(HostCallFailedError) with a diagnostic message", async () => {
166
168
  await withMockedTruApi(
167
169
  {
168
- permission: vi.fn().mockReturnValue({
169
- match: async (
170
- _onOk: (v: unknown) => unknown,
171
- onErr: (e: unknown) => unknown,
172
- ) =>
173
- onErr({
174
- tag: "v1",
175
- value: { name: "GenericError", message: "boom" },
176
- }),
177
- }),
170
+ permissions: {
171
+ requestRemotePermission: vi.fn(() => errAsync({ reason: "boom" })),
172
+ },
178
173
  },
179
174
  async (mod) => {
180
- await expect(
181
- mod.requestPermission({ tag: "ChainSubmit", value: undefined }),
182
- ).rejects.toThrow(/requestPermission failed: GenericError: boom/);
175
+ const result = await mod.requestPermission({
176
+ tag: "ChainSubmit",
177
+ value: undefined,
178
+ });
179
+ expect(result.ok).toBe(false);
180
+ if (!result.ok) {
181
+ expect(result.error.name).toBe("HostCallFailedError");
182
+ expect(result.error.message).toMatch(/requestPermission failed: boom/);
183
+ }
183
184
  },
184
185
  );
185
186
  });
186
187
  });
187
188
 
188
189
  describe("requestDevicePermission", () => {
189
- test("throws when TruAPI is unavailable", async () => {
190
+ test("returns err(HostUnavailableError) when TruAPI is unavailable", async () => {
190
191
  await withMockedTruApi(null, async (mod) => {
191
- await expect(mod.requestDevicePermission("Camera")).rejects.toThrow(
192
- /TruAPI unavailable/,
193
- );
192
+ const result = await mod.requestDevicePermission("Camera");
193
+ expect(result.ok).toBe(false);
194
+ if (!result.ok) {
195
+ expect(result.error.name).toBe("HostUnavailableError");
196
+ }
194
197
  });
195
198
  });
196
199
 
197
- test("unwraps the v1 boolean outcome", async () => {
200
+ test("returns ok with the granted flag", async () => {
198
201
  await withMockedTruApi(
199
202
  {
200
- devicePermission: vi.fn().mockReturnValue({
201
- match: async (onOk: (v: unknown) => unknown) =>
202
- onOk({ tag: "v1", value: true }),
203
- }),
203
+ permissions: {
204
+ requestDevicePermission: vi.fn(() => okAsync({ granted: true })),
205
+ },
204
206
  },
205
207
  async (mod) => {
206
- const granted = await mod.requestDevicePermission("Camera");
207
- expect(granted).toBe(true);
208
+ expect(await mod.requestDevicePermission("Camera")).toEqual({
209
+ ok: true,
210
+ value: true,
211
+ });
208
212
  },
209
213
  );
210
214
  });
211
215
 
212
- test("wraps host errors with a diagnostic message", async () => {
216
+ test("wraps host errors in err(HostCallFailedError) with a diagnostic message", async () => {
213
217
  await withMockedTruApi(
214
218
  {
215
- devicePermission: vi.fn().mockReturnValue({
216
- match: async (
217
- _onOk: (v: unknown) => unknown,
218
- onErr: (e: unknown) => unknown,
219
- ) =>
220
- onErr({
221
- tag: "v1",
222
- value: { name: "GenericError", message: "boom" },
223
- }),
224
- }),
219
+ permissions: {
220
+ requestDevicePermission: vi.fn(() => errAsync({ reason: "boom" })),
221
+ },
225
222
  },
226
223
  async (mod) => {
227
- await expect(mod.requestDevicePermission("Camera")).rejects.toThrow(
228
- /requestDevicePermission failed: GenericError: boom/,
229
- );
224
+ const result = await mod.requestDevicePermission("Camera");
225
+ expect(result.ok).toBe(false);
226
+ if (!result.ok) {
227
+ expect(result.error.name).toBe("HostCallFailedError");
228
+ expect(result.error.message).toMatch(
229
+ /requestDevicePermission failed: boom/,
230
+ );
231
+ }
230
232
  },
231
233
  );
232
234
  });
package/src/result.ts ADDED
@@ -0,0 +1,56 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * A lightweight tagged `Result` type for the host public API.
5
+ *
6
+ * Host functions return `Promise<Result<T, HostError>>` rather than throwing, so
7
+ * consumers get typed errors on the `err` channel instead of opaque thrown
8
+ * `Error`s. The shape is intentionally identical to the one
9
+ * `@parity/product-sdk-signer` exposes (`{ ok: true; value } | { ok: false; error }`),
10
+ * so the two layers compose with no adapter — host's `Result` flows straight into
11
+ * the signer's pattern matching.
12
+ *
13
+ * NOTE: host owns its own copy because the dependency edge runs `signer → host`,
14
+ * so host cannot import the signer's definition. If a third package ever needs
15
+ * this shape, extract it into a shared `@parity/product-sdk-result` package and
16
+ * have both depend on that instead of duplicating.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ /** A value that is either a success (`ok`) carrying `T`, or a failure (`err`) carrying `E`. */
22
+ export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
23
+
24
+ /** Create a successful {@link Result}. */
25
+ export function ok<T>(value: T): Result<T, never> {
26
+ return { ok: true, value };
27
+ }
28
+
29
+ /** Create a failed {@link Result}. */
30
+ export function err<E>(error: E): Result<never, E> {
31
+ return { ok: false, error };
32
+ }
33
+
34
+ if (import.meta.vitest) {
35
+ const { test, expect, describe } = import.meta.vitest;
36
+
37
+ describe("ok", () => {
38
+ test("produces an ok result with value", () => {
39
+ const result = ok(42);
40
+ expect(result.ok).toBe(true);
41
+ expect(result).toEqual({ ok: true, value: 42 });
42
+ });
43
+
44
+ test("works with null value", () => {
45
+ expect(ok(null)).toEqual({ ok: true, value: null });
46
+ });
47
+ });
48
+
49
+ describe("err", () => {
50
+ test("produces an error result", () => {
51
+ const result = err("boom");
52
+ expect(result.ok).toBe(false);
53
+ expect(result).toEqual({ ok: false, error: "boom" });
54
+ });
55
+ });
56
+ }