From b8812f409cabe481d11c361e6e7d32249fa3a9ba Mon Sep 17 00:00:00 2001 From: tinyopsstudio Date: Thu, 28 May 2026 00:45:01 -0400 Subject: [PATCH 1/5] Canonicalize wallet JSON unicode keys --- app/static/wallet.js | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/app/static/wallet.js b/app/static/wallet.js index 40f26285..ce1f4515 100644 --- a/app/static/wallet.js +++ b/app/static/wallet.js @@ -16,17 +16,37 @@ function hexToBytes(hex) { return bytes; } +function asciiJson(value) { + return JSON.stringify(value).replace(/[\u0080-\uFFFF]/g, (char) => { + return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`; + }); +} + +function compareCodePoints(left, right) { + const leftPoints = Array.from(left); + const rightPoints = Array.from(right); + const limit = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < limit; index += 1) { + const leftPoint = leftPoints[index].codePointAt(0); + const rightPoint = rightPoints[index].codePointAt(0); + if (leftPoint !== rightPoint) { + return leftPoint - rightPoint; + } + } + return leftPoints.length - rightPoints.length; +} + function stableJson(value) { if (Array.isArray(value)) { return `[${value.map(stableJson).join(",")}]`; } if (value && typeof value === "object") { return `{${Object.keys(value) - .sort() - .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .sort(compareCodePoints) + .map((key) => `${asciiJson(key)}:${stableJson(value[key])}`) .join(",")}}`; } - return JSON.stringify(value); + return asciiJson(value); } async function sha256Hex(bytes) { From 55f35ee9eaf4634879ee221919e1dd5faae6c819 Mon Sep 17 00:00:00 2001 From: tinyopsstudio Date: Thu, 28 May 2026 00:45:09 -0400 Subject: [PATCH 2/5] Cover wallet unicode canonicalization --- tests/test_wallet_api.py | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_wallet_api.py b/tests/test_wallet_api.py index 45649186..534c4215 100644 --- a/tests/test_wallet_api.py +++ b/tests/test_wallet_api.py @@ -1,5 +1,8 @@ from __future__ import annotations +import json +import shutil +import subprocess from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -618,6 +621,44 @@ def test_github_wallet_actions_clear_private_key_after_submit_attempt() -> None: assert idx_set_result < idx_refresh_nonce < idx_set_error < idx_finally < idx_clear_private_key +def test_wallet_js_canonicalizes_unicode_like_server() -> None: + node = shutil.which("node") + if node is None: + pytest.skip("node is required to execute wallet.js canonicalization") + wallet_js = Path("app/static/wallet.js").read_text(encoding="utf-8") + payload = { + "type": "mrwk_transfer_v1", + "from_address": "mrwk1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "to_address": "mrwk1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "amount_microunits": 1_500_000, + "nonce": 7, + "memo": "café 😀", + "\ue000": 1, + "😀": 2, + } + node_script = f""" +const vm = require("vm"); +const context = {{ + console, + TextEncoder, + Uint8Array, + crypto: {{subtle: {{}}}}, + document: {{querySelector: () => null, getElementById: () => null}}, +}}; +vm.createContext(context); +vm.runInContext({json.dumps(wallet_js)}, context); +console.log(vm.runInContext(`stableJson({json.dumps(payload)})`, context)); +""" + result = subprocess.run( + [node, "-e", node_script], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.strip() == canonical_wallet_json(payload) + + def test_reject_self_transfer(sqlite_url: str) -> None: create_schema(sqlite_url) with session_scope(sqlite_url) as session: From b0b601ddd953ce68cd379b1791cb2e3111372dec Mon Sep 17 00:00:00 2001 From: tinyopsstudio Date: Thu, 28 May 2026 23:32:57 -0400 Subject: [PATCH 3/5] Canonicalize wallet JSON unicode keys --- app/static/wallet.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/static/wallet.js b/app/static/wallet.js index ce1f4515..d9e8ff50 100644 --- a/app/static/wallet.js +++ b/app/static/wallet.js @@ -231,6 +231,8 @@ function setupTransfer() { await getNextNonce(fromAddress, "[data-transfer-nonce-status]"); } catch (error) { setText("[data-transfer-result]", error.message); + } finally { + clearPrivateKeyField(form); } }); } From 7ae550ad73c150c611e46f2074d423ade95f53f0 Mon Sep 17 00:00:00 2001 From: tinyopsstudio Date: Thu, 28 May 2026 23:33:06 -0400 Subject: [PATCH 4/5] Cover wallet unicode canonicalization --- tests/test_wallet_api.py | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_wallet_api.py b/tests/test_wallet_api.py index 534c4215..4b6940e5 100644 --- a/tests/test_wallet_api.py +++ b/tests/test_wallet_api.py @@ -24,6 +24,7 @@ wallet_transfer_payload, ) from app.main import _safe_next_path, _signed_value, _verified_value, create_app +from app.models import Wallet from app.wallets import address_from_public_key_hex, canonical_wallet_json @@ -518,11 +519,29 @@ def test_wallet_pages_expose_transfer_and_github_claim_flows(sqlite_url: str) -> client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret")) _register_wallet(client, public_hex, "Main smoke wallet") _register_wallet(client, funded_public, "Funded smoke wallet") + with session_scope(sqlite_url) as session: + wallet = session.get(Wallet, address) + assert wallet is not None + wallet.github_login = "alice-smoke" _fund_wallet(sqlite_url, funded_address) + with session_scope(sqlite_url) as session: + add_ledger_entry( + session, + entry_type="wallet_transfer", + from_account=funded_address, + to_account="github:filter-target", + amount_microunits=0, + reference="test-wallet-detail-filter", + ) wallets = client.get("/wallets").text + main_search = client.get("/wallets?q=Main").text + github_search = client.get("/wallets?q=alice-smoke").text + no_wallet_match = client.get("/wallets?q=missing-wallet").text detail = client.get(f"/wallets/{address}").text funded_detail = client.get(f"/wallets/{funded_address}").text + funded_type_filter = client.get(f"/wallets/{funded_address}?type=test_funding").text + funded_missing_type = client.get(f"/wallets/{funded_address}?type=bounty_payment").text transfer = client.get("/transfer").text me = client.get("/me").text @@ -537,9 +556,27 @@ def test_wallet_pages_expose_transfer_and_github_claim_flows(sqlite_url: str) -> assert address in detail assert "Main smoke wallet" in wallets assert "Main smoke wallet" in detail + assert "Search wallets" in wallets + assert "Main smoke wallet" in main_search + assert "Funded smoke wallet" not in main_search + assert "alice-smoke" in github_search + assert 'href="/accounts/github:alice-smoke">alice-smoke' in wallets + assert 'href="/accounts/github:alice-smoke">alice-smoke' in github_search + funded_row_start = wallets.index( + f'{funded_address}' + ) + funded_row = wallets[funded_row_start : wallets.index("", funded_row_start)] + assert "Funded smoke wallet" in funded_row + assert "\n -\n " in funded_row + assert "/accounts/github:" not in funded_row + assert "No registered wallets match this search." in no_wallet_match assert "To claim GitHub bounty balance" in detail assert "No activity yet" in detail assert "No activity yet" not in funded_detail + assert "Filter wallet transactions" in funded_detail + assert 'value="test_funding" selected' in funded_type_filter + assert "Showing test_funding transactions." in funded_type_filter + assert "No wallet transactions match this type." in funded_missing_type assert "Signed transfer" in transfer assert "both wallets are registered" in transfer assert "/static/wallet.js" in transfer @@ -621,6 +658,19 @@ def test_github_wallet_actions_clear_private_key_after_submit_attempt() -> None: assert idx_set_result < idx_refresh_nonce < idx_set_error < idx_finally < idx_clear_private_key +def test_transfer_action_clears_private_key_after_submit_attempt() -> None: + wallet_js = Path("app/static/wallet.js").read_text(encoding="utf-8") + transfer_start = wallet_js.find("function setupTransfer()") + github_actions_start = wallet_js.find("function setupGithubActions()") + transfer_block = wallet_js[transfer_start:github_actions_start] + + assert 'form[data-action="submit-transfer"]' in transfer_block + assert 'setText("[data-transfer-result]", transfer);' in transfer_block + assert 'setText("[data-transfer-result]", error.message);' in transfer_block + assert "} finally {" in transfer_block + assert "clearPrivateKeyField(form);" in transfer_block + + def test_wallet_js_canonicalizes_unicode_like_server() -> None: node = shutil.which("node") if node is None: From 62fc56d32422e2a26ba10ac3a43b75faf2aaa0cf Mon Sep 17 00:00:00 2001 From: tinyopsstudio Date: Thu, 28 May 2026 23:40:48 -0400 Subject: [PATCH 5/5] Cover wallet unicode canonicalization --- tests/test_wallet_api.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/test_wallet_api.py b/tests/test_wallet_api.py index 4b6940e5..3802413d 100644 --- a/tests/test_wallet_api.py +++ b/tests/test_wallet_api.py @@ -663,12 +663,24 @@ def test_transfer_action_clears_private_key_after_submit_attempt() -> None: transfer_start = wallet_js.find("function setupTransfer()") github_actions_start = wallet_js.find("function setupGithubActions()") transfer_block = wallet_js[transfer_start:github_actions_start] + set_result = 'setText("[data-transfer-result]", transfer);' + set_error = 'setText("[data-transfer-result]", error.message);' + finally_block = "} finally {" + clear_private_key = "clearPrivateKeyField(form);" assert 'form[data-action="submit-transfer"]' in transfer_block - assert 'setText("[data-transfer-result]", transfer);' in transfer_block - assert 'setText("[data-transfer-result]", error.message);' in transfer_block - assert "} finally {" in transfer_block - assert "clearPrivateKeyField(form);" in transfer_block + idx_set_result = transfer_block.find(set_result) + idx_set_error = transfer_block.find(set_error, idx_set_result) + idx_finally = transfer_block.find(finally_block, idx_set_error) + idx_clear_private_key = transfer_block.find(clear_private_key, idx_finally) + + assert -1 not in { + idx_set_result, + idx_set_error, + idx_finally, + idx_clear_private_key, + } + assert idx_set_result < idx_set_error < idx_finally < idx_clear_private_key def test_wallet_js_canonicalizes_unicode_like_server() -> None: