memwal 0.1.7.dev0__tar.gz → 0.1.7.dev2__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.
Files changed (27) hide show
  1. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/CHANGELOG.md +6 -0
  2. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/PKG-INFO +1 -1
  3. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/__init__.py +3 -1
  4. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/client.py +22 -2
  5. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/utils.py +52 -0
  6. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/pyproject.toml +1 -1
  7. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_client.py +25 -1
  8. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_integration.py +72 -60
  9. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_signing.py +59 -3
  10. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/.gitignore +0 -0
  11. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/README.md +0 -0
  12. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/examples/.env.example +0 -0
  13. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/examples/.gitignore +0 -0
  14. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/examples/async_remember_demo.py +0 -0
  15. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/examples/interactive_demo.py +0 -0
  16. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/examples/verify_credentials.py +0 -0
  17. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/compatibility.py +0 -0
  18. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/middleware.py +0 -0
  19. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/mock.py +0 -0
  20. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/types.py +0 -0
  21. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  22. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/run_tests.py +0 -0
  23. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/__init__.py +0 -0
  24. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_auth_rejected_message.py +0 -0
  25. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_env_presets.py +0 -0
  26. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_middleware.py +0 -0
  27. {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_mock.py +0 -0
@@ -1,5 +1,11 @@
1
1
  # memwal
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Added
6
+
7
+ - Added `MemWalClockDriftError`, raised when the relayer rejects a request because the signed timestamp is outside its accepted clock-drift window (`401` + `x-auth-error: ERR_TIMESTAMP_OUT_OF_BOUNDS`). Surfaces an actionable "synchronize the client clock" message instead of an opaque `401`. Subclasses `MemWalError`, so existing `except MemWalError` handlers still catch it.
8
+
3
9
  ## 0.1.7
4
10
 
5
11
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.1.7.dev0
3
+ Version: 0.1.7.dev2
4
4
  Summary: Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing
5
5
  Project-URL: Homepage, https://memory.walrus.xyz
6
6
  Project-URL: Documentation, https://memory.walrus.xyz
@@ -24,6 +24,7 @@ Quick start::
24
24
 
25
25
  from .client import (
26
26
  MemWal,
27
+ MemWalClockDriftError,
27
28
  MemWalCompatibilityError,
28
29
  MemWalError,
29
30
  MemWalRememberJobFailed,
@@ -78,6 +79,7 @@ __all__ = [
78
79
  "MemWalMockSeed",
79
80
  "MemWalError",
80
81
  "MemWalCompatibilityError",
82
+ "MemWalClockDriftError",
81
83
  "MemWalRememberJobFailed",
82
84
  "MemWalRememberJobNotFound",
83
85
  "MemWalRememberJobTimeout",
@@ -120,4 +122,4 @@ __all__ = [
120
122
  "RecallManualResult",
121
123
  ]
122
124
 
123
- __version__ = "0.1.7.dev0"
125
+ __version__ = "0.1.7.dev2"
@@ -73,6 +73,7 @@ from .utils import (
73
73
  bytes_to_hex,
74
74
  delegate_key_to_sui_address,
75
75
  encode_sui_private_key,
76
+ normalize_private_key,
76
77
  sha256_hex,
77
78
  sign_message,
78
79
  sign_sui_personal_message,
@@ -213,8 +214,8 @@ class MemWal:
213
214
  """
214
215
 
215
216
  def __init__(self, config: MemWalConfig) -> None:
216
- self._signing_key = build_signing_key(config.key)
217
- self._private_key_hex = config.key if not config.key.startswith("0x") else config.key[2:]
217
+ self._private_key_hex = normalize_private_key(config.key)
218
+ self._signing_key = build_signing_key(self._private_key_hex)
218
219
  self._account_id = config.account_id
219
220
  self._server_url = config.server_url.rstrip("/")
220
221
  self._namespace = config.namespace
@@ -1189,6 +1190,16 @@ class MemWal:
1189
1190
  f"(HTTP 426 Upgrade Required). Relayer response: "
1190
1191
  f"{err_text[:300] or 'upgrade required'}"
1191
1192
  )
1193
+ # A stale/future-dated signature is rejected with 401 + a machine-
1194
+ # readable reason header. Surface it as an actionable clock-drift
1195
+ # error rather than an opaque 401 so the caller can fix node time.
1196
+ if response.headers.get("x-auth-error") == "ERR_TIMESTAMP_OUT_OF_BOUNDS":
1197
+ raise MemWalClockDriftError(
1198
+ "Request rejected: signed timestamp is outside the relayer's "
1199
+ "accepted clock-drift window. Synchronize this client's clock "
1200
+ "(NTP); if the deployment needs a wider tolerance, raise "
1201
+ "AUTH_MAX_CLOCK_DRIFT_SECS on the relayer."
1202
+ )
1192
1203
  raise _HttpStatusError(
1193
1204
  status=response.status_code,
1194
1205
  body=err_text,
@@ -1209,6 +1220,15 @@ class MemWalCompatibilityError(MemWalError):
1209
1220
  pass
1210
1221
 
1211
1222
 
1223
+ class MemWalClockDriftError(MemWalError):
1224
+ """Raised when the relayer rejects a request because the signed timestamp is
1225
+ outside its accepted clock-drift window (401 + ``x-auth-error:
1226
+ ERR_TIMESTAMP_OUT_OF_BOUNDS``). Indicates the client's clock is skewed
1227
+ relative to the relayer; sync via NTP or widen the relayer's window."""
1228
+
1229
+ pass
1230
+
1231
+
1212
1232
  class _HttpStatusError(MemWalError):
1213
1233
  """Internal: raised when an HTTP response status is not in ``accepted_statuses``.
1214
1234
 
@@ -232,6 +232,58 @@ def bech32_encode(hrp: str, data: bytes) -> str:
232
232
  return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined)
233
233
 
234
234
 
235
+ def bech32_decode(bech: str) -> Tuple[str, bytes]:
236
+ """Decode a bech32 string into its human-readable part and 5-bit data."""
237
+ if bech != bech.lower() and bech != bech.upper():
238
+ raise ValueError("bech32 string is mixed case")
239
+ bech = bech.lower()
240
+ pos = bech.rfind("1")
241
+ if pos < 1 or pos + 7 > len(bech):
242
+ raise ValueError("bech32 string has no valid separator")
243
+
244
+ hrp = bech[:pos]
245
+ try:
246
+ data = bytes(_BECH32_CHARSET.index(c) for c in bech[pos + 1 :])
247
+ except ValueError as exc:
248
+ raise ValueError("bech32 string has a character outside the charset") from exc
249
+
250
+ if _bech32_polymod(_bech32_hrp_expand(hrp) + data) != 1:
251
+ raise ValueError("bech32 checksum mismatch")
252
+ return hrp, data[:-6]
253
+
254
+
255
+ def decode_sui_private_key(encoded: str) -> bytes:
256
+ """Decode a Sui bech32 ``suiprivkey1...`` string to its 32-byte Ed25519 seed.
257
+
258
+ Inverse of :func:`encode_sui_private_key`. Mirrors ``decodeSuiPrivateKey``
259
+ from ``@mysten/sui``, which the TypeScript SDK uses.
260
+ """
261
+ hrp, data = bech32_decode(encoded)
262
+ if hrp != "suiprivkey":
263
+ raise ValueError(f"expected a suiprivkey string, got prefix {hrp!r}")
264
+
265
+ payload = _convertbits(data, 5, 8, pad=False)
266
+ if not payload or payload[0] != _SUI_ED25519_SCHEME_FLAG:
267
+ raise ValueError("only Ed25519 private keys are supported")
268
+
269
+ seed = payload[1:]
270
+ if len(seed) != 32:
271
+ raise ValueError(f"Ed25519 seed must be exactly 32 bytes, got {len(seed)}")
272
+ return seed
273
+
274
+
275
+ def normalize_private_key(key: str) -> str:
276
+ """Accept either a hex seed or a Sui ``suiprivkey1...`` string, return hex.
277
+
278
+ The TypeScript SDK takes both, and the dashboard hands out the bech32 form,
279
+ so hex-only input would reject a key a user reasonably expects to work.
280
+ """
281
+ candidate = key.strip()
282
+ if candidate.lower().startswith("suiprivkey1"):
283
+ return bytes_to_hex(decode_sui_private_key(candidate))
284
+ return candidate[2:] if candidate.startswith("0x") else candidate
285
+
286
+
235
287
  def encode_sui_private_key(seed_bytes: bytes) -> str:
236
288
  """Encode a 32-byte Ed25519 seed to Sui bech32 `suiprivkey...` format."""
237
289
  if len(seed_bytes) != 32:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.7.dev0"
7
+ version = "0.1.7.dev2"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -17,7 +17,13 @@ import nacl.signing
17
17
  import pytest
18
18
  import respx
19
19
 
20
- from memwal.client import MemWal, MemWalCompatibilityError, MemWalError, MemWalSync
20
+ from memwal.client import (
21
+ MemWal,
22
+ MemWalClockDriftError,
23
+ MemWalCompatibilityError,
24
+ MemWalError,
25
+ MemWalSync,
26
+ )
21
27
  from memwal.types import (
22
28
  RecallManualOptions,
23
29
  RecallParams,
@@ -468,6 +474,24 @@ class TestErrorHandling:
468
474
  ):
469
475
  await memwal_client.recall("test")
470
476
 
477
+ @respx.mock
478
+ async def test_clock_drift_header_raises_clock_drift_error(
479
+ self, memwal_client: MemWal
480
+ ) -> None:
481
+ """A 401 carrying x-auth-error: ERR_TIMESTAMP_OUT_OF_BOUNDS should surface
482
+ as an actionable MemWalClockDriftError, not an opaque HTTP error."""
483
+ mock_seal_session_prereqs()
484
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(
485
+ return_value=httpx.Response(
486
+ 401,
487
+ headers={"x-auth-error": "ERR_TIMESTAMP_OUT_OF_BOUNDS"},
488
+ text="",
489
+ )
490
+ )
491
+
492
+ with pytest.raises(MemWalClockDriftError, match="clock-drift window"):
493
+ await memwal_client.remember("test")
494
+
471
495
  @respx.mock
472
496
  async def test_500_raises_memwal_error(self, memwal_client: MemWal) -> None:
473
497
  """Server errors should raise MemWalError."""
@@ -58,6 +58,18 @@ SERVER_URL = os.environ.get("MEMWAL_SERVER_URL", "https://relayer-staging.memory
58
58
  PRIVATE_KEY_HEX = os.environ.get("MEMWAL_PRIVATE_KEY", "")
59
59
  ACCOUNT_ID = os.environ.get("MEMWAL_ACCOUNT_ID", "")
60
60
 
61
+ # A live write runs embed -> SEAL encrypt -> Walrus upload -> on-chain metadata.
62
+ # Measured around 44s against the dev relayer, so the SDK's 60s default leaves
63
+ # too little headroom to be reliable in CI. 120s matches what the SDK already
64
+ # uses for bulk pipelines.
65
+ _REMEMBER_TIMEOUT_MS = int(os.environ.get("MEMWAL_REMEMBER_TIMEOUT_MS", "120000"))
66
+
67
+ # Every authenticated test writes into a namespace unique to this run. The bench
68
+ # account is shared, and `default` in particular is what real users get, so a
69
+ # recurring job must not leave live Walrus blobs there.
70
+ _E2E_NAMESPACE = f"sdk-e2e-{uuid.uuid4().hex[:8]}"
71
+ _E2E_NAMESPACE_ALT = f"{_E2E_NAMESPACE}-alt"
72
+
61
73
  HAS_KEY = bool(PRIVATE_KEY_HEX and ACCOUNT_ID)
62
74
 
63
75
  requires_key = pytest.mark.skipif(
@@ -65,6 +77,24 @@ requires_key = pytest.mark.skipif(
65
77
  reason="MEMWAL_PRIVATE_KEY and MEMWAL_ACCOUNT_ID not set",
66
78
  )
67
79
 
80
+
81
+ def _sync_client(namespace: str = _E2E_NAMESPACE) -> MemWalSync:
82
+ return MemWalSync.create(
83
+ key=PRIVATE_KEY_HEX,
84
+ account_id=ACCOUNT_ID,
85
+ server_url=SERVER_URL,
86
+ namespace=namespace,
87
+ )
88
+
89
+
90
+ def _async_client(namespace: str = _E2E_NAMESPACE) -> MemWal:
91
+ return MemWal.create(
92
+ key=PRIVATE_KEY_HEX,
93
+ account_id=ACCOUNT_ID,
94
+ server_url=SERVER_URL,
95
+ namespace=namespace,
96
+ )
97
+
68
98
  # ── Helpers ───────────────────────────────────────────────────────────────────
69
99
 
70
100
 
@@ -189,37 +219,42 @@ class TestRemember:
189
219
  """remember() / remember_and_wait() against live server."""
190
220
 
191
221
  def test_remember_returns_job_id_and_status(self) -> None:
192
- mw = MemWalSync.create(
193
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
194
- )
195
- result = mw.remember("Integration test: the sky is blue", namespace="sdk-test")
222
+ mw = _sync_client()
223
+ result = mw.remember("Integration test: the sky is blue")
196
224
  assert result.job_id is not None and isinstance(result.job_id, str)
197
225
  assert result.status in ("pending", "running")
198
226
  print(f"\n accepted job={result.job_id[:8]}... status={result.status}")
199
227
 
200
228
  def test_remember_and_wait_returns_blob_and_owner(self) -> None:
201
- mw = MemWalSync.create(
202
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
229
+ mw = _sync_client()
230
+ result = mw.remember_and_wait(
231
+ "Integration test: the sky is blue", timeout_ms=_REMEMBER_TIMEOUT_MS
203
232
  )
204
- result = mw.remember_and_wait("Integration test: the sky is blue", namespace="sdk-test")
205
233
  assert result.id is not None and isinstance(result.id, str)
206
234
  assert result.blob_id is not None and isinstance(result.blob_id, str)
207
235
  assert result.owner.startswith("0x")
208
236
  print(f"\n done job={result.id[:8]}... blob={result.blob_id[:8]}...")
209
237
 
210
- def test_remember_default_namespace(self) -> None:
211
- mw = MemWalSync.create(
212
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
238
+ def test_remember_uses_the_client_namespace(self) -> None:
239
+ """Omitting `namespace` falls back to the one the client was built with.
240
+
241
+ The literal `"default"` fallback is asserted in the mocked suite; proving
242
+ it here would mean writing a live blob into the namespace real users get.
243
+ """
244
+ mw = _sync_client()
245
+ result = mw.remember_and_wait(
246
+ "Integration test: namespace fallback", timeout_ms=_REMEMBER_TIMEOUT_MS
213
247
  )
214
- result = mw.remember_and_wait("Integration test: namespace default")
215
- assert result.namespace == "default"
248
+ assert result.namespace == _E2E_NAMESPACE
216
249
 
217
250
  def test_remember_custom_namespace(self) -> None:
218
- mw = MemWalSync.create(
219
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
251
+ mw = _sync_client()
252
+ result = mw.remember_and_wait(
253
+ "Integration test: custom namespace",
254
+ namespace=_E2E_NAMESPACE_ALT,
255
+ timeout_ms=_REMEMBER_TIMEOUT_MS,
220
256
  )
221
- result = mw.remember_and_wait("Integration test: custom namespace", namespace="sdk-test")
222
- assert result.namespace == "sdk-test"
257
+ assert result.namespace == _E2E_NAMESPACE_ALT
223
258
 
224
259
 
225
260
  @requires_key
@@ -227,25 +262,19 @@ class TestRecall:
227
262
  """recall() against live server."""
228
263
 
229
264
  def test_recall_returns_list(self) -> None:
230
- mw = MemWalSync.create(
231
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
232
- )
265
+ mw = _sync_client()
233
266
  result = mw.recall("sky blue", limit=5)
234
267
  assert isinstance(result.results, list)
235
268
  assert result.total >= 0
236
269
  print(f"\n recall total={result.total}")
237
270
 
238
271
  def test_recall_respects_limit(self) -> None:
239
- mw = MemWalSync.create(
240
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
241
- )
272
+ mw = _sync_client()
242
273
  result = mw.recall("test", limit=2)
243
274
  assert len(result.results) <= 2
244
275
 
245
276
  def test_recall_result_has_expected_fields(self) -> None:
246
- mw = MemWalSync.create(
247
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
248
- )
277
+ mw = _sync_client()
249
278
  result = mw.recall("test", limit=3)
250
279
  for mem in result.results:
251
280
  assert isinstance(mem.text, str)
@@ -258,13 +287,8 @@ class TestAnalyze:
258
287
  """analyze() against live server."""
259
288
 
260
289
  def test_analyze_returns_facts(self) -> None:
261
- mw = MemWalSync.create(
262
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
263
- )
264
- result = mw.analyze(
265
- "I love hiking and my favorite food is pho.",
266
- namespace="sdk-test",
267
- )
290
+ mw = _sync_client()
291
+ result = mw.analyze("I love hiking and my favorite food is pho.")
268
292
  assert isinstance(result.facts, list)
269
293
  assert result.total >= 0
270
294
  assert result.owner.startswith("0x")
@@ -278,9 +302,7 @@ class TestAsk:
278
302
  """ask() against live server."""
279
303
 
280
304
  def test_ask_returns_string_answer(self) -> None:
281
- mw = MemWalSync.create(
282
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
283
- )
305
+ mw = _sync_client()
284
306
  result = mw.ask("What outdoor activities do I enjoy?", limit=3)
285
307
  assert isinstance(result.answer, str)
286
308
  assert len(result.answer) > 0
@@ -301,12 +323,10 @@ class TestFullFlow:
301
323
  text = f"SDK e2e test {unique}: quantum entanglement in photonics"
302
324
  ns = f"sdk-e2e-{unique}"
303
325
 
304
- mw = MemWalSync.create(
305
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
306
- )
326
+ mw = _sync_client()
307
327
 
308
328
  # Store a distinctive memory in an isolated namespace
309
- mem = mw.remember_and_wait(text, namespace=ns)
329
+ mem = mw.remember_and_wait(text, namespace=ns, timeout_ms=_REMEMBER_TIMEOUT_MS)
310
330
  assert mem.id is not None
311
331
 
312
332
  # Recall — should find the stored memory
@@ -320,11 +340,11 @@ class TestFullFlow:
320
340
 
321
341
  def test_remember_then_ask_uses_memory(self) -> None:
322
342
  """remember → ask — answer should reference the stored fact."""
323
- mw = MemWalSync.create(
324
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
343
+ mw = _sync_client()
344
+ mw.remember_and_wait(
345
+ "I am allergic to shellfish", timeout_ms=_REMEMBER_TIMEOUT_MS
325
346
  )
326
- mw.remember_and_wait("I am allergic to shellfish", namespace="sdk-test")
327
- result = mw.ask("What are my food allergies?", limit=3, namespace="sdk-test")
347
+ result = mw.ask("What are my food allergies?", limit=3)
328
348
  assert isinstance(result.answer, str)
329
349
  assert len(result.answer) > 0
330
350
  print(f"\n ask answer: {result.answer[:100]}")
@@ -338,38 +358,30 @@ class TestAsync:
338
358
  """Async client variants."""
339
359
 
340
360
  async def test_async_health(self) -> None:
341
- async with MemWal.create(
342
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
343
- ) as mw:
361
+ async with _async_client() as mw:
344
362
  result = await mw.health()
345
363
  assert result.status == "ok"
346
364
 
347
365
  async def test_async_remember(self) -> None:
348
- async with MemWal.create(
349
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
350
- ) as mw:
366
+ async with _async_client() as mw:
351
367
  result = await mw.remember("Async SDK test: Paris is the capital of France")
352
368
  assert result.job_id is not None
353
369
  assert result.status in ("pending", "running")
354
370
 
355
371
  async def test_async_recall(self) -> None:
356
- async with MemWal.create(
357
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
358
- ) as mw:
359
- await mw.remember_and_wait("Async SDK test: I enjoy reading")
372
+ async with _async_client() as mw:
373
+ await mw.remember_and_wait(
374
+ "Async SDK test: I enjoy reading", timeout_ms=_REMEMBER_TIMEOUT_MS
375
+ )
360
376
  result = await mw.recall("reading books", limit=3)
361
377
  assert isinstance(result.results, list)
362
378
 
363
379
  async def test_async_analyze(self) -> None:
364
- async with MemWal.create(
365
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
366
- ) as mw:
367
- result = await mw.analyze("I drink tea every morning.", namespace="sdk-test")
380
+ async with _async_client() as mw:
381
+ result = await mw.analyze("I drink tea every morning.")
368
382
  assert isinstance(result.facts, list)
369
383
 
370
384
  async def test_async_ask(self) -> None:
371
- async with MemWal.create(
372
- key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
373
- ) as mw:
385
+ async with _async_client() as mw:
374
386
  result = await mw.ask("What do I drink?", limit=3)
375
387
  assert isinstance(result.answer, str)
@@ -12,6 +12,7 @@ import hashlib
12
12
  import json
13
13
 
14
14
  import nacl.signing
15
+ import pytest
15
16
 
16
17
  from memwal.utils import (
17
18
  build_seal_session_personal_message,
@@ -144,7 +145,10 @@ class TestBuildSignatureMessage:
144
145
  nonce="550e8400-e29b-41d4-a716-446655440000",
145
146
  account_id="0xabc123",
146
147
  )
147
- assert result == "1700000000.POST./api/remember.abc123.550e8400-e29b-41d4-a716-446655440000.0xabc123"
148
+ assert result == (
149
+ "1700000000.POST./api/remember.abc123"
150
+ ".550e8400-e29b-41d4-a716-446655440000.0xabc123"
151
+ )
148
152
 
149
153
  def test_get_method(self) -> None:
150
154
  result = build_signature_message(
@@ -175,7 +179,9 @@ class TestBuildSignatureMessage:
175
179
  nonce = "550e8400-e29b-41d4-a716-446655440000"
176
180
  account_id = "0xabc123"
177
181
 
178
- message = build_signature_message(timestamp, method, path, body_hash, nonce=nonce, account_id=account_id)
182
+ message = build_signature_message(
183
+ timestamp, method, path, body_hash, nonce=nonce, account_id=account_id
184
+ )
179
185
  sig_hex, pub_hex = sign_message(message, signing_key)
180
186
 
181
187
  # Verify (as the server would)
@@ -239,9 +245,10 @@ class TestDelegateKeyUtils:
239
245
 
240
246
  def test_sui_address_matches_typescript_sdk(self):
241
247
  """Verify blake2b-256(0x00 || pubkey) matches TS delegateKeyToSuiAddress output."""
242
- from memwal.utils import delegate_key_to_public_key, delegate_key_to_sui_address
243
248
  import hashlib
244
249
 
250
+ from memwal.utils import delegate_key_to_public_key, delegate_key_to_sui_address
251
+
245
252
  pub = delegate_key_to_public_key(self._KEY)
246
253
  scheme_input = bytes([0x00]) + pub
247
254
  expected = "0x" + hashlib.blake2b(scheme_input, digest_size=32).hexdigest()
@@ -272,3 +279,52 @@ class TestSealSessionUtils:
272
279
  raw = json.loads(json.dumps(signature)) # assert it's JSON-safe text
273
280
  assert isinstance(raw, str)
274
281
  assert len(base64.b64decode(signature)) == 97
282
+
283
+
284
+ class TestPrivateKeyFormats:
285
+ """The dashboard hands out `suiprivkey1...`; the SDK must take both forms."""
286
+
287
+ _SEED_HEX = "17dc3c1eecfcdc014c0eba65c1f87897abbbd214fa32d4018f48669b5d37c413"
288
+
289
+ def test_decode_reverses_encode(self) -> None:
290
+ from memwal.utils import decode_sui_private_key, encode_sui_private_key
291
+
292
+ seed = bytes.fromhex(self._SEED_HEX)
293
+ assert decode_sui_private_key(encode_sui_private_key(seed)) == seed
294
+
295
+ def test_normalize_accepts_every_accepted_form(self) -> None:
296
+ from memwal.utils import encode_sui_private_key, normalize_private_key
297
+
298
+ seed = bytes.fromhex(self._SEED_HEX)
299
+ for form in (
300
+ self._SEED_HEX,
301
+ f"0x{self._SEED_HEX}",
302
+ encode_sui_private_key(seed),
303
+ f" {encode_sui_private_key(seed)} ",
304
+ ):
305
+ assert normalize_private_key(form) == self._SEED_HEX
306
+
307
+ def test_bech32_key_produces_the_same_client_identity(self) -> None:
308
+ from memwal.client import MemWal
309
+ from memwal.utils import encode_sui_private_key
310
+
311
+ bech32 = encode_sui_private_key(bytes.fromhex(self._SEED_HEX))
312
+ from_hex = MemWal.create(key=self._SEED_HEX, account_id="0xtest")
313
+ from_bech32 = MemWal.create(key=bech32, account_id="0xtest")
314
+ assert from_bech32._private_key_hex == from_hex._private_key_hex
315
+ assert bytes(from_bech32._signing_key) == bytes(from_hex._signing_key)
316
+
317
+ def test_rejects_a_non_ed25519_scheme(self) -> None:
318
+ from memwal.utils import _convertbits, bech32_encode, decode_sui_private_key
319
+
320
+ payload = bytes([1]) + bytes.fromhex(self._SEED_HEX) # scheme flag 1 = secp256k1
321
+ with pytest.raises(ValueError, match="Ed25519"):
322
+ decode_sui_private_key(bech32_encode("suiprivkey", _convertbits(payload, 8, 5)))
323
+
324
+ def test_rejects_a_corrupted_checksum(self) -> None:
325
+ from memwal.utils import decode_sui_private_key, encode_sui_private_key
326
+
327
+ encoded = encode_sui_private_key(bytes.fromhex(self._SEED_HEX))
328
+ corrupted = encoded[:-1] + ("q" if encoded[-1] != "q" else "p")
329
+ with pytest.raises(ValueError, match="checksum"):
330
+ decode_sui_private_key(corrupted)
File without changes
File without changes
File without changes
File without changes
File without changes