@sip-protocol/sdk 0.1.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/privacy.ts ADDED
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Privacy level handling for SIP Protocol
3
+ *
4
+ * Provides authenticated encryption using XChaCha20-Poly1305 for viewing key
5
+ * selective disclosure. This allows transaction details to be encrypted and
6
+ * later revealed to auditors holding the viewing key.
7
+ *
8
+ * ## Security Properties
9
+ * - **Confidentiality**: Only viewing key holders can decrypt
10
+ * - **Integrity**: Authentication tag prevents tampering
11
+ * - **Nonce-misuse resistance**: XChaCha20 uses 24-byte nonces
12
+ */
13
+
14
+ import type {
15
+ PrivacyLevel,
16
+ ViewingKey,
17
+ EncryptedTransaction,
18
+ HexString,
19
+ Hash,
20
+ } from '@sip-protocol/types'
21
+ import { sha256 } from '@noble/hashes/sha256'
22
+ import { sha512 } from '@noble/hashes/sha512'
23
+ import { hmac } from '@noble/hashes/hmac'
24
+ import { hkdf } from '@noble/hashes/hkdf'
25
+ import { bytesToHex, hexToBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
26
+ import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'
27
+ import { ValidationError, CryptoError, ErrorCode } from './errors'
28
+
29
+ /**
30
+ * Maximum size for decrypted transaction data (1MB)
31
+ * Prevents DoS attacks via large payloads
32
+ */
33
+ const MAX_TRANSACTION_DATA_SIZE = 1024 * 1024
34
+
35
+ /**
36
+ * Privacy configuration for an intent
37
+ */
38
+ export interface PrivacyConfig {
39
+ /** The privacy level */
40
+ level: PrivacyLevel
41
+ /** Viewing key (required for compliant mode) */
42
+ viewingKey?: ViewingKey
43
+ /** Whether to use stealth addresses */
44
+ useStealth: boolean
45
+ /** Whether to encrypt transaction data */
46
+ encryptData: boolean
47
+ }
48
+
49
+ /**
50
+ * Get privacy configuration for a privacy level
51
+ */
52
+ export function getPrivacyConfig(
53
+ level: PrivacyLevel,
54
+ viewingKey?: ViewingKey,
55
+ ): PrivacyConfig {
56
+ switch (level) {
57
+ case 'transparent':
58
+ return {
59
+ level,
60
+ useStealth: false,
61
+ encryptData: false,
62
+ }
63
+
64
+ case 'shielded':
65
+ return {
66
+ level,
67
+ useStealth: true,
68
+ encryptData: true,
69
+ }
70
+
71
+ case 'compliant':
72
+ if (!viewingKey) {
73
+ throw new ValidationError(
74
+ 'viewingKey is required for compliant mode',
75
+ 'viewingKey',
76
+ undefined,
77
+ ErrorCode.MISSING_REQUIRED
78
+ )
79
+ }
80
+ return {
81
+ level,
82
+ viewingKey,
83
+ useStealth: true,
84
+ encryptData: true,
85
+ }
86
+
87
+ default:
88
+ throw new ValidationError(
89
+ `unknown privacy level: ${level}`,
90
+ 'level',
91
+ { received: level },
92
+ ErrorCode.INVALID_PRIVACY_LEVEL
93
+ )
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Generate a new viewing key
99
+ */
100
+ export function generateViewingKey(path: string = 'm/0'): ViewingKey {
101
+ const keyBytes = randomBytes(32)
102
+ const key = `0x${bytesToHex(keyBytes)}` as HexString
103
+ const hashBytes = sha256(keyBytes)
104
+
105
+ return {
106
+ key,
107
+ path,
108
+ hash: `0x${bytesToHex(hashBytes)}` as Hash,
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Derive a child viewing key using BIP32-style hierarchical derivation
114
+ *
115
+ * Uses HMAC-SHA512 for proper key derivation:
116
+ * - childKey = HMAC-SHA512(masterKey, childPath)
117
+ * - Takes first 32 bytes as the derived key
118
+ *
119
+ * This provides:
120
+ * - Cryptographic standard compliance (similar to BIP32)
121
+ * - One-way derivation (cannot derive parent from child)
122
+ * - Non-correlatable keys (different paths produce unrelated keys)
123
+ */
124
+ export function deriveViewingKey(
125
+ masterKey: ViewingKey,
126
+ childPath: string,
127
+ ): ViewingKey {
128
+ // Extract raw master key bytes (remove 0x prefix if present)
129
+ const masterKeyHex = masterKey.key.startsWith('0x')
130
+ ? masterKey.key.slice(2)
131
+ : masterKey.key
132
+ const masterKeyBytes = hexToBytes(masterKeyHex)
133
+
134
+ // Encode child path as bytes
135
+ const childPathBytes = utf8ToBytes(childPath)
136
+
137
+ // HMAC-SHA512(key=masterKey, data=childPath)
138
+ // This follows BIP32-style hierarchical derivation
139
+ const derivedFull = hmac(sha512, masterKeyBytes, childPathBytes)
140
+
141
+ // Take first 32 bytes as the derived key (standard practice)
142
+ const derivedBytes = derivedFull.slice(0, 32)
143
+ const derived = `0x${bytesToHex(derivedBytes)}` as HexString
144
+
145
+ // Compute hash of the derived key for identification
146
+ const hashBytes = sha256(derivedBytes)
147
+
148
+ return {
149
+ key: derived,
150
+ path: `${masterKey.path}/${childPath}`,
151
+ hash: `0x${bytesToHex(hashBytes)}` as Hash,
152
+ }
153
+ }
154
+
155
+ // ─── Encryption Constants ─────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * Domain separation for encryption key derivation
159
+ */
160
+ const ENCRYPTION_DOMAIN = 'SIP-VIEWING-KEY-ENCRYPTION-V1'
161
+
162
+ /**
163
+ * XChaCha20-Poly1305 nonce size (24 bytes)
164
+ */
165
+ const NONCE_SIZE = 24
166
+
167
+ // ─── Key Derivation ───────────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Derive an encryption key from a viewing key using HKDF
171
+ *
172
+ * Uses HKDF-SHA256 with domain separation for security.
173
+ *
174
+ * @param viewingKey - The viewing key to derive from
175
+ * @returns 32-byte encryption key
176
+ */
177
+ function deriveEncryptionKey(viewingKey: ViewingKey): Uint8Array {
178
+ // Extract the raw key bytes (remove 0x prefix)
179
+ const keyHex = viewingKey.key.startsWith('0x')
180
+ ? viewingKey.key.slice(2)
181
+ : viewingKey.key
182
+ const keyBytes = hexToBytes(keyHex)
183
+
184
+ // Use HKDF to derive a proper encryption key
185
+ // HKDF(SHA256, ikm=viewingKey, salt=domain, info=path, length=32)
186
+ const salt = utf8ToBytes(ENCRYPTION_DOMAIN)
187
+ const info = utf8ToBytes(viewingKey.path)
188
+
189
+ return hkdf(sha256, keyBytes, salt, info, 32)
190
+ }
191
+
192
+ // ─── Transaction Data Type ────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Transaction data that can be encrypted for viewing
196
+ */
197
+ export interface TransactionData {
198
+ sender: string
199
+ recipient: string
200
+ amount: string
201
+ timestamp: number
202
+ }
203
+
204
+ // ─── Encryption Functions ─────────────────────────────────────────────────────
205
+
206
+ /**
207
+ * Encrypt transaction data for viewing key holders
208
+ *
209
+ * Uses XChaCha20-Poly1305 authenticated encryption with:
210
+ * - 24-byte random nonce (nonce-misuse resistant)
211
+ * - HKDF-derived encryption key
212
+ * - 16-byte authentication tag (included in ciphertext)
213
+ *
214
+ * @param data - Transaction data to encrypt
215
+ * @param viewingKey - Viewing key for encryption
216
+ * @returns Encrypted transaction with nonce and key hash
217
+ *
218
+ * @example
219
+ * ```typescript
220
+ * const encrypted = encryptForViewing(
221
+ * { sender: '0x...', recipient: '0x...', amount: '100', timestamp: 123 },
222
+ * viewingKey
223
+ * )
224
+ * // encrypted.ciphertext contains the encrypted data
225
+ * // encrypted.nonce is needed for decryption
226
+ * // encrypted.viewingKeyHash identifies which key can decrypt
227
+ * ```
228
+ */
229
+ export function encryptForViewing(
230
+ data: TransactionData,
231
+ viewingKey: ViewingKey,
232
+ ): EncryptedTransaction {
233
+ // Derive encryption key from viewing key
234
+ const key = deriveEncryptionKey(viewingKey)
235
+
236
+ // Generate random nonce (24 bytes for XChaCha20)
237
+ const nonce = randomBytes(NONCE_SIZE)
238
+
239
+ // Serialize data to JSON
240
+ const plaintext = utf8ToBytes(JSON.stringify(data))
241
+
242
+ // Encrypt with XChaCha20-Poly1305
243
+ const cipher = xchacha20poly1305(key, nonce)
244
+ const ciphertext = cipher.encrypt(plaintext)
245
+
246
+ return {
247
+ ciphertext: `0x${bytesToHex(ciphertext)}` as HexString,
248
+ nonce: `0x${bytesToHex(nonce)}` as HexString,
249
+ viewingKeyHash: viewingKey.hash,
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Decrypt transaction data with viewing key
255
+ *
256
+ * Performs authenticated decryption using XChaCha20-Poly1305.
257
+ * The authentication tag is verified before returning data.
258
+ *
259
+ * @param encrypted - Encrypted transaction data
260
+ * @param viewingKey - Viewing key for decryption
261
+ * @returns Decrypted transaction data
262
+ * @throws {Error} If decryption fails (wrong key, tampered data, etc.)
263
+ *
264
+ * @example
265
+ * ```typescript
266
+ * try {
267
+ * const data = decryptWithViewing(encrypted, viewingKey)
268
+ * console.log(`Amount: ${data.amount}`)
269
+ * } catch (e) {
270
+ * console.error('Decryption failed - wrong key or tampered data')
271
+ * }
272
+ * ```
273
+ */
274
+ export function decryptWithViewing(
275
+ encrypted: EncryptedTransaction,
276
+ viewingKey: ViewingKey,
277
+ ): TransactionData {
278
+ // Verify viewing key hash matches (optional but helpful error message)
279
+ if (encrypted.viewingKeyHash !== viewingKey.hash) {
280
+ throw new CryptoError(
281
+ 'Viewing key hash mismatch - this key cannot decrypt this transaction',
282
+ ErrorCode.DECRYPTION_FAILED,
283
+ { operation: 'decryptWithViewing' }
284
+ )
285
+ }
286
+
287
+ // Derive encryption key from viewing key
288
+ const key = deriveEncryptionKey(viewingKey)
289
+
290
+ // Parse nonce and ciphertext
291
+ const nonceHex = encrypted.nonce.startsWith('0x')
292
+ ? encrypted.nonce.slice(2)
293
+ : encrypted.nonce
294
+ const nonce = hexToBytes(nonceHex)
295
+
296
+ const ciphertextHex = encrypted.ciphertext.startsWith('0x')
297
+ ? encrypted.ciphertext.slice(2)
298
+ : encrypted.ciphertext
299
+ const ciphertext = hexToBytes(ciphertextHex)
300
+
301
+ // Decrypt with XChaCha20-Poly1305
302
+ // This will throw if authentication fails (wrong key or tampered data)
303
+ const cipher = xchacha20poly1305(key, nonce)
304
+ let plaintext: Uint8Array
305
+
306
+ try {
307
+ plaintext = cipher.decrypt(ciphertext)
308
+ } catch (e) {
309
+ throw new CryptoError(
310
+ 'Decryption failed - authentication tag verification failed. ' +
311
+ 'Either the viewing key is incorrect or the data has been tampered with.',
312
+ ErrorCode.DECRYPTION_FAILED,
313
+ {
314
+ cause: e instanceof Error ? e : undefined,
315
+ operation: 'decryptWithViewing',
316
+ }
317
+ )
318
+ }
319
+
320
+ // Parse JSON
321
+ const textDecoder = new TextDecoder()
322
+ const jsonString = textDecoder.decode(plaintext)
323
+
324
+ // Validate size before parsing to prevent DoS
325
+ if (jsonString.length > MAX_TRANSACTION_DATA_SIZE) {
326
+ throw new ValidationError(
327
+ `decrypted data exceeds maximum size limit (${MAX_TRANSACTION_DATA_SIZE} bytes)`,
328
+ 'transactionData',
329
+ { received: jsonString.length, max: MAX_TRANSACTION_DATA_SIZE },
330
+ ErrorCode.INVALID_INPUT
331
+ )
332
+ }
333
+
334
+ try {
335
+ const data = JSON.parse(jsonString) as TransactionData
336
+ // Validate required fields
337
+ if (
338
+ typeof data.sender !== 'string' ||
339
+ typeof data.recipient !== 'string' ||
340
+ typeof data.amount !== 'string' ||
341
+ typeof data.timestamp !== 'number'
342
+ ) {
343
+ throw new ValidationError(
344
+ 'invalid transaction data format',
345
+ 'transactionData',
346
+ { received: data },
347
+ ErrorCode.INVALID_INPUT
348
+ )
349
+ }
350
+ return data
351
+ } catch (e) {
352
+ if (e instanceof SyntaxError) {
353
+ throw new CryptoError(
354
+ 'Decryption succeeded but data is malformed JSON',
355
+ ErrorCode.DECRYPTION_FAILED,
356
+ { cause: e, operation: 'decryptWithViewing' }
357
+ )
358
+ }
359
+ throw e
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Validate privacy level string
365
+ */
366
+ export function isValidPrivacyLevel(level: string): level is PrivacyLevel {
367
+ return ['transparent', 'shielded', 'compliant'].includes(level)
368
+ }
369
+
370
+ /**
371
+ * Get human-readable description of privacy level
372
+ */
373
+ export function getPrivacyDescription(level: PrivacyLevel): string {
374
+ const descriptions: Record<PrivacyLevel, string> = {
375
+ transparent: 'Public transaction - all details visible on-chain',
376
+ shielded: 'Private transaction - sender, amount, and recipient hidden',
377
+ compliant: 'Private with audit - hidden but viewable with key',
378
+ }
379
+ return descriptions[level]
380
+ }
381
+
382
+ // hexToBytes removed - was only needed for mocked XOR encryption
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Proof Providers for SIP Protocol
3
+ *
4
+ * This module provides a pluggable interface for ZK proof generation.
5
+ *
6
+ * ## Available Providers
7
+ *
8
+ * - **MockProofProvider**: For testing only - provides NO cryptographic security
9
+ * - **NoirProofProvider**: Production provider using Noir circuits (coming in #14, #15, #16)
10
+ *
11
+ * ## Usage
12
+ *
13
+ * ```typescript
14
+ * import { MockProofProvider, NoirProofProvider } from '@sip-protocol/sdk'
15
+ *
16
+ * // For testing
17
+ * const mockProvider = new MockProofProvider()
18
+ * await mockProvider.initialize()
19
+ *
20
+ * // For production (when available)
21
+ * const noirProvider = new NoirProofProvider()
22
+ * await noirProvider.initialize()
23
+ *
24
+ * // Use with SIP client
25
+ * const sip = new SIP({
26
+ * network: 'testnet',
27
+ * proofProvider: noirProvider,
28
+ * })
29
+ * ```
30
+ *
31
+ * @module proofs
32
+ */
33
+
34
+ // Interface and types
35
+ export type {
36
+ ProofProvider,
37
+ ProofFramework,
38
+ FundingProofParams,
39
+ ValidityProofParams,
40
+ FulfillmentProofParams,
41
+ OracleAttestation,
42
+ ProofResult,
43
+ } from './interface'
44
+
45
+ export { ProofGenerationError } from './interface'
46
+
47
+ // Mock provider (testing only)
48
+ export { MockProofProvider } from './mock'
49
+
50
+ // Noir provider (production)
51
+ export { NoirProofProvider } from './noir'
52
+ export type { NoirProviderConfig } from './noir'
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Proof Provider Interface
3
+ *
4
+ * Defines a pluggable interface for ZK proof generation and verification.
5
+ * This allows different backends (Noir, mock for testing) to be swapped.
6
+ *
7
+ * @see docs/specs/ZK-ARCHITECTURE.md for framework decision (Noir)
8
+ */
9
+
10
+ import type { ZKProof, Commitment, HexString } from '@sip-protocol/types'
11
+
12
+ /**
13
+ * Supported proof framework types
14
+ */
15
+ export type ProofFramework = 'noir' | 'mock'
16
+
17
+ /**
18
+ * Parameters for generating a Funding Proof
19
+ *
20
+ * Proves: balance >= minimumRequired without revealing balance
21
+ *
22
+ * @see docs/specs/FUNDING-PROOF.md
23
+ */
24
+ export interface FundingProofParams {
25
+ /** User's actual balance (private) */
26
+ balance: bigint
27
+ /** Minimum amount required for the intent (public) */
28
+ minimumRequired: bigint
29
+ /** Blinding factor for the commitment (private) */
30
+ blindingFactor: Uint8Array
31
+ /** Asset identifier (public) */
32
+ assetId: string
33
+ /** User's address for ownership proof (private) */
34
+ userAddress: string
35
+ /** Signature proving ownership of the address (private) */
36
+ ownershipSignature: Uint8Array
37
+ }
38
+
39
+ /**
40
+ * Parameters for generating a Validity Proof
41
+ *
42
+ * Proves: intent is authorized by sender without revealing sender
43
+ *
44
+ * @see docs/specs/VALIDITY-PROOF.md
45
+ */
46
+ export interface ValidityProofParams {
47
+ /** Hash of the intent (public) */
48
+ intentHash: HexString
49
+ /** Sender's address (private) */
50
+ senderAddress: string
51
+ /** Blinding factor for sender commitment (private) */
52
+ senderBlinding: Uint8Array
53
+ /** Sender's secret key (private) */
54
+ senderSecret: Uint8Array
55
+ /** Signature authorizing the intent (private) */
56
+ authorizationSignature: Uint8Array
57
+ /** Nonce for nullifier generation (private) */
58
+ nonce: Uint8Array
59
+ /** Intent timestamp (public) */
60
+ timestamp: number
61
+ /** Intent expiry (public) */
62
+ expiry: number
63
+ }
64
+
65
+ /**
66
+ * Parameters for generating a Fulfillment Proof
67
+ *
68
+ * Proves: solver delivered output >= minimum to correct recipient
69
+ *
70
+ * @see docs/specs/FULFILLMENT-PROOF.md
71
+ */
72
+ export interface FulfillmentProofParams {
73
+ /** Hash of the original intent (public) */
74
+ intentHash: HexString
75
+ /** Actual output amount delivered (private) */
76
+ outputAmount: bigint
77
+ /** Blinding factor for output commitment (private) */
78
+ outputBlinding: Uint8Array
79
+ /** Minimum required output from intent (public) */
80
+ minOutputAmount: bigint
81
+ /** Recipient's stealth address (public) */
82
+ recipientStealth: HexString
83
+ /** Solver's identifier (public) */
84
+ solverId: string
85
+ /** Solver's secret for authorization (private) */
86
+ solverSecret: Uint8Array
87
+ /** Oracle attestation of delivery (private) */
88
+ oracleAttestation: OracleAttestation
89
+ /** Time of fulfillment (public) */
90
+ fulfillmentTime: number
91
+ /** Intent expiry (public) */
92
+ expiry: number
93
+ }
94
+
95
+ /**
96
+ * Oracle attestation for cross-chain verification
97
+ */
98
+ export interface OracleAttestation {
99
+ /** Recipient who received funds */
100
+ recipient: HexString
101
+ /** Amount received */
102
+ amount: bigint
103
+ /** Transaction hash on destination chain */
104
+ txHash: HexString
105
+ /** Block number containing the transaction */
106
+ blockNumber: bigint
107
+ /** Oracle signature (threshold signature for multi-oracle) */
108
+ signature: Uint8Array
109
+ }
110
+
111
+ /**
112
+ * Result of proof generation
113
+ */
114
+ export interface ProofResult {
115
+ /** The generated proof */
116
+ proof: ZKProof
117
+ /** Public inputs used in the proof */
118
+ publicInputs: HexString[]
119
+ /** Commitment (if generated as part of proof) */
120
+ commitment?: Commitment
121
+ }
122
+
123
+ /**
124
+ * Proof Provider Interface
125
+ *
126
+ * Implementations of this interface provide ZK proof generation and verification.
127
+ * The SDK uses this interface to remain agnostic to the underlying ZK framework.
128
+ *
129
+ * @example
130
+ * ```typescript
131
+ * // Use mock provider for testing
132
+ * const mockProvider = new MockProofProvider()
133
+ *
134
+ * // Use Noir provider for production
135
+ * const noirProvider = new NoirProofProvider()
136
+ *
137
+ * // Configure SIP client with provider
138
+ * const sip = new SIP({
139
+ * network: 'testnet',
140
+ * proofProvider: noirProvider,
141
+ * })
142
+ * ```
143
+ */
144
+ export interface ProofProvider {
145
+ /**
146
+ * The ZK framework this provider uses
147
+ */
148
+ readonly framework: ProofFramework
149
+
150
+ /**
151
+ * Whether the provider is ready to generate proofs
152
+ * (e.g., circuits compiled, keys loaded)
153
+ */
154
+ readonly isReady: boolean
155
+
156
+ /**
157
+ * Initialize the provider (compile circuits, load keys, etc.)
158
+ *
159
+ * @throws Error if initialization fails
160
+ */
161
+ initialize(): Promise<void>
162
+
163
+ /**
164
+ * Generate a Funding Proof
165
+ *
166
+ * Proves that the user has sufficient balance without revealing the exact amount.
167
+ *
168
+ * @param params - Funding proof parameters
169
+ * @returns The generated proof with public inputs
170
+ * @throws ProofGenerationError if proof generation fails
171
+ *
172
+ * @see docs/specs/FUNDING-PROOF.md (~22,000 constraints)
173
+ */
174
+ generateFundingProof(params: FundingProofParams): Promise<ProofResult>
175
+
176
+ /**
177
+ * Generate a Validity Proof
178
+ *
179
+ * Proves that the intent is authorized without revealing the sender.
180
+ *
181
+ * @param params - Validity proof parameters
182
+ * @returns The generated proof with public inputs
183
+ * @throws ProofGenerationError if proof generation fails
184
+ *
185
+ * @see docs/specs/VALIDITY-PROOF.md (~72,000 constraints)
186
+ */
187
+ generateValidityProof(params: ValidityProofParams): Promise<ProofResult>
188
+
189
+ /**
190
+ * Generate a Fulfillment Proof
191
+ *
192
+ * Proves that the solver correctly delivered the output.
193
+ *
194
+ * @param params - Fulfillment proof parameters
195
+ * @returns The generated proof with public inputs
196
+ * @throws ProofGenerationError if proof generation fails
197
+ *
198
+ * @see docs/specs/FULFILLMENT-PROOF.md (~22,000 constraints)
199
+ */
200
+ generateFulfillmentProof(params: FulfillmentProofParams): Promise<ProofResult>
201
+
202
+ /**
203
+ * Verify a proof
204
+ *
205
+ * @param proof - The proof to verify
206
+ * @returns true if the proof is valid, false otherwise
207
+ */
208
+ verifyProof(proof: ZKProof): Promise<boolean>
209
+ }
210
+
211
+ /**
212
+ * Error thrown when proof generation fails
213
+ */
214
+ export class ProofGenerationError extends Error {
215
+ readonly proofType: 'funding' | 'validity' | 'fulfillment'
216
+ readonly cause?: Error
217
+
218
+ constructor(
219
+ proofType: 'funding' | 'validity' | 'fulfillment',
220
+ message: string,
221
+ cause?: Error,
222
+ ) {
223
+ super(`${proofType} proof generation failed: ${message}`)
224
+ this.name = 'ProofGenerationError'
225
+ this.proofType = proofType
226
+ this.cause = cause
227
+ }
228
+ }