Skip to content

Commit 9cb3d2a

Browse files
fix: enforce record filter client-side — hosted scanner ignores the filter subobject (#59)
The hosted scanner ignores the filter subobject of an OwnedFilter and returns every record the account owns (verified live 2026-07-16), so find(program=..., record=...) and find_credits() could return records from foreign programs — callers would then select records from the wrong program and hit phantom commitments on chain. Add enforce_record_filter() to _scanner_common and apply it to the scanner results in both the sync and async facades (find and the find_credits at_least=None path; find_credits_records already enforces credits-only server-side parsing). Covered by new sync and async tests with mixed-program scanner responses.
1 parent 668e0de commit 9cb3d2a

5 files changed

Lines changed: 162 additions & 8 deletions

File tree

sdk/python/aleo/_scanner_common.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,25 @@ def build_owned_filter(
199199
return owned
200200

201201

202+
def enforce_record_filter(
203+
records: list[OwnedRecord],
204+
*,
205+
program: str | None = None,
206+
record: str | None = None,
207+
) -> list[OwnedRecord]:
208+
"""Apply the ``program``/``record`` subfilter to scanned records locally.
209+
210+
The hosted scanner ignores the ``filter`` subobject of an
211+
:class:`OwnedFilter` and returns every record the account owns, so the
212+
filter contract must be enforced on the results client-side.
213+
"""
214+
if program is not None:
215+
records = [r for r in records if r.get("program_name") == program]
216+
if record is not None:
217+
records = [r for r in records if r.get("record_name") == record]
218+
return records
219+
220+
202221
def uuid_is_valid(uuid: str, network: str = "mainnet") -> bool:
203222
"""Return True if uuid is a valid Field string (e.g. '1234...field')."""
204223
try:

sdk/python/aleo/facade/async_client.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,11 @@ async def find(
291291
scanner.set_account(acct)
292292
scanner.set_decrypt_enabled(True)
293293

294-
from .._scanner_common import build_owned_filter, compute_uuid
294+
from .._scanner_common import (
295+
build_owned_filter,
296+
compute_uuid,
297+
enforce_record_filter,
298+
)
295299

296300
uuid = (
297301
str(compute_uuid(acct.view_key, self._client.provider.network))
@@ -303,8 +307,11 @@ async def find(
303307
)
304308

305309
if amounts is not None:
306-
return await scanner.find_credits_records(amounts, owned_filter)
307-
return await scanner.find_records(owned_filter)
310+
found = await scanner.find_credits_records(amounts, owned_filter)
311+
else:
312+
found = await scanner.find_records(owned_filter)
313+
# The hosted scanner ignores the filter subobject; enforce it here.
314+
return enforce_record_filter(found, program=program, record=record)
308315

309316
async def find_credits(
310317
self, account: Any = None, at_least: int | None = None
@@ -336,7 +343,13 @@ async def find_credits(
336343
owned_filter = build_owned_filter(
337344
uuid, program="credits.aleo", record="credits"
338345
)
339-
return await scanner.find_records(owned_filter)
346+
from .._scanner_common import enforce_record_filter
347+
348+
return enforce_record_filter(
349+
await scanner.find_records(owned_filter),
350+
program="credits.aleo",
351+
record="credits",
352+
)
340353

341354
async def get_unspent_credits_record(
342355
self,

sdk/python/aleo/facade/records.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,11 @@ def find(
212212
scanner.set_account(acct)
213213
scanner.set_decrypt_enabled(True)
214214

215-
from .._scanner_common import build_owned_filter, compute_uuid
215+
from .._scanner_common import (
216+
build_owned_filter,
217+
compute_uuid,
218+
enforce_record_filter,
219+
)
216220

217221
uuid = (
218222
str(compute_uuid(acct.view_key, self._client._provider.network))
@@ -224,8 +228,11 @@ def find(
224228
)
225229

226230
if amounts is not None:
227-
return scanner.find_credits_records(amounts, owned_filter)
228-
return scanner.find_records(owned_filter)
231+
found = scanner.find_credits_records(amounts, owned_filter)
232+
else:
233+
found = scanner.find_records(owned_filter)
234+
# The hosted scanner ignores the filter subobject; enforce it here.
235+
return enforce_record_filter(found, program=program, record=record)
229236

230237
def find_credits(
231238
self, account: Any = None, at_least: int | None = None
@@ -275,7 +282,13 @@ def find_credits(
275282
owned_filter = build_owned_filter(
276283
uuid, program="credits.aleo", record="credits"
277284
)
278-
return scanner.find_records(owned_filter)
285+
from .._scanner_common import enforce_record_filter
286+
287+
return enforce_record_filter(
288+
scanner.find_records(owned_filter),
289+
program="credits.aleo",
290+
record="credits",
291+
)
279292

280293
# ── RecordProvider protocol ────────────────────────────────────────────────
281294

sdk/python/tests/test_facade_async.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,50 @@ async def fake_find_records(filt: Any) -> list[Any]:
279279
assert filt["filter"]["record"] == "credits"
280280

281281

282+
@pytest.mark.asyncio
283+
async def test_async_records_find_enforces_filter_client_side() -> None:
284+
"""find() filters by program/record locally — the hosted scanner ignores
285+
the filter subobject and returns every record the account owns."""
286+
a = AsyncAleo(HTTPProvider(BASE))
287+
acct = _account(a)
288+
289+
mixed = [
290+
{"program_name": "credits.aleo", "record_name": "credits", "spent": False},
291+
{"program_name": "ethx_f466cc.aleo", "record_name": "Token", "spent": False},
292+
{"program_name": "test_arc20_eth.aleo", "record_name": "Token", "spent": False},
293+
]
294+
295+
async def fake_find_records(filt: Any) -> list[Any]:
296+
return mixed
297+
298+
a.records.scanner.set_account(acct)
299+
a.records.scanner.find_records = fake_find_records # type: ignore[method-assign]
300+
301+
records = await a.records.find(acct, program="test_arc20_eth.aleo")
302+
assert [r["program_name"] for r in records] == ["test_arc20_eth.aleo"]
303+
304+
305+
@pytest.mark.asyncio
306+
async def test_async_records_find_credits_excludes_foreign_programs() -> None:
307+
"""find_credits() must not leak non-credits records the scanner returns."""
308+
a = AsyncAleo(HTTPProvider(BASE))
309+
acct = _account(a)
310+
311+
mixed = [
312+
{"program_name": "credits.aleo", "record_name": "credits", "spent": False},
313+
{"program_name": "test_arc20_eth.aleo", "record_name": "Token", "spent": False},
314+
]
315+
316+
async def fake_find_records(filt: Any) -> list[Any]:
317+
return mixed
318+
319+
a.records.scanner.set_account(acct)
320+
a.records.scanner.find_records = fake_find_records # type: ignore[method-assign]
321+
322+
records = await a.records.find_credits(acct)
323+
assert [r["program_name"] for r in records] == ["credits.aleo"]
324+
325+
282326
@pytest.mark.asyncio
283327
async def test_async_records_get_unspent_credits_record_covering() -> None:
284328
"""get_unspent_credits_record returns a parsed RecordPlaintext when a covering record exists."""

sdk/python/tests/test_facade_records.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,71 @@ def test_find_with_nonces() -> None:
225225
assert body["nonces"] == [RECORD_NONCE]
226226

227227

228+
MIXED_PROGRAM_RECORDS = [
229+
{
230+
"record_plaintext": RECORD_PLAINTEXT_STR,
231+
"program_name": "credits.aleo",
232+
"record_name": "credits",
233+
"spent": False,
234+
},
235+
{
236+
"record_plaintext": "{ owner: aleo1..., amount: 5u128.private }",
237+
"program_name": "ethx_f466cc.aleo",
238+
"record_name": "Token",
239+
"spent": False,
240+
},
241+
{
242+
"record_plaintext": "{ owner: aleo1..., amount: 7u128.private }",
243+
"program_name": "test_arc20_eth.aleo",
244+
"record_name": "Token",
245+
"spent": False,
246+
},
247+
]
248+
249+
250+
@resp_lib.activate
251+
def test_find_enforces_program_filter_client_side() -> None:
252+
# The hosted scanner ignores the "filter" subobject and returns every
253+
# record the account owns (verified live 2026-07-16); find() must enforce
254+
# its documented program= contract on the results itself, or callers
255+
# select records from the wrong program (phantom commitments on chain).
256+
resp_lib.add(resp_lib.POST, f"{HOST}/records/owned", json=MIXED_PROGRAM_RECORDS)
257+
258+
a = _client()
259+
_inject_scanner(a)
260+
acct = _golden_account()
261+
262+
records = a.records.find(acct, program="test_arc20_eth.aleo")
263+
assert [r["program_name"] for r in records] == ["test_arc20_eth.aleo"]
264+
265+
266+
@resp_lib.activate
267+
def test_find_enforces_record_name_filter_client_side() -> None:
268+
resp_lib.add(resp_lib.POST, f"{HOST}/records/owned", json=MIXED_PROGRAM_RECORDS)
269+
270+
a = _client()
271+
_inject_scanner(a)
272+
acct = _golden_account()
273+
274+
records = a.records.find(acct, program="credits.aleo", record="credits")
275+
assert [r["program_name"] for r in records] == ["credits.aleo"]
276+
assert all(r["record_name"] == "credits" for r in records)
277+
278+
279+
@resp_lib.activate
280+
def test_find_credits_excludes_foreign_programs() -> None:
281+
# Same service quirk as above, via the find_credits(at_least=None) path:
282+
# non-credits records must not leak into the returned list.
283+
resp_lib.add(resp_lib.POST, f"{HOST}/records/owned", json=MIXED_PROGRAM_RECORDS)
284+
285+
a = _client()
286+
_inject_scanner(a)
287+
acct = _golden_account()
288+
289+
records = a.records.find_credits(acct)
290+
assert [r["program_name"] for r in records] == ["credits.aleo"]
291+
292+
228293
# ---------------------------------------------------------------------------
229294
# 4. find_credits — at_least filter
230295
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)