memwal 0.1.0.dev2__tar.gz → 0.1.0.dev3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memwal
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev3
4
4
  Summary: Python SDK for MemWal — Privacy-first AI memory with Ed25519 signing
5
5
  Project-URL: Homepage, https://memwal.ai
6
6
  Project-URL: Documentation, https://docs.memwal.ai
@@ -110,4 +110,4 @@ __all__ = [
110
110
  "RecallManualResult",
111
111
  ]
112
112
 
113
- __version__ = "0.1.0.dev2"
113
+ __version__ = "0.1.0.dev3"
@@ -26,12 +26,14 @@ Example::
26
26
  from __future__ import annotations
27
27
 
28
28
  import asyncio
29
+ import base64
29
30
  import json
30
31
  import random
31
32
  import time
32
33
  from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar
33
34
 
34
35
  import httpx
36
+ import nacl.signing
35
37
 
36
38
  from .types import (
37
39
  AnalyzedFact,
@@ -62,14 +64,20 @@ from .types import (
62
64
  RestoreResult,
63
65
  )
64
66
  from .utils import (
67
+ build_seal_session_personal_message,
65
68
  build_signature_message,
66
69
  build_signing_key,
67
70
  bytes_to_hex,
71
+ delegate_key_to_sui_address,
72
+ encode_sui_private_key,
68
73
  sha256_hex,
69
74
  sign_message,
75
+ sign_sui_personal_message,
70
76
  )
71
77
 
72
78
  T = TypeVar("T")
79
+ SEAL_SESSION_TTL_MIN = 5
80
+ SEAL_SESSION_SAFETY_MARGIN_MS = 30_000
73
81
 
74
82
 
75
83
  # ============================================================
@@ -123,6 +131,9 @@ class MemWal:
123
131
  self._server_url = config.server_url.rstrip("/")
124
132
  self._namespace = config.namespace
125
133
  self._client: Optional[httpx.AsyncClient] = None
134
+ self._server_config: Optional[Dict[str, str]] = None
135
+ self._session_cache: Optional[Tuple[str, int]] = None
136
+ self._session_build_task: Optional[asyncio.Task[str]] = None
126
137
 
127
138
  @classmethod
128
139
  def create(
@@ -659,11 +670,16 @@ class MemWal:
659
670
  Returns:
660
671
  :class:`RememberManualResult` with id, blob_id, owner, namespace.
661
672
  """
662
- data = await self._signed_request("POST", "/api/remember/manual", {
663
- "blob_id": opts.blob_id,
664
- "vector": opts.vector,
665
- "namespace": opts.namespace or self._namespace,
666
- })
673
+ data = await self._signed_request(
674
+ "POST",
675
+ "/api/remember/manual",
676
+ {
677
+ "blob_id": opts.blob_id,
678
+ "vector": opts.vector,
679
+ "namespace": opts.namespace or self._namespace,
680
+ },
681
+ include_seal_session=False,
682
+ )
667
683
  return RememberManualResult(
668
684
  id=data["id"],
669
685
  blob_id=data["blob_id"],
@@ -683,11 +699,16 @@ class MemWal:
683
699
  Returns:
684
700
  :class:`RecallManualResult` with blob_id + distance pairs.
685
701
  """
686
- data = await self._signed_request("POST", "/api/recall/manual", {
687
- "vector": opts.vector,
688
- "limit": opts.limit,
689
- "namespace": opts.namespace or self._namespace,
690
- })
702
+ data = await self._signed_request(
703
+ "POST",
704
+ "/api/recall/manual",
705
+ {
706
+ "vector": opts.vector,
707
+ "limit": opts.limit,
708
+ "namespace": opts.namespace or self._namespace,
709
+ },
710
+ include_seal_session=False,
711
+ )
691
712
  hits = [
692
713
  RecallManualHit(blob_id=h["blob_id"], distance=h["distance"])
693
714
  for h in data.get("results", [])
@@ -706,29 +727,141 @@ class MemWal:
706
727
  # Internal: Signed HTTP Requests
707
728
  # ============================================================
708
729
 
730
+ async def _fetch_server_config(self) -> Dict[str, str]:
731
+ if self._server_config is not None:
732
+ return self._server_config
733
+
734
+ response = await self._http.get(f"{self._server_url}/config")
735
+ if response.status_code != 200:
736
+ raise MemWalError(f"GET /config returned {response.status_code}")
737
+
738
+ data = response.json()
739
+ package_id = data.get("packageId")
740
+ network = data.get("network")
741
+ sui_rpc_url = data.get("suiRpcUrl")
742
+ if not package_id or not network or not sui_rpc_url:
743
+ raise MemWalError("GET /config response missing packageId / network / suiRpcUrl")
744
+
745
+ self._server_config = {
746
+ "packageId": package_id,
747
+ "network": network,
748
+ "suiRpcUrl": sui_rpc_url,
749
+ }
750
+ return self._server_config
751
+
752
+ async def _assert_first_package_version(self, sui_rpc_url: str, package_id: str) -> None:
753
+ response = await self._http.post(
754
+ sui_rpc_url,
755
+ json={
756
+ "jsonrpc": "2.0",
757
+ "id": 1,
758
+ "method": "sui_getObject",
759
+ "params": [package_id, {"showBcs": False, "showContent": False, "showType": False}],
760
+ },
761
+ )
762
+ if response.status_code != 200:
763
+ raise MemWalError(f"sui_getObject returned {response.status_code}")
764
+
765
+ body = response.json()
766
+ result = body.get("result", {})
767
+ version = None
768
+ if isinstance(result, dict):
769
+ data = result.get("data")
770
+ if isinstance(data, dict):
771
+ version = data.get("version")
772
+ if version is None:
773
+ obj = result.get("object")
774
+ if isinstance(obj, dict):
775
+ version = obj.get("version")
776
+ if str(version) != "1":
777
+ raise MemWalError(
778
+ f"SEAL package {package_id} must be at version 1 to build x-seal-session, got {version!r}"
779
+ )
780
+
781
+ async def _build_seal_session_inner(self) -> str:
782
+ cfg = await self._fetch_server_config()
783
+ await self._assert_first_package_version(cfg["suiRpcUrl"], cfg["packageId"])
784
+
785
+ session_signing_key = nacl.signing.SigningKey.generate()
786
+ session_public_key = bytes(session_signing_key.verify_key)
787
+ creation_time_ms = int(time.time() * 1000)
788
+ personal_message = build_seal_session_personal_message(
789
+ package_id=cfg["packageId"],
790
+ ttl_min=SEAL_SESSION_TTL_MIN,
791
+ creation_time_ms=creation_time_ms,
792
+ session_public_key_bytes=session_public_key,
793
+ )
794
+ personal_message_signature = sign_sui_personal_message(
795
+ personal_message,
796
+ self._signing_key,
797
+ )
798
+
799
+ json_str = json.dumps(
800
+ {
801
+ "address": delegate_key_to_sui_address(self._private_key_hex),
802
+ "packageId": cfg["packageId"],
803
+ "mvrName": None,
804
+ "creationTimeMs": creation_time_ms,
805
+ "ttlMin": SEAL_SESSION_TTL_MIN,
806
+ "personalMessageSignature": personal_message_signature,
807
+ "sessionKey": encode_sui_private_key(bytes(session_signing_key)),
808
+ },
809
+ separators=(",", ":"),
810
+ )
811
+ session_bytes = base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
812
+ self._session_cache = (
813
+ session_bytes,
814
+ int(time.time() * 1000) + SEAL_SESSION_TTL_MIN * 60_000 - SEAL_SESSION_SAFETY_MARGIN_MS,
815
+ )
816
+ return session_bytes
817
+
818
+ async def _build_seal_session(self) -> str:
819
+ now_ms = int(time.time() * 1000)
820
+ if self._session_cache is not None:
821
+ cached_bytes, expires_at_ms = self._session_cache
822
+ if now_ms < expires_at_ms:
823
+ return cached_bytes
824
+
825
+ if self._session_build_task is not None:
826
+ return await self._session_build_task
827
+
828
+ self._session_build_task = asyncio.create_task(self._build_seal_session_inner())
829
+ try:
830
+ return await self._session_build_task
831
+ finally:
832
+ self._session_build_task = None
833
+
709
834
  async def _signed_request(
710
835
  self,
711
836
  method: str,
712
837
  path: str,
713
838
  body: Dict[str, Any],
714
839
  accepted_statuses: tuple = (200,),
840
+ include_seal_session: bool = True,
715
841
  ) -> Dict[str, Any]:
716
842
  """Make a signed request to the server.
717
843
 
718
- Signature format: ``{timestamp}.{method}.{path}.{body_sha256}``
844
+ Signature format:
845
+ ``{timestamp}.{method}.{path}.{body_sha256}.{nonce}.{account_id}``
846
+
847
+ For ``GET`` requests the canonical body string is the empty string,
848
+ and no HTTP request body is sent. This keeps the signed payload hash
849
+ byte-compatible with the TypeScript SDK and with intermediaries that
850
+ strip ``GET`` bodies on the wire.
719
851
 
720
852
  Headers sent:
721
853
  - ``x-public-key``: Ed25519 public key hex
722
854
  - ``x-signature``: Ed25519 signature hex
723
855
  - ``x-timestamp``: Unix seconds string
724
- - ``x-delegate-key``: Private key hex
856
+ - ``x-seal-session``: Base64-encoded exported session envelope
725
857
  - ``x-account-id``: MemWalAccount object ID
726
858
  - ``Content-Type``: application/json
727
859
  """
728
860
  import uuid
729
861
 
862
+ method_upper = method.upper()
730
863
  timestamp = str(int(time.time()))
731
- body_str = json.dumps(body, separators=(",", ":"))
864
+ body_str = "" if method_upper == "GET" else json.dumps(body, separators=(",", ":"))
732
865
  body_hash = sha256_hex(body_str)
733
866
  # MED-1 / LOW-23: nonce + account_id are part of the canonical signed
734
867
  # message. Server rejects the request as "unsupported legacy SDK"
@@ -737,7 +870,7 @@ class MemWal:
737
870
 
738
871
  message = build_signature_message(
739
872
  timestamp,
740
- method.upper(),
873
+ method_upper,
741
874
  path,
742
875
  body_hash,
743
876
  nonce=nonce,
@@ -752,15 +885,16 @@ class MemWal:
752
885
  "x-signature": signature_hex,
753
886
  "x-timestamp": timestamp,
754
887
  "x-nonce": nonce,
755
- "x-delegate-key": self._private_key_hex,
756
888
  "x-account-id": self._account_id,
757
889
  }
890
+ if include_seal_session:
891
+ headers["x-seal-session"] = await self._build_seal_session()
758
892
 
759
893
  response = await self._http.request(
760
- method=method.upper(),
894
+ method=method_upper,
761
895
  url=url,
762
896
  headers=headers,
763
- content=body_str,
897
+ content=None if method_upper == "GET" else body_str,
764
898
  )
765
899
 
766
900
  if response.status_code not in accepted_statuses:
@@ -7,11 +7,16 @@ Uses PyNaCl (nacl.signing) as the primary Ed25519 implementation.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import base64
10
11
  import hashlib
12
+ from datetime import datetime, timezone
11
13
  from typing import Tuple
12
14
 
13
15
  import nacl.signing
14
16
 
17
+ _BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
18
+ _SUI_ED25519_SCHEME_FLAG = 0
19
+
15
20
 
16
21
  def hex_to_bytes(hex_str: str) -> bytes:
17
22
  """Convert a hex string to bytes.
@@ -170,3 +175,116 @@ def delegate_key_to_public_key(private_key_hex: str) -> bytes:
170
175
  """
171
176
  signing_key = build_signing_key(private_key_hex)
172
177
  return bytes(signing_key.verify_key)
178
+
179
+
180
+ def _bech32_polymod(values: bytes) -> int:
181
+ generators = [
182
+ 0x3B6A57B2,
183
+ 0x26508E6D,
184
+ 0x1EA119FA,
185
+ 0x3D4233DD,
186
+ 0x2A1462B3,
187
+ ]
188
+ chk = 1
189
+ for value in values:
190
+ top = chk >> 25
191
+ chk = ((chk & 0x1FFFFFF) << 5) ^ value
192
+ for i in range(5):
193
+ if (top >> i) & 1:
194
+ chk ^= generators[i]
195
+ return chk
196
+
197
+
198
+ def _bech32_hrp_expand(hrp: str) -> bytes:
199
+ return bytes([ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp])
200
+
201
+
202
+ def _bech32_create_checksum(hrp: str, data: bytes) -> bytes:
203
+ values = _bech32_hrp_expand(hrp) + data
204
+ polymod = _bech32_polymod(values + bytes(6)) ^ 1
205
+ return bytes((polymod >> 5 * (5 - i)) & 31 for i in range(6))
206
+
207
+
208
+ def _convertbits(data: bytes, frombits: int, tobits: int, pad: bool = True) -> bytes:
209
+ acc = 0
210
+ bits = 0
211
+ ret = []
212
+ maxv = (1 << tobits) - 1
213
+ max_acc = (1 << (frombits + tobits - 1)) - 1
214
+ for value in data:
215
+ if value < 0 or value >> frombits:
216
+ raise ValueError("invalid value for convertbits")
217
+ acc = ((acc << frombits) | value) & max_acc
218
+ bits += frombits
219
+ while bits >= tobits:
220
+ bits -= tobits
221
+ ret.append((acc >> bits) & maxv)
222
+ if pad:
223
+ if bits:
224
+ ret.append((acc << (tobits - bits)) & maxv)
225
+ elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
226
+ raise ValueError("invalid incomplete group for convertbits")
227
+ return bytes(ret)
228
+
229
+
230
+ def bech32_encode(hrp: str, data: bytes) -> str:
231
+ combined = data + _bech32_create_checksum(hrp, data)
232
+ return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined)
233
+
234
+
235
+ def encode_sui_private_key(seed_bytes: bytes) -> str:
236
+ """Encode a 32-byte Ed25519 seed to Sui bech32 `suiprivkey...` format."""
237
+ if len(seed_bytes) != 32:
238
+ raise ValueError(f"Ed25519 seed must be exactly 32 bytes, got {len(seed_bytes)}")
239
+ payload = bytes([_SUI_ED25519_SCHEME_FLAG]) + seed_bytes
240
+ return bech32_encode("suiprivkey", _convertbits(payload, 8, 5))
241
+
242
+
243
+ def uleb128_encode(value: int) -> bytes:
244
+ """Encode an integer using ULEB128."""
245
+ if value < 0:
246
+ raise ValueError("ULEB128 only supports non-negative integers")
247
+ encoded = bytearray()
248
+ while True:
249
+ byte = value & 0x7F
250
+ value >>= 7
251
+ if value:
252
+ encoded.append(byte | 0x80)
253
+ else:
254
+ encoded.append(byte)
255
+ return bytes(encoded)
256
+
257
+
258
+ def serialize_bcs_byte_vector(value: bytes) -> bytes:
259
+ """BCS `vector<u8>` encoding: ULEB128 length prefix followed by bytes."""
260
+ return uleb128_encode(len(value)) + value
261
+
262
+
263
+ def build_seal_session_personal_message(
264
+ package_id: str,
265
+ ttl_min: int,
266
+ creation_time_ms: int,
267
+ session_public_key_bytes: bytes,
268
+ ) -> bytes:
269
+ """Build the SEAL SessionKey personal message string expected by Mysten SEAL."""
270
+ creation_time_utc = datetime.fromtimestamp(
271
+ creation_time_ms / 1000, tz=timezone.utc
272
+ ).strftime("%Y-%m-%d %H:%M:%S UTC")
273
+ session_public_key_b64 = base64.b64encode(session_public_key_bytes).decode("ascii")
274
+ message = (
275
+ f"Accessing keys of package {package_id} for {ttl_min} mins from "
276
+ f"{creation_time_utc}, session key {session_public_key_b64}"
277
+ )
278
+ return message.encode("utf-8")
279
+
280
+
281
+ def sign_sui_personal_message(
282
+ message: bytes, signing_key: nacl.signing.SigningKey
283
+ ) -> str:
284
+ """Sign a Sui PersonalMessage and return serialized base64 signature."""
285
+ intent_message = b"\x03\x00\x00" + serialize_bcs_byte_vector(message)
286
+ digest = hashlib.blake2b(intent_message, digest_size=32).digest()
287
+ signature_bytes = signing_key.sign(digest).signature
288
+ public_key_bytes = bytes(signing_key.verify_key)
289
+ serialized = bytes([_SUI_ED25519_SCHEME_FLAG]) + signature_bytes + public_key_bytes
290
+ return base64.b64encode(serialized).decode("ascii")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.0.dev2"
7
+ version = "0.1.0.dev3"
8
8
  description = "Python SDK for MemWal — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -7,7 +7,9 @@ that the client sends correct headers, body, and handles errors.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import base64
10
11
  import json
12
+ from typing import Any
11
13
 
12
14
  import httpx
13
15
  import nacl.signing
@@ -29,6 +31,38 @@ _TEST_KEY_HEX = bytes_to_hex(bytes(_TEST_KEY))
29
31
  _TEST_PUB_HEX = bytes_to_hex(bytes(_TEST_KEY.verify_key))
30
32
  _TEST_ACCOUNT_ID = "0xabc123"
31
33
  _TEST_SERVER = "http://localhost:8000"
34
+ _TEST_PACKAGE_ID = "0x" + "11" * 32
35
+ _TEST_SUI_RPC = "http://localhost:9001"
36
+
37
+
38
+ def mock_seal_session_prereqs() -> None:
39
+ respx.get(f"{_TEST_SERVER}/config").mock(
40
+ return_value=httpx.Response(
41
+ 200,
42
+ json={
43
+ "packageId": _TEST_PACKAGE_ID,
44
+ "network": "testnet",
45
+ "suiRpcUrl": _TEST_SUI_RPC,
46
+ },
47
+ )
48
+ )
49
+ respx.post(_TEST_SUI_RPC).mock(
50
+ return_value=httpx.Response(
51
+ 200,
52
+ json={
53
+ "result": {
54
+ "data": {
55
+ "version": "1",
56
+ }
57
+ }
58
+ },
59
+ )
60
+ )
61
+
62
+
63
+ def decode_seal_session_header(request: httpx.Request) -> dict[str, Any]:
64
+ header = request.headers["x-seal-session"]
65
+ return json.loads(base64.b64decode(header).decode("utf-8"))
32
66
 
33
67
 
34
68
  @pytest.fixture
@@ -50,6 +84,7 @@ class TestRemember:
50
84
  @respx.mock
51
85
  async def test_sends_correct_body(self, memwal_client: MemWal) -> None:
52
86
  """remember() should POST to /api/remember with text and namespace."""
87
+ mock_seal_session_prereqs()
53
88
  route = respx.post(f"{_TEST_SERVER}/api/remember").mock(
54
89
  return_value=httpx.Response(
55
90
  202,
@@ -73,6 +108,7 @@ class TestRemember:
73
108
  @respx.mock
74
109
  async def test_sends_correct_headers(self, memwal_client: MemWal) -> None:
75
110
  """remember() should include all required auth headers."""
111
+ mock_seal_session_prereqs()
76
112
  route = respx.post(f"{_TEST_SERVER}/api/remember").mock(
77
113
  return_value=httpx.Response(
78
114
  202,
@@ -95,13 +131,22 @@ class TestRemember:
95
131
  assert "x-timestamp" in headers
96
132
  assert headers["x-timestamp"].isdigit()
97
133
  assert "x-nonce" in headers
98
- assert headers["x-delegate-key"] == _TEST_KEY_HEX
99
134
  assert headers["x-account-id"] == _TEST_ACCOUNT_ID
135
+ assert "x-seal-session" in headers
136
+ assert "x-delegate-key" not in headers
100
137
  assert headers["content-type"] == "application/json"
101
138
 
139
+ session = decode_seal_session_header(request)
140
+ assert session["address"].startswith("0x")
141
+ assert session["packageId"] == _TEST_PACKAGE_ID
142
+ assert session["ttlMin"] == 5
143
+ assert session["sessionKey"].startswith("suiprivkey")
144
+ assert session["personalMessageSignature"]
145
+
102
146
  @respx.mock
103
147
  async def test_signature_is_verifiable(self, memwal_client: MemWal) -> None:
104
148
  """The signature in headers should be verifiable with the public key."""
149
+ mock_seal_session_prereqs()
105
150
  route = respx.post(f"{_TEST_SERVER}/api/remember").mock(
106
151
  return_value=httpx.Response(
107
152
  202,
@@ -141,6 +186,7 @@ class TestRemember:
141
186
  @respx.mock
142
187
  async def test_custom_namespace(self, memwal_client: MemWal) -> None:
143
188
  """remember() should use custom namespace when provided."""
189
+ mock_seal_session_prereqs()
144
190
  route = respx.post(f"{_TEST_SERVER}/api/remember").mock(
145
191
  return_value=httpx.Response(
146
192
  202,
@@ -168,6 +214,7 @@ class TestRecall:
168
214
  @respx.mock
169
215
  async def test_sends_correct_body(self, memwal_client: MemWal) -> None:
170
216
  """recall() should POST to /api/recall with query, limit, namespace."""
217
+ mock_seal_session_prereqs()
171
218
  route = respx.post(f"{_TEST_SERVER}/api/recall").mock(
172
219
  return_value=httpx.Response(
173
220
  200,
@@ -198,6 +245,7 @@ class TestRecall:
198
245
  @respx.mock
199
246
  async def test_sends_correct_headers(self, memwal_client: MemWal) -> None:
200
247
  """recall() should include all required auth headers."""
248
+ mock_seal_session_prereqs()
201
249
  route = respx.post(f"{_TEST_SERVER}/api/recall").mock(
202
250
  return_value=httpx.Response(
203
251
  200,
@@ -211,6 +259,38 @@ class TestRecall:
211
259
  assert headers["x-public-key"] == _TEST_PUB_HEX
212
260
  assert len(headers["x-signature"]) == 128
213
261
  assert headers["x-account-id"] == _TEST_ACCOUNT_ID
262
+ assert "x-seal-session" in headers
263
+ assert "x-delegate-key" not in headers
264
+
265
+ @respx.mock
266
+ async def test_get_signed_request_uses_empty_body_hash_and_no_wire_body(
267
+ self, memwal_client: MemWal
268
+ ) -> None:
269
+ mock_seal_session_prereqs()
270
+ route = respx.get(f"{_TEST_SERVER}/api/remember/job-1").mock(
271
+ return_value=httpx.Response(
272
+ 200,
273
+ json={"job_id": "job-1", "status": "done", "blob_id": "b1", "owner": "0xowner"},
274
+ )
275
+ )
276
+
277
+ result = await memwal_client.wait_for_remember_job("job-1", poll_interval_ms=0, timeout_ms=100)
278
+
279
+ request = route.calls[0].request
280
+ assert request.content == b""
281
+
282
+ headers = request.headers
283
+ message = build_signature_message(
284
+ timestamp=headers["x-timestamp"],
285
+ method="GET",
286
+ path="/api/remember/job-1",
287
+ body_sha256=sha256_hex(""),
288
+ nonce=headers["x-nonce"],
289
+ account_id=headers["x-account-id"],
290
+ )
291
+ verify_key = nacl.signing.VerifyKey(bytes.fromhex(headers["x-public-key"]))
292
+ verify_key.verify(message.encode("utf-8"), bytes.fromhex(headers["x-signature"]))
293
+ assert result.blob_id == "b1"
214
294
 
215
295
 
216
296
  # ============================================================
@@ -222,6 +302,7 @@ class TestErrorHandling:
222
302
  @respx.mock
223
303
  async def test_non_200_raises_memwal_error(self, memwal_client: MemWal) -> None:
224
304
  """Non-200 responses should raise MemWalError with status and body."""
305
+ mock_seal_session_prereqs()
225
306
  respx.post(f"{_TEST_SERVER}/api/remember").mock(
226
307
  return_value=httpx.Response(
227
308
  401,
@@ -235,6 +316,7 @@ class TestErrorHandling:
235
316
  @respx.mock
236
317
  async def test_500_raises_memwal_error(self, memwal_client: MemWal) -> None:
237
318
  """Server errors should raise MemWalError."""
319
+ mock_seal_session_prereqs()
238
320
  respx.post(f"{_TEST_SERVER}/api/recall").mock(
239
321
  return_value=httpx.Response(
240
322
  500,
@@ -264,6 +346,7 @@ class TestErrorHandling:
264
346
  class TestAnalyze:
265
347
  @respx.mock
266
348
  async def test_analyze(self, memwal_client: MemWal) -> None:
349
+ mock_seal_session_prereqs()
267
350
  route = respx.post(f"{_TEST_SERVER}/api/analyze").mock(
268
351
  return_value=httpx.Response(
269
352
  200,
@@ -289,6 +372,7 @@ class TestAnalyze:
289
372
  class TestRestore:
290
373
  @respx.mock
291
374
  async def test_restore(self, memwal_client: MemWal) -> None:
375
+ mock_seal_session_prereqs()
292
376
  route = respx.post(f"{_TEST_SERVER}/api/restore").mock(
293
377
  return_value=httpx.Response(
294
378
  200,
@@ -348,8 +432,11 @@ class TestManualAPI:
348
432
  result = await memwal_client.remember_manual(opts)
349
433
 
350
434
  body = json.loads(route.calls[0].request.content)
435
+ headers = route.calls[0].request.headers
351
436
  assert body["blob_id"] == "blob-xyz"
352
437
  assert body["vector"] == [0.1, 0.2, 0.3]
438
+ assert "x-seal-session" not in headers
439
+ assert "x-delegate-key" not in headers
353
440
  assert result.blob_id == "blob-xyz"
354
441
 
355
442
  @respx.mock
@@ -370,8 +457,11 @@ class TestManualAPI:
370
457
  result = await memwal_client.recall_manual(opts)
371
458
 
372
459
  body = json.loads(route.calls[0].request.content)
460
+ headers = route.calls[0].request.headers
373
461
  assert body["vector"] == [0.1, 0.2, 0.3]
374
462
  assert body["limit"] == 5
463
+ assert "x-seal-session" not in headers
464
+ assert "x-delegate-key" not in headers
375
465
  assert len(result.results) == 1
376
466
  assert result.results[0].blob_id == "b1"
377
467
 
@@ -386,6 +476,7 @@ class TestPublicKey:
386
476
  class TestAsk:
387
477
  @respx.mock
388
478
  async def test_ask(self, memwal_client: MemWal) -> None:
479
+ mock_seal_session_prereqs()
389
480
  route = respx.post(f"{_TEST_SERVER}/api/ask").mock(
390
481
  return_value=httpx.Response(
391
482
  200,
@@ -413,6 +504,7 @@ class TestAsk:
413
504
 
414
505
  @respx.mock
415
506
  async def test_ask_empty_memories(self, memwal_client: MemWal) -> None:
507
+ mock_seal_session_prereqs()
416
508
  respx.post(f"{_TEST_SERVER}/api/ask").mock(
417
509
  return_value=httpx.Response(
418
510
  200,
@@ -430,6 +522,7 @@ class TestAsk:
430
522
 
431
523
  @respx.mock
432
524
  async def test_ask_custom_namespace(self, memwal_client: MemWal) -> None:
525
+ mock_seal_session_prereqs()
433
526
  route = respx.post(f"{_TEST_SERVER}/api/ask").mock(
434
527
  return_value=httpx.Response(
435
528
  200,
@@ -461,3 +554,19 @@ class TestContextManager:
461
554
  ) as client:
462
555
  result = await client.health()
463
556
  assert result.status == "ok"
557
+
558
+
559
+ class TestSealSession:
560
+ @respx.mock
561
+ async def test_builds_cached_session_envelope(self, memwal_client: MemWal) -> None:
562
+ mock_seal_session_prereqs()
563
+
564
+ first = await memwal_client._build_seal_session()
565
+ second = await memwal_client._build_seal_session()
566
+
567
+ exported = json.loads(base64.b64decode(first).decode("utf-8"))
568
+ assert first == second
569
+ assert exported["packageId"] == _TEST_PACKAGE_ID
570
+ assert exported["ttlMin"] == 5
571
+ assert exported["sessionKey"].startswith("suiprivkey")
572
+ assert exported["personalMessageSignature"]
@@ -13,7 +13,8 @@ No-auth tests (always run, no env vars needed):
13
13
  - Unregistered key → SDK raises MemWalError
14
14
 
15
15
  Authenticated tests (require MEMWAL_KEY + MEMWAL_ACCOUNT_ID):
16
- - remember()
16
+ - remember() acceptance
17
+ - remember_and_wait()
17
18
  - recall()
18
19
  - analyze()
19
20
  - ask()
@@ -181,30 +182,39 @@ class TestAuthRejection:
181
182
 
182
183
  @requires_key
183
184
  class TestRemember:
184
- """remember() against live server."""
185
+ """remember() / remember_and_wait() against live server."""
185
186
 
186
- def test_remember_returns_id_and_blob(self) -> None:
187
+ def test_remember_returns_job_id_and_status(self) -> None:
187
188
  mw = MemWalSync.create(
188
189
  key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
189
190
  )
190
191
  result = mw.remember("Integration test: the sky is blue", namespace="sdk-test")
192
+ assert result.job_id is not None and isinstance(result.job_id, str)
193
+ assert result.status in ("pending", "running")
194
+ print(f"\n accepted job={result.job_id[:8]}... status={result.status}")
195
+
196
+ def test_remember_and_wait_returns_blob_and_owner(self) -> None:
197
+ mw = MemWalSync.create(
198
+ key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
199
+ )
200
+ result = mw.remember_and_wait("Integration test: the sky is blue", namespace="sdk-test")
191
201
  assert result.id is not None and isinstance(result.id, str)
192
202
  assert result.blob_id is not None and isinstance(result.blob_id, str)
193
203
  assert result.owner.startswith("0x")
194
- print(f"\n id={result.id[:8]}... blob={result.blob_id[:8]}...")
204
+ print(f"\n done job={result.id[:8]}... blob={result.blob_id[:8]}...")
195
205
 
196
206
  def test_remember_default_namespace(self) -> None:
197
207
  mw = MemWalSync.create(
198
208
  key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
199
209
  )
200
- result = mw.remember("Integration test: namespace default")
210
+ result = mw.remember_and_wait("Integration test: namespace default")
201
211
  assert result.namespace == "default"
202
212
 
203
213
  def test_remember_custom_namespace(self) -> None:
204
214
  mw = MemWalSync.create(
205
215
  key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
206
216
  )
207
- result = mw.remember("Integration test: custom namespace", namespace="sdk-test")
217
+ result = mw.remember_and_wait("Integration test: custom namespace", namespace="sdk-test")
208
218
  assert result.namespace == "sdk-test"
209
219
 
210
220
 
@@ -292,7 +302,7 @@ class TestFullFlow:
292
302
  )
293
303
 
294
304
  # Store a distinctive memory in an isolated namespace
295
- mem = mw.remember(text, namespace=ns)
305
+ mem = mw.remember_and_wait(text, namespace=ns)
296
306
  assert mem.id is not None
297
307
 
298
308
  # Recall — should find the stored memory
@@ -309,7 +319,7 @@ class TestFullFlow:
309
319
  mw = MemWalSync.create(
310
320
  key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
311
321
  )
312
- mw.remember("I am allergic to shellfish", namespace="sdk-test")
322
+ mw.remember_and_wait("I am allergic to shellfish", namespace="sdk-test")
313
323
  result = mw.ask("What are my food allergies?", limit=3, namespace="sdk-test")
314
324
  assert isinstance(result.answer, str)
315
325
  assert len(result.answer) > 0
@@ -335,13 +345,14 @@ class TestAsync:
335
345
  key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
336
346
  ) as mw:
337
347
  result = await mw.remember("Async SDK test: Paris is the capital of France")
338
- assert result.id is not None
348
+ assert result.job_id is not None
349
+ assert result.status in ("pending", "running")
339
350
 
340
351
  async def test_async_recall(self) -> None:
341
352
  async with MemWal.create(
342
353
  key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
343
354
  ) as mw:
344
- await mw.remember("Async SDK test: I enjoy reading")
355
+ await mw.remember_and_wait("Async SDK test: I enjoy reading")
345
356
  result = await mw.recall("reading books", limit=3)
346
357
  assert isinstance(result.results, list)
347
358
 
@@ -51,6 +51,37 @@ _SERVER = "http://localhost:8000"
51
51
 
52
52
  _RECALL_URL = f"{_SERVER}/api/recall"
53
53
  _ANALYZE_URL = f"{_SERVER}/api/analyze"
54
+ _SUI_RPC_URL = "http://localhost:9001"
55
+ _PACKAGE_ID = "0x" + "11" * 32
56
+
57
+
58
+ def _mock_seal_session_prereqs() -> None:
59
+ """Register mocks for the SEAL session prerequisites.
60
+
61
+ Every relayer-mode signed request (recall, analyze, remember) now starts
62
+ by fetching ``GET /config`` and verifying the SEAL package version via
63
+ ``sui_getObject``. Tests that drive MemWal through ``with_memwal_*`` must
64
+ register these or the first signed request raises
65
+ ``AllMockedAssertionError`` from respx.
66
+
67
+ Mirrors the helper of the same name in ``test_client.py``.
68
+ """
69
+ respx.get(f"{_SERVER}/config").mock(
70
+ return_value=httpx.Response(
71
+ 200,
72
+ json={
73
+ "packageId": _PACKAGE_ID,
74
+ "network": "testnet",
75
+ "suiRpcUrl": _SUI_RPC_URL,
76
+ },
77
+ )
78
+ )
79
+ respx.post(_SUI_RPC_URL).mock(
80
+ return_value=httpx.Response(
81
+ 200,
82
+ json={"result": {"data": {"version": "1"}}},
83
+ )
84
+ )
54
85
 
55
86
 
56
87
  def _mock_recall(memories: list, *, total: int | None = None) -> httpx.Response:
@@ -213,6 +244,7 @@ class TestWithMemWalLangChain:
213
244
  @respx.mock
214
245
  async def test_memories_injected_as_system_message(self) -> None:
215
246
  """When memories are found, a SystemMessage is injected before the HumanMessage."""
247
+ _mock_seal_session_prereqs()
216
248
  from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
217
249
  from langchain_core.outputs import ChatGeneration, ChatResult
218
250
 
@@ -249,6 +281,7 @@ class TestWithMemWalLangChain:
249
281
  @respx.mock
250
282
  async def test_no_memories_found_no_injection(self) -> None:
251
283
  """When no memories match, the message list is passed unchanged."""
284
+ _mock_seal_session_prereqs()
252
285
  from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
253
286
  from langchain_core.outputs import ChatGeneration, ChatResult
254
287
 
@@ -278,6 +311,7 @@ class TestWithMemWalLangChain:
278
311
  @respx.mock
279
312
  async def test_min_relevance_filters_low_score_memories(self) -> None:
280
313
  """Memories below min_relevance are filtered out."""
314
+ _mock_seal_session_prereqs()
281
315
  from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
282
316
  from langchain_core.outputs import ChatGeneration, ChatResult
283
317
 
@@ -313,6 +347,7 @@ class TestWithMemWalLangChain:
313
347
  @respx.mock
314
348
  async def test_recall_failure_does_not_block_llm(self) -> None:
315
349
  """If MemWal recall fails, the LLM is still called with original messages."""
350
+ _mock_seal_session_prereqs()
316
351
  from langchain_core.messages import HumanMessage
317
352
 
318
353
  llm = self._make_llm()
@@ -329,6 +364,7 @@ class TestWithMemWalLangChain:
329
364
  @respx.mock
330
365
  async def test_auto_save_triggers_analyze(self) -> None:
331
366
  """With auto_save=True, analyze() is called fire-and-forget after LLM call."""
367
+ _mock_seal_session_prereqs()
332
368
  from langchain_core.messages import HumanMessage
333
369
 
334
370
  llm = self._make_llm()
@@ -348,6 +384,7 @@ class TestWithMemWalLangChain:
348
384
  @respx.mock
349
385
  async def test_no_user_message_no_recall(self) -> None:
350
386
  """If there's no user message, recall is not called."""
387
+ _mock_seal_session_prereqs()
351
388
  from langchain_core.messages import SystemMessage
352
389
 
353
390
  llm = self._make_llm()
@@ -402,6 +439,7 @@ class TestWithMemWalOpenAI:
402
439
  @respx.mock
403
440
  async def test_async_client_injects_memory(self) -> None:
404
441
  """Async OpenAI client: memory injected as system message before user message."""
442
+ _mock_seal_session_prereqs()
405
443
  client = self._make_async_client()
406
444
  captured_messages: list = []
407
445
 
@@ -434,6 +472,7 @@ class TestWithMemWalOpenAI:
434
472
  @respx.mock
435
473
  async def test_async_client_no_memories_no_injection(self) -> None:
436
474
  """No memories → original messages passed unchanged."""
475
+ _mock_seal_session_prereqs()
437
476
  client = self._make_async_client()
438
477
  captured: list = []
439
478
 
@@ -460,6 +499,7 @@ class TestWithMemWalOpenAI:
460
499
  @respx.mock
461
500
  async def test_async_client_recall_failure_resilient(self) -> None:
462
501
  """If recall fails, the LLM call still proceeds."""
502
+ _mock_seal_session_prereqs()
463
503
  client = self._make_async_client()
464
504
 
465
505
  respx.post(_RECALL_URL).mock(return_value=httpx.Response(500, text="error"))
@@ -477,6 +517,7 @@ class TestWithMemWalOpenAI:
477
517
  @respx.mock
478
518
  async def test_async_client_auto_save(self) -> None:
479
519
  """With auto_save=True, analyze is triggered after completion."""
520
+ _mock_seal_session_prereqs()
480
521
  client = self._make_async_client()
481
522
 
482
523
  respx.post(_RECALL_URL).mock(return_value=_mock_recall([]))
@@ -496,6 +537,7 @@ class TestWithMemWalOpenAI:
496
537
  @respx.mock
497
538
  async def test_min_relevance_filter(self) -> None:
498
539
  """Memories with relevance below min_relevance are not injected."""
540
+ _mock_seal_session_prereqs()
499
541
  client = self._make_async_client()
500
542
  captured: list = []
501
543
 
@@ -528,6 +570,7 @@ class TestWithMemWalOpenAI:
528
570
  @respx.mock
529
571
  def test_sync_client_wraps_create(self) -> None:
530
572
  """Sync OpenAI client wrapper is applied correctly."""
573
+ _mock_seal_session_prereqs()
531
574
  client = self._make_sync_client()
532
575
  original_create = client.chat.completions.create
533
576
 
@@ -7,18 +7,22 @@ Validates that:
7
7
  3. The signature message format matches the server expectation exactly
8
8
  """
9
9
 
10
+ import base64
10
11
  import hashlib
11
12
  import json
12
13
 
13
14
  import nacl.signing
14
15
 
15
16
  from memwal.utils import (
17
+ build_seal_session_personal_message,
16
18
  build_signature_message,
17
19
  build_signing_key,
18
20
  bytes_to_hex,
21
+ encode_sui_private_key,
19
22
  hex_to_bytes,
20
23
  sha256_hex,
21
24
  sign_message,
25
+ sign_sui_personal_message,
22
26
  )
23
27
 
24
28
 
@@ -242,3 +246,29 @@ class TestDelegateKeyUtils:
242
246
  scheme_input = bytes([0x00]) + pub
243
247
  expected = "0x" + hashlib.blake2b(scheme_input, digest_size=32).hexdigest()
244
248
  assert delegate_key_to_sui_address(self._KEY) == expected
249
+
250
+
251
+ class TestSealSessionUtils:
252
+ def test_encode_sui_private_key(self) -> None:
253
+ seed = b"\x02" * 32
254
+ encoded = encode_sui_private_key(seed)
255
+ assert encoded.startswith("suiprivkey1")
256
+
257
+ def test_build_personal_message(self) -> None:
258
+ message = build_seal_session_personal_message(
259
+ package_id="0x" + "11" * 32,
260
+ ttl_min=5,
261
+ creation_time_ms=1_700_000_000_000,
262
+ session_public_key_bytes=b"\x03" * 32,
263
+ )
264
+ decoded = message.decode("utf-8")
265
+ assert "Accessing keys of package" in decoded
266
+ assert "for 5 mins" in decoded
267
+ assert "session key" in decoded
268
+
269
+ def test_sign_sui_personal_message(self) -> None:
270
+ key = nacl.signing.SigningKey.generate()
271
+ signature = sign_sui_personal_message(b"hello", key)
272
+ raw = json.loads(json.dumps(signature)) # assert it's JSON-safe text
273
+ assert isinstance(raw, str)
274
+ assert len(base64.b64decode(signature)) == 97
File without changes
File without changes
File without changes
File without changes