+
{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()