samp-core 1.1.0__tar.gz

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,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: samp-core
3
+ Version: 1.1.0
4
+ Summary: Substrate Account Messaging Protocol -- Python SDK
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/samp-org/samp
7
+ Project-URL: Specification, https://github.com/samp-org/samp/blob/main/specs/samp.md
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: samp-crypto
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest; extra == "dev"
12
+ Requires-Dist: mypy; extra == "dev"
@@ -0,0 +1,87 @@
1
+ # samp
2
+
3
+ Python implementation of [SAMP](https://github.com/samp-org/samp) (Substrate Account Messaging Protocol). Crypto operations use a native Rust extension via PyO3.
4
+
5
+ ## Install
6
+
7
+ Requires a Rust toolchain for the native crypto extension.
8
+
9
+ ```
10
+ pip install maturin
11
+ cd python/samp-crypto && maturin develop && cd ../..
12
+ cd python && pip install -e .
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```python
18
+ import os
19
+
20
+ from samp import (
21
+ EncryptedRemark, Seed, decode_remark,
22
+ nonce_from_bytes, plaintext_from_bytes, pubkey_from_bytes, sr25519_signing_scalar,
23
+ )
24
+ from samp.encryption import compute_view_tag, decrypt, encrypt
25
+ from samp.wire import ContentType, encode_encrypted, encode_public
26
+
27
+ sender_seed = Seed.from_bytes(bytes.fromhex("e5be9a5092b81bca" + "00" * 24))
28
+ recipient_pub = pubkey_from_bytes(bytes.fromhex("8eaf04151687736" + "0" + "00" * 24))
29
+
30
+ # Public message
31
+ remark = encode_public(recipient_pub, "Hello from Python")
32
+
33
+ # Encrypted message
34
+ nonce = nonce_from_bytes(os.urandom(12))
35
+ plaintext = plaintext_from_bytes(b"Private message")
36
+ ciphertext = encrypt(plaintext, recipient_pub, nonce, sender_seed)
37
+ tag = compute_view_tag(sender_seed, recipient_pub, nonce)
38
+ enc_remark = encode_encrypted(ContentType.ENCRYPTED, tag, nonce, ciphertext)
39
+
40
+ # Decrypt
41
+ recipient_seed = Seed.from_bytes(bytes(32))
42
+ scalar = sr25519_signing_scalar(recipient_seed)
43
+ parsed = decode_remark(enc_remark)
44
+ assert isinstance(parsed, EncryptedRemark)
45
+ clear = decrypt(parsed.ciphertext, parsed.nonce, scalar)
46
+ ```
47
+
48
+ ## API
49
+
50
+ ### Wire
51
+
52
+ | Function | Description |
53
+ |----------|-------------|
54
+ | `encode_public` | Public message (`0x10`) |
55
+ | `encode_encrypted` | Encrypted or thread message (`0x11`/`0x12`) |
56
+ | `encode_channel_create` | Channel creation (`0x13`) |
57
+ | `encode_channel_msg` | Channel message (`0x14`) |
58
+ | `encode_group` | Group message (`0x15`) |
59
+ | `decode_remark` | Parse any SAMP remark → `Remark` dataclass |
60
+ | `encode_thread_content` / `decode_thread_content` | Thread plaintext (refs + body) |
61
+ | `encode_channel_content` / `decode_channel_content` | Channel plaintext (refs + body) |
62
+ | `decode_group_content` | Group plaintext (refs + body) |
63
+ | `encode_group_members` / `decode_group_members` | Group member list |
64
+ | `channel_ref_from_recipient` | Extract channel ref from recipient field |
65
+
66
+ ### Crypto
67
+
68
+ | Function | Description |
69
+ |----------|-------------|
70
+ | `encrypt` / `decrypt` | 1:1 ECDH + ChaCha20-Poly1305 |
71
+ | `decrypt_as_sender` | Sender self-decryption via sealed_to |
72
+ | `encrypt_for_group` / `decrypt_from_group` | Multi-recipient encryption |
73
+ | `sr25519_signing_scalar` | Derive signing scalar from sr25519 seed |
74
+ | `public_from_seed` | Derive public key from seed |
75
+ | `compute_view_tag` / `check_view_tag` | 1-byte recipient filter |
76
+ | `unseal_recipient` | Recover recipient from sealed_to field |
77
+ | `build_capsules` / `scan_capsules` | Group capsule construction and scanning |
78
+
79
+ ### Types
80
+
81
+ `Remark` (dataclass), `SampError` (exception)
82
+
83
+ ## Test
84
+
85
+ ```
86
+ pytest tests/ -v
87
+ ```
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "samp-core"
3
+ version = "1.1.0"
4
+ description = "Substrate Account Messaging Protocol -- Python SDK"
5
+ license = "MIT"
6
+ requires-python = ">=3.9"
7
+ dependencies = ["samp-crypto"]
8
+
9
+ [project.optional-dependencies]
10
+ dev = ["pytest", "mypy"]
11
+
12
+ [project.urls]
13
+ Repository = "https://github.com/samp-org/samp"
14
+ Specification = "https://github.com/samp-org/samp/blob/main/specs/samp.md"
15
+
16
+ [build-system]
17
+ requires = ["setuptools"]
18
+ build-backend = "setuptools.build_meta"
19
+
20
+ [tool.setuptools.package-data]
21
+ samp = ["py.typed"]
22
+
23
+ [tool.mypy]
24
+ strict = true
25
+ files = ["samp"]
26
+ warn_unused_ignores = true
27
+ no_implicit_optional = true
28
+ disallow_untyped_defs = true
29
+
30
+ [[tool.mypy.overrides]]
31
+ module = "samp_crypto"
32
+ ignore_missing_imports = true
33
+
34
+ [tool.pyright]
35
+ include = ["samp", "tests"]
36
+ venvPath = ".."
37
+ venv = ".venv"
38
+ reportMissingTypeStubs = "none"
@@ -0,0 +1,243 @@
1
+ from samp.encryption import (
2
+ ENCRYPTED_OVERHEAD,
3
+ build_capsules,
4
+ check_view_tag,
5
+ compute_view_tag,
6
+ decrypt,
7
+ decrypt_as_sender,
8
+ decrypt_from_group,
9
+ derive_group_ephemeral,
10
+ encrypt,
11
+ encrypt_for_group,
12
+ public_from_seed,
13
+ sr25519_sign,
14
+ sr25519_signing_scalar,
15
+ unseal_recipient,
16
+ )
17
+ from samp.error import SampError
18
+ from samp.extrinsic import (
19
+ ChainParams,
20
+ ExtractedCall,
21
+ ExtrinsicError,
22
+ build_signed_extrinsic,
23
+ extract_call,
24
+ extract_signer,
25
+ )
26
+ from samp.metadata import (
27
+ ErrorEntry,
28
+ ErrorTable,
29
+ FieldNotFoundError,
30
+ Metadata,
31
+ MetadataError,
32
+ ScaleError,
33
+ StorageLayout,
34
+ StorageNotFoundError,
35
+ )
36
+ from samp.scale import decode_bytes, decode_compact, encode_compact
37
+ from samp.secret import ContentKey, Seed, ViewScalar
38
+ from samp.ss58 import decode as ss58_decode
39
+ from samp.ss58 import encode as ss58_encode
40
+ from samp.types import (
41
+ CAPSULE_SIZE,
42
+ CHANNEL_DESC_MAX,
43
+ CHANNEL_NAME_MAX,
44
+ SS58_PREFIX_KUSAMA,
45
+ SS58_PREFIX_POLKADOT,
46
+ SS58_PREFIX_SUBSTRATE_GENERIC,
47
+ BlockNumber,
48
+ BlockRef,
49
+ CallArgs,
50
+ CallIdx,
51
+ Capsules,
52
+ ChannelDescription,
53
+ ChannelName,
54
+ Ciphertext,
55
+ EphPubkey,
56
+ ExtIndex,
57
+ ExtrinsicBytes,
58
+ ExtrinsicNonce,
59
+ GenesisHash,
60
+ Nonce,
61
+ PalletIdx,
62
+ Plaintext,
63
+ Pubkey,
64
+ RemarkBytes,
65
+ Signature,
66
+ SpecVersion,
67
+ Ss58Address,
68
+ Ss58Prefix,
69
+ TxVersion,
70
+ ViewTag,
71
+ block_number_from_int,
72
+ call_args_from_bytes,
73
+ call_idx_from_int,
74
+ capsules_count,
75
+ capsules_from_bytes,
76
+ ciphertext_from_bytes,
77
+ eph_pubkey_from_bytes,
78
+ ext_index_from_int,
79
+ extrinsic_bytes_from_bytes,
80
+ extrinsic_nonce_from_int,
81
+ genesis_hash_from_bytes,
82
+ nonce_from_bytes,
83
+ pallet_idx_from_int,
84
+ plaintext_from_bytes,
85
+ pubkey_from_bytes,
86
+ pubkey_zero,
87
+ remark_bytes_from_bytes,
88
+ signature_from_bytes,
89
+ spec_version_from_int,
90
+ ss58_prefix_from_int,
91
+ tx_version_from_int,
92
+ view_tag_from_int,
93
+ )
94
+ from samp.wire import (
95
+ CHANNEL_HEADER_SIZE,
96
+ SAMP_VERSION,
97
+ THREAD_HEADER_SIZE,
98
+ ApplicationRemark,
99
+ ChannelCreateRemark,
100
+ ChannelRemark,
101
+ ContentType,
102
+ EncryptedRemark,
103
+ GroupRemark,
104
+ PublicRemark,
105
+ Remark,
106
+ ThreadRemark,
107
+ content_type_from_byte,
108
+ decode_channel_content,
109
+ decode_channel_create,
110
+ decode_group_content,
111
+ decode_group_members,
112
+ decode_remark,
113
+ decode_thread_content,
114
+ encode_channel_content,
115
+ encode_channel_create,
116
+ encode_channel_msg,
117
+ encode_encrypted,
118
+ encode_group,
119
+ encode_group_members,
120
+ encode_public,
121
+ encode_thread_content,
122
+ is_samp_remark,
123
+ )
124
+
125
+ __all__ = [
126
+ "SAMP_VERSION",
127
+ "ContentType",
128
+ "content_type_from_byte",
129
+ "is_samp_remark",
130
+ "CAPSULE_SIZE",
131
+ "CHANNEL_HEADER_SIZE",
132
+ "THREAD_HEADER_SIZE",
133
+ "CHANNEL_NAME_MAX",
134
+ "CHANNEL_DESC_MAX",
135
+ "ENCRYPTED_OVERHEAD",
136
+ "Remark",
137
+ "PublicRemark",
138
+ "EncryptedRemark",
139
+ "ThreadRemark",
140
+ "ChannelCreateRemark",
141
+ "ChannelRemark",
142
+ "GroupRemark",
143
+ "ApplicationRemark",
144
+ "SampError",
145
+ "encode_public",
146
+ "encode_encrypted",
147
+ "encode_channel_msg",
148
+ "encode_channel_create",
149
+ "encode_group",
150
+ "encode_group_members",
151
+ "decode_remark",
152
+ "decode_thread_content",
153
+ "decode_channel_content",
154
+ "decode_channel_create",
155
+ "decode_group_content",
156
+ "decode_group_members",
157
+ "encode_thread_content",
158
+ "encode_channel_content",
159
+ "sr25519_sign",
160
+ "sr25519_signing_scalar",
161
+ "public_from_seed",
162
+ "encrypt",
163
+ "decrypt",
164
+ "decrypt_as_sender",
165
+ "compute_view_tag",
166
+ "check_view_tag",
167
+ "unseal_recipient",
168
+ "derive_group_ephemeral",
169
+ "build_capsules",
170
+ "encrypt_for_group",
171
+ "decrypt_from_group",
172
+ "decode_compact",
173
+ "encode_compact",
174
+ "decode_bytes",
175
+ "Metadata",
176
+ "StorageLayout",
177
+ "ErrorEntry",
178
+ "ErrorTable",
179
+ "MetadataError",
180
+ "ScaleError",
181
+ "StorageNotFoundError",
182
+ "FieldNotFoundError",
183
+ "ChainParams",
184
+ "ExtractedCall",
185
+ "ExtrinsicError",
186
+ "build_signed_extrinsic",
187
+ "extract_signer",
188
+ "extract_call",
189
+ "Seed",
190
+ "ViewScalar",
191
+ "ContentKey",
192
+ "ss58_encode",
193
+ "ss58_decode",
194
+ "BlockNumber",
195
+ "BlockRef",
196
+ "CallArgs",
197
+ "CallIdx",
198
+ "Capsules",
199
+ "ChannelDescription",
200
+ "ChannelName",
201
+ "Ciphertext",
202
+ "EphPubkey",
203
+ "ExtIndex",
204
+ "ExtrinsicBytes",
205
+ "ExtrinsicNonce",
206
+ "GenesisHash",
207
+ "Nonce",
208
+ "PalletIdx",
209
+ "Plaintext",
210
+ "Pubkey",
211
+ "RemarkBytes",
212
+ "Signature",
213
+ "SpecVersion",
214
+ "Ss58Address",
215
+ "Ss58Prefix",
216
+ "TxVersion",
217
+ "ViewTag",
218
+ "SS58_PREFIX_KUSAMA",
219
+ "SS58_PREFIX_POLKADOT",
220
+ "SS58_PREFIX_SUBSTRATE_GENERIC",
221
+ "block_number_from_int",
222
+ "call_args_from_bytes",
223
+ "call_idx_from_int",
224
+ "capsules_count",
225
+ "capsules_from_bytes",
226
+ "ciphertext_from_bytes",
227
+ "eph_pubkey_from_bytes",
228
+ "ext_index_from_int",
229
+ "extrinsic_bytes_from_bytes",
230
+ "extrinsic_nonce_from_int",
231
+ "genesis_hash_from_bytes",
232
+ "nonce_from_bytes",
233
+ "pallet_idx_from_int",
234
+ "plaintext_from_bytes",
235
+ "pubkey_from_bytes",
236
+ "pubkey_zero",
237
+ "remark_bytes_from_bytes",
238
+ "signature_from_bytes",
239
+ "spec_version_from_int",
240
+ "ss58_prefix_from_int",
241
+ "tx_version_from_int",
242
+ "view_tag_from_int",
243
+ ]
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import samp_crypto
6
+
7
+ from samp.error import SampError
8
+ from samp.secret import ContentKey, Seed, ViewScalar
9
+ from samp.types import (
10
+ Capsules,
11
+ Ciphertext,
12
+ EphPubkey,
13
+ Nonce,
14
+ Plaintext,
15
+ Pubkey,
16
+ Signature,
17
+ ViewTag,
18
+ capsules_from_bytes,
19
+ ciphertext_from_bytes,
20
+ eph_pubkey_from_bytes,
21
+ plaintext_from_bytes,
22
+ pubkey_from_bytes,
23
+ signature_from_bytes,
24
+ view_tag_from_int,
25
+ )
26
+
27
+ ENCRYPTED_OVERHEAD = 80
28
+
29
+
30
+ def sr25519_sign(seed: Seed, message: bytes) -> Signature:
31
+ return signature_from_bytes(samp_crypto.sr25519_sign(seed.expose_secret(), message))
32
+
33
+
34
+ def sr25519_signing_scalar(seed: Seed) -> ViewScalar:
35
+ return ViewScalar.from_bytes(samp_crypto.sr25519_signing_scalar(seed.expose_secret()))
36
+
37
+
38
+ def public_from_seed(seed: Seed) -> Pubkey:
39
+ return pubkey_from_bytes(samp_crypto.public_from_seed(seed.expose_secret()))
40
+
41
+
42
+ def encrypt(
43
+ plaintext: Plaintext,
44
+ recipient: Pubkey,
45
+ nonce: Nonce,
46
+ sender_seed: Seed,
47
+ ) -> Ciphertext:
48
+ return ciphertext_from_bytes(
49
+ samp_crypto.encrypt_content(plaintext, recipient, nonce, sender_seed.expose_secret())
50
+ )
51
+
52
+
53
+ def decrypt(
54
+ ciphertext: Ciphertext,
55
+ nonce: Nonce,
56
+ signing_scalar: ViewScalar,
57
+ ) -> Plaintext:
58
+ return plaintext_from_bytes(
59
+ samp_crypto.decrypt_content(ciphertext, signing_scalar.expose_secret(), nonce)
60
+ )
61
+
62
+
63
+ def decrypt_as_sender(
64
+ ciphertext: Ciphertext,
65
+ nonce: Nonce,
66
+ sender_seed: Seed,
67
+ ) -> Plaintext:
68
+ return plaintext_from_bytes(
69
+ samp_crypto.decrypt_as_sender(ciphertext, sender_seed.expose_secret(), nonce)
70
+ )
71
+
72
+
73
+ def compute_view_tag(sender_seed: Seed, recipient: Pubkey, nonce: Nonce) -> ViewTag:
74
+ return view_tag_from_int(
75
+ samp_crypto.compute_view_tag(sender_seed.expose_secret(), recipient, nonce)
76
+ )
77
+
78
+
79
+ def check_view_tag(ciphertext: Ciphertext, signing_scalar: ViewScalar) -> ViewTag:
80
+ return view_tag_from_int(samp_crypto.check_view_tag(signing_scalar.expose_secret(), ciphertext))
81
+
82
+
83
+ def unseal_recipient(ciphertext: Ciphertext, nonce: Nonce, sender_seed: Seed) -> Pubkey:
84
+ return pubkey_from_bytes(
85
+ samp_crypto.unseal_recipient(ciphertext, sender_seed.expose_secret(), nonce)
86
+ )
87
+
88
+
89
+ def derive_group_ephemeral(sender_seed: Seed, nonce: Nonce) -> bytes:
90
+ result: bytes = samp_crypto.derive_group_ephemeral(sender_seed.expose_secret(), nonce)
91
+ return result
92
+
93
+
94
+ def build_capsules(
95
+ content_key: ContentKey,
96
+ member_pubkeys: list[Pubkey],
97
+ eph_scalar: bytes,
98
+ nonce: Nonce,
99
+ ) -> Capsules:
100
+ return capsules_from_bytes(
101
+ samp_crypto.build_capsules(
102
+ content_key.expose_secret(), list(member_pubkeys), eph_scalar, nonce
103
+ )
104
+ )
105
+
106
+
107
+ def scan_capsules(
108
+ data: bytes,
109
+ eph_pubkey: EphPubkey,
110
+ my_scalar: ViewScalar,
111
+ nonce: Nonce,
112
+ ) -> Optional[tuple[int, ContentKey]]:
113
+ result = samp_crypto.scan_capsules(data, eph_pubkey, my_scalar.expose_secret(), nonce)
114
+ if result is None:
115
+ return None
116
+ idx, ck = result
117
+ return int(idx), ContentKey.from_bytes(bytes(ck))
118
+
119
+
120
+ def encrypt_for_group(
121
+ plaintext: Plaintext,
122
+ member_pubkeys: list[Pubkey],
123
+ nonce: Nonce,
124
+ sender_seed: Seed,
125
+ ) -> tuple[EphPubkey, Capsules, Ciphertext]:
126
+ eph, caps, ct = samp_crypto.encrypt_for_group(
127
+ plaintext, list(member_pubkeys), nonce, sender_seed.expose_secret()
128
+ )
129
+ return (
130
+ eph_pubkey_from_bytes(eph),
131
+ capsules_from_bytes(caps),
132
+ ciphertext_from_bytes(ct),
133
+ )
134
+
135
+
136
+ def decrypt_from_group(
137
+ content: bytes,
138
+ my_scalar: ViewScalar,
139
+ nonce: Nonce,
140
+ known_n: Optional[int] = None,
141
+ ) -> Plaintext:
142
+ try:
143
+ return plaintext_from_bytes(
144
+ samp_crypto.decrypt_from_group(content, my_scalar.expose_secret(), nonce, known_n)
145
+ )
146
+ except SampError:
147
+ raise
148
+ except Exception as e:
149
+ raise SampError(f"decryption failed: {e}") from e
@@ -0,0 +1,2 @@
1
+ class SampError(Exception):
2
+ pass
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import dataclass
5
+ from typing import Callable, Optional
6
+
7
+ from samp.scale import decode_compact, encode_compact
8
+ from samp.types import (
9
+ CallArgs,
10
+ CallIdx,
11
+ ExtrinsicBytes,
12
+ ExtrinsicNonce,
13
+ GenesisHash,
14
+ PalletIdx,
15
+ Pubkey,
16
+ SpecVersion,
17
+ TxVersion,
18
+ call_args_from_bytes,
19
+ call_idx_from_int,
20
+ extrinsic_bytes_from_bytes,
21
+ pallet_idx_from_int,
22
+ pubkey_from_bytes,
23
+ signature_from_bytes,
24
+ )
25
+
26
+ EXT_VERSION_SIGNED = 0x84
27
+ ADDR_TYPE_ID = 0x00
28
+ SIG_TYPE_SR25519 = 0x01
29
+ ERA_IMMORTAL = 0x00
30
+ METADATA_HASH_DISABLED = 0x00
31
+ SIGNED_HEADER_LEN = 99
32
+ MIN_SIGNED_EXTRINSIC = 103
33
+ MIN_SIGNER_PAYLOAD = 34
34
+
35
+
36
+ class ExtrinsicError(Exception):
37
+ pass
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ChainParams:
42
+ genesis_hash: GenesisHash
43
+ spec_version: SpecVersion
44
+ tx_version: TxVersion
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ExtractedCall:
49
+ pallet: PalletIdx
50
+ call: CallIdx
51
+ args: CallArgs
52
+
53
+
54
+ def build_signed_extrinsic(
55
+ pallet_idx: PalletIdx,
56
+ call_idx: CallIdx,
57
+ call_args: CallArgs,
58
+ public_key: Pubkey,
59
+ sign: Callable[[bytes], bytes],
60
+ nonce: ExtrinsicNonce,
61
+ chain_params: ChainParams,
62
+ ) -> ExtrinsicBytes:
63
+ call_data = bytes([int(pallet_idx), int(call_idx)]) + call_args
64
+ tip = bytes([0])
65
+
66
+ signing_payload = (
67
+ call_data
68
+ + bytes([ERA_IMMORTAL])
69
+ + encode_compact(int(nonce))
70
+ + tip
71
+ + bytes([METADATA_HASH_DISABLED])
72
+ + int(chain_params.spec_version).to_bytes(4, "little")
73
+ + int(chain_params.tx_version).to_bytes(4, "little")
74
+ + chain_params.genesis_hash
75
+ + chain_params.genesis_hash
76
+ + bytes([0x00])
77
+ )
78
+
79
+ if len(signing_payload) > 256:
80
+ to_sign = hashlib.blake2b(signing_payload, digest_size=32).digest()
81
+ else:
82
+ to_sign = signing_payload
83
+
84
+ signature = signature_from_bytes(sign(to_sign))
85
+
86
+ extrinsic_payload = (
87
+ bytes([EXT_VERSION_SIGNED, ADDR_TYPE_ID])
88
+ + public_key
89
+ + bytes([SIG_TYPE_SR25519])
90
+ + signature
91
+ + bytes([ERA_IMMORTAL])
92
+ + encode_compact(int(nonce))
93
+ + tip
94
+ + bytes([METADATA_HASH_DISABLED])
95
+ + call_data
96
+ )
97
+
98
+ return extrinsic_bytes_from_bytes(encode_compact(len(extrinsic_payload)) + extrinsic_payload)
99
+
100
+
101
+ def extract_signer(extrinsic_bytes: ExtrinsicBytes) -> Optional[Pubkey]:
102
+ decoded = decode_compact(extrinsic_bytes)
103
+ if decoded is None:
104
+ return None
105
+ _, prefix_len = decoded
106
+ payload = extrinsic_bytes[prefix_len:]
107
+ if (
108
+ len(payload) < MIN_SIGNER_PAYLOAD
109
+ or payload[0] & 0x80 == 0
110
+ or payload[1] != ADDR_TYPE_ID
111
+ ):
112
+ return None
113
+ return pubkey_from_bytes(payload[2:34])
114
+
115
+
116
+ def extract_call(extrinsic_bytes: ExtrinsicBytes) -> Optional[ExtractedCall]:
117
+ decoded = decode_compact(extrinsic_bytes)
118
+ if decoded is None:
119
+ return None
120
+ _, prefix_len = decoded
121
+ payload = extrinsic_bytes[prefix_len:]
122
+
123
+ if len(payload) < MIN_SIGNED_EXTRINSIC or payload[0] & 0x80 == 0:
124
+ return None
125
+
126
+ offset = SIGNED_HEADER_LEN
127
+ if payload[offset] != 0x00:
128
+ offset += 2
129
+ else:
130
+ offset += 1
131
+
132
+ nonce = decode_compact(payload[offset:])
133
+ if nonce is None:
134
+ return None
135
+ offset += nonce[1]
136
+
137
+ tip = decode_compact(payload[offset:])
138
+ if tip is None:
139
+ return None
140
+ offset += tip[1]
141
+
142
+ offset += 1
143
+
144
+ if offset + 2 > len(payload):
145
+ return None
146
+ pallet = payload[offset]
147
+ call = payload[offset + 1]
148
+ offset += 2
149
+
150
+ return ExtractedCall(
151
+ pallet=pallet_idx_from_int(pallet),
152
+ call=call_idx_from_int(call),
153
+ args=call_args_from_bytes(payload[offset:]),
154
+ )