@crossmint/openclaw-wallet 0.2.2

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.
@@ -0,0 +1,211 @@
1
+ import { Keypair } from "@solana/web3.js";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ buildAmazonProductLocator,
5
+ buildDelegationUrl,
6
+ createOrder,
7
+ getOrder,
8
+ type CreateOrderRequest,
9
+ type CrossmintApiConfig,
10
+ } from "./api.js";
11
+
12
+ /**
13
+ * Live integration tests for Crossmint Amazon purchase API.
14
+ *
15
+ * Run with: CROSSMINT_API_KEY=your-key pnpm test extensions/crossmint/src/api.test.ts
16
+ *
17
+ * These tests make real API calls to Crossmint staging (devnet).
18
+ * They require a valid client-side API key with orders.create scope.
19
+ */
20
+
21
+ const LIVE = process.env.CROSSMINT_API_KEY || process.env.LIVE;
22
+
23
+ describe("crossmint api", () => {
24
+ describe("buildDelegationUrl", () => {
25
+ it("builds URL with public key parameter", () => {
26
+ const url = buildDelegationUrl("https://example.com/delegate", "ABC123");
27
+ expect(url).toBe("https://example.com/delegate/configure?pubkey=ABC123");
28
+ });
29
+
30
+ it("removes trailing slashes from base URL", () => {
31
+ const url = buildDelegationUrl("https://example.com/delegate///", "ABC123");
32
+ expect(url).toBe("https://example.com/delegate/configure?pubkey=ABC123");
33
+ });
34
+ });
35
+
36
+ describe("buildAmazonProductLocator", () => {
37
+ it("returns existing amazon: locator unchanged", () => {
38
+ const result = buildAmazonProductLocator("amazon:B00O79SKV6");
39
+ expect(result).toBe("amazon:B00O79SKV6");
40
+ });
41
+
42
+ it("wraps Amazon URL with amazon: prefix", () => {
43
+ const result = buildAmazonProductLocator("https://www.amazon.com/dp/B00O79SKV6");
44
+ expect(result).toBe("amazon:https://www.amazon.com/dp/B00O79SKV6");
45
+ });
46
+
47
+ it("wraps ASIN with amazon: prefix", () => {
48
+ const result = buildAmazonProductLocator("B00O79SKV6");
49
+ expect(result).toBe("amazon:B00O79SKV6");
50
+ });
51
+ });
52
+
53
+ describe.skipIf(!LIVE)("live: createOrder", () => {
54
+ const config: CrossmintApiConfig = {
55
+ apiKey: process.env.CROSSMINT_API_KEY!,
56
+ environment: "staging",
57
+ };
58
+
59
+ // Generate a test keypair for the payer address
60
+ const testKeypair = Keypair.generate();
61
+
62
+ it("creates an order for an Amazon product", async () => {
63
+ const request: CreateOrderRequest = {
64
+ recipient: {
65
+ email: "test@example.com",
66
+ physicalAddress: {
67
+ name: "Test User",
68
+ line1: "123 Test Street",
69
+ city: "San Francisco",
70
+ state: "CA",
71
+ postalCode: "94102",
72
+ country: "US",
73
+ },
74
+ },
75
+ payment: {
76
+ receiptEmail: "test@example.com",
77
+ method: "solana",
78
+ currency: "usdc",
79
+ payerAddress: testKeypair.publicKey.toBase58(),
80
+ },
81
+ lineItems: [
82
+ {
83
+ // Amazon Basics product - commonly available
84
+ productLocator: "amazon:B00O79SKV6",
85
+ },
86
+ ],
87
+ };
88
+
89
+ const order = await createOrder(config, request);
90
+
91
+ console.log("Created order:", JSON.stringify(order, null, 2));
92
+
93
+ // Verify order was created
94
+ expect(order.orderId).toBeDefined();
95
+ expect(order.phase).toBeDefined();
96
+
97
+ // The order should have a quote or be in quote phase
98
+ expect(["quote", "payment"]).toContain(order.phase);
99
+
100
+ // For headless checkout with delegated signer, we expect serializedTransaction
101
+ // Note: This may not always be present depending on quote status
102
+ if (order.phase === "payment") {
103
+ expect(order.payment?.preparation?.serializedTransaction).toBeDefined();
104
+ }
105
+ });
106
+
107
+ it("creates an order with SOL currency", async () => {
108
+ const request: CreateOrderRequest = {
109
+ recipient: {
110
+ email: "test@example.com",
111
+ physicalAddress: {
112
+ name: "Test User",
113
+ line1: "456 Test Avenue",
114
+ city: "Los Angeles",
115
+ state: "CA",
116
+ postalCode: "90001",
117
+ country: "US",
118
+ },
119
+ },
120
+ payment: {
121
+ receiptEmail: "test@example.com",
122
+ method: "solana",
123
+ currency: "sol",
124
+ payerAddress: testKeypair.publicKey.toBase58(),
125
+ },
126
+ lineItems: [
127
+ {
128
+ productLocator: "amazon:B00O79SKV6",
129
+ },
130
+ ],
131
+ };
132
+
133
+ const order = await createOrder(config, request);
134
+
135
+ console.log("Created SOL order:", JSON.stringify(order, null, 2));
136
+
137
+ expect(order.orderId).toBeDefined();
138
+ expect(order.phase).toBeDefined();
139
+ });
140
+
141
+ it("retrieves order status after creation", async () => {
142
+ // First create an order
143
+ const request: CreateOrderRequest = {
144
+ recipient: {
145
+ email: "test@example.com",
146
+ physicalAddress: {
147
+ name: "Test User",
148
+ line1: "789 Test Blvd",
149
+ city: "New York",
150
+ state: "NY",
151
+ postalCode: "10001",
152
+ country: "US",
153
+ },
154
+ },
155
+ payment: {
156
+ receiptEmail: "test@example.com",
157
+ method: "solana",
158
+ currency: "usdc",
159
+ payerAddress: testKeypair.publicKey.toBase58(),
160
+ },
161
+ lineItems: [
162
+ {
163
+ productLocator: "amazon:B00O79SKV6",
164
+ },
165
+ ],
166
+ };
167
+
168
+ const createdOrder = await createOrder(config, request);
169
+ expect(createdOrder.orderId).toBeDefined();
170
+
171
+ // Now retrieve the order
172
+ const retrievedOrder = await getOrder(config, createdOrder.orderId);
173
+
174
+ console.log("Retrieved order:", JSON.stringify(retrievedOrder, null, 2));
175
+
176
+ expect(retrievedOrder.orderId).toBe(createdOrder.orderId);
177
+ expect(retrievedOrder.phase).toBeDefined();
178
+ });
179
+
180
+ it("handles invalid product gracefully", async () => {
181
+ const request: CreateOrderRequest = {
182
+ recipient: {
183
+ email: "test@example.com",
184
+ physicalAddress: {
185
+ name: "Test User",
186
+ line1: "123 Test Street",
187
+ city: "San Francisco",
188
+ state: "CA",
189
+ postalCode: "94102",
190
+ country: "US",
191
+ },
192
+ },
193
+ payment: {
194
+ receiptEmail: "test@example.com",
195
+ method: "solana",
196
+ currency: "usdc",
197
+ payerAddress: testKeypair.publicKey.toBase58(),
198
+ },
199
+ lineItems: [
200
+ {
201
+ // Invalid ASIN
202
+ productLocator: "amazon:INVALID123",
203
+ },
204
+ ],
205
+ };
206
+
207
+ // Should throw an error for invalid product
208
+ await expect(createOrder(config, request)).rejects.toThrow();
209
+ });
210
+ });
211
+ });
package/src/api.ts ADDED
@@ -0,0 +1,495 @@
1
+ import type { Keypair } from "@solana/web3.js";
2
+ import bs58 from "bs58";
3
+
4
+ export type CrossmintApiConfig = {
5
+ apiKey: string;
6
+ environment: "staging"; // Only staging (Solana devnet) supported for now
7
+ };
8
+
9
+ export type CrossmintBalance = {
10
+ token: string;
11
+ amount: string;
12
+ decimals: number;
13
+ rawAmount?: string;
14
+ };
15
+
16
+ export type CrossmintTransaction = {
17
+ id: string;
18
+ status: string;
19
+ hash?: string;
20
+ explorerLink?: string;
21
+ onChain?: {
22
+ status?: string;
23
+ chain?: string;
24
+ txId?: string;
25
+ };
26
+ };
27
+
28
+ // Only staging (Solana devnet) is supported for now
29
+ function getBaseUrl(_env: "staging"): string {
30
+ // Production URL reserved for future use: https://www.crossmint.com/api
31
+ return "https://staging.crossmint.com/api";
32
+ }
33
+
34
+ async function fetchCrossmint(
35
+ config: CrossmintApiConfig,
36
+ endpoint: string,
37
+ options: RequestInit = {},
38
+ ): Promise<Response> {
39
+ const baseUrl = getBaseUrl(config.environment);
40
+ const url = `${baseUrl}${endpoint}`;
41
+
42
+ const headers: Record<string, string> = {
43
+ "X-API-KEY": config.apiKey,
44
+ "Content-Type": "application/json",
45
+ ...(options.headers as Record<string, string>),
46
+ };
47
+
48
+ const response = await fetch(url, {
49
+ ...options,
50
+ headers,
51
+ });
52
+
53
+ return response;
54
+ }
55
+
56
+ export async function getWalletBalance(
57
+ config: CrossmintApiConfig,
58
+ walletAddress: string,
59
+ ): Promise<CrossmintBalance[]> {
60
+ const response = await fetchCrossmint(
61
+ config,
62
+ `/2025-06-09/wallets/${encodeURIComponent(walletAddress)}/balances?tokens=sol,usdc&chains=solana`,
63
+ { method: "GET" },
64
+ );
65
+
66
+ if (!response.ok) {
67
+ const error = await response.text();
68
+ throw new Error(`Failed to get balance: ${error}`);
69
+ }
70
+
71
+ const data = await response.json();
72
+
73
+ const balances: CrossmintBalance[] = [];
74
+
75
+ // Parse the response array
76
+ if (Array.isArray(data)) {
77
+ for (const token of data) {
78
+ balances.push({
79
+ token: token.symbol || "Unknown",
80
+ amount: token.amount || "0",
81
+ decimals: token.decimals || 9,
82
+ rawAmount: token.rawAmount,
83
+ });
84
+ }
85
+ }
86
+
87
+ return balances;
88
+ }
89
+
90
+ export async function getTransactionStatus(
91
+ config: CrossmintApiConfig,
92
+ walletAddress: string,
93
+ transactionId: string,
94
+ ): Promise<CrossmintTransaction> {
95
+ const response = await fetchCrossmint(
96
+ config,
97
+ `/2025-06-09/wallets/${encodeURIComponent(walletAddress)}/transactions/${encodeURIComponent(transactionId)}`,
98
+ { method: "GET" },
99
+ );
100
+
101
+ if (!response.ok) {
102
+ const error = await response.text();
103
+ throw new Error(`Failed to get transaction status: ${error}`);
104
+ }
105
+
106
+ const data = await response.json();
107
+
108
+ return {
109
+ id: data.id,
110
+ status: data.status,
111
+ hash: data.onChain?.txId || data.hash,
112
+ explorerLink: data.onChain?.txId
113
+ ? `https://explorer.solana.com/tx/${data.onChain.txId}?cluster=devnet`
114
+ : undefined,
115
+ onChain: data.onChain,
116
+ };
117
+ }
118
+
119
+ export async function waitForTransaction(
120
+ config: CrossmintApiConfig,
121
+ walletAddress: string,
122
+ transactionId: string,
123
+ timeoutMs: number = 60000,
124
+ pollIntervalMs: number = 2000,
125
+ ): Promise<CrossmintTransaction> {
126
+ const startTime = Date.now();
127
+
128
+ while (Date.now() - startTime < timeoutMs) {
129
+ const tx = await getTransactionStatus(config, walletAddress, transactionId);
130
+
131
+ // Terminal states
132
+ if (tx.status === "success" || tx.status === "completed") {
133
+ return tx;
134
+ }
135
+ if (tx.status === "failed" || tx.status === "rejected" || tx.status === "cancelled") {
136
+ return tx;
137
+ }
138
+
139
+ // Still pending, wait and retry
140
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
141
+ }
142
+
143
+ // Timeout - return last known status
144
+ return getTransactionStatus(config, walletAddress, transactionId);
145
+ }
146
+
147
+ export async function createTransfer(
148
+ config: CrossmintApiConfig,
149
+ walletAddress: string,
150
+ recipient: string,
151
+ token: string,
152
+ amount: string,
153
+ keypair: Keypair,
154
+ ): Promise<CrossmintTransaction> {
155
+ // Token locator format for Solana: solana:tokenAddress or solana:sol for native
156
+ const tokenLocator = token.toLowerCase() === "sol" ? "solana:sol" : `solana:${token}`;
157
+
158
+ // Step 1: Create the transfer transaction
159
+ const createResponse = await fetchCrossmint(
160
+ config,
161
+ `/2025-06-09/wallets/${encodeURIComponent(walletAddress)}/tokens/${encodeURIComponent(tokenLocator)}/transfers`,
162
+ {
163
+ method: "POST",
164
+ body: JSON.stringify({
165
+ recipient,
166
+ amount,
167
+ signer: `external-wallet:${keypair.publicKey.toBase58()}`,
168
+ }),
169
+ },
170
+ );
171
+
172
+ if (!createResponse.ok) {
173
+ const error = await createResponse.text();
174
+ throw new Error(`Failed to create transfer: ${error}`);
175
+ }
176
+
177
+ const txData = await createResponse.json();
178
+
179
+ // Step 2: If approval is needed, sign and submit
180
+ if (txData.status === "awaiting-approval" && txData.approvals?.pending?.length > 0) {
181
+ const approval = txData.approvals.pending[0];
182
+ if (approval?.message) {
183
+ // Sign the approval message using ed25519
184
+ // Message is base58 encoded (Solana standard), not hex
185
+ const messageBytes = bs58.decode(approval.message);
186
+ const nacl = (await import("tweetnacl")).default;
187
+ const signature = nacl.sign.detached(messageBytes, keypair.secretKey);
188
+ const signatureBase58 = bs58.encode(signature);
189
+
190
+ // Submit approval
191
+ const approveResponse = await fetchCrossmint(
192
+ config,
193
+ `/2025-06-09/wallets/${encodeURIComponent(walletAddress)}/transactions/${txData.id}/approvals`,
194
+ {
195
+ method: "POST",
196
+ body: JSON.stringify({
197
+ approvals: [
198
+ {
199
+ signer: `external-wallet:${keypair.publicKey.toBase58()}`,
200
+ signature: signatureBase58,
201
+ },
202
+ ],
203
+ }),
204
+ },
205
+ );
206
+
207
+ if (!approveResponse.ok) {
208
+ const error = await approveResponse.text();
209
+ throw new Error(`Failed to approve transfer: ${error}`);
210
+ }
211
+
212
+ const approvedData = await approveResponse.json();
213
+ return {
214
+ id: approvedData.id || txData.id,
215
+ status: approvedData.status || "pending",
216
+ hash: approvedData.hash,
217
+ explorerLink: approvedData.explorerLink,
218
+ };
219
+ }
220
+ }
221
+
222
+ return {
223
+ id: txData.id,
224
+ status: txData.status,
225
+ hash: txData.hash,
226
+ explorerLink: txData.explorerLink,
227
+ };
228
+ }
229
+
230
+ export function buildDelegationUrl(
231
+ delegationBaseUrl: string,
232
+ publicAddress: string,
233
+ ): string {
234
+ // URL format: {baseUrl}/configure?pubkey={publicKey}
235
+ const baseUrl = delegationBaseUrl.replace(/\/+$/, ""); // Remove trailing slashes
236
+ return `${baseUrl}/configure?pubkey=${publicAddress}`;
237
+ }
238
+
239
+ // ============================================================================
240
+ // Headless Checkout Types (Amazon purchases with delegated signer)
241
+ // ============================================================================
242
+
243
+ export type OrderRecipient = {
244
+ email: string;
245
+ physicalAddress: {
246
+ name: string;
247
+ line1: string;
248
+ line2?: string;
249
+ city: string;
250
+ state?: string;
251
+ postalCode: string;
252
+ country: string; // ISO 3166-1 alpha-2 (e.g., "US")
253
+ };
254
+ };
255
+
256
+ export type OrderLineItem = {
257
+ productLocator: string; // e.g., "amazon:B00O79SKV6"
258
+ };
259
+
260
+ export type OrderPayment = {
261
+ receiptEmail: string;
262
+ method: "solana";
263
+ currency: string; // e.g., "usdc"
264
+ payerAddress: string; // Smart wallet address that pays
265
+ };
266
+
267
+ export type CreateOrderRequest = {
268
+ recipient: OrderRecipient;
269
+ payment: OrderPayment;
270
+ lineItems: OrderLineItem[];
271
+ };
272
+
273
+ export type OrderPhase = "quote" | "payment" | "delivery" | "completed" | "failed";
274
+
275
+ export type CrossmintOrder = {
276
+ orderId: string;
277
+ phase: OrderPhase;
278
+ quote?: {
279
+ status: string;
280
+ totalPrice?: { amount: string; currency: string };
281
+ };
282
+ payment?: {
283
+ status: string;
284
+ preparation?: {
285
+ serializedTransaction?: string; // Transaction to sign for payment
286
+ };
287
+ };
288
+ delivery?: {
289
+ status: string;
290
+ items?: Array<{
291
+ status: string;
292
+ packageTracking?: {
293
+ carrierName: string;
294
+ carrierTrackingNumber: string;
295
+ };
296
+ }>;
297
+ };
298
+ lineItems?: Array<{
299
+ metadata?: {
300
+ title?: string;
301
+ imageUrl?: string;
302
+ price?: { amount: string; currency: string };
303
+ };
304
+ }>;
305
+ };
306
+
307
+ export type TransactionResponse = {
308
+ id: string;
309
+ status: string;
310
+ approvals?: {
311
+ pending?: Array<{
312
+ signer: string;
313
+ message: string; // Message to sign
314
+ }>;
315
+ };
316
+ };
317
+
318
+ // ============================================================================
319
+ // Headless Checkout API Functions (Delegated Signer Flow)
320
+ // ============================================================================
321
+
322
+ /**
323
+ * Step 1: Create an order for purchasing products (e.g., from Amazon)
324
+ * Returns order with serializedTransaction to use in step 2
325
+ */
326
+ export async function createOrder(
327
+ config: CrossmintApiConfig,
328
+ request: CreateOrderRequest,
329
+ ): Promise<CrossmintOrder> {
330
+ const response = await fetchCrossmint(config, "/2022-06-09/orders", {
331
+ method: "POST",
332
+ body: JSON.stringify(request),
333
+ });
334
+
335
+ if (!response.ok) {
336
+ const error = await response.text();
337
+ throw new Error(`Failed to create order: ${error}`);
338
+ }
339
+
340
+ // API returns { clientSecret, order } - extract the order
341
+ const data = await response.json();
342
+ return data.order;
343
+ }
344
+
345
+ /**
346
+ * Step 2a: Create transaction from serialized transaction
347
+ * Returns transactionId and message to sign
348
+ */
349
+ export async function createTransaction(
350
+ config: CrossmintApiConfig,
351
+ payerAddress: string,
352
+ serializedTransaction: string,
353
+ signerAddress?: string,
354
+ ): Promise<TransactionResponse> {
355
+ const response = await fetchCrossmint(
356
+ config,
357
+ `/2025-06-09/wallets/${encodeURIComponent(payerAddress)}/transactions`,
358
+ {
359
+ method: "POST",
360
+ body: JSON.stringify({
361
+ params: {
362
+ transaction: serializedTransaction,
363
+ ...(signerAddress && { signer: `external-wallet:${signerAddress}` }),
364
+ },
365
+ }),
366
+ },
367
+ );
368
+
369
+ if (!response.ok) {
370
+ const error = await response.text();
371
+ throw new Error(`Failed to create transaction: ${error}`);
372
+ }
373
+
374
+ return response.json();
375
+ }
376
+
377
+ /**
378
+ * Step 2b: Submit approval with signed message
379
+ */
380
+ export async function submitApproval(
381
+ config: CrossmintApiConfig,
382
+ payerAddress: string,
383
+ transactionId: string,
384
+ signerAddress: string,
385
+ signature: string,
386
+ ): Promise<TransactionResponse> {
387
+ const response = await fetchCrossmint(
388
+ config,
389
+ `/2025-06-09/wallets/${encodeURIComponent(payerAddress)}/transactions/${encodeURIComponent(transactionId)}/approvals`,
390
+ {
391
+ method: "POST",
392
+ body: JSON.stringify({
393
+ approvals: [
394
+ {
395
+ signer: `external-wallet:${signerAddress}`,
396
+ signature,
397
+ },
398
+ ],
399
+ }),
400
+ },
401
+ );
402
+
403
+ if (!response.ok) {
404
+ const error = await response.text();
405
+ throw new Error(`Failed to submit approval: ${error}`);
406
+ }
407
+
408
+ return response.json();
409
+ }
410
+
411
+ /**
412
+ * Get order status
413
+ */
414
+ export async function getOrder(
415
+ config: CrossmintApiConfig,
416
+ orderId: string,
417
+ ): Promise<CrossmintOrder> {
418
+ const response = await fetchCrossmint(
419
+ config,
420
+ `/2022-06-09/orders/${encodeURIComponent(orderId)}`,
421
+ { method: "GET" },
422
+ );
423
+
424
+ if (!response.ok) {
425
+ const error = await response.text();
426
+ throw new Error(`Failed to get order: ${error}`);
427
+ }
428
+
429
+ return response.json();
430
+ }
431
+
432
+ /**
433
+ * Complete Amazon purchase flow with delegated signer
434
+ * Combines all 3 API calls + local signing
435
+ */
436
+ export async function purchaseProduct(
437
+ config: CrossmintApiConfig,
438
+ request: CreateOrderRequest,
439
+ keypair: Keypair,
440
+ ): Promise<{ order: CrossmintOrder; transactionId: string }> {
441
+ // Step 1: Create order
442
+ const order = await createOrder(config, request);
443
+
444
+ const serializedTransaction = order.payment?.preparation?.serializedTransaction;
445
+ if (!serializedTransaction) {
446
+ throw new Error(
447
+ `Order created but no serialized transaction returned. Payment status: ${order.payment?.status || "unknown"}`,
448
+ );
449
+ }
450
+
451
+ // Step 2a: Create transaction with delegated signer
452
+ const txResponse = await createTransaction(
453
+ config,
454
+ request.payment.payerAddress,
455
+ serializedTransaction,
456
+ keypair.publicKey.toBase58(),
457
+ );
458
+
459
+ const messageToSign = txResponse.approvals?.pending?.[0]?.message;
460
+ if (!messageToSign) {
461
+ throw new Error("Transaction created but no message to sign");
462
+ }
463
+
464
+ // Step 2 (local): Sign the message with ed25519
465
+ // Message is base58 encoded (Solana standard) - same as transfers
466
+ const messageBytes = bs58.decode(messageToSign);
467
+ const nacl = (await import("tweetnacl")).default;
468
+ const signature = nacl.sign.detached(messageBytes, keypair.secretKey);
469
+ const signatureBase58 = bs58.encode(signature);
470
+
471
+ // Step 2b: Submit approval
472
+ await submitApproval(
473
+ config,
474
+ request.payment.payerAddress,
475
+ txResponse.id,
476
+ keypair.publicKey.toBase58(),
477
+ signatureBase58,
478
+ );
479
+
480
+ return { order, transactionId: txResponse.id };
481
+ }
482
+
483
+ /**
484
+ * Build Amazon product locator from ASIN or URL
485
+ */
486
+ export function buildAmazonProductLocator(productIdOrUrl: string): string {
487
+ if (productIdOrUrl.startsWith("amazon:")) {
488
+ return productIdOrUrl;
489
+ }
490
+ if (productIdOrUrl.includes("amazon.com")) {
491
+ return `amazon:${productIdOrUrl}`;
492
+ }
493
+ // Assume it's an ASIN
494
+ return `amazon:${productIdOrUrl}`;
495
+ }
package/src/config.ts ADDED
@@ -0,0 +1,11 @@
1
+ // Hardcoded configuration - no user config needed
2
+ export const DELEGATION_URL = "https://www.lobster.cash/";
3
+ export const ENVIRONMENT = "staging" as const;
4
+
5
+ export type CrossmintPluginConfig = Record<string, never>;
6
+
7
+ export const crossmintConfigSchema = {
8
+ parse(_value: unknown): CrossmintPluginConfig {
9
+ return {};
10
+ },
11
+ };