sol-parser-sdk-python 0.5.8__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.
@@ -0,0 +1,510 @@
1
+ """Native instruction-level route analysis, aligned with Rust parser 0.7.7.
2
+
3
+ Accepts JSON-encoded RPC transactions. This is execution evidence, not a quote
4
+ or automatic route selector. Unknown invocations and failed intent remain visible.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ from dataclasses import dataclass, field, asdict
9
+ from typing import Any, Iterable, Optional
10
+ import base58
11
+ import base64
12
+ from .wire_transaction import decode_wire_transaction
13
+
14
+ ZERO = "11111111111111111111111111111111"
15
+ TOKEN = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
16
+ TOKEN_2022 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
17
+ WSOL = "So11111111111111111111111111111111111111112"
18
+ PROGRAMS = {
19
+ 'PumpFun': '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P',
20
+ "MeteoraDammV2": "cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG",
21
+ "RaydiumClmm": "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK",
22
+ "RaydiumCpmm": "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C",
23
+ "RaydiumAmmV4": "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8",
24
+ "LaunchLab": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj",
25
+ "OrcaWhirlpool": "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc",
26
+ "MeteoraDlmm": "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo",
27
+ "PumpSwap": "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA",
28
+ }
29
+ STANDARD = "4E876qZTE9FJMrBzgVtBrSrzz2TLivB5Y5QXPjB4gZL7"
30
+ REWARD = "6BwHHDg3u1854jC8PDLXvR4spTcLNaoBxLJNGC4nTESt"
31
+ U64_MAX = (1 << 64) - 1
32
+
33
+
34
+ def stonkfun_mode_from_platform_config(key: str) -> Optional[str]:
35
+ return "Standard" if key == STANDARD else "Reward" if key == REWARD else None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class InstructionPosition:
40
+ outer_index: int
41
+ inner_index: Optional[int]
42
+ stack_height: Optional[int]
43
+
44
+
45
+ @dataclass
46
+ class RouteSwapLeg:
47
+ position: InstructionPosition
48
+ program: str
49
+ protocol: str
50
+ pool: str
51
+ trader: str
52
+ input_account: str
53
+ output_account: str
54
+ input_mint: Optional[str]
55
+ output_mint: Optional[str]
56
+ amount_specified_is_input: bool
57
+ specified_amount: int
58
+ other_amount_threshold: int
59
+ actual_input_amount: Optional[int] = None
60
+ actual_output_amount: Optional[int] = None
61
+ stonkfun_mode: Optional[str] = None
62
+ stonkfun_graduated: bool = False
63
+
64
+
65
+ @dataclass
66
+ class RouteTokenTransfer:
67
+ position: InstructionPosition
68
+ program: str
69
+ source: str
70
+ destination: str
71
+ mint: Optional[str]
72
+ amount: int
73
+ withheld_fee: Optional[int]
74
+
75
+
76
+ @dataclass
77
+ class RouteUnknownInvocation:
78
+ position: InstructionPosition
79
+ program: str
80
+ has_token_transfers: bool
81
+ has_known_swap_descendants: bool
82
+
83
+
84
+ @dataclass
85
+ class RouteNativeTokenAction:
86
+ position: InstructionPosition
87
+ account: str
88
+ action: Any
89
+
90
+
91
+ @dataclass
92
+ class TransactionRoute:
93
+ signature: str
94
+ succeeded: bool
95
+ legs: list[RouteSwapLeg] = field(default_factory=list)
96
+ transfers: list[RouteTokenTransfer] = field(default_factory=list)
97
+ native_token_actions: list[RouteNativeTokenAction] = field(default_factory=list)
98
+ unknown_invocations: list[RouteUnknownInvocation] = field(default_factory=list)
99
+
100
+ def to_dict(self) -> dict:
101
+ return asdict(self)
102
+
103
+
104
+ @dataclass
105
+ class _Invocation:
106
+ position: InstructionPosition
107
+ program: str
108
+ accounts: list[str]
109
+ data: bytes
110
+
111
+ def a(self, i: int) -> str:
112
+ return self.accounts[i] if 0 <= i < len(self.accounts) else ZERO
113
+
114
+
115
+ def _disc(values: list[int]) -> bytes:
116
+ return bytes(values)
117
+
118
+
119
+ def _pumpfun_native_debit(ix, children, leg):
120
+ # Only immediate protocol payments count; exclude rent and wrapping.
121
+ if leg.protocol!='PumpFun' or len(ix.accounts)<17 or ix.a(2)!=WSOL or leg.input_account!=leg.trader:
122
+ return None
123
+ recipients={ix.a(i) for i in (6,8,10,16)}
124
+ depth=ix.position.stack_height
125
+ if depth is None:return None
126
+ total=0;paid_pool=False
127
+ for child in children:
128
+ if child.program!=ZERO or len(child.data)!=12 or child.data[:4]!=bytes([2,0,0,0]) or len(child.accounts)<2 or child.a(0)!=leg.trader:
129
+ continue
130
+ if child.position.stack_height!=depth+1:return None
131
+ if child.a(1) not in recipients:return None
132
+ paid_pool=paid_pool or child.a(1)==leg.pool
133
+ total+=int.from_bytes(child.data[4:12],'little')
134
+ if total>U64_MAX:return None
135
+ return total if paid_pool else None
136
+
137
+
138
+ SWAP = _disc([248, 198, 158, 145, 225, 117, 135, 200])
139
+ SWAP_V2 = _disc([43, 4, 237, 11, 26, 201, 30, 98])
140
+ CP_IN = _disc([143, 190, 90, 218, 196, 30, 51, 222])
141
+ CP_OUT = _disc([55, 217, 98, 86, 163, 74, 180, 173])
142
+ DL_IN = {SWAP, _disc([65, 75, 63, 76, 235, 91, 91, 136])}
143
+ DL_OUT = {
144
+ _disc([250, 73, 101, 33, 38, 207, 75, 184]),
145
+ _disc([43, 215, 247, 132, 137, 60, 243, 81]),
146
+ }
147
+ LAB = {
148
+ _disc([250, 234, 13, 123, 213, 156, 19, 236]): (True, True),
149
+ _disc([24, 211, 116, 40, 105, 3, 153, 56]): (True, False),
150
+ _disc([149, 39, 222, 155, 211, 124, 152, 26]): (False, True),
151
+ _disc([95, 200, 71, 34, 8, 9, 11, 166]): (False, False),
152
+ }
153
+ PUMP = {
154
+ _disc([198, 46, 21, 82, 180, 217, 232, 112]): (True, True),
155
+ _disc([102, 6, 61, 18, 1, 218, 235, 234]): (True, False),
156
+ _disc([51, 230, 133, 164, 1, 127, 131, 173]): (False, True),
157
+ }
158
+ CURVE_V2={
159
+ _disc([194,171,28,70,104,77,91,47]):(True,True),
160
+ _disc([184,23,238,97,103,197,211,61]):(True,False),
161
+ _disc([93,246,130,60,231,233,64,178]):(False,True),
162
+ }
163
+ CURVE_LEGACY={
164
+ _disc([56,252,116,8,158,223,205,95]):(True,True),
165
+ _disc([102,6,61,18,1,218,235,234]):(True,False),
166
+ _disc([51,230,133,164,1,127,131,173]):(False,True),
167
+ }
168
+
169
+
170
+ def _swap(
171
+ ix: _Invocation, mints: dict[str, str], graduated: set[str]
172
+ ) -> Optional[RouteSwapLeg]:
173
+ d, a, n = ix.data, ix.a, len(ix.accounts)
174
+ disc, pair, mode = d[:8], None, None
175
+ protocol = next((p for p, key in PROGRAMS.items() if key == ix.program), None)
176
+ exact, offset, invert = True, 8, False
177
+ if (
178
+ protocol == "RaydiumClmm"
179
+ and disc in (SWAP, SWAP_V2)
180
+ and n >= 10
181
+ and len(d) >= 41
182
+ ):
183
+ pool, user, source, dest = a(2), a(0), a(3), a(4)
184
+ exact = d[40] != 0
185
+ if disc == SWAP_V2 and n >= 13:
186
+ pair = (a(11), a(12))
187
+ elif protocol == "OrcaWhirlpool" and disc in (SWAP, SWAP_V2) and len(d) >= 42:
188
+ v2, direction = disc == SWAP_V2, d[41] != 0
189
+ if n < (15 if v2 else 11):
190
+ return None
191
+ pool, user = a(4 if v2 else 2), a(3 if v2 else 1)
192
+ x, y = (7, 9) if v2 else (3, 5)
193
+ source, dest, exact = (
194
+ a(x if direction else y),
195
+ a(y if direction else x),
196
+ d[40] != 0,
197
+ )
198
+ if v2:
199
+ pair = (a(5), a(6)) if direction else (a(6), a(5))
200
+ elif protocol == "RaydiumCpmm" and disc in (CP_IN, CP_OUT) and n >= 13:
201
+ pool, user, source, dest = a(3), a(0), a(4), a(5)
202
+ pair, exact = (a(10), a(11)), disc == CP_IN
203
+ invert = not exact
204
+ elif protocol == "MeteoraDammV2" and disc in (SWAP, _disc([65,75,63,76,235,91,91,136])) and n >= 11:
205
+ if len(d) < 24:
206
+ return None
207
+ if disc != SWAP:
208
+ if len(d) < 25 or d[24] > 2:
209
+ return None
210
+ exact = d[24] != 2
211
+ pool, user, source, dest = a(1), a(8), a(2), a(3)
212
+ # Pool mint order does not determine direction; use user-account evidence.
213
+ elif protocol == "MeteoraDlmm" and disc in DL_IN | DL_OUT and n >= 11:
214
+ pool, user, source, dest = a(0), a(10), a(4), a(5)
215
+ exact, invert = disc in DL_IN, disc in DL_OUT
216
+ elif protocol in ("LaunchLab", "PumpSwap") and disc in (
217
+ LAB if protocol == "LaunchLab" else PUMP
218
+ ):
219
+ buy, exact = (LAB if protocol == "LaunchLab" else PUMP)[disc]
220
+ if n < (18 if protocol == "LaunchLab" else 21):
221
+ return None
222
+ source, dest = a(6 if buy else 5), a(5 if buy else 6)
223
+ if protocol == "LaunchLab":
224
+ pool, user = a(4), a(0)
225
+ pair, mode = (
226
+ (a(10), a(9)) if buy else (a(9), a(10))
227
+ ), stonkfun_mode_from_platform_config(a(3))
228
+ else:
229
+ pool, user = a(0), a(1)
230
+ pair = (a(4), a(3)) if buy else (a(3), a(4))
231
+ elif protocol=='PumpFun' and disc in CURVE_V2 and n>=16:
232
+ buy,exact=CURVE_V2[disc]
233
+ pool,user,source,dest=a(10),a(13),a(15 if buy else 14),a(14 if buy else 15)
234
+ pair=(a(2),a(1)) if buy else (a(1),a(2))
235
+ if a(2)==WSOL:
236
+ if buy: source=user
237
+ else: dest=user
238
+ elif protocol=='PumpFun' and disc in CURVE_LEGACY and n>=12:
239
+ buy,exact=CURVE_LEGACY[disc]
240
+ pool,user,source,dest=a(3),a(6),a(6 if buy else 5),a(5 if buy else 6)
241
+ pair=(WSOL,a(2)) if buy else (a(2),WSOL)
242
+ elif protocol == "RaydiumAmmV4" and len(d) >= 17 and d[0] in (9, 11, 16, 17):
243
+ modern = d[0] in (16, 17)
244
+ if n < (8 if modern else 17):
245
+ return None
246
+ pool, user = a(1), a(7 if modern else n - 1)
247
+ source, dest = (a(5), a(6)) if modern else (a(n - 3), a(n - 2))
248
+ offset, exact = 1, d[0] in (9, 16)
249
+ invert = not exact
250
+ else:
251
+ return None
252
+ if len(d) < offset + 16:
253
+ return None
254
+ first, second = int.from_bytes(d[offset : offset + 8], "little"), int.from_bytes(
255
+ d[offset + 8 : offset + 16], "little"
256
+ )
257
+ source_mint = mints.get(source) or (pair[0] if pair and pair[0] != ZERO else None)
258
+ dest_mint = mints.get(dest) or (pair[1] if pair and pair[1] != ZERO else None)
259
+ return RouteSwapLeg(
260
+ ix.position,
261
+ ix.program,
262
+ protocol,
263
+ pool,
264
+ user,
265
+ source,
266
+ dest,
267
+ source_mint,
268
+ dest_mint,
269
+ exact,
270
+ second if invert else first,
271
+ first if invert else second,
272
+ stonkfun_mode=mode,
273
+ stonkfun_graduated=protocol == "RaydiumCpmm" and pool in graduated,
274
+ )
275
+
276
+
277
+ def _checked(ix: _Invocation) -> bool:
278
+ return (
279
+ ix.program in (TOKEN, TOKEN_2022)
280
+ and len(ix.data) >= 10
281
+ and ix.data[0] == 12
282
+ and len(ix.accounts) >= 4
283
+ )
284
+
285
+
286
+ def _with_fee(ix: _Invocation) -> bool:
287
+ return (
288
+ ix.program == TOKEN_2022
289
+ and len(ix.data) >= 19
290
+ and ix.data[:2] == bytes([26, 1])
291
+ and len(ix.accounts) >= 4
292
+ )
293
+
294
+
295
+ def _transfer(ix: _Invocation, mints: dict[str, str]) -> Optional[RouteTokenTransfer]:
296
+ if ix.program not in (TOKEN, TOKEN_2022) or not ix.data:
297
+ return None
298
+ if _checked(ix):
299
+ dest, offset, fee = 2, 1, 0 if ix.program == TOKEN else None
300
+ elif _with_fee(ix):
301
+ dest, offset, fee = 2, 2, int.from_bytes(ix.data[11:19], "little")
302
+ elif ix.data[0] == 3 and len(ix.data) >= 9 and len(ix.accounts) >= 3:
303
+ dest, offset, fee = 1, 1, 0
304
+ else:
305
+ return None
306
+ return RouteTokenTransfer(
307
+ ix.position,
308
+ ix.program,
309
+ ix.a(0),
310
+ ix.a(dest),
311
+ mints.get(ix.a(0)),
312
+ int.from_bytes(ix.data[offset : offset + 8], "little"),
313
+ fee,
314
+ )
315
+
316
+
317
+ def _descendant(parent: InstructionPosition, child: InstructionPosition) -> bool:
318
+ if parent.outer_index != child.outer_index:
319
+ return False
320
+ if parent.inner_index is None:
321
+ return child.inner_index is not None
322
+ return (
323
+ child.inner_index is not None
324
+ and child.inner_index > parent.inner_index
325
+ and parent.stack_height is not None
326
+ and child.stack_height is not None
327
+ and child.stack_height > parent.stack_height
328
+ )
329
+
330
+
331
+ def analyze_rpc_transaction_routes(
332
+ transaction: dict, graduated_stonkfun_pools: Iterable[str] = ()
333
+ ) -> TransactionRoute:
334
+ tx = transaction.get("result", transaction)
335
+ if not isinstance(tx, dict) or not isinstance(tx.get("meta"), dict):
336
+ raise ValueError("Transaction metadata missing")
337
+ if "err" not in tx["meta"]:
338
+ raise ValueError("Transaction execution status missing")
339
+ body = tx["transaction"]
340
+ if isinstance(body, list):
341
+ if len(body) != 2 or body[1] != "base64":
342
+ raise ValueError("Expected base64 wire encoding")
343
+ body, _ = decode_wire_transaction(base64.b64decode(body[0], validate=True))
344
+ message, meta = body["message"], tx["meta"]
345
+ if not isinstance(message, dict):
346
+ raise ValueError("Expected JSON-encoded compiled transaction")
347
+ keys = [k if isinstance(k, str) else k["pubkey"] for k in message["accountKeys"]]
348
+ loaded = meta.get("loadedAddresses") or {}
349
+ keys += loaded.get("writable", []) + loaded.get("readonly", [])
350
+
351
+ def key(i: int) -> str:
352
+ if type(i) is not int or not 0 <= i < len(keys):
353
+ raise ValueError("Invalid compiled account index")
354
+ return keys[i]
355
+
356
+ invocations: list[_Invocation] = []
357
+ groups = meta.get("innerInstructions") or []
358
+ if not isinstance(groups, list):
359
+ raise ValueError("Invalid compiled instruction groups")
360
+ seen = set()
361
+ for group in groups:
362
+ if not isinstance(group, dict):
363
+ raise ValueError("Invalid compiled instruction group")
364
+ index = group.get("index")
365
+ if type(index) is not int or not 0 <= index < len(message["instructions"]) or index in seen or not isinstance(group.get("instructions"), list):
366
+ raise ValueError("Invalid compiled instruction group")
367
+ seen.add(index)
368
+ for ix in group["instructions"]:
369
+ if not isinstance(ix, dict) or (ix.get("stackHeight") is not None and (type(ix["stackHeight"]) is not int or ix["stackHeight"] < 2)):
370
+ raise ValueError("Invalid compiled stack height")
371
+ for i, outer in enumerate(message["instructions"]):
372
+ batch = [(outer, InstructionPosition(i, None, 1))]
373
+ for group in groups:
374
+ if group["index"] == i:
375
+ batch += [
376
+ (ix, InstructionPosition(i, j, ix.get("stackHeight")))
377
+ for j, ix in enumerate(group["instructions"])
378
+ ]
379
+ for ix, position in batch:
380
+ if "programIdIndex" not in ix:
381
+ raise ValueError("Expected compiled instructions, not jsonParsed")
382
+ data = (
383
+ base58.b58decode(ix["data"])
384
+ if isinstance(ix["data"], str)
385
+ else bytes(ix["data"])
386
+ )
387
+ invocations.append(
388
+ _Invocation(
389
+ position,
390
+ key(ix["programIdIndex"]),
391
+ [key(k) for k in ix["accounts"]],
392
+ data,
393
+ )
394
+ )
395
+ mints: dict[str, str] = {}
396
+ for balance in (meta.get("preTokenBalances") or []) + (
397
+ meta.get("postTokenBalances") or []
398
+ ):
399
+ # Reject malformed mints rather than inventing pool identity from a key.
400
+ if len(base58.b58decode(balance["mint"])) == 32:
401
+ mints[key(balance["accountIndex"])] = balance["mint"]
402
+ for ix in invocations:
403
+ if _checked(ix) or _with_fee(ix):
404
+ mints[ix.a(0)] = mints[ix.a(2)] = ix.a(1)
405
+ if (
406
+ ix.program in (TOKEN, TOKEN_2022)
407
+ and len(ix.accounts) >= 2
408
+ and (
409
+ (ix.data == bytes([1]))
410
+ or (len(ix.data) == 33 and ix.data[0] in (16, 18))
411
+ )
412
+ ):
413
+ mints[ix.a(0)] = ix.a(1)
414
+ while True:
415
+ before = len(mints)
416
+ for ix in invocations:
417
+ if (
418
+ ix.program in (TOKEN, TOKEN_2022)
419
+ and len(ix.data) >= 9
420
+ and ix.data[0] == 3
421
+ and len(ix.accounts) >= 3
422
+ ):
423
+ mint = mints.get(ix.a(0)) or mints.get(ix.a(1))
424
+ if mint:
425
+ mints.setdefault(ix.a(0), mint)
426
+ mints.setdefault(ix.a(1), mint)
427
+ if len(mints) == before:
428
+ break
429
+ mints.pop(ZERO, None)
430
+ signatures = body.get("signatures", [])
431
+ route = TransactionRoute(
432
+ signatures[0] if signatures else base58.b58encode(bytes(64)).decode(),
433
+ meta.get("err") is None,
434
+ )
435
+ graduated = set(graduated_stonkfun_pools)
436
+ for i, ix in enumerate(invocations):
437
+ own = _transfer(ix, mints)
438
+ if own:
439
+ route.transfers.append(own)
440
+ children = []
441
+ for child in invocations[i + 1 :]:
442
+ if not _descendant(ix.position, child.position):
443
+ break
444
+ children.append(child)
445
+ nested = [t for c in children if (t := _transfer(c, mints)) is not None]
446
+ leg = _swap(ix, mints, graduated)
447
+ if leg:
448
+ if route.succeeded:
449
+ inputs = [t.amount for t in nested if t.source == leg.input_account]
450
+ outputs = [
451
+ (
452
+ None
453
+ if t.withheld_fee is None or t.withheld_fee > t.amount
454
+ else t.amount - t.withheld_fee
455
+ )
456
+ for t in nested
457
+ if t.destination == leg.output_account
458
+ ]
459
+ if inputs and sum(inputs) <= U64_MAX:
460
+ leg.actual_input_amount = sum(inputs)
461
+ if leg.protocol=='PumpFun' and leg.input_mint==WSOL and leg.input_account==leg.trader:
462
+ leg.actual_input_amount=_pumpfun_native_debit(ix,children,leg)
463
+ if (
464
+ outputs
465
+ and all(x is not None for x in outputs)
466
+ and sum(outputs) <= U64_MAX
467
+ ):
468
+ leg.actual_output_amount = sum(outputs)
469
+ route.legs.append(leg)
470
+ elif ix.program not in (
471
+ TOKEN,
472
+ TOKEN_2022,
473
+ ZERO,
474
+ "ComputeBudget111111111111111111111111111111",
475
+ "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
476
+ "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
477
+ ):
478
+ route.unknown_invocations.append(
479
+ RouteUnknownInvocation(
480
+ ix.position,
481
+ ix.program,
482
+ bool(nested),
483
+ any(_swap(c, mints, graduated) for c in children),
484
+ )
485
+ )
486
+ target, action = None, None
487
+ if (
488
+ ix.program == ZERO
489
+ and len(ix.data) == 12
490
+ and ix.data[:4] == bytes([2, 0, 0, 0])
491
+ and len(ix.accounts) >= 2
492
+ ):
493
+ target, action = ix.a(1), {
494
+ "Fund": {
495
+ "source": ix.a(0),
496
+ "lamports": int.from_bytes(ix.data[4:12], "little"),
497
+ }
498
+ }
499
+ elif ix.program in (TOKEN, TOKEN_2022):
500
+ if ix.data == bytes([17]) and ix.accounts:
501
+ target, action = ix.a(0), "SyncNative"
502
+ elif ix.data == bytes([9]) and len(ix.accounts) >= 3:
503
+ target, action = ix.a(0), {
504
+ "Close": {"destination": ix.a(1), "authority": ix.a(2)}
505
+ }
506
+ if target and mints.get(target) == WSOL:
507
+ route.native_token_actions.append(
508
+ RouteNativeTokenAction(ix.position, target, action)
509
+ )
510
+ return route
@@ -0,0 +1,172 @@
1
+ """Native canonical Solana Legacy, V0 and V1 wire decoding (SIMD-0385)."""
2
+
3
+ from __future__ import annotations
4
+ import base58
5
+
6
+
7
+ class _Reader:
8
+ def __init__(self, data: bytes, offset: int = 0):
9
+ self.data, self.pos = data, offset
10
+
11
+ def take(self, n: int) -> bytes:
12
+ if n < 0 or self.pos + n > len(self.data):
13
+ raise ValueError("Truncated transaction")
14
+ value = self.data[self.pos : self.pos + n]
15
+ self.pos += n
16
+ return value
17
+
18
+ def uint(self, n: int = 1) -> int:
19
+ return int.from_bytes(self.take(n), "little")
20
+
21
+ def short(self) -> int:
22
+ value = 0
23
+ for i in range(3):
24
+ b = self.uint()
25
+ if i == 2 and b > 3:
26
+ raise ValueError("Invalid short-u16")
27
+ value |= (b & 127) << (7 * i)
28
+ if not b & 128:
29
+ if i and b == 0:
30
+ raise ValueError("Noncanonical short-u16")
31
+ return value
32
+ raise ValueError("Invalid short-u16")
33
+
34
+
35
+ def decode_wire_transaction(
36
+ data: bytes, offset: int = 0, require_complete: bool = True
37
+ ) -> tuple[dict, int]:
38
+ if not isinstance(offset, int) or offset < 0 or offset >= len(data):
39
+ raise ValueError("Invalid wire offset")
40
+ r = _Reader(data, offset)
41
+ version, signatures, config, lookups = "legacy", [], None, []
42
+ if r.data[r.pos : r.pos + 1] == bytes([129]):
43
+ r.take(1)
44
+ version = 1
45
+ header = r.take(3)
46
+ mask = r.uint(4)
47
+ blockhash = r.take(32)
48
+ if mask & ~31 or mask & 3 not in (0, 3):
49
+ raise ValueError("Invalid V1 config mask")
50
+ count, keys_count = r.uint(), r.uint()
51
+ if count > 64 or keys_count > 64 or header[0] > 12:
52
+ raise ValueError("V1 transaction limit exceeded")
53
+ keys = [r.take(32) for _ in range(keys_count)]
54
+ config = {}
55
+ for bit, key, size in [
56
+ (3, "priorityFee", 8),
57
+ (4, "computeUnitLimit", 4),
58
+ (8, "loadedAccountsDataSizeLimit", 4),
59
+ (16, "heapSize", 4),
60
+ ]:
61
+ if mask & bit:
62
+ config[key] = r.uint(size)
63
+ if "heapSize" in config and (
64
+ not 32768 <= config["heapSize"] <= 262144 or config["heapSize"] % 1024
65
+ ):
66
+ raise ValueError("Invalid heap size")
67
+ headers = [(r.uint(), r.uint(), r.uint(2)) for _ in range(count)]
68
+ instructions = [
69
+ {
70
+ "programIdIndex": program,
71
+ "accounts": list(r.take(accounts)),
72
+ "data": base58.b58encode(r.take(length)).decode(),
73
+ }
74
+ for program, accounts, length in headers
75
+ ]
76
+ signatures = [r.take(64) for _ in range(header[0])]
77
+ if r.pos - offset > 4096:
78
+ raise ValueError("V1 transaction exceeds 4096 bytes")
79
+ else:
80
+ signature_count = r.short()
81
+ if signature_count > 127:
82
+ raise ValueError("Too many signatures")
83
+ signatures = [r.take(64) for _ in range(signature_count)]
84
+ first = r.uint()
85
+ if first & 128:
86
+ if first != 128:
87
+ raise ValueError("Unknown transaction version")
88
+ version = 0
89
+ header = r.take(3)
90
+ else:
91
+ header = bytes([first]) + r.take(2)
92
+ keys_count = r.short()
93
+ if keys_count > 256:
94
+ raise ValueError("Too many account keys")
95
+ keys = [r.take(32) for _ in range(keys_count)]
96
+ blockhash = r.take(32)
97
+ instructions = []
98
+ for _ in range(r.short()):
99
+ program = r.uint()
100
+ accounts = list(r.take(r.short()))
101
+ payload = r.take(r.short())
102
+ instructions.append(
103
+ {
104
+ "programIdIndex": program,
105
+ "accounts": accounts,
106
+ "data": base58.b58encode(payload).decode(),
107
+ }
108
+ )
109
+ if version == 0:
110
+ for _ in range(r.short()):
111
+ key = r.take(32)
112
+ writable = list(r.take(r.short()))
113
+ readonly = list(r.take(r.short()))
114
+ lookups.append(
115
+ {
116
+ "accountKey": base58.b58encode(key).decode(),
117
+ "writableIndexes": writable,
118
+ "readonlyIndexes": readonly,
119
+ }
120
+ )
121
+ if len(signatures) != header[0]:
122
+ raise ValueError("Signature count does not match header")
123
+ if (
124
+ header[0] > len(keys)
125
+ or header[1] > header[0]
126
+ or header[2] > len(keys) - header[0]
127
+ ):
128
+ raise ValueError("Invalid message header")
129
+ if version == 1:
130
+ if header[1] >= header[0]:
131
+ raise ValueError("V1 requires a writable fee payer")
132
+ if len(set(keys)) != len(keys):
133
+ raise ValueError("Duplicate V1 accounts")
134
+ for ix in instructions:
135
+ if (
136
+ ix["programIdIndex"] == 0
137
+ or ix["programIdIndex"] >= len(keys)
138
+ or any(i >= len(keys) for i in ix["accounts"])
139
+ ):
140
+ raise ValueError("Invalid V1 instruction index")
141
+ account_count = len(keys) + sum(
142
+ len(l["writableIndexes"]) + len(l["readonlyIndexes"]) for l in lookups
143
+ )
144
+ if account_count > 256:
145
+ raise ValueError("Too many resolved account keys")
146
+ if any(ix["programIdIndex"] >= len(keys) or any(i >= account_count for i in ix["accounts"]) for ix in instructions):
147
+ raise ValueError("Invalid instruction index")
148
+ if require_complete and r.pos != len(data):
149
+ raise ValueError("Trailing transaction bytes")
150
+ message = {
151
+ "header": dict(
152
+ zip(
153
+ (
154
+ "numRequiredSignatures",
155
+ "numReadonlySignedAccounts",
156
+ "numReadonlyUnsignedAccounts",
157
+ ),
158
+ header,
159
+ )
160
+ ),
161
+ "accountKeys": [base58.b58encode(key).decode() for key in keys],
162
+ "recentBlockhash": base58.b58encode(blockhash).decode(),
163
+ "instructions": instructions,
164
+ "addressTableLookups": lookups,
165
+ }
166
+ if config is not None:
167
+ message["config"] = config
168
+ return {
169
+ "signatures": [base58.b58encode(s).decode() for s in signatures],
170
+ "message": message,
171
+ "version": version,
172
+ }, r.pos - offset