Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 50 additions & 5 deletions apps/desktop/app/process/BlePair.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancelling BLE pairing shows a confusing hardware failure instead of a cancellation

Reported at apps/desktop/app/process/BlePair.ts:156-158 (outside the diff of this PR, so this is a file-level comment)

Severity: non-severe

The user-cancelled reason recorded for the pairing attempt is immediately replaced by the helper's own generic failure text (lastError = event.message at apps/desktop/app/process/BlePair.ts:157) before the attempt finishes, so a deliberate cancel is reported as an unexplained pairing failure.
Impact: A user who presses Cancel on the pairing-code dialog (or cancels from the app) sees a scary low-level Bluetooth failure message rather than a clean "cancelled" result.

How the cancel reason is overwritten before the promise rejects

When the decision callback receives cancel it sets lastError = PAIR_CANCELLED_REASON (apps/desktop/app/process/BlePair.ts:109-111), whose whole purpose is to match the SDK's /connect cancelled/i test so it maps to BlePairingCancelled (see comment at apps/desktop/app/process/BlePair.ts:23-24).

The helper then declines the ceremony, PairAsync returns a non-Paired status, and run_pair returns Err(format!("pairing failed with status {status:?}")) (apps/desktop/native-modules/onekey-ble-pair/src/main.rs:787). main turns that into {"type":"error","message":"pairing failed with status DevicePairingResultStatus(N)"} on stdout, which the stdout parser assigns to lastError, clobbering the cancel reason. The exit handler (apps/desktop/app/process/BlePair.ts:175-190) then rejects with the clobbered message.

A cancelRequested flag that makes the cancel reason win (or that skips overwriting lastError once a cancel has been sent) restores the intended mapping.

Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { getAppStaticResourcesPath } from '../resoucePath';

const RESOURCE = 'ble-pair';
const PROCESS_NAME = 'onekey-ble-pair';
// Matches the SDK's /connect cancelled/i -> BlePairingCancelled (10310).
const PAIR_CANCELLED_REASON = 'connect cancelled: BLE pairing declined by user';
// The user has up to the OS pairing window to confirm; keep some headroom.
const PAIR_TIMEOUT_MS = 60_000;

Expand Down Expand Up @@ -76,13 +78,15 @@ export function isBlePairAvailable(): boolean {
function runHelper(
args: string[],
onEvent?: (event: IBlePairEvent) => void,
registerDecide?: (decide: (decision: IPairDecision) => void) => void,
): Promise<IBlePairEvent[]> {
return new Promise((resolve, reject) => {
const helperPath = resolveHelperPath();
logger.info(`[BlePair] spawning ${helperPath} ${args.join(' ')}`);

// stdin carries the pair decision (confirm/cancel).
const child = spawn(helperPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['pipe', 'pipe', 'pipe'],
});

const spawnedAt = Date.now();
Expand All @@ -98,6 +102,23 @@ function runHelper(
child.kill();
}, PAIR_TIMEOUT_MS);

// Cancel goes through stdin, not kill(): only a live helper can decline the
// request, which is what makes Windows send the device an SMP Pairing Failed.
registerDecide?.((decision) => {
if (settled) return;
if (decision === 'cancel') {
lastError = PAIR_CANCELLED_REASON;
Comment on lines +109 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 保留显式取消的错误原因

在 Windows Desktop(Electron main 为单 JS runtime,WinRT 配对状态由独立 helper 子进程持有)中,用户取消时这里先写入可映射为 BlePairingCancelled 的原因,但 helper 随后以 pairing failed with status ... 发出 error 事件,runHelper 的现有错误处理会再次覆盖 lastError;在该已刷新事件先于进程退出到达的正常路径中,调用方最终收到通用配对失败而不是取消错误。应在已显式取消时忽略后续 helper 错误,或让 helper 返回同一个取消原因。

AGENTS.md reference: AGENTS.md:L36-L38

Useful? React with 👍 / 👎.

}
child.stdin?.write(`${decision}\n`, (error) => {
if (!error) return;
logger.warn(
`[BlePair] failed to send '${decision}' to helper: ${error.message}`,
);
// Pipe is gone; no clean decline left.
if (decision === 'cancel') child.kill();
});
});

const settle = (fn: () => void) => {
if (settled) return;
settled = true;
Expand Down Expand Up @@ -170,6 +191,22 @@ function runHelper(
});
}

/** The host's half of the BLE numeric comparison. */
export type IPairDecision = 'confirm' | 'cancel';

// Only `pair` registers here.
let decideActivePair: ((decision: IPairDecision) => void) | null = null;

/**
* Answer the in-flight OS pairing ceremony. Returns false when none is waiting.
*/
export function decideActivePairing(decision: IPairDecision): boolean {
const decide = decideActivePair;
if (!decide) return false;
decide(decision);
return true;
}

/**
* Pair `address` (a colon/dash BLE MAC) at the OS level, streaming the
* numeric-comparison pin to `onPin` so the UI can show it. Resolves once the
Expand All @@ -190,10 +227,18 @@ export async function ensureDevicePaired(
// of the "who is holding the device" experiment.
args.push('--keep-link');
}
const events = await runHelper(args, (event) => {
if (event.type === 'pairing') {
onPin(event.pin);
}
const events = await runHelper(
args,
(event) => {
if (event.type === 'pairing') {
onPin(event.pin);
}
},
(decide) => {
decideActivePair = decide;
},
).finally(() => {
decideActivePair = null;
});
if (events.some((e) => e.type === 'paired')) {
return 'paired';
Expand Down
50 changes: 37 additions & 13 deletions apps/desktop/app/process/trezorBlePairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import logger from 'electron-log/main';
import { ElectronTranslations, i18nText } from '../i18n';

import {
decideActivePairing,
ensureDevicePaired,
isBlePairAvailable,
startRawAdvertisementWatch,
Expand Down Expand Up @@ -106,13 +107,8 @@ export function createTrezorBlePairingIpcMain(
};

const showPin = (pin: string) => {
// The helper has ALREADY called Accept() on the Windows side by the time we
// get here, so this dialog gates nothing — it only lets the user perform the
// numeric comparison. It must therefore not read as "click OK first": the
// ceremony is waiting on the DEVICE confirmation, and any time spent here is
// time spent inside the pairing window. Copy is localized via the shared
// main-process i18n (i18nText) so it follows the app language instead of the
// previous hardcoded English; keys are reused from the RN side.
// The host's half of the numeric comparison: the helper holds the pairing
// request open until we answer. Declining is what tells the device over SMP.
const shownAt = Date.now();
// Never log the code itself — it authorizes the bond while it is on screen.
logger.info('[TrezorBLE] pin dialog shown');
Expand All @@ -122,16 +118,30 @@ export function createTrezorBlePairingIpcMain(
title: i18nText(ElectronTranslations.transfer_pair_code),
message: pin,
detail: i18nText(ElectronTranslations.global_confirm_on_device),
buttons: [i18nText(ElectronTranslations.global_confirm)],
buttons: [
i18nText(ElectronTranslations.global_confirm),
i18nText(ElectronTranslations.global_cancel),
],
defaultId: 0,
// X routes here too, so closing the dialog now actually cancels.
cancelId: 1,
noLink: true,
})
.then(() => {
// How long the human spent on the PC before (probably) turning to the
// device. Compare against the helper's `sinceAccept` to tell a fixed OS
// timeout apart from "we simply outran the user".
.then(({ response }) => {
const decision = response === 1 ? 'cancel' : 'confirm';
// Compare against the helper's `sinceAccept` to tell an OS timeout apart
// from "we outran the user".
logger.info(
`[TrezorBLE] pin dialog dismissed after ${Date.now() - shownAt}ms`,
`[TrezorBLE] pin dialog answered '${decision}' after ${
Date.now() - shownAt
}ms`,
);
const delivered = decideActivePairing(decision);
if (!delivered) {
logger.warn(
`[TrezorBLE] pin dialog '${decision}' had no ceremony to answer (already settled)`,
);
}
})
.catch(() => undefined);
};
Expand Down Expand Up @@ -430,6 +440,20 @@ export function createTrezorBlePairingIpcMain(
return;
}

if (channel === TREZOR_BLE_CHANNELS.cancelPairing) {
base.handle(channel, async (event, ...args) => {
// The SDK abandons its noble connect; the OS-pairing ceremony is ours.
const declined = decideActivePairing('cancel');
if (declined) {
logger.info(
'[TrezorBLE] cancelPairing: declined the in-flight OS pairing ceremony',
);
}
return listener(event, ...args);
});
return;
}

if (channel === TREZOR_BLE_CHANNELS.disconnect) {
base.handle(channel, async (event, ...args) => {
// Disconnect is the RPA-rotation trigger; timestamp it.
Expand Down
116 changes: 106 additions & 10 deletions apps/desktop/native-modules/onekey-ble-pair/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
//! Usage: onekey-ble-pair <pair|is-paired|forget|inspect> --address AA:BB:..:FF

#[cfg(windows)]
use std::sync::atomic::AtomicU64;
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
#[cfg(windows)]
use std::sync::{Mutex, OnceLock};
#[cfg(windows)]
Expand Down Expand Up @@ -83,6 +83,99 @@ fn init_log_file() {
#[cfg(windows)]
static ACCEPT_MS: AtomicU64 = AtomicU64::new(u64::MAX);

// The host's half of the numeric comparison, fed by the parent over stdin.
// Accepting unconditionally skips that comparison and leaves no way to cancel:
// after Accept() an abort can only drop the link, which reads as a dead peer.
#[cfg(windows)]
const DECISION_PENDING: u8 = 0;
#[cfg(windows)]
const DECISION_CONFIRM: u8 = 1;
#[cfg(windows)]
const DECISION_CANCEL: u8 = 2;
#[cfg(windows)]
static DECISION: AtomicU8 = AtomicU8::new(DECISION_PENDING);

/// Poll interval while the delegate waits for the parent's decision.
#[cfg(windows)]
const DECISION_POLL_MS: u64 = 25;

/// Backstop so a parent that never answers cannot wedge the helper; the device
/// gives up on its own pairing window long before this.
#[cfg(windows)]
const DECISION_TIMEOUT_MS: u64 = 120_000;
Comment on lines +102 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 让 native 拒绝超时先于父进程终止

在 Windows Desktop 的无人应答路径中,Electron main 会在 60 秒时直接 child.kill(),但持有 WinRT pairing deferral 的 helper 要到 120 秒才把沉默视为取消,因此这个 native backstop 永远无法完成 deferral 并向设备发送 Pairing Failed;例如 PIN 对话框创建失败或一直未响应时,最终仍是强制断链,正是本次修改要避免的半配对状态。应让 helper 的拒绝期限短于父进程期限,或让父进程先写入 cancel 并留出退出宽限期。

AGENTS.md reference: AGENTS.md:L36-L38

Useful? React with 👍 / 👎.


/// Hold the ceremony until the parent decides, then Accept (or not) and release
/// the deferral. Completing WITHOUT Accept is what makes Windows send the device
/// an SMP Pairing Failed, so it shows the cancel and leaves pairing mode.
#[cfg(windows)]
fn await_decision(
args: &windows::Devices::Enumeration::DevicePairingRequestedEventArgs,
kind: &str,
) -> windows::core::Result<()> {
let deferral = args.GetDeferral()?;
let started = t_ms();
diag(&format!("{kind}: awaiting host confirmation"));

let decision = loop {
let current = DECISION.load(Ordering::SeqCst);
if current != DECISION_PENDING {
break current;
}
if t_ms().saturating_sub(started) >= DECISION_TIMEOUT_MS {
// Treat silence as refusal: never bond a device nobody confirmed.
break DECISION_CANCEL;
}
std::thread::sleep(std::time::Duration::from_millis(DECISION_POLL_MS));
};

let waited = t_ms().saturating_sub(started);
if decision == DECISION_CONFIRM {
args.Accept()?;
ACCEPT_MS.store(t_ms(), Ordering::SeqCst);
diag(&format!(
"accepted {kind} after {waited}ms (device confirmation pending)"
));
} else {
diag(&format!(
"declined {kind} after {waited}ms; the device is told via SMP Pairing Failed"
));
}
deferral.Complete()?;
Ok(())
}

/// Watch stdin for a `confirm` / `cancel` line. EOF (parent closed the pipe or
/// died) counts as a cancel, so the device is told rather than left to time out.
#[cfg(windows)]
fn spawn_decision_reader() {
std::thread::spawn(|| {
use std::io::BufRead;
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
let decision = match line.trim() {
"confirm" => DECISION_CONFIRM,
"cancel" => DECISION_CANCEL,
_ => continue,
};
// First decision wins; a later line cannot flip a settled ceremony.
let _ = DECISION.compare_exchange(
DECISION_PENDING,
decision,
Ordering::SeqCst,
Ordering::SeqCst,
);
return;
}
let _ = DECISION.compare_exchange(
DECISION_PENDING,
DECISION_CANCEL,
Ordering::SeqCst,
Ordering::SeqCst,
);
});
}

#[cfg(windows)]
fn t_ms() -> u64 {
START
Expand Down Expand Up @@ -121,6 +214,12 @@ fn main() {
// again. Runtime-selectable so both halves can be tried from one build.
let keep_link = args.iter().any(|a| a == "--keep-link");

// Only `pair` has a ceremony to gate; the read-only commands never block on
// a decision, and starting the reader for them would just hold their stdin.
if command == "pair" {
spawn_decision_reader();
}

let result = pollster::block_on(async {
match command {
"pair" => win::run_pair(address, keep_link).await,
Expand Down Expand Up @@ -482,29 +581,26 @@ mod win {
let kind = args.PairingKind()?;
diag(&format!("PairingRequested kind={kind:?}"));
match kind {
// Numeric comparison: surface the pin so the user can check
// it against the device screen, then accept.
// Numeric comparison: surface the pin, then hold the
// ceremony until the parent says the user matched it.
DevicePairingKinds::ConfirmPinMatch => {
let pin = args.Pin()?;
super::emit(&format!(
r#"{{"type":"pairing","pin":"{}"}}"#,
super::json_escape(&pin.to_string())
));
args.Accept()?;
ACCEPT_MS.store(t_ms(), Ordering::SeqCst);
diag("accepted ConfirmPinMatch (device confirmation pending)");
super::await_decision(&args, "ConfirmPinMatch")?;
}
// Device shows a pin; Windows just needs a yes. Surface it too.
// Device shows a pin; Windows just needs a yes. Same gate:
// the user is still confirming a code they can read.
DevicePairingKinds::DisplayPin => {
if let Ok(pin) = args.Pin() {
super::emit(&format!(
r#"{{"type":"pairing","pin":"{}"}}"#,
super::json_escape(&pin.to_string())
));
}
args.Accept()?;
ACCEPT_MS.store(t_ms(), Ordering::SeqCst);
diag("accepted DisplayPin");
super::await_decision(&args, "DisplayPin")?;
}
// Just-works: no code, so nothing ties the bond to the device
// in front of the user. A Safe 7 has a screen and always
Expand Down
Binary file not shown.
Binary file not shown.
42 changes: 42 additions & 0 deletions packages/core/src/chains/kaspa/sdkKaspa/types/clientRestApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,45 @@ export interface IKaspaGetTransactionOutput {
script_public_key_type: string;
accepting_block_hash: null;
}

// Shapes returned by GET /blocks/{blockId}?includeTransactions=true — the only
// source carrying sequence, scriptPublicKey.version, lockTime and gas, which the
// /transactions endpoints have no keys for. All four are needed to rebuild a
// refTx the device can verify.
export interface IKaspaBlockTransactionInput {
previousOutpoint: {
transactionId: string;
index?: number;
};
signatureScript: string;
sigOpCount?: number | string | null;
sequence: number | string | null;
computeBudget?: number | string | null;
}

export interface IKaspaBlockTransactionOutput {
amount: number | string;
scriptPublicKey: {
scriptPublicKey: string;
version?: number;
};
}

export interface IKaspaBlockTransaction {
version: number;
// null for coinbase transactions
inputs: IKaspaBlockTransactionInput[] | null;
outputs: IKaspaBlockTransactionOutput[];
lockTime?: number | string | null;
subnetworkId: string;
gas?: number | string | null;
payload?: string | null;
mass?: number | string;
verboseData?: {
transactionId?: string;
};
}

export interface IKaspaGetBlockResponse {
transactions?: IKaspaBlockTransaction[];
}
Loading
Loading