From 2b19fb8cabaee52df5bf0c86b1b6387236983b51 Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Wed, 19 Nov 2025 09:19:18 +0000 Subject: [PATCH] frontends/tests: add AOPP flow test. --- .github/workflows/playwright.yml | 9 +- frontends/web/src/components/aopp/aopp.tsx | 4 +- .../web/src/routes/account/actionButtons.tsx | 2 +- frontends/web/tests/aopp.test.ts | 220 +++++++++++++++++ frontends/web/tests/helpers/aopp.ts | 88 +++++++ frontends/web/tests/helpers/servewallet.ts | 55 +++-- frontends/web/tests/util/aopp/server.py | 228 ++++++++++++++++++ 7 files changed, 587 insertions(+), 19 deletions(-) create mode 100644 frontends/web/tests/aopp.test.ts create mode 100644 frontends/web/tests/helpers/aopp.ts create mode 100644 frontends/web/tests/util/aopp/server.py diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index f18c9de808..455b55bed1 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -69,7 +69,6 @@ jobs: name: simulator-binary path: ./simulator-bin - - name: Setup Node.js uses: actions/setup-node@v3 with: @@ -85,6 +84,14 @@ jobs: cd frontends/web npx playwright install --with-deps chromium webkit + - name: Setup Python 3 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install Python dependencies + run: | + pip install ecdsa bech32 - name: Restore executable permission run: chmod +x simulator-bin/simulator diff --git a/frontends/web/src/components/aopp/aopp.tsx b/frontends/web/src/components/aopp/aopp.tsx index 087a3666c6..e7dcbe1968 100644 --- a/frontends/web/src/components/aopp/aopp.tsx +++ b/frontends/web/src/components/aopp/aopp.tsx @@ -226,11 +226,11 @@ export const Aopp = () => {

- + -
+
{aopp.message}
diff --git a/frontends/web/src/routes/account/actionButtons.tsx b/frontends/web/src/routes/account/actionButtons.tsx index e6de4bc09f..dd1a928e8a 100644 --- a/frontends/web/src/routes/account/actionButtons.tsx +++ b/frontends/web/src/routes/account/actionButtons.tsx @@ -77,7 +77,7 @@ export const ActionButtons = ({ canSend, code, coinCode, exchangeSupported, acco primary to={`/account/${code}/receive`} > - {t('generic.receiveWithoutCoinCode')} + {t('generic.receiveWithoutCoinCode')} {(exchangeSupported && !isMobile) && ( diff --git a/frontends/web/tests/aopp.test.ts b/frontends/web/tests/aopp.test.ts new file mode 100644 index 0000000000..0302b765fe --- /dev/null +++ b/frontends/web/tests/aopp.test.ts @@ -0,0 +1,220 @@ +/** +* Copyright 2025 Shift Crypto AG +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { test } from './helpers/fixtures'; +import { expect } from '@playwright/test'; +import { ServeWallet } from './helpers/servewallet'; +import { launchRegtest, setupRegtestWallet, sendCoins, mineBlocks, cleanupRegtest } from './helpers/regtest'; +import { startSimulator, completeWalletSetupFlow, cleanFakeMemoryFiles } from './helpers/simulator'; +import { ChildProcess } from 'child_process'; +import { startAOPPServer, generateAOPPRequest } from './helpers/aopp'; +import { assertFieldsCount } from './helpers/dom'; + + +let servewallet: ServeWallet; +let regtest: ChildProcess; +let aoppServer: ChildProcess | undefined; +let simulatorProc : ChildProcess | undefined; + +test('AOPP', async ({ page, host, frontendPort, servewalletPort }, testInfo) => { + + + await test.step('Start regtest and init wallet', async () => { + regtest = await launchRegtest(); + // Give regtest some time to start + await new Promise((resolve) => setTimeout(resolve, 3000)); + await setupRegtestWallet(); + }); + + + await test.step('Start servewallet', async () => { + servewallet = new ServeWallet(page, servewalletPort, frontendPort, host, testInfo.title, testInfo.project.name, { regtest: true, testnet: false, simulator: true }); + await servewallet.start(); + }); + + await test.step('Start simulator', async () => { + const simulatorPath = process.env.SIMULATOR_PATH; + if (!simulatorPath) { + throw new Error('SIMULATOR_PATH environment variable not set'); + } + + simulatorProc = startSimulator(simulatorPath, testInfo.title, testInfo.project.name, true); + console.log('Simulator started'); + }); + + + await test.step('Initialize wallet', async () => { + await completeWalletSetupFlow(page); + }); + + let recvAdd: string; + await test.step('Grab receive address', async () => { + await page.getByRole('link', { name: 'Bitcoin Regtest Bitcoin' }).click(); + await page.getByRole('button', { name: 'Receive RBTC' }).click(); + await page.getByRole('button', { name: 'Verify address on BitBox' }).click(); + const addressLocator = page.locator('[data-testid="receive-address"]'); + recvAdd = await addressLocator.inputValue(); + console.log(`Receive address: ${recvAdd}`); + }); + + await test.step('Send RBTC to receive address', async () => { + await page.waitForTimeout(2000); + const sendAmount = '10'; + await sendCoins(recvAdd, sendAmount); + await mineBlocks(12); + console.log(`Sent ${sendAmount} RBTC to ${recvAdd}`); + }); + + + await test.step('Add second RBTC account', async () => { + await page.goto('/#/account-summary'); + await page.getByRole('link', { name: 'Settings' }).click(); + await page.getByRole('link', { name: 'Manage Accounts' }).click(); + await page.getByRole('button', { name: 'Add account' }).click(); + await page.getByRole('button', { name: 'Add account' }).click(); + await expect(page.locator('body')).toContainText('Bitcoin Regtest 2 has now been added to your accounts.'); + await page.getByRole('button', { name: 'Done' }).click(); + }); + + + await test.step('Grab receive address for second account', async () => { + await page.goto('/#/account-summary'); + await page.getByRole('link', { name: 'Bitcoin Regtest 2' }).click(); + + await page.getByRole('button', { name: 'Receive RBTC' }).click(); + await page.getByRole('button', { name: 'Verify address on BitBox' }).click(); + const addressLocator = page.locator('[data-testid="receive-address"]'); + recvAdd = await addressLocator.inputValue(); + expect(recvAdd).toContain('bcrt1'); + console.log(`Receive address: ${recvAdd}`); + }); + + await test.step('Send RBTC to receive address', async () => { + await page.waitForTimeout(2000); + const sendAmount = '10'; + sendCoins(recvAdd, sendAmount); + mineBlocks(12); + console.log(`Sent ${sendAmount} RBTC to ${recvAdd}`); + }); + + let aoppRequest: string; + await test.step('Start AOPP server and generate AOPP request', async () => { + console.log('Starting AOPP server...'); + aoppServer = await startAOPPServer(); + console.log('AOPP server started.'); + console.log('Generating AOPP request...'); + aoppRequest = await generateAOPPRequest('rbtc'); + console.log(`AOPP Request URI: ${aoppRequest}`); + }); + + await test.step('Kill the simulator', async () => { + // We kill the simulator so that we can verify that with no BB connected, + // the app shows "Address request in progress. Please connect your device to continue" + if (simulatorProc) { + simulatorProc.kill('SIGTERM'); + simulatorProc = undefined; + console.log('Simulator killed.'); + } + }); + + await test.step('Kill servewallet and restart with AOPP request', async () => { + await servewallet.stop(); + console.log('Servewallet stopped.'); + servewallet = new ServeWallet(page, servewalletPort, frontendPort, host, testInfo.title, testInfo.project.name, { regtest: true, testnet: false, simulator: true }); + await servewallet.start({ extraFlags: { aoppUrl: aoppRequest } }); + console.log('Servewallet restarted with AOPP request.'); + }); + + await test.step('Address request in progress', async () => { + await page.goto('/'); + const body = page.locator('body'); + await expect(body).toContainText('localhost:8888 is requesting a receiving address'); + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(body).toContainText('Address request in progress. Please connect your device to continue'); + }); + + // Restart the simulator to continue the AOPP flow + await test.step('Restart simulator to continue AOPP flow', async () => { + const simulatorPath = process.env.SIMULATOR_PATH; + if (!simulatorPath) { + throw new Error('SIMULATOR_PATH environment variable not set'); + } + + simulatorProc = startSimulator(simulatorPath, testInfo.title, testInfo.project.name, true); + console.log('Simulator restarted.'); + }); + + let aoppAddress : string | null; + await test.step('Verify AOPP flow is in progress', async () => { + await page.goto('/'); + const body = page.locator('body'); + + // Verify that we can select one of two accounts + await assertFieldsCount(page, 'id', 'account', 1); + const options = page.locator('select[id="account"] option'); + await expect(options).toHaveCount(2); + + // Select the first account. + await page.selectOption('#account', { index: 0 }); + + await page.getByRole('button', { name: 'Next' }).click(); + + // The simulator automatically accepts and signs the message request, + // so we should see the success message immediately. + await expect(body).toContainText('Address successfully sent'); + await expect(body).toContainText('Proceed on localhost:8888'); + + const address = page.locator('[data-testid="aopp-address"]'); + aoppAddress = await address.textContent(); + + const message = page.locator('[data-testid="aopp-message"]'); + const messageValue = await message.textContent(); + expect(messageValue).toContain('I confirm that I solely control this address.'); //TODO extract ID + await page.getByRole('button', { name: 'Done' }).click(); + }); + + + await test.step('Compare receive address with aopp address', async () => { + await page.goto('/'); + await page.getByRole('link', { name: 'Bitcoin Regtest Bitcoin' }).click(); + const receiveButton = page.locator('[data-testid="receive-button"]'); + await receiveButton.click(); + await page.getByRole('button', { name: 'Verify address on BitBox' }).click(); + const addressLocator = page.locator('[data-testid="receive-address"]'); + recvAdd = await addressLocator.inputValue(); + console.log(`Receive address: ${recvAdd}`); + expect(recvAdd).toBe(aoppAddress); + }); +}); + + +// Ensure a clean state before running all tests. +test.beforeAll(async () => { + cleanFakeMemoryFiles(); +}); + +test.afterAll(async () => { + await servewallet.stop(); + if (aoppServer) { + aoppServer.kill('SIGTERM'); + aoppServer = undefined; + } + await cleanupRegtest(regtest); + if (simulatorProc) { + simulatorProc.kill('SIGTERM'); + simulatorProc = undefined; + } +}); diff --git a/frontends/web/tests/helpers/aopp.ts b/frontends/web/tests/helpers/aopp.ts new file mode 100644 index 0000000000..17fb765049 --- /dev/null +++ b/frontends/web/tests/helpers/aopp.ts @@ -0,0 +1,88 @@ +/** +* Copyright 2025 Shift Crypto AG +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { spawn, ChildProcessByStdio } from 'child_process'; +import path from 'path'; +import type { Readable } from 'stream'; + +/** + * Starts the AOPP server and waits until it prints its "ready" line. + * Returns the spawned child process. + */ +export async function startAOPPServer(): Promise< + ChildProcessByStdio +> { + const PROJECT_ROOT = process.env.GITHUB_WORKSPACE || + path.resolve(__dirname, '../../../..'); + + const scriptPath = path.resolve(PROJECT_ROOT, 'frontends/web/tests/util/aopp/server.py'); + + const child = spawn('python3', ['-u', scriptPath], { + cwd: PROJECT_ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env }, + }); + + const readyMsg = 'Listening on localhost:8888'; + + await new Promise((resolve, reject) => { + const onData = (data: Buffer) => { + const text = data.toString(); + if (text.includes(readyMsg)) { + child.stdout.off('data', onData); + resolve(); + } + }; + + const onError = (err: Error) => { + child.stdout.off('data', onData); + reject(err); + }; + + child.stdout.on('data', onData); + child.on('error', onError); + }); + + return child; +} + +/** + * Perform a POST request to the AOPP server and return the cleaned `uri` string. + */ +export async function generateAOPPRequest( + asset: 'rbtc' | 'btc' | 'eth' | 'tbtc' = 'rbtc' +): Promise { + const allowed = ['rbtc', 'btc', 'eth', 'tbtc'] as const; + if (!allowed.includes(asset)) { + throw new Error(`Invalid asset: ${asset}. Allowed: ${allowed.join(', ')}`); + } + + const url = `http://localhost:8888/generate?asset=${asset}`; + + const res = await fetch(url, { method: 'POST' }); + + if (!res.ok) { + throw new Error(`AOPP server responded with ${res.status}`); + } + + const json = await res.json(); + + if (!json.uri || typeof json.uri !== 'string') { + throw new Error('AOPP server returned unexpected JSON'); + } + + return json.uri; +} diff --git a/frontends/web/tests/helpers/servewallet.ts b/frontends/web/tests/helpers/servewallet.ts index c1c5da938f..754f281ea6 100644 --- a/frontends/web/tests/helpers/servewallet.ts +++ b/frontends/web/tests/helpers/servewallet.ts @@ -35,6 +35,14 @@ export interface ServeWalletOptions { timeout?: number; testnet?: boolean; regtest?: boolean; + /** + * Extra flags to pass to the servewallet + * + * Valid values are thospe specified in `cmd/servewallet/main.go` + * which can be obtained also by running `go run ./cmd/servewallet --help` + * from the project root. + */ + extraFlags?: Record; } export class ServeWallet { @@ -63,7 +71,7 @@ export class ServeWallet { ) { const { simulator = false, timeout = 90000, testnet = true, regtest = false } = options; - if (!testnet && simulator) { + if (!(testnet || regtest) && simulator) { throw new Error('ServeWallet: mainnet simulator is not supported'); } @@ -89,24 +97,41 @@ export class ServeWallet { this.outStream = fs.openSync(this.logPath, append ? 'a' : 'w'); } - async start(): Promise { + async start(options: ServeWalletOptions = {}): Promise { this.openOutStream(true); // On starts/restarts, open the file in "a" mode. - let target: string; - if (this.testnet && !this.simulator) { - target = 'servewallet'; - } else if (this.testnet && this.simulator) { - target = 'servewallet-simulator'; - } else if (!this.testnet && !this.simulator && !this.regtest) { - target = 'servewallet-mainnet'; - } else if (this.regtest) { - target = 'servewallet-regtest'; - } else { - // This should never happen because the constructor already guards against it - throw new Error('Invalid ServeWallet configuration'); + const extraFlags = options.extraFlags || {}; + + // Determine base flags + const args: string[] = ['./cmd/servewallet']; + if (!this.testnet && !this.simulator && !this.regtest) { + args.push('-mainnet'); + } + if (this.simulator) { + args.push('-simulator'); + } + if (this.regtest) { + args.push('-regtest'); + } + + // Append extra flags, disallow overriding reserved ones + const reservedFlags = ['mainnet', 'testnet', 'regtest', 'simulator']; + for (const [key, value] of Object.entries(extraFlags)) { + if (reservedFlags.includes(key)) { + throw new Error(`Cannot override reserved flag "${key}" in extraFlags`); + } + if (value === null || value === true) { + args.push(`-${key}`); + } else if (value === false) { + // skip false flags + continue; + } else { + args.push(`-${key}=${value}`); + } } - this.proc = spawn('make', ['-C', '../../', target], { + this.proc = spawn('go', ['run', ...args], { + cwd: '../../', // maintain previous working dir stdio: ['ignore', this.outStream, this.outStream], detached: true, }); diff --git a/frontends/web/tests/util/aopp/server.py b/frontends/web/tests/util/aopp/server.py new file mode 100644 index 0000000000..58c1bc2c52 --- /dev/null +++ b/frontends/web/tests/util/aopp/server.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +# Copyright 2025 Shift Crypto AG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import http.server +import socketserver +import json +import uuid +import hashlib +import base64 +from urllib.parse import urlparse, parse_qs + +from ecdsa import VerifyingKey, SECP256k1, util +from bech32 import bech32_encode, convertbits + +# In-memory store +requests_store = {} +REGTEST_NET = "regtest" +PORT = 8888 + +class AOPPProof: + def __init__(self, version: int, address: str, signature: bytes): + self.version = version + self.address = address + self.signature = signature + +def sighash(msg: str) -> bytes: + prefix = b"\x18Bitcoin Signed Message:\n" + msg_bytes = msg.encode() + digest = hashlib.sha256(hashlib.sha256(prefix + bytes([len(msg_bytes)]) + msg_bytes).digest()).digest() + return digest + +def compress_pubkey(vk: VerifyingKey) -> bytes: + """ + Manually compress a generic ECDSA public key. + Format: + Prefix: 0x02 if Y is even, 0x03 if Y is odd. + """ + # Get the point on the curve + point = vk.pubkey.point + + # Get X as 32-byte integer + x_bytes = point.x().to_bytes(32, 'big') + + # Determine prefix based on Y parity + if point.y() & 1: + prefix = b'\x03' # Odd + else: + prefix = b'\x02' # Even + + return prefix + x_bytes + +def pubkey_to_p2wpkh_address(pubkey_bytes: bytes, network: str = "regtest") -> str: + """ + Converts a compressed public key to a SegWit (P2WPKH) address. + Format: bech32(HRP, [WitnessVersion(0)] + WitnessProgram) + """ + sha256_digest = hashlib.sha256(pubkey_bytes).digest() + h160 = hashlib.new('ripemd160', sha256_digest).digest() + data = convertbits(h160, 8, 5) + + # Prepend Witness Version 0 + witness_program = [0] + data + + hrp = {"regtest": "bcrt"}.get(network, "bc") + return bech32_encode(hrp, witness_program) + +def verify_address_ownership(r_s_bytes: bytes, message_digest: bytes, expected_address: str, network: str) -> bool: + """ + Recovers potential public keys from the signature (R, S) and checks + if any of them derive to the expected address. + """ + try: + # ecdsa library can recover candidates directly from R+S. + # It returns a list of VerifyingKey objects (usually 2). + candidates = VerifyingKey.from_public_key_recovery_with_digest( + r_s_bytes, + message_digest, + curve=SECP256k1, + hashfunc=hashlib.sha256 + ) + + for vk in candidates: + # 1. Compress the key + compressed_bytes = compress_pubkey(vk) + + # 2. Derive Address + recovered_address = pubkey_to_p2wpkh_address(compressed_bytes, network) + + # 3. Check match + if recovered_address == expected_address: + return True + + return False + except Exception: + return False + +# --- HTTP Request Handler --- + +class AOPPRequestHandler(http.server.BaseHTTPRequestHandler): + + def _send_response(self, status_code, body=None): + self.send_response(status_code) + if body: + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(body).encode('utf-8')) + else: + self.end_headers() + + def do_POST(self): + parsed_path = urlparse(self.path) + path = parsed_path.path + query_params = parse_qs(parsed_path.query) + + if path == "/generate": + self.handle_generate() + return + + if path == "/cb": + self.handle_callback(query_params) + return + + self._send_response(404, {"error": "endpoint not found"}) + + def handle_generate(self): + request_id = str(uuid.uuid4()) + msg = f"I confirm that I solely control this address. ID: {request_id}" + callback = f"http://localhost:{PORT}/cb?id={request_id}" + asset = "rbtc" + uri = f"aopp:?v=0&msg={msg}&asset={asset}&format=any&callback={callback}" + + requests_store[request_id] = {"uri": uri, "proof": None} + self._send_response(200, {"id": request_id, "uri": uri}) + + def handle_callback(self, query_params): + # 1. Parse Query Params + request_id_list = query_params.get("id") + request_id = request_id_list[0] if request_id_list else None + + if not request_id or request_id not in requests_store: + self._send_response(404, {"error": "unknown request ID"}) + return + + aopp_request = requests_store[request_id] + if aopp_request["proof"] is not None: + self._send_response(400, {"error": "proof already submitted"}) + return + + # 2. Parse JSON Body + content_length = int(self.headers.get('Content-Length', 0)) + if content_length == 0: + self._send_response(400, {"error": "missing payload"}) + return + + post_data = self.rfile.read(content_length) + try: + data = json.loads(post_data) + except json.JSONDecodeError: + self._send_response(400, {"error": "invalid JSON"}) + return + + # 3. Process Proof Data + try: + addr = data.get("address") or data.get("Address") + sig_b64 = data.get("signature") or data.get("Signature") + version = int(data.get("version", 0)) + + if not addr or not sig_b64: + raise ValueError("missing required fields") + + proof_bytes = base64.b64decode(sig_b64) + proof = AOPPProof(version=version, address=addr, signature=proof_bytes) + except Exception as e: + self._send_response(400, {"error": f"invalid proof format: {e}"}) + return + + # 4. Extract message from stored URI + uri = aopp_request["uri"] + msg_start = uri.find("msg=") + msg_end = uri.find("&", msg_start) + msg = uri[msg_start + 4:] if msg_end == -1 else uri[msg_start + 4:msg_end] + + sig_bytes = proof.signature + message_digest = sighash(msg) + + # 5. Handle Signature Format (We need pure 64-byte R+S for ecdsa) + r_s_bytes = b"" + if len(sig_bytes) == 65: + r_s_bytes = sig_bytes[1:] # Strip Header + elif len(sig_bytes) == 64: + r_s_bytes = sig_bytes + else: + self._send_response(400, {"error": "invalid signature length"}) + return + + # 6. Verify + # We don't need a loop here; ecdsa checks all candidates internally + is_valid = verify_address_ownership(r_s_bytes, message_digest, proof.address, REGTEST_NET) + + if not is_valid: + self._send_response(400, {"error": "cannot recover pubkey or address mismatch"}) + return + + # Success + aopp_request["proof"] = proof + self._send_response(204) + +if __name__ == "__main__": + socketserver.TCPServer.allow_reuse_address = True + with socketserver.TCPServer(("localhost", PORT), AOPPRequestHandler) as httpd: + print(f"Listening on localhost:{PORT}") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nShutting down server.") + httpd.shutdown()