diff --git a/apps/desktop/app/process/BlePair.ts b/apps/desktop/app/process/BlePair.ts index fb77ee11dde0..3ecc833e67b5 100644 --- a/apps/desktop/app/process/BlePair.ts +++ b/apps/desktop/app/process/BlePair.ts @@ -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; @@ -76,13 +78,15 @@ export function isBlePairAvailable(): boolean { function runHelper( args: string[], onEvent?: (event: IBlePairEvent) => void, + registerDecide?: (decide: (decision: IPairDecision) => void) => void, ): Promise { 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(); @@ -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; + } + 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; @@ -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 @@ -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'; diff --git a/apps/desktop/app/process/trezorBlePairing.ts b/apps/desktop/app/process/trezorBlePairing.ts index de7f4624c3b3..25fe8d47b00e 100644 --- a/apps/desktop/app/process/trezorBlePairing.ts +++ b/apps/desktop/app/process/trezorBlePairing.ts @@ -9,6 +9,7 @@ import logger from 'electron-log/main'; import { ElectronTranslations, i18nText } from '../i18n'; import { + decideActivePairing, ensureDevicePaired, isBlePairAvailable, startRawAdvertisementWatch, @@ -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'); @@ -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); }; @@ -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. diff --git a/apps/desktop/native-modules/onekey-ble-pair/src/main.rs b/apps/desktop/native-modules/onekey-ble-pair/src/main.rs index 924f3b32272f..660aad8f411a 100644 --- a/apps/desktop/native-modules/onekey-ble-pair/src/main.rs +++ b/apps/desktop/native-modules/onekey-ble-pair/src/main.rs @@ -37,7 +37,7 @@ //! Usage: onekey-ble-pair --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)] @@ -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; + +/// 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 @@ -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, @@ -482,19 +581,18 @@ 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!( @@ -502,9 +600,7 @@ mod win { 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 diff --git a/apps/desktop/public/static/bin/ble-pair/win-arm64/onekey-ble-pair.exe b/apps/desktop/public/static/bin/ble-pair/win-arm64/onekey-ble-pair.exe index acd793867839..c4af41ed830e 100755 Binary files a/apps/desktop/public/static/bin/ble-pair/win-arm64/onekey-ble-pair.exe and b/apps/desktop/public/static/bin/ble-pair/win-arm64/onekey-ble-pair.exe differ diff --git a/apps/desktop/public/static/bin/ble-pair/win-x64/onekey-ble-pair.exe b/apps/desktop/public/static/bin/ble-pair/win-x64/onekey-ble-pair.exe index a6786f278ed2..df86c69b5730 100755 Binary files a/apps/desktop/public/static/bin/ble-pair/win-x64/onekey-ble-pair.exe and b/apps/desktop/public/static/bin/ble-pair/win-x64/onekey-ble-pair.exe differ diff --git a/packages/core/src/chains/kaspa/sdkKaspa/types/clientRestApi.ts b/packages/core/src/chains/kaspa/sdkKaspa/types/clientRestApi.ts index 71fe26b9bb17..5a3228a52f1f 100644 --- a/packages/core/src/chains/kaspa/sdkKaspa/types/clientRestApi.ts +++ b/packages/core/src/chains/kaspa/sdkKaspa/types/clientRestApi.ts @@ -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[]; +} diff --git a/packages/kit-bg/src/vaults/impls/kaspa/Vault.ts b/packages/kit-bg/src/vaults/impls/kaspa/Vault.ts index e56f5f05dbae..17da0ac28136 100644 --- a/packages/kit-bg/src/vaults/impls/kaspa/Vault.ts +++ b/packages/kit-bg/src/vaults/impls/kaspa/Vault.ts @@ -19,7 +19,7 @@ import { } from '@onekeyhq/core/src/chains/kaspa/sdkKaspa'; import { RestAPIClient } from '@onekeyhq/core/src/chains/kaspa/sdkKaspa/clientRestApi'; import sdk from '@onekeyhq/core/src/chains/kaspa/sdkKaspa/sdk'; -import type { IKaspaGetTransactionResponse } from '@onekeyhq/core/src/chains/kaspa/sdkKaspa/types'; +import type { IKaspaBlockTransaction } from '@onekeyhq/core/src/chains/kaspa/sdkKaspa/types'; import type { IEncodedTxKaspa } from '@onekeyhq/core/src/chains/kaspa/types'; import { MAX_UINT64_VALUE } from '@onekeyhq/core/src/consts'; import { @@ -70,6 +70,7 @@ import { KeyringHardware } from './KeyringHardware'; import { KeyringHd } from './KeyringHd'; import { KeyringImported } from './KeyringImported'; import { KeyringWatching } from './KeyringWatching'; +import { buildKaspaRefTx } from './refTxUtils'; import { ClientKaspa } from './sdkKaspa/ClientKaspa'; import type { IDBWalletType } from '../../../dbs/local/types'; @@ -813,43 +814,8 @@ export default class Vault extends VaultBase { }; } - // Map a REST tx to the refTx shape the device recomputes the txid from. The REST - // API doesn't return lockTime/gas/sequenceNumber/scriptVersion; they are 0 on the - // v0 txs this is gated to below. - buildPrevTx(tx: IKaspaGetTransactionResponse): IKaspaRefTransaction { - return { - txId: tx.transaction_id, - version: tx.version, - inputs: (tx.inputs ?? []).map((input) => ({ - prevTxId: input.previous_outpoint_hash, - outputIndex: Number(input.previous_outpoint_index), - sequenceNumber: 0, - })), - outputs: tx.outputs.map((output) => { - // The proxy JSON-parses amount into a JS number, so a sompi value beyond - // 2^53 (reachable on kaspa: supply ~2.9e18) is already rounded before we - // see it — BigNumber can't recover what the parse lost. Bail so the - // caller blind-signs rather than stream a refTx whose recomputed txid - // would silently be wrong. - const satoshis = new BigNumber(String(output.amount)); - if (!satoshis.isInteger() || satoshis.gt(Number.MAX_SAFE_INTEGER)) { - throw new OneKeyLocalError( - `kaspa refTx: output amount ${String( - output.amount, - )} exceeds safe integer range`, - ); - } - return { - satoshis: satoshis.toFixed(), - script: output.script_public_key, - scriptVersion: 0, - }; - }), - lockTime: 0, - subNetworkID: tx.subnetwork_id, - gas: 0, - payload: tx.payload ?? '', - }; + buildPrevTx(tx: IKaspaBlockTransaction): IKaspaRefTransaction { + return buildKaspaRefTx({ tx, networkId: this.networkId }); } // Fetch previous transactions as refTxs for on-device input verification. @@ -858,13 +824,13 @@ export default class Vault extends VaultBase { networkId: this.networkId, backgroundApi: this.backgroundApi, }); - const prevTxs = await client.getTransactions(txids); - const refTxs = prevTxs - .filter((tx) => tx?.transaction_id) - .map((tx) => this.buildPrevTx(tx)); - // Only trust version-0 txs: the REST API returns bad fields for non-standard - // txs (a v1 tx's subnetwork_id mirrors its txid prefix), which would make the - // device hard-reject. Bail → caller blind-signs. TODO: handle v1 via wasm SDK. + const prevTxs = await client.getRefTransactions(txids); + const refTxs = Array.from(prevTxs.values()).map((tx) => + this.buildPrevTx(tx), + ); + // Only trust version-0 txs: recomputing a txid off the fields the prev-tx + // stream carries reproduces a v0 id exactly but never a v1 one, so v1 + // commits to something this protocol cannot express. Bail → blind-sign. if (refTxs.some((tx) => tx.version !== 0)) { throw new OneKeyLocalError('kaspa refTx: unsupported non-v0 prev tx'); } diff --git a/packages/kit-bg/src/vaults/impls/kaspa/refTxUtils.test.ts b/packages/kit-bg/src/vaults/impls/kaspa/refTxUtils.test.ts new file mode 100644 index 000000000000..c952d5ce253d --- /dev/null +++ b/packages/kit-bg/src/vaults/impls/kaspa/refTxUtils.test.ts @@ -0,0 +1,143 @@ +import type { IKaspaBlockTransaction } from '@onekeyhq/core/src/chains/kaspa/sdkKaspa/types'; + +import { buildKaspaRefTx } from './refTxUtils'; + +const networkId = 'kaspa--kaspa'; +const txId = '9bf74f7f5f5b5b3e3b5d3c27769897e3aa907cff29045b5049df2ed294791231'; +const prevTxId = + '1ad23c4b34354be1dbc0e8f4a0a2f0f3b0c5b6a7d8e9f0112233445566778899'; +const script = + '2008e329d016e63871fbb8ea9a2fbe0aa4dcd6e0a1f6e9c9d5b2a3948576e1f2ac'; + +function blockTx( + overrides: Partial = {}, +): IKaspaBlockTransaction { + return { + version: 0, + subnetworkId: '0000000000000000000000000000000000000000', + lockTime: 0, + gas: 0, + payload: '', + verboseData: { transactionId: txId }, + inputs: [ + { + previousOutpoint: { transactionId: prevTxId, index: 1 }, + signatureScript: '41aa', + sigOpCount: 1, + sequence: 0, + computeBudget: 0, + }, + ], + outputs: [ + { + amount: 3_035_044_000_000, + scriptPublicKey: { scriptPublicKey: script, version: 0 }, + }, + ], + ...overrides, + }; +} + +describe('buildKaspaRefTx', () => { + it('maps every field the device recomputes the txid from', () => { + const r = buildKaspaRefTx({ tx: blockTx(), networkId }); + expect(r).toEqual({ + txId, + version: 0, + inputs: [{ prevTxId, outputIndex: 1, sequenceNumber: '0' }], + outputs: [{ satoshis: '3035044000000', script, scriptVersion: 0 }], + lockTime: '0', + subNetworkID: '0000000000000000000000000000000000000000', + gas: '0', + payload: '', + }); + }); + + // The proxy nulls numeric fields whose value is 0, so these must read as 0 + // rather than abort every signature that streams a refTx. + it('reads a nulled sequence, lockTime, gas and script version as 0', () => { + const tx = blockTx({ lockTime: null, gas: null }); + tx.inputs![0].sequence = null; + tx.outputs[0].scriptPublicKey.version = undefined; + const r = buildKaspaRefTx({ tx, networkId }); + expect(r.inputs[0].sequenceNumber).toBe('0'); + expect(r.lockTime).toBe('0'); + expect(r.gas).toBe('0'); + expect(r.outputs[0].scriptVersion).toBe(0); + }); + + // An empty amount is far more likely to be a value that could not be + // represented than a genuine zero, so it must not be defaulted. + it('rejects a missing output amount instead of defaulting it', () => { + const tx = blockTx(); + // @ts-expect-error exercising a malformed upstream response + tx.outputs[0].amount = null; + expect(() => buildKaspaRefTx({ tx, networkId })).toThrow( + /output\.amount missing/, + ); + }); + + it('rejects values already rounded by the JSON parse', () => { + const amountTx = blockTx(); + amountTx.outputs[0].amount = Number.MAX_SAFE_INTEGER + 2; + expect(() => buildKaspaRefTx({ tx: amountTx, networkId })).toThrow( + /exceeds safe integer range/, + ); + + // A 2^64-1 sequence reaches us as 2^64: the nearest double, which is what + // the parse rounded it to. + const seqTx = blockTx(); + seqTx.inputs![0].sequence = 2 ** 64; + expect(() => buildKaspaRefTx({ tx: seqTx, networkId })).toThrow( + /input\.sequence .* exceeds safe integer range/, + ); + }); + + it('rejects a non-numeric value', () => { + const tx = blockTx({ lockTime: 'not-a-number' }); + expect(() => buildKaspaRefTx({ tx, networkId })).toThrow( + /exceeds safe integer range/, + ); + }); + + it('accepts a coinbase prev tx, which has no inputs', () => { + const r = buildKaspaRefTx({ tx: blockTx({ inputs: null }), networkId }); + expect(r.inputs).toEqual([]); + expect(r.outputs).toHaveLength(1); + }); + + it('rejects a tx with no outputs, which could only hash wrong', () => { + expect(() => + buildKaspaRefTx({ tx: blockTx({ outputs: [] }), networkId }), + ).toThrow(/no outputs/); + }); + + it('rejects a tx the block response gave no id for', () => { + expect(() => + buildKaspaRefTx({ tx: blockTx({ verboseData: {} }), networkId }), + ).toThrow(/no txId/); + }); + + it('keeps a non-zero sequence, gas, lockTime and script version', () => { + const tx = blockTx({ lockTime: 12_345, gas: 678 }); + tx.inputs![0].sequence = 42; + tx.outputs[0].scriptPublicKey.version = 1; + const r = buildKaspaRefTx({ tx, networkId }); + expect(r.inputs[0].sequenceNumber).toBe('42'); + expect(r.lockTime).toBe('12345'); + expect(r.gas).toBe('678'); + expect(r.outputs[0].scriptVersion).toBe(1); + }); + + it('carries the payload and subnetwork id of a v1 tx untouched', () => { + const tx = blockTx({ + version: 1, + subnetworkId: '97b1000000000000000000000000000000000000', + payload: '95789c', + }); + const r = buildKaspaRefTx({ tx, networkId }); + expect(r.version).toBe(1); + expect(r.subNetworkID).toBe('97b1000000000000000000000000000000000000'); + expect(r.payload).toBe('95789c'); + }); +}); diff --git a/packages/kit-bg/src/vaults/impls/kaspa/refTxUtils.ts b/packages/kit-bg/src/vaults/impls/kaspa/refTxUtils.ts new file mode 100644 index 000000000000..a43299f010c1 --- /dev/null +++ b/packages/kit-bg/src/vaults/impls/kaspa/refTxUtils.ts @@ -0,0 +1,100 @@ +import BigNumber from 'bignumber.js'; + +import type { IKaspaBlockTransaction } from '@onekeyhq/core/src/chains/kaspa/sdkKaspa/types'; +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; + +import type { IKaspaRefTransaction } from './Vault'; + +// Read a uint64 that feeds the txid the device recomputes from a refTx. +// +// `nullMeansZero` is for fields an upstream may drop when their value is 0 — true +// for sequence/lockTime/gas, false for an amount, where empty is far more likely +// to be a value that could not be represented than a genuine zero. Every +// substitution is logged: otherwise a field that should have carried a value +// leaves nothing behind but a device rejection. +// +// Past 2^53 the JSON parse already rounded the value and it cannot be recovered, +// so bail and let the caller blind-sign. +export function readRefTxUint64({ + value, + field, + txId, + networkId, + nullMeansZero, +}: { + value: number | string | null | undefined; + field: string; + txId: string; + networkId: string | undefined; + nullMeansZero: boolean; +}): string { + if (value === null || value === undefined) { + if (!nullMeansZero) { + throw new OneKeyLocalError(`kaspa refTx: ${field} missing for ${txId}`); + } + defaultLogger.transaction.send.refTxFieldDefaulted({ + network: networkId, + txId, + field, + }); + return '0'; + } + const parsed = new BigNumber(String(value)); + if (!parsed.isInteger() || parsed.gt(Number.MAX_SAFE_INTEGER)) { + throw new OneKeyLocalError( + `kaspa refTx: ${field} ${String(value)} exceeds safe integer range`, + ); + } + return parsed.toFixed(); +} + +// Map a block-endpoint tx to the refTx shape the device recomputes the txid from. +// Every field here feeds that hash, so a wrong value is a hard reject. +export function buildKaspaRefTx({ + tx, + networkId, +}: { + tx: IKaspaBlockTransaction; + networkId: string | undefined; +}): IKaspaRefTransaction { + const txId = tx.verboseData?.transactionId; + if (!txId) { + throw new OneKeyLocalError('kaspa refTx: block transaction has no txId'); + } + // A coinbase prev tx legitimately has no inputs; missing outputs is bad data, + // and streaming an empty output list would only produce a wrong txid. + if (!Array.isArray(tx.outputs) || tx.outputs.length === 0) { + throw new OneKeyLocalError(`kaspa refTx: no outputs for ${txId}`); + } + const uint64 = ( + value: number | string | null | undefined, + field: string, + nullMeansZero: boolean, + ) => readRefTxUint64({ value, field, txId, networkId, nullMeansZero }); + + return { + txId, + version: tx.version, + inputs: (tx.inputs ?? []).map((input) => ({ + prevTxId: input.previousOutpoint.transactionId, + outputIndex: Number(input.previousOutpoint.index ?? 0), + sequenceNumber: uint64(input.sequence, 'input.sequence', true), + })), + outputs: tx.outputs.map((output) => ({ + satoshis: uint64(output.amount, 'output.amount', false), + script: output.scriptPublicKey.scriptPublicKey, + scriptVersion: Number( + uint64( + output.scriptPublicKey.version, + 'output.scriptPublicKey.version', + true, + ), + ), + })), + lockTime: uint64(tx.lockTime, 'lockTime', true), + subNetworkID: tx.subnetworkId, + gas: uint64(tx.gas, 'gas', true), + payload: tx.payload ?? '', + }; +} diff --git a/packages/kit-bg/src/vaults/impls/kaspa/sdkKaspa/ClientKaspa.ts b/packages/kit-bg/src/vaults/impls/kaspa/sdkKaspa/ClientKaspa.ts index c5795fc05993..d7ca6448e6bc 100644 --- a/packages/kit-bg/src/vaults/impls/kaspa/sdkKaspa/ClientKaspa.ts +++ b/packages/kit-bg/src/vaults/impls/kaspa/sdkKaspa/ClientKaspa.ts @@ -1,4 +1,8 @@ -import type { IKaspaGetTransactionResponse } from '@onekeyhq/core/src/chains/kaspa/sdkKaspa/types'; +import type { + IKaspaBlockTransaction, + IKaspaGetBlockResponse, + IKaspaGetTransactionResponse, +} from '@onekeyhq/core/src/chains/kaspa/sdkKaspa/types'; import type { IBackgroundApi } from '@onekeyhq/kit-bg/src/apis/IBackgroundApi'; import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; @@ -22,16 +26,47 @@ export class ClientKaspa { this.backgroundApi = backgroundApi; } + // Bound a request so a slow upstream can't stall the signing flow. Callers that + // chain requests pass one deadline so the total stays capped instead of adding + // up per hop; on expiry the refTx flow falls back to blind signing. + private async withDeadline( + label: string, + deadline: number, + run: () => Promise, + ): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + run(), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new OneKeyLocalError(`kaspa ${label} timeout`)), + Math.max(0, deadline - Date.now()), + ); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + + private static defaultDeadline() { + return Date.now() + timerUtils.getTimeDurationMs({ seconds: 30 }); + } + // Batch-fetch full transactions by id via the REST search endpoint: one upstream // request for all txids (POST body carried by params.data), instead of one GET - // per txid. Capped at 30s so a slow request can't block the caller (the refTx - // flow falls back to blind signing on failure). + // per txid. async getTransactions( txids: string[], + deadline: number = ClientKaspa.defaultDeadline(), ): Promise { - let timer: ReturnType | undefined; - try { - const [txs = []] = await Promise.race([ + const [txs = []] = await this.withDeadline( + 'getTransactions', + deadline, + () => this.backgroundApi.serviceAccountProfile.sendProxyRequest< IKaspaGetTransactionResponse[] >({ @@ -48,18 +83,58 @@ export class ClientKaspa { }, ], }), - new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new OneKeyLocalError('kaspa getTransactions timeout')), - timerUtils.getTimeDurationMs({ seconds: 30 }), - ); - }), - ]); - return txs; - } finally { - if (timer) { - clearTimeout(timer); + ); + return txs; + } + + // Search resolves txid -> block (it is the only endpoint with a txid index); + // the block then supplies the fields search has no keys for. Both hops are + // batched into one request each, so a 90-input transaction costs 2 round trips. + async getRefTransactions( + txids: string[], + ): Promise> { + const deadline = ClientKaspa.defaultDeadline(); + const searched = await this.getTransactions(txids, deadline); + + const blockHashByTxid = new Map(); + for (const tx of searched) { + // Any block containing the tx carries identical transaction bytes; the + // first is enough. + const blockHash = tx?.block_hash?.[0]; + if (tx?.transaction_id && blockHash) { + blockHashByTxid.set(tx.transaction_id.toLowerCase(), blockHash); + } + } + const blockHashes = Array.from(new Set(blockHashByTxid.values())); + if (blockHashes.length === 0) { + return new Map(); + } + + const blocks = await this.withDeadline('getRefTransactions', deadline, () => + this.backgroundApi.serviceAccountProfile.sendProxyRequest( + { + networkId: this.networkId, + body: blockHashes.map((blockHash) => ({ + route: 'rpc', + params: { + method: 'GET', + url: `/blocks/${blockHash}?includeTransactions=true`, + params: [], + }, + })), + }, + ), + ); + + const result = new Map(); + for (const block of blocks ?? []) { + for (const tx of block?.transactions ?? []) { + const txid = tx?.verboseData?.transactionId?.toLowerCase(); + if (txid && blockHashByTxid.has(txid)) { + result.set(txid, tx); + } } } + return result; } } diff --git a/packages/shared/src/logger/scopes/transaction/scenes/send.ts b/packages/shared/src/logger/scopes/transaction/scenes/send.ts index 5aece1a9fae8..8be6b4341189 100644 --- a/packages/shared/src/logger/scopes/transaction/scenes/send.ts +++ b/packages/shared/src/logger/scopes/transaction/scenes/send.ts @@ -440,4 +440,24 @@ export class SendScene extends BaseScene { error, }; } + + // A refTx field arrived empty and was read as 0. Expected while an upstream + // drops zero-valued numbers; if it ever fires for a field that should carry a + // value, the device will reject the recomputed txid with no other trace. + @LogToLocal() + public refTxFieldDefaulted({ + network, + txId, + field, + }: { + network: string | undefined; + txId: string; + field: string; + }) { + return { + network, + txId, + field, + }; + } }