sol-parser-sdk-python 0.5.7__py3-none-any.whl → 0.5.9__py3-none-any.whl

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.
sol_parser/__init__.py CHANGED
@@ -152,6 +152,11 @@ from .event_types import (
152
152
  MeteoraDammV2AddLiquidityEvent,
153
153
  MeteoraDammV2RemoveLiquidityEvent,
154
154
  MeteoraDammV2InitializePoolEvent,
155
+ MeteoraDammV2DynamicFeeParameters,
156
+ MeteoraDammV2UpdateDelegatePermissionEvent,
157
+ MeteoraDammV2WithdrawDeadLiquidityRewardEvent,
158
+ MeteoraDammV2CreateConfigEvent,
159
+ MeteoraDammV2CreateDynamicConfigEvent,
155
160
  MeteoraDbcCurveCompleteEvent,
156
161
  MeteoraDbcInitializePoolEvent,
157
162
  MeteoraDbcSwapEvent,
@@ -432,6 +437,11 @@ __all__ = [
432
437
  "MeteoraDammV2AddLiquidityEvent",
433
438
  "MeteoraDammV2RemoveLiquidityEvent",
434
439
  "MeteoraDammV2InitializePoolEvent",
440
+ "MeteoraDammV2DynamicFeeParameters",
441
+ "MeteoraDammV2UpdateDelegatePermissionEvent",
442
+ "MeteoraDammV2WithdrawDeadLiquidityRewardEvent",
443
+ "MeteoraDammV2CreateConfigEvent",
444
+ "MeteoraDammV2CreateDynamicConfigEvent",
435
445
  "MeteoraDbcCurveCompleteEvent",
436
446
  "MeteoraDbcInitializePoolEvent",
437
447
  "MeteoraDbcSwapEvent",
@@ -456,3 +466,27 @@ __all__ = [
456
466
  "ShredStreamClient",
457
467
  "ShredStreamConfig",
458
468
  ]
469
+
470
+ # Native route and subscription state APIs aligned with Rust 0.7.7.
471
+ from .transaction_route import (analyze_rpc_transaction_routes, TransactionRoute, RouteSwapLeg,
472
+ RouteTokenTransfer, RouteUnknownInvocation, RouteNativeTokenAction, InstructionPosition,
473
+ stonkfun_mode_from_platform_config)
474
+ from .stonkfun_registry import StonkFunPoolRegistry, StonkFunGraduatedPool
475
+ from .liquidity_snapshot import RawAccountSnapshotEvent, LiquidityAccountSnapshotEvent, parse_liquidity_account
476
+ from .wire_transaction import decode_wire_transaction
477
+
478
+ from .simulation_route import analyze_simulation_routes
479
+
480
+ __all__.append("analyze_simulation_routes")
481
+
482
+ from .block_meta import BlockMetaEvent, parse_block_meta_update
483
+ from .grpc.subscribe_builder import build_subscribe_request_with_event_filter
484
+
485
+ from .event_types import (
486
+ LaunchLabPoolCreateEvent, StonkFunPoolCreateEvent,
487
+ LaunchLabTradeEvent, StonkFunTradeEvent,
488
+ )
489
+ __all__ += ["LaunchLabPoolCreateEvent", "StonkFunPoolCreateEvent", "LaunchLabTradeEvent", "StonkFunTradeEvent"]
490
+
491
+ from .grpc_client import DexEventQueue, GrpcStreamStatus
492
+ __all__ += ["DexEventQueue", "GrpcStreamStatus"]
@@ -82,11 +82,26 @@ def fill_accounts_with_owned_keys(
82
82
  w = [bytes(x) for x in meta_pb.loaded_writable_addresses]
83
83
  r = [bytes(x) for x in meta_pb.loaded_readonly_addresses]
84
84
 
85
- def run(program_b58: str, filler) -> None:
85
+ def run(program_b58: str, filler, anchor=None, unambiguous=False) -> None:
86
86
  pid = base58.b58decode(program_b58)
87
87
  inv = invokes.get(pid)
88
88
  if not inv:
89
89
  return
90
+ if anchor is not None or unambiguous:
91
+ pool, index = anchor or (None,1)
92
+ has_anchor=pool and pool != "11111111111111111111111111111111"
93
+ if not has_anchor and not unambiguous:return
94
+ selected = None
95
+ for invocation in inv:
96
+ candidate = get_instruction_account_getter(meta_pb, transaction_pb, static_keys, w, r, invocation)
97
+ if candidate is None or (has_anchor and candidate(index) != pool):
98
+ continue
99
+ if selected is not None and any(selected(i) != candidate(i) for i in range(18)):
100
+ return # Same pool, conflicting wallet/account context: do not guess.
101
+ selected = candidate
102
+ if selected is not None:
103
+ filler(selected)
104
+ return
90
105
  ix = find_instruction_invoke(inv, meta_pb, transaction_pb)
91
106
  if ix is None:
92
107
  return
@@ -146,7 +161,7 @@ def fill_accounts_with_owned_keys(
146
161
  elif et == EventType.RAYDIUM_CPMM_INITIALIZE:
147
162
  run(RAYDIUM_CPMM_PROGRAM_ID, lambda g: raydium.fill_cpmm_initialize_accounts(data, g))
148
163
  elif et == EventType.RAYDIUM_AMM_V4_SWAP:
149
- run(RAYDIUM_AMM_V4_PROGRAM_ID, lambda g: raydium.fill_amm_v4_swap_accounts(data, g))
164
+ run(RAYDIUM_AMM_V4_PROGRAM_ID, lambda g: raydium.fill_amm_v4_swap_accounts(data, g), (data.amm,1), True)
150
165
  elif et == EventType.RAYDIUM_AMM_V4_DEPOSIT:
151
166
  run(RAYDIUM_AMM_V4_PROGRAM_ID, lambda g: raydium.fill_amm_v4_deposit_accounts(data, g))
152
167
  elif et == EventType.RAYDIUM_AMM_V4_WITHDRAW:
@@ -182,9 +197,9 @@ def fill_accounts_with_owned_keys(
182
197
  elif et == EventType.METEORA_DLMM_REMOVE_LIQUIDITY:
183
198
  run(METEORA_DLMM_PROGRAM_ID, lambda g: meteora.fill_dlmm_remove_liquidity_accounts(data, g))
184
199
  elif et == EventType.RAYDIUM_LAUNCHLAB_TRADE:
185
- run(RAYDIUM_LAUNCHLAB_PROGRAM_ID, lambda g: raydium_launchlab.fill_trade_accounts(data, g))
200
+ run(RAYDIUM_LAUNCHLAB_PROGRAM_ID, lambda g: raydium_launchlab.fill_trade_accounts(data, g), (data.pool_state,4))
186
201
  elif et == EventType.RAYDIUM_LAUNCHLAB_POOL_CREATE:
187
- run(RAYDIUM_LAUNCHLAB_PROGRAM_ID, lambda g: raydium_launchlab.fill_pool_create_accounts(data, g))
202
+ run(RAYDIUM_LAUNCHLAB_PROGRAM_ID, lambda g: raydium_launchlab.fill_pool_create_accounts(data, g), (data.pool_state,5))
188
203
 
189
204
 
190
205
  def fill_data(
@@ -209,3 +224,82 @@ def fill_data(
209
224
  return
210
225
  is_pump = raw[9] != 0
211
226
  setattr(data, "is_pump_pool", is_pump)
227
+
228
+
229
+ def _account_index(transaction_pb: Any, meta_pb: Any, account: str) -> Optional[int]:
230
+ if not account or account == "11111111111111111111111111111111":
231
+ return None
232
+ msg = getattr(transaction_pb, "message", None)
233
+ if msg is None:
234
+ return None
235
+ keys: List[str] = []
236
+ for b in getattr(msg, "account_keys", []) or []:
237
+ raw = bytes(b) if not isinstance(b, (bytes, bytearray)) else bytes(b)
238
+ if len(raw) == 32:
239
+ keys.append(base58.b58encode(raw).decode("ascii"))
240
+ for b in getattr(meta_pb, "loaded_writable_addresses", []) or []:
241
+ raw = bytes(b) if not isinstance(b, (bytes, bytearray)) else bytes(b)
242
+ if len(raw) == 32:
243
+ keys.append(base58.b58encode(raw).decode("ascii"))
244
+ for b in getattr(meta_pb, "loaded_readonly_addresses", []) or []:
245
+ raw = bytes(b) if not isinstance(b, (bytes, bytearray)) else bytes(b)
246
+ if len(raw) == 32:
247
+ keys.append(base58.b58encode(raw).decode("ascii"))
248
+ try:
249
+ return keys.index(account)
250
+ except ValueError:
251
+ return None
252
+
253
+
254
+ def _token_balance_raw(t: Any) -> int:
255
+ ui = getattr(t, "ui_token_amount", None)
256
+ if ui is None:
257
+ return 0
258
+ amt = getattr(ui, "amount", None)
259
+ if amt is None:
260
+ return 0
261
+ try:
262
+ return int(str(amt))
263
+ except Exception:
264
+ return 0
265
+
266
+
267
+ def fill_token_balances(event: DexEvent, meta_pb: Any, transaction_pb: Any) -> None:
268
+ """对齐 Rust ``common_filler::fill_token_balances``(PumpFun trade 用户余额)。"""
269
+ if meta_pb is None or transaction_pb is None:
270
+ return
271
+ if event.type not in (
272
+ EventType.PUMP_FUN_TRADE,
273
+ EventType.PUMP_FUN_BUY,
274
+ EventType.PUMP_FUN_SELL,
275
+ EventType.PUMP_FUN_BUY_EXACT_SOL_IN,
276
+ ):
277
+ return
278
+ trade = event.data
279
+ user = getattr(trade, "user", "") or ""
280
+ associated_user = getattr(trade, "associated_user", "") or ""
281
+ user_idx = _account_index(transaction_pb, meta_pb, user)
282
+ if user_idx is not None:
283
+ pre_balances = list(getattr(meta_pb, "pre_balances", []) or [])
284
+ post_balances = list(getattr(meta_pb, "post_balances", []) or [])
285
+ if user_idx < len(pre_balances):
286
+ trade.pre_sol_balance = int(pre_balances[user_idx])
287
+ if user_idx < len(post_balances):
288
+ trade.post_sol_balance = int(post_balances[user_idx])
289
+ token_idx = _account_index(transaction_pb, meta_pb, associated_user)
290
+ if token_idx is None:
291
+ return
292
+ token_idx_u32 = token_idx
293
+ pre = None
294
+ post = None
295
+ for bal in getattr(meta_pb, "pre_token_balances", []) or []:
296
+ if int(getattr(bal, "account_index", -1)) == token_idx_u32:
297
+ pre = _token_balance_raw(bal)
298
+ break
299
+ for bal in getattr(meta_pb, "post_token_balances", []) or []:
300
+ if int(getattr(bal, "account_index", -1)) == token_idx_u32:
301
+ post = _token_balance_raw(bal)
302
+ break
303
+ if pre is not None or post is not None:
304
+ trade.pre_token_balance = pre if pre is not None else 0
305
+ trade.post_token_balance = post if post is not None else 0
@@ -54,8 +54,16 @@ def fill_pools_remove_liquidity_accounts(_e: Any, _get: AccountGetter) -> None:
54
54
  pass
55
55
 
56
56
 
57
- def fill_dlmm_swap_accounts(_e: Any, _get: AccountGetter) -> None:
58
- pass
57
+ def fill_dlmm_swap_accounts(e: Any, get: AccountGetter) -> None:
58
+ z = "11111111111111111111111111111111"
59
+ if not getattr(e, "user_token_in", "") or e.user_token_in == z:
60
+ e.user_token_in = get(4)
61
+ if not getattr(e, "user_token_out", "") or e.user_token_out == z:
62
+ e.user_token_out = get(5)
63
+ if not getattr(e, "token_x_mint", "") or e.token_x_mint == z:
64
+ e.token_x_mint = get(6)
65
+ if not getattr(e, "token_y_mint", "") or e.token_y_mint == z:
66
+ e.token_y_mint = get(7)
59
67
 
60
68
 
61
69
  def fill_dlmm_add_liquidity_accounts(_e: Any, _get: AccountGetter) -> None:
@@ -77,8 +77,9 @@ def fill_clmm_close_position_accounts(e: RaydiumClmmClosePositionEvent, get: Acc
77
77
  e.position_nft_mint = get(1)
78
78
 
79
79
 
80
- def fill_cpmm_swap_accounts(_e: RaydiumCpmmSwapEvent, _get: AccountGetter) -> None:
81
- pass
80
+ def fill_cpmm_swap_accounts(e: RaydiumCpmmSwapEvent, get: AccountGetter) -> None:
81
+ if _empty(e.pool_id):
82
+ e.pool_id = get(3)
82
83
 
83
84
 
84
85
  def fill_cpmm_deposit_accounts(e: RaydiumCpmmDepositEvent, get: AccountGetter) -> None:
@@ -99,8 +100,12 @@ def fill_cpmm_initialize_accounts(e: RaydiumCpmmInitializeEvent, get: AccountGet
99
100
 
100
101
 
101
102
  def fill_amm_v4_swap_accounts(e: RaydiumAmmV4SwapEvent, get: AccountGetter) -> None:
102
- if _empty(e.amm):
103
- e.amm = get(1)
103
+ if _empty(e.amm):e.amm=get(1)
104
+ modern=_empty(get(8)) and not _empty(get(7))
105
+ if modern:
106
+ for field,index in [('token_program',0),('amm_authority',2),('pool_coin_token_account',3),('pool_pc_token_account',4),('user_source_token_account',5),('user_destination_token_account',6),('user_source_owner',7)]:
107
+ if _empty(getattr(e,field)):setattr(e,field,get(index))
108
+ elif _empty(e.user_source_owner):e.user_source_owner=get(16) if _empty(get(17)) else get(17)
104
109
 
105
110
 
106
111
  def fill_amm_v4_deposit_accounts(e: RaydiumAmmV4DepositEvent, get: AccountGetter) -> None:
@@ -7,6 +7,7 @@ from typing import Callable
7
7
  from ..event_types import RaydiumLaunchlabPoolCreateEvent, RaydiumLaunchlabTradeEvent
8
8
 
9
9
  Z = "11111111111111111111111111111111"
10
+ RAYDIUM_LAUNCHLAB_PROGRAM_ID = "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj"
10
11
 
11
12
 
12
13
  def _empty(s: str) -> bool:
@@ -21,6 +22,30 @@ def fill_trade_accounts(e: RaydiumLaunchlabTradeEvent, get: AccountGetter) -> No
21
22
  e.user = get(0)
22
23
  if _empty(e.pool_state):
23
24
  e.pool_state = get(4)
25
+ if _empty(e.global_config):
26
+ e.global_config = get(2)
27
+ if _empty(e.platform_config):
28
+ e.platform_config = get(3)
29
+ if _empty(e.user_base_token):
30
+ e.user_base_token = get(5)
31
+ if _empty(e.user_quote_token):
32
+ e.user_quote_token = get(6)
33
+ if _empty(e.base_vault):
34
+ e.base_vault = get(7)
35
+ if _empty(e.quote_vault):
36
+ e.quote_vault = get(8)
37
+ if _empty(e.base_mint):
38
+ e.base_mint = get(9)
39
+ if _empty(e.quote_mint):
40
+ e.quote_mint = get(10)
41
+ if _empty(e.base_token_program):
42
+ e.base_token_program = get(11)
43
+ if _empty(e.quote_token_program):
44
+ e.quote_token_program = get(12)
45
+
46
+ for name, index in [("system_program", 15), ("platform_associated_account", 16), ("creator_associated_account", 17)]:
47
+ if _empty(getattr(e,name,"")):
48
+ setattr(e,name,get(index))
24
49
 
25
50
 
26
51
  def fill_pool_create_accounts(e: RaydiumLaunchlabPoolCreateEvent, get: AccountGetter) -> None:
@@ -28,3 +53,32 @@ def fill_pool_create_accounts(e: RaydiumLaunchlabPoolCreateEvent, get: AccountGe
28
53
  e.pool_state = get(5)
29
54
  if _empty(e.creator):
30
55
  e.creator = get(1)
56
+ if _empty(e.payer):
57
+ e.payer = get(0)
58
+ if _empty(e.global_config):
59
+ e.global_config = get(2)
60
+ if _empty(e.platform_config):
61
+ e.platform_config = get(3)
62
+ if _empty(e.base_mint):
63
+ e.base_mint = get(6)
64
+ if _empty(e.quote_mint):
65
+ e.quote_mint = get(7)
66
+ if _empty(e.base_vault):
67
+ e.base_vault = get(8)
68
+ if _empty(e.quote_vault):
69
+ e.quote_vault = get(9)
70
+ # initialize / initialize_v2 use token-program indices 11-12;
71
+ # initialize_with_token_2022 uses 10-11 (detect via program id at end).
72
+ try:
73
+ if get(17) == RAYDIUM_LAUNCHLAB_PROGRAM_ID:
74
+ bi, qi = 11, 12
75
+ elif get(14) == RAYDIUM_LAUNCHLAB_PROGRAM_ID:
76
+ bi, qi = 10, 11
77
+ else:
78
+ return
79
+ except Exception:
80
+ return
81
+ if _empty(e.base_token_program):
82
+ e.base_token_program = get(bi)
83
+ if _empty(e.quote_token_program):
84
+ e.quote_token_program = get(qi)
@@ -42,10 +42,15 @@ TOKEN_ACCOUNT_SIZE = 165
42
42
  NONCE_ACCOUNT_SIZE = 80
43
43
  PUMPFUN_GLOBAL_BODY = 1037
44
44
  PUMPFUN_BONDING_CURVE_BODY = 107
45
+ PUMPFUN_BONDING_CURVE_CREATOR_FEE_BODY = 116
46
+ PUMPFUN_BONDING_CURVE_HOLDER_REWARD_BODY = 117
45
47
  PUMPFUN_GLOBAL_VOLUME_ACCUMULATOR_BODY = 536
46
48
  PUMPFUN_USER_VOLUME_ACCUMULATOR_BODY = 98
47
49
  GLOBAL_CONFIG_BODY = 634
48
- POOL_BODY = 244
50
+ POOL_LEGACY_BODY = 244
51
+ POOL_BOOST_BODY = 253
52
+ POOL_CREATOR_FEE_BODY = 262
53
+ POOL_BODY = 263
49
54
  MAX_PUMPFUN_FEE_TIERS = 64
50
55
  MAX_PUMPFUN_SHAREHOLDERS = 64
51
56
 
@@ -201,6 +206,11 @@ def parse_account_unified(
201
206
  event_type_filter: Optional[EventTypeFilter] = None,
202
207
  ) -> Optional[DexEvent]:
203
208
  """对齐 Rust ``parse_account_unified``(含 ``Option<EventTypeFilter>`` 语义)。"""
209
+ from ..liquidity_snapshot import parse_liquidity_account
210
+ if event_type_filter is None or event_type_filter.should_include(EventType.ACCOUNT_LIQUIDITY_SNAPSHOT):
211
+ snapshot = parse_liquidity_account(account, metadata)
212
+ if snapshot is not None:
213
+ return DexEvent(type=EventType.ACCOUNT_LIQUIDITY_SNAPSHOT, data=snapshot)
204
214
  data = account.data
205
215
  if not data:
206
216
  return None
@@ -483,6 +493,12 @@ def _parse_pumpfun_bonding_curve_fast(
483
493
  is_cashback_coin = data[o] != 0
484
494
  o += 1
485
495
  quote_mint = read_pubkey_fast(data, o)
496
+ o += 32
497
+ creator_fee_bps = read_u64_fast(data, o) if o + 8 <= len(data) else 0
498
+ o += 8
499
+ can_edit_creator_fee = data[o] != 0 if o < len(data) else False
500
+ o += 1
501
+ is_holder_reward = data[o] != 0 if o < len(data) else False
486
502
  return _account_event(
487
503
  EventType.ACCOUNT_PUMP_FUN_BONDING_CURVE,
488
504
  {
@@ -499,6 +515,9 @@ def _parse_pumpfun_bonding_curve_fast(
499
515
  "is_mayhem_mode": is_mayhem_mode,
500
516
  "is_cashback_coin": is_cashback_coin,
501
517
  "quote_mint": quote_mint,
518
+ "creator_fee_bps": creator_fee_bps,
519
+ "can_edit_creator_fee": can_edit_creator_fee,
520
+ "is_holder_reward": is_holder_reward,
502
521
  },
503
522
  },
504
523
  )
@@ -752,6 +771,18 @@ def _parse_pumpswap_pool_fast(account: AccountData, metadata: EventMetadata) ->
752
771
  o += 32
753
772
  is_mayhem = data[o] != 0
754
773
  is_cashback = data[o + 1] != 0
774
+ o += 2
775
+ virtual_quote_reserves = (
776
+ int.from_bytes(data[o : o + 16], "little", signed=True)
777
+ if len(data) >= POOL_BOOST_BODY
778
+ else 0
779
+ )
780
+ o += 16
781
+ creator_fee_bps = read_u64_fast(data, o) if o + 8 <= len(data) else 0
782
+ o += 8
783
+ can_edit_creator_fee = data[o] != 0 if o < len(data) else False
784
+ o += 1
785
+ is_holder_reward = data[o] != 0 if o < len(data) else False
755
786
  return _account_event(
756
787
  EventType.ACCOUNT_PUMP_SWAP_POOL,
757
788
  {
@@ -770,6 +801,10 @@ def _parse_pumpswap_pool_fast(account: AccountData, metadata: EventMetadata) ->
770
801
  "coin_creator": coin_creator,
771
802
  "is_mayhem_mode": is_mayhem,
772
803
  "is_cashback_coin": is_cashback,
804
+ "virtual_quote_reserves": virtual_quote_reserves,
805
+ "creator_fee_bps": creator_fee_bps,
806
+ "can_edit_creator_fee": can_edit_creator_fee,
807
+ "is_holder_reward": is_holder_reward,
773
808
  },
774
809
  },
775
810
  )
@@ -807,6 +842,13 @@ def parse_pumpfun_bonding_curve(
807
842
  ) -> Optional[DexEvent]:
808
843
  if len(account.data) < 8 + PUMPFUN_BONDING_CURVE_BODY:
809
844
  return None
845
+ body_len = len(account.data) - 8
846
+ if (
847
+ body_len != PUMPFUN_BONDING_CURVE_BODY
848
+ and body_len != PUMPFUN_BONDING_CURVE_CREATOR_FEE_BODY
849
+ and body_len < PUMPFUN_BONDING_CURVE_HOLDER_REWARD_BODY
850
+ ):
851
+ return None
810
852
  if not has_discriminator(account.data, _DISC_PUMPFUN_BONDING_CURVE):
811
853
  return None
812
854
  return _parse_pumpfun_bonding_curve_fast(account, metadata)
@@ -861,7 +903,15 @@ def parse_pumpswap_global_config(account: AccountData, metadata: EventMetadata)
861
903
 
862
904
 
863
905
  def parse_pumpswap_pool(account: AccountData, metadata: EventMetadata) -> Optional[DexEvent]:
864
- if len(account.data) < 8 + POOL_BODY:
906
+ if len(account.data) < 8 + POOL_LEGACY_BODY:
907
+ return None
908
+ body_len = len(account.data) - 8
909
+ if (
910
+ body_len != POOL_LEGACY_BODY
911
+ and body_len != POOL_BOOST_BODY
912
+ and body_len != POOL_CREATOR_FEE_BODY
913
+ and body_len < POOL_BODY
914
+ ):
865
915
  return None
866
916
  if not has_discriminator(account.data, _DISC_POOL):
867
917
  return None
@@ -0,0 +1,29 @@
1
+ """Local BlockMeta adapter, matching Rust's saturated timestamp conversion."""
2
+
3
+ from dataclasses import dataclass
4
+ from .grpc_types import EventType, EventMetadata
5
+ from .event_types import DexEvent
6
+
7
+
8
+ @dataclass
9
+ class BlockMetaEvent:
10
+ metadata: EventMetadata
11
+
12
+
13
+ def parse_block_meta_update(meta, grpc_recv_us, fallback_block_us=None):
14
+ block_us = fallback_block_us or 0
15
+ if meta.block_time is not None:
16
+ block_us = max(-(1 << 63), min((1 << 63) - 1, meta.block_time * 1_000_000))
17
+ return DexEvent(
18
+ EventType.BLOCK_META,
19
+ BlockMetaEvent(
20
+ EventMetadata(
21
+ signature="1" * 64,
22
+ slot=meta.slot,
23
+ tx_index=0,
24
+ block_time_us=block_us,
25
+ grpc_recv_us=grpc_recv_us,
26
+ recent_blockhash=meta.blockhash,
27
+ )
28
+ ),
29
+ )